diff --git a/README.md b/README.md index 7dfb784..0ddd08e 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ As we already use ginkgo as testing framework - and that seems to scale as well - QEMU (no KVM) - Docker - Virtualbox +- Proxmox VE They share the same common apis, so you can control machine created with the engines in the same way from a testing perspective. @@ -24,6 +25,45 @@ Software like QEMU, Docker, and Virtualbox needs to be installed in the machine. If you are running tests on Github, keep in mind that the Virtualbox engine is specifically tailored for it - you should just be good to go as is with no additional configuration. +### Proxmox VE + +The Proxmox engine creates and manages a QEMU VM through the Proxmox VE API. +It supports pre-staged ISO references, URL downloads, local ISO transfer, +bridge/SDN networking, custom QEMU arguments, disk management, CD detachment, +SSH-based commands and file transfer, and VNC screenshots. + +Use exactly one authentication method: an API token, or a username/password. +Custom QEMU arguments require username/password authentication because Proxmox +restricts the `args` setting. TLS certificates are verified by default; only set +`insecureTLS` for a trusted development environment with a self-signed certificate. + +```yaml +machine: + engine: proxmox + memory: "2048" + cpu: "2" + iso: local:iso/your-os.iso + driveSizes: ["30000"] + ssh: + host: proxmox.example.com + port: "2222" + user: root + pass: testpassword + proxmox: + apiURL: https://proxmox.example.com:8006/api2/json + node: pve + tokenID: peg@pam!automation + tokenSecret: replace-me + storage: local-lvm + isoStorage: local + bridge: vnet1 + zone: nat # omit for a regular Linux bridge such as vmbr0 +``` + +For SLIRP-only networking, omit `bridge`, configure the required QEMU arguments +in `machine.args`, and use `username`/`password` authentication instead of an API +token. See `examples/example_proxmox.yaml` for a complete runnable specification. + ## Usage `peg` both support it's own syntax with yaml files, or either can be just used as a helper library to use with [ginkgo](https://github.com/onsi/ginkgo/). diff --git a/examples/example_proxmox.yaml b/examples/example_proxmox.yaml new file mode 100644 index 0000000..baba11d --- /dev/null +++ b/examples/example_proxmox.yaml @@ -0,0 +1,45 @@ +machine: + engine: "proxmox" + memory: "2048" + cpu: "2" + # ISO source: local path, HTTP URL, or Proxmox storage reference + # iso: "/home/user/build/your-os.iso" # local file → auto-transferred to Proxmox + # iso: "https://example.com/your-os.iso" # URL → Proxmox downloads directly + iso: "local:iso/your-os.iso" # storage ref → used as-is (no transfer) + driveSizes: + - "30000" # 30GB boot disk (in MB, converted to GB for Proxmox) + ssh: + host: "proxmox.example.com" # Proxmox host IP (DNAT target) + port: "2222" # DNAT forwarded port + user: "root" + pass: "testpassword" + proxmox: + apiURL: "https://proxmox.example.com:8006/api2/json" + node: "pve" + tokenID: "peg@pam!automation" + tokenSecret: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + # Alternatively, remove tokenID/tokenSecret and use login authentication: + # username: "root@pam" + # password: "replace-me" + storage: "local-lvm" + bridge: "vnet1" # SDN VNet, or a Linux bridge such as vmbr0 + zone: "nat" # SDN zone; omit when using a Linux bridge + # insecureTLS: true # trusted development environments only + isoStorage: "local" # storage for ISOs (default: "local") +clean: true + +specs: +- describe: "Proxmox VM tests" + assertions: + "Basic connectivity": + - preOps: + - eventuallyConnects: 180 + command: | + echo hello + expect: + containString: "hello" + "System info": + - command: | + uname -a + expect: + containString: "Linux" diff --git a/go.mod b/go.mod index 1b84fe3..82240df 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/cavaliergopher/grab/v3 v3.0.1 github.com/codingsince1985/checksum v1.2.4 github.com/ipfs/go-log v1.0.5 + github.com/luthermonson/go-proxmox v0.3.2 github.com/mudler/go-processmanager v0.0.0-20220724164624-c45b5c61312d github.com/onsi/ginkgo/v2 v2.1.4 github.com/onsi/gomega v1.20.1 @@ -21,14 +22,19 @@ require ( ) require ( + github.com/buger/goterm v1.0.4 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect + github.com/diskfs/go-diskfs v1.7.0 // indirect + github.com/djherbis/times v1.6.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/google/go-cmp v0.5.9 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/gorilla/websocket v1.4.2 // indirect github.com/ipfs/go-log/v2 v2.1.3 // indirect + github.com/jinzhu/copier v0.3.4 // indirect + github.com/magefile/mage v1.14.0 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/russross/blackfriday/v2 v2.0.1 // indirect github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect - github.com/stretchr/testify v1.7.0 // indirect go.uber.org/atomic v1.7.0 // indirect go.uber.org/multierr v1.6.0 // indirect golang.org/x/net v0.47.0 // indirect diff --git a/go.sum b/go.sum index 0ba2a1d..670d4ff 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,11 @@ github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs= +github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk= github.com/bramvdbogaerde/go-scp v1.5.0 h1:a9BinAjTfQh273eh7vd3qUgmBC+bx+3TRDtkZWmIpzM= github.com/bramvdbogaerde/go-scp v1.5.0/go.mod h1:on2aH5AxaFb2G0N5Vsdy6B0Ml7k9HuHSwfo1y0QzAbQ= +github.com/buger/goterm v1.0.4 h1:Z9YvGmOih81P0FbVtEYTFF6YsSgxSUKEhf/f9bTMXbY= +github.com/buger/goterm v1.0.4/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE= github.com/cavaliergopher/grab/v3 v3.0.1 h1:4z7TkBfmPjmLAAmkkAZNX/6QJ1nNFdv3SdIHXju0Fr4= github.com/cavaliergopher/grab/v3 v3.0.1/go.mod h1:1U/KNnD+Ft6JJiYoYBAimKH2XrYptb8Kl3DFGmsjpq4= github.com/codingsince1985/checksum v1.2.4 h1:kQUpBE1b43jrthLR/RYO4ucEXcZJCq3LpGsMfPDVJYQ= @@ -11,10 +15,18 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:ma github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/diskfs/go-diskfs v1.7.0 h1:vonWmt5CMowXwUc79jWyGrf2DIMeoOjkLlMnQYGVOs8= +github.com/diskfs/go-diskfs v1.7.0/go.mod h1:LhQyXqOugWFRahYUSw47NyZJPezFzB9UELwhpszLP/k= +github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= +github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= +github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab h1:h1UgjJdAAhj+uPL68n7XASS6bU+07ZX1WJvVS2eyoeY= +github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -31,21 +43,37 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE= +github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8= github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo= github.com/ipfs/go-log/v2 v2.1.3 h1:1iS3IU7aXRlbgUpN8yTTpJ53NXYjAe37vcI5+5nYrzk= github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g= +github.com/jinzhu/copier v0.3.4 h1:mfU6jI9PtCeUjkjQ322dlff9ELjGDu975C2p/nrubVI= +github.com/jinzhu/copier v0.3.4/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= +github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/luthermonson/go-proxmox v0.3.2 h1:/zUg6FCl9cAABx0xU3OIgtDtClY0gVXxOCsrceDNylc= +github.com/luthermonson/go-proxmox v0.3.2/go.mod h1:oyFgg2WwTEIF0rP6ppjiixOHa5ebK1p8OaRiFhvICBQ= +github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo= +github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mudler/go-processmanager v0.0.0-20220724164624-c45b5c61312d h1:/lAg9vPAAU+s35cDMCx1IyeMn+4OYfCBPqi08Q8vXDg= @@ -68,9 +96,13 @@ github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+ github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 h1:Ii+DKncOVM8Cu1Hc+ETb5K+23HdAMvESYE3ZJ5b5cMI= github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE= +github.com/pierrec/lz4/v4 v4.1.17 h1:kV4Ip+/hUBC+8T6+2EgburRtkE9ef4nbY3f4dFhGjMc= +github.com/pierrec/lz4/v4 v4.1.17/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/xattr v0.4.9 h1:5883YPCtkSd8LFbs13nXplj9g9tlrwoJRjgpgMu1/fE= +github.com/pkg/xattr v0.4.9/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/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= @@ -78,12 +110,16 @@ github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af h1:Sp5TG9f7K39yfB+If0vjp97vuT74F72r8hfRpP8jLU0= +github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8= +github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/urfave/cli v1.22.9 h1:cv3/KhXGBGjEXLC4bH0sLuJ9BewaAbpk5oyMOveu4pw= github.com/urfave/cli v1.22.9/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -136,7 +172,9 @@ golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210331175145-43e1dd70ce54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -185,7 +223,6 @@ gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM= diff --git a/main.go b/main.go index c6091b5..9e70761 100644 --- a/main.go +++ b/main.go @@ -166,6 +166,71 @@ $ peg --iso path_to_iso_file Usage: "forces VBox engine", EnvVar: "PEG_VBOX", }, + cli.BoolFlag{ + Name: "proxmox", + Usage: "forces Proxmox engine", + EnvVar: "PEG_PROXMOX", + }, + cli.StringFlag{ + Name: "proxmox-api-url", + Usage: "Proxmox API URL (e.g., https://host:8006/api2/json)", + EnvVar: "PEG_PROXMOX_API_URL", + }, + cli.StringFlag{ + Name: "proxmox-node", + Usage: "Proxmox node name", + EnvVar: "PEG_PROXMOX_NODE", + }, + cli.StringFlag{ + Name: "proxmox-token-id", + Usage: "Proxmox API token ID (e.g., user@pam!tokenname)", + EnvVar: "PEG_PROXMOX_TOKEN_ID", + }, + cli.StringFlag{ + Name: "proxmox-token-secret", + Usage: "Proxmox API token secret", + EnvVar: "PEG_PROXMOX_TOKEN_SECRET", + }, + cli.StringFlag{ + Name: "proxmox-username", + Usage: "Proxmox login username (e.g., root@pam)", + EnvVar: "PEG_PROXMOX_USERNAME", + }, + cli.StringFlag{ + Name: "proxmox-password", + Usage: "Proxmox login password", + EnvVar: "PEG_PROXMOX_PASSWORD", + }, + cli.StringFlag{ + Name: "proxmox-storage", + Usage: "Proxmox storage pool (e.g., local-lvm)", + EnvVar: "PEG_PROXMOX_STORAGE", + }, + cli.StringFlag{ + Name: "proxmox-bridge", + Usage: "Proxmox SDN VNet bridge (e.g., vnet1)", + EnvVar: "PEG_PROXMOX_BRIDGE", + }, + cli.StringFlag{ + Name: "proxmox-zone", + Usage: "Proxmox SDN zone (e.g., nat-zone)", + EnvVar: "PEG_PROXMOX_ZONE", + }, + cli.StringFlag{ + Name: "proxmox-iso-storage", + Usage: "Proxmox storage pool for ISO images (default: local)", + EnvVar: "PEG_PROXMOX_ISO_STORAGE", + }, + cli.BoolFlag{ + Name: "proxmox-insecure-tls", + Usage: "disable Proxmox API TLS certificate verification", + EnvVar: "PEG_PROXMOX_INSECURE_TLS", + }, + cli.StringFlag{ + Name: "ssh-host", + Usage: "SSH host override (default: 127.0.0.1)", + EnvVar: "PEG_SSH_HOST", + }, }, UsageText: ``, Copyright: "Spectro Cloud", @@ -190,6 +255,7 @@ $ peg --iso path_to_iso_file types.WithImage(c.String("image")), types.WithISO(c.String("iso")), types.WithISOChecksum(c.String("iso-checksum")), + types.WithSSHHost(c.String("ssh-host")), } if c.Bool("vbox") { @@ -200,6 +266,25 @@ $ peg --iso path_to_iso_file machineOpts = append(machineOpts, types.QEMUEngine) } + if c.Bool("proxmox") { + machineOpts = append(machineOpts, + types.ProxmoxEngine, + types.WithProxmoxAPIURL(c.String("proxmox-api-url")), + types.WithProxmoxNode(c.String("proxmox-node")), + types.WithProxmoxTokenID(c.String("proxmox-token-id")), + types.WithProxmoxTokenSecret(c.String("proxmox-token-secret")), + types.WithProxmoxUsername(c.String("proxmox-username")), + types.WithProxmoxPassword(c.String("proxmox-password")), + types.WithProxmoxStorage(c.String("proxmox-storage")), + types.WithProxmoxBridge(c.String("proxmox-bridge")), + types.WithProxmoxZone(c.String("proxmox-zone")), + types.WithProxmoxISOStorage(c.String("proxmox-iso-storage")), + ) + if c.IsSet("proxmox-insecure-tls") { + machineOpts = append(machineOpts, types.WithProxmoxInsecureTLS(c.Bool("proxmox-insecure-tls"))) + } + } + pegOpts := []peg.Option{ peg.WithLabelFilter(c.String("label")), peg.WithMachineOptions(machineOpts...), diff --git a/peg/generator.go b/peg/generator.go index 252a380..3ae6c8b 100644 --- a/peg/generator.go +++ b/peg/generator.go @@ -2,7 +2,10 @@ package peg import ( "context" + "fmt" + "io" "os" + "strings" "sync" logging "github.com/ipfs/go-log" @@ -45,6 +48,8 @@ func runAssertion(a AssertionBlock) { out, err = matcher.Machine.Command(a.Command) } + writeAssertionOutput(GinkgoWriter, out) + if a.Expect.ToFail { Expect(err).To(HaveOccurred(), out) } else { @@ -111,6 +116,14 @@ func runAssertion(a AssertionBlock) { } } +func writeAssertionOutput(w io.Writer, out string) { + fmt.Fprintln(w, "Command output:") + fmt.Fprint(w, out) + if out != "" && !strings.HasSuffix(out, "\n") { + fmt.Fprintln(w) + } +} + var logOutline = logging.Logger("test-preview") // Generates test suites from a peg file. @@ -140,7 +153,8 @@ func Generate(c *Config) error { for _, t := range c.Tests { logOutline.Infof("-> Test spec '%s' ( label: %s )", t.Describe, t.Label) - Describe(t.Describe, Label(t.Label), func() { + describeArgs := labelDecorators(t.Label) + describeArgs = append(describeArgs, func() { for context, assertions := range t.Assertion { logOutline.Infof("--> Context: %s", context) Context(context, func() { @@ -154,11 +168,20 @@ func Generate(c *Config) error { }) } }) + Describe(t.Describe, describeArgs...) } return nil } +func labelDecorators(label string) []interface{} { + label = strings.TrimSpace(label) + if label == "" { + return nil + } + return []interface{}{Label(label)} +} + // Failer returns a simple fails that exists on failure. func NewFailer() *Failer { return &Failer{} diff --git a/peg/generator_internal_test.go b/peg/generator_internal_test.go new file mode 100644 index 0000000..4f1ead6 --- /dev/null +++ b/peg/generator_internal_test.go @@ -0,0 +1,51 @@ +package peg + +import ( + "bytes" + "testing" +) + +func TestLabelDecorators(t *testing.T) { + for _, label := range []string{"", " ", "\t"} { + if decorators := labelDecorators(label); len(decorators) != 0 { + t.Fatalf("labelDecorators(%q) returned %d decorators, want none", label, len(decorators)) + } + } + + if decorators := labelDecorators(" smoke "); len(decorators) != 1 { + t.Fatalf("labelDecorators() returned %d decorators, want one", len(decorators)) + } +} + +func TestWriteAssertionOutput(t *testing.T) { + tests := []struct { + name string + out string + want string + }{ + { + name: "output ending in newline", + out: "hello\n", + want: "Command output:\nhello\n", + }, + { + name: "output without newline", + out: "Linux", + want: "Command output:\nLinux\n", + }, + { + name: "empty output", + want: "Command output:\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got bytes.Buffer + writeAssertionOutput(&got, tt.out) + if got.String() != tt.want { + t.Fatalf("output = %q, want %q", got.String(), tt.want) + } + }) + } +} diff --git a/pkg/controller/controller.go b/pkg/controller/controller.go index de01a10..faa604a 100644 --- a/pkg/controller/controller.go +++ b/pkg/controller/controller.go @@ -2,7 +2,7 @@ package controller import ( "context" - "fmt" + "net" "os" "time" @@ -37,15 +37,32 @@ func NewClient(m types.Machine) (*ssh.Client, *ssh.Session, error) { } func sshConfig(m types.Machine) (*ssh.ClientConfig, string) { + pass := m.Config().SSH.Pass sshConfig := &ssh.ClientConfig{ - User: m.Config().SSH.User, - Auth: []ssh.AuthMethod{ssh.Password(m.Config().SSH.Pass)}, + User: m.Config().SSH.User, + Auth: []ssh.AuthMethod{ + ssh.Password(pass), + // Also try keyboard-interactive auth, which modern distros + // (RHEL 9, Fedora) use instead of plain password auth. + ssh.KeyboardInteractive(func(user, instruction string, questions []string, echos []bool) ([]string, error) { + answers := make([]string, len(questions)) + for i := range answers { + answers[i] = pass + } + return answers, nil + }), + }, Timeout: 30 * time.Second, // max time to establish connection } sshConfig.HostKeyCallback = ssh.InsecureIgnoreHostKey() - return sshConfig, fmt.Sprintf("127.0.0.1:%s", m.Config().SSH.Port) + host := m.Config().SSH.Host + if host == "" { + host = "127.0.0.1" + } + + return sshConfig, net.JoinHostPort(host, m.Config().SSH.Port) } func ReceiveFile(m types.Machine, src, dst string) error { diff --git a/pkg/machine/machine.go b/pkg/machine/machine.go index 784038e..c76eae3 100644 --- a/pkg/machine/machine.go +++ b/pkg/machine/machine.go @@ -144,6 +144,40 @@ func monitor(ctx context.Context, p *process.Process, f func(p *process.Process) return newCtx } +// prepareProxmox handles preparation for the Proxmox engine. +// It only generates an ID and creates a local StateDir for artifacts (logs, screenshots). +// It skips freeport allocation and ISO downloading since those don't apply to Proxmox. +func prepareProxmox(mc *types.MachineConfig) error { + if mc.ID == "" { + mc.ID = RandStringRunes(10) + log.Infof("Automatically generated machine with id: %s", mc.ID) + } + + if mc.StateDir == "" { + f, err := os.MkdirTemp("", "peg") + if err != nil { + return err + } + mc.StateDir = f + signals.AddCleanupFn(func() { + log.Debug("Cleaning", f) + os.RemoveAll(f) + }) + } else if err := os.MkdirAll(mc.StateDir, 0o755); err != nil { + return fmt.Errorf("creating Proxmox state directory %q: %w", mc.StateDir, err) + } + + // Default SSH port to 22 for Proxmox (DNAT port should be set by user) + if mc.SSH == nil { + mc.SSH = &types.SSH{} + } + if mc.SSH.Port == "" { + mc.SSH.Port = "22" + } + + return nil +} + // New returns a new machine. func New(opts ...types.MachineOption) (types.Machine, error) { mc := types.DefaultMachineConfig() @@ -153,6 +187,13 @@ func New(opts ...types.MachineOption) (types.Machine, error) { return nil, err } + if mc.Engine == types.Proxmox { + if err := prepareProxmox(mc); err != nil { + return nil, fmt.Errorf("failure while preparing proxmox: %w", err) + } + return &Proxmox{machineConfig: *mc}, nil + } + if err := prepare(mc); err != nil { return nil, fmt.Errorf("failure while preparing: %w", err) } diff --git a/pkg/machine/proxmox.go b/pkg/machine/proxmox.go new file mode 100644 index 0000000..be73cc2 --- /dev/null +++ b/pkg/machine/proxmox.go @@ -0,0 +1,670 @@ +package machine + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + proxmoxapi "github.com/luthermonson/go-proxmox" + "github.com/spectrocloud/peg/pkg/controller" + "github.com/spectrocloud/peg/pkg/machine/types" +) + +const ( + proxmoxTaskTimeout = 300 // seconds + proxmoxAPITimeout = 5 * time.Minute + proxmoxCreateTimeout = 15 * time.Minute + proxmoxPingTimeout = 10 * time.Second + + proxmoxMonitorInterval = 3 * time.Second + proxmoxMonitorFailureLimit = 3 +) + +// Proxmox implements the Machine interface for Proxmox VE. +type Proxmox struct { + machineConfig types.MachineConfig + client *proxmoxapi.Client + node *proxmoxapi.Node + vm *proxmoxapi.VirtualMachine + vmid int + monitorCancel context.CancelFunc + monitorDone <-chan struct{} +} + +func (p *Proxmox) Config() types.MachineConfig { + return p.machineConfig +} + +func (p *Proxmox) Create(ctx context.Context) (resultCtx context.Context, resultErr error) { + log.Info("Create proxmox machine") + operationCtx, cancelOperation := context.WithTimeout(ctx, proxmoxCreateTimeout) + defer cancelOperation() + + cfg := p.machineConfig.Proxmox + if cfg == nil { + return ctx, fmt.Errorf("proxmox configuration is required") + } + + if err := p.validateConfig(cfg); err != nil { + return ctx, fmt.Errorf("invalid proxmox configuration: %w", err) + } + + // Create Proxmox API client + if err := p.initClient(cfg); err != nil { + return ctx, fmt.Errorf("failed to initialize proxmox client: %w", err) + } + + // Get the node + node, err := p.client.Node(operationCtx, cfg.Node) + if err != nil { + return ctx, fmt.Errorf("failed to get proxmox node %q: %w", cfg.Node, err) + } + p.node = node + + // Handle ISO transfer to Proxmox storage if needed + if err := p.prepareISO(operationCtx, cfg); err != nil { + return ctx, fmt.Errorf("failed to prepare ISO: %w", err) + } + + // Validate SDN infrastructure + if err := p.validateSDN(operationCtx, cfg); err != nil { + return ctx, fmt.Errorf("SDN validation failed: %w", err) + } + + // Get next available VMID + cluster, err := p.client.Cluster(operationCtx) + if err != nil { + return ctx, fmt.Errorf("failed to get cluster: %w", err) + } + + vmid, err := cluster.NextID(operationCtx) + if err != nil { + return ctx, fmt.Errorf("failed to get next VMID: %w", err) + } + p.vmid = vmid + + log.Infof("Creating Proxmox VM %d on node %s [ Memory: %s, CPU: %s ]", + vmid, cfg.Node, p.machineConfig.Memory, p.machineConfig.CPU) + + // Build VM creation options + vmOpts, err := p.buildVMOptions(cfg) + if err != nil { + return ctx, fmt.Errorf("invalid Proxmox VM configuration: %w", err) + } + + // Create the VM + task, err := node.NewVirtualMachine(operationCtx, vmid, vmOpts...) + if err != nil { + return ctx, fmt.Errorf("failed to create VM %d: %w", vmid, err) + } + created := true + defer func() { + if resultErr == nil || !created { + return + } + rollbackCtx, cancel := context.WithTimeout(context.Background(), proxmoxAPITimeout) + defer cancel() + if rollbackErr := p.rollbackVM(rollbackCtx); rollbackErr != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("rolling back VM %d: %w", vmid, rollbackErr)) + } + }() + + if err := task.WaitFor(operationCtx, proxmoxTaskTimeout); err != nil { + return ctx, fmt.Errorf("VM creation task failed: %w", err) + } + + log.Infof("VM %d created successfully", vmid) + + // Start the VM + vm, err := node.VirtualMachine(operationCtx, vmid) + if err != nil { + return ctx, fmt.Errorf("failed to get VM %d after creation: %w", vmid, err) + } + p.vm = vm + + startTask, err := vm.Start(operationCtx) + if err != nil { + return ctx, fmt.Errorf("failed to start VM %d: %w", vmid, err) + } + + if err := startTask.WaitFor(operationCtx, proxmoxTaskTimeout); err != nil { + return ctx, fmt.Errorf("VM start task failed: %w", err) + } + + log.Infof("VM %d started successfully", vmid) + created = false + + // Start monitoring goroutine + newCtx := p.monitorVM(ctx) + + return newCtx, nil +} + +func (p *Proxmox) Stop() error { + if p.vm == nil { + return fmt.Errorf("VM not initialized") + } + + p.stopMonitoring() + ctx, cancel := context.WithTimeout(context.Background(), proxmoxAPITimeout) + defer cancel() + return p.stopVM(ctx) +} + +func (p *Proxmox) stopVM(ctx context.Context) error { + + // Check if already stopped + if err := p.vm.Ping(ctx); err != nil { + return fmt.Errorf("failed to get VM status: %w", err) + } + + if p.vm.IsStopped() { + log.Info("VM is already stopped") + return nil + } + + task, err := p.vm.Stop(ctx) + if err != nil { + return fmt.Errorf("failed to stop VM %d: %w", p.vmid, err) + } + + if err := task.WaitFor(ctx, proxmoxTaskTimeout); err != nil { + return fmt.Errorf("VM stop task failed: %w", err) + } + + log.Infof("VM %d stopped", p.vmid) + return nil +} + +func (p *Proxmox) Clean() error { + if p.vm == nil { + if p.machineConfig.StateDir != "" { + return os.RemoveAll(p.machineConfig.StateDir) + } + return nil + } + + p.stopMonitoring() + ctx, cancel := context.WithTimeout(context.Background(), proxmoxAPITimeout) + defer cancel() + + // Ensure VM is stopped first + if err := p.vm.Ping(ctx); err == nil && !p.vm.IsStopped() { + if err := p.stopVM(ctx); err != nil { + log.Warnf("Failed to stop VM before cleanup: %v", err) + } + } + + // Delete the VM with purge to remove disks + task, err := p.vm.Delete(ctx) + if err != nil { + return fmt.Errorf("failed to delete VM %d: %w", p.vmid, err) + } + + if err := task.WaitFor(ctx, proxmoxTaskTimeout); err != nil { + return fmt.Errorf("VM delete task failed: %w", err) + } + + log.Infof("VM %d deleted", p.vmid) + p.vm = nil + + // Clean local state directory + if p.machineConfig.StateDir != "" { + return os.RemoveAll(p.machineConfig.StateDir) + } + + return nil +} + +func (p *Proxmox) Screenshot() (string, error) { + if p.vm == nil { + return "", fmt.Errorf("VM not initialized") + } + + return p.screenshotVNC() +} + +// screenshotVNC captures a screenshot via VNC WebSocket (API-only, no SSH). +func (p *Proxmox) screenshotVNC() (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + // Get VNC proxy ticket + vnc, err := p.vm.VNCProxy(ctx, &proxmoxapi.VNCConfig{Websocket: true}) + if err != nil { + return "", fmt.Errorf("VNCProxy failed: %w", err) + } + + // Connect WebSocket + send, recv, errs, closer, err := p.vm.VNCWebSocket(vnc) + if err != nil { + return "", fmt.Errorf("VNCWebSocket failed: %w", err) + } + defer closer() + + // Grab a single frame via the RFB protocol + reader := &wsChanReader{recv: recv, errs: errs, timeout: 10 * time.Second} + width, height, pixels, err := rfbGrabFrame(ctx, reader, send, vnc.Ticket) + if err != nil { + return "", fmt.Errorf("RFB frame capture failed: %w", err) + } + + // Convert to PPM and write to state dir + ppm, err := pixelsToPPM(width, height, pixels) + if err != nil { + return "", fmt.Errorf("converting VNC frame: %w", err) + } + localPath := filepath.Join(p.machineConfig.StateDir, fmt.Sprintf("screenshot-%d.ppm", p.vmid)) + if err := os.WriteFile(localPath, ppm, 0644); err != nil { + return "", fmt.Errorf("failed to write screenshot: %w", err) + } + + log.Infof("VNC screenshot captured: %s (%dx%d)", localPath, width, height) + return localPath, nil +} + +func (p *Proxmox) CreateDisk(diskname, size string) error { + if p.vm == nil { + return fmt.Errorf("VM not initialized") + } + + cfg := p.machineConfig.Proxmox + if cfg == nil { + return fmt.Errorf("proxmox configuration is required") + } + + ctx, cancel := context.WithTimeout(context.Background(), proxmoxAPITimeout) + defer cancel() + + sizeGB, err := diskSizeGiB(size) + if err != nil { + return err + } + + // Find the next available SCSI slot + scsiIdx, err := p.nextSCSIIndex(ctx) + if err != nil { + return fmt.Errorf("failed to find available SCSI slot: %w", err) + } + + diskSpec := fmt.Sprintf("%s:%d", cfg.Storage, sizeGB) + task, err := p.vm.Config(ctx, proxmoxapi.VirtualMachineOption{ + Name: fmt.Sprintf("scsi%d", scsiIdx), + Value: diskSpec, + }) + if err != nil { + return fmt.Errorf("failed to add disk: %w", err) + } + + if err := task.WaitFor(ctx, proxmoxTaskTimeout); err != nil { + return fmt.Errorf("disk creation task failed: %w", err) + } + + log.Infof("Added disk %q as scsi%d (%dGB) to VM %d", diskname, scsiIdx, sizeGB, p.vmid) + return nil +} + +func (p *Proxmox) Command(cmd string) (string, error) { + return controller.SSHCommand(p, cmd) +} + +func (p *Proxmox) DetachCD() error { + if p.vm == nil { + return fmt.Errorf("VM not initialized") + } + + ctx, cancel := context.WithTimeout(context.Background(), proxmoxAPITimeout) + defer cancel() + + // Remove the IDE2 CD-ROM by setting it to none + task, err := p.vm.Config(ctx, proxmoxapi.VirtualMachineOption{ + Name: "ide2", + Value: "none,media=cdrom", + }) + if err != nil { + return fmt.Errorf("failed to detach CD: %w", err) + } + + if err := task.WaitFor(ctx, proxmoxTaskTimeout); err != nil { + return fmt.Errorf("detach CD task failed: %w", err) + } + + log.Infof("Detached CD from VM %d", p.vmid) + return nil +} + +func (p *Proxmox) ReceiveFile(src, dst string) error { + return controller.ReceiveFile(p, src, dst) +} + +func (p *Proxmox) SendFile(src, dst, permissions string) error { + return controller.SendFile(p, src, dst, permissions) +} + +// --- Private helpers --- + +func (p *Proxmox) validateConfig(cfg *types.ProxmoxConfig) error { + if cfg.APIURL == "" { + return fmt.Errorf("proxmox apiURL is required") + } + if cfg.Node == "" { + return fmt.Errorf("proxmox node is required") + } + parsedURL, err := url.ParseRequestURI(cfg.APIURL) + if err != nil || parsedURL.Host == "" || (parsedURL.Scheme != "https" && parsedURL.Scheme != "http") { + return fmt.Errorf("proxmox apiURL must be an absolute HTTP(S) URL") + } + if (cfg.TokenID == "") != (cfg.TokenSecret == "") { + return fmt.Errorf("proxmox token authentication requires both tokenID and tokenSecret") + } + if (cfg.Username == "") != (cfg.Password == "") { + return fmt.Errorf("proxmox login authentication requires both username and password") + } + hasToken := cfg.TokenID != "" + hasLogin := cfg.Username != "" + if hasToken == hasLogin { + return fmt.Errorf("proxmox auth requires exactly one of tokenID+tokenSecret or username+password") + } + if len(p.machineConfig.Args) > 0 && !hasLogin { + return fmt.Errorf("custom Proxmox QEMU args require username/password authentication") + } + if cfg.Storage == "" { + return fmt.Errorf("proxmox storage is required") + } + return nil +} + +func (p *Proxmox) initClient(cfg *types.ProxmoxConfig) error { + var authOpt proxmoxapi.Option + if cfg.Username != "" && cfg.Password != "" { + authOpt = proxmoxapi.WithLogins(cfg.Username, cfg.Password) + } else { + authOpt = proxmoxapi.WithAPIToken(cfg.TokenID, cfg.TokenSecret) + } + + opts := []proxmoxapi.Option{authOpt} + + if cfg.InsecureTLS { + opts = append(opts, proxmoxapi.WithHTTPClient(&http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + })) + } + + p.client = proxmoxapi.NewClient(cfg.APIURL, opts...) + return nil +} + +// validateSDN checks that the expected SDN infrastructure exists. +// If the API token lacks SDN permissions, it logs a warning and continues +// rather than failing -- the user has already confirmed the SDN is configured. +func (p *Proxmox) validateSDN(ctx context.Context, cfg *types.ProxmoxConfig) error { + // A bridge without an SDN zone is a regular Proxmox/Linux bridge (for + // example vmbr0) and does not exist in the cluster SDN API. + if cfg.Bridge == "" || cfg.Zone == "" { + return nil + } + + cluster, err := p.client.Cluster(ctx) + if err != nil { + log.Warnf("Could not get cluster for SDN validation (will continue anyway): %v", err) + return nil + } + + // Validate zone exists. + zone, err := cluster.SDNZone(ctx, cfg.Zone) + if err != nil { + log.Warnf("Could not validate SDN zone %q (may lack SDN.Audit permission): %v", cfg.Zone, err) + } else { + log.Infof("SDN zone %q found (type: %s)", cfg.Zone, zone.Type) + } + + // Validate VNet exists + vnet, err := cluster.SDNVNet(ctx, cfg.Bridge) + if err != nil { + log.Warnf("Could not validate SDN VNet %q (may lack SDN.Audit permission): %v", cfg.Bridge, err) + return nil + } + log.Infof("SDN VNet %q found (zone: %s)", cfg.Bridge, vnet.Zone) + + // Validate VNet has a subnet with SNAT enabled + subnets, err := cluster.SDNSubnets(ctx, cfg.Bridge) + if err != nil { + log.Warnf("Could not validate subnets for VNet %q: %v", cfg.Bridge, err) + return nil + } + + snatFound := false + for _, subnet := range subnets { + if subnet.SNAT != 0 { + snatFound = true + log.Infof("SDN subnet %s with SNAT enabled (gateway: %s)", subnet.CIDR, subnet.Gateway) + break + } + } + + if !snatFound { + log.Warnf("No subnet with SNAT enabled found on VNet %q; SNAT is required for outbound connectivity", cfg.Bridge) + } + + return nil +} + +func (p *Proxmox) buildVMOptions(cfg *types.ProxmoxConfig) ([]proxmoxapi.VirtualMachineOption, error) { + // Default CPU type to "host" to pass through host CPU features. + // Many modern distros require x86-64-v2 or higher which kvm64 doesn't provide. + cpuType := "host" + if p.machineConfig.CPUType != "" { + cpuType = p.machineConfig.CPUType + } + + opts := []proxmoxapi.VirtualMachineOption{ + {Name: "name", Value: fmt.Sprintf("peg-%s", p.machineConfig.ID)}, + {Name: "memory", Value: p.machineConfig.Memory}, + {Name: "cores", Value: p.machineConfig.CPU}, + {Name: "cpu", Value: cpuType}, + {Name: "scsihw", Value: "virtio-scsi-pci"}, + } + + // Bridge NIC is optional — when using SLIRP-only networking (via Args), + // the bridge is omitted so the VM has a single NIC matching the cloud-config. + if cfg.Bridge != "" { + opts = append(opts, proxmoxapi.VirtualMachineOption{ + Name: "net0", Value: fmt.Sprintf("virtio,bridge=%s", cfg.Bridge), + }) + } + + // Boot disk from DriveSizes + driveSizes := p.machineConfig.DriveSizes + if len(driveSizes) == 0 { + driveSizes = []string{types.DefaultDriveSize} + } + if len(driveSizes) > 31 { + return nil, fmt.Errorf("Proxmox supports at most 31 SCSI disks, got %d", len(driveSizes)) + } + + // Primary boot disk + sizeGB, err := diskSizeGiB(driveSizes[0]) + if err != nil { + return nil, err + } + opts = append(opts, proxmoxapi.VirtualMachineOption{ + Name: "scsi0", + Value: fmt.Sprintf("%s:%d", cfg.Storage, sizeGB), + }) + + // Additional drives + for i := 1; i < len(driveSizes); i++ { + sizeGB, err := diskSizeGiB(driveSizes[i]) + if err != nil { + return nil, err + } + opts = append(opts, proxmoxapi.VirtualMachineOption{ + Name: fmt.Sprintf("scsi%d", i), + Value: fmt.Sprintf("%s:%d", cfg.Storage, sizeGB), + }) + } + + // ISO (pre-staged on Proxmox storage) + if p.machineConfig.ISO != "" { + opts = append(opts, proxmoxapi.VirtualMachineOption{ + Name: "ide2", + Value: fmt.Sprintf("%s,media=cdrom", p.machineConfig.ISO), + }) + } + + // Boot order: disk first, then CD + bootOrder := "order=scsi0" + if p.machineConfig.ISO != "" { + bootOrder += ";ide2" + } + opts = append(opts, proxmoxapi.VirtualMachineOption{ + Name: "boot", + Value: bootOrder, + }) + + // Pass custom QEMU args (e.g., SLIRP NAT for port forwarding) + if len(p.machineConfig.Args) > 0 { + opts = append(opts, proxmoxapi.VirtualMachineOption{ + Name: "args", + Value: strings.Join(p.machineConfig.Args, " "), + }) + } + + return opts, nil +} + +func diskSizeGiB(size string) (int, error) { + normalized := strings.TrimSpace(strings.TrimSuffix(strings.ToUpper(size), "M")) + sizeMB, err := strconv.Atoi(normalized) + if err != nil || sizeMB <= 0 { + return 0, fmt.Errorf("invalid disk size %q: expected a positive number of MB", size) + } + return (sizeMB + 1023) / 1024, nil +} + +// monitorVM starts a goroutine that polls VM status and cancels the context +// when the VM unexpectedly stops. +func (p *Proxmox) monitorVM(ctx context.Context) context.Context { + return p.monitorVMWithInterval(ctx, proxmoxMonitorInterval) +} + +func (p *Proxmox) monitorVMWithInterval(ctx context.Context, interval time.Duration) context.Context { + monitorCtx, cancelFunc := context.WithCancel(ctx) + done := make(chan struct{}) + p.monitorCancel = cancelFunc + p.monitorDone = done + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + defer cancelFunc() + defer close(done) + failures := 0 + for { + select { + case <-monitorCtx.Done(): + return + case <-ticker.C: + if p.vm == nil { + return + } + pingCtx, cancel := context.WithTimeout(monitorCtx, proxmoxPingTimeout) + err := p.vm.Ping(pingCtx) + cancel() + if err != nil { + if monitorCtx.Err() != nil { + return + } + failures++ + log.Warnf("Failed to ping VM %d: %v", p.vmid, err) + if failures >= proxmoxMonitorFailureLimit { + return + } + continue + } + failures = 0 + if !p.vm.IsRunning() { + log.Warnf("VM %d is no longer running (status: %s)", p.vmid, p.vm.Status) + return + } + } + } + }() + return monitorCtx +} + +func (p *Proxmox) stopMonitoring() { + if p.monitorCancel != nil { + p.monitorCancel() + } + if p.monitorDone != nil { + <-p.monitorDone + } + p.monitorCancel = nil + p.monitorDone = nil +} + +// nextSCSIIndex finds the next available SCSI device index by checking current config. +func (p *Proxmox) nextSCSIIndex(ctx context.Context) (int, error) { + // Refresh VM config + vm, err := p.node.VirtualMachine(ctx, p.vmid) + if err != nil { + return 0, err + } + p.vm = vm + + if vm.VirtualMachineConfig == nil { + return 0, fmt.Errorf("VM %d returned no configuration", p.vmid) + } + return nextFreeSCSIIndex(vm.VirtualMachineConfig) +} + +func nextFreeSCSIIndex(config *proxmoxapi.VirtualMachineConfig) (int, error) { + if config == nil { + return 0, fmt.Errorf("virtual machine configuration is required") + } + used := config.MergeSCSIs() + for i := 0; i <= 30; i++ { + if _, exists := used[fmt.Sprintf("scsi%d", i)]; !exists { + return i, nil + } + } + return 0, fmt.Errorf("virtual machine has no free SCSI slots") +} + +func proxmoxAPIEndpoint(apiURL string) (string, error) { + parsed, err := url.Parse(apiURL) + if err != nil || parsed.Hostname() == "" { + return "", fmt.Errorf("invalid Proxmox API URL %q", apiURL) + } + port := parsed.Port() + if port == "" { + port = "8006" + } + return net.JoinHostPort(parsed.Hostname(), port), nil +} + +func (p *Proxmox) rollbackVM(ctx context.Context) error { + var upid proxmoxapi.UPID + path := fmt.Sprintf("/nodes/%s/qemu/%d", p.machineConfig.Proxmox.Node, p.vmid) + if err := p.client.Delete(ctx, path, &upid); err != nil { + return err + } + task := proxmoxapi.NewTask(upid, p.client) + if task == nil { + return nil + } + return task.WaitFor(ctx, proxmoxTaskTimeout) +} diff --git a/pkg/machine/proxmox_internal_test.go b/pkg/machine/proxmox_internal_test.go new file mode 100644 index 0000000..83f216d --- /dev/null +++ b/pkg/machine/proxmox_internal_test.go @@ -0,0 +1,635 @@ +package machine + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + proxmoxapi "github.com/luthermonson/go-proxmox" + process "github.com/mudler/go-processmanager" + "github.com/spectrocloud/peg/pkg/machine/types" +) + +func validProxmoxConfig() *types.ProxmoxConfig { + return &types.ProxmoxConfig{ + APIURL: "https://pve.example.test:8006/api2/json", + Node: "pve", + TokenID: "peg@pam!test", + TokenSecret: "secret", + Storage: "local-lvm", + } +} + +func TestValidateProxmoxConfig(t *testing.T) { + tests := []struct { + name string + mutate func(*Proxmox, *types.ProxmoxConfig) + wantErr string + }{ + {name: "token authentication"}, + {name: "login authentication", mutate: func(_ *Proxmox, cfg *types.ProxmoxConfig) { + cfg.TokenID, cfg.TokenSecret = "", "" + cfg.Username, cfg.Password = "root@pam", "password" + }}, + {name: "partial token", mutate: func(_ *Proxmox, cfg *types.ProxmoxConfig) { cfg.TokenSecret = "" }, wantErr: "requires both"}, + {name: "both authentication methods", mutate: func(_ *Proxmox, cfg *types.ProxmoxConfig) { + cfg.Username, cfg.Password = "root@pam", "password" + }, wantErr: "exactly one"}, + {name: "invalid URL", mutate: func(_ *Proxmox, cfg *types.ProxmoxConfig) { cfg.APIURL = "pve.local" }, wantErr: "absolute HTTP(S)"}, + {name: "args require login", mutate: func(p *Proxmox, _ *types.ProxmoxConfig) { p.machineConfig.Args = []string{"-netdev", "user"} }, wantErr: "require username/password"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := validProxmoxConfig() + p := &Proxmox{} + if tt.mutate != nil { + tt.mutate(p, cfg) + } + err := p.validateConfig(cfg) + if tt.wantErr == "" && err != nil { + t.Fatalf("validateConfig() error = %v", err) + } + if tt.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tt.wantErr)) { + t.Fatalf("validateConfig() error = %v, want substring %q", err, tt.wantErr) + } + }) + } +} + +func TestValidateSDNSkipsRegularBridge(t *testing.T) { + p := &Proxmox{} + if err := p.validateSDN(context.Background(), &types.ProxmoxConfig{Bridge: "vmbr0"}); err != nil { + t.Fatal(err) + } +} + +func TestPrepareProxmoxCreatesStateAndSSHDefaults(t *testing.T) { + stateDir := filepath.Join(t.TempDir(), "nested", "state") + cfg := &types.MachineConfig{StateDir: stateDir} + if err := prepareProxmox(cfg); err != nil { + t.Fatal(err) + } + if cfg.ID == "" || cfg.SSH == nil || cfg.SSH.Port != "22" { + t.Fatalf("unexpected prepared config: %#v", cfg) + } + if info, err := os.Stat(stateDir); err != nil || !info.IsDir() { + t.Fatalf("state directory was not created: %v", err) + } +} + +func TestDiskSizeGiB(t *testing.T) { + tests := map[string]int{"1": 1, "1024": 1, "1025": 2, "30000M": 30} + for input, want := range tests { + got, err := diskSizeGiB(input) + if err != nil || got != want { + t.Fatalf("diskSizeGiB(%q) = %d, %v; want %d", input, got, err, want) + } + } + for _, input := range []string{"", "0", "-1", "1G", "garbage"} { + if _, err := diskSizeGiB(input); err == nil { + t.Fatalf("diskSizeGiB(%q) unexpectedly succeeded", input) + } + } +} + +func TestNextFreeSCSIIndex(t *testing.T) { + config := &proxmoxapi.VirtualMachineConfig{ + SCSI0: "local-lvm:vm-100-disk-0", + SCSI2: "local-lvm:vm-100-disk-2", + } + got, err := nextFreeSCSIIndex(config) + if err != nil || got != 1 { + t.Fatalf("nextFreeSCSIIndex() = %d, %v; want 1", got, err) + } + + full := &proxmoxapi.VirtualMachineConfig{SCSIs: make(map[string]string)} + for i := 0; i <= 30; i++ { + full.SCSIs[fmt.Sprintf("scsi%d", i)] = "in-use" + } + if _, err := nextFreeSCSIIndex(full); err == nil { + t.Fatal("nextFreeSCSIIndex() unexpectedly found a slot in a full configuration") + } + if _, err := nextFreeSCSIIndex(nil); err == nil { + t.Fatal("nextFreeSCSIIndex(nil) unexpectedly succeeded") + } +} + +func TestBuildProxmoxVMOptions(t *testing.T) { + cfg := validProxmoxConfig() + cfg.Bridge = "vnet1" + p := &Proxmox{machineConfig: types.MachineConfig{ + ID: "test", Memory: "2048", CPU: "2", CPUType: "x86-64-v2", + DriveSizes: []string{"30000", "1025"}, ISO: "local:iso/test.iso", + }} + opts, err := p.buildVMOptions(cfg) + if err != nil { + t.Fatal(err) + } + got := make(map[string]interface{}, len(opts)) + for _, option := range opts { + got[option.Name] = option.Value + } + for name, want := range map[string]interface{}{ + "name": "peg-test", "cpu": "x86-64-v2", "net0": "virtio,bridge=vnet1", + "scsi0": "local-lvm:30", "scsi1": "local-lvm:2", + "ide2": "local:iso/test.iso,media=cdrom", "boot": "order=scsi0;ide2", + } { + if got[name] != want { + t.Errorf("option %s = %#v, want %#v", name, got[name], want) + } + } + p.machineConfig.DriveSizes = make([]string, 32) + for i := range p.machineConfig.DriveSizes { + p.machineConfig.DriveSizes[i] = "1024" + } + if _, err := p.buildVMOptions(cfg); err == nil { + t.Fatal("buildVMOptions accepted more than 31 SCSI disks") + } +} + +func TestProxmoxLoginAuthentication(t *testing.T) { + var ticketCalls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + path := strings.TrimPrefix(r.URL.Path, "/api2/json") + switch { + case r.Method == http.MethodPost && path == "/access/ticket": + ticketCalls.Add(1) + fmt.Fprint(w, `{"data":{"username":"root@pam","ticket":"test-ticket","CSRFPreventionToken":"csrf"}}`) + case r.Method == http.MethodGet && path == "/nodes/pve/status": + if cookie, err := r.Cookie("PVEAuthCookie"); err != nil || cookie.Value != "test-ticket" { + http.Error(w, "authentication required", http.StatusUnauthorized) + return + } + fmt.Fprint(w, `{"data":{}}`) + default: + http.Error(w, "unexpected request", http.StatusInternalServerError) + } + })) + defer server.Close() + + p := &Proxmox{} + cfg := validProxmoxConfig() + cfg.APIURL = server.URL + "/api2/json" + cfg.TokenID, cfg.TokenSecret = "", "" + cfg.Username, cfg.Password = "root@pam", "password" + if err := p.initClient(cfg); err != nil { + t.Fatal(err) + } + if _, err := p.client.Node(context.Background(), "pve"); err != nil { + t.Fatal(err) + } + if ticketCalls.Load() != 1 { + t.Fatalf("ticket requests = %d, want 1", ticketCalls.Load()) + } +} + +func TestProxmoxAPIEndpoint(t *testing.T) { + for input, want := range map[string]string{ + "https://pve.example/api2/json": "pve.example:8006", + "https://pve.example:9443/api2/json": "pve.example:9443", + "https://[2001:db8::1]/api2/json": "[2001:db8::1]:8006", + "https://[2001:db8::1]:9443/api2/json": "[2001:db8::1]:9443", + } { + got, err := proxmoxAPIEndpoint(input) + if err != nil || got != want { + t.Errorf("proxmoxAPIEndpoint(%q) = %q, %v; want %q", input, got, err, want) + } + } +} + +func TestServeISO(t *testing.T) { + isoPath := filepath.Join(t.TempDir(), "image with spaces.iso") + want := []byte("iso contents") + if err := os.WriteFile(isoPath, want, 0o600); err != nil { + t.Fatal(err) + } + serveURL, shutdown, err := serveISO(isoPath, "127.0.0.1") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = shutdown() }) + + response, err := http.Get(serveURL) + if err != nil { + t.Fatal(err) + } + got, err := io.ReadAll(response.Body) + _ = response.Body.Close() + if err != nil || !bytes.Equal(got, want) { + t.Fatalf("GET ISO = %q, %v; want %q", got, err, want) + } + + request, _ := http.NewRequest(http.MethodPost, serveURL, nil) + response, err = http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + _ = response.Body.Close() + if response.StatusCode != http.StatusMethodNotAllowed { + t.Fatalf("POST status = %d, want %d", response.StatusCode, http.StatusMethodNotAllowed) + } +} + +func TestProxmoxISOModesAndCollisions(t *testing.T) { + if !isProxmoxStorageRef("local:iso/image.iso") || isProxmoxStorageRef("https://example.test/image.iso") { + t.Fatal("storage-reference ISO detection returned an unexpected result") + } + if alg, hash, err := parseChecksum("sha512:abc123"); err != nil || alg != "sha512" || hash != "abc123" { + t.Fatalf("parseChecksum() = %q, %q, %v", alg, hash, err) + } + if _, _, err := parseChecksum("crc32:abc123"); err == nil { + t.Fatal("parseChecksum accepted an unsupported algorithm") + } + + contents := []*proxmoxapi.StorageContent{ + {Volid: "other:iso/peg-owned.iso", Size: 10}, + {Volid: "local:iso/unrelated.iso", Size: 10}, + } + exists, err := pegISOExists(contents, "local", "peg-owned.iso", 10) + if err != nil || exists { + t.Fatalf("unrelated collision = %v, %v; want false, nil", exists, err) + } + + contents = append(contents, &proxmoxapi.StorageContent{Volid: "local:iso/peg-owned.iso", Size: 10}) + exists, err = pegISOExists(contents, "local", "peg-owned.iso", 10) + if err != nil || !exists { + t.Fatalf("owned ISO match = %v, %v; want true, nil", exists, err) + } + if _, err := pegISOExists(contents, "local", "peg-owned.iso", 11); err == nil || !strings.Contains(err.Error(), "refusing to delete") { + t.Fatalf("unsafe collision error = %v", err) + } + + if got := sanitizeProxmoxName("../../my image.iso"); strings.ContainsAny(got, "/ ") { + t.Fatalf("sanitizeProxmoxName() = %q, want a path-safe name", got) + } + firstName := proxmoxURLISOName("https://example.test/one/image.iso", "/one/image.iso") + secondName := proxmoxURLISOName("https://example.test/two/image.iso", "/two/image.iso") + if firstName == secondName || !strings.HasPrefix(firstName, "peg-") { + t.Fatalf("URL ISO names are not Peg-owned and URL-addressed: %q, %q", firstName, secondName) + } +} + +func TestDownloadURLToProxmox(t *testing.T) { + previousInterval := proxmoxapi.DefaultWaitInterval + proxmoxapi.DefaultWaitInterval = time.Millisecond + t.Cleanup(func() { proxmoxapi.DefaultWaitInterval = previousInterval }) + + const isoURL = "https://images.example.test/releases/image.iso" + var requestedFilename string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + path := strings.TrimPrefix(r.URL.Path, "/api2/json") + switch { + case r.Method == http.MethodGet && path == "/nodes/pve/status": + fmt.Fprint(w, `{"data":{}}`) + case r.Method == http.MethodPost && path == "/nodes/pve/storage/local/download-url": + var options proxmoxapi.StorageDownloadURLOptions + if err := json.NewDecoder(r.Body).Decode(&options); err != nil { + t.Errorf("decode download options: %v", err) + http.Error(w, "bad request", http.StatusBadRequest) + return + } + requestedFilename = options.Filename + if options.URL != isoURL || options.Content != "iso" || options.ChecksumAlgorithm != "sha256" { + t.Errorf("unexpected download options: %#v", options) + } + fmt.Fprint(w, `{"data":"UPID:pve:1:1:1:download:iso:root@pam:"}`) + case r.Method == http.MethodGet && strings.Contains(path, "/tasks/UPID:pve:1:1:1:download:iso:root@pam:/status"): + fmt.Fprint(w, taskStatusJSON("UPID:pve:1:1:1:download:iso:root@pam:", "download")) + default: + http.Error(w, "unexpected request: "+r.Method+" "+path, http.StatusInternalServerError) + } + })) + defer server.Close() + + cfg := validProxmoxConfig() + cfg.APIURL = server.URL + "/api2/json" + p := &Proxmox{machineConfig: types.MachineConfig{ID: "iso", ISOChecksum: "sha256:abc123"}} + if err := p.initClient(cfg); err != nil { + t.Fatal(err) + } + node, err := p.client.Node(context.Background(), "pve") + if err != nil { + t.Fatal(err) + } + p.node = node + if err := p.downloadURLToProxmox(context.Background(), cfg, isoURL, "local"); err != nil { + t.Fatal(err) + } + if requestedFilename == "" || p.machineConfig.ISO != "local:iso/"+requestedFilename { + t.Fatalf("downloaded ISO = %q, filename = %q", p.machineConfig.ISO, requestedFilename) + } +} + +func TestRFBGrabFrame(t *testing.T) { + stream := &bytes.Buffer{} + stream.WriteString("RFB 003.008\n") + stream.Write([]byte{1, rfbSecurityNone}) + _ = binary.Write(stream, binary.BigEndian, uint32(0)) + _ = binary.Write(stream, binary.BigEndian, uint16(1)) + _ = binary.Write(stream, binary.BigEndian, uint16(1)) + stream.Write(make([]byte, 16)) + _ = binary.Write(stream, binary.BigEndian, uint32(0)) + stream.Write([]byte{rfbMsgFramebufferUpdate, 0}) + _ = binary.Write(stream, binary.BigEndian, uint16(1)) + for _, value := range []uint16{0, 0, 1, 1} { + _ = binary.Write(stream, binary.BigEndian, value) + } + _ = binary.Write(stream, binary.BigEndian, int32(rfbEncodingRaw)) + stream.Write([]byte{1, 2, 3, 0}) + + send := make(chan []byte, 8) + width, height, pixels, err := rfbGrabFrame(context.Background(), stream, send, "") + if err != nil { + t.Fatal(err) + } + if width != 1 || height != 1 || !bytes.Equal(pixels, []byte{1, 2, 3, 0}) { + t.Fatalf("frame = %dx%d %v", width, height, pixels) + } + if len(send) != 6 { + t.Fatalf("sent %d RFB messages, want 6", len(send)) + } +} + +func TestRFBValidation(t *testing.T) { + if _, err := pixelsToPPM(1, 1, []byte{1, 2, 3}); err == nil { + t.Fatal("pixelsToPPM accepted a short pixel buffer") + } + if _, err := vncAuthEncrypt(make([]byte, 15), "password"); err == nil { + t.Fatal("vncAuthEncrypt accepted a short challenge") + } + response, err := vncAuthEncrypt([]byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}, "password") + if err != nil { + t.Fatal(err) + } + wantResponse := []byte{0xb8, 0x66, 0x92, 0x41, 0x25, 0xc8, 0xee, 0xbb, 0x9d, 0xeb, 0xc1, 0xdb, 0x61, 0xc5, 0x38, 0xe2} + if !bytes.Equal(response, wantResponse) { + t.Fatalf("VNC authentication response = %x, want %x", response, wantResponse) + } + malformed := &bytes.Buffer{} + malformed.WriteString("RFB 003.008\n") + malformed.WriteByte(0) + _ = binary.Write(malformed, binary.BigEndian, uint32(maxRFBNameLength+1)) + if _, _, _, err := rfbGrabFrame(context.Background(), malformed, make(chan []byte, 1), ""); err == nil || !strings.Contains(err.Error(), "too large") { + t.Fatalf("malformed RFB error = %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + if err := sendRFB(ctx, make(chan []byte), []byte("blocked")); err == nil { + t.Fatal("sendRFB unexpectedly succeeded") + } +} + +func TestMonitorVMWithoutInitializedVM(t *testing.T) { + var callbackCalls atomic.Int32 + p := &Proxmox{machineConfig: types.MachineConfig{ + OnFailure: func(_ *process.Process) { callbackCalls.Add(1) }, + }} + monitoredCtx := p.monitorVMWithInterval(context.Background(), time.Millisecond) + select { + case <-monitoredCtx.Done(): + case <-time.After(time.Second): + t.Fatal("monitor context was not canceled") + } + if callbackCalls.Load() != 0 { + t.Fatalf("failure callback calls = %d, want 0", callbackCalls.Load()) + } +} + +func TestStopMonitoringStopsPollingGoroutine(t *testing.T) { + p := &Proxmox{} + monitoredCtx := p.monitorVMWithInterval(context.Background(), time.Hour) + stopped := make(chan struct{}) + go func() { + p.stopMonitoring() + close(stopped) + }() + select { + case <-stopped: + case <-time.After(time.Second): + t.Fatal("stopMonitoring did not synchronize with the polling goroutine") + } + if err := monitoredCtx.Err(); err != context.Canceled { + t.Fatalf("monitor context error = %v, want context.Canceled", err) + } +} + +func TestMonitorVMToleratesTransientPingFailures(t *testing.T) { + var statusCalls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + path := strings.TrimPrefix(r.URL.Path, "/api2/json") + switch { + case r.Method == http.MethodGet && path == "/nodes/pve/status": + fmt.Fprint(w, `{"data":{}}`) + case r.Method == http.MethodGet && path == "/nodes/pve/qemu/100/status/current": + call := statusCalls.Add(1) + if call == 2 || call == 3 { + http.Error(w, "temporary failure", http.StatusInternalServerError) + return + } + fmt.Fprint(w, `{"data":{"Node":"pve","VMID":100,"Status":"running"}}`) + case r.Method == http.MethodGet && path == "/nodes/pve/qemu/100/config": + fmt.Fprint(w, `{"data":{}}`) + default: + http.Error(w, "unexpected request: "+r.Method+" "+path, http.StatusInternalServerError) + } + })) + defer server.Close() + + cfg := validProxmoxConfig() + cfg.APIURL = server.URL + "/api2/json" + p := &Proxmox{vmid: 100} + if err := p.initClient(cfg); err != nil { + t.Fatal(err) + } + node, err := p.client.Node(context.Background(), "pve") + if err != nil { + t.Fatal(err) + } + p.node = node + p.vm, err = node.VirtualMachine(context.Background(), 100) + if err != nil { + t.Fatal(err) + } + + parentCtx, cancel := context.WithCancel(context.Background()) + monitoredCtx := p.monitorVMWithInterval(parentCtx, time.Millisecond) + deadline := time.Now().Add(time.Second) + for statusCalls.Load() < 4 && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if statusCalls.Load() < 4 { + t.Fatalf("monitor made only %d status calls", statusCalls.Load()) + } + if err := monitoredCtx.Err(); err != nil { + t.Fatalf("monitor canceled after transient failures: %v", err) + } + cancel() + select { + case <-monitoredCtx.Done(): + case <-time.After(time.Second): + t.Fatal("monitor did not stop after parent cancellation") + } +} + +func TestProxmoxLifecycle(t *testing.T) { + previousInterval := proxmoxapi.DefaultWaitInterval + proxmoxapi.DefaultWaitInterval = time.Millisecond + t.Cleanup(func() { proxmoxapi.DefaultWaitInterval = previousInterval }) + + var status atomic.Int32 + var startCalls, stopCalls, deleteCalls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "PVEAPIToken=peg@pam!test=secret" { + t.Errorf("Authorization header = %q", got) + } + w.Header().Set("Content-Type", "application/json") + path := strings.TrimPrefix(r.URL.Path, "/api2/json") + switch { + case r.Method == http.MethodGet && path == "/nodes/pve/status": + fmt.Fprint(w, `{"data":{}}`) + case r.Method == http.MethodGet && path == "/cluster/status": + fmt.Fprint(w, `{"data":[]}`) + case r.Method == http.MethodGet && path == "/cluster/nextid": + fmt.Fprint(w, `{"data":"100"}`) + case r.Method == http.MethodPost && path == "/nodes/pve/qemu": + fmt.Fprint(w, `{"data":"UPID:pve:1:1:1:qmcreate:100:root@pam:"}`) + case r.Method == http.MethodGet && strings.Contains(path, "/tasks/UPID:pve:1:1:1:qmcreate:100:root@pam:/status"): + fmt.Fprint(w, taskStatusJSON("UPID:pve:1:1:1:qmcreate:100:root@pam:", "qmcreate")) + case r.Method == http.MethodGet && path == "/nodes/pve/qemu/100/status/current": + vmStatus := "stopped" + if status.Load() == 1 { + vmStatus = "running" + } + fmt.Fprintf(w, `{"data":{"Node":"pve","VMID":100,"Status":%q}}`, vmStatus) + case r.Method == http.MethodGet && path == "/nodes/pve/qemu/100/config": + fmt.Fprint(w, `{"data":{"scsi0":"local-lvm:vm-100-disk-0,size=1G"}}`) + case r.Method == http.MethodPost && path == "/nodes/pve/qemu/100/status/start": + startCalls.Add(1) + status.Store(1) + fmt.Fprint(w, `{"data":"UPID:pve:1:1:1:qmstart:100:root@pam:"}`) + case r.Method == http.MethodGet && strings.Contains(path, "/tasks/UPID:pve:1:1:1:qmstart:100:root@pam:/status"): + fmt.Fprint(w, taskStatusJSON("UPID:pve:1:1:1:qmstart:100:root@pam:", "qmstart")) + case r.Method == http.MethodPost && path == "/nodes/pve/qemu/100/status/stop": + stopCalls.Add(1) + status.Store(0) + fmt.Fprint(w, `{"data":"UPID:pve:1:1:1:qmstop:100:root@pam:"}`) + case r.Method == http.MethodGet && strings.Contains(path, "/tasks/UPID:pve:1:1:1:qmstop:100:root@pam:/status"): + fmt.Fprint(w, taskStatusJSON("UPID:pve:1:1:1:qmstop:100:root@pam:", "qmstop")) + case r.Method == http.MethodDelete && path == "/nodes/pve/qemu/100": + deleteCalls.Add(1) + fmt.Fprint(w, `{"data":"UPID:pve:1:1:1:qmdestroy:100:root@pam:"}`) + case r.Method == http.MethodGet && strings.Contains(path, "/tasks/UPID:pve:1:1:1:qmdestroy:100:root@pam:/status"): + fmt.Fprint(w, taskStatusJSON("UPID:pve:1:1:1:qmdestroy:100:root@pam:", "qmdestroy")) + default: + http.Error(w, "unexpected request: "+r.Method+" "+path, http.StatusInternalServerError) + } + })) + defer server.Close() + + stateDir := filepath.Join(t.TempDir(), "state") + if err := os.MkdirAll(stateDir, 0o700); err != nil { + t.Fatal(err) + } + cfg := validProxmoxConfig() + cfg.APIURL = server.URL + "/api2/json" + p := &Proxmox{machineConfig: types.MachineConfig{ + ID: "lifecycle", CPU: "2", Memory: "2048", DriveSizes: []string{"1024"}, + SSH: &types.SSH{}, Proxmox: cfg, StateDir: stateDir, + }} + monitorCtx, err := p.Create(context.Background()) + if err != nil { + t.Fatal(err) + } + if err := p.Stop(); err != nil { + t.Fatal(err) + } + select { + case <-monitorCtx.Done(): + case <-time.After(time.Second): + t.Fatal("Stop did not cancel the monitor context") + } + if err := p.Clean(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(stateDir); !os.IsNotExist(err) { + t.Fatalf("state directory still exists: %v", err) + } + if startCalls.Load() != 1 || stopCalls.Load() != 1 || deleteCalls.Load() != 1 { + t.Fatalf("lifecycle calls: start=%d stop=%d delete=%d; want 1 each", startCalls.Load(), stopCalls.Load(), deleteCalls.Load()) + } +} + +func TestCreateRollsBackAfterStartFailure(t *testing.T) { + previousInterval := proxmoxapi.DefaultWaitInterval + proxmoxapi.DefaultWaitInterval = time.Millisecond + t.Cleanup(func() { proxmoxapi.DefaultWaitInterval = previousInterval }) + + var deleteCalls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + path := strings.TrimPrefix(r.URL.Path, "/api2/json") + t.Logf("mock Proxmox request: %s %s", r.Method, path) + switch { + case r.Method == http.MethodGet && path == "/nodes/pve/status": + fmt.Fprint(w, `{"data":{}}`) + case r.Method == http.MethodGet && path == "/cluster/status": + fmt.Fprint(w, `{"data":[]}`) + case r.Method == http.MethodGet && path == "/cluster/nextid": + fmt.Fprint(w, `{"data":"100"}`) + case r.Method == http.MethodPost && path == "/nodes/pve/qemu": + fmt.Fprint(w, `{"data":"UPID:pve:1:1:1:qmcreate:100:root@pam:"}`) + case r.Method == http.MethodGet && strings.Contains(path, "/tasks/UPID:pve:1:1:1:qmcreate:100:root@pam:/status"): + fmt.Fprint(w, taskStatusJSON("UPID:pve:1:1:1:qmcreate:100:root@pam:", "qmcreate")) + case r.Method == http.MethodGet && path == "/nodes/pve/qemu/100/status/current": + fmt.Fprint(w, `{"data":{"status":"stopped","vmid":100}}`) + case r.Method == http.MethodGet && path == "/nodes/pve/qemu/100/config": + fmt.Fprint(w, `{"data":{"scsi0":"local-lvm:vm-100-disk-0,size=30G"}}`) + case r.Method == http.MethodPost && path == "/nodes/pve/qemu/100/status/start": + http.Error(w, "start failed", http.StatusInternalServerError) + case r.Method == http.MethodDelete && path == "/nodes/pve/qemu/100": + deleteCalls.Add(1) + fmt.Fprint(w, `{"data":"UPID:pve:1:1:1:qmdestroy:100:root@pam:"}`) + case r.Method == http.MethodGet && strings.Contains(path, "/tasks/UPID:pve:1:1:1:qmdestroy:100:root@pam:/status"): + fmt.Fprint(w, taskStatusJSON("UPID:pve:1:1:1:qmdestroy:100:root@pam:", "qmdestroy")) + default: + http.Error(w, "unexpected request: "+r.Method+" "+path, http.StatusInternalServerError) + } + })) + defer server.Close() + + cfg := validProxmoxConfig() + cfg.APIURL = server.URL + "/api2/json" + p := &Proxmox{machineConfig: types.MachineConfig{ + ID: "rollback", CPU: "2", Memory: "2048", SSH: &types.SSH{}, Proxmox: cfg, + }} + _, err := p.Create(context.Background()) + if err == nil || !strings.Contains(err.Error(), "failed to start") { + t.Fatalf("Create() error = %v, want start failure", err) + } + if deleteCalls.Load() != 1 { + t.Fatalf("rollback DELETE calls = %d, want 1", deleteCalls.Load()) + } +} + +func taskStatusJSON(upid, taskType string) string { + return fmt.Sprintf( + `{"data":{"UPID":%q,"Node":"pve","Type":%q,"ID":"100","User":"root@pam","Status":"stopped","ExitStatus":"OK"}}`, + upid, taskType, + ) +} diff --git a/pkg/machine/proxmox_iso.go b/pkg/machine/proxmox_iso.go new file mode 100644 index 0000000..cc1ef66 --- /dev/null +++ b/pkg/machine/proxmox_iso.go @@ -0,0 +1,306 @@ +package machine + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/codingsince1985/checksum" + proxmoxapi "github.com/luthermonson/go-proxmox" + "github.com/spectrocloud/peg/pkg/machine/internal/utils" + "github.com/spectrocloud/peg/pkg/machine/types" +) + +// --- ISO transfer helpers --- + +const ( + // Timeout for ISO download tasks (generous for multi-GB ISOs) + isoTransferTimeout = 600 // seconds +) + +// isProxmoxStorageRef returns true if the ISO string is a Proxmox storage +// reference like "local:iso/foo.iso" or "ceph:iso/bar.iso". +func isProxmoxStorageRef(iso string) bool { + parts := strings.SplitN(iso, ":", 2) + if len(parts) != 2 { + return false + } + return strings.HasPrefix(parts[1], "iso/") +} + +// prepareISO handles ISO provisioning for the Proxmox backend. +// It detects the ISO source type and ensures the ISO is available on Proxmox storage. +func (p *Proxmox) prepareISO(ctx context.Context, cfg *types.ProxmoxConfig) error { + iso := p.machineConfig.ISO + if iso == "" { + return nil + } + + // Already a Proxmox storage reference — use as-is + if isProxmoxStorageRef(iso) { + log.Infof("ISO is a Proxmox storage reference, using as-is: %s", iso) + return nil + } + + isoStorage := cfg.ISOStorage + if isoStorage == "" { + isoStorage = "local" + } + + // HTTP/HTTPS URL — tell Proxmox to download directly + if utils.IsValidURL(iso) { + log.Infof("ISO is a URL, directing Proxmox to download: %s", iso) + return p.downloadURLToProxmox(ctx, cfg, iso, isoStorage) + } + + // Local file path — serve via temp HTTP server and use StorageDownloadURL + log.Infof("ISO is a local file, transferring to Proxmox: %s", iso) + return p.transferLocalISO(ctx, cfg, iso, isoStorage) +} + +// downloadURLToProxmox tells Proxmox to download an ISO directly from a URL. +func (p *Proxmox) downloadURLToProxmox(ctx context.Context, cfg *types.ProxmoxConfig, isoURL, isoStorage string) error { + parsedURL, err := url.Parse(isoURL) + if err != nil { + return fmt.Errorf("invalid ISO URL %q: %w", isoURL, err) + } + filename := proxmoxURLISOName(isoURL, parsedURL.Path) + + opts := &proxmoxapi.StorageDownloadURLOptions{ + Content: "iso", + Filename: filename, + Storage: isoStorage, + URL: isoURL, + Node: cfg.Node, + } + + // Use checksum if provided + if p.machineConfig.ISOChecksum != "" { + alg, hash, err := parseChecksum(p.machineConfig.ISOChecksum) + if err != nil { + return err + } + opts.Checksum = hash + opts.ChecksumAlgorithm = alg + } + + upid, err := p.node.StorageDownloadURL(ctx, opts) + if err != nil { + return fmt.Errorf("StorageDownloadURL failed: %w", err) + } + + task := proxmoxapi.NewTask(proxmoxapi.UPID(upid), p.client) + if err := task.WaitFor(ctx, isoTransferTimeout); err != nil { + return fmt.Errorf("ISO download task failed: %w", err) + } + + p.machineConfig.ISO = fmt.Sprintf("%s:iso/%s", isoStorage, filename) + log.Infof("ISO available on Proxmox storage: %s", p.machineConfig.ISO) + return nil +} + +// transferLocalISO serves a local ISO file via a temporary HTTP server and +// directs Proxmox to download it via StorageDownloadURL. +func (p *Proxmox) transferLocalISO(ctx context.Context, cfg *types.ProxmoxConfig, isoPath, isoStorage string) error { + // Validate the local file exists + fi, err := os.Stat(isoPath) + if err != nil { + return fmt.Errorf("ISO file not found: %w", err) + } + + log.Infof("Computing SHA256 checksum for %s...", isoPath) + sha256sum, err := checksum.SHA256sum(isoPath) + if err != nil { + return fmt.Errorf("failed to compute SHA256: %w", err) + } + filename := fmt.Sprintf("peg-%s-%s", sha256sum[:12], sanitizeProxmoxName(filepath.Base(isoPath))) + + // Check if ISO already exists on Proxmox with matching size + exists, err := p.isoExistsOnStorage(ctx, isoStorage, filename, fi.Size()) + if err != nil { + return fmt.Errorf("failed to check existing ISO: %w", err) + } + if exists { + log.Infof("ISO %q already current on Proxmox storage %q, skipping upload", filename, isoStorage) + p.machineConfig.ISO = fmt.Sprintf("%s:iso/%s", isoStorage, filename) + return nil + } + + // Detect the local IP that can reach the Proxmox host + apiEndpoint, err := proxmoxAPIEndpoint(cfg.APIURL) + if err != nil { + return err + } + localIP, err := detectLocalIP(apiEndpoint) + if err != nil { + return err + } + log.Infof("Detected local IP reachable from Proxmox: %s", localIP) + + // Start temporary HTTP server + serveURL, shutdown, err := serveISO(isoPath, localIP) + if err != nil { + return fmt.Errorf("failed to start temp HTTP server: %w", err) + } + defer func() { + if err := shutdown(); err != nil { + log.Warnf("Failed to stop temporary ISO server: %v", err) + } + }() + log.Infof("SHA256: %s", sha256sum) + + // Tell Proxmox to download from our temp HTTP server + opts := &proxmoxapi.StorageDownloadURLOptions{ + Content: "iso", + Filename: filename, + Storage: isoStorage, + URL: serveURL, + Checksum: sha256sum, + ChecksumAlgorithm: "sha256", + Node: cfg.Node, + } + + upid, err := p.node.StorageDownloadURL(ctx, opts) + if err != nil { + return fmt.Errorf("StorageDownloadURL failed: %w", err) + } + + log.Infof("Proxmox downloading ISO from %s (task: %s)", serveURL, upid) + task := proxmoxapi.NewTask(proxmoxapi.UPID(upid), p.client) + if err := task.WaitFor(ctx, isoTransferTimeout); err != nil { + return fmt.Errorf("ISO download task failed: %w", err) + } + + p.machineConfig.ISO = fmt.Sprintf("%s:iso/%s", isoStorage, filename) + log.Infof("ISO transferred to Proxmox storage: %s", p.machineConfig.ISO) + return nil +} + +// isoExistsOnStorage checks whether a Peg-owned, content-addressed ISO already +// exists. A size mismatch is reported rather than deleting shared storage. +func (p *Proxmox) isoExistsOnStorage(ctx context.Context, storageName, filename string, localSize int64) (bool, error) { + storage, err := p.node.Storage(ctx, storageName) + if err != nil { + return false, fmt.Errorf("failed to get storage %q: %w", storageName, err) + } + + contents, err := storage.GetContent(ctx) + if err != nil { + return false, fmt.Errorf("failed to list storage %q: %w", storageName, err) + } + return pegISOExists(contents, storageName, filename, localSize) +} + +func pegISOExists(contents []*proxmoxapi.StorageContent, storageName, filename string, localSize int64) (bool, error) { + wantedVolume := fmt.Sprintf("%s:iso/%s", storageName, filename) + for _, content := range contents { + if content == nil || content.Volid != wantedVolume { + continue + } + remoteSize := int64(content.Size) + if remoteSize == localSize { + return true, nil + } + return false, fmt.Errorf("ISO %q exists with unexpected size (local: %d, remote: %d); refusing to delete it", filename, localSize, remoteSize) + } + return false, nil +} + +// detectLocalIP finds the local IP address that routes to the Proxmox API. +func detectLocalIP(apiEndpoint string) (string, error) { + conn, err := net.DialTimeout("tcp", apiEndpoint, 5*time.Second) + if err != nil { + return "", fmt.Errorf("failed to detect local IP reachable from Proxmox API %s: %w", apiEndpoint, err) + } + defer conn.Close() + + localAddr := conn.LocalAddr().(*net.TCPAddr) + return localAddr.IP.String(), nil +} + +// serveISO starts a temporary HTTP server that serves a single ISO file. +// It returns the full URL to the file and a shutdown function. +func serveISO(filePath, bindIP string) (url string, shutdown func() error, err error) { + listener, err := net.Listen("tcp", net.JoinHostPort(bindIP, "0")) + if err != nil { + return "", nil, fmt.Errorf("failed to bind temp HTTP server: %w", err) + } + + mux := http.NewServeMux() + mux.HandleFunc("/iso", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + log.Infof("Serving ISO to %s", r.RemoteAddr) + http.ServeFile(w, r, filePath) + }) + + srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second} + go func() { + if err := srv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Errorf("Temporary ISO server failed: %v", err) + } + }() + + addr := listener.Addr().(*net.TCPAddr) + url = "http://" + net.JoinHostPort(addr.IP.String(), strconv.Itoa(addr.Port)) + "/iso" + shutdown = func() error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return srv.Shutdown(ctx) + } + + log.Infof("Temp HTTP server listening at %s", url) + return url, shutdown, nil +} + +// parseChecksum splits a checksum string like "sha256:abc123" into algorithm and hash. +// If no algorithm prefix is present, defaults to "sha256". +func parseChecksum(cs string) (alg, hash string, err error) { + parts := strings.SplitN(cs, ":", 2) + if len(parts) == 2 { + alg, hash = strings.ToLower(parts[0]), parts[1] + } else { + alg, hash = "sha256", cs + } + if hash == "" { + return "", "", fmt.Errorf("ISO checksum hash is empty") + } + switch alg { + case "md5", "sha1", "sha224", "sha256", "sha384", "sha512": + return alg, hash, nil + default: + return "", "", fmt.Errorf("unsupported Proxmox ISO checksum algorithm %q", alg) + } +} + +func proxmoxURLISOName(isoURL, urlPath string) string { + urlHash := sha256.Sum256([]byte(isoURL)) + return fmt.Sprintf("peg-%x-%s", urlHash[:6], sanitizeProxmoxName(filepath.Base(urlPath))) +} + +func sanitizeProxmoxName(name string) string { + name = strings.TrimSpace(name) + if name == "" || name == "." { + return "image.iso" + } + return strings.Map(func(r rune) rune { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '-', r == '_': + return r + default: + return '-' + } + }, name) +} diff --git a/pkg/machine/proxmox_vnc.go b/pkg/machine/proxmox_vnc.go new file mode 100644 index 0000000..bd2877c --- /dev/null +++ b/pkg/machine/proxmox_vnc.go @@ -0,0 +1,384 @@ +package machine + +import ( + "bytes" + "context" + "crypto/des" + "encoding/binary" + "fmt" + "io" + "strings" + "time" +) + +// --- VNC screenshot helpers --- + +// RFB protocol constants +const ( + rfbMsgSetPixelFormat = 0 + rfbMsgFramebufferUpdateRequest = 3 + rfbMsgSetEncodings = 2 + rfbMsgFramebufferUpdate = 0 + rfbSecurityNone = 1 + rfbSecurityVNCAuth = 2 + rfbEncodingRaw = 0 + maxRFBNameLength = 1 << 20 + maxRFBPixels = 64 * 1024 * 1024 + maxRFBRectangles = 4096 +) + +// wsChanReader adapts a WebSocket recv channel into an io.Reader. +type wsChanReader struct { + recv <-chan []byte + errs <-chan error + buf []byte + timeout time.Duration +} + +func (r *wsChanReader) Read(p []byte) (int, error) { + // Drain buffered data first + if len(r.buf) > 0 { + n := copy(p, r.buf) + r.buf = r.buf[n:] + return n, nil + } + + // Wait for next message from WebSocket + timer := time.NewTimer(r.timeout) + defer timer.Stop() + select { + case msg, ok := <-r.recv: + if !ok { + return 0, io.EOF + } + n := copy(p, msg) + if n < len(msg) { + r.buf = msg[n:] + } + return n, nil + case err, ok := <-r.errs: + if !ok { + return 0, io.EOF + } + return 0, err + case <-timer.C: + return 0, fmt.Errorf("VNC read timeout (%v)", r.timeout) + } +} + +// rfbGrabFrame performs a minimal RFB handshake and captures a single frame. +// vncPassword is the VNC ticket used for VNCAuth (security type 2). +func rfbGrabFrame(ctx context.Context, r io.Reader, send chan<- []byte, vncPassword string) (width, height uint16, pixels []byte, err error) { + // 1. Version handshake + verBuf := make([]byte, 12) + if _, err = io.ReadFull(r, verBuf); err != nil { + return 0, 0, nil, fmt.Errorf("reading server version: %w", err) + } + log.Debugf("RFB server version: %s", strings.TrimSpace(string(verBuf))) + if err = sendRFB(ctx, send, []byte("RFB 003.008\n")); err != nil { + return 0, 0, nil, err + } + + // 2. Security handshake + var numSecTypes uint8 + if err = binary.Read(r, binary.BigEndian, &numSecTypes); err != nil { + return 0, 0, nil, fmt.Errorf("reading security type count: %w", err) + } + if numSecTypes == 0 { + // Server sent a reason string for failure + var reasonLen uint32 + if err = binary.Read(r, binary.BigEndian, &reasonLen); err != nil { + return 0, 0, nil, fmt.Errorf("reading VNC rejection reason length: %w", err) + } + if reasonLen > maxRFBNameLength { + return 0, 0, nil, fmt.Errorf("VNC rejection reason is too large: %d bytes", reasonLen) + } + reason := make([]byte, reasonLen) + if _, err = io.ReadFull(r, reason); err != nil { + return 0, 0, nil, fmt.Errorf("reading VNC rejection reason: %w", err) + } + return 0, 0, nil, fmt.Errorf("VNC server rejected connection: %s", string(reason)) + } + + secTypes := make([]byte, numSecTypes) + if _, err = io.ReadFull(r, secTypes); err != nil { + return 0, 0, nil, fmt.Errorf("reading security types: %w", err) + } + log.Debugf("RFB security types offered: %v", secTypes) + + // Prefer None (1), fall back to VNCAuth (2) + selectedSec := byte(0) + for _, st := range secTypes { + if st == rfbSecurityNone { + selectedSec = rfbSecurityNone + break + } + if st == rfbSecurityVNCAuth { + selectedSec = rfbSecurityVNCAuth + } + } + if selectedSec == 0 { + return 0, 0, nil, fmt.Errorf("VNC server offers no supported security types (got: %v)", secTypes) + } + if err = sendRFB(ctx, send, []byte{selectedSec}); err != nil { + return 0, 0, nil, err + } + + if selectedSec == rfbSecurityVNCAuth { + // VNC Authentication: server sends 16-byte challenge, client responds with DES-encrypted challenge + challenge := make([]byte, 16) + if _, err = io.ReadFull(r, challenge); err != nil { + return 0, 0, nil, fmt.Errorf("reading VNC auth challenge: %w", err) + } + response, encryptErr := vncAuthEncrypt(challenge, vncPassword) + if encryptErr != nil { + return 0, 0, nil, encryptErr + } + if err = sendRFB(ctx, send, response); err != nil { + return 0, 0, nil, err + } + } + + // Read SecurityResult + var secResult uint32 + if err = binary.Read(r, binary.BigEndian, &secResult); err != nil { + return 0, 0, nil, fmt.Errorf("reading security result: %w", err) + } + if secResult != 0 { + return 0, 0, nil, fmt.Errorf("VNC security handshake failed (result=%d)", secResult) + } + + // 3. ClientInit (shared=true) + if err = sendRFB(ctx, send, []byte{1}); err != nil { + return 0, 0, nil, err + } + + // 4. ServerInit — read framebuffer dimensions and pixel format + var serverInit struct { + Width uint16 + Height uint16 + PixelFormat [16]byte + NameLen uint32 + } + if err = binary.Read(r, binary.BigEndian, &serverInit); err != nil { + return 0, 0, nil, fmt.Errorf("reading ServerInit: %w", err) + } + width = serverInit.Width + height = serverInit.Height + if width == 0 || height == 0 || uint64(width)*uint64(height) > maxRFBPixels { + return 0, 0, nil, fmt.Errorf("invalid framebuffer dimensions %dx%d", width, height) + } + if serverInit.NameLen > maxRFBNameLength { + return 0, 0, nil, fmt.Errorf("RFB desktop name is too large: %d bytes", serverInit.NameLen) + } + + // Read and discard the desktop name + name := make([]byte, serverInit.NameLen) + if _, err = io.ReadFull(r, name); err != nil { + return 0, 0, nil, fmt.Errorf("reading desktop name: %w", err) + } + log.Debugf("RFB desktop: %s (%dx%d)", string(name), width, height) + + // 5. SetPixelFormat — request 32bpp with R at byte 0, G at byte 1, B at byte 2 + pixFmt := &bytes.Buffer{} + pixFmt.WriteByte(rfbMsgSetPixelFormat) // message type + pixFmt.Write([]byte{0, 0, 0}) // padding + pixFmt.WriteByte(32) // bits-per-pixel + pixFmt.WriteByte(24) // depth + pixFmt.WriteByte(0) // big-endian (0=little) + pixFmt.WriteByte(1) // true-color + binary.Write(pixFmt, binary.BigEndian, uint16(255)) // red-max + binary.Write(pixFmt, binary.BigEndian, uint16(255)) // green-max + binary.Write(pixFmt, binary.BigEndian, uint16(255)) // blue-max + pixFmt.WriteByte(0) // red-shift + pixFmt.WriteByte(8) // green-shift + pixFmt.WriteByte(16) // blue-shift + pixFmt.Write([]byte{0, 0, 0}) // padding + if err = sendRFB(ctx, send, pixFmt.Bytes()); err != nil { + return 0, 0, nil, err + } + + // 6. SetEncodings — request Raw encoding only + encMsg := &bytes.Buffer{} + encMsg.WriteByte(rfbMsgSetEncodings) // message type + encMsg.WriteByte(0) // padding + binary.Write(encMsg, binary.BigEndian, uint16(1)) // number of encodings + binary.Write(encMsg, binary.BigEndian, int32(rfbEncodingRaw)) // Raw + if err = sendRFB(ctx, send, encMsg.Bytes()); err != nil { + return 0, 0, nil, err + } + + // 7. FramebufferUpdateRequest — full screen, non-incremental + fbReq := &bytes.Buffer{} + fbReq.WriteByte(rfbMsgFramebufferUpdateRequest) + fbReq.WriteByte(0) // incremental = false + binary.Write(fbReq, binary.BigEndian, uint16(0)) // x + binary.Write(fbReq, binary.BigEndian, uint16(0)) // y + binary.Write(fbReq, binary.BigEndian, width) // width + binary.Write(fbReq, binary.BigEndian, height) // height + if err = sendRFB(ctx, send, fbReq.Bytes()); err != nil { + return 0, 0, nil, err + } + + // 8. Read FramebufferUpdate response + // The server may send other message types first; skip until we get type 0 + for { + var msgType uint8 + if err = binary.Read(r, binary.BigEndian, &msgType); err != nil { + return 0, 0, nil, fmt.Errorf("reading message type: %w", err) + } + if msgType == rfbMsgFramebufferUpdate { + break + } + // Skip unknown messages — read and discard based on type + if err = rfbSkipMessage(r, msgType); err != nil { + return 0, 0, nil, fmt.Errorf("skipping message type %d: %w", msgType, err) + } + } + + // Parse FramebufferUpdate header + var pad uint8 + var numRects uint16 + if err = binary.Read(r, binary.BigEndian, &pad); err != nil { + return 0, 0, nil, fmt.Errorf("reading framebuffer update padding: %w", err) + } + if err = binary.Read(r, binary.BigEndian, &numRects); err != nil { + return 0, 0, nil, fmt.Errorf("reading rect count: %w", err) + } + if numRects > maxRFBRectangles { + return 0, 0, nil, fmt.Errorf("framebuffer update has too many rectangles: %d", numRects) + } + + // Read all rectangles — accumulate pixel data + totalPixels := make([]byte, int(width)*int(height)*4) + + for i := uint16(0); i < numRects; i++ { + var rect struct { + X, Y, W, H uint16 + EncodingType int32 + } + if err = binary.Read(r, binary.BigEndian, &rect); err != nil { + return 0, 0, nil, fmt.Errorf("reading rect %d header: %w", i, err) + } + + if rect.EncodingType != rfbEncodingRaw { + return 0, 0, nil, fmt.Errorf("unsupported encoding type %d in rect %d", rect.EncodingType, i) + } + if uint32(rect.X)+uint32(rect.W) > uint32(width) || uint32(rect.Y)+uint32(rect.H) > uint32(height) { + return 0, 0, nil, fmt.Errorf("rectangle %d is outside framebuffer bounds", i) + } + + rectSize := int(rect.W) * int(rect.H) * 4 + rectData := make([]byte, rectSize) + if _, err = io.ReadFull(r, rectData); err != nil { + return 0, 0, nil, fmt.Errorf("reading rect %d pixels: %w", i, err) + } + + // Place rectangle pixels into the full framebuffer at the correct position + for row := 0; row < int(rect.H); row++ { + srcOff := row * int(rect.W) * 4 + dstOff := ((int(rect.Y) + row) * int(width) * 4) + (int(rect.X) * 4) + copy(totalPixels[dstOff:dstOff+int(rect.W)*4], rectData[srcOff:srcOff+int(rect.W)*4]) + } + } + + return width, height, totalPixels, nil +} + +func sendRFB(ctx context.Context, send chan<- []byte, message []byte) error { + select { + case send <- message: + return nil + case <-ctx.Done(): + return fmt.Errorf("sending RFB message: %w", ctx.Err()) + } +} + +// rfbSkipMessage skips over a server-to-client RFB message that isn't a FramebufferUpdate. +func rfbSkipMessage(r io.Reader, msgType uint8) error { + switch msgType { + case 1: // SetColourMapEntries + var header struct { + Pad uint8 + FirstColor uint16 + NumColors uint16 + } + if err := binary.Read(r, binary.BigEndian, &header); err != nil { + return err + } + skip := make([]byte, int(header.NumColors)*6) // 3x uint16 per color + _, err := io.ReadFull(r, skip) + return err + case 2: // Bell — no payload + return nil + case 3: // ServerCutText + var pad [3]byte + if _, err := io.ReadFull(r, pad[:]); err != nil { + return err + } + var textLen uint32 + if err := binary.Read(r, binary.BigEndian, &textLen); err != nil { + return err + } + if textLen > maxRFBNameLength { + return fmt.Errorf("server cut text is too large: %d bytes", textLen) + } + skip := make([]byte, textLen) + _, err := io.ReadFull(r, skip) + return err + default: + return fmt.Errorf("unknown server message type %d", msgType) + } +} + +// pixelsToPPM converts 32bpp RGBA pixel data to PPM (P6) format. +func pixelsToPPM(width, height uint16, pixels []byte) ([]byte, error) { + expected := int(width) * int(height) * 4 + if len(pixels) != expected { + return nil, fmt.Errorf("pixel buffer has %d bytes, want %d", len(pixels), expected) + } + header := fmt.Sprintf("P6\n%d %d\n255\n", width, height) + rgbSize := int(width) * int(height) * 3 + buf := make([]byte, 0, len(header)+rgbSize) + buf = append(buf, header...) + + // Convert 32bpp (R, G, B, pad) to 24bpp (R, G, B) + for i := 0; i < len(pixels); i += 4 { + buf = append(buf, pixels[i], pixels[i+1], pixels[i+2]) + } + + return buf, nil +} + +// vncAuthEncrypt performs VNC Authentication DES encryption. +// The password is truncated/padded to 8 bytes, each byte is bit-reversed, +// then used as a DES key to encrypt the 16-byte challenge. +func vncAuthEncrypt(challenge []byte, password string) ([]byte, error) { + if len(challenge) != 16 { + return nil, fmt.Errorf("VNC authentication challenge has %d bytes, want 16", len(challenge)) + } + key := make([]byte, 8) + for i := 0; i < 8 && i < len(password); i++ { + key[i] = reverseBits(password[i]) + } + + cipher, err := des.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("creating VNC authentication cipher: %w", err) + } + + response := make([]byte, 16) + cipher.Encrypt(response[0:8], challenge[0:8]) + cipher.Encrypt(response[8:16], challenge[8:16]) + return response, nil +} + +// reverseBits reverses the bit order of a byte (VNC DES key quirk). +func reverseBits(b byte) byte { + var result byte + for i := 0; i < 8; i++ { + result = (result << 1) | (b & 1) + b >>= 1 + } + return result +} diff --git a/pkg/machine/types/config.go b/pkg/machine/types/config.go index d985b7c..7a46746 100644 --- a/pkg/machine/types/config.go +++ b/pkg/machine/types/config.go @@ -16,6 +16,7 @@ type SSH struct { User string `yaml:"user,omitempty"` Port string `yaml:"port,omitempty"` Pass string `yaml:"pass,omitempty"` + Host string `yaml:"host,omitempty"` // defaults to 127.0.0.1; set to proxmox host for DNAT } type MachineConfig struct { @@ -36,7 +37,7 @@ type MachineConfig struct { // only for qemu Display string `yaml:"display,omitempty"` - CPUType string `yaml:"cpu,omitempty"` + CPUType string `yaml:"cpuType,omitempty"` // Network configuration DisableDefaultNetworking bool `yaml:"disable_default_networking,omitempty"` @@ -46,14 +47,34 @@ type MachineConfig struct { Arch string `yaml:"arch,omitempty"` OnFailure func(*process.Process) + + Proxmox *ProxmoxConfig `yaml:"proxmox,omitempty"` +} + +// ProxmoxConfig holds configuration specific to the Proxmox VE backend. +// Authentication: use either TokenID+TokenSecret (API token) or Username+Password (root login). +// Root login is required for VM features like custom QEMU args. +type ProxmoxConfig struct { + APIURL string `yaml:"apiURL,omitempty"` + Node string `yaml:"node,omitempty"` + TokenID string `yaml:"tokenID,omitempty"` + TokenSecret string `yaml:"tokenSecret,omitempty"` + Username string `yaml:"username,omitempty"` // e.g., "root@pam" + Password string `yaml:"password,omitempty"` + Storage string `yaml:"storage,omitempty"` // e.g., "local-lvm" + Bridge string `yaml:"bridge,omitempty"` // SDN VNet bridge, e.g., "vnet1" + Zone string `yaml:"zone,omitempty"` // SDN zone, e.g., "nat-zone" + InsecureTLS bool `yaml:"insecureTLS,omitempty"` + ISOStorage string `yaml:"isoStorage,omitempty"` // Proxmox storage for ISOs, default: "local" } type Engine string const ( - VBox Engine = "vbox" - QEMU Engine = "qemu" - Docker Engine = "docker" + VBox Engine = "vbox" + QEMU Engine = "qemu" + Docker Engine = "docker" + Proxmox Engine = "proxmox" ) type MachineOption func(*MachineConfig) error @@ -291,3 +312,151 @@ var EnableDefaultNetworking MachineOption = func(mc *MachineConfig) error { mc.DisableDefaultNetworking = false return nil } + +// ProxmoxEngine sets the machine engine to Proxmox. +var ProxmoxEngine MachineOption = func(mc *MachineConfig) error { + mc.Engine = Proxmox + if mc.Proxmox == nil { + mc.Proxmox = &ProxmoxConfig{} + } + return nil +} + +func WithSSHHost(host string) MachineOption { + return func(mc *MachineConfig) error { + if host != "" { + mc.SSH.Host = host + } + return nil + } +} + +func WithProxmoxAPIURL(url string) MachineOption { + return func(mc *MachineConfig) error { + if mc.Proxmox == nil { + mc.Proxmox = &ProxmoxConfig{} + } + if url != "" { + mc.Proxmox.APIURL = url + } + return nil + } +} + +func WithProxmoxNode(node string) MachineOption { + return func(mc *MachineConfig) error { + if mc.Proxmox == nil { + mc.Proxmox = &ProxmoxConfig{} + } + if node != "" { + mc.Proxmox.Node = node + } + return nil + } +} + +func WithProxmoxTokenID(tokenID string) MachineOption { + return func(mc *MachineConfig) error { + if mc.Proxmox == nil { + mc.Proxmox = &ProxmoxConfig{} + } + if tokenID != "" { + mc.Proxmox.TokenID = tokenID + } + return nil + } +} + +func WithProxmoxTokenSecret(secret string) MachineOption { + return func(mc *MachineConfig) error { + if mc.Proxmox == nil { + mc.Proxmox = &ProxmoxConfig{} + } + if secret != "" { + mc.Proxmox.TokenSecret = secret + } + return nil + } +} + +func WithProxmoxStorage(storage string) MachineOption { + return func(mc *MachineConfig) error { + if mc.Proxmox == nil { + mc.Proxmox = &ProxmoxConfig{} + } + if storage != "" { + mc.Proxmox.Storage = storage + } + return nil + } +} + +func WithProxmoxBridge(bridge string) MachineOption { + return func(mc *MachineConfig) error { + if mc.Proxmox == nil { + mc.Proxmox = &ProxmoxConfig{} + } + if bridge != "" { + mc.Proxmox.Bridge = bridge + } + return nil + } +} + +func WithProxmoxISOStorage(isoStorage string) MachineOption { + return func(mc *MachineConfig) error { + if mc.Proxmox == nil { + mc.Proxmox = &ProxmoxConfig{} + } + if isoStorage != "" { + mc.Proxmox.ISOStorage = isoStorage + } + return nil + } +} + +func WithProxmoxZone(zone string) MachineOption { + return func(mc *MachineConfig) error { + if mc.Proxmox == nil { + mc.Proxmox = &ProxmoxConfig{} + } + if zone != "" { + mc.Proxmox.Zone = zone + } + return nil + } +} + +func WithProxmoxUsername(username string) MachineOption { + return func(mc *MachineConfig) error { + if mc.Proxmox == nil { + mc.Proxmox = &ProxmoxConfig{} + } + if username != "" { + mc.Proxmox.Username = username + } + return nil + } +} + +func WithProxmoxPassword(password string) MachineOption { + return func(mc *MachineConfig) error { + if mc.Proxmox == nil { + mc.Proxmox = &ProxmoxConfig{} + } + if password != "" { + mc.Proxmox.Password = password + } + return nil + } +} + +func WithProxmoxInsecureTLS(insecure bool) MachineOption { + return func(mc *MachineConfig) error { + if mc.Proxmox == nil { + mc.Proxmox = &ProxmoxConfig{} + } + mc.Proxmox.InsecureTLS = insecure + return nil + } +} diff --git a/pkg/machine/types/proxmox_config_test.go b/pkg/machine/types/proxmox_config_test.go new file mode 100644 index 0000000..676f942 --- /dev/null +++ b/pkg/machine/types/proxmox_config_test.go @@ -0,0 +1,63 @@ +package types + +import ( + "testing" + + "gopkg.in/yaml.v3" +) + +func TestProxmoxConfigYAML(t *testing.T) { + var cfg MachineConfig + err := yaml.Unmarshal([]byte(` +engine: proxmox +cpuType: host +ssh: + host: pve.example.test + port: "2222" +proxmox: + apiURL: https://pve.example.test:8006/api2/json + node: pve + username: root@pam + password: secret + storage: local-lvm + isoStorage: local + bridge: vnet1 + zone: nat + insecureTLS: true +`), &cfg) + if err != nil { + t.Fatal(err) + } + if cfg.Engine != Proxmox || cfg.CPUType != "host" || cfg.SSH.Host != "pve.example.test" { + t.Fatalf("unexpected machine config: %#v", cfg) + } + if cfg.Proxmox == nil || cfg.Proxmox.Username != "root@pam" || !cfg.Proxmox.InsecureTLS { + t.Fatalf("unexpected Proxmox config: %#v", cfg.Proxmox) + } +} + +func TestProxmoxMachineOptions(t *testing.T) { + cfg := DefaultMachineConfig() + err := cfg.Apply( + ProxmoxEngine, + WithSSHHost("pve.example.test"), + WithProxmoxAPIURL("https://pve.example.test:8006/api2/json"), + WithProxmoxNode("pve"), + WithProxmoxUsername("root@pam"), + WithProxmoxPassword("secret"), + WithProxmoxStorage("local-lvm"), + WithProxmoxISOStorage("local"), + WithProxmoxBridge("vnet1"), + WithProxmoxZone("nat"), + WithProxmoxInsecureTLS(true), + ) + if err != nil { + t.Fatal(err) + } + if cfg.Engine != Proxmox || cfg.SSH.Host != "pve.example.test" || cfg.Proxmox == nil { + t.Fatalf("unexpected machine config: %#v", cfg) + } + if cfg.Proxmox.ISOStorage != "local" || !cfg.Proxmox.InsecureTLS { + t.Fatalf("unexpected Proxmox options: %#v", cfg.Proxmox) + } +}