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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,5 +70,11 @@ jobs:
- uses: actions/setup-go@v7
with:
go-version: '1.26'
- name: build CLI
run: go build ./cmd/stoat
- name: file locks
run: go test ./internal/filelock
- name: host guards
run: go test ./internal/hostops ./internal/hostcheck
- name: refusal boundaries
run: go test ./internal/config ./internal/qemu ./internal/cli ./internal/tui -run '^(TestUnsupported|TestHostDiagnostics|TestHostConfig|TestCapabilitiesNative)'
8 changes: 2 additions & 6 deletions cmd/installer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,8 @@ package main
import "os"

// main dispatches to run, defined per platform: main_linux.go builds and
// installs stoat, main_other.go prints the Linux-only message.
//
// A runtime.GOOS check here cannot replace this split. internal/installer
// imports kvm_linux.go's KVMCheck unconditionally, so this package refuses
// to compile on a non-Linux GOOS. Code that never compiles never runs its
// check. See kvm_linux.go's comment for the same platform-seam pattern.
// installs stoat, while main_other.go reports that source installation stays
// Linux-only.
func main() {
os.Exit(run())
}
9 changes: 4 additions & 5 deletions cmd/installer/main_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,10 @@ import (
"runtime"
)

// This build skips internal/installer entirely. That package imports
// kvm_linux.go's KVMCheck unconditionally, so it refuses to compile on this
// GOOS. Without this file, a non-Linux user would see a bare
// "undefined: KVMCheck" compiler error instead of this message.
// The source installer remains Linux-only. The command binary itself builds
// on other hosts so users can run read-only diagnostics, while native VM
// operations remain unqualified there.
func run() int {
fmt.Fprintln(os.Stderr, "stoat is Linux-only: it needs KVM, and it does not compile for "+runtime.GOOS+" yet.")
fmt.Fprintln(os.Stderr, "stoat command is buildable for diagnostics on "+runtime.GOOS+"/"+runtime.GOARCH+", but native VM operations are not qualified there; source installation remains Linux-only.")
return 1
}
112 changes: 112 additions & 0 deletions internal/cli/capabilities_other_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
//go:build !linux

package cli

import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)

type nativeCapabilitiesPathState struct {
directory bool
contents []byte
}

func snapshotNativeCapabilitiesRoot(t *testing.T, root string) map[string]nativeCapabilitiesPathState {
t.Helper()
state := make(map[string]nativeCapabilitiesPathState)
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(root, path)
if err != nil {
return err
}
item := nativeCapabilitiesPathState{directory: entry.IsDir()}
if !entry.IsDir() {
item.contents, err = os.ReadFile(path)
if err != nil {
return err
}
}
state[rel] = item
return nil
})
if err != nil {
t.Fatalf("snapshot %s: %v", root, err)
}
return state
}

func TestCapabilitiesNativeReadsStoppedMetadataWithoutMutation(t *testing.T) {
root := cliRoot(t)
vmDir := filepath.Join(root, "stopped")
if err := os.MkdirAll(vmDir, 0o755); err != nil {
t.Fatal(err)
}
vmTOML := []byte("name = \"stopped\"\nmode = \"live\"\nos = \"alpine\"\nsshport = 2200\nagent_access = \"observe\"\n")
if err := os.WriteFile(filepath.Join(vmDir, "vm.toml"), vmTOML, 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(vmDir, "qemu.pid"), []byte("999999999\n"), 0o644); err != nil {
t.Fatal(err)
}
wantState := snapshotNativeCapabilitiesRoot(t, root)

cases := []struct {
name string
args []string
json bool
}{
{name: "targetless human", args: []string{"capabilities"}},
{name: "targetless JSON", args: []string{"--json", "capabilities"}, json: true},
{name: "targeted human", args: []string{"capabilities", "stopped"}},
{name: "targeted JSON", args: []string{"--json", "capabilities", "stopped"}, json: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var out, errOut bytes.Buffer
if code := Main(tc.args, "v-test", strings.NewReader(""), &out, &errOut); code != ExitOK {
t.Fatalf("capabilities exit = %d, want ExitOK; stdout=%q stderr=%q", code, out.String(), errOut.String())
}
if tc.json {
var envelope struct {
Cmd string `json:"cmd"`
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(bytes.TrimSpace(out.Bytes()), &envelope); err != nil {
t.Fatalf("JSON capabilities output is not one envelope: %v; output=%q", err, out.String())
}
if envelope.Cmd != "capabilities" || !envelope.OK {
t.Fatalf("JSON capabilities envelope = %+v, want cmd=capabilities ok=true", envelope)
}
var report struct {
Target *struct {
Name string `json:"name"`
} `json:"target"`
}
if err := json.Unmarshal(envelope.Data, &report); err != nil {
t.Fatalf("JSON capabilities data is not a report: %v; data=%s", err, envelope.Data)
}
if strings.HasPrefix(tc.name, "targeted") && (report.Target == nil || report.Target.Name != "stopped") {
t.Errorf("targeted report = %+v, want stopped target", report.Target)
}
if strings.HasPrefix(tc.name, "targetless") && report.Target != nil {
t.Errorf("targetless report target = %+v, want nil", report.Target)
}
} else if !strings.Contains(strings.ToUpper(out.String()), "NAME") {
t.Errorf("human capabilities omitted NAME header: %q", out.String())
}
if got := snapshotNativeCapabilitiesRoot(t, root); !reflect.DeepEqual(got, wantState) {
t.Errorf("capabilities mutated stored root:\n before: %#v\n after: %#v", wantState, got)
}
})
}
}
2 changes: 2 additions & 0 deletions internal/cli/capabilities_test.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
//go:build linux

