Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,19 @@ tasks:
set -eou pipefail
echo "🔍 Running golangci-lint with config: .golangci.yml"
GOOS=linux golangci-lint run --config .golangci.yml ./...
# The tagged files, compiled.
#
# Neither the lint above nor `go test ./...` looks at them, so a rename
# that misses one is green here and fails in CI as `[build failed]`,
# after five minutes of setting up a VM — the slowest lane in the
# repository reporting the fastest kind of error there is. That happened.
#
# `go vet` and not golangci-lint with the tags: the break to catch is a
# build break, and turning the full linter loose on files it has never
# seen reports seven pre-existing style findings that have nothing to do
# with whoever is running this.
echo "🔍 Type-checking the tagged tests"
GOOS=linux go vet -tags=integration,e2e ./...
echo "✓ Linting passed"

integration:
Expand Down Expand Up @@ -253,10 +266,10 @@ tasks:
trap 'rm -rf "${WORKDIR}"' EXIT

echo "📦 Verifying QEMU firmware in build output..."
if [ -d "{{.OUTPUT_DIR}}/share/spin-stack/qemu" ]; then
ls -la "{{.OUTPUT_DIR}}/share/spin-stack/qemu"
if [ -d "{{.OUTPUT_DIR}}/qemu" ]; then
ls -la "{{.OUTPUT_DIR}}/qemu"
else
echo "👹 Error: {{.OUTPUT_DIR}}/share/spin-stack/qemu not found"
echo "👹 Error: {{.OUTPUT_DIR}}/qemu not found"
exit 1
fi

Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ require (
github.com/opencontainers/image-spec v1.1.1
github.com/opencontainers/runc v1.5.1
github.com/opencontainers/runtime-spec v1.3.0
github.com/spin-stack/spin-machine v0.0.0-20260908021448-4a006f3f2f33
github.com/stretchr/testify v1.11.1
github.com/vishvananda/netlink v1.3.1
github.com/vishvananda/netns v0.0.5
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/spin-stack/spin-machine v0.0.0-20260908021448-4a006f3f2f33 h1:7AZSlQGTyFxi6eoEW0CZ11JV+DX9azwya1W5wObOgxk=
github.com/spin-stack/spin-machine v0.0.0-20260908021448-4a006f3f2f33/go.mod h1:VbqCcwvAK1lcazIf6rSli6qLrgLU5f22CutMng1pRbc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
Expand Down
14 changes: 7 additions & 7 deletions hack/release
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ mkdir -p "${RELEASE_DIR}/usr/share/spin-stack/config"
echo "Copying artifacts to release structure..."

artifacts=(
"${OUTPUT_DIR}/vmlinux:kernel:vmlinux"
"${OUTPUT_DIR}/kernel/vmlinux:kernel:vmlinux"
"${OUTPUT_DIR}/spinbox-initrd:kernel:spinbox-initrd"
"${OUTPUT_DIR}/containerd-shim-spinbox-v1:bin:containerd-shim-spinbox-v1"
"${OUTPUT_DIR}/spinbox-commit:bin:spinbox-commit"
Expand Down Expand Up @@ -77,16 +77,16 @@ for binary in qemu-system-x86_64 qemu-img; do
fi
done

if [ -d "${OUTPUT_DIR}/share/spin-stack/qemu" ]; then
if [ -d "${OUTPUT_DIR}/qemu" ]; then
mkdir -p "${RELEASE_DIR}/usr/share/spin-stack/qemu"
cp -r "${OUTPUT_DIR}/share/spin-stack/qemu/"* "${RELEASE_DIR}/usr/share/spin-stack/qemu/"
cp -r "${OUTPUT_DIR}/qemu/"* "${RELEASE_DIR}/usr/share/spin-stack/qemu/"
echo " ok: QEMU firmware files"
else
echo " warn: QEMU firmware files not found - skipping"
fi

# The firmware a q35 started with -nodefaults -nographic and a PVH kernel needs, which is
# what Dockerfile.qemu ships. vgabios-stdvga.bin is not on the list any more because no
# what a spin-machine release ships. vgabios-stdvga.bin is not on the list any more because no
# display adapter is built or created; pvh.bin is, and it was missing from this check while
# being the one file a direct kernel boot cannot start without — QEMU has no entry point
# into a PVH ELF kernel otherwise, and it fails as a rom-open error that reads like a
Expand All @@ -100,9 +100,9 @@ for firmware in bios.bin bios-256k.bin pvh.bin kvmvapic.bin efi-virtio.rom; do
done

if [ "${missing_qemu_firmware}" -ne 0 ]; then
if [ -d "${OUTPUT_DIR}/share/spin-stack/qemu" ]; then
echo "Contents of ${OUTPUT_DIR}/share/spin-stack/qemu:"
ls -la "${OUTPUT_DIR}/share/spin-stack/qemu" || true
if [ -d "${OUTPUT_DIR}/qemu" ]; then
echo "Contents of ${OUTPUT_DIR}/qemu:"
ls -la "${OUTPUT_DIR}/qemu" || true
fi
exit 1
fi
Expand Down
36 changes: 15 additions & 21 deletions hack/spin-machine
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,14 @@ want=(

# place <root-of-a-release-tree>
#
# Copies the wanted files into _output/ in the layout the rest of the build and
# hack/release expect: binaries in bin/, firmware in share/spin-stack/qemu/, the
# kernel at the top.
# Copies the wanted files into _output/ at the paths they already have.
#
# It used to rearrange them — binaries here, firmware under another prefix, the
# kernel moved to the top — which meant the same four files had one layout in the
# release, a second here, and a third wherever they were installed, with a
# translation between each pair and a discovery function on the Go side guessing
# which one it had been handed. The release layout is the layout now, everywhere,
# so this is a copy.
place() {
local root="$1" missing=0
for rel in "${want[@]}"; do
Expand All @@ -63,11 +68,7 @@ place() {
missing=1
continue
fi
case "${rel}" in
bin/*) dest="${OUTPUT_DIR}/${rel}" ;;
qemu/*) dest="${OUTPUT_DIR}/share/spin-stack/${rel}" ;;
kernel/*) dest="${OUTPUT_DIR}/$(basename "${rel}")" ;;
esac
dest="${OUTPUT_DIR}/${rel}"
mkdir -p "$(dirname "${dest}")"
# Removed first: _output may hold files from when this repository built the
# machine itself, and those were written by a container running as root.
Expand All @@ -93,22 +94,15 @@ place() {
# through to a download or, worse, reported success. It did both before this split.
have_sibling() {
local out="${SIBLING}/_output"
[ -f "${out}/bin/qemu-system-x86_64" ] && [ -f "${out}/vmlinux" ]
[ -f "${out}/bin/qemu-system-x86_64" ] && [ -f "${out}/kernel/vmlinux" ]
}

from_sibling() {
local out="${SIBLING}/_output"
echo "Using the machine built in ${SIBLING}"
# A built tree, not an unpacked release: same files, different shape.
local staged
staged="$(mktemp -d)"
trap 'rm -rf "${staged}"' RETURN
mkdir -p "${staged}/bin" "${staged}/kernel" "${staged}/qemu"
cp "${out}/bin/qemu-system-x86_64" "${out}/bin/qemu-img" "${staged}/bin/"
cp "${out}/vmlinux" "${staged}/kernel/vmlinux"
cp "${out}/kernel-config" "${staged}/kernel/kernel-config"
cp "${out}"/share/spin-stack/qemu/* "${staged}/qemu/"
place "${staged}"
# A built tree and an unpacked release are now the same shape, so this is the
# same copy the release path does. It used to stage the files into a third
# layout first, because they were not.
place "${SIBLING}/_output"
}

from_release() {
Expand Down Expand Up @@ -165,4 +159,4 @@ else
from_release
fi

echo "✓ machine ${VERSION}: qemu $("${OUTPUT_DIR}/bin/qemu-system-x86_64" --version | head -1 | awk '{print $4}'), kernel $(stat -c%s "${OUTPUT_DIR}/vmlinux") bytes"
echo "✓ machine ${VERSION}: qemu $("${OUTPUT_DIR}/bin/qemu-system-x86_64" --version | head -1 | awk '{print $4}'), kernel $(stat -c%s "${OUTPUT_DIR}/kernel/vmlinux") bytes"
57 changes: 30 additions & 27 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,7 @@ func createTestConfigEnv(t *testing.T, baseDir string) testConfigEnv {
t.Fatalf("failed to create log dir: %v", err)
}

// Create dummy kernel and initrd files
kernelPath := filepath.Join(env.shareDir, "kernel", "spinbox-kernel-x86_64")
initrdPath := filepath.Join(env.shareDir, "kernel", "spinbox-initrd")
if err := os.WriteFile(kernelPath, []byte("dummy"), 0644); err != nil {
t.Fatalf("failed to create dummy kernel: %v", err)
}
if err := os.WriteFile(initrdPath, []byte("dummy"), 0644); err != nil {
t.Fatalf("failed to create dummy initrd: %v", err)
}
writeMachine(t, env.shareDir)

// Create and write config
cfg := DefaultConfig()
Expand Down Expand Up @@ -159,15 +151,7 @@ func TestLoadFrom_ValidConfig(t *testing.T) {
t.Fatal(err)
}

// Create dummy kernel and initrd
kernelPath := filepath.Join(kernelDir, "spinbox-kernel-x86_64")
initrdPath := filepath.Join(kernelDir, "spinbox-initrd")
if err := os.WriteFile(kernelPath, []byte("dummy"), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(initrdPath, []byte("dummy"), 0600); err != nil {
t.Fatal(err)
}
writeMachine(t, shareDir)

cfg := &Config{
Paths: PathsConfig{
Expand Down Expand Up @@ -333,15 +317,7 @@ func TestValidate_Comprehensive(t *testing.T) {
t.Fatal(err)
}

// Create dummy kernel and initrd
kernelPath := filepath.Join(kernelDir, "spinbox-kernel-x86_64")
initrdPath := filepath.Join(kernelDir, "spinbox-initrd")
if err := os.WriteFile(kernelPath, []byte("dummy"), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(initrdPath, []byte("dummy"), 0600); err != nil {
t.Fatal(err)
}
writeMachine(t, shareDir)

cfg.Paths.ShareDir = shareDir
cfg.Paths.StateDir = stateDir
Expand Down Expand Up @@ -686,3 +662,30 @@ func TestReset(t *testing.T) {
t.Fatal("test setup error: directories should be different")
}
}

// writeMachine fills a share directory with a whole spin-machine release plus
// the initrd this repository builds, which together are what validatePaths
// requires.
//
// It writes all four release files and not just a kernel: validation opens the
// release rather than stating one path, because a host with three of the four is
// a host that cannot start a guest and should be told so once, by name, instead
// of finding out from whichever file something happened to ask for first.
func writeMachine(t *testing.T, shareDir string) {
t.Helper()
for _, f := range []string{
"bin/qemu-system-x86_64",
"bin/qemu-img",
"kernel/vmlinux",
"kernel/spinbox-initrd",
"qemu/pvh.bin",
} {
p := filepath.Join(shareDir, f)
if err := os.MkdirAll(filepath.Dir(p), 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(f), 0o600); err != nil {
t.Fatal(err)
}
}
}
40 changes: 16 additions & 24 deletions internal/config/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"path/filepath"
"time"

"github.com/spin-stack/spin-machine/machine"
"golang.org/x/sys/unix"
)

Expand Down Expand Up @@ -37,31 +38,22 @@ func (c *Config) validatePaths() error {
return err
}

// Check kernel and initrd exist.
//
// The two kernel names are repeated from paths.KernelPath rather than taken
// from it: that package imports this one for PathsConfig, so calling into it
// here is an import cycle. Two names in two places is a duplication with a
// reason, and it is the reason this comment exists — whoever adds a third
// name has to change both.
//
// "vmlinux" is what a spin-machine release installs;
// "spinbox-kernel-x86_64" is what installs from before the machine moved out
// of this repository carry.
kernelDir := filepath.Join(c.Paths.ShareDir, "kernel")
initrdPath := filepath.Join(kernelDir, "spinbox-initrd")

kernelPath := ""
for _, name := range []string{"vmlinux", "spinbox-kernel-x86_64"} {
p := filepath.Join(kernelDir, name)
if _, err := os.Stat(p); err == nil {
kernelPath = p
break
}
}
if kernelPath == "" {
return fmt.Errorf("no guest kernel in %s (run 'task machine' to fetch the pinned one)", kernelDir)
// The machine — QEMU, the kernel and the firmware — checked as one thing,
// because that is what it is: a release with a part missing is not a machine
// with a gap in it, it is a host that cannot start a guest, and the failure
// is worth having here rather than at boot.
if _, err := machine.Open(c.Paths.ShareDir); err != nil {
return fmt.Errorf("%w (run 'task machine' to fetch the pinned release)", err)
}

// The initrd is not part of a release: what runs as PID 1 inside a guest is
// this repository's business. It is checked separately for the same reason it
// is built separately.
// The name is spelled here and in internal/paths rather than shared: that
// package imports this one for PathsConfig, so calling into it would be a
// cycle. One filename in two places, and this is the note for whoever changes
// it.
initrdPath := filepath.Join(c.Paths.ShareDir, "kernel", "spinbox-initrd")
if _, err := os.Stat(initrdPath); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("initrd not found at %s (run 'task build:initrd')", initrdPath)
Expand Down
6 changes: 6 additions & 0 deletions internal/host/vm/qemu/devices.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,17 @@ func (q *Instance) AddDisk(ctx context.Context, blockID, mountPath string, opts
blockID = stableID
}

format := mc.Format
if format == "" {
format = vm.DefaultDiskFormat
}

q.disks = append(q.disks, &DiskConfig{
Path: mountPath,
Readonly: mc.Readonly,
ID: blockID,
Serial: mc.Serial,
Format: format,
})

log.G(ctx).WithFields(log.Fields{
Expand Down
26 changes: 14 additions & 12 deletions internal/host/vm/qemu/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,34 +113,36 @@ func validateResourceConfig(cfg *vm.VMResourceConfig) *vm.VMResourceConfig {
return &result
}

// findQemu and findKernel return the two files that come out of a spin-machine
// release. They open the release rather than stat a composed path: a release
// with a part missing should say which part, once, and not surface as whichever
// of the three files somebody happened to ask for first.
func findQemu() (string, error) {
cfg, err := config.Get()
if err != nil {
return "", fmt.Errorf("failed to get config: %w", err)
}

path := paths.QemuPath(cfg.Paths)
if _, err := os.Stat(path); err == nil {
return path, nil
rel, err := paths.Machine(cfg.Paths)
if err != nil {
return "", err
}
return "", fmt.Errorf("qemu-system-x86_64 binary not found at %s", path)
return paths.QemuPath(cfg.Paths, rel), nil
}

// findKernel returns the path to the kernel binary for QEMU
func findKernel() (string, error) {
cfg, err := config.Get()
if err != nil {
return "", fmt.Errorf("failed to get config: %w", err)
}

path := paths.KernelPath(cfg.Paths)
if _, err := os.Stat(path); err == nil {
return path, nil
rel, err := paths.Machine(cfg.Paths)
if err != nil {
return "", err
}
return "", fmt.Errorf("kernel not found at %s (use SPINBOX_SHARE_DIR to override)", path)
return rel.Kernel(), nil
}

// findInitrd returns the path to the initrd for QEMU
// findInitrd returns the path to the initrd for QEMU. It is not part of a
// release: what runs as PID 1 inside a guest is this repository's business.
func findInitrd() (string, error) {
cfg, err := config.Get()
if err != nil {
Expand Down
Loading