mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-11-03 17:54:42 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c4fc1a894 | |||
| d0215f72ec | |||
| 8d7ac35333 | |||
| e36a77ed80 |
+1
-1
@@ -111,7 +111,7 @@ Daemons (`spinifex/runtime/daemon/daemon.go`) subscribe to NATS topics and handl
|
||||
When a daemon handles `RunInstances`:
|
||||
|
||||
1. **Resource Check**: Validates CPU/memory availability via `ResourceManager`
|
||||
2. **Volume Generation**: Creates boot and EFI volumes via Viperblock
|
||||
2. **Volume Generation**: Creates the boot volume through the EBS provider and, for a UEFI guest, seeds its variable store as an object under `uefi/<instance-id>/` with a store record in `spinifex-uefi-stores` and a pointer on the instance record
|
||||
3. **Volume Mount**: Sends a mount request to the colocated Viperblock worker on `ebs.{node}.mount`, receives NBD URI
|
||||
4. **QEMU Launch**: Builds and executes QEMU command with NBD-backed disks
|
||||
5. **QMP Monitoring**: Establishes QEMU Machine Protocol connection for VM management
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
package instance
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/providers/ebs"
|
||||
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// varsTemplateBytes stands in for a firmware VARS template. The real one is
|
||||
// 540,672 bytes, but nothing here depends on the size beyond it being exact.
|
||||
func varsTemplateBytes() []byte { return bytes.Repeat([]byte{0xAB}, 4096) }
|
||||
|
||||
func providerEFIService(t *testing.T, provider ebsprovider.EBSProvider) *InstanceServiceImpl {
|
||||
t.Helper()
|
||||
return providerRootVolumeService(t, provider, rootVolumeAMILoader(), objectstore.NewMemoryObjectStore())
|
||||
}
|
||||
|
||||
// TestCreateEFIVolume_Provider_SeedsFromTemplate is the core of the EFI
|
||||
// decouple: the variable store is allocated through the provider with the
|
||||
// template as seed bytes, so no control-plane engine is built for it.
|
||||
func TestCreateEFIVolume_Provider_SeedsFromTemplate(t *testing.T) {
|
||||
provider := ebsprovider.NewMemoryProvider(ebsprovider.Capabilities{VolumeSeeding: true})
|
||||
svc := providerEFIService(t, provider)
|
||||
template := varsTemplateBytes()
|
||||
|
||||
require.NoError(t, svc.createEFIVolumeViaProvider(context.Background(), "vol-efiseed001-efi", template))
|
||||
|
||||
volume, err := provider.GetVolume(context.Background(), ebsprovider.GetVolumeRequest{
|
||||
Versioned: ebsprovider.NewVersioned(), VolumeID: "vol-efiseed001-efi",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(len(template)), volume.CapacityBytes,
|
||||
"the store must be sized byte-exactly to the VARS template")
|
||||
|
||||
seed, ok := provider.SeedData("vol-efiseed001-efi")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, template, seed, "the template must reach the provider as seed bytes")
|
||||
}
|
||||
|
||||
// A provider that rounds capacity to a GiB boundary produces a VARS region
|
||||
// pflash refuses, which would surface as an unbootable guest rather than a
|
||||
// failed launch. The mismatch has to fail the launch instead.
|
||||
func TestCreateEFIVolume_Provider_CapacityMismatchFails(t *testing.T) {
|
||||
provider := &roundingProvider{
|
||||
EBSProvider: ebsprovider.NewMemoryProvider(ebsprovider.Capabilities{VolumeSeeding: true}),
|
||||
}
|
||||
svc := providerEFIService(t, provider)
|
||||
|
||||
err := svc.createEFIVolumeViaProvider(context.Background(), "vol-efiround01-efi", varsTemplateBytes())
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
|
||||
}
|
||||
|
||||
// A relaunch reissues the same create. Reseeding would overwrite the BootOrder
|
||||
// the guest wrote on its first boot, so the second call must be a no-op.
|
||||
func TestCreateEFIVolume_Provider_RepeatDoesNotReseed(t *testing.T) {
|
||||
provider := ebsprovider.NewMemoryProvider(ebsprovider.Capabilities{VolumeSeeding: true})
|
||||
svc := providerEFIService(t, provider)
|
||||
ctx := context.Background()
|
||||
template := varsTemplateBytes()
|
||||
|
||||
require.NoError(t, svc.createEFIVolumeViaProvider(ctx, "vol-efirepeat1-efi", template))
|
||||
require.NoError(t, svc.createEFIVolumeViaProvider(ctx, "vol-efirepeat1-efi", template))
|
||||
|
||||
seed, ok := provider.SeedData("vol-efirepeat1-efi")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, template, seed)
|
||||
}
|
||||
|
||||
// A seed above the wire bound must fail the launch with an error naming the
|
||||
// limit, not reach NATS and fail as an oversized publish.
|
||||
func TestCreateEFIVolume_Provider_OversizedTemplateFails(t *testing.T) {
|
||||
provider := ebsprovider.NewMemoryProvider(ebsprovider.Capabilities{VolumeSeeding: true})
|
||||
svc := providerEFIService(t, provider)
|
||||
|
||||
err := svc.createEFIVolumeViaProvider(context.Background(), "vol-efitoobig1-efi", make([]byte, ebsprovider.MaxSeedBytes+1))
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
|
||||
}
|
||||
|
||||
// TestPrepareEFIVolume_Provider_RegistersEBSRequest covers the whole function
|
||||
// against this host's real firmware, including the name the VM launches with.
|
||||
func TestPrepareEFIVolume_Provider_RegistersEBSRequest(t *testing.T) {
|
||||
if _, _, _, err := vm.FirmwarePaths("x86_64"); err != nil {
|
||||
t.Skipf("no UEFI firmware on this host: %v", err)
|
||||
}
|
||||
|
||||
provider := ebsprovider.NewMemoryProvider(ebsprovider.Capabilities{VolumeSeeding: true})
|
||||
svc := providerEFIService(t, provider)
|
||||
instance := &vm.VM{}
|
||||
|
||||
require.NoError(t, svc.prepareEFIVolume(context.Background(), "vol-efiprepare001", instance, "x86_64"))
|
||||
|
||||
instance.EBSRequests.Mu.Lock()
|
||||
defer instance.EBSRequests.Mu.Unlock()
|
||||
require.Len(t, instance.EBSRequests.Requests, 1)
|
||||
assert.Equal(t, "vol-efiprepare001-efi", instance.EBSRequests.Requests[0].Name)
|
||||
assert.True(t, instance.EBSRequests.Requests[0].EFI)
|
||||
assert.False(t, instance.EBSRequests.Requests[0].Boot)
|
||||
|
||||
seed, ok := provider.SeedData("vol-efiprepare001-efi")
|
||||
require.True(t, ok)
|
||||
assert.NotEmpty(t, seed, "the store must be seeded from this host's VARS template")
|
||||
}
|
||||
|
||||
// roundingProvider reports a GiB-rounded capacity the way a provider with no
|
||||
// sub-GiB volume granularity would.
|
||||
type roundingProvider struct {
|
||||
ebsprovider.EBSProvider
|
||||
}
|
||||
|
||||
func (p *roundingProvider) CreateVolume(ctx context.Context, req ebsprovider.CreateVolumeRequest) (*ebsprovider.Volume, error) {
|
||||
volume, err := p.EBSProvider.CreateVolume(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
volume.CapacityBytes = 1 << 30
|
||||
return volume, nil
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ec2/tagmirror"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"os"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -245,6 +244,8 @@ type InstanceServiceImpl struct {
|
||||
ipAllocator PublicIPAllocator
|
||||
dnsBaseDomain string
|
||||
dnsInternalDomain string
|
||||
uefiStores UEFIStoreRecords
|
||||
uefiNode string
|
||||
}
|
||||
|
||||
// SetEBSProvider injects the provider boundary used for instance-created
|
||||
@@ -1597,18 +1598,15 @@ func (s *InstanceServiceImpl) GenerateVolumes(ctx context.Context, input *ec2.Ru
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Step 2: Create EFI variable store (only when the AMI is UEFI; BIOS
|
||||
// guests must not allocate an orphan VARS volume).
|
||||
if instance.BootMode == "uefi" || instance.BootMode == "uefi-preferred" {
|
||||
// Step 2: Seed the UEFI variable store. A BIOS guest has none.
|
||||
if vm.UsesUEFIVarStore(instance) {
|
||||
arch := instanceArchitecture(s.instanceTypes[*input.InstanceType])
|
||||
err = s.prepareEFIVolume(ctx, imageId, instance, arch)
|
||||
if err != nil {
|
||||
if err := s.seedUEFIVarStore(ctx, instance, aws.StringValue(input.ImageId), arch); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// The EFI store is internal, so it is not reported; every volume the
|
||||
// request named is.
|
||||
// Every volume the request named is reported.
|
||||
volumeInfos := []VolumeInfo{
|
||||
{
|
||||
VolumeId: imageId,
|
||||
@@ -1870,81 +1868,6 @@ func rootVolumeIOPS(requested int) int {
|
||||
return requested
|
||||
}
|
||||
|
||||
// prepareEFIVolume creates the per-VM EFI variable store, sized exactly to the
|
||||
// firmware VARS template (pflash requires byte-exact size) and seeded from it.
|
||||
// arch is "x86_64" | "arm64".
|
||||
func (s *InstanceServiceImpl) prepareEFIVolume(ctx context.Context, volumeID string, instance *vm.VM, arch string) error {
|
||||
codePath, varsTemplate, varsSize, err := vm.FirmwarePaths(arch)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "UEFI firmware not installed on this host", "arch", arch, "err", err)
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
template, err := os.ReadFile(varsTemplate)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to read VARS template", "path", varsTemplate, "err", err)
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
if int64(len(template)) != varsSize {
|
||||
slog.ErrorContext(ctx, "VARS template size mismatch between stat and read", "path", varsTemplate, "statSize", varsSize, "readSize", len(template))
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
slog.InfoContext(ctx, "Preparing EFI variable store", "arch", arch, "firmwarePath", codePath, "varsTemplate", varsTemplate, "size", varsSize)
|
||||
|
||||
efiVolumeName := fmt.Sprintf("%s-efi", volumeID)
|
||||
if s.ebsProvider == nil {
|
||||
slog.ErrorContext(ctx, "no EBS provider configured", "op", "prepareEFIVolume")
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
if err := s.createEFIVolumeViaProvider(ctx, efiVolumeName, template); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
instance.EBSRequests.Mu.Lock()
|
||||
instance.EBSRequests.Requests = append(instance.EBSRequests.Requests, viperblocklegacyv1.EBSRequest{
|
||||
Name: efiVolumeName,
|
||||
Boot: false,
|
||||
EFI: true,
|
||||
})
|
||||
instance.EBSRequests.Mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createEFIVolumeViaProvider allocates the EFI variable store through the
|
||||
// provider, seeding it with this node's firmware VARS template. The bytes
|
||||
// travel with the request so the region matches the firmware that will run.
|
||||
func (s *InstanceServiceImpl) createEFIVolumeViaProvider(ctx context.Context, efiVolumeName string, template []byte) error {
|
||||
slog.InfoContext(ctx, "Creating EFI variable store via EBS provider", "name", efiVolumeName, "seedBytes", len(template))
|
||||
|
||||
// The store is internal to the launch, never attachable and filtered out of
|
||||
// DescribeVolumes, so it gets no ebsmetadata document the way a root volume
|
||||
// does. Its lifecycle is the root volume's DeleteVolume.
|
||||
created, err := s.ebsProvider.CreateVolume(ctx, ebsprovider.CreateVolumeRequest{
|
||||
Versioned: ebsprovider.NewVersioned(),
|
||||
VolumeID: efiVolumeName,
|
||||
CapacityRange: ebsprovider.CapacityRange{RequiredBytes: int64(len(template))},
|
||||
AvailabilityZone: s.config.AZ,
|
||||
SeedData: template,
|
||||
})
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Provider EFI volume creation failed", "name", efiVolumeName, "err", err)
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
if created == nil {
|
||||
slog.ErrorContext(ctx, "Provider returned no volume for the EFI variable store", "name", efiVolumeName)
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
// pflash rejects a VARS region that is not byte-exact, so a provider that
|
||||
// rounded the capacity up to a GiB boundary yields a guest that cannot boot.
|
||||
if created.CapacityBytes != int64(len(template)) {
|
||||
slog.ErrorContext(ctx, "Provider EFI volume capacity does not match the VARS template",
|
||||
"name", efiVolumeName, "wantBytes", len(template), "gotBytes", created.CapacityBytes)
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// instanceArchitecture returns the AWS architecture string from the instance type.
|
||||
// Returns "" on nil/malformed spec; the caller's firmware probe surfaces the error.
|
||||
func instanceArchitecture(it *ec2.InstanceTypeInfo) string {
|
||||
@@ -2689,6 +2612,15 @@ func (s *InstanceServiceImpl) capacityRefusal(ctx context.Context, instance *vm.
|
||||
// start returns it to the stopped store, a recovery returns it to the node it
|
||||
// was taken from.
|
||||
func (s *InstanceServiceImpl) launchClaimedInstance(ctx context.Context, instance *vm.VM, undo func()) error {
|
||||
// Before anything is stamped or claimed, so the record handed back is the
|
||||
// one that was claimed.
|
||||
if err := vm.CheckUEFIVarStore(instance); err != nil {
|
||||
slog.ErrorContext(ctx, "launchClaimedInstance: refusing to launch a UEFI instance without a variable store",
|
||||
"instanceId", instance.ID, "err", err)
|
||||
undo()
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
instance.ResetNodeLocalState()
|
||||
|
||||
// AWS restamps LaunchTime on every start. The pending watchdog times a
|
||||
|
||||
@@ -2605,6 +2605,34 @@ func TestStartStoppedInstance_InstanceTypeUnknown(t *testing.T) {
|
||||
assert.Equal(t, vm.StateStopped, store.WroteStopped[id].Status)
|
||||
}
|
||||
|
||||
// A UEFI instance with no variable store pointer is refused before anything is
|
||||
// stamped or claimed, so the record handed back is the one that was claimed.
|
||||
func TestStartStoppedInstance_UEFIInstanceWithoutAPointerIsRefused(t *testing.T) {
|
||||
id := "i-uefi-nopointer"
|
||||
itype := "t3.micro"
|
||||
launched := time.Unix(1700000000, 0).UTC()
|
||||
stopped := &vm.VM{
|
||||
ID: id, Status: vm.StateStopped, AccountID: "acc", InstanceType: itype, BootMode: "uefi",
|
||||
Instance: &ec2.Instance{LaunchTime: aws.Time(launched)},
|
||||
}
|
||||
before := stopped.Record()
|
||||
store := &vmmock.StateStore{Stopped: map[string]*vm.VM{id: stopped}}
|
||||
prov := &fakeResourceCapacityProvider{instanceTypes: map[string]*ec2.InstanceTypeInfo{
|
||||
itype: {InstanceType: aws.String(itype)},
|
||||
}}
|
||||
svc := &InstanceServiceImpl{stoppedStore: store, resourceMgr: prov, vmMgr: vm.NewManager()}
|
||||
|
||||
_, err := svc.StartStoppedInstance(context.Background(), &StartStoppedInstanceInput{InstanceID: id}, "acc")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
|
||||
assert.Empty(t, prov.allocated, "nothing is allocated for a start that cannot boot")
|
||||
require.NotNil(t, store.WroteStopped[id], "the claimed instance is handed back")
|
||||
assert.Equal(t, before, store.WroteStopped[id].Record(), "the record is unchanged")
|
||||
_, inManager := svc.vmMgr.Get(id)
|
||||
assert.False(t, inManager)
|
||||
}
|
||||
|
||||
func TestStartStoppedInstance_AllocateFails(t *testing.T) {
|
||||
id := "i-alloc-fail"
|
||||
itype := "t3.micro"
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package instance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ec2/instance/uefistore"
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
otelsetup "github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
|
||||
)
|
||||
|
||||
// UEFIStoreRecords creates the store record that owns a new instance's
|
||||
// variable store objects.
|
||||
type UEFIStoreRecords interface {
|
||||
Create(ctx context.Context, rec *uefistore.UEFIStoreRecord) (uint64, error)
|
||||
}
|
||||
|
||||
var _ UEFIStoreRecords = (*uefistore.Store)(nil)
|
||||
|
||||
// SetUEFIVarStore wires where RunInstances records and writes a new UEFI
|
||||
// instance's variable store, and the node named as its writer.
|
||||
func (s *InstanceServiceImpl) SetUEFIVarStore(records UEFIStoreRecords, node string) {
|
||||
s.uefiStores = records
|
||||
s.uefiNode = node
|
||||
}
|
||||
|
||||
// seedUEFIVarStore gives a new UEFI instance its variable store: the store
|
||||
// record, live, then the seed object at a fresh key, then the pointer on the
|
||||
// in-memory instance, which the launch's record writes carry.
|
||||
func (s *InstanceServiceImpl) seedUEFIVarStore(ctx context.Context, instance *vm.VM, imageID, arch string) error {
|
||||
bucket := ""
|
||||
if s.config != nil {
|
||||
bucket = s.config.Predastore.Bucket
|
||||
}
|
||||
if s.uefiStores == nil || s.objectStore == nil || bucket == "" || s.uefiNode == "" {
|
||||
slog.ErrorContext(ctx, "UEFI variable store seeding is not wired on this node", "instanceId", instance.ID)
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
fw, err := vm.NodeFirmwareFor(arch)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "UEFI firmware not installed on this host", "instanceId", instance.ID, "arch", arch, "err", err)
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
region, source, err := s.uefiSeedRegion(ctx, imageID, fw)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to read the UEFI variable store seed", "instanceId", instance.ID, "err", err)
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
key, err := uefistore.NewObjectKey(instance.ID)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to name the UEFI variable store object", "instanceId", instance.ID, "err", err)
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
// The record first, so terminate can reach every object under the prefix.
|
||||
if _, err := s.uefiStores.Create(ctx, &uefistore.UEFIStoreRecord{
|
||||
InstanceID: instance.ID,
|
||||
AccountID: instance.AccountID,
|
||||
State: uefistore.StateLive,
|
||||
}); err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to create the UEFI store record", "instanceId", instance.ID, "err", err)
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
if err := uefistore.PutObject(ctx, s.objectStore, bucket, key, region, uefistore.ObjectMeta{
|
||||
OwnerID: instance.ID,
|
||||
WriterNode: s.uefiNode,
|
||||
Firmware: fw.Identity,
|
||||
CodePath: fw.Code,
|
||||
Source: source,
|
||||
}); err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to write the UEFI variable store seed", "instanceId", instance.ID, "key", key,
|
||||
"put_ms", otelsetup.Millis(time.Since(start)), "err", err)
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
ref := &vm.UEFIVarStoreRef{Key: key, Firmware: fw.Identity, WriterNode: s.uefiNode, WrittenAt: time.Now().UTC()}
|
||||
s.vmMgr.Inspect(instance, func(v *vm.VM) { v.UEFIVarStore = ref })
|
||||
slog.InfoContext(ctx, "Seeded the UEFI variable store", "instanceId", instance.ID, "key", key, "source", source,
|
||||
"arch", fw.Identity.Arch, "varsSize", fw.Identity.VarsSize, "put_ms", otelsetup.Millis(time.Since(start)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// uefiSeedRegion returns the region a new instance's store starts from and
|
||||
// the source its object records. AMI documents name no variable store yet,
|
||||
// so every launch seeds from this node's VARS template.
|
||||
func (s *InstanceServiceImpl) uefiSeedRegion(_ context.Context, _ string, fw vm.NodeFirmware) ([]byte, string, error) {
|
||||
region, err := os.ReadFile(fw.VarsTemplate)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return region, uefistore.SourceTemplate, nil
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package instance
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
testutil "github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ec2/instance/uefistore"
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
ebsprovider "github.com/mulgadc/spinifex/spinifex/providers/ebs"
|
||||
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
testSeedInstance = "i-0a1b2c3d4e5f60718"
|
||||
testSeedNode = "node-a"
|
||||
testSeedType = "t3.nano"
|
||||
)
|
||||
|
||||
// recordingObjects keeps each put's metadata, which the memory store drops,
|
||||
// and can fail store puts or check the store record exists when one lands.
|
||||
type recordingObjects struct {
|
||||
*objectstore.MemoryObjectStore
|
||||
|
||||
mu sync.Mutex
|
||||
metadata map[string]map[string]string
|
||||
putErr error
|
||||
onPut func(key string)
|
||||
}
|
||||
|
||||
var _ objectstore.ObjectStore = (*recordingObjects)(nil)
|
||||
|
||||
func (r *recordingObjects) PutObject(ctx context.Context, in *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
|
||||
if r.onPut != nil {
|
||||
r.onPut(aws.StringValue(in.Key))
|
||||
}
|
||||
if r.putErr != nil && strings.HasPrefix(aws.StringValue(in.Key), "uefi/") {
|
||||
return nil, r.putErr
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.metadata[aws.StringValue(in.Key)] = aws.StringValueMap(in.Metadata)
|
||||
r.mu.Unlock()
|
||||
return r.MemoryObjectStore.PutObject(ctx, in)
|
||||
}
|
||||
|
||||
type seedFixture struct {
|
||||
svc *InstanceServiceImpl
|
||||
provider *ebsprovider.MemoryProvider
|
||||
objects *recordingObjects
|
||||
records *uefistore.Store
|
||||
template []byte
|
||||
codePath string
|
||||
identity uefistore.FirmwareIdentity
|
||||
}
|
||||
|
||||
func newSeedFixture(t *testing.T) *seedFixture {
|
||||
t.Helper()
|
||||
fwDir := t.TempDir()
|
||||
code := filepath.Join(fwDir, "OVMF_CODE.fd")
|
||||
vars := filepath.Join(fwDir, "OVMF_VARS.fd")
|
||||
codeBytes := []byte("ovmf code build 7")
|
||||
template := bytes.Repeat([]byte("VARS"), 1024)
|
||||
require.NoError(t, os.WriteFile(code, codeBytes, 0o600))
|
||||
require.NoError(t, os.WriteFile(vars, template, 0o600))
|
||||
orig := vm.FirmwarePathCandidates
|
||||
vm.FirmwarePathCandidates = map[string][]vm.FirmwareCandidate{"x86_64": {{Code: code, VarsTemplate: vars}}} //nolint:reassign // exported for tests to swap, restored below
|
||||
|
||||
t.Cleanup(func() { vm.FirmwarePathCandidates = orig }) //nolint:reassign // restoring the candidates captured above
|
||||
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
records := uefistore.NewStore(js)
|
||||
require.NoError(t, records.Open(t.Context()))
|
||||
|
||||
provider := ebsprovider.NewMemoryProvider(ebsprovider.Capabilities{CrashConsistentSnapshot: true, VolumeSeeding: true, VolumeEnumeration: true})
|
||||
seedProviderSnapshot(t, provider, "vol-origin", "snap-source")
|
||||
objects := &recordingObjects{MemoryObjectStore: objectstore.NewMemoryObjectStore(), metadata: map[string]map[string]string{}}
|
||||
svc := providerRootVolumeService(t, provider, rootVolumeAMILoader(), objects)
|
||||
svc.vmMgr = vm.NewManager()
|
||||
svc.instanceTypes = map[string]*ec2.InstanceTypeInfo{testSeedType: {
|
||||
InstanceType: aws.String(testSeedType),
|
||||
ProcessorInfo: &ec2.ProcessorInfo{SupportedArchitectures: []*string{aws.String("x86_64")}},
|
||||
}}
|
||||
svc.SetUEFIVarStore(records, testSeedNode)
|
||||
|
||||
sum := sha256.Sum256(codeBytes)
|
||||
return &seedFixture{
|
||||
svc: svc, provider: provider, objects: objects, records: records, template: template, codePath: code,
|
||||
identity: uefistore.FirmwareIdentity{Arch: "x86_64", VarsSize: int64(len(template)), CodeSHA256: hex.EncodeToString(sum[:])},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *seedFixture) instance(bootMode string) *vm.VM {
|
||||
v := &vm.VM{ID: testSeedInstance, AccountID: testRootAccount, InstanceType: testSeedType, BootMode: bootMode, Instance: &ec2.Instance{}}
|
||||
f.svc.vmMgr.Insert(v)
|
||||
return v
|
||||
}
|
||||
|
||||
func seedRunInput() *ec2.RunInstancesInput {
|
||||
return &ec2.RunInstancesInput{ImageId: aws.String("ami-1"), InstanceType: aws.String(testSeedType)}
|
||||
}
|
||||
|
||||
func (f *seedFixture) objectKeys(t *testing.T) []string {
|
||||
t.Helper()
|
||||
out, err := f.objects.ListObjectsV2(t.Context(), &s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(testRootBucket), Prefix: aws.String(uefistore.KeyPrefix(testSeedInstance)),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
keys := make([]string, 0, len(out.Contents))
|
||||
for _, o := range out.Contents {
|
||||
keys = append(keys, aws.StringValue(o.Key))
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func (f *seedFixture) providerVolumeIDs(t *testing.T) []string {
|
||||
t.Helper()
|
||||
out, err := f.provider.ListVolumes(t.Context(), ebsprovider.ListVolumesRequest{Versioned: ebsprovider.NewVersioned()})
|
||||
require.NoError(t, err)
|
||||
ids := make([]string, 0, len(out.Volumes))
|
||||
for _, v := range out.Volumes {
|
||||
ids = append(ids, v.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// A UEFI launch seeds its store as an object and a pointer: no <root>-efi
|
||||
// volume, no EFI request, a live store record, and a gzip of this node's
|
||||
// template whose metadata and pointer name the firmware and the writer.
|
||||
func TestGenerateVolumes_UEFISeedsTheStoreFromTheTemplate(t *testing.T) {
|
||||
f := newSeedFixture(t)
|
||||
instance := f.instance("uefi")
|
||||
|
||||
infos, err := f.svc.GenerateVolumes(t.Context(), seedRunInput(), instance)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, infos, 1, "only the root volume is reported")
|
||||
for _, id := range f.providerVolumeIDs(t) {
|
||||
assert.False(t, strings.HasSuffix(id, "-efi"), "no EFI volume is created, found %s", id)
|
||||
}
|
||||
for _, r := range instance.EBSRequests.Requests {
|
||||
assert.False(t, r.EFI, "no EFI request is appended, found %s", r.Name)
|
||||
}
|
||||
|
||||
ref := instance.UEFIVarStore
|
||||
require.NotNil(t, ref, "the launch's record carries a pointer")
|
||||
assert.Equal(t, []string{ref.Key}, f.objectKeys(t), "the pointer names the one object under the prefix")
|
||||
assert.Equal(t, f.identity, ref.Firmware)
|
||||
assert.Equal(t, testSeedNode, ref.WriterNode)
|
||||
assert.False(t, ref.WrittenAt.IsZero())
|
||||
assert.Equal(t, ref, instance.Record().Status.UEFIVarStore, "the pointer is in the record's status")
|
||||
|
||||
out, err := f.objects.GetObject(t.Context(), &s3.GetObjectInput{Bucket: aws.String(testRootBucket), Key: aws.String(ref.Key)})
|
||||
require.NoError(t, err)
|
||||
defer out.Body.Close()
|
||||
gz, err := gzip.NewReader(out.Body)
|
||||
require.NoError(t, err, "the body is gzip")
|
||||
region, err := io.ReadAll(gz)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, f.template, region, "the store starts as this node's VARS template")
|
||||
|
||||
assert.Equal(t, map[string]string{
|
||||
"instance-id": testSeedInstance,
|
||||
"writer-node": testSeedNode,
|
||||
"firmware-arch": "x86_64",
|
||||
"firmware-vars-size": strconv.Itoa(len(f.template)),
|
||||
"firmware-code-sha256": f.identity.CodeSHA256,
|
||||
"firmware-code-path": f.codePath,
|
||||
"source": "template",
|
||||
}, f.objects.metadata[ref.Key])
|
||||
|
||||
rec, _, err := f.records.Get(t.Context(), testSeedInstance)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, uefistore.StateLive, rec.State)
|
||||
assert.Equal(t, testRootAccount, rec.AccountID)
|
||||
assert.Nil(t, rec.WriteBack)
|
||||
}
|
||||
|
||||
// The store record exists before the object is put, so terminate can reach
|
||||
// every object under the prefix, including one whose put failed midway.
|
||||
func TestGenerateVolumes_UEFIRecordsTheStoreBeforeItsObject(t *testing.T) {
|
||||
f := newSeedFixture(t)
|
||||
instance := f.instance("uefi")
|
||||
recordAtPut := errors.New("no put")
|
||||
f.objects.onPut = func(key string) {
|
||||
if strings.HasPrefix(key, uefistore.KeyPrefix(testSeedInstance)) {
|
||||
_, _, recordAtPut = f.records.Get(context.Background(), testSeedInstance)
|
||||
}
|
||||
}
|
||||
|
||||
_, err := f.svc.GenerateVolumes(t.Context(), seedRunInput(), instance)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, recordAtPut, "the record is live when the object is put")
|
||||
}
|
||||
|
||||
// A failed put fails the launch with InternalError and sets no pointer, and
|
||||
// leaves the record so terminate can still settle the prefix.
|
||||
func TestGenerateVolumes_UEFIPutFailureFailsTheLaunch(t *testing.T) {
|
||||
f := newSeedFixture(t)
|
||||
instance := f.instance("uefi")
|
||||
f.objects.putErr = errors.New("predastore unreachable")
|
||||
|
||||
_, err := f.svc.GenerateVolumes(t.Context(), seedRunInput(), instance)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
|
||||
assert.Nil(t, instance.UEFIVarStore)
|
||||
rec, _, getErr := f.records.Get(t.Context(), testSeedInstance)
|
||||
require.NoError(t, getErr)
|
||||
assert.Equal(t, uefistore.StateLive, rec.State)
|
||||
}
|
||||
|
||||
// A record already under the instance's ID is not this launch's, so the
|
||||
// launch writes nothing under its prefix.
|
||||
func TestGenerateVolumes_UEFIExistingRecordFailsTheLaunch(t *testing.T) {
|
||||
f := newSeedFixture(t)
|
||||
instance := f.instance("uefi")
|
||||
_, err := f.records.Create(t.Context(), &uefistore.UEFIStoreRecord{InstanceID: testSeedInstance, AccountID: "000000000099", State: uefistore.StateLive})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = f.svc.GenerateVolumes(t.Context(), seedRunInput(), instance)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
|
||||
assert.Empty(t, f.objectKeys(t))
|
||||
assert.Nil(t, instance.UEFIVarStore)
|
||||
}
|
||||
|
||||
// A BIOS guest has no variable store: no record, no object, no pointer.
|
||||
func TestGenerateVolumes_BIOSSeedsNoStore(t *testing.T) {
|
||||
f := newSeedFixture(t)
|
||||
instance := f.instance("legacy-bios")
|
||||
|
||||
_, err := f.svc.GenerateVolumes(t.Context(), seedRunInput(), instance)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, instance.UEFIVarStore)
|
||||
assert.Empty(t, f.objectKeys(t))
|
||||
_, _, err = f.records.Get(t.Context(), testSeedInstance)
|
||||
assert.ErrorIs(t, err, kvstore.ErrNotFound)
|
||||
}
|
||||
|
||||
// A node without the store wired cannot seed, so it fails the launch rather
|
||||
// than booting a UEFI guest with no store.
|
||||
func TestGenerateVolumes_UEFIWithoutStoreWiringFails(t *testing.T) {
|
||||
f := newSeedFixture(t)
|
||||
f.svc.SetUEFIVarStore(nil, "")
|
||||
instance := f.instance("uefi")
|
||||
|
||||
_, err := f.svc.GenerateVolumes(t.Context(), seedRunInput(), instance)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
|
||||
assert.Nil(t, instance.UEFIVarStore)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package uefistore
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
|
||||
)
|
||||
|
||||
const (
|
||||
// SourceTemplate is a store seeded from the launching node's VARS template.
|
||||
SourceTemplate = "template"
|
||||
// SourceWriteBack is a store written back from a node's live copy.
|
||||
SourceWriteBack = "write-back"
|
||||
|
||||
sourceImagePrefix = "image:"
|
||||
|
||||
// PutTimeout bounds one put of a store object. The object is a fixed-size
|
||||
// region, so a put that takes longer is a wedged object store.
|
||||
PutTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// ErrInvalidObject is a store object whose key, region or metadata cannot be
|
||||
// written as the owner's store.
|
||||
var ErrInvalidObject = errors.New("uefistore: invalid store object")
|
||||
|
||||
// SourceImage is a store seeded from imageID's own store.
|
||||
func SourceImage(imageID string) string {
|
||||
return sourceImagePrefix + imageID
|
||||
}
|
||||
|
||||
// ObjectMeta is what a store object records about itself, as x-amz-meta-
|
||||
// entries, so its owner, writer and firmware are known from the object alone.
|
||||
type ObjectMeta struct {
|
||||
OwnerID string
|
||||
WriterNode string
|
||||
Firmware FirmwareIdentity
|
||||
CodePath string
|
||||
Source string
|
||||
}
|
||||
|
||||
// Metadata returns m as object metadata, keyed by the owner's kind.
|
||||
func (m ObjectMeta) Metadata() (map[string]*string, error) {
|
||||
var ownerKey string
|
||||
switch {
|
||||
case strings.HasPrefix(m.OwnerID, "i-"):
|
||||
ownerKey = "instance-id"
|
||||
case strings.HasPrefix(m.OwnerID, "ami-"):
|
||||
ownerKey = "image-id"
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: owner %q is neither an instance nor an image", ErrInvalidObject, m.OwnerID)
|
||||
}
|
||||
switch {
|
||||
case m.WriterNode == "":
|
||||
return nil, fmt.Errorf("%w: %s names no writer node", ErrInvalidObject, m.OwnerID)
|
||||
case m.Source == "":
|
||||
return nil, fmt.Errorf("%w: %s names no source", ErrInvalidObject, m.OwnerID)
|
||||
case m.Firmware.Arch == "" || m.Firmware.VarsSize <= 0 || m.Firmware.CodeSHA256 == "":
|
||||
return nil, fmt.Errorf("%w: %s has an incomplete firmware identity", ErrInvalidObject, m.OwnerID)
|
||||
}
|
||||
return map[string]*string{
|
||||
ownerKey: aws.String(m.OwnerID),
|
||||
"writer-node": aws.String(m.WriterNode),
|
||||
"firmware-arch": aws.String(m.Firmware.Arch),
|
||||
"firmware-vars-size": aws.String(strconv.FormatInt(m.Firmware.VarsSize, 10)),
|
||||
"firmware-code-sha256": aws.String(m.Firmware.CodeSHA256),
|
||||
"firmware-code-path": aws.String(m.CodePath),
|
||||
"source": aws.String(m.Source),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewObjectKey returns a fresh key under ownerID's prefix, named by a random
|
||||
// version 4 UUID, so no write ever replaces an object a record points at.
|
||||
func NewObjectKey(ownerID string) (string, error) {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", fmt.Errorf("generate UEFI store key for %s: %w", ownerID, err)
|
||||
}
|
||||
b[6] = b[6]&0x0f | 0x40
|
||||
b[8] = b[8]&0x3f | 0x80
|
||||
return fmt.Sprintf("%s%x-%x-%x-%x-%x", KeyPrefix(ownerID), b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
|
||||
}
|
||||
|
||||
// PutObject writes region gzip-compressed to key with meta, within
|
||||
// PutTimeout. The region must be exactly the firmware's VARS size, because
|
||||
// pflash refuses any other.
|
||||
func PutObject(ctx context.Context, objects objectstore.ObjectStore, bucket, key string, region []byte, meta ObjectMeta) error {
|
||||
if !ownsKey(meta.OwnerID, key) {
|
||||
return fmt.Errorf("%w: key %q is not under %s", ErrInvalidObject, key, KeyPrefix(meta.OwnerID))
|
||||
}
|
||||
if int64(len(region)) != meta.Firmware.VarsSize {
|
||||
return fmt.Errorf("%w: %s is a %d-byte region under firmware with a %d-byte VARS region",
|
||||
ErrInvalidObject, key, len(region), meta.Firmware.VarsSize)
|
||||
}
|
||||
md, err := meta.Metadata()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var body bytes.Buffer
|
||||
zw := gzip.NewWriter(&body)
|
||||
if _, err := zw.Write(region); err != nil {
|
||||
return fmt.Errorf("compress UEFI store %s: %w", key, err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return fmt.Errorf("compress UEFI store %s: %w", key, err)
|
||||
}
|
||||
|
||||
putCtx, cancel := context.WithTimeout(ctx, PutTimeout)
|
||||
defer cancel()
|
||||
if _, err := objects.PutObject(putCtx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(key),
|
||||
Body: bytes.NewReader(body.Bytes()),
|
||||
Metadata: md,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("put UEFI store %s: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package uefistore_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ec2/instance/uefistore"
|
||||
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const testBucket = "instances"
|
||||
|
||||
func testMeta(owner string) uefistore.ObjectMeta {
|
||||
return uefistore.ObjectMeta{
|
||||
OwnerID: owner,
|
||||
WriterNode: "node-1",
|
||||
Firmware: uefistore.FirmwareIdentity{Arch: "arm64", VarsSize: 1 << 16, CodeSHA256: "abc123"},
|
||||
CodePath: "/usr/share/AAVMF/AAVMF_CODE.fd",
|
||||
Source: uefistore.SourceTemplate,
|
||||
}
|
||||
}
|
||||
|
||||
var uuidV4 = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)
|
||||
|
||||
// Every key is a fresh version 4 UUID directly under the owner's prefix, so
|
||||
// a write never lands on an object a record already points at.
|
||||
func TestNewObjectKey_IsAFreshUUIDUnderTheOwnersPrefix(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for range 64 {
|
||||
key, err := uefistore.NewObjectKey(instanceID)
|
||||
require.NoError(t, err)
|
||||
name, ok := bytes.CutPrefix([]byte(key), []byte(uefistore.KeyPrefix(instanceID)))
|
||||
require.True(t, ok, key)
|
||||
assert.Regexp(t, uuidV4, string(name))
|
||||
assert.False(t, seen[key], "key %s repeated", key)
|
||||
seen[key] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestObjectMeta_Metadata_NamesTheOwnerByKind(t *testing.T) {
|
||||
md, err := testMeta(instanceID).Metadata()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]string{
|
||||
"instance-id": instanceID,
|
||||
"writer-node": "node-1",
|
||||
"firmware-arch": "arm64",
|
||||
"firmware-vars-size": "65536",
|
||||
"firmware-code-sha256": "abc123",
|
||||
"firmware-code-path": "/usr/share/AAVMF/AAVMF_CODE.fd",
|
||||
"source": "template",
|
||||
}, aws.StringValueMap(md))
|
||||
|
||||
img := testMeta("ami-0123456789abcdef0")
|
||||
img.Source = uefistore.SourceImage("ami-0fedcba9876543210")
|
||||
md, err = img.Metadata()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "ami-0123456789abcdef0", aws.StringValue(md["image-id"]))
|
||||
assert.NotContains(t, md, "instance-id")
|
||||
assert.Equal(t, "image:ami-0fedcba9876543210", aws.StringValue(md["source"]))
|
||||
}
|
||||
|
||||
func TestObjectMeta_Metadata_RefusesAnIncompleteObject(t *testing.T) {
|
||||
tests := map[string]func(*uefistore.ObjectMeta){
|
||||
"unknown owner": func(m *uefistore.ObjectMeta) { m.OwnerID = "vol-1" },
|
||||
"no writer": func(m *uefistore.ObjectMeta) { m.WriterNode = "" },
|
||||
"no source": func(m *uefistore.ObjectMeta) { m.Source = "" },
|
||||
"no arch": func(m *uefistore.ObjectMeta) { m.Firmware.Arch = "" },
|
||||
"no vars size": func(m *uefistore.ObjectMeta) { m.Firmware.VarsSize = 0 },
|
||||
"no code sha256": func(m *uefistore.ObjectMeta) { m.Firmware.CodeSHA256 = "" },
|
||||
}
|
||||
for name, mutate := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
m := testMeta(instanceID)
|
||||
mutate(&m)
|
||||
_, err := m.Metadata()
|
||||
require.ErrorIs(t, err, uefistore.ErrInvalidObject)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The 64 MiB arm64 region is mostly erased flash, so it is stored compressed
|
||||
// and decompresses to the exact region.
|
||||
func TestPutObject_StoresTheRegionCompressed(t *testing.T) {
|
||||
objects := objectstore.NewMemoryObjectStore()
|
||||
meta := testMeta(instanceID)
|
||||
region := bytes.Repeat([]byte{0xff}, int(meta.Firmware.VarsSize))
|
||||
copy(region, "BootOrder")
|
||||
key, err := uefistore.NewObjectKey(instanceID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, uefistore.PutObject(t.Context(), objects, testBucket, key, region, meta))
|
||||
|
||||
out, err := objects.GetObject(t.Context(), &s3.GetObjectInput{Bucket: aws.String(testBucket), Key: aws.String(key)})
|
||||
require.NoError(t, err)
|
||||
body, err := io.ReadAll(out.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Less(t, len(body), len(region)/10, "the erased region compresses")
|
||||
gz, err := gzip.NewReader(bytes.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
got, err := io.ReadAll(gz)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, region, got)
|
||||
}
|
||||
|
||||
// pflash needs a byte-exact region, and a key outside the owner's prefix is
|
||||
// one no store record owns; either is refused before anything is put.
|
||||
func TestPutObject_RefusesWhatTheOwnerCannotBoot(t *testing.T) {
|
||||
meta := testMeta(instanceID)
|
||||
good, err := uefistore.NewObjectKey(instanceID)
|
||||
require.NoError(t, err)
|
||||
other, err := uefistore.NewObjectKey("i-0fedcba9876543210")
|
||||
require.NoError(t, err)
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
region []byte
|
||||
}{
|
||||
{name: "short region", key: good, region: make([]byte, meta.Firmware.VarsSize-1)},
|
||||
{name: "long region", key: good, region: make([]byte, meta.Firmware.VarsSize+1)},
|
||||
{name: "another owner's key", key: other, region: make([]byte, meta.Firmware.VarsSize)},
|
||||
{name: "nested key", key: uefistore.KeyPrefix(instanceID) + "a/b", region: make([]byte, meta.Firmware.VarsSize)},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
objects := objectstore.NewMemoryObjectStore()
|
||||
err := uefistore.PutObject(t.Context(), objects, testBucket, tt.key, tt.region, meta)
|
||||
require.ErrorIs(t, err, uefistore.ErrInvalidObject)
|
||||
out, listErr := objects.ListObjectsV2(t.Context(), &s3.ListObjectsV2Input{Bucket: aws.String(testBucket)})
|
||||
require.NoError(t, listErr)
|
||||
assert.Empty(t, out.Contents)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type failingPut struct {
|
||||
*objectstore.MemoryObjectStore
|
||||
}
|
||||
|
||||
func (failingPut) PutObject(ctx context.Context, _ *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
|
||||
if _, ok := ctx.Deadline(); !ok {
|
||||
return nil, errors.New("put had no deadline")
|
||||
}
|
||||
return nil, errors.New("predastore unreachable")
|
||||
}
|
||||
|
||||
// A put runs under a deadline, so a wedged object store fails the write
|
||||
// rather than holding the caller, and its error reaches the caller.
|
||||
func TestPutObject_IsBoundedAndReportsAFailedPut(t *testing.T) {
|
||||
key, err := uefistore.NewObjectKey(instanceID)
|
||||
require.NoError(t, err)
|
||||
meta := testMeta(instanceID)
|
||||
err = uefistore.PutObject(t.Context(), failingPut{objectstore.NewMemoryObjectStore()}, testBucket, key, make([]byte, meta.Firmware.VarsSize), meta)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "predastore unreachable")
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// Package uefistore holds the types that describe an instance's UEFI variable
|
||||
// store and the bucket of store records that carry its deletion and write-back
|
||||
// obligations past the life of the instance record.
|
||||
package uefistore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/migrate"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
const (
|
||||
// BucketName holds one store record per instance, keyed by instance ID.
|
||||
BucketName = "spinifex-uefi-stores"
|
||||
// BucketVersion is the bucket's schema stamp.
|
||||
BucketVersion = 1
|
||||
// RecordSchemaVersion is the version every record is written at.
|
||||
RecordSchemaVersion uint16 = 1
|
||||
|
||||
// StateLive is a store whose objects belong to a running or stopped instance.
|
||||
StateLive = "live"
|
||||
// StateDeleting is a store whose objects are to be deleted, and then the record.
|
||||
StateDeleting = "deleting"
|
||||
|
||||
objectRoot = "uefi/"
|
||||
)
|
||||
|
||||
// ErrInvalidRecord is returned for a store record that breaks its invariants,
|
||||
// whether it is about to be written or was just read.
|
||||
var ErrInvalidRecord = errors.New("uefistore: invalid store record")
|
||||
|
||||
// FirmwareIdentity names the firmware a variable store was written under. A
|
||||
// store only boots where Arch and VarsSize match the node's firmware, because
|
||||
// pflash needs a byte-exact region; CodeSHA256 is recorded, not enforced.
|
||||
type FirmwareIdentity struct {
|
||||
Arch string `json:"arch"`
|
||||
VarsSize int64 `json:"vars_size"`
|
||||
CodeSHA256 string `json:"code_sha256"`
|
||||
}
|
||||
|
||||
// UEFIStoreRecord is the durable owner of one instance's variable store
|
||||
// objects. It outlives the instance record, so an obligation recorded here is
|
||||
// not lost when a terminated record expires.
|
||||
type UEFIStoreRecord struct {
|
||||
SchemaVersion uint16 `json:"schema_version"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
AccountID string `json:"account_id"`
|
||||
State string `json:"state"`
|
||||
WriteBack *UEFIWriteBack `json:"write_back,omitempty"`
|
||||
}
|
||||
|
||||
// UEFIWriteBack is a write-back that failed and is still owed. Key is the
|
||||
// object every retry puts to, so retries leave no extra objects; SeededFrom is
|
||||
// the pointer Node seeded from, which a retry must still find on the record.
|
||||
type UEFIWriteBack struct {
|
||||
Node string `json:"node"`
|
||||
Key string `json:"key"`
|
||||
SeededFrom string `json:"seeded_from"`
|
||||
Since time.Time `json:"since"`
|
||||
}
|
||||
|
||||
// KeyPrefix is the object-store prefix every store object of ownerID (an
|
||||
// instance or image ID) is written under.
|
||||
func KeyPrefix(ownerID string) string {
|
||||
return objectRoot + ownerID + "/"
|
||||
}
|
||||
|
||||
// ownsKey reports whether key names one object directly under ownerID's prefix.
|
||||
func ownsKey(ownerID, key string) bool {
|
||||
name, ok := strings.CutPrefix(key, KeyPrefix(ownerID))
|
||||
return ok && name != "" && !strings.Contains(name, "/")
|
||||
}
|
||||
|
||||
// validate checks rec against key. A write-back key outside the instance's own
|
||||
// prefix is refused because a superseded obligation deletes that key.
|
||||
func (rec *UEFIStoreRecord) validate(key string) error {
|
||||
switch {
|
||||
case rec.SchemaVersion > RecordSchemaVersion:
|
||||
return fmt.Errorf("%w: %s is at schema_version %d, newer than this node understands (%d)",
|
||||
ErrInvalidRecord, key, rec.SchemaVersion, RecordSchemaVersion)
|
||||
case rec.SchemaVersion != RecordSchemaVersion:
|
||||
return fmt.Errorf("%w: %s has schema_version %d", ErrInvalidRecord, key, rec.SchemaVersion)
|
||||
case !strings.HasPrefix(rec.InstanceID, "i-"):
|
||||
return fmt.Errorf("%w: instance_id %q is not an instance ID", ErrInvalidRecord, rec.InstanceID)
|
||||
case rec.InstanceID != key:
|
||||
return fmt.Errorf("%w: instance_id %s stored at key %s", ErrInvalidRecord, rec.InstanceID, key)
|
||||
case rec.State != StateLive && rec.State != StateDeleting:
|
||||
return fmt.Errorf("%w: %s has state %q", ErrInvalidRecord, key, rec.State)
|
||||
}
|
||||
if wb := rec.WriteBack; wb != nil {
|
||||
switch {
|
||||
case wb.Node == "":
|
||||
return fmt.Errorf("%w: %s write-back names no node", ErrInvalidRecord, key)
|
||||
case wb.Since.IsZero():
|
||||
return fmt.Errorf("%w: %s write-back has no since", ErrInvalidRecord, key)
|
||||
case !ownsKey(rec.InstanceID, wb.Key):
|
||||
return fmt.Errorf("%w: %s write-back key %q is not under %s", ErrInvalidRecord, key, wb.Key, KeyPrefix(rec.InstanceID))
|
||||
case !ownsKey(rec.InstanceID, wb.SeededFrom):
|
||||
return fmt.Errorf("%w: %s write-back seeded_from %q is not under %s", ErrInvalidRecord, key, wb.SeededFrom, KeyPrefix(rec.InstanceID))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func bucketConfig() kvstore.Config {
|
||||
return kvstore.Config{
|
||||
Name: BucketName,
|
||||
Description: "Spinifex UEFI variable store records (deletion and write-back obligations)",
|
||||
History: 1,
|
||||
// Left false: a lost bucket has lost obligations, and an empty
|
||||
// recreated one would report every store as having none.
|
||||
RecreateIfMissing: false,
|
||||
OnOpen: func(ctx context.Context, kv jetstream.KeyValue) error {
|
||||
return migrate.DefaultRegistry.RunKV(ctx, BucketName, kv, BucketVersion)
|
||||
},
|
||||
Missing: "UEFI store KV bucket not initialized",
|
||||
}
|
||||
}
|
||||
|
||||
// Store reads and writes store records. Every change is guarded by the
|
||||
// revision the caller read, so a lost race is reported rather than retried.
|
||||
type Store struct {
|
||||
kv *kvstore.Store[UEFIStoreRecord]
|
||||
}
|
||||
|
||||
// NewStore returns a Store over js. The bucket is created on Open or first use.
|
||||
func NewStore(js jetstream.JetStream) *Store {
|
||||
return &Store{kv: kvstore.New[UEFIStoreRecord](js, bucketConfig())}
|
||||
}
|
||||
|
||||
// Open creates the bucket if it is missing and stamps its schema version.
|
||||
func (s *Store) Open(ctx context.Context) error {
|
||||
_, err := s.kv.KV(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// Get returns instanceID's record and the revision it was read at. A missing
|
||||
// record is kvstore.ErrNotFound; one that breaks its invariants is
|
||||
// ErrInvalidRecord, so a newer or corrupt record is never acted on.
|
||||
func (s *Store) Get(ctx context.Context, instanceID string) (*UEFIStoreRecord, uint64, error) {
|
||||
rec, rev, err := s.kv.Get(ctx, instanceID)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := rec.validate(instanceID); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return rec, rev, nil
|
||||
}
|
||||
|
||||
// Create writes a new record, which must be live: a store is recorded before
|
||||
// its first object is written. An existing record is kvstore.ErrExists. Every
|
||||
// write stamps rec with RecordSchemaVersion.
|
||||
func (s *Store) Create(ctx context.Context, rec *UEFIStoreRecord) (uint64, error) {
|
||||
rec.SchemaVersion = RecordSchemaVersion
|
||||
if rec.State != StateLive {
|
||||
return 0, fmt.Errorf("%w: %s created in state %q, not %q", ErrInvalidRecord, rec.InstanceID, rec.State, StateLive)
|
||||
}
|
||||
if err := rec.validate(rec.InstanceID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.kv.Create(ctx, rec.InstanceID, rec)
|
||||
}
|
||||
|
||||
// CompareAndSet replaces the record only if it is still at rev, returning
|
||||
// kvstore.ErrConflict when it is not, and the revision written otherwise.
|
||||
func (s *Store) CompareAndSet(ctx context.Context, rec *UEFIStoreRecord, rev uint64) (uint64, error) {
|
||||
rec.SchemaVersion = RecordSchemaVersion
|
||||
if err := rec.validate(rec.InstanceID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.kv.CompareAndSet(ctx, rec.InstanceID, rec, rev)
|
||||
}
|
||||
|
||||
// CompareAndDelete removes instanceID's record only if it is still at rev,
|
||||
// returning kvstore.ErrConflict when it is not.
|
||||
func (s *Store) CompareAndDelete(ctx context.Context, instanceID string, rev uint64) error {
|
||||
return s.kv.CompareAndDelete(ctx, instanceID, rev)
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package uefistore_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
testutil "github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ec2/instance/uefistore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const instanceID = "i-0123456789abcdef0"
|
||||
|
||||
func openStore(t *testing.T) (*uefistore.Store, jetstream.JetStream) {
|
||||
t.Helper()
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
s := uefistore.NewStore(js)
|
||||
require.NoError(t, s.Open(t.Context()))
|
||||
return s, js
|
||||
}
|
||||
|
||||
func liveRecord() *uefistore.UEFIStoreRecord {
|
||||
return &uefistore.UEFIStoreRecord{InstanceID: instanceID, AccountID: "111122223333", State: uefistore.StateLive}
|
||||
}
|
||||
|
||||
func writeBack() *uefistore.UEFIWriteBack {
|
||||
return &uefistore.UEFIWriteBack{
|
||||
Node: "node-2",
|
||||
Key: uefistore.KeyPrefix(instanceID) + "b",
|
||||
SeededFrom: uefistore.KeyPrefix(instanceID) + "a",
|
||||
Since: time.Date(2026, 11, 3, 1, 2, 3, 0, time.UTC),
|
||||
}
|
||||
}
|
||||
|
||||
func rawPut(t *testing.T, js jetstream.JetStream, key, body string) {
|
||||
t.Helper()
|
||||
kv, err := js.KeyValue(t.Context(), uefistore.BucketName)
|
||||
require.NoError(t, err)
|
||||
_, err = kv.Put(t.Context(), key, []byte(body))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// The records carry obligations past the 1 h terminated-record TTL, so the
|
||||
// bucket must not expire them, and it is stamped so a newer schema is refused.
|
||||
func TestOpen_CreatesBucketWithNoTTLAndStampsVersion(t *testing.T) {
|
||||
_, js := openStore(t)
|
||||
|
||||
kv, err := js.KeyValue(t.Context(), uefistore.BucketName)
|
||||
require.NoError(t, err)
|
||||
status, err := kv.Status(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), status.History())
|
||||
assert.Zero(t, status.TTL(), "a TTL would expire deletion obligations before they are settled")
|
||||
|
||||
v, err := kvutil.ReadVersion(t.Context(), kv)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, uefistore.BucketVersion, v)
|
||||
}
|
||||
|
||||
func TestCreate_RoundTripsAndStampsSchemaVersion(t *testing.T) {
|
||||
s, _ := openStore(t)
|
||||
rec := liveRecord()
|
||||
rec.WriteBack = writeBack()
|
||||
|
||||
rev, err := s.Create(t.Context(), rec)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, gotRev, err := s.Get(t.Context(), instanceID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, rev, gotRev)
|
||||
assert.Equal(t, uefistore.RecordSchemaVersion, got.SchemaVersion)
|
||||
assert.Equal(t, rec, got)
|
||||
}
|
||||
|
||||
// The wire names are what every release reads, so they are pinned.
|
||||
func TestRecord_WireShape(t *testing.T) {
|
||||
s, js := openStore(t)
|
||||
rec := liveRecord()
|
||||
rec.WriteBack = writeBack()
|
||||
_, err := s.Create(t.Context(), rec)
|
||||
require.NoError(t, err)
|
||||
|
||||
kv, err := js.KeyValue(t.Context(), uefistore.BucketName)
|
||||
require.NoError(t, err)
|
||||
entry, err := kv.Get(t.Context(), instanceID)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, `{
|
||||
"schema_version": 1,
|
||||
"instance_id": "i-0123456789abcdef0",
|
||||
"account_id": "111122223333",
|
||||
"state": "live",
|
||||
"write_back": {
|
||||
"node": "node-2",
|
||||
"key": "uefi/i-0123456789abcdef0/b",
|
||||
"seeded_from": "uefi/i-0123456789abcdef0/a",
|
||||
"since": "2026-11-03T01:02:03Z"
|
||||
}
|
||||
}`, string(entry.Value()))
|
||||
}
|
||||
|
||||
func TestGet_MissingIsNotFound(t *testing.T) {
|
||||
s, _ := openStore(t)
|
||||
_, _, err := s.Get(t.Context(), instanceID)
|
||||
require.ErrorIs(t, err, kvstore.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestCreate_ExistingRecordIsErrExists(t *testing.T) {
|
||||
s, _ := openStore(t)
|
||||
_, err := s.Create(t.Context(), liveRecord())
|
||||
require.NoError(t, err)
|
||||
|
||||
dup := liveRecord()
|
||||
dup.AccountID = "999999999999"
|
||||
_, err = s.Create(t.Context(), dup)
|
||||
require.ErrorIs(t, err, kvstore.ErrExists)
|
||||
|
||||
got, _, err := s.Get(t.Context(), instanceID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "111122223333", got.AccountID, "the second create must not overwrite the first")
|
||||
}
|
||||
|
||||
// A store is recorded live before its first object, so a record that starts
|
||||
// out deleting has no object it could be describing.
|
||||
func TestCreate_RefusesDeleting(t *testing.T) {
|
||||
s, _ := openStore(t)
|
||||
rec := liveRecord()
|
||||
rec.State = uefistore.StateDeleting
|
||||
|
||||
_, err := s.Create(t.Context(), rec)
|
||||
require.ErrorIs(t, err, uefistore.ErrInvalidRecord)
|
||||
_, _, err = s.Get(t.Context(), instanceID)
|
||||
require.ErrorIs(t, err, kvstore.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestCompareAndSet_StaleRevisionConflictsAndWritesNothing(t *testing.T) {
|
||||
s, _ := openStore(t)
|
||||
first, err := s.Create(t.Context(), liveRecord())
|
||||
require.NoError(t, err)
|
||||
|
||||
moved := liveRecord()
|
||||
moved.WriteBack = writeBack()
|
||||
second, err := s.CompareAndSet(t.Context(), moved, first)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, second, first)
|
||||
|
||||
stale := liveRecord()
|
||||
stale.State = uefistore.StateDeleting
|
||||
_, err = s.CompareAndSet(t.Context(), stale, first)
|
||||
require.ErrorIs(t, err, kvstore.ErrConflict)
|
||||
|
||||
got, rev, err := s.Get(t.Context(), instanceID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, second, rev)
|
||||
assert.Equal(t, uefistore.StateLive, got.State)
|
||||
assert.Equal(t, writeBack(), got.WriteBack)
|
||||
}
|
||||
|
||||
// Two actors that read the same revision race to change it; exactly one may
|
||||
// win, so neither can overwrite an obligation the other recorded.
|
||||
func TestCompareAndSet_ConcurrentWritersFromOneReadHaveOneWinner(t *testing.T) {
|
||||
s, _ := openStore(t)
|
||||
_, err := s.Create(t.Context(), liveRecord())
|
||||
require.NoError(t, err)
|
||||
_, rev, err := s.Get(t.Context(), instanceID)
|
||||
require.NoError(t, err)
|
||||
|
||||
const writers = 8
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
wins []string
|
||||
)
|
||||
for i := range writers {
|
||||
wg.Go(func() {
|
||||
rec := liveRecord()
|
||||
rec.WriteBack = writeBack()
|
||||
rec.WriteBack.Node = fmt.Sprintf("node-%d", i)
|
||||
if _, err := s.CompareAndSet(context.Background(), rec, rev); err == nil {
|
||||
mu.Lock()
|
||||
wins = append(wins, rec.WriteBack.Node)
|
||||
mu.Unlock()
|
||||
} else {
|
||||
assert.ErrorIs(t, err, kvstore.ErrConflict)
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
require.Len(t, wins, 1)
|
||||
got, _, err := s.Get(t.Context(), instanceID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, wins[0], got.WriteBack.Node)
|
||||
}
|
||||
|
||||
func TestCompareAndDelete_StaleRevisionKeepsRecord(t *testing.T) {
|
||||
s, _ := openStore(t)
|
||||
first, err := s.Create(t.Context(), liveRecord())
|
||||
require.NoError(t, err)
|
||||
deleting := liveRecord()
|
||||
deleting.State = uefistore.StateDeleting
|
||||
second, err := s.CompareAndSet(t.Context(), deleting, first)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.ErrorIs(t, s.CompareAndDelete(t.Context(), instanceID, first), kvstore.ErrConflict)
|
||||
_, _, err = s.Get(t.Context(), instanceID)
|
||||
require.NoError(t, err, "a delete guarded on a stale revision must leave the record")
|
||||
|
||||
require.NoError(t, s.CompareAndDelete(t.Context(), instanceID, second))
|
||||
_, _, err = s.Get(t.Context(), instanceID)
|
||||
require.ErrorIs(t, err, kvstore.ErrNotFound)
|
||||
}
|
||||
|
||||
// A superseded obligation deletes its recorded key, so a key outside the
|
||||
// instance's own prefix must never reach the bucket.
|
||||
func TestCompareAndSet_RefusesInvalidRecords(t *testing.T) {
|
||||
cases := map[string]func(*uefistore.UEFIStoreRecord){
|
||||
"unknown state": func(r *uefistore.UEFIStoreRecord) { r.State = "gone" },
|
||||
"not an instance ID": func(r *uefistore.UEFIStoreRecord) { r.InstanceID = "ami-0123456789abcdef0" },
|
||||
"write-back with no node": func(r *uefistore.UEFIStoreRecord) { r.WriteBack.Node = "" },
|
||||
"write-back with no since": func(r *uefistore.UEFIStoreRecord) { r.WriteBack.Since = time.Time{} },
|
||||
"write-back key under another instance": func(r *uefistore.UEFIStoreRecord) {
|
||||
r.WriteBack.Key = uefistore.KeyPrefix("i-0fedcba9876543210") + "b"
|
||||
},
|
||||
"write-back key under an image": func(r *uefistore.UEFIStoreRecord) {
|
||||
r.WriteBack.Key = uefistore.KeyPrefix("ami-0123456789abcdef0") + "b"
|
||||
},
|
||||
"write-back key is the prefix itself": func(r *uefistore.UEFIStoreRecord) {
|
||||
r.WriteBack.Key = uefistore.KeyPrefix(instanceID)
|
||||
},
|
||||
"write-back key nested below the prefix": func(r *uefistore.UEFIStoreRecord) {
|
||||
r.WriteBack.Key = uefistore.KeyPrefix(instanceID) + "x/y"
|
||||
},
|
||||
"write-back seeded from another instance": func(r *uefistore.UEFIStoreRecord) {
|
||||
r.WriteBack.SeededFrom = uefistore.KeyPrefix("i-0fedcba9876543210") + "a"
|
||||
},
|
||||
}
|
||||
for name, mutate := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
s, _ := openStore(t)
|
||||
rev, err := s.Create(t.Context(), liveRecord())
|
||||
require.NoError(t, err)
|
||||
|
||||
rec := liveRecord()
|
||||
rec.WriteBack = writeBack()
|
||||
mutate(rec)
|
||||
_, err = s.CompareAndSet(t.Context(), rec, rev)
|
||||
require.ErrorIs(t, err, uefistore.ErrInvalidRecord)
|
||||
|
||||
got, gotRev, err := s.Get(t.Context(), instanceID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, rev, gotRev, "an invalid record must not be written")
|
||||
assert.Nil(t, got.WriteBack)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A record a newer release wrote, or one that does not describe its own key,
|
||||
// is reported rather than decoded into something a reaper would act on.
|
||||
func TestGet_RefusesRecordsItCannotTrust(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"newer schema": `{"schema_version":2,"instance_id":"i-0123456789abcdef0","state":"live"}`,
|
||||
"no schema": `{"instance_id":"i-0123456789abcdef0","state":"live"}`,
|
||||
"another instance": `{"schema_version":1,"instance_id":"i-0fedcba9876543210","state":"deleting"}`,
|
||||
"unknown state": `{"schema_version":1,"instance_id":"i-0123456789abcdef0","state":"purged"}`,
|
||||
"foreign write-back": `{"schema_version":1,"instance_id":"i-0123456789abcdef0","state":"live","write_back":{"node":"n","key":"uefi/i-0fedcba9876543210/b","seeded_from":"uefi/i-0123456789abcdef0/a","since":"2026-11-03T00:00:00Z"}}`,
|
||||
}
|
||||
for name, body := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
s, js := openStore(t)
|
||||
require.True(t, json.Valid([]byte(body)))
|
||||
rawPut(t, js, instanceID, body)
|
||||
|
||||
_, _, err := s.Get(t.Context(), instanceID)
|
||||
require.ErrorIs(t, err, uefistore.ErrInvalidRecord)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ Single source of truth for the current schema version of every config file Spini
|
||||
| `spinifex-cluster-state` | `1` | `daemon.ClusterStateBucketVersion` |
|
||||
| `spinifex-cluster-config` | `1` | `daemon.ClusterConfigBucketVersion` |
|
||||
| `spinifex-terminated-instances` | `3` | `daemon.TerminatedInstanceBucketVersion` |
|
||||
| `spinifex-uefi-stores` | `1` | `uefistore.BucketVersion` |
|
||||
|
||||
## Registered migrations
|
||||
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
package vm
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ec2/instance/uefistore"
|
||||
)
|
||||
|
||||
// FirmwareCandidate is a (CODE, VARS template) pair shipped together by a
|
||||
// firmware package. CODE is mounted readonly as pflash unit 0; VARS is copied
|
||||
// into a per-VM viperblock volume on first boot and mounted as pflash unit 1
|
||||
// so EFI variables (BootOrder, BootNext, secure-boot state) survive reboots.
|
||||
// firmware package. CODE is mounted readonly as pflash unit 0; VARS seeds the
|
||||
// instance's variable store, which is mounted as pflash unit 1 so EFI
|
||||
// variables (BootOrder, BootNext, secure-boot state) survive reboots.
|
||||
type FirmwareCandidate struct {
|
||||
Code string
|
||||
VarsTemplate string
|
||||
@@ -48,3 +55,67 @@ func FirmwarePaths(arch string) (code, varsTemplate string, varsSize int64, err
|
||||
}
|
||||
return "", "", 0, fmt.Errorf("no UEFI firmware found for architecture %q; install ovmf (x86_64) or qemu-efi-aarch64 (arm64)", arch)
|
||||
}
|
||||
|
||||
// NodeFirmware is the UEFI firmware this node runs for one architecture: the
|
||||
// candidate FirmwarePaths matched and the identity a variable store is
|
||||
// checked against.
|
||||
type NodeFirmware struct {
|
||||
Code string
|
||||
VarsTemplate string
|
||||
Identity uefistore.FirmwareIdentity
|
||||
}
|
||||
|
||||
// codeHashKey identifies one version of a CODE blob on disk, so a package
|
||||
// update that replaces the file is hashed again.
|
||||
type codeHashKey struct {
|
||||
path string
|
||||
size int64
|
||||
modTime time.Time
|
||||
}
|
||||
|
||||
// codeHashes caches CODE blob hashes by codeHashKey. The arm64 blob is 64 MiB,
|
||||
// and every UEFI launch needs its hash.
|
||||
var codeHashes sync.Map
|
||||
|
||||
// NodeFirmwareFor returns the firmware this node runs for arch, with the
|
||||
// SHA-256 of its CODE blob.
|
||||
func NodeFirmwareFor(arch string) (NodeFirmware, error) {
|
||||
code, vars, varsSize, err := FirmwarePaths(arch)
|
||||
if err != nil {
|
||||
return NodeFirmware{}, err
|
||||
}
|
||||
sum, err := codeSHA256(code)
|
||||
if err != nil {
|
||||
return NodeFirmware{}, err
|
||||
}
|
||||
return NodeFirmware{
|
||||
Code: code,
|
||||
VarsTemplate: vars,
|
||||
Identity: uefistore.FirmwareIdentity{Arch: arch, VarsSize: varsSize, CodeSHA256: sum},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func codeSHA256(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open UEFI CODE %s: %w", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stat UEFI CODE %s: %w", path, err)
|
||||
}
|
||||
key := codeHashKey{path: path, size: info.Size(), modTime: info.ModTime()}
|
||||
if cached, ok := codeHashes.Load(key); ok {
|
||||
if sum, ok := cached.(string); ok {
|
||||
return sum, nil
|
||||
}
|
||||
}
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", fmt.Errorf("hash UEFI CODE %s: %w", path, err)
|
||||
}
|
||||
sum := hex.EncodeToString(h.Sum(nil))
|
||||
codeHashes.Store(key, sum)
|
||||
return sum, nil
|
||||
}
|
||||
|
||||
@@ -92,6 +92,9 @@ func (m *Manager) Start(ctx context.Context, id string) error {
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: %s", ErrInstanceNotFound, id)
|
||||
}
|
||||
if err := CheckUEFIVarStore(instance); err != nil {
|
||||
return err
|
||||
}
|
||||
// StateError is rejected by launchStillValid; move to Pending first so
|
||||
// launch proceeds (resource re-allocation is the caller's responsibility).
|
||||
if m.Status(instance) == StateError && m.deps.TransitionState != nil {
|
||||
@@ -127,6 +130,13 @@ func (m *Manager) launch(ctx context.Context, instance *VM) (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Before the reason below is cleared, so a refused start changes nothing.
|
||||
if err := CheckUEFIVarStore(instance); err != nil {
|
||||
slog.ErrorContext(ctx, "Refusing to launch a UEFI instance without a variable store",
|
||||
"instanceId", instance.ID, "err", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// A reason left by the stop, or by a restore that found this node could not
|
||||
// schedule the instance, describes a state the instance is now leaving.
|
||||
// Clearing it here keeps the node the only writer of the field.
|
||||
@@ -148,6 +158,16 @@ func (m *Manager) launch(ctx context.Context, instance *VM) (err error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Before the mount, so a store this node cannot boot fails the launch
|
||||
// with nothing to roll back.
|
||||
uefiCtx, uefiSpan := otel.Tracer(vmTracerName).Start(ctx, "vm.launch.uefi_var_store")
|
||||
uefiErr := m.prepareUEFIVarStore(uefiCtx, instance)
|
||||
endSpanWithError(uefiSpan, uefiErr)
|
||||
if uefiErr != nil {
|
||||
slog.ErrorContext(ctx, "Failed to prepare the UEFI variable store", "instanceId", instance.ID, "err", uefiErr)
|
||||
return uefiErr
|
||||
}
|
||||
|
||||
_, mountSpan := otel.Tracer(vmTracerName).Start(ctx, "vm.launch.mount_volumes")
|
||||
mountErr := m.deps.VolumeMounter.Mount(ctx, instance)
|
||||
endSpanWithError(mountSpan, mountErr)
|
||||
@@ -249,10 +269,6 @@ func (m *Manager) markAttachedVolumesInUse(instance *VM) error {
|
||||
instance.EBSRequests.Mu.Unlock()
|
||||
|
||||
for _, ebsReq := range requests {
|
||||
// EFI pflash is not a KV-tracked EBS volume; skip it like the unmount gate.
|
||||
if ebsReq.EFI {
|
||||
continue
|
||||
}
|
||||
if m.deps.VolumeStateUpdater == nil {
|
||||
return errors.New("volume state updater not wired")
|
||||
}
|
||||
@@ -324,6 +340,14 @@ func (m *Manager) startQEMU(instance *VM, appendConsole bool) error {
|
||||
instance.Config.IOThreads = append(instance.Config.IOThreads, driveCfg.IOThreads...)
|
||||
instance.Config.Devices = append(instance.Config.Devices, driveCfg.Devices...)
|
||||
instance.Config.Blockdevs = append(instance.Config.Blockdevs, driveCfg.Blockdevs...)
|
||||
|
||||
varsDrive, err := m.uefiVarsDrive(instance)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if varsDrive != nil {
|
||||
instance.Config.Drives = append(instance.Config.Drives, *varsDrive)
|
||||
}
|
||||
}
|
||||
|
||||
if instance.DirectBoot {
|
||||
@@ -1322,7 +1346,7 @@ func buildBaseVMConfig(instanceID, instanceType, pidFile, consoleLogPath, serial
|
||||
CPUCount: vCPUs,
|
||||
Architecture: architecture,
|
||||
InstanceType: instanceType,
|
||||
UseUEFI: bootMode == "uefi" || bootMode == "uefi-preferred",
|
||||
UseUEFI: isUEFIBootMode(bootMode),
|
||||
|
||||
// Present EC2-shaped DMI so stock cloud-init activates the Ec2 datasource.
|
||||
SMBIOSUUID: ec2SMBIOSUUID(instanceID),
|
||||
@@ -1415,7 +1439,8 @@ func buildDiskBootDrives(requests []EBSRequest, cpuCount int, machineType string
|
||||
//
|
||||
// The boot volume is a -blockdev node named "os" under a bootindex=1 device
|
||||
// that carries werror/rerror; it is never detached, so it keeps fixed names.
|
||||
// EFI volumes emit pflash unit=1; Config.Execute adds the CODE blob (unit=0).
|
||||
// The UEFI variable store is not a volume: uefiVarsDrive adds it as pflash
|
||||
// unit=1, and Config.Execute adds the CODE blob (unit=0).
|
||||
//
|
||||
// Every other (data) volume is given the same named node-name/device-id/
|
||||
// iothread-id shape AttachVolume's hot-plug path uses, via a -blockdev entry
|
||||
@@ -1440,10 +1465,6 @@ func buildDrives(requests []EBSRequest, cpuCount int, machineType string) (drive
|
||||
}
|
||||
|
||||
switch {
|
||||
case kind == volumeExportBlock && v.EFI:
|
||||
// The VARS store is a pflash -drive, which has no host_device form.
|
||||
return driveConfig{}, fmt.Errorf("volume %s: block device export is not supported for an EFI volume", v.Name)
|
||||
|
||||
case v.Boot:
|
||||
blockdev, err := exportBlockdev(*v, kind, bootNodeName, bootNBDBlockdev)
|
||||
if err != nil {
|
||||
@@ -1464,18 +1485,10 @@ func buildDrives(requests []EBSRequest, cpuCount int, machineType string) (drive
|
||||
cfg.Blockdevs = append(cfg.Blockdevs, blockdev)
|
||||
cfg.Devices = append(cfg.Devices, dev)
|
||||
|
||||
case v.EFI:
|
||||
cfg.Drives = append(cfg.Drives, Drive{
|
||||
File: v.NBDURI,
|
||||
Format: "raw",
|
||||
If: "pflash",
|
||||
Unit: 1,
|
||||
})
|
||||
|
||||
default:
|
||||
// microvm has no PCIe root ports, so its data volumes take none.
|
||||
microvm := IsMicrovm(machineType)
|
||||
// A data (non-boot, non-EFI) volume. Allocate a stable hot-plug
|
||||
// A data (non-boot) volume. Allocate a stable hot-plug
|
||||
// port when the persisted state predates port accounting, or the
|
||||
// volume was attached at RunInstances time without one;
|
||||
// freeHotplugEBSPort re-scans requests as mutated so far in this
|
||||
|
||||
@@ -156,16 +156,6 @@ func TestBuildDrives(t *testing.T) {
|
||||
{Value: `{"driver":"virtio-blk-pci","drive":"os","num-queues":4,"bootindex":1,"iothread-vq-mapping":[{"iothread":"ioth-os-0","vqs":[0,2]},{"iothread":"ioth-os-1","vqs":[1,3]}],"werror":"stop","rerror":"stop"}`},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "EFI volume emits pflash unit=1",
|
||||
requests: []EBSRequest{
|
||||
{Name: "vol-efi", NBDURI: "nbd:unix:/tmp/efi.sock", EFI: true},
|
||||
},
|
||||
cpuCount: 2,
|
||||
wantDrives: []Drive{
|
||||
{File: "nbd:unix:/tmp/efi.sock", Format: "raw", If: "pflash", Unit: 1},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing NBDURI returns error",
|
||||
requests: []EBSRequest{
|
||||
@@ -174,14 +164,6 @@ func TestBuildDrives(t *testing.T) {
|
||||
cpuCount: 2,
|
||||
wantErr: "NBDURI not set for volume vol-bad",
|
||||
},
|
||||
{
|
||||
name: "missing NBDURI on EFI returns error",
|
||||
requests: []EBSRequest{
|
||||
{Name: "vol-efi-bad", EFI: true},
|
||||
},
|
||||
cpuCount: 2,
|
||||
wantErr: "NBDURI not set for volume vol-efi-bad",
|
||||
},
|
||||
{
|
||||
name: "data volume with a recorded HotplugPort keeps it and emits a named blockdev+device",
|
||||
requests: []EBSRequest{
|
||||
@@ -249,19 +231,15 @@ func TestBuildDrives(t *testing.T) {
|
||||
wantErr: "no free EBS hot-plug port for volume vol-overflow",
|
||||
},
|
||||
{
|
||||
name: "mixed boot + EFI + data: boot is a -blockdev node and EFI keeps the pflash -drive",
|
||||
name: "mixed boot + data: boot is a -blockdev node and no volume is a pflash -drive",
|
||||
requests: []EBSRequest{
|
||||
{Name: "vol-boot", NBDURI: "nbd:unix:/tmp/boot.sock", Boot: true},
|
||||
{Name: "vol-efi", NBDURI: "nbd:unix:/tmp/efi.sock", EFI: true},
|
||||
{Name: "vol-data-a", NBDURI: "nbd:unix:/tmp/data-a.sock", HotplugPort: 3},
|
||||
},
|
||||
// 2 vCPUs, so the boot disk keeps the legacy scalar shape and this
|
||||
// case still asserts what its name says it does. Data volumes are
|
||||
// unaffected by the pool either way — they keep one IOThread each.
|
||||
cpuCount: 2,
|
||||
wantDrives: []Drive{
|
||||
{File: "nbd:unix:/tmp/efi.sock", Format: "raw", If: "pflash", Unit: 1},
|
||||
},
|
||||
wantIOThreads: []IOThread{
|
||||
{ID: "ioth-os"},
|
||||
{ID: "ioth-vol-data-a"},
|
||||
@@ -274,7 +252,7 @@ func TestBuildDrives(t *testing.T) {
|
||||
{Value: "virtio-blk-pci,drive=os,iothread=ioth-os,num-queues=2,bootindex=1,werror=stop,rerror=stop"},
|
||||
{Value: "virtio-blk-pci,id=vdisk-vol-data-a,drive=nbd-vol-data-a,iothread=ioth-vol-data-a,serial=voldataa,bus=hotplug-ebs3,werror=stop,rerror=stop"},
|
||||
},
|
||||
wantHotplugPorts: []int{0, 0, 3},
|
||||
wantHotplugPorts: []int{0, 3},
|
||||
},
|
||||
{
|
||||
name: "block data volume emits a raw node over host_device with the same device wiring",
|
||||
@@ -409,12 +387,6 @@ func TestBuildDrives(t *testing.T) {
|
||||
cpuCount: 2,
|
||||
wantErr: "parse NBDURI for volume vol-boot",
|
||||
},
|
||||
{
|
||||
name: "block EFI volume is rejected",
|
||||
requests: []EBSRequest{{Name: "vol-efi", BlockDevice: "/dev/sdx", BlockSizeBytes: 1 << 20, EFI: true}},
|
||||
cpuCount: 2,
|
||||
wantErr: "volume vol-efi: block device export is not supported for an EFI volume",
|
||||
},
|
||||
{
|
||||
name: "both NBDURI and BlockDevice set is rejected",
|
||||
requests: []EBSRequest{{Name: "vol-both", NBDURI: "nbd:unix:/tmp/x.sock", BlockDevice: "/dev/sdx", BlockSizeBytes: 1 << 20}},
|
||||
|
||||
@@ -87,6 +87,9 @@ type Deps struct {
|
||||
// (predastore + viperblock) is confirmed up. Nil is treated as ready,
|
||||
// preserving behaviour for tests and nodes with storage unwired.
|
||||
BackingStoreReady func() bool
|
||||
|
||||
// UEFIVarStore is where UEFI variable stores are read from and kept.
|
||||
UEFIVarStore UEFIVarStoreDeps
|
||||
}
|
||||
|
||||
// Manager owns the in-memory map of running VMs on this node and every
|
||||
|
||||
@@ -106,6 +106,10 @@ type InstanceStatus struct {
|
||||
EBSRequests []EBSRequest `json:"ebs_requests,omitempty"`
|
||||
ENIAvailableSlots []int `json:"eni_available_slots,omitempty"`
|
||||
ENIAttachedByID map[string]int `json:"eni_attached_by_id,omitempty"`
|
||||
|
||||
// UEFIVarStore is optional, so records written without it still decode
|
||||
// and the bucket schema does not change.
|
||||
UEFIVarStore *UEFIVarStoreRef `json:"uefi_var_store,omitempty"`
|
||||
}
|
||||
|
||||
// Record projects a VM onto the persisted record. QMPClient and the request
|
||||
@@ -166,6 +170,7 @@ func (v *VM) Record() *InstanceRecord {
|
||||
EBSRequests: v.EBSRequests.Requests,
|
||||
ENIAvailableSlots: v.ENIRequests.AvailableSlots,
|
||||
ENIAttachedByID: v.ENIRequests.AttachedByENIID,
|
||||
UEFIVarStore: v.UEFIVarStore,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -222,6 +227,7 @@ func VMFromRecord(r *InstanceRecord) *VM {
|
||||
TerminatedAt: r.Status.TerminatedAt,
|
||||
ShuttingDownAt: r.Status.ShuttingDownAt,
|
||||
FirstLaunch: r.Status.FirstLaunch,
|
||||
UEFIVarStore: r.Status.UEFIVarStore,
|
||||
}
|
||||
v.EBSRequests.Requests = r.Status.EBSRequests
|
||||
v.ENIRequests.AvailableSlots = r.Status.ENIAvailableSlots
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ec2/instance/uefistore"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/compute/gpu"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/compute/qmp"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
|
||||
@@ -114,6 +115,37 @@ func TestRecord_RoundTripThroughJSON(t *testing.T) {
|
||||
assertVMFieldsEqual(t, original, vm.VMFromRecord(&decoded))
|
||||
}
|
||||
|
||||
// The pointer is optional: a record written before it existed decodes with
|
||||
// none, and a record without one writes no key, so the bucket schema holds.
|
||||
func TestRecord_UEFIVarStoreIsOptional(t *testing.T) {
|
||||
var decoded vm.InstanceRecord
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"metadata":{"name":"i-old"},"spec":{"config":{}},"status":{"status":"stopped"}}`), &decoded))
|
||||
assert.Nil(t, decoded.Status.UEFIVarStore)
|
||||
|
||||
encoded, err := json.Marshal((&vm.VM{ID: "i-bios"}).Record())
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, string(encoded), "uefi_var_store")
|
||||
}
|
||||
|
||||
// The pointer's wire names are what the record carries across releases, so
|
||||
// they are pinned rather than left to follow the Go field names.
|
||||
func TestRecord_UEFIVarStoreWireShape(t *testing.T) {
|
||||
encoded, err := json.Marshal(populatedVM().Record())
|
||||
require.NoError(t, err)
|
||||
|
||||
var wire struct {
|
||||
Status struct {
|
||||
UEFIVarStore map[string]json.RawMessage `json:"uefi_var_store"`
|
||||
} `json:"status"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(encoded, &wire))
|
||||
ref := wire.Status.UEFIVarStore
|
||||
assert.JSONEq(t, `"uefi/i-round-trip/0d6f3c1e-5b7a-4f43-9a8e-2c1d4e5f6a7b"`, string(ref["key"]))
|
||||
assert.JSONEq(t, `{"arch":"arm64","vars_size":67108864,"code_sha256":"ab12"}`, string(ref["firmware"]))
|
||||
assert.JSONEq(t, `"node-3"`, string(ref["writer_node"]))
|
||||
assert.JSONEq(t, `"2026-08-28T04:00:00Z"`, string(ref["written_at"]))
|
||||
}
|
||||
|
||||
// The mutexes are dropped on the way out, so the rebuilt instance must arrive
|
||||
// with usable zero-value locks rather than ones somebody else left held.
|
||||
func TestVMFromRecord_RequestWrappersAreUsable(t *testing.T) {
|
||||
@@ -206,6 +238,12 @@ func populatedVM() *vm.VM {
|
||||
CapacityReservationId: "cr-1",
|
||||
InstanceLifecycle: "spot",
|
||||
SpotInstanceRequestId: "sir-1",
|
||||
UEFIVarStore: &vm.UEFIVarStoreRef{
|
||||
Key: "uefi/i-round-trip/0d6f3c1e-5b7a-4f43-9a8e-2c1d4e5f6a7b",
|
||||
Firmware: uefistore.FirmwareIdentity{Arch: "arm64", VarsSize: 67108864, CodeSHA256: "ab12"},
|
||||
WriterNode: "node-3",
|
||||
WrittenAt: time.Date(2026, 8, 28, 4, 0, 0, 0, time.UTC),
|
||||
},
|
||||
|
||||
Status: vm.StateRunning,
|
||||
Instance: &ec2.Instance{InstanceId: aws.String("i-round-trip")},
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
package vm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ec2/instance/uefistore"
|
||||
otelsetup "github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
|
||||
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
)
|
||||
|
||||
const (
|
||||
// uefiStoreDirName is the directory under DataDir that holds one
|
||||
// subdirectory per instance with its live variable store.
|
||||
uefiStoreDirName = "uefi"
|
||||
uefiVarsFileName = "vars.fd"
|
||||
uefiSeededFileName = "seeded.json"
|
||||
|
||||
// uefiStoreGetTimeout bounds the one get a launch makes for a fixed-size
|
||||
// object, so a wedged object store fails the start rather than holding it.
|
||||
uefiStoreGetTimeout = 10 * time.Second
|
||||
|
||||
// uefiStoreBodySlack is how far a compressed body may exceed its region
|
||||
// before it is refused; gzip grows incompressible data by far less.
|
||||
uefiStoreBodySlack = 1 << 20
|
||||
|
||||
// uefiStateReasonCode is the StateReason a launch refused over its
|
||||
// variable store records, as EC2 reports an internal launch failure.
|
||||
uefiStateReasonCode = "Server.InternalError"
|
||||
)
|
||||
|
||||
// ErrUEFIVarStoreMissing is a UEFI instance whose record names no variable
|
||||
// store, so it has nothing to boot its variables from.
|
||||
var ErrUEFIVarStoreMissing = errors.New("UEFI instance has no variable store")
|
||||
|
||||
// ErrUEFIFirmwareMismatch is a variable store written under firmware whose
|
||||
// architecture or VARS size differs from this node's, which pflash cannot load.
|
||||
var ErrUEFIFirmwareMismatch = errors.New("UEFI variable store firmware does not match this node")
|
||||
|
||||
// ErrUEFIVarStoreUnusable is a variable store object that is missing or does
|
||||
// not decode to a region of this node's VARS size. Retrying does not help.
|
||||
var ErrUEFIVarStoreUnusable = errors.New("UEFI variable store object is unusable")
|
||||
|
||||
// UEFIVarStoreDeps is where a node reads instance variable stores from and
|
||||
// keeps the live copy QEMU opens. DataDir is the daemon's persistent data
|
||||
// directory, so the copy survives a daemon restart and a host reboot.
|
||||
type UEFIVarStoreDeps struct {
|
||||
Objects objectstore.ObjectStore
|
||||
Bucket string
|
||||
DataDir string
|
||||
}
|
||||
|
||||
// uefiSeed is seeded.json: the pointer key vars.fd beside it was seeded from.
|
||||
type uefiSeed struct {
|
||||
Key string `json:"key"`
|
||||
SeededAt time.Time `json:"seeded_at"`
|
||||
}
|
||||
|
||||
// isUEFIBootMode reports whether bootMode boots the guest through UEFI firmware.
|
||||
func isUEFIBootMode(bootMode string) bool {
|
||||
return bootMode == "uefi" || bootMode == "uefi-preferred"
|
||||
}
|
||||
|
||||
// UsesUEFIVarStore reports whether v boots through UEFI and so needs a
|
||||
// variable store. A direct-boot microvm boots qboot and has none.
|
||||
func UsesUEFIVarStore(v *VM) bool {
|
||||
return !v.DirectBoot && isUEFIBootMode(v.BootMode)
|
||||
}
|
||||
|
||||
// CheckUEFIVarStore refuses a UEFI instance whose record names no variable
|
||||
// store, before anything about it is changed. The caller owns v.
|
||||
func CheckUEFIVarStore(v *VM) error {
|
||||
if UsesUEFIVarStore(v) && v.UEFIVarStore == nil {
|
||||
return fmt.Errorf("%w: %s", ErrUEFIVarStoreMissing, v.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// uefiInstanceDir is the directory holding instanceID's vars.fd and
|
||||
// seeded.json. An ID that is not a single path segment is refused.
|
||||
func uefiInstanceDir(dataDir, instanceID string) (string, error) {
|
||||
if !strings.HasPrefix(instanceID, "i-") || strings.ContainsAny(instanceID, `/\`) || instanceID != filepath.Clean(instanceID) {
|
||||
return "", fmt.Errorf("instance ID %q cannot name a UEFI store directory", instanceID)
|
||||
}
|
||||
return filepath.Join(dataDir, uefiStoreDirName, instanceID), nil
|
||||
}
|
||||
|
||||
// uefiVarsPath is the live variable store QEMU opens as pflash unit 1.
|
||||
func (m *Manager) uefiVarsPath(instanceID string) (string, error) {
|
||||
dir, err := uefiInstanceDir(m.deps.UEFIVarStore.DataDir, instanceID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, uefiVarsFileName), nil
|
||||
}
|
||||
|
||||
// uefiVarsDrive is pflash unit 1 from this node's copy of the store the
|
||||
// record points at, or nil for a guest that boots without UEFI. Launch seeds
|
||||
// the copy and a reboot reuses it, so a missing one fails the boot.
|
||||
func (m *Manager) uefiVarsDrive(instance *VM) (*Drive, error) {
|
||||
if !UsesUEFIVarStore(instance) {
|
||||
return nil, nil
|
||||
}
|
||||
hasPointer := false
|
||||
m.Inspect(instance, func(v *VM) { hasPointer = v.UEFIVarStore != nil })
|
||||
if !hasPointer {
|
||||
return nil, fmt.Errorf("%w: %s", ErrUEFIVarStoreMissing, instance.ID)
|
||||
}
|
||||
path, err := m.uefiVarsPath(instance.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return nil, fmt.Errorf("UEFI variable store for %s: %w", instance.ID, err)
|
||||
}
|
||||
return &Drive{File: path, Format: "raw", If: "pflash", Unit: 1}, nil
|
||||
}
|
||||
|
||||
// prepareUEFIVarStore leaves vars.fd holding the store the record points at,
|
||||
// reusing this node's copy when seeded.json names the pointer and fetching
|
||||
// the object otherwise. The object and the record's pointer are only read.
|
||||
func (m *Manager) prepareUEFIVarStore(ctx context.Context, instance *VM) error {
|
||||
var ref *UEFIVarStoreRef
|
||||
m.Inspect(instance, func(v *VM) {
|
||||
if v.UEFIVarStore != nil {
|
||||
cp := *v.UEFIVarStore
|
||||
ref = &cp
|
||||
}
|
||||
})
|
||||
if ref == nil || !UsesUEFIVarStore(instance) {
|
||||
return nil
|
||||
}
|
||||
|
||||
deps := m.deps.UEFIVarStore
|
||||
if deps.Objects == nil || deps.Bucket == "" || deps.DataDir == "" {
|
||||
return errors.New("UEFI variable store access is not wired on this node")
|
||||
}
|
||||
if name, ok := strings.CutPrefix(ref.Key, uefistore.KeyPrefix(instance.ID)); !ok || name == "" || strings.Contains(name, "/") {
|
||||
return fmt.Errorf("%w: pointer %q is not under %s", ErrUEFIVarStoreUnusable, ref.Key, uefistore.KeyPrefix(instance.ID))
|
||||
}
|
||||
if m.deps.InstanceTypes == nil {
|
||||
return errors.New("InstanceTypeResolver not wired")
|
||||
}
|
||||
spec, ok := m.deps.InstanceTypes.Resolve(instance.InstanceType)
|
||||
if !ok {
|
||||
return fmt.Errorf("instance type %s not found", instance.InstanceType)
|
||||
}
|
||||
fw, err := NodeFirmwareFor(spec.Architecture)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.checkUEFIFirmware(instance, ref, fw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dir, err := uefiInstanceDir(deps.DataDir, instance.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if reuseUEFIVars(dir, ref.Key, fw.Identity.VarsSize) {
|
||||
recordUEFIStoreSeed("local")
|
||||
slog.InfoContext(ctx, "Reusing this node's UEFI variable store", "instanceId", instance.ID, "key", ref.Key)
|
||||
return nil
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
region, err := fetchUEFIVars(ctx, deps, ref.Key, fw.Identity.VarsSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFileAtomic(dir, uefiVarsFileName, region); err != nil {
|
||||
return fmt.Errorf("write UEFI variable store for %s: %w", instance.ID, err)
|
||||
}
|
||||
seed, err := json.Marshal(uefiSeed{Key: ref.Key, SeededAt: time.Now().UTC()})
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode %s for %s: %w", uefiSeededFileName, instance.ID, err)
|
||||
}
|
||||
if err := writeFileAtomic(dir, uefiSeededFileName, seed); err != nil {
|
||||
return fmt.Errorf("write %s for %s: %w", uefiSeededFileName, instance.ID, err)
|
||||
}
|
||||
recordUEFIStoreSeed("object")
|
||||
slog.InfoContext(ctx, "Seeded UEFI variable store from the object store",
|
||||
"instanceId", instance.ID, "key", ref.Key, "bytes", len(region), "fetch_ms", otelsetup.Millis(time.Since(start)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkUEFIFirmware fails a store whose arch or VARS size differs from this
|
||||
// node's, naming both firmwares in the log and the StateReason. A different
|
||||
// CODE build boots, because a package update changes it on every node.
|
||||
func (m *Manager) checkUEFIFirmware(instance *VM, ref *UEFIVarStoreRef, fw NodeFirmware) error {
|
||||
stored, node := ref.Firmware, fw.Identity
|
||||
var kind string
|
||||
switch {
|
||||
case stored.Arch != node.Arch:
|
||||
kind = "arch"
|
||||
case stored.VarsSize != node.VarsSize:
|
||||
kind = "vars_size"
|
||||
case stored.CodeSHA256 != node.CodeSHA256:
|
||||
recordUEFIFirmwareMismatch("code")
|
||||
slog.Warn("UEFI variable store was written under a different CODE build; booting it",
|
||||
"instanceId", instance.ID, "key", ref.Key, "writerNode", ref.WriterNode,
|
||||
"storeCodeSha256", stored.CodeSHA256, "nodeCodeSha256", node.CodeSHA256, "nodeCodePath", fw.Code)
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
recordUEFIFirmwareMismatch(kind)
|
||||
msg := fmt.Sprintf("%s: the UEFI variable store %s was written under firmware arch=%s vars_size=%d code_sha256=%s, and this node runs arch=%s vars_size=%d code_sha256=%s (%s)",
|
||||
uefiStateReasonCode, ref.Key, stored.Arch, stored.VarsSize, stored.CodeSHA256,
|
||||
node.Arch, node.VarsSize, node.CodeSHA256, fw.Code)
|
||||
m.Inspect(instance, func(v *VM) {
|
||||
if v.Instance != nil {
|
||||
v.Instance.StateReason = &ec2.StateReason{Code: aws.String(uefiStateReasonCode), Message: aws.String(msg)}
|
||||
}
|
||||
})
|
||||
slog.Error("UEFI variable store firmware does not match this node; refusing the launch",
|
||||
"instanceId", instance.ID, "key", ref.Key, "writerNode", ref.WriterNode, "mismatch", kind,
|
||||
"storeArch", stored.Arch, "storeVarsSize", stored.VarsSize, "storeCodeSha256", stored.CodeSHA256,
|
||||
"nodeArch", node.Arch, "nodeVarsSize", node.VarsSize, "nodeCodeSha256", node.CodeSHA256, "nodeCodePath", fw.Code)
|
||||
return fmt.Errorf("%w: %s", ErrUEFIFirmwareMismatch, msg)
|
||||
}
|
||||
|
||||
// reuseUEFIVars reports whether dir already holds the store key names: a
|
||||
// seeded.json naming key beside a vars.fd of the region's size.
|
||||
func reuseUEFIVars(dir, key string, varsSize int64) bool {
|
||||
data, err := os.ReadFile(filepath.Join(dir, uefiSeededFileName))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
var seed uefiSeed
|
||||
if err := json.Unmarshal(data, &seed); err != nil || seed.Key != key {
|
||||
return false
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(dir, uefiVarsFileName))
|
||||
if err != nil || info.Size() != varsSize {
|
||||
slog.Warn("UEFI variable store seeded here has no usable vars.fd; fetching it again", "dir", dir, "key", key)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// fetchUEFIVars gets key and returns its decompressed region, which must be
|
||||
// exactly varsSize bytes. A failed get is retryable; a missing or malformed
|
||||
// object is not.
|
||||
func fetchUEFIVars(ctx context.Context, deps UEFIVarStoreDeps, key string, varsSize int64) ([]byte, error) {
|
||||
getCtx, cancel := context.WithTimeout(ctx, uefiStoreGetTimeout)
|
||||
defer cancel()
|
||||
|
||||
out, err := deps.Objects.GetObject(getCtx, &s3.GetObjectInput{Bucket: aws.String(deps.Bucket), Key: aws.String(key)})
|
||||
if err != nil {
|
||||
if objectstore.IsNoSuchKeyError(err) {
|
||||
return nil, fmt.Errorf("%w: %s does not exist", ErrUEFIVarStoreUnusable, key)
|
||||
}
|
||||
return nil, fmt.Errorf("get UEFI variable store %s: %w: %w", key, err, ErrMountRetryable)
|
||||
}
|
||||
defer out.Body.Close()
|
||||
|
||||
limit := varsSize + uefiStoreBodySlack
|
||||
raw, err := io.ReadAll(io.LimitReader(out.Body, limit+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read UEFI variable store %s: %w: %w", key, err, ErrMountRetryable)
|
||||
}
|
||||
if int64(len(raw)) > limit {
|
||||
return nil, fmt.Errorf("%w: %s is over %d bytes compressed", ErrUEFIVarStoreUnusable, key, limit)
|
||||
}
|
||||
|
||||
gz, err := gzip.NewReader(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s is not gzip: %w", ErrUEFIVarStoreUnusable, key, err)
|
||||
}
|
||||
region, err := io.ReadAll(io.LimitReader(gz, varsSize+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s does not decompress: %w", ErrUEFIVarStoreUnusable, key, err)
|
||||
}
|
||||
if int64(len(region)) != varsSize {
|
||||
return nil, fmt.Errorf("%w: %s holds a %d-byte region, and this node's VARS template is %d bytes",
|
||||
ErrUEFIVarStoreUnusable, key, len(region), varsSize)
|
||||
}
|
||||
return region, nil
|
||||
}
|
||||
|
||||
// writeFileAtomic replaces dir/name with data by temporary file and rename,
|
||||
// syncing both, so a crash leaves the old file or the new one.
|
||||
func writeFileAtomic(dir, name string, data []byte) error {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, "."+name+".*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpName, filepath.Join(dir, name)); err != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
d, err := os.Open(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer d.Close()
|
||||
return d.Sync()
|
||||
}
|
||||
|
||||
// uefiStoreMeterName is the instrumentation scope for variable store launches.
|
||||
const uefiStoreMeterName = "github.com/mulgadc/spinifex/spinifex/runtime/compute/vm/uefistore"
|
||||
|
||||
var (
|
||||
uefiStoreMetricsOnce sync.Once
|
||||
uefiStoreSeedCounter metric.Int64Counter
|
||||
uefiFirmwareMismatches metric.Int64Counter
|
||||
)
|
||||
|
||||
func uefiStoreMetrics() {
|
||||
uefiStoreMetricsOnce.Do(func() {
|
||||
meter := otel.Meter(uefiStoreMeterName)
|
||||
var err error
|
||||
uefiStoreSeedCounter, err = meter.Int64Counter("mulga.vm.uefi_store.seeds",
|
||||
metric.WithDescription("UEFI launches by where their variable store came from: this node's copy or the object store."),
|
||||
metric.WithUnit("{launch}"))
|
||||
if err != nil {
|
||||
otel.Handle(err)
|
||||
}
|
||||
uefiFirmwareMismatches, err = meter.Int64Counter("mulga.vm.uefi_store.firmware_mismatches",
|
||||
metric.WithDescription("UEFI launches whose variable store was written under different firmware, by what differed. Only kind=code boots."),
|
||||
metric.WithUnit("{launch}"))
|
||||
if err != nil {
|
||||
otel.Handle(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func recordUEFIStoreSeed(source string) {
|
||||
uefiStoreMetrics()
|
||||
if uefiStoreSeedCounter != nil {
|
||||
uefiStoreSeedCounter.Add(context.Background(), 1, metric.WithAttributes(attribute.String("source", source)))
|
||||
}
|
||||
}
|
||||
|
||||
func recordUEFIFirmwareMismatch(kind string) {
|
||||
uefiStoreMetrics()
|
||||
if uefiFirmwareMismatches != nil {
|
||||
uefiFirmwareMismatches.Add(context.Background(), 1, metric.WithAttributes(attribute.String("kind", kind)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
package vm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ec2/instance/uefistore"
|
||||
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/metric/metricdata"
|
||||
)
|
||||
|
||||
const (
|
||||
testUEFIBucket = "instances"
|
||||
testUEFIVarsSize = 4096
|
||||
testUEFIInstance = "i-0123456789abcdef0"
|
||||
)
|
||||
|
||||
var (
|
||||
uefiMetricsReaderOnce sync.Once
|
||||
uefiMetricsReader *sdkmetric.ManualReader
|
||||
)
|
||||
|
||||
// uefiCounter returns the sum of the named counter's points whose attribute
|
||||
// key has value.
|
||||
func uefiCounter(t *testing.T, name, key, value string) int64 {
|
||||
t.Helper()
|
||||
uefiMetricsReaderOnce.Do(func() {
|
||||
uefiMetricsReader = sdkmetric.NewManualReader()
|
||||
otel.SetMeterProvider(sdkmetric.NewMeterProvider(sdkmetric.WithReader(uefiMetricsReader)))
|
||||
})
|
||||
var rm metricdata.ResourceMetrics
|
||||
require.NoError(t, uefiMetricsReader.Collect(context.Background(), &rm))
|
||||
var total int64
|
||||
for _, sm := range rm.ScopeMetrics {
|
||||
for _, m := range sm.Metrics {
|
||||
sum, ok := m.Data.(metricdata.Sum[int64])
|
||||
if m.Name != name || !ok {
|
||||
continue
|
||||
}
|
||||
for _, dp := range sum.DataPoints {
|
||||
if v, ok := dp.Attributes.Value(attribute.Key(key)); ok && v.AsString() == value {
|
||||
total += dp.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// uefiFixture is a node with one x86_64 firmware candidate, an in-memory
|
||||
// object store and a data directory.
|
||||
type uefiFixture struct {
|
||||
m *Manager
|
||||
mounter *fakeVolumeMounter
|
||||
objects *objectstore.MemoryObjectStore
|
||||
dataDir string
|
||||
codePath string
|
||||
identity uefistore.FirmwareIdentity
|
||||
}
|
||||
|
||||
func newUEFIFixture(t *testing.T) *uefiFixture {
|
||||
t.Helper()
|
||||
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
|
||||
fwDir := t.TempDir()
|
||||
code := filepath.Join(fwDir, "OVMF_CODE.fd")
|
||||
vars := filepath.Join(fwDir, "OVMF_VARS.fd")
|
||||
codeBytes := []byte("ovmf code build 1")
|
||||
require.NoError(t, os.WriteFile(code, codeBytes, 0o600))
|
||||
require.NoError(t, os.WriteFile(vars, make([]byte, testUEFIVarsSize), 0o600))
|
||||
orig := FirmwarePathCandidates
|
||||
FirmwarePathCandidates = map[string][]FirmwareCandidate{"x86_64": {{Code: code, VarsTemplate: vars}}}
|
||||
t.Cleanup(func() { FirmwarePathCandidates = orig })
|
||||
|
||||
sum := sha256.Sum256(codeBytes)
|
||||
f := &uefiFixture{
|
||||
mounter: &fakeVolumeMounter{},
|
||||
objects: objectstore.NewMemoryObjectStore(),
|
||||
dataDir: t.TempDir(),
|
||||
codePath: code,
|
||||
identity: uefistore.FirmwareIdentity{Arch: "x86_64", VarsSize: testUEFIVarsSize, CodeSHA256: hex.EncodeToString(sum[:])},
|
||||
}
|
||||
f.m = NewManagerWithDeps(Deps{
|
||||
VolumeMounter: f.mounter,
|
||||
InstanceTypes: fakeInstanceTypeResolver{"t3.nano": {Architecture: "x86_64", VCPUs: 1, MemoryMiB: 512}},
|
||||
UEFIVarStore: UEFIVarStoreDeps{Objects: f.objects, Bucket: testUEFIBucket, DataDir: f.dataDir},
|
||||
})
|
||||
return f
|
||||
}
|
||||
|
||||
// region is a VARS region of the fixture's size whose bytes say which store it is.
|
||||
func region(tag string) []byte {
|
||||
b := make([]byte, testUEFIVarsSize)
|
||||
copy(b, tag)
|
||||
return b
|
||||
}
|
||||
|
||||
func gzipped(t *testing.T, data []byte) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := gzip.NewWriter(&buf)
|
||||
_, err := zw.Write(data)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, zw.Close())
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func (f *uefiFixture) putObject(t *testing.T, key string, body []byte) {
|
||||
t.Helper()
|
||||
_, err := f.objects.PutObject(context.Background(), &s3.PutObjectInput{
|
||||
Bucket: aws.String(testUEFIBucket), Key: aws.String(key), Body: bytes.NewReader(body),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func (f *uefiFixture) objectBody(t *testing.T, key string) []byte {
|
||||
t.Helper()
|
||||
out, err := f.objects.GetObject(context.Background(), &s3.GetObjectInput{Bucket: aws.String(testUEFIBucket), Key: aws.String(key)})
|
||||
require.NoError(t, err)
|
||||
defer out.Body.Close()
|
||||
b, err := io.ReadAll(out.Body)
|
||||
require.NoError(t, err)
|
||||
return b
|
||||
}
|
||||
|
||||
func (f *uefiFixture) instanceDir() string {
|
||||
return filepath.Join(f.dataDir, "uefi", testUEFIInstance)
|
||||
}
|
||||
|
||||
func (f *uefiFixture) writeLocal(t *testing.T, seededKey string, vars []byte) {
|
||||
t.Helper()
|
||||
require.NoError(t, os.MkdirAll(f.instanceDir(), 0o700))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(f.instanceDir(), "vars.fd"), vars, 0o600))
|
||||
seed, err := json.Marshal(uefiSeed{Key: seededKey, SeededAt: time.Now()})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(f.instanceDir(), "seeded.json"), seed, 0o600))
|
||||
}
|
||||
|
||||
func (f *uefiFixture) localVars(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(filepath.Join(f.instanceDir(), "vars.fd"))
|
||||
require.NoError(t, err)
|
||||
return b
|
||||
}
|
||||
|
||||
func (f *uefiFixture) seededKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(filepath.Join(f.instanceDir(), "seeded.json"))
|
||||
require.NoError(t, err)
|
||||
var seed uefiSeed
|
||||
require.NoError(t, json.Unmarshal(b, &seed))
|
||||
return seed.Key
|
||||
}
|
||||
|
||||
func (f *uefiFixture) instance(key string, fw uefistore.FirmwareIdentity) *VM {
|
||||
v := &VM{
|
||||
ID: testUEFIInstance, InstanceType: "t3.nano", BootMode: "uefi", Status: StateStopped,
|
||||
Instance: &ec2.Instance{},
|
||||
}
|
||||
if key != "" {
|
||||
v.UEFIVarStore = &UEFIVarStoreRef{Key: key, Firmware: fw, WriterNode: "node-a", WrittenAt: time.Unix(1700000000, 0).UTC()}
|
||||
}
|
||||
f.m.Insert(v)
|
||||
return v
|
||||
}
|
||||
|
||||
func keyFor(name string) string { return uefistore.KeyPrefix(testUEFIInstance) + name }
|
||||
|
||||
func TestPrepareUEFIVarStore_FetchesTheObjectAndRecordsTheSeed(t *testing.T) {
|
||||
f := newUEFIFixture(t)
|
||||
key := keyFor("11111111-1111-1111-1111-111111111111")
|
||||
f.putObject(t, key, gzipped(t, region("converted")))
|
||||
instance := f.instance(key, f.identity)
|
||||
before := uefiCounter(t, "mulga.vm.uefi_store.seeds", "source", "object")
|
||||
|
||||
require.NoError(t, f.m.prepareUEFIVarStore(t.Context(), instance))
|
||||
|
||||
assert.Equal(t, region("converted"), f.localVars(t), "vars.fd must hold the decompressed region")
|
||||
assert.Equal(t, key, f.seededKey(t))
|
||||
assert.Equal(t, before+1, uefiCounter(t, "mulga.vm.uefi_store.seeds", "source", "object"))
|
||||
assert.Equal(t, key, instance.UEFIVarStore.Key, "seeding only reads the pointer")
|
||||
}
|
||||
|
||||
// A start on the node that seeded the pointer boots that node's copy, which
|
||||
// can be newer than the object, and makes no get at all.
|
||||
func TestPrepareUEFIVarStore_ReusesTheLocalCopyWhenSeededNamesThePointer(t *testing.T) {
|
||||
f := newUEFIFixture(t)
|
||||
key := keyFor("22222222-2222-2222-2222-222222222222")
|
||||
f.writeLocal(t, key, region("guest wrote this"))
|
||||
instance := f.instance(key, f.identity)
|
||||
before := uefiCounter(t, "mulga.vm.uefi_store.seeds", "source", "local")
|
||||
|
||||
require.NoError(t, f.m.prepareUEFIVarStore(t.Context(), instance), "no object exists, so a get would fail")
|
||||
|
||||
assert.Equal(t, region("guest wrote this"), f.localVars(t))
|
||||
assert.Equal(t, before+1, uefiCounter(t, "mulga.vm.uefi_store.seeds", "source", "local"))
|
||||
}
|
||||
|
||||
// Once the pointer has moved past what this node seeded, the local copy is
|
||||
// stale and the pointed object replaces it.
|
||||
func TestPrepareUEFIVarStore_RefetchesWhenTheLocalCopyIsNotThePointer(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
local func(f *uefiFixture, t *testing.T)
|
||||
}{
|
||||
{name: "seeded from an older pointer", local: func(f *uefiFixture, t *testing.T) {
|
||||
f.writeLocal(t, keyFor("older"), region("stale"))
|
||||
}},
|
||||
{name: "vars.fd of the wrong size", local: func(f *uefiFixture, t *testing.T) {
|
||||
f.writeLocal(t, keyFor("current"), []byte("short"))
|
||||
}},
|
||||
{name: "seeded.json unreadable", local: func(f *uefiFixture, t *testing.T) {
|
||||
f.writeLocal(t, keyFor("current"), region("stale"))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(f.instanceDir(), "seeded.json"), []byte("{"), 0o600))
|
||||
}},
|
||||
{name: "no vars.fd", local: func(f *uefiFixture, t *testing.T) {
|
||||
f.writeLocal(t, keyFor("current"), region("stale"))
|
||||
require.NoError(t, os.Remove(filepath.Join(f.instanceDir(), "vars.fd")))
|
||||
}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newUEFIFixture(t)
|
||||
key := keyFor("current")
|
||||
tt.local(f, t)
|
||||
f.putObject(t, key, gzipped(t, region("restored")))
|
||||
instance := f.instance(key, f.identity)
|
||||
|
||||
require.NoError(t, f.m.prepareUEFIVarStore(t.Context(), instance))
|
||||
|
||||
assert.Equal(t, region("restored"), f.localVars(t))
|
||||
assert.Equal(t, key, f.seededKey(t))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A store pflash cannot load fails the launch before any volume is mounted,
|
||||
// names both firmwares, and leaves the object, the pointer and the local
|
||||
// copy as they were.
|
||||
func TestRun_UEFIFirmwareMismatchFailsTheLaunchAndChangesNothing(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
kind string
|
||||
mutate func(*uefistore.FirmwareIdentity)
|
||||
want []string
|
||||
}{
|
||||
{name: "arch", kind: "arch", mutate: func(fw *uefistore.FirmwareIdentity) { fw.Arch = "arm64" },
|
||||
want: []string{"arch=arm64", "arch=x86_64"}},
|
||||
{name: "vars size", kind: "vars_size", mutate: func(fw *uefistore.FirmwareIdentity) { fw.VarsSize = 67108864 },
|
||||
want: []string{"vars_size=67108864", "vars_size=4096"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newUEFIFixture(t)
|
||||
key := keyFor("mismatch")
|
||||
body := gzipped(t, region("other firmware"))
|
||||
f.putObject(t, key, body)
|
||||
f.writeLocal(t, keyFor("older"), region("local"))
|
||||
stored := f.identity
|
||||
tt.mutate(&stored)
|
||||
instance := f.instance(key, stored)
|
||||
wantRef := *instance.UEFIVarStore
|
||||
before := uefiCounter(t, "mulga.vm.uefi_store.firmware_mismatches", "kind", tt.kind)
|
||||
|
||||
err := f.m.Run(t.Context(), instance)
|
||||
|
||||
require.ErrorIs(t, err, ErrUEFIFirmwareMismatch)
|
||||
require.NotErrorIs(t, err, ErrMountRetryable, "a mismatch does not clear with time")
|
||||
assert.Empty(t, f.mounter.mounted, "nothing is mounted for a store that cannot boot")
|
||||
require.NotNil(t, instance.Instance.StateReason)
|
||||
assert.Equal(t, "Server.InternalError", aws.StringValue(instance.Instance.StateReason.Code))
|
||||
for _, s := range append(tt.want, f.identity.CodeSHA256, f.codePath, key) {
|
||||
assert.Contains(t, aws.StringValue(instance.Instance.StateReason.Message), s)
|
||||
}
|
||||
assert.Equal(t, wantRef, *instance.UEFIVarStore, "the pointer is unchanged")
|
||||
assert.Equal(t, body, f.objectBody(t, key), "the object is unchanged")
|
||||
assert.Equal(t, region("local"), f.localVars(t), "the local copy is unchanged")
|
||||
assert.Equal(t, keyFor("older"), f.seededKey(t))
|
||||
assert.Equal(t, before+1, uefiCounter(t, "mulga.vm.uefi_store.firmware_mismatches", "kind", tt.kind))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An ovmf package update changes the CODE hash on every node, so a store
|
||||
// written under another build boots and the difference is counted.
|
||||
func TestPrepareUEFIVarStore_DifferentCodeBuildBootsAndIsCounted(t *testing.T) {
|
||||
f := newUEFIFixture(t)
|
||||
key := keyFor("code")
|
||||
f.putObject(t, key, gzipped(t, region("older ovmf")))
|
||||
stored := f.identity
|
||||
stored.CodeSHA256 = strings.Repeat("0", 64)
|
||||
instance := f.instance(key, stored)
|
||||
before := uefiCounter(t, "mulga.vm.uefi_store.firmware_mismatches", "kind", "code")
|
||||
|
||||
require.NoError(t, f.m.prepareUEFIVarStore(t.Context(), instance))
|
||||
|
||||
assert.Equal(t, region("older ovmf"), f.localVars(t))
|
||||
assert.Nil(t, instance.Instance.StateReason)
|
||||
assert.Equal(t, before+1, uefiCounter(t, "mulga.vm.uefi_store.firmware_mismatches", "kind", "code"))
|
||||
}
|
||||
|
||||
// failingGetStore fails every get, standing in for an object store that
|
||||
// does not answer.
|
||||
type failingGetStore struct {
|
||||
*objectstore.MemoryObjectStore
|
||||
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *failingGetStore) GetObject(context.Context, *s3.GetObjectInput) (*s3.GetObjectOutput, error) {
|
||||
return nil, s.err
|
||||
}
|
||||
|
||||
func TestPrepareUEFIVarStore_FailedGetIsRetryable(t *testing.T) {
|
||||
f := newUEFIFixture(t)
|
||||
f.m.deps.UEFIVarStore.Objects = &failingGetStore{MemoryObjectStore: f.objects, err: errors.New("connection refused")}
|
||||
instance := f.instance(keyFor("down"), f.identity)
|
||||
|
||||
err := f.m.prepareUEFIVarStore(t.Context(), instance)
|
||||
|
||||
require.ErrorIs(t, err, ErrMountRetryable, "restore backs off on a get as it does on a mount")
|
||||
assert.NoFileExists(t, filepath.Join(f.instanceDir(), "seeded.json"))
|
||||
}
|
||||
|
||||
// An object that is missing or does not decode to this node's region is
|
||||
// refused for good, and nothing is written locally.
|
||||
func TestPrepareUEFIVarStore_UnusableObjectIsRefused(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body []byte
|
||||
}{
|
||||
{name: "missing"},
|
||||
{name: "not gzip", body: region("raw, uncompressed")},
|
||||
{name: "short region", body: gzipped(t, make([]byte, testUEFIVarsSize-1))},
|
||||
{name: "long region", body: gzipped(t, make([]byte, testUEFIVarsSize+1))},
|
||||
{name: "truncated gzip", body: gzipped(t, region("cut"))[:20]},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newUEFIFixture(t)
|
||||
key := keyFor("bad")
|
||||
if tt.body != nil {
|
||||
f.putObject(t, key, tt.body)
|
||||
}
|
||||
instance := f.instance(key, f.identity)
|
||||
|
||||
err := f.m.prepareUEFIVarStore(t.Context(), instance)
|
||||
|
||||
require.ErrorIs(t, err, ErrUEFIVarStoreUnusable)
|
||||
require.NotErrorIs(t, err, ErrMountRetryable)
|
||||
assert.NoFileExists(t, filepath.Join(f.instanceDir(), "vars.fd"))
|
||||
assert.NoFileExists(t, filepath.Join(f.instanceDir(), "seeded.json"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareUEFIVarStore_PointerOutsideTheInstancePrefixIsRefused(t *testing.T) {
|
||||
for _, key := range []string{"uefi/i-0aaaaaaaaaaaaaaaa/other", keyFor("a/b"), uefistore.KeyPrefix(testUEFIInstance)} {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
f := newUEFIFixture(t)
|
||||
f.putObject(t, key, gzipped(t, region("x")))
|
||||
instance := f.instance(key, f.identity)
|
||||
|
||||
require.ErrorIs(t, f.m.prepareUEFIVarStore(t.Context(), instance), ErrUEFIVarStoreUnusable)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A BIOS instance, a microvm and a legacy instance with no pointer touch
|
||||
// neither the object store nor the data directory.
|
||||
func TestPrepareUEFIVarStore_NoPointerIsANoOp(t *testing.T) {
|
||||
f := newUEFIFixture(t)
|
||||
f.m.deps.UEFIVarStore.Objects = &failingGetStore{MemoryObjectStore: f.objects, err: errors.New("must not be called")}
|
||||
bios := f.instance(keyFor("bios"), f.identity)
|
||||
bios.BootMode = ""
|
||||
require.NoError(t, f.m.prepareUEFIVarStore(t.Context(), bios))
|
||||
|
||||
legacy := f.instance("", f.identity)
|
||||
require.NoError(t, f.m.prepareUEFIVarStore(t.Context(), legacy))
|
||||
assert.NoDirExists(t, filepath.Join(f.dataDir, "uefi"))
|
||||
}
|
||||
|
||||
// A UEFI instance with no pointer is refused before the launch clears or
|
||||
// changes anything on its record, and an EFI volume is not a store.
|
||||
func TestRun_UEFIInstanceWithoutAPointerIsRefused(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
requests []EBSRequest
|
||||
}{
|
||||
{name: "no volumes"},
|
||||
{name: "an EFI volume", requests: []EBSRequest{{Name: "vol-1-efi", NBDURI: "nbd:unix:/tmp/efi.sock", EFI: true}}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newUEFIFixture(t)
|
||||
instance := f.instance("", f.identity)
|
||||
instance.EBSRequests.Requests = tt.requests
|
||||
stale := &ec2.StateReason{Code: aws.String("Client.UserInitiatedShutdown"), Message: aws.String("Client.UserInitiatedShutdown: User initiated shutdown")}
|
||||
instance.Instance.StateReason = stale
|
||||
before := instance.Record()
|
||||
|
||||
err := f.m.Run(t.Context(), instance)
|
||||
|
||||
require.ErrorIs(t, err, ErrUEFIVarStoreMissing)
|
||||
assert.Empty(t, f.mounter.mounted)
|
||||
assert.Equal(t, before, instance.Record(), "the record is unchanged")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Start from StateError transitions to pending before launching; a refused
|
||||
// UEFI instance must not take that transition either.
|
||||
func TestStart_UEFIInstanceWithoutAPointerIsRefusedBeforeTheTransition(t *testing.T) {
|
||||
f := newUEFIFixture(t)
|
||||
transitions := 0
|
||||
f.m.deps.TransitionState = func(v *VM, s InstanceState) error { transitions++; v.Status = s; return nil }
|
||||
instance := f.instance("", f.identity)
|
||||
instance.Status = StateError
|
||||
|
||||
err := f.m.Start(t.Context(), instance.ID)
|
||||
|
||||
require.ErrorIs(t, err, ErrUEFIVarStoreMissing)
|
||||
assert.Equal(t, 0, transitions)
|
||||
assert.Equal(t, StateError, instance.Status)
|
||||
}
|
||||
|
||||
// A UEFI instance with a pointer launches, and a BIOS or microvm guest needs
|
||||
// no store at all.
|
||||
func TestCheckUEFIVarStore_Accepts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
v *VM
|
||||
}{
|
||||
{name: "pointer", v: &VM{BootMode: "uefi-preferred", UEFIVarStore: &UEFIVarStoreRef{Key: "k"}}},
|
||||
{name: "BIOS", v: &VM{BootMode: "legacy-bios"}},
|
||||
{name: "no boot mode", v: &VM{}},
|
||||
{name: "microvm", v: &VM{BootMode: "uefi", DirectBoot: true}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.NoError(t, CheckUEFIVarStore(tt.v))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A launch from a pointer opens this node's copy as pflash unit 1 with
|
||||
// format=raw, after the read-only CODE blob.
|
||||
func TestStartQEMU_PointerPassesTheLocalCopyAsPflashUnit1(t *testing.T) {
|
||||
f := newUEFIFixture(t)
|
||||
f.m.deps.NetworkPlumber = &fakeNetworkPlumber{}
|
||||
launches := withFakeQEMU(t)
|
||||
key := keyFor("boot")
|
||||
f.writeLocal(t, key, region("seeded"))
|
||||
instance := f.instance(key, f.identity)
|
||||
instance.EBSRequests.Requests = []EBSRequest{{Name: "vol-root", NBDURI: nbdListener(t), Boot: true}}
|
||||
|
||||
require.NoError(t, f.m.startQEMU(instance, false))
|
||||
|
||||
got := launches()
|
||||
require.Len(t, got, 1)
|
||||
vars := filepath.Join(f.instanceDir(), "vars.fd")
|
||||
assert.Equal(t, []string{
|
||||
"file=" + f.codePath + ",format=raw,if=pflash,unit=0,readonly=on",
|
||||
"file=" + vars + ",format=raw,if=pflash,unit=1",
|
||||
}, allArgValues(got[0], "-drive"))
|
||||
}
|
||||
|
||||
// A UEFI guest is never started without its VARS drive: not when the
|
||||
// node's copy is missing, and not when the record names no store.
|
||||
func TestStartQEMU_UEFIWithoutAPointerIsNotLaunched(t *testing.T) {
|
||||
f := newUEFIFixture(t)
|
||||
f.m.deps.NetworkPlumber = &fakeNetworkPlumber{}
|
||||
launches := withFakeQEMU(t)
|
||||
instance := f.instance("", f.identity)
|
||||
instance.EBSRequests.Requests = []EBSRequest{{Name: "vol-root", NBDURI: nbdListener(t), Boot: true}}
|
||||
|
||||
err := f.m.startQEMU(instance, false)
|
||||
|
||||
require.ErrorIs(t, err, ErrUEFIVarStoreMissing)
|
||||
assert.Empty(t, launches())
|
||||
}
|
||||
|
||||
// A reboot reuses the copy the launch seeded; without one there is nothing
|
||||
// safe to boot, so QEMU is not started.
|
||||
func TestStartQEMU_PointerWithoutALocalCopyIsNotLaunched(t *testing.T) {
|
||||
f := newUEFIFixture(t)
|
||||
f.m.deps.NetworkPlumber = &fakeNetworkPlumber{}
|
||||
launches := withFakeQEMU(t)
|
||||
instance := f.instance(keyFor("boot"), f.identity)
|
||||
instance.EBSRequests.Requests = []EBSRequest{{Name: "vol-root", NBDURI: nbdListener(t), Boot: true}}
|
||||
|
||||
err := f.m.startQEMU(instance, false)
|
||||
|
||||
require.ErrorIs(t, err, os.ErrNotExist)
|
||||
assert.Empty(t, launches())
|
||||
}
|
||||
|
||||
func TestUEFIInstanceDir_RefusesIDsThatAreNotOnePathSegment(t *testing.T) {
|
||||
for _, id := range []string{"", "..", "i-../x", "i-a/b", `i-a\b`, "vol-1"} {
|
||||
_, err := uefiInstanceDir("/data", id)
|
||||
assert.Error(t, err, id)
|
||||
}
|
||||
dir, err := uefiInstanceDir("/data", testUEFIInstance)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "/data/uefi/"+testUEFIInstance, dir)
|
||||
}
|
||||
|
||||
// The CODE hash is what a store records; a package update that replaces the
|
||||
// blob must be hashed again rather than served from the cache.
|
||||
func TestNodeFirmwareFor_HashesTheCodeBlobAgainAfterItChanges(t *testing.T) {
|
||||
f := newUEFIFixture(t)
|
||||
first, err := NodeFirmwareFor("x86_64")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, f.identity, first.Identity)
|
||||
|
||||
updated := []byte("ovmf code build 2, a longer blob")
|
||||
require.NoError(t, os.WriteFile(f.codePath, updated, 0o600))
|
||||
second, err := NodeFirmwareFor("x86_64")
|
||||
require.NoError(t, err)
|
||||
|
||||
sum := sha256.Sum256(updated)
|
||||
assert.Equal(t, hex.EncodeToString(sum[:]), second.Identity.CodeSHA256)
|
||||
assert.Equal(t, f.codePath, second.Code)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/contracts/ec2/v1"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ec2/instance/uefistore"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/compute/gpu"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/compute/qmp"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/host/hostos"
|
||||
@@ -308,6 +309,20 @@ type VM struct {
|
||||
// tolerated by migrate.
|
||||
InstanceLifecycle string `json:"instance_lifecycle,omitempty"`
|
||||
SpotInstanceRequestId string `json:"spot_instance_request_id,omitempty"`
|
||||
|
||||
// UEFIVarStore points at the object holding this instance's UEFI variable
|
||||
// store. Status, so written only by the node; nil for a BIOS instance.
|
||||
UEFIVarStore *UEFIVarStoreRef `json:"uefi_var_store,omitempty"`
|
||||
}
|
||||
|
||||
// UEFIVarStoreRef names the store object an instance launches from, with the
|
||||
// firmware it was written under, so a launch can check it without reading the
|
||||
// object's metadata.
|
||||
type UEFIVarStoreRef struct {
|
||||
Key string `json:"key"`
|
||||
Firmware uefistore.FirmwareIdentity `json:"firmware"`
|
||||
WriterNode string `json:"writer_node"`
|
||||
WrittenAt time.Time `json:"written_at"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON folds the fields of legacy records that mixed a request with an
|
||||
@@ -372,7 +387,7 @@ type Device struct {
|
||||
}
|
||||
|
||||
// Drive is one QEMU -drive entry, used for the pflash firmware drives (the
|
||||
// CODE blob and the EFI volume). Each set field becomes a key=value option.
|
||||
// CODE blob and the variable store). Each set field becomes a key=value option.
|
||||
type Drive struct {
|
||||
File string `json:"file"`
|
||||
Format string `json:"format"`
|
||||
@@ -381,7 +396,7 @@ type Drive struct {
|
||||
ID string `json:"id"`
|
||||
Cache string `json:"cache,omitempty"`
|
||||
// Unit selects the pflash slot when If=="pflash": 0 is the CODE blob,
|
||||
// 1 is the per-VM VARS volume. Ignored for non-pflash drives.
|
||||
// 1 is the per-VM variable store. Ignored for non-pflash drives.
|
||||
Unit int `json:"unit,omitempty"`
|
||||
// ReadOnly emits readonly=on; used for the pflash CODE blob.
|
||||
ReadOnly bool `json:"readonly,omitempty"`
|
||||
@@ -398,7 +413,7 @@ type IOThread struct {
|
||||
// monitor-owned node addressable by node-name, which is what makes
|
||||
// blockdev-del able to remove it later. Used for boot and data volumes, so a
|
||||
// cold-booted data volume has the same detachable block-graph shape as a
|
||||
// hot-attached one; EFI volumes keep the pflash -drive shape.
|
||||
// hot-attached one.
|
||||
type Blockdev struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
@@ -573,8 +588,8 @@ func (cfg *Config) Execute() (*exec.Cmd, error) {
|
||||
return nil, fwErr
|
||||
}
|
||||
// pflash CODE must come before VARS so QEMU loads firmware into the
|
||||
// readonly slot first; buildDrives emits the VARS drive (unit 1) from
|
||||
// the per-VM EFI viperblock volume.
|
||||
// readonly slot first; startQEMU emits the VARS drive (unit 1) from
|
||||
// the node's copy of the variable store.
|
||||
drives = append([]Drive{{If: "pflash", Format: "raw", Unit: 0, ReadOnly: true, File: codePath}}, drives...)
|
||||
}
|
||||
|
||||
|
||||
@@ -152,6 +152,7 @@ type Daemon struct {
|
||||
natsConn *nats.Conn
|
||||
resourceMgr *ResourceManager
|
||||
instanceService *ec2instance.InstanceServiceImpl
|
||||
instanceObjects objectstore.ObjectStore
|
||||
dnsWriter *dns.Writer
|
||||
dnsReconciler *dns.Reconciler
|
||||
dnsBaseDomain string
|
||||
@@ -2017,6 +2018,7 @@ func (d *Daemon) startCluster() error {
|
||||
|
||||
// Create services before loading/launching instances, since LaunchInstance depends on them
|
||||
store := objectstore.NewS3ObjectStoreFromConfig(netaddr.DialTarget(d.config.Predastore.Host), d.config.Predastore.Region, d.config.Predastore.AccessKey, d.config.Predastore.SecretKey)
|
||||
d.instanceObjects = store
|
||||
d.instanceService = ec2instance.NewInstanceServiceImpl(d.config, d.resourceMgr.instanceTypes, d.natsConn, store, d.vmMgr, d.resourceMgr, d.jsManager)
|
||||
d.dnsWriter = dns.NewWriter(d.config, d.clusterConfig, d.natsConn)
|
||||
d.dnsReconciler = dns.NewReconciler(d.config, d.clusterConfig, d.natsConn, d.dnsWriter, d.dnsDesiredSet, d.dnsWatchSources()...)
|
||||
@@ -2213,6 +2215,9 @@ func (d *Daemon) startCluster() error {
|
||||
d.instanceService.SetTerminationDeps(d.volumeService, d.vpcService, ipReleaser, d.tagsService)
|
||||
d.instanceService.SetVolumeCreator(d.volumeService)
|
||||
d.instanceService.SetRunInstancesDeps(d.imageService, d.keyService, &daemonENICreator{d: d}, ipAllocator)
|
||||
if d.jsManager != nil && d.jsManager.UEFIStores() != nil {
|
||||
d.instanceService.SetUEFIVarStore(d.jsManager.UEFIStores(), d.node)
|
||||
}
|
||||
|
||||
if d.gpuManager != nil {
|
||||
d.instanceService.SetGPUClaimer(&daemonGPUClaimer{d: d})
|
||||
@@ -2754,6 +2759,10 @@ func (d *Daemon) initJetStream() error {
|
||||
err = d.jsManager.InitClusterConfigBucket()
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
err = d.jsManager.InitUEFIStoreBucket()
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
d.jsManager.SetSyncObserver(d)
|
||||
// Replicas cannot be undeclared here: bucket creation refuses
|
||||
|
||||
@@ -3504,8 +3504,8 @@ func TestVolumeMounterAdapter_Unmount_SealFailureSkipsAvailable(t *testing.T) {
|
||||
// TestVolumeMounterAdapter_Unmount_NotFoundFlipsToAvailable verifies the
|
||||
// timeout-then-completed-seal retry path: an ebs.unmount response reporting
|
||||
// NotFound (the seal already completed on a prior, client-timed-out request)
|
||||
// must still flip a non-boot data volume to available, while the boot/EFI
|
||||
// gate stays intact.
|
||||
// must still flip a non-boot data volume to available, while the boot gate
|
||||
// stays intact.
|
||||
func TestVolumeMounterAdapter_Unmount_NotFoundFlipsToAvailable(t *testing.T) {
|
||||
daemon := createTestDaemon(t, sharedNATSURL)
|
||||
volState := &recordingVolState{}
|
||||
@@ -3530,8 +3530,8 @@ func TestVolumeMounterAdapter_Unmount_NotFoundFlipsToAvailable(t *testing.T) {
|
||||
AccountID: "000000000042",
|
||||
EBSRequests: vm.EBSRequests{
|
||||
Requests: []viperblocklegacyv1.EBSRequest{
|
||||
{Name: "vol-boot-retry", Boot: true, EFI: false},
|
||||
{Name: "vol-data-retry", Boot: false, EFI: false},
|
||||
{Name: "vol-boot-retry", Boot: true},
|
||||
{Name: "vol-data-retry", Boot: false},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
clusterv1 "github.com/mulgadc/spinifex/contracts/cluster/v1"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ec2/instance/uefistore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/migrate"
|
||||
@@ -88,6 +89,7 @@ type JetStreamManager struct {
|
||||
termRecords *kvstore.Store[vm.InstanceRecord] // i.<id>, terminated bucket
|
||||
clusterKV jetstream.KeyValue // spinifex-cluster-state
|
||||
clusterConfig *kvstore.Bucket // spinifex-cluster-config
|
||||
uefiStores *uefistore.Store // spinifex-uefi-stores
|
||||
obs KVSyncObserver
|
||||
running runningSetState
|
||||
}
|
||||
@@ -232,6 +234,18 @@ func (m *JetStreamManager) InitClusterConfigBucket() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// InitUEFIStoreBucket opens the UEFI store-record bucket, creating it if it
|
||||
// doesn't exist, so a bucket that cannot be created fails startup.
|
||||
func (m *JetStreamManager) InitUEFIStoreBucket() error {
|
||||
m.uefiStores = uefistore.NewStore(m.js)
|
||||
return m.uefiStores.Open(context.Background())
|
||||
}
|
||||
|
||||
// UEFIStores returns the store-record bucket opened by InitUEFIStoreBucket.
|
||||
func (m *JetStreamManager) UEFIStores() *uefistore.Store {
|
||||
return m.uefiStores
|
||||
}
|
||||
|
||||
// InitTerminatedInstanceBucket initializes the terminated-instances KV bucket with a 1-hour TTL.
|
||||
// JetStream automatically purges keys after 1 hour, matching AWS behavior for terminated instances.
|
||||
func (m *JetStreamManager) InitTerminatedInstanceBucket() error {
|
||||
|
||||
@@ -42,10 +42,16 @@ type LocalState struct {
|
||||
// LocalStatePath returns the absolute path to the per-node instance state file.
|
||||
// Empty dataDir falls back to DefaultDataDir.
|
||||
func LocalStatePath(dataDir string) string {
|
||||
return filepath.Join(DataDirOrDefault(dataDir), DefaultLocalStateDir, LocalStateFileName)
|
||||
}
|
||||
|
||||
// DataDirOrDefault is the daemon's persistent data directory: dataDir, or
|
||||
// DefaultDataDir when it is empty.
|
||||
func DataDirOrDefault(dataDir string) string {
|
||||
if dataDir == "" {
|
||||
dataDir = DefaultDataDir
|
||||
return DefaultDataDir
|
||||
}
|
||||
return filepath.Join(dataDir, DefaultLocalStateDir, LocalStateFileName)
|
||||
return dataDir
|
||||
}
|
||||
|
||||
// MarshalLocalState produces the JSON wire form of vms with the lowest schema
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package daemon_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
testutil "github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ec2/instance/uefistore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/daemon"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The daemon creates the store-record bucket at startup and hands out the
|
||||
// store it opened, so later readers share one handle.
|
||||
func TestInitUEFIStoreBucket_CreatesBucketAndServesTheStore(t *testing.T) {
|
||||
_, nc, js := testutil.StartTestJetStream(t)
|
||||
m, err := daemon.NewJetStreamManager(nc)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, m.InitUEFIStoreBucket())
|
||||
|
||||
_, err = js.KeyValue(t.Context(), uefistore.BucketName)
|
||||
require.NoError(t, err, "init must create the bucket rather than leave it to first use")
|
||||
|
||||
rec := &uefistore.UEFIStoreRecord{InstanceID: "i-0123456789abcdef0", State: uefistore.StateLive}
|
||||
rev, err := m.UEFIStores().Create(t.Context(), rec)
|
||||
require.NoError(t, err)
|
||||
got, gotRev, err := m.UEFIStores().Get(t.Context(), rec.InstanceID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, rev, gotRev)
|
||||
assert.Equal(t, rec, got)
|
||||
|
||||
_, err = m.UEFIStores().Create(t.Context(), rec)
|
||||
require.ErrorIs(t, err, kvstore.ErrExists)
|
||||
}
|
||||
@@ -251,11 +251,10 @@ func (a *volumeMounterAdapter) Unmount(ctx context.Context, instance *vm.VM) err
|
||||
"instance", instance.ID, "volume", ebsRequest.Name, "data", string(msg.Data))
|
||||
}
|
||||
|
||||
// Boot/root volumes must stay attached across stop/crash unmount: EFI is
|
||||
// the wrong proxy for "boot" (BIOS-boot roots are Boot && !EFI), so gate on
|
||||
// Boot too. Only DetachVolume and terminate release a boot volume; flipping
|
||||
// it available here while the instance restarts splits the state record.
|
||||
if sealed && !ebsRequest.EFI && !ebsRequest.Boot && a.volState != nil {
|
||||
// A boot volume stays attached across stop/crash unmount: only
|
||||
// DetachVolume and terminate release it, and flipping it available while
|
||||
// the instance restarts would split the state record.
|
||||
if sealed && !ebsRequest.Boot && a.volState != nil {
|
||||
if err := a.volState.UpdateVolumeState(instance.AccountID, ebsRequest.Name, "available", "", ""); err != nil {
|
||||
slog.Error("Failed to update volume state to available after unmount",
|
||||
"volumeId", ebsRequest.Name, "err", err)
|
||||
@@ -696,6 +695,11 @@ func (d *Daemon) buildVMManagerDeps() vm.Deps {
|
||||
DeviceDeletedTimeout: d.deviceDeletedTimeout,
|
||||
ConsumeCleanShutdownMarker: d.consumeCleanShutdownMarker(),
|
||||
BackingStoreReady: func() bool { return d.checkPredastoreReady() && d.checkViperblockReady() },
|
||||
UEFIVarStore: vm.UEFIVarStoreDeps{
|
||||
Objects: d.instanceObjects,
|
||||
Bucket: d.config.Predastore.Bucket,
|
||||
DataDir: DataDirOrDefault(d.config.DataDir),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -190,6 +190,24 @@ func TestBuildVMManagerDeps_BackingStoreReady(t *testing.T) {
|
||||
"no predastore configured and JetStream answering is ready")
|
||||
}
|
||||
|
||||
// Variable stores live in the instance domain's bucket, read through the
|
||||
// same object store, with the live copy under the persistent data directory.
|
||||
func TestBuildVMManagerDeps_UEFIVarStore(t *testing.T) {
|
||||
objects := objectstore.NewMemoryObjectStore()
|
||||
d := &Daemon{
|
||||
config: &config.Config{DataDir: "/srv/spinifex", Predastore: config.PredastoreConfig{Bucket: "spinifex-instances"}},
|
||||
vmMgr: vm.NewManager(),
|
||||
instanceObjects: objects,
|
||||
}
|
||||
got := d.buildVMManagerDeps().UEFIVarStore
|
||||
assert.Same(t, objects, got.Objects)
|
||||
assert.Equal(t, "spinifex-instances", got.Bucket)
|
||||
assert.Equal(t, "/srv/spinifex", got.DataDir)
|
||||
|
||||
d.config.DataDir = ""
|
||||
assert.Equal(t, DefaultDataDir, d.buildVMManagerDeps().UEFIVarStore.DataDir)
|
||||
}
|
||||
|
||||
// --- volumeMounterAdapter ---
|
||||
|
||||
func TestVolumeMounterAdapter_Mount_ClosedConnIsNotRetryable(t *testing.T) {
|
||||
|
||||
@@ -17,9 +17,8 @@ import (
|
||||
// (runtime/compute/vm/crash_recovery.go), where the instance keeps its attachment and restarts —
|
||||
// only DetachVolume and terminate release a boot volume. Releasing it here while
|
||||
// the instance still owns it splits the volume-state record (describe-instances
|
||||
// "attached" vs describe-volumes "available"). EFI is not a
|
||||
// sufficient proxy for "boot": BIOS-boot roots are Boot && !EFI, so the gate must
|
||||
// also exclude Boot. Only non-boot data volumes may be released by Unmount.
|
||||
// "attached" vs describe-volumes "available"). Only non-boot data volumes may be
|
||||
// released by Unmount.
|
||||
func TestUnmountNeverReleasesBootVolume(t *testing.T) {
|
||||
daemon := createTestDaemon(t, sharedNATSURL)
|
||||
volState := &recordingVolState{}
|
||||
@@ -40,19 +39,16 @@ func TestUnmountNeverReleasesBootVolume(t *testing.T) {
|
||||
ID: "i-unmount-boot",
|
||||
EBSRequests: vm.EBSRequests{
|
||||
Requests: []viperblocklegacyv1.EBSRequest{
|
||||
{Name: "vol-bios-root", Boot: true, EFI: false},
|
||||
{Name: "vol-efi-root", Boot: true, EFI: true},
|
||||
{Name: "vol-data", Boot: false, EFI: false},
|
||||
{Name: "vol-root", Boot: true},
|
||||
{Name: "vol-data", Boot: false},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.NoError(t, adapter.Unmount(t.Context(), inst))
|
||||
|
||||
calls := volState.snapshot()
|
||||
assert.NotContains(t, calls, "vol-bios-root:available",
|
||||
"a BIOS-boot root volume (Boot && !EFI) must stay attached across stop/crash unmount, not be released to available")
|
||||
assert.NotContains(t, calls, "vol-efi-root:available",
|
||||
"an EFI-boot root volume must stay attached across stop/crash unmount")
|
||||
assert.NotContains(t, calls, "vol-root:available",
|
||||
"a root volume must stay attached across stop/crash unmount, not be released to available")
|
||||
assert.Contains(t, calls, "vol-data:available",
|
||||
"a non-boot data volume is released to available by Unmount")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user