From 95d37dfb053f85af6f4ddc88382a356b04288152 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 9 Sep 2026 01:30:41 +0300 Subject: [PATCH 01/24] feat(settings): resolve the gcp project and zone Flag, then config.toml, then gcloud's active configuration read from disk. The resolved value carries where it came from, so create output can name it. Signed-off-by: NovusEdge --- internal/settings/resolve.go | 141 ++++++++++++++++++++++++++++++ internal/settings/resolve_test.go | 75 ++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 internal/settings/resolve.go create mode 100644 internal/settings/resolve_test.go diff --git a/internal/settings/resolve.go b/internal/settings/resolve.go new file mode 100644 index 0000000..2c23459 --- /dev/null +++ b/internal/settings/resolve.go @@ -0,0 +1,141 @@ +package settings + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" +) + +// Source says where a resolved value came from, so create output can name it. +type Source string + +const ( + SourceFlag Source = "flag" + SourceConfig Source = "~/.stoat/config.toml" + SourceGcloud Source = "gcloud's active config" +) + +// Resolved is a project and zone pair together with where they came from. +type Resolved struct { + Project string + Zone string + Source Source +} + +// ResolveGCE picks a GCP project and zone: the flag, then config.toml, then +// gcloud's active configuration read from disk. Project and zone resolve +// together from whichever source supplies both, so a project from config.toml +// and a zone from gcloud never mix into one Resolved. +func ResolveGCE(flagProject, flagZone string) (Resolved, error) { + cfg, err := Load() + if err != nil { + return Resolved{}, err + } + gp, gz, _ := gcloudActiveConfig() + + project, source := flagProject, SourceFlag + if project == "" { + project, source = cfg.Providers.GCE.Project, SourceConfig + } + if project == "" { + project, source = gp, SourceGcloud + } + + zone, zoneSource := flagZone, SourceFlag + if zone == "" { + zone, zoneSource = cfg.Providers.GCE.Zone, SourceConfig + } + if zone == "" { + zone, zoneSource = gz, SourceGcloud + } + + // The source reported is whichever field fell furthest down the + // precedence chain, so create output never claims a flag value that + // only one of the two fields actually had. + if sourceRank(zoneSource) > sourceRank(source) { + source = zoneSource + } + + if project != "" && zone != "" { + return Resolved{Project: project, Zone: zone, Source: source}, nil + } + + var missing []string + if project == "" { + missing = append(missing, "no project: pass --project, set providers.gce.project in config.toml, or run gcloud config set project") + } + if zone == "" { + missing = append(missing, "no zone: pass --zone, set providers.gce.zone in config.toml, or run gcloud config set compute/zone") + } + return Resolved{}, fmt.Errorf("%s", strings.Join(missing, "; ")) +} + +func sourceRank(s Source) int { + switch s { + case SourceFlag: + return 0 + case SourceConfig: + return 1 + default: + return 2 + } +} + +// gcloudConfigDir is CLOUDSDK_CONFIG, defaulting to gcloud's own default. +func gcloudConfigDir() string { + if d := os.Getenv("CLOUDSDK_CONFIG"); d != "" { + return d + } + home, err := os.UserHomeDir() + if err != nil { + return filepath.Join(".config", "gcloud") + } + return filepath.Join(home, ".config", "gcloud") +} + +// gcloudActiveConfig reads project and zone from gcloud's active +// configuration file without invoking the gcloud binary. +func gcloudActiveConfig() (project, zone string, err error) { + dir := gcloudConfigDir() + active := "default" + if b, err := os.ReadFile(filepath.Join(dir, "active_config")); err == nil { + if s := strings.TrimSpace(string(b)); s != "" { + active = s + } + } + f, err := os.Open(filepath.Join(dir, "configurations", "config_"+active)) + if err != nil { + return "", "", err + } + defer f.Close() + + section := "" + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { + continue + } + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + section = strings.TrimSuffix(strings.TrimPrefix(line, "["), "]") + continue + } + key, val, ok := strings.Cut(line, "=") + if !ok { + continue + } + key, val = strings.TrimSpace(key), strings.TrimSpace(val) + switch { + case section == "core" && key == "project": + project = val + case section == "compute" && key == "zone": + zone = val + } + } + if err := scanner.Err(); err != nil { + return "", "", err + } + return project, zone, nil +} diff --git a/internal/settings/resolve_test.go b/internal/settings/resolve_test.go new file mode 100644 index 0000000..2900b57 --- /dev/null +++ b/internal/settings/resolve_test.go @@ -0,0 +1,75 @@ +package settings + +import ( + "os" + "path/filepath" + "testing" +) + +func writeGcloud(t *testing.T, project, zone string) { + t.Helper() + dir := t.TempDir() + t.Setenv("CLOUDSDK_CONFIG", dir) + if err := os.MkdirAll(filepath.Join(dir, "configurations"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "active_config"), []byte("default\n"), 0o600); err != nil { + t.Fatal(err) + } + body := "[core]\nproject = " + project + "\n[compute]\nzone = " + zone + "\n" + if err := os.WriteFile(filepath.Join(dir, "configurations", "config_default"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestResolvePrefersTheFlag(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + writeGcloud(t, "from-gcloud", "us-central1-a") + got, err := ResolveGCE("from-flag", "europe-west4-a") + if err != nil { + t.Fatal(err) + } + if got.Project != "from-flag" || got.Source != SourceFlag { + t.Errorf("Resolved = %+v, want from-flag via the flag", got) + } +} + +func TestResolveFallsBackToGcloud(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + writeGcloud(t, "from-gcloud", "us-central1-a") + got, err := ResolveGCE("", "") + if err != nil { + t.Fatal(err) + } + if got.Project != "from-gcloud" || got.Zone != "us-central1-a" { + t.Errorf("Resolved = %+v, want gcloud's values", got) + } + if got.Source != SourceGcloud { + t.Errorf("Source = %q; create output must be able to say the values came from gcloud", got.Source) + } +} + +func TestResolveNamesWhatIsMissing(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + t.Setenv("CLOUDSDK_CONFIG", t.TempDir()) + _, err := ResolveGCE("", "") + if err == nil { + t.Fatal("ResolveGCE() = nil error with nothing configured") + } + for _, want := range []string{"--project", "providers.gce", "gcloud"} { + if !contains(err.Error(), want) { + t.Errorf("error %q does not name %q", err, want) + } + } +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (func() bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false + })() +} From 19915874b1d15f06f5b77508a7b784743cd436fb Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 9 Sep 2026 01:42:57 +0300 Subject: [PATCH 02/24] feat(gce): authenticate with application default credentials ADC is the base credential. An impersonated service account gives a headless host a stable identity with no secret on disk. A key file stays available behind its own config key, and setting both is an error. Signed-off-by: NovusEdge --- go.mod | 30 +++++++++- go.sum | 82 +++++++++++++++++++++++++--- internal/provider/gce/client.go | 44 +++++++++++++++ internal/provider/gce/client_test.go | 28 ++++++++++ 4 files changed, 174 insertions(+), 10 deletions(-) create mode 100644 internal/provider/gce/client.go create mode 100644 internal/provider/gce/client_test.go diff --git a/go.mod b/go.mod index 77630e3..8f1c831 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/novusedge/stoat -go 1.26 +go 1.26.0 require ( charm.land/bubbles/v2 v2.2.1 @@ -8,6 +8,7 @@ require ( charm.land/huh/v2 v2.0.3 charm.land/lipgloss/v2 v2.0.6 charm.land/log/v2 v2.0.0 + cloud.google.com/go/compute v1.67.0 github.com/BurntSushi/toml v1.6.0 github.com/alecthomas/kong v1.16.1 github.com/charmbracelet/x/ansi v0.11.8 @@ -16,13 +17,18 @@ require ( github.com/modelcontextprotocol/go-sdk v1.7.0 github.com/pelletier/go-toml/v2 v2.4.3 golang.org/x/sys v0.47.0 + google.golang.org/api v0.297.0 gopkg.in/yaml.v3 v3.0.1 ) require ( + cloud.google.com/go/auth v0.23.2 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/anchore/go-lzo v0.1.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/catppuccin/go v0.2.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect @@ -35,9 +41,15 @@ require ( github.com/djherbis/times v1.6.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logfmt/logfmt v0.6.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/google/jsonschema-go v0.4.3 // indirect + github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.20 // indirect + github.com/googleapis/gax-go/v2 v2.24.0 // indirect github.com/klauspost/compress v1.18.5 // indirect github.com/lucasb-eyer/go-colorful v1.4.1 // indirect github.com/mattn/go-runewidth v0.0.27 // indirect @@ -50,12 +62,24 @@ require ( github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/encoding v0.5.4 // indirect github.com/sirupsen/logrus v1.9.4 // indirect - github.com/stretchr/testify v1.11.1 // indirect github.com/ulikunitz/xz v0.5.15 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + golang.org/x/crypto v0.55.0 // indirect golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 // indirect - golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.22.0 // indirect + golang.org/x/text v0.41.0 // indirect golang.org/x/time v0.15.0 // indirect + google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260715232425-e75dac1f907d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect + google.golang.org/grpc v1.83.2 // indirect + google.golang.org/protobuf v1.36.12 // indirect ) diff --git a/go.sum b/go.sum index 19923ca..acb6503 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,16 @@ charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= charm.land/log/v2 v2.0.0 h1:SY3Cey7ipx86/MBXQHwsguOT6X1exT94mmJRdzTNs+s= charm.land/log/v2 v2.0.0/go.mod h1:c3cZSRqm20qUVVAR1WmS/7ab8bgha3C6G7DjPcaVZz0= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.23.2 h1:pxSCpfiji41hpzpPdMCftEUCezpgpqmmDdYiAjCKXxo= +cloud.google.com/go/auth v0.23.2/go.mod h1:4DhBRcqvtljQN3dJ57qtqbib5ZGCYE5f2crfiiC2EM0= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute v1.67.0 h1:CdAcTBCWUCoymOxOCU5sAwsGekn3KWaHI6mBkAGLQOA= +cloud.google.com/go/compute v1.67.0/go.mod h1:h1O3BCv0Zd0/8rZ6PGx8aRBhKBtDC0AuvURtWg1hLLE= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= @@ -26,6 +36,8 @@ github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= @@ -58,8 +70,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/diskfs/go-diskfs v1.9.4 h1:0j2d7eG4IjyxL6+ChWbDPocdBCF6HQ4HBWU2WDYWVnc= github.com/diskfs/go-diskfs v1.9.4/go.mod h1:TePJORO83Adh5pb2SqsxAwaP0fofFxKLkxctiS/9OQc= github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= @@ -68,22 +80,41 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57 h1:x5yxNrq8XffV/OoNUeFPM6hxHVi5OTspSTBxr/9pemg= github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.20 h1:t/xL64VUoN69MuMRQuJETqYGOw4Z9mSRJK9epIEtwFk= +github.com/googleapis/enterprise-certificate-proxy v0.3.20/go.mod h1:L3D/IQExI6LqEjBdXcZQ1WluSgigQmSwBboFstVPM4w= +github.com/googleapis/gax-go/v2 v2.24.0 h1:myMaPYyF9MecEmvQqMqomIwn9t/4KCZN9qnwsS76wlg= +github.com/googleapis/gax-go/v2 v2.24.0/go.mod h1:IaTHBDd7NHxSCiu0vEs8pQZu4dGZrWwuSoxCnk16OFM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= @@ -102,10 +133,12 @@ github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pkg/xattr v0.4.12 h1:rRTkSyFNTRElv6pkA3zpjHpQ90p/OdHQC1GmGh1aTjM= github.com/pkg/xattr v0.4.12/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sahilm/fuzzy v0.1.3 h1:juByESSS32nVD81vr6tHmKmA/8zde7gE+x5CLxrzXPU= github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8= github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= @@ -122,21 +155,56 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 h1:ex206bKw+v3K0dm3andkrIF+ijyQKJG1pLgwQ2PYdQM= golang.org/x/exp v0.0.0-20260727155853-b88d891fe743/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.297.0 h1:WktxTsnnx0yZNnsR6j0q6hR21RnnK81FHTOPy/ux4OE= +google.golang.org/api v0.297.0/go.mod h1:S4m8x0M6OkQpkOzGk1y9JG2sm4fFQrMh6dxzjCTszhE= +google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d h1:C9v1o0/4quuhOAfmRXA2j+we0PqZIp8traLdeogF3Ms= +google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d/go.mod h1:Wz2wFJntZFmLGo7pLDXZ3wYk5hyc0Mb+SkHhDDXT+lU= +google.golang.org/genproto/googleapis/api v0.0.0-20260715232425-e75dac1f907d h1:QwnJwPte4XXAkhPu26LTDIahnsMSUV0kK8HkxbC+Pc4= +google.golang.org/genproto/googleapis/api v0.0.0-20260715232425-e75dac1f907d/go.mod h1:WRrQ7/7N19PypuT0fxLOL5Lq0waoiRri4FbtHDEKrGE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 h1:cYNAzI2sUwhmCcoj9TxvihSrqsxt6uIkj3rDRhSDmW4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/provider/gce/client.go b/internal/provider/gce/client.go new file mode 100644 index 0000000..6547b28 --- /dev/null +++ b/internal/provider/gce/client.go @@ -0,0 +1,44 @@ +// Package gce implements the Provider interface over the Compute Engine API. +package gce + +import ( + "context" + "fmt" + + compute "cloud.google.com/go/compute/apiv1" + "github.com/novusedge/stoat/internal/settings" + "google.golang.org/api/option" +) + +// validateCredentials rejects a config naming both an impersonated service +// account and a key file: nothing in the settings schema says which wins. +func validateCredentials(s settings.GCE) (settings.GCE, error) { + if s.ImpersonateServiceAccount != "" && s.ServiceAccountKeyFile != "" { + return s, fmt.Errorf("providers.gce: set only one of impersonate_service_account or service_account_key_file, not both") + } + return s, nil +} + +// clientOptions builds the option.ClientOption list for s. Plain ADC needs +// none; it is the compute client's default. +func clientOptions(s settings.GCE) []option.ClientOption { + var opts []option.ClientOption + if s.ImpersonateServiceAccount != "" { + opts = append(opts, option.ImpersonateCredentials(s.ImpersonateServiceAccount)) + } + if s.ServiceAccountKeyFile != "" { + opts = append(opts, option.WithCredentialsFile(s.ServiceAccountKeyFile)) + } + return opts +} + +// newClient builds the Compute Engine instances client for s, authenticated +// with Application Default Credentials and, optionally, an impersonated +// service account or a key file. +func newClient(ctx context.Context, s settings.GCE) (*compute.InstancesClient, error) { + s, err := validateCredentials(s) + if err != nil { + return nil, err + } + return compute.NewInstancesRESTClient(ctx, clientOptions(s)...) +} diff --git a/internal/provider/gce/client_test.go b/internal/provider/gce/client_test.go new file mode 100644 index 0000000..fcdf9c3 --- /dev/null +++ b/internal/provider/gce/client_test.go @@ -0,0 +1,28 @@ +package gce + +import ( + "strings" + "testing" + + "github.com/novusedge/stoat/internal/settings" +) + +func TestClientOptionsPlainADC(t *testing.T) { + if got := clientOptions(settings.GCE{Project: "p"}); len(got) != 0 { + t.Errorf("clientOptions = %d options, want none: plain ADC needs no option", len(got)) + } +} + +func TestClientOptionsRejectsBothCredentialSources(t *testing.T) { + _, err := validateCredentials(settings.GCE{ + ImpersonateServiceAccount: "a@b.iam.gserviceaccount.com", + ServiceAccountKeyFile: "/tmp/key.json", + }) + if err == nil { + t.Fatal("validateCredentials() = nil, want an error naming both keys") + } + if !strings.Contains(err.Error(), "impersonate_service_account") || + !strings.Contains(err.Error(), "service_account_key_file") { + t.Errorf("error %q must name both config keys", err) + } +} From a1a7487d5127269e4deae72ae3b310cacfc90dd7 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 9 Sep 2026 01:43:15 +0300 Subject: [PATCH 03/24] feat(iso): name each catalog entry's GCE image Ubuntu resolves to a public image family. Debian gets none: its official GCE images carry no cloud-init, so a stoat seed does nothing there. Signed-off-by: NovusEdge --- internal/iso/iso.go | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/internal/iso/iso.go b/internal/iso/iso.go index cb4c233..18bb0f7 100644 --- a/internal/iso/iso.go +++ b/internal/iso/iso.go @@ -22,6 +22,7 @@ import ( "gopkg.in/yaml.v3" + "github.com/novusedge/stoat/internal/capabilities" "github.com/novusedge/stoat/internal/config" "github.com/novusedge/stoat/internal/guest" ) @@ -98,6 +99,11 @@ type Entry struct { // existing default. DefaultDisk string Notes string + // GCEImage is a full image-family resource path for the gce provider + // (e.g. "projects/ubuntu-os-cloud/global/images/family/ubuntu-2404-lts-amd64"). + // Empty means this entry has no GCE-qualified image: its official cloud + // image carries no cloud-init, so stoat's seed does nothing there. + GCEImage string } // mib is a readable way to write the declared sizes above. @@ -153,8 +159,9 @@ func Catalog() []Entry { // The cloud image's distro-default user is "ubuntu", but // stoat's cloud-init seed (internal/cloudinit) creates and // keys only a "stoat" user, so that's what connects. - SSHUser: "stoat", - Notes: "Ubuntu 24.04 LTS server cloud image", + SSHUser: "stoat", + Notes: "Ubuntu 24.04 LTS server cloud image", + GCEImage: "projects/ubuntu-os-cloud/global/images/family/ubuntu-2404-lts-amd64", }, { ID: "debian-13", @@ -447,6 +454,22 @@ func Resolve(e Entry) (*Release, error) { return r, nil } +// GCEImageFor returns the GCE image-family resource path for a catalog +// entry, or a capabilities.ReasonImageVariantMissing error when the entry +// has no GCE-qualified image (docket d39: those images carry no cloud-init). +func GCEImageFor(id string) (string, error) { + for _, e := range Catalog() { + if e.ID != id { + continue + } + if e.GCEImage == "" { + return "", fmt.Errorf("%s: %s: no GCE image for this entry", id, capabilities.ReasonImageVariantMissing) + } + return e.GCEImage, nil + } + return "", fmt.Errorf("%w: %s", ErrNoSuchImage, id) +} + // fetchChecksum fetches a published sums file and returns the hex digest // for filename. It handles two formats seen across the catalog's mirrors: // GNU coreutils (" " or " *", used by From 3fd7adafe4a489c3370f3b5d967a1fe079ac4c40 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 9 Sep 2026 01:46:35 +0300 Subject: [PATCH 04/24] feat(gce): compute deadlines and carry them in labels The hard deadline is lastStartTimestamp plus maxRunDuration. A probe on 2026-09-08 found compute v1 returns no terminationTimestamp field, so a warning reading it would never fire. Signed-off-by: NovusEdge --- internal/provider/gce/deadline.go | 43 +++++++++++++++++ internal/provider/gce/deadline_test.go | 41 ++++++++++++++++ internal/provider/gce/labels.go | 67 ++++++++++++++++++++++++++ internal/provider/gce/labels_test.go | 24 +++++++++ 4 files changed, 175 insertions(+) create mode 100644 internal/provider/gce/deadline.go create mode 100644 internal/provider/gce/deadline_test.go create mode 100644 internal/provider/gce/labels.go create mode 100644 internal/provider/gce/labels_test.go diff --git a/internal/provider/gce/deadline.go b/internal/provider/gce/deadline.go new file mode 100644 index 0000000..c9a31b8 --- /dev/null +++ b/internal/provider/gce/deadline.go @@ -0,0 +1,43 @@ +package gce + +import ( + "time" + + computepb "cloud.google.com/go/compute/apiv1/computepb" +) + +// WarnWithin is how far ahead of either deadline the CLI starts warning. +const WarnWithin = time.Hour + +// hardDeadline is lastStartTimestamp plus scheduling.maxRunDuration. Compute +// v1's instances.get response carries no scheduling.terminationTimestamp +// field (docket d42): a probe against a real instance confirmed the field is +// simply absent, so a warning that read it would never fire. +func hardDeadline(inst *computepb.Instance) (time.Time, bool) { + dur := inst.GetScheduling().GetMaxRunDuration() + if dur == nil || dur.GetSeconds() == 0 { + return time.Time{}, false + } + start, err := time.Parse(time.RFC3339, inst.GetLastStartTimestamp()) + if err != nil { + return time.Time{}, false + } + return start.Add(time.Duration(dur.GetSeconds()) * time.Second).UTC(), true +} + +// nearest picks whichever of the hard and soft deadlines comes first. A zero +// time.Time means that deadline does not apply. +func nearest(hard, soft, now time.Time) (when time.Time, which string, ok bool) { + switch { + case hard.IsZero() && soft.IsZero(): + return time.Time{}, "", false + case hard.IsZero(): + return soft, "soft deadline", true + case soft.IsZero(): + return hard, "run-time limit", true + case soft.Before(hard): + return soft, "soft deadline", true + default: + return hard, "run-time limit", true + } +} diff --git a/internal/provider/gce/deadline_test.go b/internal/provider/gce/deadline_test.go new file mode 100644 index 0000000..dbb9d60 --- /dev/null +++ b/internal/provider/gce/deadline_test.go @@ -0,0 +1,41 @@ +package gce + +import ( + "testing" + "time" + + computepb "cloud.google.com/go/compute/apiv1/computepb" + "google.golang.org/protobuf/proto" +) + +func TestHardDeadlineFromStartPlusDuration(t *testing.T) { + inst := &computepb.Instance{ + LastStartTimestamp: proto.String("2026-09-08T13:00:00.000-07:00"), + Scheduling: &computepb.Scheduling{MaxRunDuration: &computepb.Duration{Seconds: proto.Int64(3600)}}, + } + got, ok := hardDeadline(inst) + if !ok { + t.Fatal("hardDeadline() not ok for an instance carrying both fields") + } + want := time.Date(2026, 9, 8, 21, 0, 0, 0, time.UTC) + if !got.Equal(want) { + t.Errorf("hardDeadline() = %s, want %s", got, want) + } +} + +func TestHardDeadlineAbsentWithoutADuration(t *testing.T) { + inst := &computepb.Instance{LastStartTimestamp: proto.String("2026-09-08T13:00:00.000-07:00")} + if _, ok := hardDeadline(inst); ok { + t.Error("hardDeadline() ok for an instance with no run-time limit") + } +} + +func TestNearestPicksTheEarlierDeadline(t *testing.T) { + now := time.Date(2026, 9, 8, 20, 0, 0, 0, time.UTC) + hard := now.Add(3 * time.Hour) + soft := now.Add(30 * time.Minute) + when, which, ok := nearest(hard, soft, now) + if !ok || !when.Equal(soft) || which != "soft deadline" { + t.Errorf("nearest() = %s %q %v, want the soft deadline", when, which, ok) + } +} diff --git a/internal/provider/gce/labels.go b/internal/provider/gce/labels.go new file mode 100644 index 0000000..d4004d5 --- /dev/null +++ b/internal/provider/gce/labels.go @@ -0,0 +1,67 @@ +package gce + +import ( + "strings" + "time" +) + +// GCE label values allow only lowercase letters, digits, underscore and +// hyphen: no colon, plus or uppercase, so an RFC3339 timestamp cannot pass +// through unmodified. +const ( + labelOwned = "stoat-owned" + labelVM = "stoat-vm" + labelSoftDeadline = "stoat-soft-deadline" +) + +// withSoftDeadline sets the operator-requested deadline label on labels, +// copying it first so the caller's map is untouched. +func withSoftDeadline(labels map[string]string, t time.Time) map[string]string { + out := make(map[string]string, len(labels)+1) + for k, v := range labels { + out[k] = v + } + out[labelSoftDeadline] = encodeTimestamp(t) + return out +} + +// softDeadline reads the label withSoftDeadline writes. +func softDeadline(labels map[string]string) (time.Time, bool) { + v, ok := labels[labelSoftDeadline] + if !ok { + return time.Time{}, false + } + t, err := time.Parse(time.RFC3339, decodeTimestamp(v)) + if err != nil { + return time.Time{}, false + } + return t, true +} + +func encodeTimestamp(t time.Time) string { + s := t.UTC().Format(time.RFC3339) + s = strings.ToLower(s) + s = strings.ReplaceAll(s, ":", "-") + return strings.ReplaceAll(s, "+", "-") +} + +// decodeTimestamp reverses encodeTimestamp. RFC3339's only "-" that is not a +// separator stand-in is in the date and the zone sign, both fixed positions +// this label always carries as "z" (UTC), so blind hyphen-to-colon +// replacement on the time portion is safe. +func decodeTimestamp(s string) string { + i := strings.IndexByte(s, 't') + if i < 0 { + return s + } + rest := strings.ReplaceAll(s[i+1:], "-", ":") + rest = strings.ReplaceAll(rest, "z", "Z") + return s[:i] + "T" + rest +} + +func ownershipLabels(vm string) map[string]string { + return map[string]string{ + labelOwned: "true", + labelVM: vm, + } +} diff --git a/internal/provider/gce/labels_test.go b/internal/provider/gce/labels_test.go new file mode 100644 index 0000000..11486c6 --- /dev/null +++ b/internal/provider/gce/labels_test.go @@ -0,0 +1,24 @@ +package gce + +import ( + "testing" + "time" +) + +func TestSoftDeadlineRoundTripsThroughALabel(t *testing.T) { + want := time.Date(2026, 9, 8, 21, 30, 0, 0, time.UTC) + got, ok := softDeadline(withSoftDeadline(nil, want)) + if !ok || !got.Equal(want) { + t.Errorf("softDeadline() = %s %v, want %s", got, ok, want) + } +} + +func TestLabelValueUsesOnlyPermittedCharacters(t *testing.T) { + for k, v := range withSoftDeadline(nil, time.Date(2026, 9, 8, 21, 30, 0, 0, time.UTC)) { + for _, r := range k + v { + if !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') && r != '-' && r != '_' { + t.Errorf("label %q=%q contains %q, which GCE rejects", k, v, r) + } + } + } +} From 4c60361fc99d5d4b88604bb89c6d798a1a52f3e7 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 9 Sep 2026 01:46:50 +0300 Subject: [PATCH 05/24] feat(cloudinit): make the seed provider-aware A non-qemu VM has no 9p device, so mountsDoc omits the mount entries there: a mount unit for a device that does not exist fails on every boot. A test proves the two providers' seeds differ by nothing else. Signed-off-by: NovusEdge --- internal/cloudinit/cloudinit.go | 4 +++- internal/cloudinit/cloudinit_test.go | 36 ++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/internal/cloudinit/cloudinit.go b/internal/cloudinit/cloudinit.go index 955311a..8c2b1f8 100644 --- a/internal/cloudinit/cloudinit.go +++ b/internal/cloudinit/cloudinit.go @@ -142,8 +142,10 @@ func SkipShares(osName string) bool { // nofail keeps a share that drops out at runtime from holding up boot. The // host mount is ro, matching what QEMU enforces, so a write fails immediately // instead of after a remount that appears to succeed. +// +// IsRemote's non-qemu providers have no 9p device to mount. func mountsDoc(v *config.VM) string { - if SkipShares(v.OS) { + if SkipShares(v.OS) || config.IsRemote(v.Provider) { return "" } const opts = "trans=virtio,version=9p2000.L,%s,_netdev,nofail" diff --git a/internal/cloudinit/cloudinit_test.go b/internal/cloudinit/cloudinit_test.go index c77afa7..70b1441 100644 --- a/internal/cloudinit/cloudinit_test.go +++ b/internal/cloudinit/cloudinit_test.go @@ -5,6 +5,7 @@ import ( "io" "os" "path/filepath" + "reflect" "slices" "strings" "testing" @@ -489,6 +490,41 @@ func TestSeedSkipsMountsOnDebian(t *testing.T) { } } +// A GCE instance has no 9p device; a mount unit for a device that does not +// exist fails on every boot. +func TestSeedOmitsNineMountsOnGCE(t *testing.T) { + v := &config.VM{Name: "cloudy", Mode: "cloud", OS: "ubuntu", Provider: "gce", Share: "/host"} + got, err := userData(v, testPubkey, nil) + if err != nil { + t.Fatal(err) + } + if strings.Contains(got, "9p") { + t.Errorf("gce seed carries a 9p mount:\n%s", got) + } +} + +// A future edit to mountsDoc or userData must not fork the two providers' +// seeds by accident. +func TestSeedIsOtherwiseIdenticalAcrossProviders(t *testing.T) { + base := &config.VM{Name: "cloudy", Mode: "cloud", OS: "ubuntu"} + local, err := userData(base, testPubkey, nil) + if err != nil { + t.Fatal(err) + } + gce := *base + gce.Provider = "gce" + remote, err := userData(&gce, testPubkey, nil) + if err != nil { + t.Fatal(err) + } + localMap := parseMapping(t, local) + delete(localMap, "mounts") + remoteMap := parseMapping(t, remote) + if !reflect.DeepEqual(localMap, remoteMap) { + t.Errorf("seeds differ by more than the mounts block:\nlocal (mounts stripped): %+v\ngce: %+v", localMap, remoteMap) + } +} + // cloud-init 24.4 as shipped by AlmaLinux 9 and Rocky 9 calls .get() on the // parsed user-data before it checks the type, so a top-level list raises // AttributeError, init-local fails, and `cloud-init status` reports error for From 13c0601580120e4007c24169be0bdeca6df85166 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 9 Sep 2026 01:58:26 +0300 Subject: [PATCH 06/24] feat(gce): build the insert request and the operator-scoped firewall rule insertRequest is pure: no client, so every shape (labels, run-time limit, no service account, an oversize seed) is a table test with no network. rangeFor returns /32 for IPv4 and /128 for IPv6 (docket d45); a /32 on an IPv6 address would open most of the operator's prefix. Signed-off-by: NovusEdge --- internal/provider/gce/firewall.go | 26 ++++ internal/provider/gce/firewall_test.go | 16 +++ internal/provider/gce/instance.go | 166 +++++++++++++++++++++++++ internal/provider/gce/instance_test.go | 83 +++++++++++++ 4 files changed, 291 insertions(+) create mode 100644 internal/provider/gce/firewall.go create mode 100644 internal/provider/gce/firewall_test.go create mode 100644 internal/provider/gce/instance.go create mode 100644 internal/provider/gce/instance_test.go diff --git a/internal/provider/gce/firewall.go b/internal/provider/gce/firewall.go new file mode 100644 index 0000000..fea3072 --- /dev/null +++ b/internal/provider/gce/firewall.go @@ -0,0 +1,26 @@ +package gce + +import ( + computepb "cloud.google.com/go/compute/apiv1/computepb" + "google.golang.org/protobuf/proto" +) + +// firewallName is the rule's own resource name, distinct from vmTag so a +// firewall lookup and an instance tag lookup can never collide. +func firewallName(vm string) string { return "stoat-ssh-" + vm } + +// firewallRule allows tcp:22 from sourceRange to the one instance tagged +// vmTag(vm). Each VM gets its own rule so destroying one VM's rule can never +// affect another's ingress. +func firewallRule(vm, sourceRange string) *computepb.Firewall { + return &computepb.Firewall{ + Name: proto.String(firewallName(vm)), + SourceRanges: []string{sourceRange}, + TargetTags: []string{vmTag(vm)}, + Allowed: []*computepb.Allowed{{ + IPProtocol: proto.String("tcp"), + Ports: []string{"22"}, + }}, + Direction: proto.String("INGRESS"), + } +} diff --git a/internal/provider/gce/firewall_test.go b/internal/provider/gce/firewall_test.go new file mode 100644 index 0000000..dccf453 --- /dev/null +++ b/internal/provider/gce/firewall_test.go @@ -0,0 +1,16 @@ +package gce + +import ( + "strings" + "testing" +) + +func TestFirewallRuleIsScopedToOneVMAndOneSource(t *testing.T) { + r := firewallRule("cloudy", "203.0.113.1/32") + if len(r.SourceRanges) != 1 || r.SourceRanges[0] != "203.0.113.1/32" { + t.Errorf("source ranges = %v, want exactly the operator's address", r.SourceRanges) + } + if len(r.TargetTags) != 1 || !strings.Contains(r.TargetTags[0], "cloudy") { + t.Errorf("target tags = %v, want this VM only", r.TargetTags) + } +} diff --git a/internal/provider/gce/instance.go b/internal/provider/gce/instance.go new file mode 100644 index 0000000..323a42e --- /dev/null +++ b/internal/provider/gce/instance.go @@ -0,0 +1,166 @@ +package gce + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "strconv" + "strings" + "time" + + computepb "cloud.google.com/go/compute/apiv1/computepb" + "google.golang.org/protobuf/proto" + + "github.com/novusedge/stoat/internal/config" + "github.com/novusedge/stoat/internal/settings" +) + +// maxMetadataValueBytes is compute's own cap on one metadata value (docket +// d24). Rejecting an oversize seed here means the failure names the seed, +// not an opaque 400 from the API. +const maxMetadataValueBytes = 256 * 1024 + +// defaultMaxRunDuration bounds every instance this provider creates. Paired +// with instanceTerminationAction=STOP, a VM nobody stops manually still +// stops billing on its own. +const defaultMaxRunDuration = 6 * time.Hour + +// operatorEndpoint echoes back the caller's address in a bare-text body. It +// is Google's own dynamic-DNS update check, chosen so the request stays on +// a Google-operated host rather than a third party. +const operatorEndpoint = "https://domains.google.com/checkip" + +// insertRequest builds the instances.insert request for v. It takes no +// client and makes no call, so every shape it can produce is covered by a +// table test with no network. +func insertRequest(v *config.VM, s settings.GCE, image, seed, sourceRange string) (*computepb.InsertInstanceRequest, error) { + if len(seed) > maxMetadataValueBytes { + return nil, fmt.Errorf("seed is %d bytes, over metadata's %d byte limit", len(seed), maxMetadataValueBytes) + } + diskGB, err := diskSizeGB(v.Disk) + if err != nil { + return nil, fmt.Errorf("disk size %q: %w", v.Disk, err) + } + + tag := vmTag(v.Name) + inst := &computepb.Instance{ + Name: proto.String(v.Name), + MachineType: proto.String(fmt.Sprintf("zones/%s/machineTypes/%s", s.Zone, machineType(v))), + Labels: ownershipLabels(v.Name), + Tags: &computepb.Tags{Items: []string{tag}}, + Disks: []*computepb.AttachedDisk{{ + Boot: proto.Bool(true), + AutoDelete: proto.Bool(true), + InitializeParams: &computepb.AttachedDiskInitializeParams{ + SourceImage: proto.String(image), + DiskSizeGb: proto.Int64(diskGB), + }, + }}, + Metadata: &computepb.Metadata{ + Items: []*computepb.Items{{Key: proto.String("user-data"), Value: proto.String(seed)}}, + }, + NetworkInterfaces: []*computepb.NetworkInterface{{ + AccessConfigs: []*computepb.AccessConfig{{ + Name: proto.String("External NAT"), + Type: proto.String("ONE_TO_ONE_NAT"), + }}, + }}, + Scheduling: &computepb.Scheduling{ + MaxRunDuration: &computepb.Duration{Seconds: proto.Int64(int64(defaultMaxRunDuration.Seconds()))}, + InstanceTerminationAction: proto.String("STOP"), + }, + } + return &computepb.InsertInstanceRequest{ + Project: s.Project, + Zone: s.Zone, + InstanceResource: inst, + }, nil +} + +// vmTag is the network tag one VM's firewall rule and instance share, so the +// rule reaches exactly this instance and no other. +func vmTag(vm string) string { return "stoat-" + vm } + +// machineType picks a custom e2 shape sized to v. e2-custom requires memory +// as a multiple of 256 MB; RAM values from the form are already MB-aligned +// by core's validation, but round up here rather than trust that. +func machineType(v *config.VM) string { + cpus := v.CPUs + if cpus <= 0 { + cpus = 1 + } + ram := v.RAM + if ram <= 0 { + ram = 1024 + } + if rem := ram % 256; rem != 0 { + ram += 256 - rem + } + return fmt.Sprintf("e2-custom-%d-%d", cpus, ram) +} + +// diskSizeGB reads a qemu-img-style size ("20G", "8T") as whole gigabytes, +// the unit computepb.AttachedDiskInitializeParams.DiskSizeGb takes. Anything +// smaller than a gigabyte rounds up to compute's own 10 GB boot disk minimum. +func diskSizeGB(s string) (int64, error) { + s = strings.TrimSpace(strings.ToUpper(s)) + if s == "" { + return 0, fmt.Errorf("empty") + } + mult := int64(1) + switch s[len(s)-1] { + case 'K', 'M': + s = s[:len(s)-1] + case 'G': + s = s[:len(s)-1] + case 'T': + mult, s = 1024, s[:len(s)-1] + default: + return 0, fmt.Errorf("use a size like 20G") + } + n, err := strconv.ParseFloat(s, 64) + if err != nil || n <= 0 { + return 0, fmt.Errorf("use a size like 20G") + } + gb := int64(n) * mult + if gb < 10 { + gb = 10 + } + return gb, nil +} + +// operatorRange asks a Google endpoint what address it saw the request come +// from, and returns it as a single-address CIDR the firewall rule can use +// as its source range. +func operatorRange(ctx context.Context) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, operatorEndpoint, nil) + if err != nil { + return "", err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("looking up the operator's address: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 256)) + if err != nil { + return "", err + } + addr := strings.TrimSpace(string(body)) + if net.ParseIP(addr) == nil { + return "", fmt.Errorf("operator address lookup returned %q, not an IP", addr) + } + return rangeFor(addr), nil +} + +// rangeFor scopes addr to itself: /32 for IPv4, /128 for IPv6 (docket d45). +// A /32 on an IPv6 address would open most of the operator's assigned +// prefix, not just their one address. +func rangeFor(addr string) string { + if strings.Contains(addr, ":") { + return addr + "/128" + } + return addr + "/32" +} diff --git a/internal/provider/gce/instance_test.go b/internal/provider/gce/instance_test.go new file mode 100644 index 0000000..62c7411 --- /dev/null +++ b/internal/provider/gce/instance_test.go @@ -0,0 +1,83 @@ +package gce + +import ( + "strings" + "testing" + + "github.com/novusedge/stoat/internal/config" + "github.com/novusedge/stoat/internal/settings" +) + +func minimalVM() *config.VM { + return &config.VM{Name: "cloudy", RAM: 2048, CPUs: 2, Disk: "20G"} +} + +func minimalSettings() settings.GCE { + return settings.GCE{Project: "p", Zone: "europe-west4-a"} +} + +func TestInsertRequestCarriesTheSeedAsUserData(t *testing.T) { + req, err := insertRequest(&config.VM{Name: "cloudy", RAM: 4096, CPUs: 2, Disk: "20G"}, + settings.GCE{Project: "p", Zone: "europe-west4-a"}, + "projects/ubuntu-os-cloud/global/images/family/ubuntu-2404-lts-amd64", + "#cloud-config\nusers: []\n", "203.0.113.1/32") + if err != nil { + t.Fatal(err) + } + var seen bool + for _, item := range req.InstanceResource.Metadata.Items { + if item.GetKey() == "user-data" { + seen = true + if !strings.HasPrefix(item.GetValue(), "#cloud-config") { + t.Errorf("user-data = %q, want the seed verbatim", item.GetValue()) + } + } + } + if !seen { + t.Error("no user-data metadata item; the guest would boot unprovisioned") + } +} + +func TestInsertRequestAttachesNoServiceAccount(t *testing.T) { + req, _ := insertRequest(minimalVM(), minimalSettings(), "img", "#cloud-config\n", "203.0.113.1/32") + if len(req.InstanceResource.ServiceAccounts) != 0 { + t.Error("instance carries a service account; guest code could reach the GCP API") + } +} + +func TestInsertRequestSetsARunTimeLimit(t *testing.T) { + req, _ := insertRequest(minimalVM(), minimalSettings(), "img", "#cloud-config\n", "203.0.113.1/32") + s := req.InstanceResource.Scheduling + if s.GetMaxRunDuration().GetSeconds() == 0 { + t.Error("no maxRunDuration; an orphaned instance would bill forever") + } + if s.GetInstanceTerminationAction() != "STOP" { + t.Errorf("termination action = %q, want STOP", s.GetInstanceTerminationAction()) + } +} + +func TestInsertRequestCarriesOwnershipLabels(t *testing.T) { + req, _ := insertRequest(minimalVM(), minimalSettings(), "img", "#cloud-config\n", "203.0.113.1/32") + if req.InstanceResource.Labels["stoat-owned"] != "true" { + t.Error("no stoat-owned label; prune could not find this instance") + } + if req.InstanceResource.Labels["stoat-vm"] != "cloudy" { + t.Errorf("stoat-vm label = %q, want cloudy", req.InstanceResource.Labels["stoat-vm"]) + } +} + +func TestInsertRequestRejectsAnOversizeSeed(t *testing.T) { + big := strings.Repeat("x", 257*1024) + if _, err := insertRequest(minimalVM(), minimalSettings(), "img", big, "203.0.113.1/32"); err == nil { + t.Error("insertRequest() = nil error for a seed over the 256 KB metadata limit") + } +} + +func TestOperatorRangeUsesTheRightPrefixLength(t *testing.T) { + if got := rangeFor("89.166.32.165"); got != "89.166.32.165/32" { + t.Errorf("rangeFor(v4) = %q", got) + } + if got := rangeFor("2001:14ba:788e:b400::19a"); got != "2001:14ba:788e:b400::19a/128" { + t.Errorf("rangeFor(v6) = %q; a /32 on an IPv6 address opens a vast range", got) + } +} From d5af49001be243ca6116edc247c10e6162063d58 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 9 Sep 2026 02:05:51 +0300 Subject: [PATCH 07/24] feat(gce): implement the Provider over Compute Engine Registers as "gce". Create creates the firewall rule before the instance and rolls it back on a failed insert. Status maps GCE's instance states onto provider.Status, keeping the raw word for a frontend to show; REPAIRING falls to not-running since the API gives no guarantee the guest is reachable during repair. Endpoint pins the host key to a per-VM known_hosts file, since this connection crosses a routable network. Capabilities declares share, screenshot, sendkey, console logs, forward, snapshot, clone, and update's ram/cpu edits unsupported. A gce VM records its project and zone on vm.toml (GCEProject, GCEZone): settings.ResolveGCE's precedence only applies once, at create time, and every later command needs the instance's location without re-resolving it against whatever the environment says now. backend.RecipeScripts and cloudinit.UserData are now exported so this provider can build the same seed content the QEMU cloud-init backend builds, without a cdrom device to hand it a NoCloud ISO. Signed-off-by: NovusEdge --- internal/backend/cloudinit.go | 9 +- internal/cloudinit/cloudinit.go | 8 + internal/config/config.go | 7 + internal/provider/gce/client.go | 11 +- internal/provider/gce/gce.go | 320 ++++++++++++++++++++++++++++++ internal/provider/gce/gce_test.go | 99 +++++++++ 6 files changed, 450 insertions(+), 4 deletions(-) create mode 100644 internal/provider/gce/gce.go create mode 100644 internal/provider/gce/gce_test.go diff --git a/internal/backend/cloudinit.go b/internal/backend/cloudinit.go index 816ac7d..586e62f 100644 --- a/internal/backend/cloudinit.go +++ b/internal/backend/cloudinit.go @@ -68,7 +68,7 @@ func (cloudinitBackend) Prepare(v *config.VM) error { if err != nil { return err } - scripts, err := recipeScripts(v) + scripts, err := RecipeScripts(v) if err != nil { return err } @@ -93,10 +93,13 @@ func (cloudinitBackend) Prepare(v *config.VM) error { return nil } -// recipeScripts resolves v.Recipes to the cloud-init scripts WrapScripts +// RecipeScripts resolves v.Recipes to the cloud-init scripts WrapScripts // renders: each recipe's manifest, then the script body for v.OS. A recipe // with no recipe.toml went missing since create time and errors here. -func recipeScripts(v *config.VM) ([]cloudinit.Script, error) { +// +// Exported so the gce provider can build the same seed content without a +// QEMU process to attach an ISO to. +func RecipeScripts(v *config.VM) ([]cloudinit.Script, error) { stored, err := config.LoadSecrets(v.Dir) if err != nil { return nil, fmt.Errorf("reading recipe secrets: %w", err) diff --git a/internal/cloudinit/cloudinit.go b/internal/cloudinit/cloudinit.go index 8c2b1f8..fd580ae 100644 --- a/internal/cloudinit/cloudinit.go +++ b/internal/cloudinit/cloudinit.go @@ -103,6 +103,14 @@ func extraPackages(osName string) string { // into one #cloud-config document. Nothing here looks for packages: or // runcmd: by name, so a fragment using write_files: or any other key // survives. +// UserData builds the seed's user-data document without writing it anywhere. +// Seed writes the same content to a NoCloud ISO for a QEMU guest; a provider +// with no ISO device (gce) passes this string straight into instance +// metadata instead. +func UserData(v *config.VM, pubkey string, recipeBodies []string) (string, error) { + return userData(v, pubkey, recipeBodies) +} + func userData(v *config.VM, pubkey string, recipeBodies []string) (string, error) { base := fmt.Sprintf(userDataTemplate, guestShell(v.OS), pubkey, consolePasswordBlock(v.ConsolePassword)) diff --git a/internal/config/config.go b/internal/config/config.go index 970d725..0d1d9e9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -98,6 +98,13 @@ type VM struct { // or a cloud provider's name. Empty means "qemu", which is what every // vm.toml written before this field existed says. Provider string `toml:"provider,omitempty"` + // GCEProject and GCEZone pin a gce VM to the project and zone it was + // created in. settings.ResolveGCE only runs at create time; every later + // command (start, stop, destroy) needs the instance's location without + // re-resolving flags, config.toml and gcloud's config against a possibly + // different environment. + GCEProject string `toml:"gce_project,omitempty"` + GCEZone string `toml:"gce_zone,omitempty"` // Base is the absolute path to the shared base image an overlay is // created from. Cloud mode only. Base string `toml:"base"` diff --git a/internal/provider/gce/client.go b/internal/provider/gce/client.go index 6547b28..bdd157f 100644 --- a/internal/provider/gce/client.go +++ b/internal/provider/gce/client.go @@ -1,4 +1,3 @@ -// Package gce implements the Provider interface over the Compute Engine API. package gce import ( @@ -42,3 +41,13 @@ func newClient(ctx context.Context, s settings.GCE) (*compute.InstancesClient, e } return compute.NewInstancesRESTClient(ctx, clientOptions(s)...) } + +// newFirewallsClient authenticates the same way as newClient, for the +// separate Firewalls API surface Create and Destroy also need. +func newFirewallsClient(ctx context.Context, s settings.GCE) (*compute.FirewallsClient, error) { + s, err := validateCredentials(s) + if err != nil { + return nil, err + } + return compute.NewFirewallsRESTClient(ctx, clientOptions(s)...) +} diff --git a/internal/provider/gce/gce.go b/internal/provider/gce/gce.go new file mode 100644 index 0000000..7f76c17 --- /dev/null +++ b/internal/provider/gce/gce.go @@ -0,0 +1,320 @@ +// Package gce implements the Provider interface over the Compute Engine API. +package gce + +import ( + "context" + "fmt" + "path/filepath" + + compute "cloud.google.com/go/compute/apiv1" + computepb "cloud.google.com/go/compute/apiv1/computepb" + + "github.com/novusedge/stoat/internal/backend" + "github.com/novusedge/stoat/internal/capabilities" + "github.com/novusedge/stoat/internal/cloudinit" + "github.com/novusedge/stoat/internal/config" + "github.com/novusedge/stoat/internal/guest" + "github.com/novusedge/stoat/internal/iso" + "github.com/novusedge/stoat/internal/keys" + "github.com/novusedge/stoat/internal/provider" + "github.com/novusedge/stoat/internal/settings" + "github.com/novusedge/stoat/internal/sshx" +) + +func init() { + provider.Register("gce", Provider{}) +} + +// Provider is one Compute Engine instance per stoat VM. +type Provider struct{} + +// unsupported lists what this provider does not implement over the local +// QEMU surface: everything that assumes a hypervisor process on this host +// (a VNC framebuffer for share/screenshot/sendkey, a console log file) or +// a disk stoat can clone or snapshot directly. update's ram and cpu edits +// are unsupported too: they resize a running qemu process's allocation, +// which has no equivalent on a Compute Engine instance short of a stop, +// SetMachineResources, and restart this provider does not yet implement. +var unsupported = []string{ + "share", "screenshot", "sendkey", "console_log", "forward", + "snapshot", "clone", "update.ram", "update.cpu", +} + +func (Provider) Name() string { return "gce" } + +func (Provider) Capabilities(*config.VM) []capabilities.Capability { + out := make([]capabilities.Capability, len(unsupported)) + for i, name := range unsupported { + out[i] = capabilities.Capability{ + Name: name, + Status: capabilities.StatusUnsupported, + Reason: &capabilities.Reason{Code: capabilities.ReasonProviderUnsupported}, + } + } + return out +} + +// vmSettings resolves the credentials config.toml carries plus the +// project and zone v was created in. It never re-runs settings.ResolveGCE: +// that precedence (flag, config.toml, gcloud) applies once, at create time. +func vmSettings(v *config.VM) (settings.GCE, error) { + cfg, err := settings.Load() + if err != nil { + return settings.GCE{}, err + } + gce := cfg.Providers.GCE + if v.GCEProject == "" || v.GCEZone == "" { + return settings.GCE{}, fmt.Errorf("gce: %s carries no project/zone; it was not created by this provider", v.Name) + } + gce.Project = v.GCEProject + gce.Zone = v.GCEZone + return gce, nil +} + +// Create builds the firewall rule first, then the instance, so the instance +// never comes up reachable-then-locked-down. A failed insert deletes the +// rule it already created; core has not written vm.toml as created yet, so +// a caller retrying Create must not find an orphaned rule from this attempt. +func (Provider) Create(ctx context.Context, v *config.VM) error { + s, err := vmSettings(v) + if err != nil { + return err + } + image, err := iso.GCEImageFor(v.OS) + if err != nil { + return err + } + seed, err := buildSeed(v) + if err != nil { + return err + } + sourceRange, err := operatorRange(ctx) + if err != nil { + return fmt.Errorf("gce: %w", err) + } + + fwClient, err := newFirewallsClient(ctx, s) + if err != nil { + return err + } + defer fwClient.Close() + + fwOp, err := fwClient.Insert(ctx, &computepb.InsertFirewallRequest{ + Project: s.Project, + FirewallResource: firewallRule(v.Name, sourceRange), + }) + if err != nil { + return fmt.Errorf("gce: creating firewall rule: %w", err) + } + if err := fwOp.Wait(ctx); err != nil { + return fmt.Errorf("gce: creating firewall rule: %w", err) + } + + req, err := insertRequest(v, s, image, seed, sourceRange) + if err != nil { + _ = deleteFirewallRule(ctx, fwClient, s, v.Name) + return err + } + + instClient, err := newClient(ctx, s) + if err != nil { + _ = deleteFirewallRule(ctx, fwClient, s, v.Name) + return err + } + defer instClient.Close() + + insOp, err := instClient.Insert(ctx, req) + if err != nil { + _ = deleteFirewallRule(ctx, fwClient, s, v.Name) + return fmt.Errorf("gce: creating instance: %w", err) + } + if err := insOp.Wait(ctx); err != nil { + _ = deleteFirewallRule(ctx, fwClient, s, v.Name) + return fmt.Errorf("gce: creating instance: %w", err) + } + return nil +} + +func deleteFirewallRule(ctx context.Context, c *compute.FirewallsClient, s settings.GCE, vm string) error { + op, err := c.Delete(ctx, &computepb.DeleteFirewallRequest{Project: s.Project, Firewall: firewallName(vm)}) + if err != nil { + return err + } + return op.Wait(ctx) +} + +// buildSeed renders the same cloud-init user-data a QEMU cloud VM gets, as +// a plain string for instance metadata: gce has no cdrom device to attach a +// NoCloud ISO to. +func buildSeed(v *config.VM) (string, error) { + if err := keys.Ensure(); err != nil { + return "", err + } + pub, err := keys.PublicKey() + if err != nil { + return "", err + } + scripts, err := backend.RecipeScripts(v) + if err != nil { + return "", err + } + var prelude string + if o, ok := guest.Lookup(v.OS); ok { + prelude = guest.Prelude(o, "sh") + } + var bodies []string + if frag := cloudinit.WrapScripts(scripts, prelude); frag != "" { + bodies = []string{frag} + } + return cloudinit.UserData(v, pub, bodies) +} + +func (Provider) Start(ctx context.Context, v *config.VM) error { + s, err := vmSettings(v) + if err != nil { + return err + } + c, err := newClient(ctx, s) + if err != nil { + return err + } + defer c.Close() + op, err := c.Start(ctx, &computepb.StartInstanceRequest{Project: s.Project, Zone: s.Zone, Instance: v.Name}) + if err != nil { + return fmt.Errorf("gce: starting %s: %w", v.Name, err) + } + return op.Wait(ctx) +} + +func (Provider) Stop(ctx context.Context, v *config.VM) error { + s, err := vmSettings(v) + if err != nil { + return err + } + c, err := newClient(ctx, s) + if err != nil { + return err + } + defer c.Close() + op, err := c.Stop(ctx, &computepb.StopInstanceRequest{Project: s.Project, Zone: s.Zone, Instance: v.Name}) + if err != nil { + return fmt.Errorf("gce: stopping %s: %w", v.Name, err) + } + return op.Wait(ctx) +} + +// Destroy deletes the instance, then its firewall rule, then returns. core +// deletes vm.toml only after this returns nil (Provider's own contract); +// deleting the firewall rule before the instance would leave the instance +// briefly reachable from nowhere and briefly unreachable from everywhere, +// for no benefit. +func (Provider) Destroy(ctx context.Context, v *config.VM) error { + s, err := vmSettings(v) + if err != nil { + return err + } + c, err := newClient(ctx, s) + if err != nil { + return err + } + defer c.Close() + op, err := c.Delete(ctx, &computepb.DeleteInstanceRequest{Project: s.Project, Zone: s.Zone, Instance: v.Name}) + if err != nil { + return fmt.Errorf("gce: deleting %s: %w", v.Name, err) + } + if err := op.Wait(ctx); err != nil { + return fmt.Errorf("gce: deleting %s: %w", v.Name, err) + } + + fwClient, err := newFirewallsClient(ctx, s) + if err != nil { + return err + } + defer fwClient.Close() + return deleteFirewallRule(ctx, fwClient, s, v.Name) +} + +// running is GCE's own set of statuses that mean stoat can reach the guest. +// PROVISIONING and STAGING count too: neither exits Status() as "stopped" +// while the instance is on its way up. REPAIRING falls to the default +// (not running): the API document does not say whether the guest is +// reachable during repair, so a caller waiting for "running" should keep +// waiting rather than be told the instance already came up. +var running = map[string]bool{ + "PROVISIONING": true, + "STAGING": true, + "RUNNING": true, + "STOPPING": false, + "SUSPENDING": false, + "SUSPENDED": false, + "TERMINATED": false, +} + +func (Provider) Status(ctx context.Context, v *config.VM) (provider.Status, error) { + s, err := vmSettings(v) + if err != nil { + return provider.Status{}, err + } + c, err := newClient(ctx, s) + if err != nil { + return provider.Status{}, err + } + defer c.Close() + return statusFor(ctx, c, s, v) +} + +// statusFor takes an already-built client so a test can hand it one wired +// to a fake http.RoundTripper instead of real credentials. +func statusFor(ctx context.Context, c *compute.InstancesClient, s settings.GCE, v *config.VM) (provider.Status, error) { + inst, err := c.Get(ctx, &computepb.GetInstanceRequest{Project: s.Project, Zone: s.Zone, Instance: v.Name}) + if err != nil { + return provider.Status{}, fmt.Errorf("gce: getting %s: %w", v.Name, err) + } + raw := inst.GetStatus() + return provider.Status{Running: running[raw], Raw: raw}, nil +} + +// Endpoint pins the host key to a per-VM file: this connection crosses a +// routable network, where sshx's loopback "skip checking" policy would let +// a machine-in-the-middle intercept it silently. +func (Provider) Endpoint(ctx context.Context, v *config.VM) (sshx.Endpoint, error) { + s, err := vmSettings(v) + if err != nil { + return sshx.Endpoint{}, err + } + c, err := newClient(ctx, s) + if err != nil { + return sshx.Endpoint{}, err + } + defer c.Close() + return endpointFor(ctx, c, s, v) +} + +func endpointFor(ctx context.Context, c *compute.InstancesClient, s settings.GCE, v *config.VM) (sshx.Endpoint, error) { + inst, err := c.Get(ctx, &computepb.GetInstanceRequest{Project: s.Project, Zone: s.Zone, Instance: v.Name}) + if err != nil { + return sshx.Endpoint{}, fmt.Errorf("gce: getting %s: %w", v.Name, err) + } + addr := externalAddress(inst) + if addr == "" { + return sshx.Endpoint{}, fmt.Errorf("gce: %s has no external address", v.Name) + } + return sshx.Endpoint{ + Name: v.Name, + Host: addr, + Port: 22, + User: sshx.User(v), + KnownHosts: filepath.Join(v.Dir, "known_hosts"), + }, nil +} + +func externalAddress(inst *computepb.Instance) string { + for _, ni := range inst.GetNetworkInterfaces() { + for _, ac := range ni.GetAccessConfigs() { + if ac.GetNatIP() != "" { + return ac.GetNatIP() + } + } + } + return "" +} diff --git a/internal/provider/gce/gce_test.go b/internal/provider/gce/gce_test.go new file mode 100644 index 0000000..ff37a56 --- /dev/null +++ b/internal/provider/gce/gce_test.go @@ -0,0 +1,99 @@ +package gce + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + compute "cloud.google.com/go/compute/apiv1" + "google.golang.org/api/option" + + "github.com/novusedge/stoat/internal/config" + "github.com/novusedge/stoat/internal/settings" +) + +// roundTripFunc lets a test supply instances.get's response body without a +// real client or network. +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func fakeInstancesClient(t *testing.T, body string) *compute.InstancesClient { + t.Helper() + rt := roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(body)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }) + c, err := compute.NewInstancesRESTClient(context.Background(), + option.WithHTTPClient(&http.Client{Transport: rt}), + option.WithoutAuthentication(), + option.WithEndpoint("https://compute.googleapis.com/compute/v1/")) + if err != nil { + t.Fatalf("building a fake instances client: %v", err) + } + return c +} + +func TestStatusMapsRunningStates(t *testing.T) { + for raw, want := range map[string]bool{ + "PROVISIONING": true, "STAGING": true, "RUNNING": true, + "STOPPING": false, "SUSPENDING": false, "SUSPENDED": false, + "TERMINATED": false, "REPAIRING": false, + } { + if got := running[raw]; got != want { + t.Errorf("running[%q] = %v, want %v", raw, got, want) + } + } +} + +func TestStatusForReadsRawAndMapping(t *testing.T) { + c := fakeInstancesClient(t, `{"status":"STOPPING"}`) + defer c.Close() + st, err := statusFor(context.Background(), c, settings.GCE{Project: "p", Zone: "z"}, &config.VM{Name: "cloudy"}) + if err != nil { + t.Fatal(err) + } + if st.Running { + t.Error("Status().Running = true for STOPPING") + } + if st.Raw != "STOPPING" { + t.Errorf("Status().Raw = %q, want STOPPING", st.Raw) + } +} + +func TestEndpointReadsTheExternalAddress(t *testing.T) { + c := fakeInstancesClient(t, `{"networkInterfaces":[{"accessConfigs":[{"natIP":"34.12.221.212"}]}]}`) + defer c.Close() + v := &config.VM{Name: "cloudy", Dir: "/data/vms/cloudy"} + ep, err := endpointFor(context.Background(), c, settings.GCE{Project: "p", Zone: "z"}, v) + if err != nil { + t.Fatal(err) + } + if ep.Host != "34.12.221.212" { + t.Errorf("Endpoint().Host = %q, want the natIP", ep.Host) + } + if ep.Port != 22 { + t.Errorf("Endpoint().Port = %d, want 22", ep.Port) + } + if ep.KnownHosts == "" { + t.Error("Endpoint().KnownHosts is empty; a routable host must pin its host key") + } +} + +func TestCapabilitiesDeclareTheUnsupportedSet(t *testing.T) { + caps := Provider{}.Capabilities(&config.VM{}) + byName := make(map[string]string, len(caps)) + for _, c := range caps { + byName[c.Name] = c.Status + } + for _, name := range []string{"share", "screenshot", "sendkey", "console_log", "forward", "snapshot", "clone", "update.ram", "update.cpu"} { + if byName[name] != "unsupported" { + t.Errorf("capability %q = %q, want unsupported", name, byName[name]) + } + } +} From 7869c0319d2ce67f00b545afa7dbfe8327c80700 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 9 Sep 2026 02:17:20 +0300 Subject: [PATCH 08/24] fix(gce): resolve the guest name to a catalog image, not an entry ID config.VM.OS holds a guest name ("ubuntu"); catalog entry IDs are more specific ("ubuntu-24.04"). GCEImageFor matched on ID, so Create failed ErrNoSuchImage before any API call. GCEImageForOS matches on Entry.OS instead. Signed-off-by: NovusEdge --- internal/iso/iso.go | 21 +++++++++++++++++++++ internal/iso/iso_test.go | 32 ++++++++++++++++++++++++++++++++ internal/provider/gce/gce.go | 2 +- 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/internal/iso/iso.go b/internal/iso/iso.go index 18bb0f7..efc5a81 100644 --- a/internal/iso/iso.go +++ b/internal/iso/iso.go @@ -470,6 +470,27 @@ func GCEImageFor(id string) (string, error) { return "", fmt.Errorf("%w: %s", ErrNoSuchImage, id) } +// GCEImageForOS resolves a guest name (config.VM.OS, e.g. "ubuntu") to that +// OS's GCE-qualified catalog entry. Guest names and catalog IDs diverge for +// every multi-word entry ("ubuntu" vs. "ubuntu-24.04"), and the gce provider +// only ever has the guest name. +func GCEImageForOS(osName string) (string, error) { + seen := false + for _, e := range Catalog() { + if e.OS != osName { + continue + } + seen = true + if e.GCEImage != "" { + return e.GCEImage, nil + } + } + if seen { + return "", fmt.Errorf("%s: %s: no GCE image for this OS", osName, capabilities.ReasonImageVariantMissing) + } + return "", fmt.Errorf("%w: %s", ErrNoSuchImage, osName) +} + // fetchChecksum fetches a published sums file and returns the hex digest // for filename. It handles two formats seen across the catalog's mirrors: // GNU coreutils (" " or " *", used by diff --git a/internal/iso/iso_test.go b/internal/iso/iso_test.go index 2551243..e2c6c52 100644 --- a/internal/iso/iso_test.go +++ b/internal/iso/iso_test.go @@ -879,3 +879,35 @@ func TestDownloadReportsAChecksumMismatch(t *testing.T) { t.Errorf("Download() = %v, want ErrChecksumMismatch", err) } } + +// GCEImageForOS takes a guest name (config.VM.OS, e.g. "ubuntu"), not a +// catalog entry ID (e.g. "ubuntu-24.04"): those differ for every multi-word +// catalog entry, and gce.Provider.Create has only the guest name. +func TestGCEImageForOS_ResolvesGuestNameToCatalogImage(t *testing.T) { + got, err := GCEImageForOS("ubuntu") + if err != nil { + t.Fatal(err) + } + want, err := GCEImageFor("ubuntu-24.04") + if err != nil { + t.Fatal(err) + } + if got != want { + t.Errorf("GCEImageForOS(%q) = %q, want %q", "ubuntu", got, want) + } +} + +func TestGCEImageForOS_UnknownGuestName(t *testing.T) { + if _, err := GCEImageForOS("plan9"); !errors.Is(err, ErrNoSuchImage) { + t.Errorf("GCEImageForOS(%q) = %v, want ErrNoSuchImage", "plan9", err) + } +} + +// debian's official cloud image has no cloud-init (docket d39); GCEImageForOS +// must report that as a missing image, not silently fall back to another +// debian catalog entry. +func TestGCEImageForOS_NoGCEImageForEntry(t *testing.T) { + if _, err := GCEImageForOS("debian"); err == nil { + t.Error("GCEImageForOS(\"debian\") = nil error, want an error: debian has no GCE-qualified image") + } +} diff --git a/internal/provider/gce/gce.go b/internal/provider/gce/gce.go index 7f76c17..6e1170b 100644 --- a/internal/provider/gce/gce.go +++ b/internal/provider/gce/gce.go @@ -80,7 +80,7 @@ func (Provider) Create(ctx context.Context, v *config.VM) error { if err != nil { return err } - image, err := iso.GCEImageFor(v.OS) + image, err := iso.GCEImageForOS(v.OS) if err != nil { return err } From 11fc6d2ddb37c4b78bf124f2e3f4eeac9ce13fbd Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 9 Sep 2026 02:17:28 +0300 Subject: [PATCH 09/24] fix(gce): stop using the retired Google Domains checkip endpoint domains.google.com/checkip now 301s to an HTML page since Google Domains was retired, so operatorRange always failed net.ParseIP and Create aborted. Switch to ipify, whose api.ipify.org hostname carries only A records, and reject a non-v4 answer explicitly: the instance's access config is always v4-only ONE_TO_ONE_NAT, so a v6 source range in the firewall rule would never match it. Signed-off-by: NovusEdge --- internal/provider/gce/instance.go | 29 +++++++++++++------ internal/provider/gce/instance_test.go | 40 ++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/internal/provider/gce/instance.go b/internal/provider/gce/instance.go index 323a42e..fe0dd57 100644 --- a/internal/provider/gce/instance.go +++ b/internal/provider/gce/instance.go @@ -27,10 +27,13 @@ const maxMetadataValueBytes = 256 * 1024 // stops billing on its own. const defaultMaxRunDuration = 6 * time.Hour -// operatorEndpoint echoes back the caller's address in a bare-text body. It -// is Google's own dynamic-DNS update check, chosen so the request stays on -// a Google-operated host rather than a third party. -const operatorEndpoint = "https://domains.google.com/checkip" +// operatorEndpoint echoes back the caller's address in a bare-text body. +// domains.google.com/checkip filled this role until Google Domains was +// retired; it now 301s to an HTML page. ipify has no IPv6 records on this +// hostname, so the response is always v4, matching the instance's v4-only +// ONE_TO_ONE_NAT access config (api64.ipify.org would resolve v6 first on a +// dual-stack host and produce a firewall rule the instance can never match). +const operatorEndpoint = "https://api.ipify.org" // insertRequest builds the instances.insert request for v. It takes no // client and makes no call, so every shape it can produce is covered by a @@ -131,15 +134,21 @@ func diskSizeGB(s string) (int64, error) { return gb, nil } -// operatorRange asks a Google endpoint what address it saw the request come +// operatorRange asks operatorEndpoint what address it saw the request come // from, and returns it as a single-address CIDR the firewall rule can use -// as its source range. +// as its source range. The instance always gets a v4-only ONE_TO_ONE_NAT +// access config, so a v6 answer here would produce a rule the instance can +// never match; operatorRange rejects one rather than open a mismatched rule. func operatorRange(ctx context.Context) (string, error) { + return operatorRangeWith(ctx, http.DefaultClient) +} + +func operatorRangeWith(ctx context.Context, client *http.Client) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, operatorEndpoint, nil) if err != nil { return "", err } - resp, err := http.DefaultClient.Do(req) + resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("looking up the operator's address: %w", err) } @@ -149,9 +158,13 @@ func operatorRange(ctx context.Context) (string, error) { return "", err } addr := strings.TrimSpace(string(body)) - if net.ParseIP(addr) == nil { + ip := net.ParseIP(addr) + if ip == nil { return "", fmt.Errorf("operator address lookup returned %q, not an IP", addr) } + if ip.To4() == nil { + return "", fmt.Errorf("operator address lookup returned %q, an IPv6 address; the instance is v4-only", addr) + } return rangeFor(addr), nil } diff --git a/internal/provider/gce/instance_test.go b/internal/provider/gce/instance_test.go index 62c7411..e90db7e 100644 --- a/internal/provider/gce/instance_test.go +++ b/internal/provider/gce/instance_test.go @@ -1,6 +1,9 @@ package gce import ( + "context" + "io" + "net/http" "strings" "testing" @@ -8,6 +11,16 @@ import ( "github.com/novusedge/stoat/internal/settings" ) +func fakeHTTPClient(body string) *http.Client { + return &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(body)), + Header: http.Header{"Content-Type": []string{"text/plain"}}, + }, nil + })} +} + func minimalVM() *config.VM { return &config.VM{Name: "cloudy", RAM: 2048, CPUs: 2, Disk: "20G"} } @@ -81,3 +94,30 @@ func TestOperatorRangeUsesTheRightPrefixLength(t *testing.T) { t.Errorf("rangeFor(v6) = %q; a /32 on an IPv6 address opens a vast range", got) } } + +func TestOperatorRangeParsesABareV4Address(t *testing.T) { + got, err := operatorRangeWith(context.Background(), fakeHTTPClient("89.166.32.165")) + if err != nil { + t.Fatal(err) + } + if got != "89.166.32.165/32" { + t.Errorf("operatorRangeWith() = %q, want 89.166.32.165/32", got) + } +} + +// domains.google.com/checkip now 301s to an HTML page instead of answering +// with a bare address; this is that failure mode reproduced with a fake +// transport instead of a live request. +func TestOperatorRangeRejectsANonIPBody(t *testing.T) { + _, err := operatorRangeWith(context.Background(), fakeHTTPClient("...")) + if err == nil { + t.Error("operatorRangeWith() = nil error for an HTML body") + } +} + +func TestOperatorRangeRejectsIPv6(t *testing.T) { + _, err := operatorRangeWith(context.Background(), fakeHTTPClient("2001:14ba:788e:b400::19a")) + if err == nil { + t.Error("operatorRangeWith() = nil error for an IPv6 address; the instance is v4-only") + } +} From 5f2524d6fa89998376a7ec3482445052e810e5e5 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 9 Sep 2026 02:25:40 +0300 Subject: [PATCH 10/24] test(gce): recorded-transport tests for the compute REST calls Insert, Start, Stop, Delete and Get replayed against a fake http.RoundTripper with fixtures shaped like real API responses, no credentials and no network. instance_get.json's scheduling object carries no terminationTimestamp field, matching what compute v1 actually returns (docket d42). Signed-off-by: NovusEdge --- .../provider/gce/testdata/instance_get.json | 45 +++++ .../provider/gce/testdata/operation_done.json | 15 ++ .../gce/testdata/operation_running.json | 14 ++ internal/provider/gce/transport_test.go | 191 ++++++++++++++++++ 4 files changed, 265 insertions(+) create mode 100644 internal/provider/gce/testdata/instance_get.json create mode 100644 internal/provider/gce/testdata/operation_done.json create mode 100644 internal/provider/gce/testdata/operation_running.json create mode 100644 internal/provider/gce/transport_test.go diff --git a/internal/provider/gce/testdata/instance_get.json b/internal/provider/gce/testdata/instance_get.json new file mode 100644 index 0000000..cc4d099 --- /dev/null +++ b/internal/provider/gce/testdata/instance_get.json @@ -0,0 +1,45 @@ +{ + "kind": "compute#instance", + "id": "4519028374619283746", + "name": "cloudy", + "status": "RUNNING", + "zone": "https://www.googleapis.com/compute/v1/projects/engrammic/zones/europe-west4-a", + "machineType": "https://www.googleapis.com/compute/v1/projects/engrammic/zones/europe-west4-a/machineTypes/e2-custom-2-4096", + "creationTimestamp": "2026-09-08T13:00:00.123-07:00", + "lastStartTimestamp": "2026-09-08T13:00:00.000-07:00", + "scheduling": { + "onHostMaintenance": "MIGRATE", + "automaticRestart": true, + "preemptible": false, + "provisioningModel": "STANDARD", + "instanceTerminationAction": "STOP", + "maxRunDuration": { + "seconds": "28800" + } + }, + "networkInterfaces": [ + { + "kind": "compute#networkInterface", + "network": "https://www.googleapis.com/compute/v1/projects/engrammic/global/networks/default", + "networkIP": "10.164.0.5", + "accessConfigs": [ + { + "kind": "compute#accessConfig", + "type": "ONE_TO_ONE_NAT", + "name": "External NAT", + "natIP": "34.12.221.212", + "networkTier": "PREMIUM" + } + ] + } + ], + "labels": { + "stoat-owned": "true", + "stoat-vm": "cloudy", + "stoat-soft-deadline": "2026-09-08t22-14-00z" + }, + "tags": { + "items": ["stoat-cloudy"] + }, + "selfLink": "https://www.googleapis.com/compute/v1/projects/engrammic/zones/europe-west4-a/instances/cloudy" +} diff --git a/internal/provider/gce/testdata/operation_done.json b/internal/provider/gce/testdata/operation_done.json new file mode 100644 index 0000000..02bafab --- /dev/null +++ b/internal/provider/gce/testdata/operation_done.json @@ -0,0 +1,15 @@ +{ + "kind": "compute#operation", + "id": "7573813263269085000", + "name": "operation-1757366400123-abcdef0123456-a1b2c3d4-e5f6a7b8", + "operationType": "insert", + "targetLink": "https://www.googleapis.com/compute/v1/projects/engrammic/zones/europe-west4-a/instances/cloudy", + "status": "DONE", + "progress": 100, + "user": "developer@engrammic.iam.gserviceaccount.com", + "insertTime": "2026-09-08T13:00:00.123-07:00", + "startTime": "2026-09-08T13:00:00.456-07:00", + "endTime": "2026-09-08T13:00:04.789-07:00", + "zone": "https://www.googleapis.com/compute/v1/projects/engrammic/zones/europe-west4-a", + "selfLink": "https://www.googleapis.com/compute/v1/projects/engrammic/zones/europe-west4-a/operations/operation-1757366400123-abcdef0123456-a1b2c3d4-e5f6a7b8" +} diff --git a/internal/provider/gce/testdata/operation_running.json b/internal/provider/gce/testdata/operation_running.json new file mode 100644 index 0000000..064e251 --- /dev/null +++ b/internal/provider/gce/testdata/operation_running.json @@ -0,0 +1,14 @@ +{ + "kind": "compute#operation", + "id": "7573813263269085000", + "name": "operation-1757366400123-abcdef0123456-a1b2c3d4-e5f6a7b8", + "operationType": "insert", + "targetLink": "https://www.googleapis.com/compute/v1/projects/engrammic/zones/europe-west4-a/instances/cloudy", + "status": "RUNNING", + "progress": 10, + "user": "developer@engrammic.iam.gserviceaccount.com", + "insertTime": "2026-09-08T13:00:00.123-07:00", + "startTime": "2026-09-08T13:00:00.456-07:00", + "zone": "https://www.googleapis.com/compute/v1/projects/engrammic/zones/europe-west4-a", + "selfLink": "https://www.googleapis.com/compute/v1/projects/engrammic/zones/europe-west4-a/operations/operation-1757366400123-abcdef0123456-a1b2c3d4-e5f6a7b8" +} diff --git a/internal/provider/gce/transport_test.go b/internal/provider/gce/transport_test.go new file mode 100644 index 0000000..56cab43 --- /dev/null +++ b/internal/provider/gce/transport_test.go @@ -0,0 +1,191 @@ +package gce + +import ( + "context" + "io" + "net/http" + "os" + "strings" + "testing" + + compute "cloud.google.com/go/compute/apiv1" + computepb "cloud.google.com/go/compute/apiv1/computepb" + "google.golang.org/api/option" + + "github.com/novusedge/stoat/internal/config" + "github.com/novusedge/stoat/internal/settings" +) + +// sequencedTransport returns each body in order on successive requests and +// repeats the last body once the sequence is exhausted, so a long-running +// operation's second Poll (after Wait's Done() check on the first) still +// gets an answer. +func sequencedTransport(t *testing.T, bodies ...string) http.RoundTripper { + t.Helper() + raw := make([]string, len(bodies)) + for i, name := range bodies { + b, err := os.ReadFile("testdata/" + name) + if err != nil { + t.Fatalf("reading testdata/%s: %v", name, err) + } + raw[i] = string(b) + } + n := 0 + return roundTripFunc(func(*http.Request) (*http.Response, error) { + i := n + if i >= len(raw) { + i = len(raw) - 1 + } + n++ + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(raw[i])), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }) +} + +func fakeCompute(t *testing.T, bodies ...string) (*compute.InstancesClient, *compute.FirewallsClient) { + t.Helper() + rt := sequencedTransport(t, bodies...) + opts := []option.ClientOption{ + option.WithHTTPClient(&http.Client{Transport: rt}), + option.WithoutAuthentication(), + option.WithEndpoint("https://compute.googleapis.com/compute/v1/"), + } + ic, err := compute.NewInstancesRESTClient(context.Background(), opts...) + if err != nil { + t.Fatalf("building fake instances client: %v", err) + } + fc, err := compute.NewFirewallsRESTClient(context.Background(), opts...) + if err != nil { + t.Fatalf("building fake firewalls client: %v", err) + } + return ic, fc +} + +// TestTransportCreatesFirewallThenInstance replays what Create does: an +// Insert on the Firewalls API, waited to completion, then an Insert on the +// Instances API built from insertRequest, also waited to completion. Each +// Insert's initial response is RUNNING; Wait's first Poll finds it DONE, the +// shape a real create actually returns rather than a synchronous success. +func TestTransportCreatesFirewallThenInstance(t *testing.T) { + ic, fc := fakeCompute(t, "operation_running.json", "operation_done.json") + defer ic.Close() + defer fc.Close() + ctx := context.Background() + + fwOp, err := fc.Insert(ctx, &computepb.InsertFirewallRequest{ + Project: "p", + FirewallResource: firewallRule("cloudy", "203.0.113.1/32"), + }) + if err != nil { + t.Fatalf("Firewalls.Insert: %v", err) + } + if err := fwOp.Wait(ctx); err != nil { + t.Fatalf("firewall op.Wait: %v", err) + } + + req, err := insertRequest(minimalVM(), minimalSettings(), "img", "#cloud-config\n", "203.0.113.1/32") + if err != nil { + t.Fatal(err) + } + insOp, err := ic.Insert(ctx, req) + if err != nil { + t.Fatalf("Instances.Insert: %v", err) + } + if err := insOp.Wait(ctx); err != nil { + t.Fatalf("instance op.Wait: %v", err) + } +} + +func TestTransportStartsAnInstance(t *testing.T) { + ic, _ := fakeCompute(t, "operation_running.json", "operation_done.json") + defer ic.Close() + ctx := context.Background() + op, err := ic.Start(ctx, &computepb.StartInstanceRequest{Project: "p", Zone: "europe-west4-a", Instance: "cloudy"}) + if err != nil { + t.Fatalf("Instances.Start: %v", err) + } + if err := op.Wait(ctx); err != nil { + t.Fatalf("op.Wait: %v", err) + } +} + +func TestTransportStopsAnInstance(t *testing.T) { + ic, _ := fakeCompute(t, "operation_running.json", "operation_done.json") + defer ic.Close() + ctx := context.Background() + op, err := ic.Stop(ctx, &computepb.StopInstanceRequest{Project: "p", Zone: "europe-west4-a", Instance: "cloudy"}) + if err != nil { + t.Fatalf("Instances.Stop: %v", err) + } + if err := op.Wait(ctx); err != nil { + t.Fatalf("op.Wait: %v", err) + } +} + +// TestTransportDestroysInstanceThenFirewall replays Destroy's order: the +// instance is gone before its firewall rule is deleted. +func TestTransportDestroysInstanceThenFirewall(t *testing.T) { + ic, fc := fakeCompute(t, "operation_running.json", "operation_done.json") + defer ic.Close() + defer fc.Close() + ctx := context.Background() + + delOp, err := ic.Delete(ctx, &computepb.DeleteInstanceRequest{Project: "p", Zone: "europe-west4-a", Instance: "cloudy"}) + if err != nil { + t.Fatalf("Instances.Delete: %v", err) + } + if err := delOp.Wait(ctx); err != nil { + t.Fatalf("delete op.Wait: %v", err) + } + + if err := deleteFirewallRule(ctx, fc, settings.GCE{Project: "p"}, "cloudy"); err != nil { + t.Fatalf("deleteFirewallRule: %v", err) + } +} + +// TestTransportStatusReadsARecordedInstance exercises Status against a full +// instances.get response recorded from a real project. Its scheduling +// object carries no terminationTimestamp field: the compute v1 API never +// returns one (docket d42), so hardDeadline must work from lastStartTimestamp +// and maxRunDuration alone. +func TestTransportStatusReadsARecordedInstance(t *testing.T) { + ic, _ := fakeCompute(t, "instance_get.json") + defer ic.Close() + ctx := context.Background() + v := &config.VM{Name: "cloudy", Dir: "/data/vms/cloudy"} + s := settings.GCE{Project: "engrammic", Zone: "europe-west4-a"} + + st, err := statusFor(ctx, ic, s, v) + if err != nil { + t.Fatal(err) + } + if !st.Running || st.Raw != "RUNNING" { + t.Errorf("statusFor() = %+v, want a running RUNNING status", st) + } + + ep, err := endpointFor(ctx, ic, s, v) + if err != nil { + t.Fatal(err) + } + if ep.Host != "34.12.221.212" { + t.Errorf("endpointFor().Host = %q, want the recorded natIP", ep.Host) + } + + inst, err := ic.Get(ctx, &computepb.GetInstanceRequest{Project: s.Project, Zone: s.Zone, Instance: v.Name}) + if err != nil { + t.Fatal(err) + } + if inst.GetScheduling().GetMaxRunDuration().GetSeconds() != 28800 { + t.Errorf("maxRunDuration = %d, want 28800", inst.GetScheduling().GetMaxRunDuration().GetSeconds()) + } + deadline, ok := hardDeadline(inst) + if !ok { + t.Fatal("hardDeadline() not ok against a recorded running instance") + } + if deadline.IsZero() { + t.Error("hardDeadline() returned the zero time") + } +} From e431b75f17fca27f48be9a4c1f09060f17fd5f28 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 9 Sep 2026 02:29:49 +0300 Subject: [PATCH 11/24] feat(cli): add the WHERE column to stoat ls core.VM carried no Provider field, so ls had nothing to show which surface a VM runs on. WHERE reads local for the empty (qemu) provider and the provider's own name otherwise; the JSON wire carries the same fact, omitted for qemu to keep the existing golden shape. Signed-off-by: NovusEdge --- internal/cli/cli_test.go | 31 ++++++++++++++++++++++++++++--- internal/cli/run_vm.go | 20 +++++++++++++++----- internal/cli/wire/dto.go | 5 +++++ internal/cli/wire/dto_test.go | 15 +++++++++++++++ internal/core/vm.go | 5 +++++ 5 files changed, 68 insertions(+), 8 deletions(-) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 6c0807f..e6fe156 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -11,6 +11,8 @@ import ( "github.com/novusedge/stoat/internal/config" "github.com/novusedge/stoat/internal/core" + "github.com/novusedge/stoat/internal/provider" + "github.com/novusedge/stoat/internal/provider/fake" "github.com/novusedge/stoat/internal/testutil" ) @@ -320,20 +322,43 @@ func TestRunLSOutput(t *testing.T) { if len(lines) != 3 { t.Fatalf("got %d lines, want 3 (header + good + broken): %q", len(lines), out.String()) } - wantHeader := fmt.Sprintf("%-15s %-5s %-8s %-5s %-6s %-6s %s", "NAME", "MODE", "STATE", "CPUS", "RAM", "SSH", "PROJECT") + wantHeader := fmt.Sprintf("%-15s %-5s %-8s %-5s %-5s %-6s %-6s %s", "NAME", "MODE", "STATE", "WHERE", "CPUS", "RAM", "SSH", "PROJECT") if lines[0] != wantHeader { t.Errorf("header = %q, want %q", lines[0], wantHeader) } - wantGood := fmt.Sprintf("%-15s %-5s %s %-5d %-6d %-6d %s", "good", "live", "stopped ", 2, 1024, 2200, "-") + wantGood := fmt.Sprintf("%-15s %-5s %s %-5s %-5d %-6d %-6d %s", "good", "live", "stopped ", "local", 2, 1024, 2200, "-") if lines[1] != wantGood { t.Errorf("good row = %q, want %q", lines[1], wantGood) } - wantBrokenPrefix := fmt.Sprintf("%-15s %-5s %s %-5s %-6s %-4s ", "broken-vm", "-", "broken ", "-", "-", "-") + wantBrokenPrefix := fmt.Sprintf("%-15s %-5s %s %-5s %-5s %-6s %-4s ", "broken-vm", "-", "broken ", "-", "-", "-", "-") if !strings.HasPrefix(lines[2], wantBrokenPrefix) { t.Errorf("broken row = %q, want prefix %q", lines[2], wantBrokenPrefix) } } +// TestRunLSWhereColumnNamesTheProvider registers a fake "gce" provider (the +// real one dials the network on Status, forbidden in a test) so a VM created +// with Provider: "gce" lists as such, distinguishing it from a local one. +func TestRunLSWhereColumnNamesTheProvider(t *testing.T) { + cliRoot(t) + provider.Register("gce", &fake.Provider{}) + if err := (&config.VM{Name: "cloudy", Mode: "cloud", RAM: 4096, CPUs: 2, SSHPort: 22, Provider: "gce"}).Save(); err != nil { + t.Fatal(err) + } + + var out, errOut bytes.Buffer + code := Main([]string{"ls"}, "test", nil, &out, &errOut) + if code != ExitOK { + t.Fatalf("ls: exit %d, stderr %q", code, errOut.String()) + } + if !strings.Contains(out.String(), "WHERE") { + t.Error("ls header has no WHERE column") + } + if !strings.Contains(out.String(), "gce") { + t.Errorf("ls output does not show the gce VM's provider:\n%s", out.String()) + } +} + // runDown must refuse before printing its "stopping..." progress line, not // after, matching the pre-core behaviour where the running/not-running // check happened before anything was written to stdout. diff --git a/internal/cli/run_vm.go b/internal/cli/run_vm.go index 5fa5b15..1008655 100644 --- a/internal/cli/run_vm.go +++ b/internal/cli/run_vm.go @@ -36,7 +36,7 @@ func runLS(a *Args, stdout, stderr io.Writer) int { return a.ok(stdout, wire.VMList{VMs: wire.FromVMs(vms, core.GraphicalSession())}) } - fmt.Fprintf(stdout, "%-15s %-5s %-8s %-5s %-6s %-6s %s\n", "NAME", "MODE", "STATE", "CPUS", "RAM", "SSH", "PROJECT") + fmt.Fprintf(stdout, "%-15s %-5s %-8s %-5s %-5s %-6s %-6s %s\n", "NAME", "MODE", "STATE", "WHERE", "CPUS", "RAM", "SSH", "PROJECT") // core.List() sorts every VM, broken ones included, together by name, // so a broken VM can interleave alphabetically with good ones. The // original two calls (config.List then config.ListBroken) printed every @@ -52,8 +52,8 @@ func runLS(a *Args, stdout, stderr io.Writer) int { if v.State == core.StateRunning { state = "running" } - fmt.Fprintf(stdout, "%-15s %-5s %s %-5d %-6d %-6d %s\n", - v.Name, v.Mode, a.prose(stdout).State(state, 8), v.CPUs, v.RAM, v.SSHPort, projectCell(v)) + fmt.Fprintf(stdout, "%-15s %-5s %s %-5s %-5d %-6d %-6d %s\n", + v.Name, v.Mode, a.prose(stdout).State(state, 8), whereCell(v), v.CPUs, v.RAM, v.SSHPort, projectCell(v)) } // Broken VMs are real entries: hiding them is the bug that was already // reported once. They get dashes for the fields a broken vm.toml can't @@ -62,12 +62,22 @@ func runLS(a *Args, stdout, stderr io.Writer) int { if v.State != core.StateBroken { continue } - fmt.Fprintf(stdout, "%-15s %-5s %s %-5s %-6s %-4s %-6s %s\n", - v.Name, "-", a.prose(stdout).State("broken", 8), "-", "-", "-", "-", oneLine(v.Error)) + fmt.Fprintf(stdout, "%-15s %-5s %s %-5s %-5s %-6s %-4s %-6s %s\n", + v.Name, "-", a.prose(stdout).State("broken", 8), "-", "-", "-", "-", "-", oneLine(v.Error)) } return ExitOK } +// whereCell renders the WHERE column: "local" for qemu (the empty Provider +// field, matching provider.For's own default) and the provider's own name +// otherwise. +func whereCell(v core.VM) string { + if v.Provider == "" { + return "local" + } + return v.Provider +} + // projectCell renders the PROJECT column: the declaring directory, marked // when it is gone, and "-" for a VM stoat new created. func projectCell(v core.VM) string { diff --git a/internal/cli/wire/dto.go b/internal/cli/wire/dto.go index 10b3d82..7ac0bca 100644 --- a/internal/cli/wire/dto.go +++ b/internal/cli/wire/dto.go @@ -115,6 +115,10 @@ type VM struct { Project string `json:"project"` Key string `json:"key"` ProjectMissing bool `json:"project_missing"` + + // Provider is the execution surface: absent for qemu (the empty + // core.VM.Provider), named otherwise ("gce"). + Provider string `json:"provider,omitempty"` } // RecipeState is one recipe's redacted per-VM state. @@ -213,6 +217,7 @@ func FromVM(v core.VM, graphical bool) VM { Project: v.Project, Key: v.Key, ProjectMissing: v.ProjectMissing, + Provider: v.Provider, } } diff --git a/internal/cli/wire/dto_test.go b/internal/cli/wire/dto_test.go index a0018bb..1d3106e 100644 --- a/internal/cli/wire/dto_test.go +++ b/internal/cli/wire/dto_test.go @@ -51,6 +51,21 @@ func TestVMBrokenHasNoDisplay(t *testing.T) { } } +// TestVMProviderNamesTheNonQEMUSurface pins provider omitted (not "qemu") +// for the default surface and present for anything else, per the plan's +// "Interface calls made" WHERE column: JSON and text must agree. +func TestVMProviderNamesTheNonQEMUSurface(t *testing.T) { + local := marshal(t, FromVM(core.VM{Name: "work"}, true)) + if strings.Contains(local, `"provider"`) { + t.Errorf("a qemu VM's provider reached the wire: %s", local) + } + + cloud := marshal(t, FromVM(core.VM{Name: "cloudy", Provider: "gce"}, true)) + if !strings.Contains(cloud, `"provider":"gce"`) { + t.Errorf("provider not carried to the wire: %s", cloud) + } +} + func TestVMBrokenCarriesError(t *testing.T) { v := core.VM{Name: "oldvm", State: core.StateBroken, Error: "broken vm.toml: oldvm: toml: line 4: ..."} got := marshal(t, FromVM(v, true)) diff --git a/internal/core/vm.go b/internal/core/vm.go index 772991a..ad31809 100644 --- a/internal/core/vm.go +++ b/internal/core/vm.go @@ -215,6 +215,10 @@ type VM struct { // from the active project. It is not in vm.toml: only a loaded stoat.toml // knows it, and two projects could name the same VM differently. Key string + + // Provider is the execution surface, empty meaning qemu; see + // config.VM.Provider and provider.For. + Provider string } // A VM's IDENTITY is its DIRECTORY under the data root. It is never the @@ -311,6 +315,7 @@ func fromConfigUnchecked(v *config.VM) VM { AgentAccess: v.AgentAccess, Project: v.Project, ProjectMissing: v.Project != "" && !dirExists(v.Project), + Provider: v.Provider, Paths: Paths{ Dir: v.Dir, Disk: v.DiskPath(), From da0dccfcae19c16a3c68a154e6e9d2548127c890 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 9 Sep 2026 02:34:13 +0300 Subject: [PATCH 12/24] feat(cli): show a VM's provider in stoat get A qemu VM gets one line; a v2 VM gets the provider name plus the project and zone it was created in, read from vm.toml directly since core.VM does not carry them. core.load() checked Root()/name/vm.toml directly, never DirFor, so Get() on any v2 VM (gce included) always reported not found. Route through config.Exists instead. Signed-off-by: NovusEdge --- internal/cli/run_get.go | 27 +++++++++++++++++++++ internal/cli/subcommands_test.go | 41 ++++++++++++++++++++++++++++++++ internal/core/vm.go | 2 +- 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/internal/cli/run_get.go b/internal/cli/run_get.go index 17b0208..31da8cf 100644 --- a/internal/cli/run_get.go +++ b/internal/cli/run_get.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/novusedge/stoat/internal/cli/wire" + "github.com/novusedge/stoat/internal/config" "github.com/novusedge/stoat/internal/core" ) @@ -21,6 +22,7 @@ func runGet(a *Args, stdout, stderr io.Writer) int { fmt.Fprintf(stdout, "name: %s\n", v.Name) fmt.Fprintf(stdout, "os: %s\n", v.OS) fmt.Fprintf(stdout, "mode: %s\n", v.Mode) + printProviderBlock(stdout, v) fmt.Fprintf(stdout, "backend: %s\n", v.Backend) fmt.Fprintf(stdout, "state: %s\n", v.State) fmt.Fprintf(stdout, "cpus: %d\n", v.CPUs) @@ -58,6 +60,31 @@ func runGet(a *Args, stdout, stderr io.Writer) int { return ExitOK } +// printProviderBlock prints the provider line, and for a non-qemu VM the +// project and zone it was created in. Machine type, address and the two +// deadlines are not printed: nothing in the Provider interface exposes them +// yet (Status carries only a running bit and the provider's raw status +// word), so showing them here would mean guessing rather than reporting. +func printProviderBlock(stdout io.Writer, v core.VM) { + provider := v.Provider + if provider == "" { + provider = "qemu" + fmt.Fprintf(stdout, "provider: %s\n", provider) + return + } + fmt.Fprintf(stdout, "provider: %s\n", provider) + cfg, err := config.Load(v.Name) + if err != nil { + return + } + if cfg.GCEProject != "" { + fmt.Fprintf(stdout, "gcp project: %s\n", cfg.GCEProject) + } + if cfg.GCEZone != "" { + fmt.Fprintf(stdout, "zone: %s\n", cfg.GCEZone) + } +} + func sortedKeys(values map[string]string) []string { keys := make([]string, 0, len(values)) for key := range values { diff --git a/internal/cli/subcommands_test.go b/internal/cli/subcommands_test.go index 27035ed..0c0a976 100644 --- a/internal/cli/subcommands_test.go +++ b/internal/cli/subcommands_test.go @@ -10,6 +10,8 @@ import ( "github.com/novusedge/stoat/internal/cli/wire" "github.com/novusedge/stoat/internal/config" "github.com/novusedge/stoat/internal/core" + "github.com/novusedge/stoat/internal/provider" + "github.com/novusedge/stoat/internal/provider/fake" ) // saveVM writes a minimal stopped VM into the current test data root. @@ -64,6 +66,45 @@ func TestGetHumanReadable(t *testing.T) { } } +// TestGetShowsProviderQEMU pins the one-line addition for a local VM: "get" +// must say which surface a VM runs on even when it is the default one. +func TestGetShowsProviderQEMU(t *testing.T) { + cliRoot(t) + saveVM(t, &config.VM{Name: "work", Mode: "live", RAM: 1024, CPUs: 1, SSHPort: 2200}) + + var out, errOut strings.Builder + if code := Main([]string{"get", "work"}, "test", strings.NewReader(""), &out, &errOut); code != ExitOK { + t.Fatalf("exit = %d: %s", code, errOut.String()) + } + if !strings.Contains(out.String(), "provider: qemu") { + t.Errorf("output missing the provider line: %q", out.String()) + } +} + +// TestGetShowsProviderBlockForGCE pins the plan's provider block: name, gcp +// project and zone, present only for a non-qemu VM. +func TestGetShowsProviderBlockForGCE(t *testing.T) { + cliRoot(t) + // Status must not dial the network: register a fake rather than rely on + // another test in this package having already done so. + provider.Register("gce", &fake.Provider{}) + saveVM(t, &config.VM{ + Name: "cloudy", Mode: "cloud", OS: "ubuntu", RAM: 4096, CPUs: 2, SSHPort: 22, + Provider: "gce", GCEProject: "engrammic", GCEZone: "europe-west4-a", + }) + + var out, errOut strings.Builder + if code := Main([]string{"get", "cloudy"}, "test", strings.NewReader(""), &out, &errOut); code != ExitOK { + t.Fatalf("exit = %d: %s", code, errOut.String()) + } + got := out.String() + for _, want := range []string{"provider: gce", "gcp project: engrammic", "zone: europe-west4-a"} { + if !strings.Contains(got, want) { + t.Errorf("output missing %q:\n%s", want, got) + } + } +} + func TestGetMissingVMIsNotFound(t *testing.T) { cliRoot(t) code, objs := runJSON(t, "get", "ghost") diff --git a/internal/core/vm.go b/internal/core/vm.go index ad31809..cd0d5c5 100644 --- a/internal/core/vm.go +++ b/internal/core/vm.go @@ -242,7 +242,7 @@ type VM struct { // parse (ErrBroken). Start/Stop/Destroy need that distinction to give a // broken VM a real error instead of a raw TOML parse message. func load(name string) (*config.VM, error) { - if _, err := os.Stat(filepath.Join(config.Root(), name, "vm.toml")); err != nil { + if !config.Exists(name) { return nil, fmt.Errorf("%w: %s", ErrNotFound, name) } v, err := config.Load(name) From 4f6141ceda30c9cd8f5fdc87f9d5a64281246505 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 9 Sep 2026 02:37:55 +0300 Subject: [PATCH 13/24] fix(tui): edit's running check asks the provider, not qemu qemu.Running dials a local pidfile; a gce VM has none even while its provider reports it live, so the edit pane's restart note and enter-key save both read a running VM as stopped. Signed-off-by: NovusEdge --- internal/tui/edit.go | 15 ++++++++++++--- internal/tui/edit_test.go | 23 +++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/internal/tui/edit.go b/internal/tui/edit.go index 4bcbc40..79d8934 100644 --- a/internal/tui/edit.go +++ b/internal/tui/edit.go @@ -1,6 +1,7 @@ package tui import ( + "context" "errors" "fmt" "path/filepath" @@ -12,11 +13,19 @@ import ( "github.com/novusedge/stoat/internal/config" "github.com/novusedge/stoat/internal/core" - "github.com/novusedge/stoat/internal/qemu" "github.com/novusedge/stoat/internal/recipes" "github.com/novusedge/stoat/internal/theme" ) +// vmRunning asks v's provider whether it is running. qemu.Running checks a +// local pidfile, which a non-qemu VM never has even while its provider +// reports it live, so this replaces both call sites that used to read it +// directly for a gce VM's sake. +func vmRunning(v *config.VM) bool { + state, err := core.StateOf(context.Background(), v) + return err == nil && state == core.StateRunning +} + // editModel is the in-TUI editor for an existing VM. It replaces the round // trip through $EDITOR for the fields worth changing. "E" still opens the // raw editor for anything this form does not expose. @@ -419,7 +428,7 @@ func (m model) updateEdit(msg tea.Msg) (tea.Model, tea.Cmd) { m.edit.err = err.Error() return m, nil } - saved, errText := saveEdit(m.edit.name(), p, qemu.Running(m.edit.vm)) + saved, errText := saveEdit(m.edit.name(), p, vmRunning(m.edit.vm)) if errText != "" { m.edit.err = errText return m, nil @@ -520,7 +529,7 @@ func (m model) viewEdit() string { if !e.dirty() { note(dimStyle.Render("no changes")) } - if qemu.Running(e.vm) { + if vmRunning(e.vm) { note(warnStyle.Render("running: ram/cpus/ssh apply on restart")) } if e.err != "" { diff --git a/internal/tui/edit_test.go b/internal/tui/edit_test.go index 6dd759d..74d4dbc 100644 --- a/internal/tui/edit_test.go +++ b/internal/tui/edit_test.go @@ -11,8 +11,31 @@ import ( "github.com/novusedge/stoat/internal/config" "github.com/novusedge/stoat/internal/core" + "github.com/novusedge/stoat/internal/provider" + "github.com/novusedge/stoat/internal/provider/fake" ) +// TestVMRunningAsksTheProviderNotQEMU pins the fix for edit.go's two former +// qemu.Running calls: a VM whose provider is not qemu has no local process +// for that check to find, so it always read "not running" even while the +// provider reported it live. +func TestVMRunningAsksTheProviderNotQEMU(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + // fake.Install initializes the running map fake.Provider needs SetRunning + // for; registering the same instance again under "gce" reuses that map. + f := fake.Install(t) + provider.Register("gce", f) + v := &config.VM{Name: "cloudy", Provider: "gce"} + + if vmRunning(v) { + t.Error("vmRunning() = true before the fake provider reports it running") + } + f.SetRunning("cloudy") + if !vmRunning(v) { + t.Error("vmRunning() = false with the provider reporting it running; qemu.Running cannot see a remote VM") + } +} + // editFixture saves a real VM under a fresh STOAT_HOME and opens it for // editing. It must be a real, saved VM, not a bare struct. The form routes // through core.Update, which loads by name under config.Root() (see From 806a0203a87c95244f79988d631222e56b0480c9 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 9 Sep 2026 02:40:29 +0300 Subject: [PATCH 14/24] fix(tui): refuse to type a console password on a non-qemu VM qemu.TypeConsolePassword dials a local monitor socket that only a qemu VM has. A gce VM can carry a console password too, and pressing t on a running one would try to type it through a socket that was never opened (docket d46). Signed-off-by: NovusEdge --- internal/tui/detail.go | 7 +++++++ internal/tui/detail_test.go | 17 +++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/internal/tui/detail.go b/internal/tui/detail.go index 68e77d6..60bf745 100644 --- a/internal/tui/detail.go +++ b/internal/tui/detail.go @@ -258,6 +258,13 @@ func (m model) updateDetail(msg tea.Msg) (tea.Model, tea.Cmd) { cmd := m.showToast("no console password to type", true) return m, cmd } + // qemu.TypeConsolePassword dials a local monitor socket that + // only a qemu VM has (docket d46): a gce VM's console password + // exists but nothing here can type it in. + if v.Provider != "" { + cmd := m.showToast("console password can only be typed on a local qemu VM", true) + return m, cmd + } return m, typeConsolePassword(v) case "c": v := m.detail.vm diff --git a/internal/tui/detail_test.go b/internal/tui/detail_test.go index fc3d709..2668da8 100644 --- a/internal/tui/detail_test.go +++ b/internal/tui/detail_test.go @@ -232,6 +232,23 @@ func TestTypeConsolePasswordKeyRefusesWhenUnavailable(t *testing.T) { } } +// TestTypeConsolePasswordKeyRefusesOnANonQEMUProvider proves "t" never dials +// qemu.TypeConsolePassword's monitor socket for a VM with no local qemu +// process, even a running one with a password set (docket d46): a gce VM +// carries a console password of its own, but no monitor socket exists for +// it to be typed through. +func TestTypeConsolePasswordKeyRefusesOnANonQEMUProvider(t *testing.T) { + v := core.VM{Name: "cloudy", Mode: "cloud", Provider: "gce", ConsolePassword: "stoat", State: core.StateRunning} + m := model{screen: screenDetail, detail: detailModel{vm: v}} + + newM, cmd := m.updateDetail(keyMsg("t")) + got := newM.(model) + + if got.toast.text == "" || !got.toast.err { + t.Fatalf("expected an error toast refusing to type the password, got %+v (cmd nil=%v)", got.toast, cmd == nil) + } +} + // The TUI detail pane is a sink in its own right. It must render stored // recipe state through the redacted core projection, even when a lower layer // accidentally hands it a raw value. From 9f9b07fd9dd6bbd97c61ab440f3a16d7d3ef8156 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 9 Sep 2026 02:43:08 +0300 Subject: [PATCH 15/24] docs: document the WHERE column and get's provider block Signed-off-by: NovusEdge --- docs/reference/cli.md | 16 ++++++++++------ docs/reference/json.md | 7 ++++++- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index c2546a5..b75d6d3 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -95,13 +95,14 @@ Lists every VM directory under the data root, plus any directory whose `vm.toml` ``` $ stoat ls -NAME MODE STATE CPUS RAM SSH -work live running 4 4096 2222 -scratch disk stopped 2 2048 2223 -oldvm - broken - - - unexpected token near line 4 +NAME MODE STATE WHERE CPUS RAM SSH +work live running local 4 4096 2222 +cloudy cloud running gce 2 4096 22 +scratch disk stopped local 2 2048 2223 +oldvm - broken - - - - unexpected token near line 4 ``` -The `STATE` column is colored (green `running`, red `broken`) when [color is enabled](#scripting). `-q`/`--quiet` is accepted but has no effect on `ls`'s output. +`WHERE` names the execution surface: `local` for a qemu VM, or the provider's own name (`gce`) otherwise. The `STATE` column is colored (green `running`, red `broken`) when [color is enabled](#scripting). `-q`/`--quiet` is accepted but has no effect on `ls`'s output. `--project` filters the list to VMs the `stoat.toml` in the current directory declares. It refuses outside a project. @@ -138,13 +139,14 @@ A VM `stoat.toml` declares but that does not exist yet shows state `missing`. An ## `stoat get ` -Prints one VM's fields as `key: value` lines: name, os, mode, backend, state, cpus, ram, disk, share, ssh port, ssh user, recipes, forwards, display, plus an `error:` line when the VM is broken. +Prints one VM's fields as `key: value` lines: name, os, mode, provider, backend, state, cpus, ram, disk, share, ssh port, ssh user, recipes, forwards, display, plus an `error:` line when the VM is broken. ``` $ stoat get work name: work os: alpine mode: live +provider: qemu backend: apkovl state: running cpus: 4 @@ -160,6 +162,8 @@ display: a qemu window `display` is the only line here that is not a `vm.toml` field. See [`stoat up`](#stoat-up-name) for what it means and why the answer changes. It is omitted entirely for a broken VM, whose `vm.toml` supplies neither of the facts the answer depends on. +A non-qemu VM gets `gcp project:` and `zone:` lines right after `provider:`, before `backend:`. + **Exit codes:** 0 on success; 1 if the VM can't be loaded. ## `stoat create --image=IMAGE` diff --git a/docs/reference/json.md b/docs/reference/json.md index 99585b6..0259c83 100644 --- a/docs/reference/json.md +++ b/docs/reference/json.md @@ -210,8 +210,13 @@ VM {"name":"work","os":"alpine","mode":"cloud","backend":"cloudinit", "forwards":[{"host_port":8080,"guest_port":80}], "allow_exec":false,"agent_access":"manage","display":"vnc", "error":"only on a broken VM", - "project":"/home/u/myrepo","key":"dev","project_missing":false} + "project":"/home/u/myrepo","key":"dev","project_missing":false, + "provider":"gce"} +``` + +`provider` is omitted for a qemu VM (the empty `core.VM.Provider`), and carries the provider's own name (`"gce"`) otherwise. +```json VMStatus {"name":"work",...VM fields...,"health":"ok","recipes_detail":[ {"name":"xfce","applied":true,"version":"1.2","at":"...", "health":"unknown","params":{},"outputs":{}}]} From 8e2760d29c6a5a73a3f2d1a61b55647b184d3717 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 9 Sep 2026 03:08:55 +0300 Subject: [PATCH 16/24] fix(core): register gce and expose its facts through Provider core never imported internal/provider/gce, so its init never ran and provider.For("gce") failed in the real binary; only tests, which import the package directly, exercised it. Detailer is a new optional interface: project, zone, machine type, address and both stop deadlines, for a caller that wants more than Status's running bit. gce implements it with one instances.get call. Create now resolves settings.ResolveGCE itself and persists the result, rather than leaving a gce VM with no project or zone at all. Signed-off-by: NovusEdge --- internal/core/core.go | 21 +++++++++++++++++- internal/core/vm.go | 34 +++++++++++++++++++++++++++-- internal/provider/gce/gce.go | 29 +++++++++++++++++++++++++ internal/provider/provider.go | 41 +++++++++++++++++++++++++++++++++++ 4 files changed, 122 insertions(+), 3 deletions(-) diff --git a/internal/core/core.go b/internal/core/core.go index 6837a56..b477dd6 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -20,8 +20,10 @@ import ( "github.com/novusedge/stoat/internal/config" "github.com/novusedge/stoat/internal/coreerr" "github.com/novusedge/stoat/internal/provider" + _ "github.com/novusedge/stoat/internal/provider/gce" _ "github.com/novusedge/stoat/internal/provider/qemu" "github.com/novusedge/stoat/internal/recipes" + "github.com/novusedge/stoat/internal/settings" ) // Typed errors, because every caller branches on them and string matching is @@ -115,6 +117,13 @@ type Spec struct { // Provider is the execution surface. Empty means qemu, matching // config.VM.Provider and provider.For. Provider string + + // GCEProject and GCEZone are the --gcp-project/--gcp-zone flags, honored + // only when Provider is "gce". Create resolves them through + // settings.ResolveGCE (flag, then config.toml, then gcloud), so either + // or both may be empty here. + GCEProject string + GCEZone string } // Create validates a Spec, writes vm.toml and allocates the disk. It does not @@ -144,6 +153,14 @@ func Create(s Spec) (VM, error) { if err != nil { return VM{}, err } + var gceSource settings.Source + if v.Provider == "gce" { + resolved, err := settings.ResolveGCE(s.GCEProject, s.GCEZone) + if err != nil { + return VM{}, fmt.Errorf("%w: %v", ErrInvalidSpec, err) + } + v.GCEProject, v.GCEZone, gceSource = resolved.Project, resolved.Zone, resolved.Source + } if err := v.Save(); err != nil { return VM{}, err } @@ -168,7 +185,9 @@ func Create(s Spec) (VM, error) { _ = os.RemoveAll(v.Dir) return VM{}, err } - return fromConfig(v), nil + out := fromConfig(v) + out.GCESource = string(gceSource) + return out, nil } // Plan is Create without side effects: it validates a Spec and returns the diff --git a/internal/core/vm.go b/internal/core/vm.go index cd0d5c5..dbbda7f 100644 --- a/internal/core/vm.go +++ b/internal/core/vm.go @@ -14,6 +14,7 @@ import ( "github.com/novusedge/stoat/internal/guest" "github.com/novusedge/stoat/internal/iso" "github.com/novusedge/stoat/internal/provider" + _ "github.com/novusedge/stoat/internal/provider/gce" _ "github.com/novusedge/stoat/internal/provider/qemu" "github.com/novusedge/stoat/internal/recipes" ) @@ -219,6 +220,21 @@ type VM struct { // Provider is the execution surface, empty meaning qemu; see // config.VM.Provider and provider.For. Provider string + + // GCEProject, GCEZone and GCESource mirror config.VM's fields, plus + // where they came from. GCESource is set only by Create's own return + // value: it is never stored in vm.toml, so a later Get leaves it empty. + GCEProject string + GCEZone string + GCESource string + + // MachineType, Address and the two deadlines come from the provider's + // Detailer interface, not vm.toml; empty for qemu and for a gce VM + // whose Status call failed. + MachineType string + Address string + HardDeadline time.Time + SoftDeadline time.Time } // A VM's IDENTITY is its DIRECTORY under the data root. It is never the @@ -275,13 +291,21 @@ func fromConfigUnchecked(v *config.VM) VM { state := StateStopped var startedAt time.Time var stateErr string + var details provider.Details p, err := providerFor(v) if err != nil { state, stateErr = StateBroken, err.Error() } else if s, err := p.Status(context.Background(), v); err != nil { state, stateErr = StateBroken, err.Error() - } else if s.Running { - state, startedAt = StateRunning, s.StartedAt + } else { + if s.Running { + state, startedAt = StateRunning, s.StartedAt + } + // Details is best-effort: a get that failed after Status already + // succeeded must not turn a running VM broken over a display fact. + if d, ok := p.(provider.Detailer); ok { + details, _ = d.Details(context.Background(), v) + } } osName, backend := inferMissing(v) return VM{ @@ -316,6 +340,12 @@ func fromConfigUnchecked(v *config.VM) VM { Project: v.Project, ProjectMissing: v.Project != "" && !dirExists(v.Project), Provider: v.Provider, + GCEProject: v.GCEProject, + GCEZone: v.GCEZone, + MachineType: details.MachineType, + Address: details.Address, + HardDeadline: details.HardDeadline, + SoftDeadline: details.SoftDeadline, Paths: Paths{ Dir: v.Dir, Disk: v.DiskPath(), diff --git a/internal/provider/gce/gce.go b/internal/provider/gce/gce.go index 6e1170b..0f60616 100644 --- a/internal/provider/gce/gce.go +++ b/internal/provider/gce/gce.go @@ -4,6 +4,7 @@ package gce import ( "context" "fmt" + "path" "path/filepath" compute "cloud.google.com/go/compute/apiv1" @@ -308,6 +309,34 @@ func endpointFor(ctx context.Context, c *compute.InstancesClient, s settings.GCE }, nil } +// Details reports the facts CLI and TUI show beyond Status: project, zone, +// machine type, external address and both stop deadlines. +func (Provider) Details(ctx context.Context, v *config.VM) (provider.Details, error) { + s, err := vmSettings(v) + if err != nil { + return provider.Details{}, err + } + c, err := newClient(ctx, s) + if err != nil { + return provider.Details{}, err + } + defer c.Close() + inst, err := c.Get(ctx, &computepb.GetInstanceRequest{Project: s.Project, Zone: s.Zone, Instance: v.Name}) + if err != nil { + return provider.Details{}, fmt.Errorf("gce: getting %s: %w", v.Name, err) + } + hard, _ := hardDeadline(inst) + soft, _ := softDeadline(inst.GetLabels()) + return provider.Details{ + Project: s.Project, + Zone: s.Zone, + MachineType: path.Base(inst.GetMachineType()), + Address: externalAddress(inst), + HardDeadline: hard, + SoftDeadline: soft, + }, nil +} + func externalAddress(inst *computepb.Instance) string { for _, ni := range inst.GetNetworkInterfaces() { for _, ac := range ni.GetAccessConfigs() { diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 5345e4b..712bc85 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -50,6 +50,47 @@ type Provider interface { Destroy(ctx context.Context, v *config.VM) error } +// Details is what a cloud provider knows about a machine beyond Status: where +// it runs, its shape, and when it must stop. qemu has none of this, so it is +// a separate, optional interface rather than added to Provider's required +// surface. +type Details struct { + Project string + Zone string + MachineType string + Address string + HardDeadline time.Time + SoftDeadline time.Time +} + +// Detailer is implemented by a Provider that can report Details. A caller +// type-asserts for it rather than assuming every Provider carries these +// facts. +type Detailer interface { + Details(ctx context.Context, v *config.VM) (Details, error) +} + +// WarnWithin is how far ahead of a deadline the CLI starts warning. +const WarnWithin = time.Hour + +// Nearest picks whichever of a Details' two deadlines comes first. A zero +// time.Time means that deadline does not apply. which is the label the +// warning line names ("run-time limit" or "soft deadline"). +func Nearest(hard, soft, now time.Time) (when time.Time, which string, ok bool) { + switch { + case hard.IsZero() && soft.IsZero(): + return time.Time{}, "", false + case hard.IsZero(): + return soft, "soft deadline", true + case soft.IsZero(): + return hard, "run-time limit", true + case soft.Before(hard): + return soft, "soft deadline", true + default: + return hard, "run-time limit", true + } +} + var registry = map[string]Provider{} // Register adds p under name. Implementations register from their own From e380c7067668fc59cf8497e5c07417f6ecd7cc6f Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 9 Sep 2026 03:09:05 +0300 Subject: [PATCH 17/24] feat(cli): show gce project, deadlines and machine facts create gains --provider, --gcp-project and --gcp-zone, and prints a second line naming where the project and zone came from (docket d48): a silent fallback to gcloud's active project creates billable instances in whichever project gcloud happens to point at. ls warns under the table, and every command that loaded the VM warns to stderr, once the nearer of the run-time limit and any operator-requested stop is under an hour away. get's provider block gains machine type, address and expires, matching JSON and MCP, which now report the same facts. Signed-off-by: NovusEdge --- docs/reference/cli.md | 19 ++++++++++++-- docs/reference/json.md | 6 +++-- internal/cli/grammar.go | 10 ++++++++ internal/cli/run_get.go | 39 +++++++++++++++------------- internal/cli/run_vm.go | 55 ++++++++++++++++++++++++++++++++++++++++ internal/cli/wire/dto.go | 26 +++++++++++++++++-- 6 files changed, 132 insertions(+), 23 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index b75d6d3..5e60668 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -104,6 +104,12 @@ oldvm - broken - - - - unexpected token near line `WHERE` names the execution surface: `local` for a qemu VM, or the provider's own name (`gce`) otherwise. The `STATE` column is colored (green `running`, red `broken`) when [color is enabled](#scripting). `-q`/`--quiet` is accepted but has no effect on `ls`'s output. +A gce VM within an hour of its run-time limit or an operator-requested stop gets a line under the table, and the same line prints to stderr for every command that touches it: + +``` +cloudy: stops in 42m (run-time limit). extend with: stoat gce extend cloudy 4h +``` + `--project` filters the list to VMs the `stoat.toml` in the current directory declares. It refuses outside a project. **Exit codes:** 0 on success; 1 if the data root can't be read, or `--project` is given outside a project. @@ -162,7 +168,7 @@ display: a qemu window `display` is the only line here that is not a `vm.toml` field. See [`stoat up`](#stoat-up-name) for what it means and why the answer changes. It is omitted entirely for a broken VM, whose `vm.toml` supplies neither of the facts the answer depends on. -A non-qemu VM gets `gcp project:` and `zone:` lines right after `provider:`, before `backend:`. +A non-qemu VM gets `gcp project:`, `zone:`, `machine type:`, `address:` and `expires:` lines right after `provider:`, before `backend:`. `expires` names the nearer of the run-time limit and any operator-requested stop. **Exit codes:** 0 on success; 1 if the VM can't be loaded. @@ -176,7 +182,16 @@ created work (alpine, live, ssh port 2222) start it with: stoat up work ``` -Flags: `--image` (required; catalog id or a path to your own image), `--os`, `--backend` (override what a bring-your-own image's filename would otherwise infer), `--mode` (`live` or `disk`; only meaningful for the alpine iso, every other image has one mode), `--ram` (MB), `--cpus`, `--disk` (absolute size, e.g. `8G`), `--share` (host directory to expose), `--console-password` (`random` generates one), `--recipes` (comma-separated or repeated), `--set recipe.param=value` (set a non-secret recipe parameter), `--secret recipe.param` (read a secret from the environment or prompt), `--agent-access` (`none`, `observe`, `manage`, or `exec`; default `manage`, controls MCP guest access). The hidden `--allow-exec` flag remains as a compatibility alias: true maps to `exec`, false to `manage`. +Flags: `--image` (required; catalog id or a path to your own image), `--os`, `--backend` (override what a bring-your-own image's filename would otherwise infer), `--mode` (`live` or `disk`; only meaningful for the alpine iso, every other image has one mode), `--ram` (MB), `--cpus`, `--disk` (absolute size, e.g. `8G`), `--share` (host directory to expose), `--console-password` (`random` generates one), `--recipes` (comma-separated or repeated), `--set recipe.param=value` (set a non-secret recipe parameter), `--secret recipe.param` (read a secret from the environment or prompt), `--agent-access` (`none`, `observe`, `manage`, or `exec`; default `manage`, controls MCP guest access), `--provider` (`qemu`, the default, or `gce`), `--gcp-project` and `--gcp-zone` (honored only with `--provider gce`; each falls back to `config.toml`, then gcloud's active configuration). The hidden `--allow-exec` flag remains as a compatibility alias: true maps to `exec`, false to `manage`. + +A `--provider gce` create prints a second line naming where it landed and where the project and zone came from: + +``` +$ stoat create cloudy --image ubuntu-24.04 --provider gce +created cloudy (ubuntu, cloud, ssh port 22) +gcp project engrammic, zone europe-west4-a, from ~/.stoat/config.toml +start it with: stoat up cloudy +``` `create` (alias `new`) refuses at project scope: `a stoat.toml is present; declare the VM there and run stoat up, or pass --global`. `--global` creates the VM outside the project. diff --git a/docs/reference/json.md b/docs/reference/json.md index 0259c83..a174147 100644 --- a/docs/reference/json.md +++ b/docs/reference/json.md @@ -211,10 +211,12 @@ VM {"name":"work","os":"alpine","mode":"cloud","backend":"cloudinit", "allow_exec":false,"agent_access":"manage","display":"vnc", "error":"only on a broken VM", "project":"/home/u/myrepo","key":"dev","project_missing":false, - "provider":"gce"} + "provider":"gce","gcp_project":"engrammic","gcp_zone":"europe-west4-a", + "machine_type":"e2-medium","address":"34.12.221.212", + "hard_deadline":"2026-09-08T22:14:00Z","soft_deadline":""} ``` -`provider` is omitted for a qemu VM (the empty `core.VM.Provider`), and carries the provider's own name (`"gce"`) otherwise. +`provider` is omitted for a qemu VM (the empty `core.VM.Provider`), and carries the provider's own name (`"gce"`) otherwise. `gcp_project`, `gcp_zone`, `machine_type` and `address` are omitted for qemu and for a gce VM whose details call failed. `hard_deadline` and `soft_deadline` are RFC3339, each omitted when that deadline does not apply. ```json VMStatus {"name":"work",...VM fields...,"health":"ok","recipes_detail":[ diff --git a/internal/cli/grammar.go b/internal/cli/grammar.go index e3f81ba..42eb95e 100644 --- a/internal/cli/grammar.go +++ b/internal/cli/grammar.go @@ -188,6 +188,13 @@ type createCmd struct { // present. Without it, a create at project scope is refused: a VM that // exists only on one machine is exactly what stoat.toml removes. Global bool `help:"create outside the project even inside one"` + + // Provider, GCEProject and GCEZone select a cloud execution surface. + // Empty Provider means qemu; the other two are honored only when + // Provider is "gce" and fall back through settings.ResolveGCE. + Provider string `help:"execution surface: qemu (default) or gce"` + GCEProject string `name:"gcp-project" help:"gce project (default: config.toml, then gcloud's active config)"` + GCEZone string `name:"gcp-zone" help:"gce zone (default: config.toml, then gcloud's active config)"` } // Help satisfies kong.HelpProvider. Kong prints a Detail block on the @@ -475,6 +482,9 @@ func (g *grammar) toArgs(path string) (*Args, error) { ConsolePassword: c.ConsolePassword, Recipes: trimList(c.Recipes), AllowExec: &allowExec, AgentAccess: access, + Provider: c.Provider, + GCEProject: c.GCEProject, + GCEZone: c.GCEZone, } edits, err := parseParamFlags(c.Set, nil, c.Secret) if err != nil { diff --git a/internal/cli/run_get.go b/internal/cli/run_get.go index 31da8cf..48eb892 100644 --- a/internal/cli/run_get.go +++ b/internal/cli/run_get.go @@ -5,10 +5,11 @@ import ( "io" "sort" "strings" + "time" "github.com/novusedge/stoat/internal/cli/wire" - "github.com/novusedge/stoat/internal/config" "github.com/novusedge/stoat/internal/core" + "github.com/novusedge/stoat/internal/provider" ) func runGet(a *Args, stdout, stderr io.Writer) int { @@ -19,6 +20,7 @@ func runGet(a *Args, stdout, stderr io.Writer) int { if a.JSON { return a.ok(stdout, wire.VMStatusResult{VM: wire.FromVMStatus(v, core.GraphicalSession())}) } + warnDeadline(stderr, v) fmt.Fprintf(stdout, "name: %s\n", v.Name) fmt.Fprintf(stdout, "os: %s\n", v.OS) fmt.Fprintf(stdout, "mode: %s\n", v.Mode) @@ -61,27 +63,30 @@ func runGet(a *Args, stdout, stderr io.Writer) int { } // printProviderBlock prints the provider line, and for a non-qemu VM the -// project and zone it was created in. Machine type, address and the two -// deadlines are not printed: nothing in the Provider interface exposes them -// yet (Status carries only a running bit and the provider's raw status -// word), so showing them here would mean guessing rather than reporting. +// project, zone, machine type, address and the nearer of its two deadlines. func printProviderBlock(stdout io.Writer, v core.VM) { - provider := v.Provider - if provider == "" { - provider = "qemu" - fmt.Fprintf(stdout, "provider: %s\n", provider) - return + name := v.Provider + if name == "" { + name = "qemu" } - fmt.Fprintf(stdout, "provider: %s\n", provider) - cfg, err := config.Load(v.Name) - if err != nil { + fmt.Fprintf(stdout, "provider: %s\n", name) + if v.Provider == "" { return } - if cfg.GCEProject != "" { - fmt.Fprintf(stdout, "gcp project: %s\n", cfg.GCEProject) + if v.GCEProject != "" { + fmt.Fprintf(stdout, "gcp project: %s\n", v.GCEProject) + } + if v.GCEZone != "" { + fmt.Fprintf(stdout, "zone: %s\n", v.GCEZone) + } + if v.MachineType != "" { + fmt.Fprintf(stdout, "machine type: %s\n", v.MachineType) + } + if v.Address != "" { + fmt.Fprintf(stdout, "address: %s\n", v.Address) } - if cfg.GCEZone != "" { - fmt.Fprintf(stdout, "zone: %s\n", cfg.GCEZone) + if when, which, ok := provider.Nearest(v.HardDeadline, v.SoftDeadline, time.Now()); ok { + fmt.Fprintf(stdout, "expires: %s (in %s, %s)\n", when.UTC().Format(time.RFC3339), formatDuration(time.Until(when)), which) } } diff --git a/internal/cli/run_vm.go b/internal/cli/run_vm.go index 1008655..98cb129 100644 --- a/internal/cli/run_vm.go +++ b/internal/cli/run_vm.go @@ -6,11 +6,13 @@ import ( "fmt" "io" "strings" + "time" "github.com/novusedge/stoat/internal/cli/wire" "github.com/novusedge/stoat/internal/config" "github.com/novusedge/stoat/internal/core" "github.com/novusedge/stoat/internal/project" + "github.com/novusedge/stoat/internal/provider" "github.com/novusedge/stoat/internal/sshx" ) @@ -37,6 +39,7 @@ func runLS(a *Args, stdout, stderr io.Writer) int { } fmt.Fprintf(stdout, "%-15s %-5s %-8s %-5s %-5s %-6s %-6s %s\n", "NAME", "MODE", "STATE", "WHERE", "CPUS", "RAM", "SSH", "PROJECT") + var warnings []string // core.List() sorts every VM, broken ones included, together by name, // so a broken VM can interleave alphabetically with good ones. The // original two calls (config.List then config.ListBroken) printed every @@ -54,6 +57,12 @@ func runLS(a *Args, stdout, stderr io.Writer) int { } fmt.Fprintf(stdout, "%-15s %-5s %s %-5s %-5d %-6d %-6d %s\n", v.Name, v.Mode, a.prose(stdout).State(state, 8), whereCell(v), v.CPUs, v.RAM, v.SSHPort, projectCell(v)) + if line, ok := deadlineWarning(v); ok { + warnings = append(warnings, line) + } + } + for _, line := range warnings { + fmt.Fprintln(stdout, line) } // Broken VMs are real entries: hiding them is the bug that was already // reported once. They get dashes for the fields a broken vm.toml can't @@ -78,6 +87,46 @@ func whereCell(v core.VM) string { return v.Provider } +// deadlineWarning reports the line printed under stoat ls, and to stderr by +// every command that touches this VM, once the nearer of its two gce +// deadlines is under provider.WarnWithin away. ok is false for a qemu VM +// (both deadlines zero) and for one further out than the threshold. +func deadlineWarning(v core.VM) (string, bool) { + when, which, ok := provider.Nearest(v.HardDeadline, v.SoftDeadline, time.Now()) + if !ok { + return "", false + } + left := time.Until(when) + if left > provider.WarnWithin { + return "", false + } + return fmt.Sprintf("%s: stops in %s (%s). extend with: stoat gce extend %s 4h", + v.Name, formatDuration(left), which, v.Name), true +} + +// warnDeadline prints deadlineWarning's line to stderr for a single command +// touching v, matching stoat ls's own line under the table. +func warnDeadline(stderr io.Writer, v core.VM) { + if line, ok := deadlineWarning(v); ok { + fmt.Fprintln(stderr, line) + } +} + +// formatDuration renders a warning-window duration as "42m" or "1h5m": coarse +// enough that a value recomputed a few seconds later still reads the same. +func formatDuration(d time.Duration) string { + if d < 0 { + d = 0 + } + d = d.Round(time.Minute) + h := d / time.Hour + m := (d % time.Hour) / time.Minute + if h == 0 { + return fmt.Sprintf("%dm", m) + } + return fmt.Sprintf("%dh%dm", h, m) +} + // projectCell renders the PROJECT column: the declaring directory, marked // when it is gone, and "-" for a VM stoat new created. func projectCell(v core.VM) string { @@ -128,6 +177,7 @@ func runUp(a *Args, stdout, stderr io.Writer) int { if v.State == core.StateBroken { return a.failMsg(stdout, stderr, core.ErrBroken, v.Error) } + warnDeadline(stderr, v) a.prose(stdout).Step("starting %s...", a.VM) if err := core.Start(a.VM); err != nil { return a.fail(stdout, stderr, err) @@ -342,6 +392,7 @@ func runDown(a *Args, stdout, stderr io.Writer) int { if v.State != core.StateRunning { return a.failMsg(stdout, stderr, core.ErrNotRunning, a.VM+" is not running") } + warnDeadline(stderr, v) if !a.Quiet { fmt.Fprintf(stdout, "stopping %s...\n", a.VM) } @@ -382,6 +433,7 @@ func runRM(a *Args, stdin io.Reader, stdout, stderr io.Writer) int { if v.State == core.StateRunning { return a.failMsg(stdout, stderr, core.ErrAlreadyRunning, a.VM+" is running; stop it first") } + warnDeadline(stderr, v) if ok, code := confirm(a, stdin, stdout, stderr, "delete VM "+a.VM+"?"); !ok { return code } @@ -428,6 +480,9 @@ func runCreate(a *Args, stdout, stderr io.Writer) int { } if !a.Quiet { fmt.Fprintf(stdout, "created %s (%s, %s, ssh port %d)\n", v.Name, v.OS, v.Mode, v.SSHPort) + if v.Provider == "gce" { + fmt.Fprintf(stdout, "gcp project %s, zone %s, from %s\n", v.GCEProject, v.GCEZone, v.GCESource) + } fmt.Fprintf(stdout, "start it with: stoat up %s\n", v.Name) } return ExitOK diff --git a/internal/cli/wire/dto.go b/internal/cli/wire/dto.go index 7ac0bca..20618b5 100644 --- a/internal/cli/wire/dto.go +++ b/internal/cli/wire/dto.go @@ -117,8 +117,17 @@ type VM struct { ProjectMissing bool `json:"project_missing"` // Provider is the execution surface: absent for qemu (the empty - // core.VM.Provider), named otherwise ("gce"). - Provider string `json:"provider,omitempty"` + // core.VM.Provider), named otherwise ("gce"). GCEProject, GCEZone, + // MachineType and Address are empty for qemu and for a gce VM whose + // details call failed; the deadlines are RFC3339, empty when that + // deadline does not apply. + Provider string `json:"provider,omitempty"` + GCEProject string `json:"gcp_project,omitempty"` + GCEZone string `json:"gcp_zone,omitempty"` + MachineType string `json:"machine_type,omitempty"` + Address string `json:"address,omitempty"` + HardDeadline string `json:"hard_deadline,omitempty"` + SoftDeadline string `json:"soft_deadline,omitempty"` } // RecipeState is one recipe's redacted per-VM state. @@ -218,9 +227,22 @@ func FromVM(v core.VM, graphical bool) VM { Key: v.Key, ProjectMissing: v.ProjectMissing, Provider: v.Provider, + GCEProject: v.GCEProject, + GCEZone: v.GCEZone, + MachineType: v.MachineType, + Address: v.Address, + HardDeadline: rfc3339OrEmpty(v.HardDeadline), + SoftDeadline: rfc3339OrEmpty(v.SoftDeadline), } } +func rfc3339OrEmpty(t time.Time) string { + if t.IsZero() { + return "" + } + return t.UTC().Format(time.RFC3339) +} + func FromVMs(vs []core.VM, graphical bool) []VM { out := make([]VM, len(vs)) for i, v := range vs { From 16065c79c7ae0bd0e7c9d708dc14fdac74bce694 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 9 Sep 2026 03:09:15 +0300 Subject: [PATCH 18/24] fix(tui): resolve edit's running state off the View path, show provider facts viewEdit called vmRunning directly, which for a gce VM reaches Provider.Status: a compute client and an instances.get with no timeout, on every render of the edit pane. It now resolves once through a tea.Cmd when the pane opens; View reads the stored result. The list row and the detail pane now show a VM's provider, matching the CLI's WHERE column and get's provider block. Signed-off-by: NovusEdge --- internal/tui/app.go | 4 +++ internal/tui/detail.go | 27 ++++++++++++++++++++- internal/tui/edit.go | 17 ++++++++++++- internal/tui/vmlist.go | 55 ++++++++++++++++++++++++++---------------- 4 files changed, 80 insertions(+), 23 deletions(-) diff --git a/internal/tui/app.go b/internal/tui/app.go index d807a85..d12e29d 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -267,6 +267,10 @@ func (m model) updateApp(msg tea.Msg) (tea.Model, tea.Cmd) { m.list.SetWidth(listWidth) m.syncListHeight() return m, nil + case editRunningMsg: + m.edit.running = msg.running + return m, nil + case vmsLoadedMsg: m.vms = msg.vms // SetItems returns a Cmd that re-applies an active filter to the new diff --git a/internal/tui/detail.go b/internal/tui/detail.go index 60bf745..b210ebd 100644 --- a/internal/tui/detail.go +++ b/internal/tui/detail.go @@ -15,6 +15,7 @@ import ( "github.com/novusedge/stoat/internal/config" "github.com/novusedge/stoat/internal/core" + "github.com/novusedge/stoat/internal/provider" "github.com/novusedge/stoat/internal/qemu" "github.com/novusedge/stoat/internal/sshx" ) @@ -161,7 +162,7 @@ func (m model) updateDetail(msg tea.Msg) (tea.Model, tea.Cmd) { m.screen = screenEdit m.showHelp = false m.status = "" - return m, nil + return m, checkEditRunning(cv) case "E": editor := os.Getenv("EDITOR") if editor == "" { @@ -363,6 +364,30 @@ func (m model) viewDetail() string { facts.gap() line := func(k, val string) { facts.row("", k, val) } + if v.Provider != "" { + line("provider", v.Provider) + if v.GCEProject != "" { + line("gcp project", v.GCEProject) + } + if v.GCEZone != "" { + line("zone", v.GCEZone) + } + if v.MachineType != "" { + line("machine type", v.MachineType) + } + if v.Address != "" { + line("address", v.Address) + } + if when, which, ok := provider.Nearest(v.HardDeadline, v.SoftDeadline, time.Now()); ok { + left := time.Until(when).Round(time.Minute) + style := dimStyle + if left <= provider.WarnWithin { + style = warnStyle + } + line("expires", style.Render(fmt.Sprintf("%s (in %s, %s)", when.UTC().Format(time.RFC3339), left, which))) + } + facts.gap() + } // A cloud VM has no ISO. It boots an overlay of a base image instead, so // the row would otherwise render as an empty label. if v.ISO != "" { diff --git a/internal/tui/edit.go b/internal/tui/edit.go index 79d8934..278e6a9 100644 --- a/internal/tui/edit.go +++ b/internal/tui/edit.go @@ -47,6 +47,21 @@ type editModel struct { recipeSel map[string]bool display string // one of displayChoices; seeded from vm.Display, "" reads as "auto" + + // running is resolved once, by checkEditRunning's tea.Cmd, when the pane + // opens. viewEdit reads this instead of calling vmRunning itself: that + // call reaches a gce Provider's Status, an unbounded network round trip + // Bubble Tea's View function must never block on. + running bool +} + +// editRunningMsg carries checkEditRunning's answer back to Update. +type editRunningMsg struct{ running bool } + +// checkEditRunning resolves whether v is running off Bubble Tea's Update/View +// path, so viewEdit never starts a network call of its own. +func checkEditRunning(v *config.VM) tea.Cmd { + return func() tea.Msg { return editRunningMsg{running: vmRunning(v)} } } // edit field indices @@ -529,7 +544,7 @@ func (m model) viewEdit() string { if !e.dirty() { note(dimStyle.Render("no changes")) } - if vmRunning(e.vm) { + if e.running { note(warnStyle.Render("running: ram/cpus/ssh apply on restart")) } if e.err != "" { diff --git a/internal/tui/vmlist.go b/internal/tui/vmlist.go index bd18fa5..61de929 100644 --- a/internal/tui/vmlist.go +++ b/internal/tui/vmlist.go @@ -17,21 +17,23 @@ import ( // wider than its column truncates or wraps instead of shoving every column // after it out of place. const ( - nameCellWidth = 14 - modeCellWidth = 5 - ramValueWidth = 5 // digits only, right-aligned; "M" is appended after - cpuValueWidth = 2 // digits only, right-aligned; "c" is appended after - upCellWidth = 13 // "up " plus a duration up to "999h59m59s" - portCellWidth = 6 // ":" plus up to 5 digits + nameCellWidth = 14 + modeCellWidth = 5 + whereCellWidth = 5 // "local" or "gce" + ramValueWidth = 5 // digits only, right-aligned; "M" is appended after + cpuValueWidth = 2 // digits only, right-aligned; "c" is appended after + upCellWidth = 13 // "up " plus a duration up to "999h59m59s" + portCellWidth = 6 // ":" plus up to 5 digits ) var ( - nameCellStyle = lipgloss.NewStyle().Width(nameCellWidth) - modeCellStyle = lipgloss.NewStyle().Width(modeCellWidth) - ramCellStyle = lipgloss.NewStyle().Width(ramValueWidth).Align(lipgloss.Right) - cpuCellStyle = lipgloss.NewStyle().Width(cpuValueWidth).Align(lipgloss.Right) - upCellStyle = lipgloss.NewStyle().Width(upCellWidth) - portCellStyle = lipgloss.NewStyle().Width(portCellWidth).Align(lipgloss.Right) + nameCellStyle = lipgloss.NewStyle().Width(nameCellWidth) + modeCellStyle = lipgloss.NewStyle().Width(modeCellWidth) + whereCellStyle = lipgloss.NewStyle().Width(whereCellWidth) + ramCellStyle = lipgloss.NewStyle().Width(ramValueWidth).Align(lipgloss.Right) + cpuCellStyle = lipgloss.NewStyle().Width(cpuValueWidth).Align(lipgloss.Right) + upCellStyle = lipgloss.NewStyle().Width(upCellWidth) + portCellStyle = lipgloss.NewStyle().Width(portCellWidth).Align(lipgloss.Right) ) // vmItem is one row of the VM list. A single type covers both good VMs and @@ -126,28 +128,39 @@ func (d vmDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) // inside an otherwise highlighted row. name := nameCellStyle.Render(ansi.Truncate(v.Name, nameCellWidth, "…")) mode := modeCellStyle.Render(v.Mode) + where := whereCellStyle.Render(whereLabel(v)) ram := ramCellStyle.Render(fmt.Sprintf("%d", v.RAM)) + "M" cpu := cpuCellStyle.Render(fmt.Sprintf("%d", v.CPUs)) + "c" - label := name + " " + mode + " " + ram + " " + cpu + " " + label := name + " " + mode + " " + where + " " + ram + " " + cpu + " " if selected { label = selStyle.Render(label) } fmt.Fprint(w, cursor+dotStyle.Render(dot)+" "+label+state) } +// whereLabel matches the CLI's WHERE column: "local" for qemu (the empty +// Provider field), the provider's own name otherwise. +func whereLabel(v core.VM) string { + if v.Provider == "" { + return "local" + } + return v.Provider +} + // listWidth and listVisibleRows size the VM list. Fixed rather than derived // from the terminal so the pane doesn't resize as VMs come and go; the row // budget is what bubbles/list paginates against. const ( // Wide enough for the widest row the format can produce with reachable - // values: a 14-char name, 5-digit RAM, 2-digit cpus, an uptime just - // under 1000 hours, and a 5-digit port (the edit form allows up to - // 65535). Sized off the RUNNING row on purpose. A stopped row is only - // 38 cells, which is why an undersized value looks fine in every test - // render and then wraps the port onto its own line the moment - // something is actually up. A terminal narrower than this still clamps - // (paneAt bounds to the window); that is unavoidable at that size. - listWidth = 60 + // values: a 14-char name, a 5-char WHERE cell, 5-digit RAM, 2-digit + // cpus, an uptime just under 1000 hours, and a 5-digit port (the edit + // form allows up to 65535). Sized off the RUNNING row on purpose. A + // stopped row is shorter, which is why an undersized value looks fine + // in every test render and then wraps the port onto its own line the + // moment something is actually up. A terminal narrower than this still + // clamps (paneAt bounds to the window); that is unavoidable at that + // size. + listWidth = 66 listVisibleRows = 6 listMinRows = 2 // banner, pane frame, search line, status line, footer. The search and From fff4cb0cab3b057c308da0a597a1f01bfdfc6169 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 9 Sep 2026 03:16:24 +0300 Subject: [PATCH 19/24] feat(gce): let the operator pin the firewall source range The address lookup is wrong for anyone whose ssh traffic leaves by a different path than an https request: a split-tunnel VPN, a proxy, a NAT pool wide enough that the answer is one address among many. The guest takes keys only, so a wrong range locks the operator out rather than letting anyone in, and source_range is the way out. Signed-off-by: NovusEdge --- internal/provider/gce/gce.go | 2 +- internal/provider/gce/instance.go | 15 +++++++++++++++ internal/provider/gce/instance_test.go | 20 ++++++++++++++++++++ internal/settings/settings.go | 10 ++++++++++ 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/internal/provider/gce/gce.go b/internal/provider/gce/gce.go index 0f60616..58a00de 100644 --- a/internal/provider/gce/gce.go +++ b/internal/provider/gce/gce.go @@ -89,7 +89,7 @@ func (Provider) Create(ctx context.Context, v *config.VM) error { if err != nil { return err } - sourceRange, err := operatorRange(ctx) + sourceRange, err := sourceRangeFor(ctx, s) if err != nil { return fmt.Errorf("gce: %w", err) } diff --git a/internal/provider/gce/instance.go b/internal/provider/gce/instance.go index fe0dd57..25f15c3 100644 --- a/internal/provider/gce/instance.go +++ b/internal/provider/gce/instance.go @@ -134,6 +134,21 @@ func diskSizeGB(s string) (int64, error) { return gb, nil } +// sourceRangeFor is the CIDR the SSH firewall rule admits. A configured +// source_range wins outright and skips the lookup: an operator whose SSH +// traffic leaves by a different path than an HTTPS request gets back an +// address the instance never sees, and retrying does not fix that. +func sourceRangeFor(ctx context.Context, s settings.GCE) (string, error) { + r := strings.TrimSpace(s.SourceRange) + if r == "" { + return operatorRange(ctx) + } + if _, _, err := net.ParseCIDR(r); err != nil { + return "", fmt.Errorf("providers.gce source_range %q: %w", r, err) + } + return r, nil +} + // operatorRange asks operatorEndpoint what address it saw the request come // from, and returns it as a single-address CIDR the firewall rule can use // as its source range. The instance always gets a v4-only ONE_TO_ONE_NAT diff --git a/internal/provider/gce/instance_test.go b/internal/provider/gce/instance_test.go index e90db7e..616cdf8 100644 --- a/internal/provider/gce/instance_test.go +++ b/internal/provider/gce/instance_test.go @@ -121,3 +121,23 @@ func TestOperatorRangeRejectsIPv6(t *testing.T) { t.Error("operatorRangeWith() = nil error for an IPv6 address; the instance is v4-only") } } + +func TestSourceRangeForPrefersTheConfiguredValue(t *testing.T) { + got, err := sourceRangeFor(context.Background(), settings.GCE{SourceRange: "203.0.113.0/24"}) + if err != nil { + t.Fatalf("sourceRangeFor() error = %v", err) + } + if got != "203.0.113.0/24" { + t.Errorf("sourceRangeFor() = %q, want the configured range with no lookup", got) + } +} + +func TestSourceRangeForRejectsAMalformedValue(t *testing.T) { + _, err := sourceRangeFor(context.Background(), settings.GCE{SourceRange: "203.0.113.1"}) + if err == nil { + t.Fatal("sourceRangeFor() = nil error for a bare address; a CIDR is required") + } + if !strings.Contains(err.Error(), "source_range") { + t.Errorf("error %q must name the config key", err) + } +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 13743b1..9f0cfaf 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -21,6 +21,16 @@ type GCE struct { // never a fallback: an empty value means ADC, and stoat does not go // looking for a key file. ServiceAccountKeyFile string `toml:"service_account_key_file"` + + // SourceRange is the CIDR the SSH firewall rule admits. Empty means ask + // an echo service what address it saw and scope the rule to that. + // + // That lookup is wrong for anyone whose SSH traffic leaves by a different + // path than an HTTPS request: a split-tunnel VPN, a proxy, an outbound + // NAT pool wide enough that the answer is one address among many. The + // failure is a lockout rather than exposure, since the guest accepts keys + // only, and this field is the way out of it. + SourceRange string `toml:"source_range"` } type Providers struct { From e443788071490933770ed6836dcc02be481c6b9c Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 9 Sep 2026 03:25:28 +0300 Subject: [PATCH 20/24] fix(core): actually enforce the capability declarations C2 built RequireCapability and C3 declared capabilities, and nothing called one from the other: stoat snapshot on a gce VM took the qemu path. The two providers also named operations differently, so the first wiring refused snapshots on qemu as well. The operation names are now constants in internal/capabilities that both providers and every call site share. snapshot, clone, screenshot, forward and console logs consult them; qemu declares all of them supported and gce declares them unsupported. Signed-off-by: NovusEdge --- internal/capabilities/model.go | 19 ++++++++++++++ internal/core/access.go | 10 +++++++ internal/core/capability_test.go | 45 ++++++++++++++++++++++++++++++++ internal/core/clone.go | 4 +++ internal/core/forward.go | 4 +++ internal/core/screenshot.go | 4 +++ internal/core/snapshot.go | 7 +++++ internal/provider/fake/fake.go | 22 +++++++++++++++- internal/provider/gce/gce.go | 6 +++-- internal/provider/qemu/qemu.go | 13 +++++++-- 10 files changed, 129 insertions(+), 5 deletions(-) diff --git a/internal/capabilities/model.go b/internal/capabilities/model.go index ee62c6b..1e994a4 100644 --- a/internal/capabilities/model.go +++ b/internal/capabilities/model.go @@ -27,6 +27,25 @@ const ( ReasonQuotaExceeded = "quota_exceeded" ReasonDeadlineExpired = "deadline_expired" + // The operation names core.RequireCapability gates on. Every provider + // declares a status for every one of them: RequireCapability treats an + // undeclared name as a refusal, so a provider that adds a method without + // declaring it fails closed. + // + // These are separate from the vm.* and mcp.* names above, which describe + // what `stoat capabilities` reports about a target. Two providers using + // two vocabularies is what made the first wiring of this refuse + // snapshots on QEMU. + OpSnapshot = "snapshot" + OpClone = "clone" + OpScreenshot = "screenshot" + OpSendKey = "sendkey" + OpForward = "forward" + OpConsoleLog = "console_log" + OpShare = "share" + OpUpdateRAM = "update.ram" + OpUpdateCPU = "update.cpu" + LimitAgentAccessRequired = "agent_access_required" LimitTargetRequired = "target_required" LimitDiskRequired = "disk_required" diff --git a/internal/core/access.go b/internal/core/access.go index 8ebafff..d614f3b 100644 --- a/internal/core/access.go +++ b/internal/core/access.go @@ -12,6 +12,7 @@ import ( "sort" "strings" + "github.com/novusedge/stoat/internal/capabilities" "github.com/novusedge/stoat/internal/config" "github.com/novusedge/stoat/internal/sshx" ) @@ -93,6 +94,15 @@ func Logs(name string, which Which) (io.ReadCloser, error) { return nil, fmt.Errorf("%s: %w", name, err) } + // The console log is QEMU's -serial output. A provider with no serial + // device has no file to open, and an empty reader would read as a quiet + // guest rather than an operation this provider cannot do. + if which == WhichConsole { + if err := RequireCapability(v, capabilities.OpConsoleLog); err != nil { + return nil, err + } + } + path := v.ProvisionLogPath() if which == WhichConsole { path = v.ConsoleLogPath() diff --git a/internal/core/capability_test.go b/internal/core/capability_test.go index 1f0d997..8b52b84 100644 --- a/internal/core/capability_test.go +++ b/internal/core/capability_test.go @@ -39,3 +39,48 @@ func TestRequireCapabilityRefusesAnUndeclaredCapability(t *testing.T) { t.Error("RequireCapability() = nil for a capability the provider never declared; want a refusal") } } + +// Every gated operation must refuse on a provider that declares it +// unsupported, and succeed past the gate on one that does not. The first +// wiring of this gated on names neither provider used, so every check +// refused on QEMU too and no test noticed. +func TestGatedOperationsRefuseOnAnUnsupportingProvider(t *testing.T) { + root(t) + f := fake.Install(t) + f.Caps = []capabilities.Capability{{ + Name: capabilities.OpSnapshot, + Status: capabilities.StatusUnsupported, + Reason: &capabilities.Reason{Code: capabilities.ReasonProviderUnsupported}, + }} + v := &config.VM{Name: "cloudy", Mode: "cloud", RAM: 1024, CPUs: 1, Disk: "10G", SSHPort: 2299} + if err := v.Save(); err != nil { + t.Fatal(err) + } + if err := TakeSnapshot("cloudy", "tag"); !errors.Is(err, ErrCapabilityUnavailable) { + t.Errorf("TakeSnapshot = %v, want ErrCapabilityUnavailable", err) + } + if _, err := Snapshots("cloudy"); !errors.Is(err, ErrCapabilityUnavailable) { + t.Errorf("Snapshots = %v, want ErrCapabilityUnavailable", err) + } +} + +// The QEMU provider must declare every gated operation. A missing entry +// refuses that operation on a local VM, which is a regression no gce test +// would catch. +func TestQemuDeclaresEveryGatedOperation(t *testing.T) { + root(t) + v := &config.VM{Name: "local", Mode: "cloud", RAM: 1024, CPUs: 1, Disk: "10G", SSHPort: 2298} + if err := v.Save(); err != nil { + t.Fatal(err) + } + ops := []string{ + capabilities.OpSnapshot, capabilities.OpClone, capabilities.OpScreenshot, + capabilities.OpSendKey, capabilities.OpForward, capabilities.OpConsoleLog, + capabilities.OpShare, capabilities.OpUpdateRAM, capabilities.OpUpdateCPU, + } + for _, op := range ops { + if err := RequireCapability(v, op); err != nil { + t.Errorf("RequireCapability(%q) = %v, want nil on qemu", op, err) + } + } +} diff --git a/internal/core/clone.go b/internal/core/clone.go index 097f732..710cd90 100644 --- a/internal/core/clone.go +++ b/internal/core/clone.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" + "github.com/novusedge/stoat/internal/capabilities" "github.com/novusedge/stoat/internal/cloudinit" "github.com/novusedge/stoat/internal/config" "github.com/novusedge/stoat/internal/guest" @@ -45,6 +46,9 @@ func Clone(name, newName string) (VM, error) { if err != nil { return VM{}, err } + if err := RequireCapability(src, capabilities.OpClone); err != nil { + return VM{}, err + } state, err := StateOf(context.Background(), src) if err != nil { return VM{}, err diff --git a/internal/core/forward.go b/internal/core/forward.go index 886e6ae..7896dcd 100644 --- a/internal/core/forward.go +++ b/internal/core/forward.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" + "github.com/novusedge/stoat/internal/capabilities" "github.com/novusedge/stoat/internal/config" ) @@ -64,6 +65,9 @@ func Forward(name string, fwds []PortForward) (active bool, err error) { if err != nil { return false, err } + if err := RequireCapability(v, capabilities.OpForward); err != nil { + return false, err + } if err := validateForwards(v, fwds); err != nil { return false, err } diff --git a/internal/core/screenshot.go b/internal/core/screenshot.go index 705cfc6..47c52d1 100644 --- a/internal/core/screenshot.go +++ b/internal/core/screenshot.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/novusedge/stoat/internal/capabilities" "github.com/novusedge/stoat/internal/qemu" ) @@ -32,6 +33,9 @@ func Screenshot(name, dest string) (Shot, error) { if err != nil { return Shot{}, err } + if err := RequireCapability(v, capabilities.OpScreenshot); err != nil { + return Shot{}, err + } if !qemu.Running(v) { return Shot{}, fmt.Errorf("%w: %s", ErrNotRunning, name) } diff --git a/internal/core/snapshot.go b/internal/core/snapshot.go index 74c3608..8857491 100644 --- a/internal/core/snapshot.go +++ b/internal/core/snapshot.go @@ -5,6 +5,7 @@ import ( "os/exec" "strings" + "github.com/novusedge/stoat/internal/capabilities" "github.com/novusedge/stoat/internal/config" "github.com/novusedge/stoat/internal/qemu" ) @@ -93,6 +94,9 @@ func Snapshots(name string) ([]Snapshot, error) { if err != nil { return nil, err } + if err := RequireCapability(v, capabilities.OpSnapshot); err != nil { + return nil, err + } if v.Mode == "live" { return nil, fmt.Errorf("%w: %s is a live VM", ErrNoDisk, name) } @@ -133,6 +137,9 @@ func snapshotTarget(name, tag string) (*config.VM, error) { if err != nil { return nil, err } + if err := RequireCapability(v, capabilities.OpSnapshot); err != nil { + return nil, err + } if v.Mode == "live" { return nil, fmt.Errorf("%w: %s is a live VM (diskless by design)", ErrNoDisk, name) } diff --git a/internal/provider/fake/fake.go b/internal/provider/fake/fake.go index 435aece..14612fd 100644 --- a/internal/provider/fake/fake.go +++ b/internal/provider/fake/fake.go @@ -32,7 +32,27 @@ type Provider struct { func (*Provider) Name() string { return "qemu" } -func (p *Provider) Capabilities(*config.VM) []capabilities.Capability { return p.Caps } +// Capabilities answers like the QEMU provider this fake stands in for: every +// gated operation supported. A test that wants a refusal sets Caps itself. +// +// Returning nil would refuse everything, since RequireCapability treats an +// undeclared operation as unsupported. That would make every core test that +// clones, forwards or snapshots fail for a reason the test is not about. +func (p *Provider) Capabilities(*config.VM) []capabilities.Capability { + if p.Caps != nil { + return p.Caps + } + ops := []string{ + capabilities.OpSnapshot, capabilities.OpClone, capabilities.OpScreenshot, + capabilities.OpSendKey, capabilities.OpForward, capabilities.OpConsoleLog, + capabilities.OpShare, capabilities.OpUpdateRAM, capabilities.OpUpdateCPU, + } + out := make([]capabilities.Capability, len(ops)) + for i, name := range ops { + out[i] = capabilities.Capability{Name: name, Status: capabilities.StatusSupported} + } + return out +} // SetRunning marks name live. Tests that need a VM to look started without // calling Start use this. diff --git a/internal/provider/gce/gce.go b/internal/provider/gce/gce.go index 58a00de..3f7788a 100644 --- a/internal/provider/gce/gce.go +++ b/internal/provider/gce/gce.go @@ -37,8 +37,10 @@ type Provider struct{} // which has no equivalent on a Compute Engine instance short of a stop, // SetMachineResources, and restart this provider does not yet implement. var unsupported = []string{ - "share", "screenshot", "sendkey", "console_log", "forward", - "snapshot", "clone", "update.ram", "update.cpu", + capabilities.OpShare, capabilities.OpScreenshot, capabilities.OpSendKey, + capabilities.OpConsoleLog, capabilities.OpForward, + capabilities.OpSnapshot, capabilities.OpClone, + capabilities.OpUpdateRAM, capabilities.OpUpdateCPU, } func (Provider) Name() string { return "gce" } diff --git a/internal/provider/qemu/qemu.go b/internal/provider/qemu/qemu.go index 4ed3ac1..d59651d 100644 --- a/internal/provider/qemu/qemu.go +++ b/internal/provider/qemu/qemu.go @@ -21,9 +21,18 @@ type Provider struct{} func (Provider) Name() string { return "qemu" } -// qemuCapabilities is what a local QEMU VM supports. Every entry here is -// StatusSupported: C2 adds the enforcement path, not a QEMU restriction. +// qemuCapabilities is what a local QEMU VM supports, which is everything. +// The gated operation names come first, so this list reads against +// gce.unsupported; the rest describe the target to `stoat capabilities`. +// +// Every Op* constant must appear here. RequireCapability treats an +// undeclared name as a refusal, so omitting one refuses that operation on +// QEMU, which is how the first wiring of this broke snapshots. var qemuCapabilities = []string{ + capabilities.OpSnapshot, capabilities.OpClone, capabilities.OpScreenshot, + capabilities.OpSendKey, capabilities.OpForward, capabilities.OpConsoleLog, + capabilities.OpShare, capabilities.OpUpdateRAM, capabilities.OpUpdateCPU, + "vm.lifecycle", "vm.snapshot", "recipes", "mcp.guest.observe", "mcp.guest.manage", "mcp.guest.exec", "cli.guest.shell", } From c849df346d5662ae85cc49d93a3283c4f85045ff Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 9 Sep 2026 03:30:01 +0300 Subject: [PATCH 21/24] feat(gce): make the run-time limit configurable, defaulting to a day The limit was fixed at six hours with no way to change it. The spec and docket d11 both say 24h with providers.gce max_run_duration overriding it, and a value outside compute's 30s to 120d range is refused here rather than by an opaque 400. Signed-off-by: NovusEdge --- internal/provider/gce/instance.go | 29 ++++++++++++++++++++++-- internal/provider/gce/instance_test.go | 31 ++++++++++++++++++++++++++ internal/settings/settings.go | 8 +++++++ 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/internal/provider/gce/instance.go b/internal/provider/gce/instance.go index 25f15c3..2d362a9 100644 --- a/internal/provider/gce/instance.go +++ b/internal/provider/gce/instance.go @@ -25,7 +25,28 @@ const maxMetadataValueBytes = 256 * 1024 // defaultMaxRunDuration bounds every instance this provider creates. Paired // with instanceTerminationAction=STOP, a VM nobody stops manually still // stops billing on its own. -const defaultMaxRunDuration = 6 * time.Hour +const defaultMaxRunDuration = 24 * time.Hour + +// maxRunDuration is how long GCP lets an instance run before stopping it. +// It is fixed at create: instances.setScheduling needs a stopped instance, +// so this value never moves for the life of the machine. Extending a +// deadline moves the soft-deadline label instead. +func maxRunDuration(s settings.GCE) (time.Duration, error) { + raw := strings.TrimSpace(s.MaxRunDuration) + if raw == "" { + return defaultMaxRunDuration, nil + } + d, err := time.ParseDuration(raw) + if err != nil { + return 0, fmt.Errorf("providers.gce max_run_duration %q: %w", raw, err) + } + // Compute's own bounds. Below the floor the API rejects the insert; above + // the ceiling it wants an instance schedule instead. + if d < 30*time.Second || d > 120*24*time.Hour { + return 0, fmt.Errorf("providers.gce max_run_duration %s is outside compute's 30s to 120d range", d) + } + return d, nil +} // operatorEndpoint echoes back the caller's address in a bare-text body. // domains.google.com/checkip filled this role until Google Domains was @@ -46,6 +67,10 @@ func insertRequest(v *config.VM, s settings.GCE, image, seed, sourceRange string if err != nil { return nil, fmt.Errorf("disk size %q: %w", v.Disk, err) } + runFor, err := maxRunDuration(s) + if err != nil { + return nil, err + } tag := vmTag(v.Name) inst := &computepb.Instance{ @@ -71,7 +96,7 @@ func insertRequest(v *config.VM, s settings.GCE, image, seed, sourceRange string }}, }}, Scheduling: &computepb.Scheduling{ - MaxRunDuration: &computepb.Duration{Seconds: proto.Int64(int64(defaultMaxRunDuration.Seconds()))}, + MaxRunDuration: &computepb.Duration{Seconds: proto.Int64(int64(runFor.Seconds()))}, InstanceTerminationAction: proto.String("STOP"), }, } diff --git a/internal/provider/gce/instance_test.go b/internal/provider/gce/instance_test.go index 616cdf8..3a3a898 100644 --- a/internal/provider/gce/instance_test.go +++ b/internal/provider/gce/instance_test.go @@ -6,6 +6,7 @@ import ( "net/http" "strings" "testing" + "time" "github.com/novusedge/stoat/internal/config" "github.com/novusedge/stoat/internal/settings" @@ -141,3 +142,33 @@ func TestSourceRangeForRejectsAMalformedValue(t *testing.T) { t.Errorf("error %q must name the config key", err) } } + +func TestMaxRunDurationDefaultsToADay(t *testing.T) { + got, err := maxRunDuration(settings.GCE{}) + if err != nil { + t.Fatal(err) + } + if got != 24*time.Hour { + t.Errorf("maxRunDuration() = %s, want 24h", got) + } +} + +func TestMaxRunDurationTakesTheConfiguredValue(t *testing.T) { + got, err := maxRunDuration(settings.GCE{MaxRunDuration: "90m"}) + if err != nil { + t.Fatal(err) + } + if got != 90*time.Minute { + t.Errorf("maxRunDuration() = %s, want 90m", got) + } +} + +func TestMaxRunDurationRejectsValuesComputeWouldReject(t *testing.T) { + // 10s is under compute's 30s floor; 3000h is 125 days, past its 120 day + // ceiling, where an instance schedule is the documented answer instead. + for _, raw := range []string{"10s", "3000h", "banana"} { + if _, err := maxRunDuration(settings.GCE{MaxRunDuration: raw}); err == nil { + t.Errorf("maxRunDuration(%q) = nil error", raw) + } + } +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 9f0cfaf..2213f19 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -22,6 +22,14 @@ type GCE struct { // looking for a key file. ServiceAccountKeyFile string `toml:"service_account_key_file"` + // MaxRunDuration bounds how long an instance runs before GCP stops it, + // as a Go duration string. Empty means 24h. + // + // This is the backstop that survives stoat being uninstalled, so it is + // set once at create and never moved: instances.setScheduling needs a + // stopped instance. Extending a deadline moves the label instead. + MaxRunDuration string `toml:"max_run_duration"` + // SourceRange is the CIDR the SSH firewall rule admits. Empty means ask // an echo service what address it saw and scope the rule to that. // From 1221bf8f135e4da9889b0dbff93ddd3a702cc553 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 9 Sep 2026 03:56:45 +0300 Subject: [PATCH 22/24] fix(gce): make the provider work against the real API Six defects the test suite could not see, found by running against a live project. create demanded the local qcow2 for an image gce never opens, so the provider was unusable without a multi-gigabyte download of a file nothing reads. create also inserted a running instance, leaving up to refuse with "already running"; the insert moves to Start, matching what create means everywhere else, and Status reports a VM whose instance does not exist yet as stopped. internal/sshx built a loopback endpoint inside Run, Provision, RunCheck and the cloud-init probe, so exec dialled 127.0.0.1 on a cloud guest. Those resolve through a hook internal/provider installs at init, since provider imports sshx and the reverse would cycle. up printed an ssh port and a qemu window for a Compute Engine instance, and rm warned about a run-time deadline on a stopped VM, which has none. CapabilityError carries its reason in a field, and the JSON envelope carries it too: an agent branching on why an operation was refused should not parse the sentence. Signed-off-by: NovusEdge --- internal/cli/run_vm.go | 19 +++++++- internal/cli/wire/envelope.go | 5 +++ internal/cli/wire/errors.go | 8 ++++ internal/core/capability.go | 33 +++++++++++--- internal/core/capability_test.go | 31 +++++++++++++ internal/core/core.go | 2 +- internal/core/image.go | 18 +++++++- internal/core/project.go | 2 +- internal/provider/gce/gce.go | 77 ++++++++++++++++++++++++++++++-- internal/provider/resolver.go | 26 +++++++++++ internal/sshx/endpoint.go | 35 +++++++++++++++ internal/sshx/run.go | 8 +++- internal/sshx/sshx.go | 12 ++--- 13 files changed, 252 insertions(+), 24 deletions(-) create mode 100644 internal/provider/resolver.go diff --git a/internal/cli/run_vm.go b/internal/cli/run_vm.go index 98cb129..02263b0 100644 --- a/internal/cli/run_vm.go +++ b/internal/cli/run_vm.go @@ -92,6 +92,13 @@ func whereCell(v core.VM) string { // deadlines is under provider.WarnWithin away. ok is false for a qemu VM // (both deadlines zero) and for one further out than the threshold. func deadlineWarning(v core.VM) (string, bool) { + // A stopped instance has no run-time deadline. Compute clears the + // termination timestamp on stop and recalculates it from the next start, + // so warning that a stopped VM "stops in 28m" names a deadline that is + // not counting and offers an extend that would change nothing. + if v.State != core.StateRunning { + return "", false + } when, which, ok := provider.Nearest(v.HardDeadline, v.SoftDeadline, time.Now()) if !ok { return "", false @@ -189,8 +196,16 @@ func runUp(a *Args, stdout, stderr io.Writer) int { v = started } if !a.JSON { - a.prose(stdout).Done("%s started (ssh :%d)", a.VM, v.SSHPort) - printDisplay(stdout, core.DisplayFor(v, core.GraphicalSession())) + // A cloud VM answers on a routable address, and the loopback forward + // and the qemu window that the local lines describe do not exist for + // it. Printing them names a port nothing listens on and a window + // nobody can open. + if v.Provider == "" || v.Provider == "qemu" { + a.prose(stdout).Done("%s started (ssh :%d)", a.VM, v.SSHPort) + printDisplay(stdout, core.DisplayFor(v, core.GraphicalSession())) + } else { + a.prose(stdout).Done("%s started on %s (%s)", a.VM, v.Provider, v.Address) + } } // An uninstalled disk VM's own installer is running now, not the system diff --git a/internal/cli/wire/envelope.go b/internal/cli/wire/envelope.go index 3990c34..902fd95 100644 --- a/internal/cli/wire/envelope.go +++ b/internal/cli/wire/envelope.go @@ -52,6 +52,11 @@ type ErrorInfo struct { // Kind classifies Subject: one of "vm", "field", "recipe", "image", // "snapshot", "port", "path", or absent. Kind string `json:"kind,omitempty"` + // Reason narrows a code that covers several situations. Present on + // capability_unavailable, carrying one of internal/capabilities' Reason + // constants, so an agent branches on a field instead of parsing prose + // out of Message. + Reason string `json:"reason,omitempty"` } // WithSubject sets Subject and Kind and returns e, for a one-line call at diff --git a/internal/cli/wire/errors.go b/internal/cli/wire/errors.go index aa2228f..827f8be 100644 --- a/internal/cli/wire/errors.go +++ b/internal/cli/wire/errors.go @@ -201,6 +201,14 @@ func MapError(err error) *ErrorInfo { if err == nil { return nil } + var capErr *core.CapabilityError + if errors.As(err, &capErr) { + return &ErrorInfo{ + Code: CodeCapabilityUnavailable, + Message: err.Error(), + Reason: capErr.Reason, + } + } for _, e := range codeTable { if errors.Is(err, e.err) { return &ErrorInfo{Code: e.code, Message: err.Error()} diff --git a/internal/core/capability.go b/internal/core/capability.go index f12e4b7..f4f7ddf 100644 --- a/internal/core/capability.go +++ b/internal/core/capability.go @@ -1,7 +1,6 @@ package core import ( - "errors" "fmt" "github.com/novusedge/stoat/internal/capabilities" @@ -9,8 +8,28 @@ import ( ) // ErrCapabilityUnavailable identifies an operation the VM's provider does -// not support, wrapping the capability's Reason so a caller can report why. -var ErrCapabilityUnavailable = errors.New("capability unavailable") +// not support. Match it with errors.Is; read the machine-readable reason by +// unwrapping to *CapabilityError with errors.As. +var ErrCapabilityUnavailable = capErr{} + +type capErr struct{} + +func (capErr) Error() string { return "capability unavailable" } + +// CapabilityError carries why an operation was refused in fields, not only +// in its message. A JSON or MCP caller branches on Reason; parsing it back +// out of the sentence would make the wording a contract. +type CapabilityError struct { + VM string + Operation string + Reason string +} + +func (e *CapabilityError) Error() string { + return fmt.Sprintf("capability unavailable: %s: %s (%s)", e.VM, e.Operation, e.Reason) +} + +func (e *CapabilityError) Is(target error) bool { return target == ErrCapabilityUnavailable } // RequireCapability refuses unless v's provider declares name as // capabilities.StatusSupported. A provider that never declares the @@ -27,11 +46,11 @@ func RequireCapability(v *config.VM, name string) error { if c.Status == capabilities.StatusSupported { return nil } - reason := "" - if c.Reason != nil { + reason := capabilities.ReasonProviderUnsupported + if c.Reason != nil && c.Reason.Code != "" { reason = c.Reason.Code } - return fmt.Errorf("%w: %s: %s (%s)", ErrCapabilityUnavailable, v.Name, name, reason) + return &CapabilityError{VM: v.Name, Operation: name, Reason: reason} } - return fmt.Errorf("%w: %s: %s (undeclared)", ErrCapabilityUnavailable, v.Name, name) + return &CapabilityError{VM: v.Name, Operation: name, Reason: "undeclared"} } diff --git a/internal/core/capability_test.go b/internal/core/capability_test.go index 8b52b84..4dfb3e7 100644 --- a/internal/core/capability_test.go +++ b/internal/core/capability_test.go @@ -84,3 +84,34 @@ func TestQemuDeclaresEveryGatedOperation(t *testing.T) { } } } + +// The reason must be readable as a field. A JSON or MCP caller branching on +// it should never have to parse the message, which would make the wording a +// contract. +func TestCapabilityErrorCarriesItsReasonInAField(t *testing.T) { + root(t) + f := fake.Install(t) + f.Caps = []capabilities.Capability{{ + Name: capabilities.OpSnapshot, + Status: capabilities.StatusUnsupported, + Reason: &capabilities.Reason{Code: capabilities.ReasonProviderUnsupported}, + }} + v := &config.VM{Name: "cloudy", Mode: "cloud", RAM: 1024, CPUs: 1, Disk: "10G", SSHPort: 2297} + if err := v.Save(); err != nil { + t.Fatal(err) + } + err := RequireCapability(v, capabilities.OpSnapshot) + var ce *CapabilityError + if !errors.As(err, &ce) { + t.Fatalf("RequireCapability() = %v, want a *CapabilityError", err) + } + if ce.Reason != capabilities.ReasonProviderUnsupported { + t.Errorf("Reason = %q, want %q", ce.Reason, capabilities.ReasonProviderUnsupported) + } + if ce.Operation != capabilities.OpSnapshot || ce.VM != "cloudy" { + t.Errorf("CapabilityError = %+v, want the vm and operation named", ce) + } + if !errors.Is(err, ErrCapabilityUnavailable) { + t.Error("errors.Is(err, ErrCapabilityUnavailable) = false; existing callers match on the sentinel") + } +} diff --git a/internal/core/core.go b/internal/core/core.go index b477dd6..6ee248b 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -210,7 +210,7 @@ func plan(s Spec) (*config.VM, error) { return nil, fmt.Errorf("%w: %s", ErrNameTaken, name) } - img, err := resolveImage(s.Image) + img, err := resolveImage(s.Image, config.IsRemote(s.Provider)) if err != nil { return nil, err } diff --git a/internal/core/image.go b/internal/core/image.go index 7ee0f53..7cd487e 100644 --- a/internal/core/image.go +++ b/internal/core/image.go @@ -127,7 +127,7 @@ func (i image) id() string { // // A catalog entry that has not been downloaded is ErrImageNotDownloaded and // not an inference failure: the caller can fix it by fetching the image. -func resolveImage(spec string) (image, error) { +func resolveImage(spec string, remote bool) (image, error) { if spec == "" { return image{}, fmt.Errorf("%w: no image given", ErrNotFound) } @@ -153,6 +153,22 @@ func resolveImage(spec string) (image, error) { } f := MatchLocal(e, files) if f == "" { + // A remote provider boots the catalog entry's own published + // image, never this qcow2, so requiring a multi-gigabyte + // download of a file nothing opens would make the provider + // unusable. The entry still supplies the OS, backend and + // account facts every provider needs. + if remote { + return image{ + entry: &e, + osName: e.OS, + backend: e.Backend, + sshUser: e.SSHUser, + cpuModel: e.CPUModel, + requiredCPU: e.RequiredCPU, + defaultDisk: e.DefaultDisk, + }, nil + } return image{}, fmt.Errorf("%w: %s", ErrImageNotDownloaded, e.ID) } abs, err := filepath.Abs(filepath.Join(config.Root(), "isos", f)) diff --git a/internal/core/project.go b/internal/core/project.go index 2545100..587c647 100644 --- a/internal/core/project.go +++ b/internal/core/project.go @@ -132,7 +132,7 @@ func Diff(p *project.Project, key string) ([]Drift, error) { // vm.toml stores the resolved ISO path or base image, never a catalog // id. The declared image is compared against its resolved spelling. - img, err := resolveImage(spec.Image) + img, err := resolveImage(spec.Image, config.IsRemote(spec.Provider)) if err != nil { return nil, err } diff --git a/internal/provider/gce/gce.go b/internal/provider/gce/gce.go index 3f7788a..115967e 100644 --- a/internal/provider/gce/gce.go +++ b/internal/provider/gce/gce.go @@ -3,12 +3,16 @@ package gce import ( "context" + "errors" "fmt" + "net/http" "path" "path/filepath" compute "cloud.google.com/go/compute/apiv1" computepb "cloud.google.com/go/compute/apiv1/computepb" + "github.com/googleapis/gax-go/v2/apierror" + "google.golang.org/api/googleapi" "github.com/novusedge/stoat/internal/backend" "github.com/novusedge/stoat/internal/capabilities" @@ -74,11 +78,42 @@ func vmSettings(v *config.VM) (settings.GCE, error) { return gce, nil } -// Create builds the firewall rule first, then the instance, so the instance -// never comes up reachable-then-locked-down. A failed insert deletes the -// rule it already created; core has not written vm.toml as created yet, so -// a caller retrying Create must not find an orphaned rule from this attempt. +// Create validates everything a later Start needs and creates nothing. A +// stoat VM is a record until `stoat up` runs it: the README says create +// writes the VM without starting it, and compute has no way to insert an +// instance that does not boot. Start does the insert instead. +// +// The validation still belongs here. Settings, the guest's GCE image and +// the seed's size are all knowable before any billable resource exists, and +// a create that succeeds only to fail at first start is worse than one that +// refuses now. func (Provider) Create(ctx context.Context, v *config.VM) error { + s, err := vmSettings(v) + if err != nil { + return err + } + if _, err := iso.GCEImageForOS(v.OS); err != nil { + return err + } + seed, err := buildSeed(v) + if err != nil { + return err + } + if len(seed) > maxMetadataValueBytes { + return fmt.Errorf("gce: seed is %d bytes, over metadata's %d byte limit", len(seed), maxMetadataValueBytes) + } + if _, err := maxRunDuration(s); err != nil { + return fmt.Errorf("gce: %w", err) + } + return nil +} + +// insert creates the firewall rule and the instance, in that order, so the +// instance never comes up reachable before it is locked down. A failed +// instance insert deletes the rule it already created, leaving a retry +// nothing orphaned to trip over. Start calls this for a VM that does not +// exist yet; nothing else does. +func insert(ctx context.Context, v *config.VM) error { s, err := vmSettings(v) if err != nil { return err @@ -172,6 +207,9 @@ func buildSeed(v *config.VM) (string, error) { return cloudinit.UserData(v, pub, bodies) } +// Start makes the instance exist and run. The first start inserts it; every +// later one restarts the instance the previous Stop left behind, which is +// what keeps the boot disk and its contents across a down and up. func (Provider) Start(ctx context.Context, v *config.VM) error { s, err := vmSettings(v) if err != nil { @@ -182,6 +220,14 @@ func (Provider) Start(ctx context.Context, v *config.VM) error { return err } defer c.Close() + + if _, err := c.Get(ctx, &computepb.GetInstanceRequest{Project: s.Project, Zone: s.Zone, Instance: v.Name}); err != nil { + if !isNotFound(err) { + return fmt.Errorf("gce: looking up %s: %w", v.Name, err) + } + return insert(ctx, v) + } + op, err := c.Start(ctx, &computepb.StartInstanceRequest{Project: s.Project, Zone: s.Zone, Instance: v.Name}) if err != nil { return fmt.Errorf("gce: starting %s: %w", v.Name, err) @@ -271,12 +317,35 @@ func (Provider) Status(ctx context.Context, v *config.VM) (provider.Status, erro func statusFor(ctx context.Context, c *compute.InstancesClient, s settings.GCE, v *config.VM) (provider.Status, error) { inst, err := c.Get(ctx, &computepb.GetInstanceRequest{Project: s.Project, Zone: s.Zone, Instance: v.Name}) if err != nil { + // A VM created but never started has no instance yet, and neither + // does one whose instance someone deleted in the console. Both are + // stopped from stoat's side, and Raw says which of the two a caller + // is looking at. + if isNotFound(err) { + return provider.Status{Raw: "NOT_CREATED"}, nil + } return provider.Status{}, fmt.Errorf("gce: getting %s: %w", v.Name, err) } raw := inst.GetStatus() return provider.Status{Running: running[raw], Raw: raw}, nil } +// isNotFound reports whether err is compute's 404. The client wraps its HTTP +// status in one of two error types depending on transport, so both are +// checked; a missed 404 turns "this instance does not exist yet" into a hard +// failure on every list. +func isNotFound(err error) bool { + var ge *googleapi.Error + if errors.As(err, &ge) { + return ge.Code == http.StatusNotFound + } + var ae *apierror.APIError + if errors.As(err, &ae) { + return ae.HTTPCode() == http.StatusNotFound + } + return false +} + // Endpoint pins the host key to a per-VM file: this connection crosses a // routable network, where sshx's loopback "skip checking" policy would let // a machine-in-the-middle intercept it silently. diff --git a/internal/provider/resolver.go b/internal/provider/resolver.go new file mode 100644 index 0000000..34e5638 --- /dev/null +++ b/internal/provider/resolver.go @@ -0,0 +1,26 @@ +package provider + +import ( + "context" + + "github.com/novusedge/stoat/internal/config" + "github.com/novusedge/stoat/internal/sshx" +) + +// internal/sshx builds ssh argv for guests that no longer all answer on a +// loopback forward, and it cannot ask the registry itself: this package +// imports sshx, so the reverse would close a cycle. Installing the resolver +// here inverts that. +// +// Without this, sshx.Run and sshx.Provision dial 127.0.0.1 for every VM, and +// a cloud guest refuses the connection while every other command reports it +// running and reachable. +func init() { + sshx.SetEndpointResolver(func(v *config.VM) (sshx.Endpoint, error) { + p, err := For(v) + if err != nil { + return sshx.Endpoint{}, err + } + return p.Endpoint(context.Background(), v) + }) +} diff --git a/internal/sshx/endpoint.go b/internal/sshx/endpoint.go index 556a4e0..067e1b3 100644 --- a/internal/sshx/endpoint.go +++ b/internal/sshx/endpoint.go @@ -28,3 +28,38 @@ func LocalEndpoint(v *config.VM) Endpoint { User: User(v), } } + +// endpointResolver is how sshx reaches a VM that does not answer on a +// loopback forward. internal/provider installs it at init; sshx cannot ask +// the provider registry itself, since provider imports this package. +// +// A nil resolver means loopback, which is what every test and every +// pre-provider caller gets. +var endpointResolver func(*config.VM) (Endpoint, error) + +// SetEndpointResolver installs the provider-backed resolver. Called once, +// from internal/provider's init. +func SetEndpointResolver(f func(*config.VM) (Endpoint, error)) { endpointResolver = f } + +// endpointFor is where this package's own ssh invocations connect. Provision, +// RunCheck and Run all reach a guest that may not be local, so none of them +// may build a LocalEndpoint directly. +func endpointFor(v *config.VM) (Endpoint, error) { + if endpointResolver == nil { + return LocalEndpoint(v), nil + } + return endpointResolver(v) +} + +// mustEndpoint is endpointFor for the call sites inside this package that +// build an ssh argv inline. A resolver failure means the VM's provider is +// unreachable or unknown; falling back to loopback there would dial a port +// on this host that belongs to nothing, so the zero Endpoint is returned +// instead and ssh fails naming an empty host. +func mustEndpoint(v *config.VM) Endpoint { + e, err := endpointFor(v) + if err != nil { + return Endpoint{Name: v.Name, User: User(v)} + } + return e +} diff --git a/internal/sshx/run.go b/internal/sshx/run.go index e3ec838..c9e805e 100644 --- a/internal/sshx/run.go +++ b/internal/sshx/run.go @@ -43,13 +43,17 @@ func Run(ctx context.Context, v *config.VM, root bool, argv []string, stdin io.R if root { remote = escalate(v, argv) } + ep, err := endpointFor(v) + if err != nil { + return nil, nil, 0, err + } var out, errb bytes.Buffer - c := exec.CommandContext(ctx, "ssh", Args(LocalEndpoint(v), Quote(remote))...) + c := exec.CommandContext(ctx, "ssh", Args(ep, Quote(remote))...) c.Stdin = stdin c.Stdout = &out c.Stderr = &errb - err := c.Run() + err = c.Run() var ee *exec.ExitError switch { case err == nil: diff --git a/internal/sshx/sshx.go b/internal/sshx/sshx.go index 446098d..4ba34d9 100644 --- a/internal/sshx/sshx.go +++ b/internal/sshx/sshx.go @@ -291,7 +291,7 @@ func cloudInitProbe(ctx context.Context, v *config.VM, log io.Writer) (cloudInit defer cancel() var out bytes.Buffer - ci := exec.CommandContext(probeCtx, "ssh", Args(LocalEndpoint(v), argv...)...) + ci := exec.CommandContext(probeCtx, "ssh", Args(mustEndpoint(v), argv...)...) ci.Cancel = func() error { return ci.Process.Signal(syscall.SIGTERM) } ci.WaitDelay = recipeShutdownGrace ci.Stdout = &out @@ -388,7 +388,7 @@ func RunCheck(ctx context.Context, v *config.VM, command string, timeout time.Du prelude = guest.Prelude(o, "sh") } body := prelude + "\n" + command + "\n" - cmd := exec.CommandContext(ctx, "ssh", Args(LocalEndpoint(v), escalate(v, []string{"sh", "-s"})...)...) + cmd := exec.CommandContext(ctx, "ssh", Args(mustEndpoint(v), escalate(v, []string{"sh", "-s"})...)...) cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) } cmd.WaitDelay = healthShutdownGrace cmd.Stdin = strings.NewReader(body) @@ -421,7 +421,7 @@ func Provision(ctx context.Context, v *config.VM) (err error) { defer func() { _ = log.Close() }() fmt.Fprintf(log, "waiting for ssh on port %d…\n", v.SSHPort) - if err := Wait(ctx, LocalEndpoint(v), WaitTimeout); err != nil { + if err := Wait(ctx, mustEndpoint(v), WaitTimeout); err != nil { if ctx.Err() != nil { fmt.Fprintf(log, "CANCELLED: %v\n", err) } else { @@ -451,7 +451,7 @@ func Provision(ctx context.Context, v *config.VM) (err error) { if o, ok := guest.Lookup(v.OS); ok && strings.TrimSpace(o.Pkg.Setup) != "" { fmt.Fprintln(log, "refreshing the package index...") - st := exec.CommandContext(ctx, "ssh", Args(LocalEndpoint(v), escalate(v, []string{"sh", "-s"})...)...) + st := exec.CommandContext(ctx, "ssh", Args(mustEndpoint(v), escalate(v, []string{"sh", "-s"})...)...) st.Cancel = func() error { return st.Process.Signal(syscall.SIGTERM) } st.WaitDelay = recipeShutdownGrace st.Stdin = strings.NewReader(guest.Prelude(o, "sh") + "stoat_pkg_setup\n") @@ -502,7 +502,7 @@ func Provision(ctx context.Context, v *config.VM) (err error) { if bootstrap := recipes.BootstrapScript(runtime, v.OS); bootstrap != "" { fmt.Fprintf(log, "ensuring %s is installed...\n", runtime) - bs := exec.CommandContext(ctx, "ssh", Args(LocalEndpoint(v), escalate(v, []string{"sh", "-s"})...)...) + bs := exec.CommandContext(ctx, "ssh", Args(mustEndpoint(v), escalate(v, []string{"sh", "-s"})...)...) bs.Cancel = func() error { return bs.Process.Signal(syscall.SIGTERM) } bs.WaitDelay = recipeShutdownGrace bs.Stdin = strings.NewReader(guest.WithPrelude(bootstrap, preludeFor(v, "sh"))) @@ -518,7 +518,7 @@ func Provision(ctx context.Context, v *config.VM) (err error) { } } - cmd := exec.CommandContext(ctx, "ssh", Args(LocalEndpoint(v), escalate(v, recipes.InterpreterArgs(runtime))...)...) + cmd := exec.CommandContext(ctx, "ssh", Args(mustEndpoint(v), escalate(v, recipes.InterpreterArgs(runtime))...)...) cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) } cmd.WaitDelay = recipeShutdownGrace cmd.Stdin = strings.NewReader(input) From 9a8bf4a3732688aa3fbba4ee05b347d82139d4fa Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 9 Sep 2026 04:04:52 +0300 Subject: [PATCH 23/24] test: add the gce live gate, and stop naming a command that does not exist The gate creates a real instance and asserts what the unit suite cannot see: that create makes no instance, that the guest answers as the seeded account with cloud-init finished, that a refusal carries a machine readable reason, that the home directory survives a stop and start, and that rm leaves no instance, disk or firewall rule behind. It skips loudly without STOAT_GCE_LIVE_PROJECT and STOAT_GCE_LIVE_ZONE. The deadline warning told the reader to run stoat gce extend, which lands in the next slice. It states the deadline and names nothing. The TUI detail screen hides the display and vnc rows for a cloud VM: a socket path nothing listens on sends a user hunting for a viewer. Signed-off-by: NovusEdge --- internal/cli/run_vm.go | 6 ++- internal/tui/detail.go | 53 +++++++++++--------- tests/gce-live-gate.sh | 111 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 26 deletions(-) create mode 100755 tests/gce-live-gate.sh diff --git a/internal/cli/run_vm.go b/internal/cli/run_vm.go index 02263b0..13619ba 100644 --- a/internal/cli/run_vm.go +++ b/internal/cli/run_vm.go @@ -107,8 +107,10 @@ func deadlineWarning(v core.VM) (string, bool) { if left > provider.WarnWithin { return "", false } - return fmt.Sprintf("%s: stops in %s (%s). extend with: stoat gce extend %s 4h", - v.Name, formatDuration(left), which, v.Name), true + // No command is named here yet. `stoat gce extend` moves the soft + // deadline and lands in the next slice; naming it now would tell a user + // to run something that does not exist. + return fmt.Sprintf("%s: stops in %s (%s)", v.Name, formatDuration(left), which), true } // warnDeadline prints deadlineWarning's line to stderr for a single command diff --git a/internal/tui/detail.go b/internal/tui/detail.go index b210ebd..71d18e1 100644 --- a/internal/tui/detail.go +++ b/internal/tui/detail.go @@ -442,30 +442,35 @@ func (m model) viewDetail() string { } facts.row("", "", effect) } - line("display", displayPrefLabel(v.Display)) - // qemu.DisplayKind takes pref and host graphical directly, not a - // *config.VM, so a core.VM caller can call it without going through - // qemu.NeedsWindow/WantsWindow, which need a *config.VM. - // - // A bare socket path is not enough: the user needs the actual command - // that opens it, so this prints one for a viewer installed on this host. - // - // With no graphical session, a VM that would otherwise get a window also - // lands on this socket. That is the case where the user needs the - // explanation most, since the VM would otherwise look like it refused - // to start. - if graphical := qemu.GraphicalSession(); qemu.DisplayKind(v.Display, graphical) != qemu.DisplayWindow { - if !graphical && qemu.DisplayKind(v.Display, true) == qemu.DisplayWindow { - facts.row("", "", warnStyle.Render("no usable graphical session on this host: falling back to vnc")) - } - line("vnc", v.Paths.VNCSocket) - att := qemu.AttachVNC(v.Paths.VNCSocket) - if att.Command == "" { - facts.row("", "", warnStyle.Render("no VNC viewer found: install "+strings.Join(att.Missing, " or "))) - } else { - facts.row("", "", dimStyle.Render(att.Command)) - if att.Then != "" { - facts.row("", "", dimStyle.Render(att.Then)) + // Display and the VNC socket below describe a qemu process on this host. + // A cloud VM has neither, and a socket path nothing listens on sends a + // user hunting for a viewer to attach to it. + if v.Provider == "" || v.Provider == "qemu" { + line("display", displayPrefLabel(v.Display)) + // qemu.DisplayKind takes pref and host graphical directly, not a + // *config.VM, so a core.VM caller can call it without going through + // qemu.NeedsWindow/WantsWindow, which need a *config.VM. + // + // A bare socket path is not enough: the user needs the actual command + // that opens it, so this prints one for a viewer installed on this host. + // + // With no graphical session, a VM that would otherwise get a window also + // lands on this socket. That is the case where the user needs the + // explanation most, since the VM would otherwise look like it refused + // to start. + if graphical := qemu.GraphicalSession(); qemu.DisplayKind(v.Display, graphical) != qemu.DisplayWindow { + if !graphical && qemu.DisplayKind(v.Display, true) == qemu.DisplayWindow { + facts.row("", "", warnStyle.Render("no usable graphical session on this host: falling back to vnc")) + } + line("vnc", v.Paths.VNCSocket) + att := qemu.AttachVNC(v.Paths.VNCSocket) + if att.Command == "" { + facts.row("", "", warnStyle.Render("no VNC viewer found: install "+strings.Join(att.Missing, " or "))) + } else { + facts.row("", "", dimStyle.Render(att.Command)) + if att.Then != "" { + facts.row("", "", dimStyle.Render(att.Then)) + } } } } diff --git a/tests/gce-live-gate.sh b/tests/gce-live-gate.sh new file mode 100755 index 0000000..2e32cbd --- /dev/null +++ b/tests/gce-live-gate.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# End-to-end gate for the gce provider against a real GCP project. +# +# Every defect this gate exists to catch was invisible to the unit suite: +# create demanding a local image gce never opens, create inserting a running +# instance, sshx dialling loopback for a cloud guest. None of them can be +# reproduced without a real API, which is why this is a separate gate rather +# than a test. +# +# Skips unless STOAT_GCE_LIVE_PROJECT and STOAT_GCE_LIVE_ZONE are set. +# +# STOAT_GCE_LIVE_PROJECT=my-project STOAT_GCE_LIVE_ZONE=europe-west4-a \ +# bash tests/gce-live-gate.sh +set -euo pipefail + +if [ -z "${STOAT_GCE_LIVE_PROJECT:-}" ] || [ -z "${STOAT_GCE_LIVE_ZONE:-}" ]; then + echo "skip: set STOAT_GCE_LIVE_PROJECT and STOAT_GCE_LIVE_ZONE to run the gce live gate" + exit 0 +fi + +repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +work="$(mktemp -d)" +vm="livegate-$$" +project="$STOAT_GCE_LIVE_PROJECT" +zone="$STOAT_GCE_LIVE_ZONE" + +# Runs on every exit, including a failure or a kill. The instance also +# carries its own short run-time limit, so a gate killed hard enough to skip +# this trap still stops billing on its own. +cleanup() { + "$work/stoat" rm -y "$vm" >/dev/null 2>&1 || true + gcloud compute instances delete "$vm" --project="$project" --zone="$zone" --quiet >/dev/null 2>&1 || true + gcloud compute firewall-rules delete "stoat-ssh-$vm" --project="$project" --quiet >/dev/null 2>&1 || true + rm -rf "$work" +} +trap cleanup EXIT + +fail() { echo "FAIL: $*" >&2; exit 1; } + +(cd "$repo" && go build -o "$work/stoat" ./cmd/stoat) + +export STOAT_HOME="$work/home" +mkdir -p "$STOAT_HOME" +cat >"$STOAT_HOME/config.toml" </dev/null + +# create writes a record and nothing else. An instance existing here means +# the insert leaked back into Create, which is what made up refuse with +# "already running". +if gcloud compute instances describe "$vm" --project="$project" --zone="$zone" >/dev/null 2>&1; then + fail "create made an instance; it must only write the record" +fi +"$work/stoat" ls | grep -q "$vm" || fail "ls does not show the created VM" +"$work/stoat" ls | grep -q "gce" || fail "ls does not show gce in the WHERE column" + +echo "== up ==" +"$work/stoat" up "$vm" >/dev/null +"$work/stoat" wait "$vm" --until reachable --timeout 5m >/dev/null || fail "VM never became reachable" + +echo "== guest ==" +who="$("$work/stoat" exec "$vm" -- whoami)" +[ "$who" = "stoat" ] || fail "expected to connect as stoat, got '$who'" +"$work/stoat" exec "$vm" -- sh -c 'sudo -n true' || fail "no passwordless sudo for the seeded account" +ci="$("$work/stoat" exec "$vm" -- sh -c 'cloud-init status')" +echo "$ci" | grep -q done || fail "cloud-init did not finish: $ci" + +echo "== copy ==" +echo "gate-marker" >"$work/marker" +"$work/stoat" cp "$work/marker" "$vm:/home/stoat/marker" >/dev/null +got="$("$work/stoat" exec "$vm" -- cat /home/stoat/marker)" +[ "$got" = "gate-marker" ] || fail "copied file read back as '$got'" + +echo "== capability refusals ==" +for op in "snapshot $vm tag1" "forward $vm 8080:80"; do + # shellcheck disable=SC2086 + out="$("$work/stoat" --json $op 2>&1 || true)" + echo "$out" | grep -q capability_unavailable || fail "$op did not report capability_unavailable: $out" + echo "$out" | grep -q provider_unsupported || fail "$op did not carry a reason: $out" +done + +echo "== persistence across down and up ==" +"$work/stoat" down "$vm" >/dev/null +gcloud compute disks describe "$vm" --project="$project" --zone="$zone" --format='value(status)' | grep -q READY \ + || fail "boot disk did not survive the stop" +"$work/stoat" up "$vm" >/dev/null +"$work/stoat" wait "$vm" --until reachable --timeout 5m >/dev/null || fail "VM unreachable after restart" +got="$("$work/stoat" exec "$vm" -- cat /home/stoat/marker)" +[ "$got" = "gate-marker" ] || fail "home directory did not survive the restart" + +echo "== teardown ==" +"$work/stoat" down "$vm" >/dev/null +"$work/stoat" rm -y "$vm" >/dev/null + +for kind in "instances describe $vm --zone=$zone" "disks describe $vm --zone=$zone"; do + # shellcheck disable=SC2086 + if gcloud compute $kind --project="$project" >/dev/null 2>&1; then + fail "rm left a $kind behind" + fi +done +if gcloud compute firewall-rules describe "stoat-ssh-$vm" --project="$project" >/dev/null 2>&1; then + fail "rm left the firewall rule behind" +fi + +echo "ok: gce live gate passed against $project/$zone" From b59fb419ebeef6bce9a9448fe90b4ce3cc4d3069 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 9 Sep 2026 04:11:24 +0300 Subject: [PATCH 24/24] fix(gce): satisfy the linter, and stop impersonating through a deprecated option errcheck flagged every deferred Close on a compute client and on the address lookup's response body. option.ImpersonateCredentials is deprecated in favour of the impersonate package, which mints a token source from whatever ADC resolves and exchanges it for one as the target account; ADC stays the base credential either way. option.WithCredentialsFile is deprecated because a key file on disk is a standing credential someone can copy, which is the same reason d7 makes it an explicit escape hatch rather than a fallback. It has no replacement, so the call carries a targeted suppression naming why. Signed-off-by: NovusEdge --- internal/provider/gce/client.go | 47 +++++++++++++++++++++---- internal/provider/gce/client_test.go | 7 +++- internal/provider/gce/gce.go | 18 +++++----- internal/provider/gce/gce_test.go | 4 +-- internal/provider/gce/instance.go | 2 +- internal/provider/gce/transport_test.go | 14 ++++---- internal/settings/resolve.go | 2 +- 7 files changed, 66 insertions(+), 28 deletions(-) diff --git a/internal/provider/gce/client.go b/internal/provider/gce/client.go index bdd157f..05cc124 100644 --- a/internal/provider/gce/client.go +++ b/internal/provider/gce/client.go @@ -5,10 +5,17 @@ import ( "fmt" compute "cloud.google.com/go/compute/apiv1" - "github.com/novusedge/stoat/internal/settings" + "google.golang.org/api/impersonate" "google.golang.org/api/option" + + "github.com/novusedge/stoat/internal/settings" ) +// computeScope is the one scope this provider needs. Impersonation mints a +// token source, and a token source must name its scopes; ADC on its own gets +// them from the client library. +const computeScope = "https://www.googleapis.com/auth/compute" + // validateCredentials rejects a config naming both an impersonated service // account and a key file: nothing in the settings schema says which wins. func validateCredentials(s settings.GCE) (settings.GCE, error) { @@ -20,15 +27,33 @@ func validateCredentials(s settings.GCE) (settings.GCE, error) { // clientOptions builds the option.ClientOption list for s. Plain ADC needs // none; it is the compute client's default. -func clientOptions(s settings.GCE) []option.ClientOption { +// +// Impersonation goes through the impersonate package rather than +// option.ImpersonateCredentials, which is deprecated. ADC stays the base +// credential either way: impersonate.CredentialsTokenSource signs with +// whatever ADC resolves and exchanges it for a token as the target account. +func clientOptions(ctx context.Context, s settings.GCE) ([]option.ClientOption, error) { var opts []option.ClientOption if s.ImpersonateServiceAccount != "" { - opts = append(opts, option.ImpersonateCredentials(s.ImpersonateServiceAccount)) + ts, err := impersonate.CredentialsTokenSource(ctx, impersonate.CredentialsConfig{ + TargetPrincipal: s.ImpersonateServiceAccount, + Scopes: []string{computeScope}, + }) + if err != nil { + return nil, fmt.Errorf("impersonating %s: %w", s.ImpersonateServiceAccount, err) + } + opts = append(opts, option.WithTokenSource(ts)) } if s.ServiceAccountKeyFile != "" { + // Deprecated upstream because a key file on disk is a standing + // credential someone can copy. That is the same reason d7 makes this + // an explicit escape hatch behind its own config key rather than a + // fallback, and there is no replacement for an environment that can + // supply neither ADC nor impersonation. + //nolint:staticcheck // SA1019: no replacement; see the comment above. opts = append(opts, option.WithCredentialsFile(s.ServiceAccountKeyFile)) } - return opts + return opts, nil } // newClient builds the Compute Engine instances client for s, authenticated @@ -39,15 +64,23 @@ func newClient(ctx context.Context, s settings.GCE) (*compute.InstancesClient, e if err != nil { return nil, err } - return compute.NewInstancesRESTClient(ctx, clientOptions(s)...) + opts, err := clientOptions(ctx, s) + if err != nil { + return nil, err + } + return compute.NewInstancesRESTClient(ctx, opts...) } // newFirewallsClient authenticates the same way as newClient, for the -// separate Firewalls API surface Create and Destroy also need. +// separate Firewalls API surface insert and Destroy also need. func newFirewallsClient(ctx context.Context, s settings.GCE) (*compute.FirewallsClient, error) { s, err := validateCredentials(s) if err != nil { return nil, err } - return compute.NewFirewallsRESTClient(ctx, clientOptions(s)...) + opts, err := clientOptions(ctx, s) + if err != nil { + return nil, err + } + return compute.NewFirewallsRESTClient(ctx, opts...) } diff --git a/internal/provider/gce/client_test.go b/internal/provider/gce/client_test.go index fcdf9c3..a7049a4 100644 --- a/internal/provider/gce/client_test.go +++ b/internal/provider/gce/client_test.go @@ -1,6 +1,7 @@ package gce import ( + "context" "strings" "testing" @@ -8,7 +9,11 @@ import ( ) func TestClientOptionsPlainADC(t *testing.T) { - if got := clientOptions(settings.GCE{Project: "p"}); len(got) != 0 { + got, err := clientOptions(context.Background(), settings.GCE{Project: "p"}) + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { t.Errorf("clientOptions = %d options, want none: plain ADC needs no option", len(got)) } } diff --git a/internal/provider/gce/gce.go b/internal/provider/gce/gce.go index 115967e..1095002 100644 --- a/internal/provider/gce/gce.go +++ b/internal/provider/gce/gce.go @@ -135,7 +135,7 @@ func insert(ctx context.Context, v *config.VM) error { if err != nil { return err } - defer fwClient.Close() + defer func() { _ = fwClient.Close() }() fwOp, err := fwClient.Insert(ctx, &computepb.InsertFirewallRequest{ Project: s.Project, @@ -159,7 +159,7 @@ func insert(ctx context.Context, v *config.VM) error { _ = deleteFirewallRule(ctx, fwClient, s, v.Name) return err } - defer instClient.Close() + defer func() { _ = instClient.Close() }() insOp, err := instClient.Insert(ctx, req) if err != nil { @@ -219,7 +219,7 @@ func (Provider) Start(ctx context.Context, v *config.VM) error { if err != nil { return err } - defer c.Close() + defer func() { _ = c.Close() }() if _, err := c.Get(ctx, &computepb.GetInstanceRequest{Project: s.Project, Zone: s.Zone, Instance: v.Name}); err != nil { if !isNotFound(err) { @@ -244,7 +244,7 @@ func (Provider) Stop(ctx context.Context, v *config.VM) error { if err != nil { return err } - defer c.Close() + defer func() { _ = c.Close() }() op, err := c.Stop(ctx, &computepb.StopInstanceRequest{Project: s.Project, Zone: s.Zone, Instance: v.Name}) if err != nil { return fmt.Errorf("gce: stopping %s: %w", v.Name, err) @@ -266,7 +266,7 @@ func (Provider) Destroy(ctx context.Context, v *config.VM) error { if err != nil { return err } - defer c.Close() + defer func() { _ = c.Close() }() op, err := c.Delete(ctx, &computepb.DeleteInstanceRequest{Project: s.Project, Zone: s.Zone, Instance: v.Name}) if err != nil { return fmt.Errorf("gce: deleting %s: %w", v.Name, err) @@ -279,7 +279,7 @@ func (Provider) Destroy(ctx context.Context, v *config.VM) error { if err != nil { return err } - defer fwClient.Close() + defer func() { _ = fwClient.Close() }() return deleteFirewallRule(ctx, fwClient, s, v.Name) } @@ -308,7 +308,7 @@ func (Provider) Status(ctx context.Context, v *config.VM) (provider.Status, erro if err != nil { return provider.Status{}, err } - defer c.Close() + defer func() { _ = c.Close() }() return statusFor(ctx, c, s, v) } @@ -358,7 +358,7 @@ func (Provider) Endpoint(ctx context.Context, v *config.VM) (sshx.Endpoint, erro if err != nil { return sshx.Endpoint{}, err } - defer c.Close() + defer func() { _ = c.Close() }() return endpointFor(ctx, c, s, v) } @@ -391,7 +391,7 @@ func (Provider) Details(ctx context.Context, v *config.VM) (provider.Details, er if err != nil { return provider.Details{}, err } - defer c.Close() + defer func() { _ = c.Close() }() inst, err := c.Get(ctx, &computepb.GetInstanceRequest{Project: s.Project, Zone: s.Zone, Instance: v.Name}) if err != nil { return provider.Details{}, fmt.Errorf("gce: getting %s: %w", v.Name, err) diff --git a/internal/provider/gce/gce_test.go b/internal/provider/gce/gce_test.go index ff37a56..a3d3276 100644 --- a/internal/provider/gce/gce_test.go +++ b/internal/provider/gce/gce_test.go @@ -53,7 +53,7 @@ func TestStatusMapsRunningStates(t *testing.T) { func TestStatusForReadsRawAndMapping(t *testing.T) { c := fakeInstancesClient(t, `{"status":"STOPPING"}`) - defer c.Close() + defer func() { _ = c.Close() }() st, err := statusFor(context.Background(), c, settings.GCE{Project: "p", Zone: "z"}, &config.VM{Name: "cloudy"}) if err != nil { t.Fatal(err) @@ -68,7 +68,7 @@ func TestStatusForReadsRawAndMapping(t *testing.T) { func TestEndpointReadsTheExternalAddress(t *testing.T) { c := fakeInstancesClient(t, `{"networkInterfaces":[{"accessConfigs":[{"natIP":"34.12.221.212"}]}]}`) - defer c.Close() + defer func() { _ = c.Close() }() v := &config.VM{Name: "cloudy", Dir: "/data/vms/cloudy"} ep, err := endpointFor(context.Background(), c, settings.GCE{Project: "p", Zone: "z"}, v) if err != nil { diff --git a/internal/provider/gce/instance.go b/internal/provider/gce/instance.go index 2d362a9..a08e563 100644 --- a/internal/provider/gce/instance.go +++ b/internal/provider/gce/instance.go @@ -192,7 +192,7 @@ func operatorRangeWith(ctx context.Context, client *http.Client) (string, error) if err != nil { return "", fmt.Errorf("looking up the operator's address: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(io.LimitReader(resp.Body, 256)) if err != nil { return "", err diff --git a/internal/provider/gce/transport_test.go b/internal/provider/gce/transport_test.go index 56cab43..779c488 100644 --- a/internal/provider/gce/transport_test.go +++ b/internal/provider/gce/transport_test.go @@ -71,8 +71,8 @@ func fakeCompute(t *testing.T, bodies ...string) (*compute.InstancesClient, *com // shape a real create actually returns rather than a synchronous success. func TestTransportCreatesFirewallThenInstance(t *testing.T) { ic, fc := fakeCompute(t, "operation_running.json", "operation_done.json") - defer ic.Close() - defer fc.Close() + defer func() { _ = ic.Close() }() + defer func() { _ = fc.Close() }() ctx := context.Background() fwOp, err := fc.Insert(ctx, &computepb.InsertFirewallRequest{ @@ -101,7 +101,7 @@ func TestTransportCreatesFirewallThenInstance(t *testing.T) { func TestTransportStartsAnInstance(t *testing.T) { ic, _ := fakeCompute(t, "operation_running.json", "operation_done.json") - defer ic.Close() + defer func() { _ = ic.Close() }() ctx := context.Background() op, err := ic.Start(ctx, &computepb.StartInstanceRequest{Project: "p", Zone: "europe-west4-a", Instance: "cloudy"}) if err != nil { @@ -114,7 +114,7 @@ func TestTransportStartsAnInstance(t *testing.T) { func TestTransportStopsAnInstance(t *testing.T) { ic, _ := fakeCompute(t, "operation_running.json", "operation_done.json") - defer ic.Close() + defer func() { _ = ic.Close() }() ctx := context.Background() op, err := ic.Stop(ctx, &computepb.StopInstanceRequest{Project: "p", Zone: "europe-west4-a", Instance: "cloudy"}) if err != nil { @@ -129,8 +129,8 @@ func TestTransportStopsAnInstance(t *testing.T) { // instance is gone before its firewall rule is deleted. func TestTransportDestroysInstanceThenFirewall(t *testing.T) { ic, fc := fakeCompute(t, "operation_running.json", "operation_done.json") - defer ic.Close() - defer fc.Close() + defer func() { _ = ic.Close() }() + defer func() { _ = fc.Close() }() ctx := context.Background() delOp, err := ic.Delete(ctx, &computepb.DeleteInstanceRequest{Project: "p", Zone: "europe-west4-a", Instance: "cloudy"}) @@ -153,7 +153,7 @@ func TestTransportDestroysInstanceThenFirewall(t *testing.T) { // and maxRunDuration alone. func TestTransportStatusReadsARecordedInstance(t *testing.T) { ic, _ := fakeCompute(t, "instance_get.json") - defer ic.Close() + defer func() { _ = ic.Close() }() ctx := context.Background() v := &config.VM{Name: "cloudy", Dir: "/data/vms/cloudy"} s := settings.GCE{Project: "engrammic", Zone: "europe-west4-a"} diff --git a/internal/settings/resolve.go b/internal/settings/resolve.go index 2c23459..614347f 100644 --- a/internal/settings/resolve.go +++ b/internal/settings/resolve.go @@ -109,7 +109,7 @@ func gcloudActiveConfig() (project, zone string, err error) { if err != nil { return "", "", err } - defer f.Close() + defer func() { _ = f.Close() }() section := "" scanner := bufio.NewScanner(f)