diff --git a/docs/reference/cli.md b/docs/reference/cli.md index c2546a5..5e60668 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -95,13 +95,20 @@ 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. + +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. @@ -138,13 +145,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 +168,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:`, `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. ## `stoat create --image=IMAGE` @@ -172,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 99585b6..a174147 100644 --- a/docs/reference/json.md +++ b/docs/reference/json.md @@ -210,8 +210,15 @@ 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","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. `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":[ {"name":"xfce","applied":true,"version":"1.2","at":"...", "health":"unknown","params":{},"outputs":{}}]} 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/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/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/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/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 17b0208..48eb892 100644 --- a/internal/cli/run_get.go +++ b/internal/cli/run_get.go @@ -5,9 +5,11 @@ import ( "io" "sort" "strings" + "time" "github.com/novusedge/stoat/internal/cli/wire" "github.com/novusedge/stoat/internal/core" + "github.com/novusedge/stoat/internal/provider" ) func runGet(a *Args, stdout, stderr io.Writer) int { @@ -18,9 +20,11 @@ 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) + 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 +62,34 @@ func runGet(a *Args, stdout, stderr io.Writer) int { return ExitOK } +// printProviderBlock prints the provider line, and for a non-qemu VM the +// project, zone, machine type, address and the nearer of its two deadlines. +func printProviderBlock(stdout io.Writer, v core.VM) { + name := v.Provider + if name == "" { + name = "qemu" + } + fmt.Fprintf(stdout, "provider: %s\n", name) + if v.Provider == "" { + return + } + 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 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) + } +} + func sortedKeys(values map[string]string) []string { keys := make([]string, 0, len(values)) for key := range values { diff --git a/internal/cli/run_vm.go b/internal/cli/run_vm.go index 5fa5b15..13619ba 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" ) @@ -36,7 +38,8 @@ 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") + 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 @@ -52,8 +55,14 @@ 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)) + 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 @@ -62,12 +71,71 @@ 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 +} + +// 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) { + // 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 + } + left := time.Until(when) + if left > provider.WarnWithin { + return "", false + } + // 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 +// 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 { @@ -118,6 +186,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) @@ -129,8 +198,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 @@ -332,6 +409,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) } @@ -372,6 +450,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 } @@ -418,6 +497,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/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/cli/wire/dto.go b/internal/cli/wire/dto.go index 10b3d82..20618b5 100644 --- a/internal/cli/wire/dto.go +++ b/internal/cli/wire/dto.go @@ -115,6 +115,19 @@ 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"). 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. @@ -213,7 +226,21 @@ func FromVM(v core.VM, graphical bool) VM { Project: v.Project, 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 { 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/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/cloudinit/cloudinit.go b/internal/cloudinit/cloudinit.go index 955311a..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)) @@ -142,8 +150,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 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/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.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 1f0d997..4dfb3e7 100644 --- a/internal/core/capability_test.go +++ b/internal/core/capability_test.go @@ -39,3 +39,79 @@ 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) + } + } +} + +// 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/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/core.go b/internal/core/core.go index 6837a56..6ee248b 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 @@ -191,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/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/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/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/core/vm.go b/internal/core/vm.go index 772991a..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" ) @@ -215,6 +216,25 @@ 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 + + // 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 @@ -238,7 +258,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) @@ -271,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{ @@ -311,6 +339,13 @@ func fromConfigUnchecked(v *config.VM) VM { AgentAccess: v.AgentAccess, 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/iso/iso.go b/internal/iso/iso.go index cb4c233..efc5a81 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,43 @@ 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) +} + +// 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/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/client.go b/internal/provider/gce/client.go new file mode 100644 index 0000000..05cc124 --- /dev/null +++ b/internal/provider/gce/client.go @@ -0,0 +1,86 @@ +package gce + +import ( + "context" + "fmt" + + compute "cloud.google.com/go/compute/apiv1" + "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) { + 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. +// +// 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 != "" { + 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, nil +} + +// 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 + } + 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 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 + } + 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 new file mode 100644 index 0000000..a7049a4 --- /dev/null +++ b/internal/provider/gce/client_test.go @@ -0,0 +1,33 @@ +package gce + +import ( + "context" + "strings" + "testing" + + "github.com/novusedge/stoat/internal/settings" +) + +func TestClientOptionsPlainADC(t *testing.T) { + 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)) + } +} + +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) + } +} 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/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/gce.go b/internal/provider/gce/gce.go new file mode 100644 index 0000000..1095002 --- /dev/null +++ b/internal/provider/gce/gce.go @@ -0,0 +1,420 @@ +// Package gce implements the Provider interface over the Compute Engine API. +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" + "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{ + capabilities.OpShare, capabilities.OpScreenshot, capabilities.OpSendKey, + capabilities.OpConsoleLog, capabilities.OpForward, + capabilities.OpSnapshot, capabilities.OpClone, + capabilities.OpUpdateRAM, capabilities.OpUpdateCPU, +} + +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 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 + } + image, err := iso.GCEImageForOS(v.OS) + if err != nil { + return err + } + seed, err := buildSeed(v) + if err != nil { + return err + } + sourceRange, err := sourceRangeFor(ctx, s) + if err != nil { + return fmt.Errorf("gce: %w", err) + } + + fwClient, err := newFirewallsClient(ctx, s) + if err != nil { + return err + } + defer func() { _ = 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 func() { _ = 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) +} + +// 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 { + return err + } + c, err := newClient(ctx, s) + if err != nil { + return err + } + 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) { + 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) + } + 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 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) + } + 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 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) + } + 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 func() { _ = 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 func() { _ = 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 { + // 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. +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 func() { _ = 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 +} + +// 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 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) + } + 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() { + 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..a3d3276 --- /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 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) + } + 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 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 { + 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]) + } + } +} diff --git a/internal/provider/gce/instance.go b/internal/provider/gce/instance.go new file mode 100644 index 0000000..a08e563 --- /dev/null +++ b/internal/provider/gce/instance.go @@ -0,0 +1,219 @@ +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 = 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 +// 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 +// 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) + } + runFor, err := maxRunDuration(s) + if err != nil { + return nil, 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(runFor.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 +} + +// 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 +// 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 := client.Do(req) + if err != nil { + return "", fmt.Errorf("looking up the operator's address: %w", err) + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(io.LimitReader(resp.Body, 256)) + if err != nil { + return "", err + } + addr := strings.TrimSpace(string(body)) + 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 +} + +// 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..3a3a898 --- /dev/null +++ b/internal/provider/gce/instance_test.go @@ -0,0 +1,174 @@ +package gce + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/novusedge/stoat/internal/config" + "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"} +} + +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) + } +} + +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") + } +} + +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) + } +} + +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/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) + } + } + } +} 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..779c488 --- /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 func() { _ = ic.Close() }() + defer func() { _ = 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 func() { _ = 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 func() { _ = 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 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"}) + 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 func() { _ = 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") + } +} 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 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", } 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/settings/resolve.go b/internal/settings/resolve.go new file mode 100644 index 0000000..614347f --- /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 func() { _ = 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 + })() +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 13743b1..2213f19 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -21,6 +21,24 @@ 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"` + + // 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. + // + // 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 { 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) 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 68e77d6..71d18e1 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 == "" { @@ -258,6 +259,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 @@ -356,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 != "" { @@ -410,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/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. diff --git a/internal/tui/edit.go b/internal/tui/edit.go index 4bcbc40..278e6a9 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. @@ -38,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 @@ -419,7 +443,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 +544,7 @@ func (m model) viewEdit() string { if !e.dirty() { note(dimStyle.Render("no changes")) } - if qemu.Running(e.vm) { + if e.running { 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 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 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"