package cli

import (
Expand Down
31 changes: 20 additions & 11 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"github.com/novusedge/stoat/internal/config"
"github.com/novusedge/stoat/internal/core"
"github.com/novusedge/stoat/internal/guest"
"github.com/novusedge/stoat/internal/hostops"
"github.com/novusedge/stoat/internal/keys"
"github.com/novusedge/stoat/internal/logx"
"github.com/novusedge/stoat/internal/mcpsrv"
Expand Down Expand Up @@ -434,13 +435,6 @@ func Main(args []string, version string, stdin io.Reader, stdout, stderr io.Writ
}
return runCapabilities(a, version, stdout, stderr)
}
if len(a.Params) > 0 {
resolved, err := resolveParamEdits(a.Params, stdin, stderr, !jsonMode && streamIsTTY(stdin))
if err != nil {
return a.failMsg(stdout, stderr, core.ErrInvalidSpec, err.Error())
}
a.Params = resolved
}

switch a.Cmd {
case "help":
Expand All @@ -455,6 +449,25 @@ func Main(args []string, version string, stdin io.Reader, stdout, stderr io.Writ
}
fmt.Fprintln(stdout, "stoat", version)
return ExitOK
case "doctor":
// Doctor is read-only by design: it may report an unqualified native
// host before root setup or parameter resolution.
return runDoctor(a, stdout, stderr)
}

// Every mutating or process-facing command must reject before resolving
// secrets, reading project scope, creating the data root, or initializing
// logs. The independent capabilities command is dispatched before this
// boundary by its owner and remains metadata-only.
if err := hostops.RequireVM(); err != nil {
return a.fail(stdout, stderr, err)
}
if len(a.Params) > 0 {
resolved, err := resolveParamEdits(a.Params, stdin, stderr, !jsonMode && streamIsTTY(stdin))
if err != nil {
return a.failMsg(stdout, stderr, core.ErrInvalidSpec, err.Error())
}
a.Params = resolved
}

