diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f78e9a50..b18ed8d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,34 @@ jobs: uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} + flags: unit + + test-e2e: + runs-on: ubuntu-latest + permissions: + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.23" + + - uses: hetznercloud/tps-action@main + + - name: Run tests + run: go test -tags e2e -coverpkg=./... -coverprofile=coverage.txt -v -race ./test/e2e + + - name: Upload coverage reports to Codecov + if: > + !startsWith(github.head_ref, 'renovate/') && + !startsWith(github.head_ref, 'release-please--') + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: e2e generate: runs-on: ubuntu-latest diff --git a/.github/workflows/e2e_test.yml b/.github/workflows/e2e_test.yml new file mode 100644 index 00000000..27b3dff6 --- /dev/null +++ b/.github/workflows/e2e_test.yml @@ -0,0 +1,9 @@ +name: E2E Tests + +on: + push: + branches: [main] + pull_request: + +jobs: + test: diff --git a/go.mod b/go.mod index d25358bf..2e821532 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.19.0 github.com/stretchr/testify v1.9.0 + github.com/swaggest/assertjson v1.9.0 golang.org/x/crypto v0.27.0 golang.org/x/term v0.24.0 ) @@ -28,16 +29,19 @@ require ( require ( github.com/VividCortex/ewma v1.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/bool64/shared v0.1.5 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect + github.com/iancoleman/orderedmap v0.3.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/nxadm/tail v1.4.11 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.19.1 // indirect @@ -47,9 +51,12 @@ require ( github.com/rivo/uniseg v0.2.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sergi/go-diff v1.1.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/yudai/gojsondiff v1.0.0 // indirect + github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect diff --git a/go.sum b/go.sum index 43a61a79..4fe44263 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,10 @@ github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1o github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bool64/dev v0.2.29 h1:x+syGyh+0eWtOzQ1ItvLzOGIWyNWnyjXpHIcpF2HvL4= +github.com/bool64/dev v0.2.29/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= +github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= +github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= github.com/boumenot/gocover-cobertura v1.2.0 h1:g+VROIASoEHBrEilIyaCmgo7HGm+AV5yKEPLk0qIY+s= github.com/boumenot/gocover-cobertura v1.2.0/go.mod h1:fz7ly8dslE42VRR5ZWLt2OHGDHjkTiA2oNvKgJEjLT0= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= @@ -23,6 +27,7 @@ github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= @@ -43,12 +48,17 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hetznercloud/hcloud-go/v2 v2.13.1 h1:jq0GP4QaYE5d8xR/Zw17s9qoaESRJMXfGmtD1a/qckQ= github.com/hetznercloud/hcloud-go/v2 v2.13.1/go.mod h1:dhix40Br3fDiBhwaSG/zgaYOFFddpfBm/6R1Zz0IiF0= +github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= +github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jedib0t/go-pretty/v6 v6.5.9 h1:ACteMBRrrmm1gMsXe9PSTOClQ63IXDUt03H5U+UV8OU= github.com/jedib0t/go-pretty/v6 v6.5.9/go.mod h1:zbn98qrYlh95FIhwwsbIip0LYpwSG8SUOScs+v9/t0E= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= @@ -64,6 +74,12 @@ github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZ github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= +github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= +github.com/onsi/ginkgo v1.15.2 h1:l77YT15o814C2qVL47NOyjV/6RbaP7kKdrvZnxQ3Org= +github.com/onsi/ginkgo v1.15.2/go.mod h1:Dd6YFfwBW84ETqqtL0CPyPXillHgY6XhQH3uuCCTr/o= +github.com/onsi/gomega v1.11.0 h1:+CqWgvj0OZycCaqclBD1pxKHAU+tOkHmQIWvDHq2aug= +github.com/onsi/gomega v1.11.0/go.mod h1:azGKhqFUon9Vuj0YmTfLSmx0FUwqXYSTl5re8lQLTUg= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -86,6 +102,8 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= @@ -103,6 +121,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 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.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -111,6 +130,14 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= +github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= +github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= +github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= +github.com/yudai/pp v2.0.1+incompatible h1:Q4//iY4pNF6yPLZIigmvcl7k/bPgrcTPIFIcmawg5bI= +github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= @@ -144,6 +171,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= @@ -169,10 +197,17 @@ golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6f google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/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= diff --git a/test/e2e/combined_test.go b/test/e2e/combined_test.go new file mode 100644 index 00000000..7b2d1a04 --- /dev/null +++ b/test/e2e/combined_test.go @@ -0,0 +1,109 @@ +//go:build e2e + +package e2e + +import ( + "fmt" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCombined(t *testing.T) { + // combined tests combine multiple resources and can thus not be run in parallel + serverName := withSuffix("test-server") + serverID, err := createServer(t, serverName, TestServerType, TestImage) + require.NoError(t, err) + + firewallName := withSuffix("test-firewall") + firewallID, err := createFirewall(t, firewallName) + require.NoError(t, err) + + t.Run("firewall", func(t *testing.T) { + t.Run("apply-to-server", func(t *testing.T) { + out, err := runCommand(t, "firewall", "apply-to-resource", "--type", "server", "--server", serverName, strconv.FormatInt(firewallID, 10)) + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Firewall %d applied to resource\n", firewallID), out) + }) + + t.Run("delete-in-use", func(t *testing.T) { + out, err := runCommand(t, "firewall", "delete", strconv.FormatInt(firewallID, 10)) + assert.Regexp(t, `^firewall with ID [0-9]+ is still in use \(resource_in_use, [0-9a-f]+\)$`, err.Error()) + assert.Empty(t, out) + }) + + t.Run("remove-from-server", func(t *testing.T) { + out, err := runCommand(t, "firewall", "remove-from-resource", "--type", "server", "--server", serverName, strconv.FormatInt(firewallID, 10)) + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Firewall %d removed from resource\n", firewallID), out) + }) + + t.Run("delete", func(t *testing.T) { + out, err := runCommand(t, "firewall", "delete", strconv.FormatInt(firewallID, 10)) + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("firewall %d deleted\n", firewallID), out) + }) + + }) + + floatingIPName := withSuffix("test-floating-ip") + floatingIP, err := createFloatingIP(t, floatingIPName, "ipv4", "--server", strconv.FormatInt(serverID, 10)) + require.NoError(t, err) + + t.Run("floating-ip", func(t *testing.T) { + t.Run("unassign", func(t *testing.T) { + out, err := runCommand(t, "floating-ip", "unassign", strconv.FormatInt(floatingIP, 10)) + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Floating IP %d unassigned\n", floatingIP), out) + }) + + t.Run("assign", func(t *testing.T) { + out, err := runCommand(t, "floating-ip", "assign", strconv.FormatInt(floatingIP, 10), strconv.FormatInt(serverID, 10)) + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Floating IP %d assigned to server %d\n", floatingIP, serverID), out) + }) + + t.Run("describe", func(t *testing.T) { + out, err := runCommand(t, "floating-ip", "describe", strconv.FormatInt(floatingIP, 10)) + require.NoError(t, err) + assert.Regexp(t, `ID:\s+[0-9]+ +Type:\s+ipv4 +Name:\s+test-floating-ip-[0-9a-f]{8} +Description:\s+- +Created:.*? +IP:\s+(?:[0-9]{1,3}\.){3}[0-9]{1,3} +Blocked:\s+no +Home Location:\s+[a-z]{3}[0-9]* +Server: +\s+ID:\s+[0-9]+ +\s+Name:\s+test-server-[0-9a-f]{8} +DNS: +.*? +Protection: +\s+Delete:\s+no +Labels: +\s+No labels +`, out) + }) + + t.Run("list", func(t *testing.T) { + out, err := runCommand(t, "floating-ip", "list", "-o", "columns=server", "-o", "noheader") + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("%s\n", serverName), out) + }) + + t.Run("delete", func(t *testing.T) { + out, err := runCommand(t, "floating-ip", "delete", strconv.FormatInt(floatingIP, 10)) + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Floating IP %d deleted\n", floatingIP), out) + }) + }) + + t.Run("delete-server", func(t *testing.T) { + out, err := runCommand(t, "server", "delete", strconv.FormatInt(serverID, 10)) + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Server %d deleted\n", serverID), out) + }) +} diff --git a/test/e2e/config.toml b/test/e2e/config.toml new file mode 100644 index 00000000..22c25af7 --- /dev/null +++ b/test/e2e/config.toml @@ -0,0 +1 @@ +# config for tests goes here diff --git a/test/e2e/datacenter_test.go b/test/e2e/datacenter_test.go new file mode 100644 index 00000000..8c4deb21 --- /dev/null +++ b/test/e2e/datacenter_test.go @@ -0,0 +1,56 @@ +//go:build e2e + +package e2e + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDatacenter(t *testing.T) { + t.Parallel() + + t.Run("list", func(t *testing.T) { + t.Run("table", func(t *testing.T) { + out, err := runCommand(t, "datacenter", "list") + require.NoError(t, err) + assert.Regexp(t, `ID +NAME +DESCRIPTION +LOCATION +([0-9]+ +[a-z0-9\-]+ +[a-zA-Z0-9\- ]+ +[a-z0-9\-]+\n)+`, out) + }) + + t.Run("json", func(t *testing.T) { + out, err := runCommand(t, "datacenter", "list", "-o=json") + require.NoError(t, err) + require.NoError(t, json.Unmarshal([]byte(out), new([]any))) + }) + }) + + t.Run("describe", func(t *testing.T) { + t.Run("non-existing", func(t *testing.T) { + out, err := runCommand(t, "datacenter", "describe", "123456") + require.EqualError(t, err, "datacenter not found: 123456") + assert.Empty(t, out) + }) + + t.Run("normal", func(t *testing.T) { + out, err := runCommand(t, "datacenter", "describe", TestDatacenterID) + require.NoError(t, err) + assert.Regexp(t, `ID:\s+[0-9]+ +Name:\s+[a-z0-9\-]+ +Description:\s+[a-zA-Z0-9\- ]+ +Location: + +Name:\s+[a-z0-9]+ + +Description:\s+[a-zA-Z0-9\- ]+ + +Country:\s+[A-Z]{2} + +City:\s+[A-Za-z]+ + +Latitude:\s+[0-9\.]+ + +Longitude:\s+[0-9\.]+ +Server Types: +(\s+- ID: [0-9]+\s+Name: [a-z0-9]+\s+Supported: (true|false)\s+Available: (true|false))+ +`, out) + }) + }) +} diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go new file mode 100644 index 00000000..cafed3c5 --- /dev/null +++ b/test/e2e/e2e_test.go @@ -0,0 +1,66 @@ +//go:build e2e + +package e2e + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "fmt" + "os" + "testing" + + "github.com/spf13/cobra" + + "github.com/hetznercloud/cli/internal/cli" + "github.com/hetznercloud/cli/internal/state" + "github.com/hetznercloud/cli/internal/state/config" + "github.com/hetznercloud/hcloud-go/v2/hcloud" +) + +var client = hcloud.NewClient(hcloud.WithToken(os.Getenv("HCLOUD_TOKEN"))) + +func TestMain(m *testing.M) { + tok := os.Getenv("HCLOUD_TOKEN") + if tok == "" { + fmt.Println("HCLOUD_TOKEN is not set") + os.Exit(1) + return + } + os.Exit(m.Run()) +} + +func newRootCommand(t *testing.T) *cobra.Command { + t.Helper() + cfg := config.New() + if err := cfg.Read("config.toml"); err != nil { + t.Fatalf("unable to read config file \"%s\": %s\n", cfg.Path(), err) + } + + s, err := state.New(cfg) + if err != nil { + t.Fatal(err) + } + + return cli.NewRootCommand(s) +} + +func runCommand(t *testing.T, args ...string) (string, error) { + t.Helper() + cmd := newRootCommand(t) + var buf bytes.Buffer + cmd.SetArgs(args) + cmd.SetOut(&buf) + err := cmd.Execute() + return buf.String(), err +} + +func withSuffix(s string) string { + b := make([]byte, 4) + _, err := rand.Read(b) + if err != nil { + panic(err) + } + suffix := hex.EncodeToString(b) + return fmt.Sprintf("%s-%s", s, suffix) +} diff --git a/test/e2e/firewall_test.go b/test/e2e/firewall_test.go new file mode 100644 index 00000000..8bc27705 --- /dev/null +++ b/test/e2e/firewall_test.go @@ -0,0 +1,326 @@ +//go:build e2e + +package e2e + +import ( + "context" + "fmt" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hetznercloud/hcloud-go/v2/hcloud" +) + +func TestFirewall(t *testing.T) { + t.Parallel() + + firewallName := withSuffix("test-firewall") + firewallID, err := createFirewall(t, firewallName, "--rules-file", "rules_file.json") + require.NoError(t, err) + + t.Run("add-label", func(t *testing.T) { + t.Run("non-existing", func(t *testing.T) { + out, err := runCommand(t, "firewall", "add-label", "non-existing-firewall", "foo=bar") + require.EqualError(t, err, "firewall not found: non-existing-firewall") + assert.Empty(t, out) + }) + + t.Run("1", func(t *testing.T) { + out, err := runCommand(t, "firewall", "add-label", strconv.FormatInt(firewallID, 10), "foo=bar") + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Label(s) foo added to firewall %d\n", firewallID), out) + }) + + t.Run("2", func(t *testing.T) { + out, err := runCommand(t, "firewall", "add-label", strconv.FormatInt(firewallID, 10), "baz=qux") + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Label(s) baz added to firewall %d\n", firewallID), out) + }) + }) + + t.Run("update-name", func(t *testing.T) { + firewallName = withSuffix("new-test-firewall") + + out, err := runCommand(t, "firewall", "update", strconv.FormatInt(firewallID, 10), "--name", firewallName) + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Firewall %d updated\n", firewallID), out) + }) + + t.Run("remove-label", func(t *testing.T) { + out, err := runCommand(t, "firewall", "remove-label", firewallName, "baz") + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Label(s) baz removed from firewall %d\n", firewallID), out) + }) + + t.Run("add-rule", func(t *testing.T) { + t.Run("missing-args", func(t *testing.T) { + out, err := runCommand(t, "firewall", "add-rule", strconv.FormatInt(firewallID, 10)) + require.EqualError(t, err, `required flag(s) "direction", "protocol" not set`) + assert.Empty(t, out) + }) + + t.Run("unknown-firewall", func(t *testing.T) { + out, err := runCommand(t, "firewall", "add-rule", "non-existing-firewall", "--direction", "in", "--source-ips", "10.0.0.0/24", "--protocol", "tcp", "--port", "9100") + require.EqualError(t, err, "Firewall not found: non-existing-firewall") + assert.Empty(t, out) + }) + + t.Run("missing-port", func(t *testing.T) { + out, err := runCommand(t, "firewall", "add-rule", strconv.FormatInt(firewallID, 10), "--direction", "in", "--source-ips", "10.0.0.0/24", "--protocol", "tcp", "--description", "Some random description") + require.EqualError(t, err, "port is required (--port)") + assert.Empty(t, out) + }) + + t.Run("port-not-allowed", func(t *testing.T) { + out, err := runCommand(t, "firewall", "add-rule", strconv.FormatInt(firewallID, 10), "--direction", "out", "--destination-ips", "192.168.1.0/24", "--protocol", "icmp", "--port", "12345") + require.EqualError(t, err, "port is not allowed for this protocol") + assert.Empty(t, out) + }) + + t.Run("invalid-direction", func(t *testing.T) { + out, err := runCommand(t, "firewall", "add-rule", strconv.FormatInt(firewallID, 10), "--direction", "foo", "--destination-ips", "192.168.1.0/24", "--protocol", "tcp", "--port", "12345") + require.EqualError(t, err, "invalid direction: foo") + assert.Empty(t, out) + }) + + t.Run("invalid-protocol", func(t *testing.T) { + out, err := runCommand(t, "firewall", "add-rule", strconv.FormatInt(firewallID, 10), "--direction", "out", "--destination-ips", "192.168.1.0/24", "--protocol", "abc", "--port", "12345") + require.EqualError(t, err, "invalid protocol: abc") + assert.Empty(t, out) + }) + + t.Run("tcp-in", func(t *testing.T) { + out, err := runCommand(t, "firewall", "add-rule", strconv.FormatInt(firewallID, 10), "--direction", "in", "--source-ips", "10.0.0.0/24", "--protocol", "tcp", "--port", "9100", "--description", "Some random description") + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Firewall Rules for Firewall %d updated\n", firewallID), out) + }) + + t.Run("icmp-out", func(t *testing.T) { + out, err := runCommand(t, "firewall", "add-rule", strconv.FormatInt(firewallID, 10), "--direction", "out", "--destination-ips", "192.168.1.0/24", "--protocol", "icmp") + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Firewall Rules for Firewall %d updated\n", firewallID), out) + }) + + t.Run("invalid-ip-out", func(t *testing.T) { + out, err := runCommand(t, "firewall", "add-rule", strconv.FormatInt(firewallID, 10), "--direction", "out", "--destination-ips", "invalid-ip", "--protocol", "tcp", "--port", "9100") + require.EqualError(t, err, "destination error on index 0: invalid CIDR address: invalid-ip") + assert.Empty(t, out) + }) + + t.Run("invalid-ip-range-out", func(t *testing.T) { + out, err := runCommand(t, "firewall", "add-rule", strconv.FormatInt(firewallID, 10), "--direction", "in", "--source-ips", "10.1.2.3/8", "--protocol", "tcp", "--port", "9100") + require.EqualError(t, err, "source ips error on index 0: 10.1.2.3/8 is not the start of the cidr block 10.0.0.0/8") + assert.Empty(t, out) + }) + }) + + t.Run("apply-to-resource", func(t *testing.T) { + t.Run("unknown-type", func(t *testing.T) { + out, err := runCommand(t, "firewall", "apply-to-resource", "--type", "non-existing-type", strconv.FormatInt(firewallID, 10)) + require.EqualError(t, err, "unknown type non-existing-type") + assert.Empty(t, out) + }) + + t.Run("missing-server", func(t *testing.T) { + out, err := runCommand(t, "firewall", "apply-to-resource", "--type", "server", strconv.FormatInt(firewallID, 10)) + require.EqualError(t, err, "type server need a --server specific") + assert.Empty(t, out) + }) + + t.Run("missing-label-selector", func(t *testing.T) { + out, err := runCommand(t, "firewall", "apply-to-resource", "--type", "label_selector", strconv.FormatInt(firewallID, 10)) + require.EqualError(t, err, "type label_selector need a --label-selector specific") + assert.Empty(t, out) + }) + + t.Run("unknown-firewall", func(t *testing.T) { + out, err := runCommand(t, "firewall", "apply-to-resource", "--type", "server", "--server", "non-existing-server", "non-existing-firewall") + require.EqualError(t, err, "Firewall not found: non-existing-firewall") + assert.Empty(t, out) + }) + + t.Run("unknown-server", func(t *testing.T) { + out, err := runCommand(t, "firewall", "apply-to-resource", "--type", "server", "--server", "non-existing-server", strconv.FormatInt(firewallID, 10)) + require.EqualError(t, err, "Server not found: non-existing-server") + assert.Empty(t, out) + }) + + t.Run("label-selector", func(t *testing.T) { + out, err := runCommand(t, "firewall", "apply-to-resource", "--type", "label_selector", "--label-selector", "foo=bar", strconv.FormatInt(firewallID, 10)) + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Firewall %d applied to resource\n", firewallID), out) + }) + }) + + t.Run("describe", func(t *testing.T) { + out, err := runCommand(t, "firewall", "describe", strconv.FormatInt(firewallID, 10)) + require.NoError(t, err) + assert.Regexp(t, `ID:\s+[0-9]+ +Name:\s+new-test-firewall-[0-9a-f]{8} +Created:\s+.*? +Labels: +\s+foo: bar +Rules: +\s+- Direction:\s+in +\s+Description:\s+Allow port 80 +\s+Protocol:\s+tcp +\s+Port:\s+80 +\s+Source IPs: +\s+28\.239\.13\.1\/32 +\s+28\.239\.14\.0\/24 +\s+ff21:1eac:9a3b:ee58:5ca:990c:8bc9:c03b\/128 +\s+- Direction:\s+in +\s+Description:\s+Allow port 443 +\s+Protocol:\s+tcp +\s+Port:\s+443 +\s+Source IPs: +\s+0\.0\.0\.0\/0 +\s+::\/0 +\s+- Direction:\s+out +\s+Protocol:\s+tcp +\s+Port:\s+80 +\s+Destination IPs: +\s+28\.239\.13\.1\/32 +\s+28\.239\.14\.0\/24 +\s+ff21:1eac:9a3b:ee58:5ca:990c:8bc9:c03b\/128 +\s+- Direction:\s+in +\s+Description:\s+Some random description +\s+Protocol:\s+tcp +\s+Port:\s+9100 +\s+Source IPs: +\s+10\.0\.0\.0\/24 +\s+- Direction:\s+out +\s+Protocol:\s+icmp +\s+Destination IPs: +\s+192\.168\.1\.0\/24 +Applied To: +\s+- Type:\s+label_selector +\s+Label Selector:\s+foo=bar +`, out) + }) + + t.Run("remove-from-resource", func(t *testing.T) { + t.Run("unknown-type", func(t *testing.T) { + out, err := runCommand(t, "firewall", "remove-from-resource", "--type", "non-existing-type", strconv.FormatInt(firewallID, 10)) + require.EqualError(t, err, "unknown type non-existing-type") + assert.Empty(t, out) + }) + + t.Run("missing-server", func(t *testing.T) { + out, err := runCommand(t, "firewall", "remove-from-resource", "--type", "server", strconv.FormatInt(firewallID, 10)) + require.EqualError(t, err, "type server need a --server specific") + assert.Empty(t, out) + }) + + t.Run("missing-label-selector", func(t *testing.T) { + out, err := runCommand(t, "firewall", "remove-from-resource", "--type", "label_selector", strconv.FormatInt(firewallID, 10)) + require.EqualError(t, err, "type label_selector need a --label-selector specific") + assert.Empty(t, out) + }) + + t.Run("unknown-firewall", func(t *testing.T) { + out, err := runCommand(t, "firewall", "remove-from-resource", "--type", "server", "--server", "non-existing-server", "non-existing-firewall") + require.EqualError(t, err, "Firewall not found: non-existing-firewall") + assert.Empty(t, out) + }) + + t.Run("unknown-server", func(t *testing.T) { + out, err := runCommand(t, "firewall", "remove-from-resource", "--type", "server", "--server", "non-existing-server", strconv.FormatInt(firewallID, 10)) + require.EqualError(t, err, "Server not found: non-existing-server") + assert.Empty(t, out) + }) + + t.Run("label-selector", func(t *testing.T) { + out, err := runCommand(t, "firewall", "remove-from-resource", "--type", "label_selector", "--label-selector", "foo=bar", strconv.FormatInt(firewallID, 10)) + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Firewall %d removed from resource\n", firewallID), out) + }) + }) + + t.Run("delete-rule", func(t *testing.T) { + t.Run("unknown-firewall", func(t *testing.T) { + out, err := runCommand(t, "firewall", "delete-rule", "non-existing-firewall", "--direction", "in", "--source-ips", "10.0.0.0/24", "--protocol", "tcp", "--port", "9100") + require.EqualError(t, err, "Firewall not found: non-existing-firewall") + assert.Empty(t, out) + }) + + t.Run("missing-port", func(t *testing.T) { + out, err := runCommand(t, "firewall", "delete-rule", strconv.FormatInt(firewallID, 10), "--direction", "in", "--source-ips", "10.0.0.0/24", "--protocol", "tcp") + require.EqualError(t, err, "port is required (--port)") + assert.Empty(t, out) + }) + + t.Run("port-not-allowed", func(t *testing.T) { + out, err := runCommand(t, "firewall", "delete-rule", strconv.FormatInt(firewallID, 10), "--direction", "out", "--destination-ips", "192.168.1.0/24", "--protocol", "icmp", "--port", "12345") + require.EqualError(t, err, "port is not allowed for this protocol") + assert.Empty(t, out) + }) + + t.Run("tcp-in", func(t *testing.T) { + out, err := runCommand(t, "firewall", "delete-rule", strconv.FormatInt(firewallID, 10), "--direction", "in", "--source-ips", "10.0.0.0/24", "--protocol", "tcp", "--port", "9100", "--description", "Some random description") + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Firewall Rules for Firewall %d updated\n", firewallID), out) + }) + + t.Run("icmp-out", func(t *testing.T) { + out, err := runCommand(t, "firewall", "delete-rule", strconv.FormatInt(firewallID, 10), "--direction", "out", "--destination-ips", "192.168.1.0/24", "--protocol", "icmp") + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Firewall Rules for Firewall %d updated\n", firewallID), out) + }) + + t.Run("non-existing-rule", func(t *testing.T) { + out, err := runCommand(t, "firewall", "delete-rule", strconv.FormatInt(firewallID, 10), "--direction", "in", "--source-ips", "123.123.123.123/32", "--port", "1234", "--protocol", "tcp") + require.EqualError(t, err, fmt.Sprintf("the specified rule was not found in the ruleset of Firewall %d", firewallID)) + assert.Empty(t, out) + }) + }) + + t.Run("replace-rules", func(t *testing.T) { + t.Run("non-existing-firewall", func(t *testing.T) { + out, err := runCommand(t, "firewall", "replace-rules", "non-existing-firewall", "--rules-file", "rules_file.json") + require.EqualError(t, err, "Firewall not found: non-existing-firewall") + assert.Empty(t, out) + }) + + t.Run("normal", func(t *testing.T) { + out, err := runCommand(t, "firewall", "replace-rules", strconv.FormatInt(firewallID, 10), "--rules-file", "rules_file.json") + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Firewall Rules for Firewall %d updated\n", firewallID), out) + }) + }) + + t.Run("delete", func(t *testing.T) { + out, err := runCommand(t, "firewall", "delete", strconv.FormatInt(firewallID, 10)) + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("firewall %d deleted\n", firewallID), out) + }) +} + +func createFirewall(t *testing.T, name string, args ...string) (int64, error) { + t.Helper() + t.Cleanup(func() { + _, _ = client.Firewall.Delete(context.Background(), &hcloud.Firewall{Name: name}) + }) + + out, err := runCommand(t, append([]string{"firewall", "create", "--name", name}, args...)...) + if err != nil { + return 0, err + } + + if !assert.Regexp(t, `^Firewall [0-9]+ created\n$`, out) { + return 0, fmt.Errorf("invalid response: %s", out) + } + + id, err := strconv.ParseInt(out[9:len(out)-9], 10, 64) + if err != nil { + return 0, err + } + + t.Cleanup(func() { + _, _ = client.Firewall.Delete(context.Background(), &hcloud.Firewall{ID: id}) + }) + return id, nil +} diff --git a/test/e2e/floatingip_test.go b/test/e2e/floatingip_test.go new file mode 100644 index 00000000..26c710fc --- /dev/null +++ b/test/e2e/floatingip_test.go @@ -0,0 +1,316 @@ +//go:build e2e + +package e2e + +import ( + "context" + "fmt" + "net" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/swaggest/assertjson" + + "github.com/hetznercloud/hcloud-go/v2/hcloud" +) + +func TestFloatingIP(t *testing.T) { + t.Parallel() + + t.Run("ipv4", func(t *testing.T) { + floatingIPName := withSuffix("test-floating-ip") + _, err := createFloatingIP(t, floatingIPName, "") + require.EqualError(t, err, "type is required") + + _, err = createFloatingIP(t, floatingIPName, "ipv4") + require.EqualError(t, err, "one of --home-location or --server is required") + + _, err = createFloatingIP(t, floatingIPName, "ipv4", "--server", "non-existing-server") + require.EqualError(t, err, "server not found: non-existing-server") + + floatingIPId, err := createFloatingIP(t, floatingIPName, "ipv4", "--home-location", TestLocationName) + require.NoError(t, err) + + t.Run("labels", func(t *testing.T) { + t.Run("add-label-non-existing-floating-ip", func(t *testing.T) { + out, err := runCommand(t, "floating-ip", "add-label", "non-existing-floating-ip", "foo=bar") + require.EqualError(t, err, "floating IP not found: non-existing-floating-ip") + assert.Empty(t, out) + }) + + t.Run("add-label", func(t *testing.T) { + out, err := runCommand(t, "floating-ip", "add-label", strconv.FormatInt(floatingIPId, 10), "foo=bar") + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Label(s) foo added to Floating IP %d\n", floatingIPId), out) + }) + }) + + t.Run("update-name", func(t *testing.T) { + floatingIPName = withSuffix("new-test-floating-ip") + out, err := runCommand(t, "floating-ip", "update", strconv.FormatInt(floatingIPId, 10), "--name", floatingIPName, "--description", "Some description") + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Floating IP %d updated\n", floatingIPId), out) + }) + + t.Run("set-rnds", func(t *testing.T) { + out, err := runCommand(t, "floating-ip", "set-rdns", strconv.FormatInt(floatingIPId, 10), "--hostname", "s1.example.com") + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Reverse DNS of Floating IP %d changed\n", floatingIPId), out) + }) + + t.Run("unassign-non-existing", func(t *testing.T) { + out, err := runCommand(t, "floating-ip", "unassign", "non-existing-floating-ip") + require.EqualError(t, err, "Floating IP not found: non-existing-floating-ip") + assert.Empty(t, out) + }) + + t.Run("assign", func(t *testing.T) { + t.Run("non-existing-ip", func(t *testing.T) { + out, err := runCommand(t, "floating-ip", "assign", "non-existing-floating-ip", "non-existing-server") + require.EqualError(t, err, "Floating IP not found: non-existing-floating-ip") + assert.Empty(t, out) + }) + + t.Run("non-existing-server", func(t *testing.T) { + out, err := runCommand(t, "floating-ip", "assign", strconv.FormatInt(floatingIPId, 10), "non-existing-server") + require.EqualError(t, err, "server not found: non-existing-server") + assert.Empty(t, out) + }) + }) + + t.Run("enable-protection", func(t *testing.T) { + t.Run("unknown-protection-level", func(t *testing.T) { + out, err := runCommand(t, "floating-ip", "enable-protection", strconv.FormatInt(floatingIPId, 10), "unknown-protection-level") + require.EqualError(t, err, "unknown protection level: unknown-protection-level") + assert.Empty(t, out) + }) + + t.Run("non-existing-floating-ip", func(t *testing.T) { + out, err := runCommand(t, "floating-ip", "enable-protection", "non-existing-floating-ip", "delete") + require.EqualError(t, err, "Floating IP not found: non-existing-floating-ip") + assert.Empty(t, out) + }) + + t.Run("enable-delete-protection", func(t *testing.T) { + out, err := runCommand(t, "floating-ip", "enable-protection", strconv.FormatInt(floatingIPId, 10), "delete") + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Resource protection enabled for floating IP %d\n", floatingIPId), out) + }) + }) + + var ipStr string + + t.Run("describe", func(t *testing.T) { + t.Run("format", func(t *testing.T) { + var err error + ipStr, err = runCommand(t, "floating-ip", "describe", strconv.FormatInt(floatingIPId, 10), "--output", "format={{.IP}}") + require.NoError(t, err) + ipStr = strings.TrimSpace(ipStr) + assert.Regexp(t, `^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$`, ipStr) + }) + + t.Run("normal", func(t *testing.T) { + out, err := runCommand(t, "floating-ip", "describe", strconv.FormatInt(floatingIPId, 10)) + require.NoError(t, err) + assert.Regexp(t, `ID:\s+[0-9]+ +Type:\s+ipv4 +Name:\s+new-test-floating-ip-[0-9a-f]{8} +Description:\s+Some description +Created:.*? +IP:\s+(?:[0-9]{1,3}\.){3}[0-9]{1,3} +Blocked:\s+no +Home Location:\s+[a-z]{3}[0-9]* +Server: +\s+Not assigned +DNS: +\s+(?:[0-9]{1,3}\.){3}[0-9]{1,3}: s1\.example\.com +Protection: +\s+Delete:\s+yes +Labels: +\s+foo: bar +`, out) + }) + }) + + t.Run("list", func(t *testing.T) { + t.Run("table", func(t *testing.T) { + out, err := runCommand(t, "floating-ip", "list", "--output", "columns=id,name,type,ip,dns,server,home,blocked,protection,labels,created,age") + require.NoError(t, err) + assert.Regexp(t, `^ID +NAME +TYPE +IP +DNS +SERVER +HOME +BLOCKED +PROTECTION +LABELS +CREATED +AGE +[0-9]+ +new-test-floating-ip-[0-9a-f]{8} +ipv4 +(?:[0-9]{1,3}\.){3}[0-9]{1,3} +s1\.example\.com +- +[a-z]{3}[0-9]* +no +delete +foo=bar.*? +$`, out) + }) + + t.Run("json", func(t *testing.T) { + out, err := runCommand(t, "floating-ip", "list", "-o=json") + require.NoError(t, err) + assertjson.Equal(t, []byte(fmt.Sprintf(` +[ + { + "id": %d, + "description": "Some description", + "created": "", + "ip": "%s", + "type": "ipv4", + "server": null, + "dns_ptr": [ + { + "ip": "%s", + "dns_ptr": "s1.example.com" + } + ], + "home_location": { + "id": "", + "name": "%s", + "description": "", + "country": "DE", + "city": "", + "latitude": "", + "longitude": "", + "network_zone": "" + }, + "blocked": false, + "protection": { + "delete": true + }, + "labels": { + "foo": "bar" + }, + "name": "%s" + } +] +`, floatingIPId, ipStr, ipStr, TestLocationName, floatingIPName)), []byte(out)) + }) + }) + + t.Run("delete-protected", func(t *testing.T) { + out, err := runCommand(t, "floating-ip", "delete", strconv.FormatInt(floatingIPId, 10)) + assert.Regexp(t, `^Floating IP deletion is protected \(protected, [0-9a-f]+\)$`, err.Error()) + assert.Empty(t, out) + }) + + t.Run("disable-protection", func(t *testing.T) { + t.Run("non-existing-floating-ip", func(t *testing.T) { + out, err := runCommand(t, "floating-ip", "disable-protection", "non-existing-floating-ip", "delete") + require.EqualError(t, err, "Floating IP not found: non-existing-floating-ip") + assert.Empty(t, out) + }) + + t.Run("unknown-protection-level", func(t *testing.T) { + out, err := runCommand(t, "floating-ip", "disable-protection", strconv.FormatInt(floatingIPId, 10), "unknown-protection-level") + require.EqualError(t, err, "unknown protection level: unknown-protection-level") + assert.Empty(t, out) + }) + + t.Run("disable-delete-protection", func(t *testing.T) { + out, err := runCommand(t, "floating-ip", "disable-protection", strconv.FormatInt(floatingIPId, 10), "delete") + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Resource protection disabled for floating IP %d\n", floatingIPId), out) + }) + }) + + t.Run("delete", func(t *testing.T) { + out, err := runCommand(t, "floating-ip", "delete", strconv.FormatInt(floatingIPId, 10)) + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Floating IP %d deleted\n", floatingIPId), out) + }) + }) + + t.Run("ipv6", func(t *testing.T) { + floatingIPName := withSuffix("test-floating-ipv6") + floatingIPId, err := createFloatingIP(t, floatingIPName, "ipv6", "--home-location", TestLocationName) + require.NoError(t, err) + + var ipStr string + + t.Run("describe", func(t *testing.T) { + t.Run("format", func(t *testing.T) { + var err error + ipStr, err = runCommand(t, "floating-ip", "describe", strconv.FormatInt(floatingIPId, 10), "--output", "format={{.IP}}") + require.NoError(t, err) + ipStr = strings.TrimSpace(ipStr) + assert.NotNil(t, net.ParseIP(ipStr)) + }) + + t.Run("normal", func(t *testing.T) { + out, err := runCommand(t, "floating-ip", "describe", strconv.FormatInt(floatingIPId, 10)) + require.NoError(t, err) + assert.Regexp(t, `ID:\s+[0-9]+ +Type:\s+ipv6 +Name:\s+test-floating-ipv6-[0-9a-f]{8} +Description:\s+- +Created:.*? +IP:\s+[0-9a-f]+:[0-9a-f]+:[0-9a-f]+:[0-9a-f]+::\/64 +Blocked:\s+no +Home Location:\s+[a-z]{3}[0-9]* +Server: +\s+Not assigned +DNS: +\s+No reverse DNS entries +Protection: +\s+Delete:\s+no +Labels: +\s+No labels +`, out) + }) + }) + + t.Run("set-rdns", func(t *testing.T) { + t.Run("1", func(t *testing.T) { + out, err := runCommand(t, "floating-ip", "set-rdns", strconv.FormatInt(floatingIPId, 10), "--ip", ipStr+"1", "--hostname", "s1.example.com") + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Reverse DNS of Floating IP %d changed\n", floatingIPId), out) + }) + + t.Run("2", func(t *testing.T) { + out, err := runCommand(t, "floating-ip", "set-rdns", strconv.FormatInt(floatingIPId, 10), "--ip", ipStr+"2", "--hostname", "s2.example.com") + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Reverse DNS of Floating IP %d changed\n", floatingIPId), out) + }) + }) + + t.Run("list", func(t *testing.T) { + out, err := runCommand(t, "floating-ip", "list", "-o", "columns=ip,dns") + require.NoError(t, err) + assert.Regexp(t, fmt.Sprintf(`^IP +DNS +%s\/64 +2 entries +`, ipStr), out) + }) + + t.Run("delete", func(t *testing.T) { + out, err := runCommand(t, "floating-ip", "delete", strconv.FormatInt(floatingIPId, 10)) + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Floating IP %d deleted\n", floatingIPId), out) + }) + }) +} + +func createFloatingIP(t *testing.T, name, ipType string, args ...string) (int64, error) { + t.Helper() + t.Cleanup(func() { + _, _ = client.FloatingIP.Delete(context.Background(), &hcloud.FloatingIP{Name: name}) + }) + + out, err := runCommand(t, append([]string{"floating-ip", "create", "--name", name, "--type", ipType}, args...)...) + if err != nil { + return 0, err + } + + firstLine := strings.Split(out, "\n")[0] + if !assert.Regexp(t, `^Floating IP [0-9]+ created$`, firstLine) { + return 0, fmt.Errorf("invalid response: %s", out) + } + + id, err := strconv.ParseInt(out[12:len(firstLine)-8], 10, 64) + if err != nil { + return 0, err + } + + t.Cleanup(func() { + _, _ = client.FloatingIP.Delete(context.Background(), &hcloud.FloatingIP{ID: id}) + }) + return id, nil +} diff --git a/test/e2e/network_test.go b/test/e2e/network_test.go new file mode 100644 index 00000000..ceed5e74 --- /dev/null +++ b/test/e2e/network_test.go @@ -0,0 +1,332 @@ +//go:build e2e + +package e2e + +import ( + "context" + "fmt" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/swaggest/assertjson" + + "github.com/hetznercloud/hcloud-go/v2/hcloud" +) + +func TestNetwork(t *testing.T) { + t.Parallel() + + out, err := runCommand(t, "network", "create") + assert.Empty(t, out) + require.EqualError(t, err, `required flag(s) "ip-range", "name" not set`) + + networkName := withSuffix("test-network") + networkID, err := createNetwork(t, networkName, "--ip-range", "10.0.0.0/24") + require.NoError(t, err) + + _, err = createNetwork(t, networkName, "--ip-range", "10.0.1.0/24") + require.Error(t, err) + // TODO: API currently returns service_error instead of uniqueness_error. Add this back in once this is fixed. + // assert.Regexp(t, `^name is already used \(uniqueness_error, [0-9a-f]+\)$`, err.Error()) + + t.Run("enable-protection", func(t *testing.T) { + t.Run("non-existing-protection", func(t *testing.T) { + out, err := runCommand(t, "network", "enable-protection", strconv.FormatInt(networkID, 10), "non-existing-protection") + require.EqualError(t, err, "unknown protection level: non-existing-protection") + assert.Empty(t, out) + }) + + t.Run("non-existing-network", func(t *testing.T) { + out, err := runCommand(t, "network", "enable-protection", "non-existing-network", "delete") + require.EqualError(t, err, "network not found: non-existing-network") + assert.Empty(t, out) + }) + + t.Run("delete", func(t *testing.T) { + out, err := runCommand(t, "network", "enable-protection", strconv.FormatInt(networkID, 10), "delete") + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Resource protection enabled for network %d\n", networkID), out) + }) + }) + + t.Run("list", func(t *testing.T) { + out, err := runCommand(t, "network", "list", "-o=columns=servers,ip_range,labels,protection,created,age") + require.NoError(t, err) + assert.Regexp(t, `SERVERS +IP RANGE +LABELS +PROTECTION +CREATED +AGE +0 servers +10\.0\.0\.0/24 +delete .*? (?:just now|[0-9]+s) +`, out) + }) + + t.Run("change-ip-range", func(t *testing.T) { + t.Run("non-existing-network", func(t *testing.T) { + out, err := runCommand(t, "network", "change-ip-range", "--ip-range", "10.0.2.0/16", "non-existing-network") + require.EqualError(t, err, "network not found: non-existing-network") + assert.Empty(t, out) + }) + + t.Run("normal", func(t *testing.T) { + out, err := runCommand(t, "network", "change-ip-range", "--ip-range", "10.0.2.0/16", networkName) + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("IP range of network %d changed\n", networkID), out) + }) + }) + + t.Run("labels", func(t *testing.T) { + t.Run("add", func(t *testing.T) { + t.Run("non-existing-network", func(t *testing.T) { + out, err := runCommand(t, "network", "add-label", "non-existing-network", "foo=bar") + require.EqualError(t, err, "network not found: non-existing-network") + assert.Empty(t, out) + }) + + t.Run("1", func(t *testing.T) { + out, err := runCommand(t, "network", "add-label", networkName, "foo=bar") + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Label(s) foo added to Network %d\n", networkID), out) + }) + + t.Run("2", func(t *testing.T) { + out, err := runCommand(t, "network", "add-label", networkName, "baz=qux") + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Label(s) baz added to Network %d\n", networkID), out) + }) + }) + + t.Run("remove", func(t *testing.T) { + out, err := runCommand(t, "network", "remove-label", networkName, "baz") + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Label(s) baz removed from Network %d\n", networkID), out) + }) + }) + + oldNetworkName := networkName + networkName = withSuffix("new-test-network") + + t.Run("update-name", func(t *testing.T) { + out, err := runCommand(t, "network", "update", oldNetworkName, "--name", networkName) + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Network %s updated\n", oldNetworkName), out) + }) + + t.Run("delete-protected", func(t *testing.T) { + out, err := runCommand(t, "network", "delete", strconv.FormatInt(networkID, 10)) + assert.Empty(t, out) + assert.Regexp(t, `^network is delete protected \(protected, [0-9a-f]+\)$`, err.Error()) + }) + + t.Run("add-subnet", func(t *testing.T) { + t.Run("non-existing-network", func(t *testing.T) { + out, err := runCommand(t, "network", "add-subnet", "--type", "cloud", "--network-zone", "eu-central", "--ip-range", "10.0.16.0/24", "non-existing-network") + require.EqualError(t, err, "network not found: non-existing-network") + assert.Empty(t, out) + }) + + t.Run("non-existing-vswitch", func(t *testing.T) { + out, err := runCommand(t, "network", "add-subnet", "--type", "vswitch", "--vswitch-id", "42", "--network-zone", "eu-central", "--ip-range", "10.0.17.0/24", strconv.FormatInt(networkID, 10)) + assert.Empty(t, out) + assert.Regexp(t, `^vswitch not found \(service_error, [0-9a-f]+\)$`, err.Error()) + }) + + t.Run("normal", func(t *testing.T) { + out, err := runCommand(t, "network", "add-subnet", "--type", "cloud", "--network-zone", "eu-central", "--ip-range", "10.0.16.0/24", strconv.FormatInt(networkID, 10)) + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Subnet added to network %d\n", networkID), out) + }) + }) + + t.Run("add-route", func(t *testing.T) { + t.Run("non-existing-network", func(t *testing.T) { + out, err := runCommand(t, "network", "add-route", "--destination", "10.100.1.0/24", "--gateway", "10.0.1.1", "non-existing-network") + require.EqualError(t, err, "network not found: non-existing-network") + assert.Empty(t, out) + + }) + + t.Run("normal", func(t *testing.T) { + out, err := runCommand(t, "network", "add-route", "--destination", "10.100.1.0/24", "--gateway", "10.0.1.1", strconv.FormatInt(networkID, 10)) + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Route added to network %d\n", networkID), out) + }) + }) + + t.Run("expose-routes-to-vswitch", func(t *testing.T) { + t.Run("non-existing-network", func(t *testing.T) { + out, err := runCommand(t, "network", "expose-routes-to-vswitch", "non-existing-network") + require.EqualError(t, err, "network not found: non-existing-network") + assert.Empty(t, out) + }) + + t.Run("normal", func(t *testing.T) { + out, err := runCommand(t, "network", "expose-routes-to-vswitch", strconv.FormatInt(networkID, 10)) + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Exposing routes to connected vSwitch of network %s enabled\n", networkName), out) + }) + }) + + t.Run("describe", func(t *testing.T) { + out, err := runCommand(t, "network", "describe", strconv.FormatInt(networkID, 10)) + require.NoError(t, err) + assert.Regexp(t, `^ID:\s+[0-9]+ +Name:\s+new-test-network-[0-9a-f]{8} +Created:\s+.*? +IP Range:\s+10\.0\.0\.0\/16 +Expose Routes to vSwitch: yes +Subnets: +\s+- Type:\s+cloud +\s+Network Zone:\s+eu-central +\s+IP Range:\s+10\.0\.16\.0\/24 +\s+Gateway:\s+10\.0\.0\.1 +Routes: +\s+- Destination:\s+10\.100\.1\.0\/24 +\s+Gateway:\s+10\.0\.1\.1 +Protection: +\s+Delete:\s+yes +Labels: +\s+foo: bar +$`, out) + }) + + t.Run("list", func(t *testing.T) { + out, err := runCommand(t, "network", "list", "-o=json") + require.NoError(t, err) + assertjson.Equal(t, []byte(fmt.Sprintf(` +[ + { + "id": %d, + "name": "%s", + "created": "", + "ip_range": "10.0.0.0/16", + "subnets": [ + { + "type": "cloud", + "ip_range": "10.0.16.0/24", + "network_zone": "eu-central", + "gateway": "10.0.0.1" + } + ], + "routes": [ + { + "destination": "10.100.1.0/24", + "gateway": "10.0.1.1" + } + ], + "servers": [], + "protection": { + "delete": true + }, + "labels": { + "foo": "bar" + }, + "expose_routes_to_vswitch": true + } +] +`, networkID, networkName)), []byte(out)) + }) + + t.Run("remove-route", func(t *testing.T) { + t.Run("non-existing-network", func(t *testing.T) { + out, err := runCommand(t, "network", "remove-route", "--destination", "10.100.1.0/24", "--gateway", "10.0.1.1", "non-existing-network") + require.EqualError(t, err, "network not found: non-existing-network") + assert.Empty(t, out) + }) + + t.Run("normal", func(t *testing.T) { + out, err := runCommand(t, "network", "remove-route", "--destination", "10.100.1.0/24", "--gateway", "10.0.1.1", strconv.FormatInt(networkID, 10)) + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Route removed from network %d\n", networkID), out) + }) + }) + + t.Run("remove-subnet", func(t *testing.T) { + t.Run("non-existing-network", func(t *testing.T) { + out, err := runCommand(t, "network", "remove-subnet", "--ip-range", "10.0.16.0/24", "non-existing-network") + require.EqualError(t, err, "network not found: non-existing-network") + assert.Empty(t, out) + }) + + t.Run("normal", func(t *testing.T) { + out, err := runCommand(t, "network", "remove-subnet", "--ip-range", "10.0.16.0/24", strconv.FormatInt(networkID, 10)) + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Subnet 10.0.16.0/24 removed from network %d\n", networkID), out) + }) + }) + + t.Run("disable-protection", func(t *testing.T) { + t.Run("non-existing-network", func(t *testing.T) { + out, err := runCommand(t, "network", "disable-protection", "non-existing-network", "delete") + require.EqualError(t, err, "network not found: non-existing-network") + assert.Empty(t, out) + }) + + t.Run("normal", func(t *testing.T) { + out, err := runCommand(t, "network", "disable-protection", strconv.FormatInt(networkID, 10), "delete") + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Resource protection disabled for network %d\n", networkID), out) + }) + }) + + t.Run("remove-label", func(t *testing.T) { + out, err := runCommand(t, "network", "remove-label", strconv.FormatInt(networkID, 10), "foo") + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Label(s) foo removed from Network %d\n", networkID), out) + }) + + t.Run("disable-expose-routes-to-vswitch", func(t *testing.T) { + out, err := runCommand(t, "network", "expose-routes-to-vswitch", "--disable", strconv.FormatInt(networkID, 10)) + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Exposing routes to connected vSwitch of network %s disabled\n", networkName), out) + }) + + t.Run("describe", func(t *testing.T) { + out, err := runCommand(t, "network", "describe", strconv.FormatInt(networkID, 10)) + require.NoError(t, err) + assert.Regexp(t, `^ID:\s+[0-9]+ +Name:\s+new-test-network-[0-9a-f]{8} +Created:\s+.*? +IP Range:\s+10\.0\.0\.0\/16 +Expose Routes to vSwitch: no +Subnets: +\s+No subnets +Routes: +\s+No routes +Protection: +\s+Delete:\s+no +Labels: +\s+No labels +$`, out) + }) + + t.Run("delete", func(t *testing.T) { + out, err := runCommand(t, "network", "delete", strconv.FormatInt(networkID, 10)) + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Network %d deleted\n", networkID), out) + }) +} + +func createNetwork(t *testing.T, name string, args ...string) (int64, error) { + t.Helper() + t.Cleanup(func() { + _, _ = client.Network.Delete(context.Background(), &hcloud.Network{Name: name}) + }) + + out, err := runCommand(t, append([]string{"network", "create", "--name", name}, args...)...) + if err != nil { + return 0, err + } + + if !assert.Regexp(t, `^Network [0-9]+ created\n$`, out) { + return 0, fmt.Errorf("invalid response: %s", out) + } + + id, err := strconv.ParseInt(out[8:len(out)-9], 10, 64) + if err != nil { + return 0, err + } + + t.Cleanup(func() { + _, _ = client.Network.Delete(context.Background(), &hcloud.Network{ID: id}) + }) + return id, nil +} diff --git a/test/e2e/placement_group_test.go b/test/e2e/placement_group_test.go new file mode 100644 index 00000000..4225ac55 --- /dev/null +++ b/test/e2e/placement_group_test.go @@ -0,0 +1,138 @@ +//go:build e2e + +package e2e + +import ( + "context" + "fmt" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/swaggest/assertjson" + + "github.com/hetznercloud/hcloud-go/v2/hcloud" +) + +func TestPlacementGroup(t *testing.T) { + t.Parallel() + + out, err := runCommand(t, "placement-group", "create") + assert.Empty(t, out) + require.EqualError(t, err, `required flag(s) "name", "type" not set`) + + pgName := withSuffix("test-placement-group") + pgID, err := createPlacementGroup(t, pgName, "--type", "spread") + require.NoError(t, err) + + t.Run("add-label", func(t *testing.T) { + t.Run("non-existing-placement-group", func(t *testing.T) { + out, err = runCommand(t, "placement-group", "add-label", "non-existing-placement-group", "foo=bar") + require.EqualError(t, err, "placement group not found: non-existing-placement-group") + assert.Empty(t, out) + }) + + t.Run("1", func(t *testing.T) { + out, err = runCommand(t, "placement-group", "add-label", pgName, "foo=bar") + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Label(s) foo added to placement group %d\n", pgID), out) + }) + + t.Run("2", func(t *testing.T) { + out, err = runCommand(t, "placement-group", "add-label", pgName, "baz=qux") + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Label(s) baz added to placement group %d\n", pgID), out) + }) + }) + + oldPgName := pgName + pgName = withSuffix("new-test-placement-group") + + t.Run("update-name", func(t *testing.T) { + out, err := runCommand(t, "placement-group", "update", oldPgName, "--name", pgName) + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("placement group %s updated\n", oldPgName), out) + }) + + t.Run("list", func(t *testing.T) { + t.Run("table", func(t *testing.T) { + out, err := runCommand(t, "placement-group", "list", "-o=columns=id,name,servers,type,created,age") + require.NoError(t, err) + assert.Regexp(t, `ID +NAME +SERVERS +TYPE +CREATED +AGE +[0-9]+ +new-test-placement-group-[0-9a-f]{8} +0 servers +spread .*? (?:just now|[0-9]+s) +`, out) + }) + + t.Run("json", func(t *testing.T) { + out, err := runCommand(t, "placement-group", "list", "-o=json") + require.NoError(t, err) + assertjson.Equal(t, []byte(fmt.Sprintf(` +[ + { + "id": %d, + "name": "%s", + "labels": { + "baz": "qux", + "foo": "bar" + }, + "created": "", + "servers": [], + "type": "spread" + } +]`, pgID, pgName)), []byte(out)) + }) + }) + + t.Run("describe", func(t *testing.T) { + out, err := runCommand(t, "placement-group", "describe", strconv.FormatInt(pgID, 10)) + require.NoError(t, err) + assert.Regexp(t, `^ID:\s+[0-9]+ +Name:\s+new-test-placement-group-[0-9a-f]{8} +Created:\s+.*? +Labels: +\s+(baz: qux|foo: bar) +\s+(baz: qux|foo: bar) +Servers: +Type:\s+spread +$`, out) + }) + + t.Run("remove-label", func(t *testing.T) { + out, err := runCommand(t, "placement-group", "remove-label", pgName, "baz") + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Label(s) baz removed from placement group %d\n", pgID), out) + }) + + t.Run("delete", func(t *testing.T) { + out, err := runCommand(t, "placement-group", "delete", strconv.FormatInt(pgID, 10)) + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("placement group %d deleted\n", pgID), out) + }) +} + +func createPlacementGroup(t *testing.T, name string, args ...string) (int64, error) { + t.Helper() + t.Cleanup(func() { + _, _ = client.PlacementGroup.Delete(context.Background(), &hcloud.PlacementGroup{Name: name}) + }) + + out, err := runCommand(t, append([]string{"placement-group", "create", "--name", name}, args...)...) + if err != nil { + return 0, err + } + + if !assert.Regexp(t, `^Placement group [0-9]+ created\n$`, out) { + return 0, fmt.Errorf("invalid response: %s", out) + } + + id, err := strconv.ParseInt(out[16:len(out)-9], 10, 64) + if err != nil { + return 0, err + } + + t.Cleanup(func() { + _, _ = client.PlacementGroup.Delete(context.Background(), &hcloud.PlacementGroup{ID: id}) + }) + return id, nil +} diff --git a/test/e2e/rules_file.json b/test/e2e/rules_file.json new file mode 100644 index 00000000..3b9e3829 --- /dev/null +++ b/test/e2e/rules_file.json @@ -0,0 +1,34 @@ +[ + { + "description": "Allow port 80", + "direction": "in", + "port": "80", + "protocol": "tcp", + "source_ips": [ + "28.239.13.1/32", + "28.239.14.0/24", + "ff21:1eac:9a3b:ee58:5ca:990c:8bc9:c03b/128" + ] + }, + { + "description": "Allow port 443", + "direction": "in", + "port": "443", + "protocol": "tcp", + "source_ips": [ + "0.0.0.0/0", + "::/0" + ] + }, + { + "direction": "out", + "source_ips": [], + "destination_ips": [ + "28.239.13.1/32", + "28.239.14.0/24", + "ff21:1eac:9a3b:ee58:5ca:990c:8bc9:c03b/128" + ], + "protocol": "tcp", + "port": "80" + } +] diff --git a/test/e2e/server_test.go b/test/e2e/server_test.go new file mode 100644 index 00000000..ff4ea3ee --- /dev/null +++ b/test/e2e/server_test.go @@ -0,0 +1,42 @@ +//go:build e2e + +package e2e + +import ( + "context" + "fmt" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/hetznercloud/hcloud-go/v2/hcloud" +) + +func createServer(t *testing.T, name, serverType, image string, args ...string) (int64, error) { + t.Helper() + t.Cleanup(func() { + _, _, _ = client.Server.DeleteWithResult(context.Background(), &hcloud.Server{Name: name}) + }) + + out, err := runCommand(t, append([]string{"server", "create", "--name", name, "--type", serverType, "--image", image}, args...)...) + if err != nil { + return 0, err + } + + firstLine := strings.Split(out, "\n")[0] + if !assert.Regexp(t, `^Server [0-9]+ created$`, firstLine) { + return 0, fmt.Errorf("invalid response: %s", out) + } + + id, err := strconv.ParseInt(out[7:len(firstLine)-8], 10, 64) + if err != nil { + return 0, err + } + + t.Cleanup(func() { + _, _, _ = client.Server.DeleteWithResult(context.Background(), &hcloud.Server{ID: int64(id)}) + }) + return id, nil +} diff --git a/test/e2e/variables.go b/test/e2e/variables.go new file mode 100644 index 00000000..193cbfb2 --- /dev/null +++ b/test/e2e/variables.go @@ -0,0 +1,45 @@ +//go:build e2e + +package e2e + +import ( + "os" + + "github.com/hetznercloud/hcloud-go/v2/hcloud" +) + +var ( + // TestImage is the system image that is used in end-to-end tests. + TestImage = getEnv("TEST_IMAGE", "ubuntu-24.04") + + // TestImageID is the system image ID that is used in end-to-end tests. + TestImageID = getEnv("TEST_IMAGE_ID", "161547269") + + // TestServerType is the default server type used in end-to-end tests. + TestServerType = getEnv("TEST_SERVER_TYPE", "cpx11") + + // TestServerTypeUpgrade is the upgrade server type used in end-to-end tests. + TestServerTypeUpgrade = getEnv("TEST_SERVER_TYPE_UPGRADE", "cpx21") + + // TestArchitecture is the default architecture used in end-to-end tests, should match the architecture of the TestServerType. + TestArchitecture = getEnv("TEST_ARCHITECTURE", string(hcloud.ArchitectureX86)) + + // TestLoadBalancerType is the default Load Balancer type used in end-to-end tests. + TestLoadBalancerType = "lb11" + + // TestDatacenterName is the default datacenter name where we execute our end-to-end tests. + TestDatacenterName = getEnv("TEST_DATACENTER_NAME", "nbg1-dc3") + + // TestDatacenterID is the default datacenter ID where we execute our end-to-end tests (Must be the ID of TestDatacenterName) + TestDatacenterID = getEnv("TEST_DATACENTER_ID", "2") + + // TestLocationName is the default location where we execute our end-to-end tests. + TestLocationName = getEnv("TEST_LOCATION", "nbg1") +) + +func getEnv(key, fallback string) string { + if value, ok := os.LookupEnv(key); ok { + return value + } + return fallback +}