// Every other subcommand touches the data root, so it must be
Expand Down Expand Up @@ -549,10 +562,6 @@ func Main(args []string, version string, stdin io.Reader, stdout, stderr io.Writ
return runCheckRecipes(a, stdout, stderr)
case "update":
return runUpdate(a, stdout, stderr)
case "doctor":
return runDoctor(a, stdout, stderr)
case "capabilities":
return runCapabilities(a, version, stdout, stderr)
case "mcp":
return runMCP(a, version, stdout, stderr)
case "status":
Expand Down
97 changes: 97 additions & 0 deletions internal/cli/host_other_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
//go:build !linux

package cli

import (
"bytes"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)

func TestHostDiagnosticsRunWithoutDataRootMutation(t *testing.T) {
t.Chdir(t.TempDir())
for _, args := range [][]string{
{"help"},
{"version"},
{"--json", "help"},
{"--json", "version"},
} {
t.Run(strings.Join(args, "-"), func(t *testing.T) {
root := filepath.Join(t.TempDir(), "stoat")
t.Setenv("STOAT_HOME", root)
var out, errOut bytes.Buffer
if code := Main(args, "test", strings.NewReader(""), &out, &errOut); code != ExitOK {
t.Fatalf("Main(%v) exit = %d, want ExitOK; stdout=%q stderr=%q", args, code, out.String(), errOut.String())
}
if out.Len() == 0 {
t.Fatalf("Main(%v) produced no diagnostic output", args)
}
if _, err := os.Stat(root); !os.IsNotExist(err) {
t.Fatalf("Main(%v) created STOAT_HOME %q; stat err = %v", args, root, err)
}
})
}

root := filepath.Join(t.TempDir(), "doctor-root")
t.Setenv("STOAT_HOME", root)
var out, errOut bytes.Buffer
code := Main([]string{"doctor"}, "test", strings.NewReader(""), &out, &errOut)
if code != ExitFail {
t.Fatalf("doctor exit = %d, want ExitFail for an unqualified host; stdout=%q stderr=%q", code, out.String(), errOut.String())
}
text := strings.ToLower(out.String())
if !strings.Contains(text, runtime.GOOS+"/"+runtime.GOARCH) {
t.Fatalf("doctor output does not identify the host: %q", out.String())
}
if !strings.Contains(text, "unsupported") && !strings.Contains(text, "unavailable") && !strings.Contains(text, "qualified") {
t.Fatalf("doctor output does not describe the native host as unsupported or unavailable: %q", out.String())
}
for _, linuxAssumption := range []string{"/dev/kvm", "qemu-system-x86_64", "pacman", "apt", "dnf"} {
if strings.Contains(text, linuxAssumption) {
t.Errorf("doctor output suggests Linux dependency %q on %s: %q", linuxAssumption, runtime.GOOS, out.String())
}
}
if _, err := os.Stat(root); !os.IsNotExist(err) {
t.Fatalf("doctor created STOAT_HOME %q; stat err = %v", root, err)
}
}

func TestUnsupportedCLICommandsNoMutation(t *testing.T) {
t.Chdir(t.TempDir())
commands := [][]string{
{"create", "work", "--image", "alpine.iso", "--secret", "docker.authkey"},
{"pull", "alpine"},
{"up", "work"},
{"down", "work"},
{"apply", "work"},
{"ssh", "work"},
{"exec", "work", "true"},
{"cp", "/tmp/host", "work:/tmp/guest"},
{"snapshot", "work", "checkpoint"},
{"rm", "-y", "work"},
{"init"},
}
for _, args := range commands {
t.Run(strings.Join(args, "-"), func(t *testing.T) {
root := filepath.Join(t.TempDir(), "stoat")
t.Setenv("STOAT_HOME", root)
var out, errOut bytes.Buffer
code := Main(args, "test", strings.NewReader(""), &out, &errOut)
if code != ExitFail {
t.Fatalf("Main(%v) exit = %d, want ExitFail; stdout=%q stderr=%q", args, code, out.String(), errOut.String())
}
if strings.Contains(errOut.String(), "STOAT_SECRET_") {
t.Fatalf("Main(%v) resolved a secret before the unsupported-host guard: %q", args, errOut.String())
}
if _, err := os.Stat(root); !os.IsNotExist(err) {
t.Fatalf("Main(%v) created STOAT_HOME %q; stat err = %v", args, root, err)
}
if _, err := os.Stat("stoat.toml"); !os.IsNotExist(err) {
t.Fatalf("Main(%v) created project file before refusing: stat err = %v", args, err)
}
})
}
}
6 changes: 3 additions & 3 deletions internal/cli/run_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,9 +176,9 @@ func runSnapshot(a *Args, stdout, stderr io.Writer) int {
return ExitOK
}

// runDoctor prints core.Doctor's findings: the same checks the installer's
// pre-install checklist runs (qemu-system-x86_64, qemu-img, ssh, xorriso,
// /dev/kvm), so `stoat doctor` and `just setup` agree on host readiness.
// runDoctor prints core.Doctor's platform-specific findings. On Linux,
// `stoat doctor` and `just setup` agree on host readiness where the source
// installer is available.
//
// It prints every failed check's fix command, including optional dependencies.
// Optional checks are warnings only; required failures remain FAIL and make
Expand Down
10 changes: 10 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"strings"
"time"

"github.com/novusedge/stoat/internal/hostops"
"github.com/novusedge/stoat/internal/tomlx"
)

Expand Down Expand Up @@ -195,6 +196,9 @@ func Root() string {

// EnsureRoot creates the data root and its fixed subdirectories.
func EnsureRoot() error {
if err := hostops.RequireVM(); err != nil {
return err
}
for _, d := range []string{"isos", "recipes"} {
if err := os.MkdirAll(filepath.Join(Root(), d), 0o755); err != nil {
return err
Expand Down Expand Up @@ -266,6 +270,9 @@ func (v *VM) ISOPath() string {

// Save writes vm.toml, creating the VM directory if needed.
func (v *VM) Save() error {
if err := hostops.RequireVM(); err != nil {
return err
}
if v.Dir == "" {
v.Dir = filepath.Join(Root(), v.Name)
}
Expand Down Expand Up @@ -368,6 +375,9 @@ var sshPortLine = regexp.MustCompile(`(?m)^\s*sshport\s*=\s*(\d+)\s*$`)

// Delete removes the VM directory. It never touches isos/.
func (v *VM) Delete() error {
if err := hostops.RequireVM(); err != nil {
return err
}
if v.Dir == "" || filepath.Dir(v.Dir) != Root() {
return fmt.Errorf("refusing to delete %q: outside the data root", v.Dir)
}
Expand Down
Loading