;;; GNU Guix --- Functional package management for GNU ;;; Copyright © 2024 Roman Scherer <roman@burningswell.com> ;;; ;;; This file is part of GNU Guix. ;;; ;;; GNU Guix is free software; you can redistribute it and/or modify it ;;; under the terms of the GNU General Public License as published by ;;; the Free Software Foundation; either version 3 of the License, or (at ;;; your option) any later version. ;;; ;;; GNU Guix is distributed in the hope that it will be useful, but ;;; WITHOUT ANY WARRANTY; without even the implied warranty of ;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ;;; GNU General Public License for more details. ;;; ;;; You should have received a copy of the GNU General Public License ;;; along with GNU Guix. If not, see <http://www.gnu.org/licenses/>. (define-module (tests machine hetzner http) #:use-module (debugging assert) #:use-module (gnu machine hetzner http) #:use-module (guix build utils) #:use-module (guix tests) #:use-module (srfi srfi-1) #:use-module (srfi srfi-34) #:use-module (srfi srfi-64) #:use-module (ssh key)) ;; Unit and integration tests the (gnu machine hetzner http) module. ;; Integration tests require the GUIX_HETZNER_API_TOKEN environment variable. ;; https://docs.hetzner.com/cloud/api/getting-started/generating-api-token ;; The integration tests sometimes fail due to the Hetzner API not being able ;; to allocate a resource. Switching to a different location might help. (define %labels '(("guix.gnu.org/test" . "true"))) (define %server-name "guix-hetzner-api-test-server") (define %ssh-key-name "guix-hetzner-api-test-key") (define %ssh-key-file (string-append "/tmp/" %ssh-key-name)) (unless (file-exists? %ssh-key-file) (private-key-to-file (make-keypair 'rsa 2048) %ssh-key-file)) (define %ssh-key (hetzner-ssh-key-read-file %ssh-key-file)) (define %when-no-token (if (hetzner-api-token (hetzner-api)) 0 1)) (define action-create-server (make-hetzner-action "create_server" #f *unspecified* 1896091819 0 (list (make-hetzner-resource 59570198 "server")) #(0 17 11 2 1 125 0 32 -1 0 #f) "running")) (define action-create-server-alist '(("command" . "create_server") ("error" . null) ("finished" . null) ("id" . 1896091819) ("progress" . 0) ("resources" . #((("type" . "server") ("id" . 59570198)))) ("started" . "2025-02-02T11:17:00+00:00") ("status" . "running"))) (define action-delete-server (make-hetzner-action "delete_server" #f *unspecified* 1896091928 0 (list (make-hetzner-resource 59570198 "server")) #(10 17 11 2 1 125 0 32 -1 0 #f) "running")) (define action-delete-server-alist '(("command" . "delete_server") ("error" . null) ("finished" . null) ("id" . 1896091928) ("progress" . 0) ("resources" . #((("type" . "server") ("id" . 59570198)))) ("started" . "2025-02-02T11:17:10+00:00") ("status" . "running"))) (define action-enable-rescue (make-hetzner-action "enable_rescue" #f *unspecified* 1896091721 0 (list (make-hetzner-resource 59570198 "server")) #(10 17 11 2 1 125 0 32 -1 0 #f) "success")) (define action-enable-rescue-alist '(("command" . "enable_rescue") ("error" . null) ("finished" . null) ("id" . 1896091721) ("progress" . 0) ("resources" . #((("type" . "server") ("id" . 59570198)))) ("started" . "2025-02-02T11:17:10+00:00") ("status" . "running"))) (define action-power-off (make-hetzner-action "stop_server" #f *unspecified* 1896091721 0 (list (make-hetzner-resource 59570198 "server")) #(10 17 11 2 1 125 0 32 -1 0 #f) "success")) (define action-power-off-alist '(("command" . "stop_server") ("error" . null) ("finished" . null) ("id" . 1896091721) ("progress" . 0) ("resources" . #((("type" . "server") ("id" . 59570198)))) ("started" . "2025-02-02T11:17:10+00:00") ("status" . "running"))) (define action-power-on (make-hetzner-action "start_server" #f *unspecified* 1896091721 0 (list (make-hetzner-resource 59570198 "server")) #(10 17 11 2 1 125 0 32 -1 0 #f) "success")) (define action-power-on-alist '(("command" . "start_server") ("error" . null) ("finished" . null) ("id" . 1896091721) ("progress" . 0) ("resources" . #((("type" . "server") ("id" . 59570198)))) ("started" . "2025-02-02T11:17:10+00:00") ("status" . "running"))) (define action-reboot (make-hetzner-action "reboot_server" #f *unspecified* 1896091721 0 (list (make-hetzner-resource 59570198 "server")) #(10 17 11 2 1 125 0 32 -1 0 #f) "success")) (define action-reboot-alist '(("command" . "reboot_server") ("error" . null) ("finished" . null) ("id" . 1896091721) ("progress" . 0) ("resources" . #((("type" . "server") ("id" . 59570198)))) ("started" . "2025-02-02T11:17:10+00:00") ("status" . "running"))) (define meta-page-alist '("pagination" ("last_page" . 1) ("next_page" . null) ("page" . 1) ("per_page" . 25) ("previous_page" . null) ("total_entries" . 1))) (define location-falkenstein (make-hetzner-location "Falkenstein" "DE" "Falkenstein DC Park 1" 1 50.47612 12.370071 "fsn1" "eu-central")) (define location-falkenstein-alist `(("city" . "Falkenstein") ("country" . "DE") ("description" . "Falkenstein DC Park 1") ("id" . 1) ("latitude" . 50.47612) ("longitude" . 12.370071) ("name" . "fsn1") ("network_zone" . "eu-central"))) (define server-type-cpx-11 (make-hetzner-server-type "x86" 2 "shared" #f *unspecified* "CPX 11" 40 22 2 "cpx11" "local")) (define server-type-cpx-11-alist `(("architecture" . "x86") ("cores" . 2) ("cpu_type" . "shared") ("deprecated" . #f) ("deprecation" . null) ("description" . "CPX 11") ("disk" . 40) ("id" . 22) ("memory" . 2) ("name" . "cpx11") ("storage_type" . "local"))) (define server-x86 (make-hetzner-server "2024-12-30T16:38:11+00:00" 59570198 '() "guix-x86" (make-hetzner-public-net (make-hetzner-ipv4 #f "static.218.128.13.49.clients.your-server.de" 78014457 "49.13.128.218") (make-hetzner-ipv6 #f '() 78014458 "2a01:4f8:c17:293e::/64")) #f server-type-cpx-11)) (define server-x86-alist `(("backup_window" . null) ("created" . "2024-12-30T16:38:11+00:00") ("id" . 59570198) ("included_traffic" . 21990232555520) ("ingoing_traffic" . 124530000) ("iso" . null) ("labels") ("load_balancers" . #()) ("locked" . #f) ("name" . "guix-x86") ("outgoing_traffic" . 1391250000) ("placement_group" . null) ("primary_disk_size" . 320) ("private_net" . #()) ("protection" ("rebuild" . #f) ("delete" . #f)) ("public_net" ("firewalls" . #()) ("floating_ips" . #()) ("ipv6" ("id" . 78014458) ("dns_ptr" . #()) ("blocked" . #f) ("ip" . "2a01:4f8:c17:293e::/64")) ("ipv4" ("id" . 78014457) ("dns_ptr" . "static.218.128.13.49.clients.your-server.de") ("blocked" . #f) ("ip" . "49.13.128.218"))) ("rescue_enabled" . #f) ("server_type" ,@server-type-cpx-11-alist) ("status" . "running") ("volumes" . #()))) (define ssh-key-root (make-hetzner-ssh-key #(55 2 19 28 9 123 6 300 -1 0 #f) "8c:25:09:8f:37:0f:d8:f0:99:4e:ab:c7:5c:1b:c6:53" 16510983 '() "root@example.com" "ssh-ed25519 ABCAC3NzaC1lZDI1NTE5AAAAIBT3lLYPfOZV9NNrNk0jGCufWmXbFSz+ORxowJdHoSIM")) (define ssh-key-root-alist `(("created" . "2023-10-28T19:02:55+00:00") ("fingerprint" . "8c:25:09:8f:37:0f:d8:f0:99:4e:ab:c7:5c:1b:c6:53") ("id" . 16510983) ("labels") ("name" . "root@example.com") ("public_key" . "ssh-ed25519 ABCAC3NzaC1lZDI1NTE5AAAAIBT3lLYPfOZV9NNrNk0jGCufWmXbFSz+ORxowJdHoSIM"))) (define* (create-ssh-key api ssh-key #:key (labels %labels)) (hetzner-api-ssh-key-create api (hetzner-ssh-key-name ssh-key) (hetzner-ssh-key-public-key ssh-key) #:labels labels)) (define* (create-server api ssh-key #:key (labels %labels)) (hetzner-api-server-create api %server-name (list ssh-key) #:labels labels #:server-type "cpx31")) (define (cleanup api) (for-each (lambda (server) (hetzner-api-server-delete api server)) (hetzner-api-servers api #:params `(("label_selector" . "guix.gnu.org/test=true")))) (for-each (lambda (ssh-key) (hetzner-api-ssh-key-delete api ssh-key)) (hetzner-api-ssh-keys api #:params `(("label_selector" . "guix.gnu.org/test=true")))) api) (define-syntax-rule (with-cleanup-api (api-sym api-init) body ...) (let ((api-sym (cleanup api-init))) (dynamic-wind (const #t) (lambda () body ...) (lambda () (cleanup api-sym))))) (test-begin "machine-hetzner-api") ;; Unit Tests (test-equal "hetzner-api-actions-unit" (list action-create-server action-delete-server) (let ((actions (list action-create-server-alist action-delete-server-alist))) (mock ((gnu machine hetzner http) hetzner-api-request-send (lambda* (request #:key expected) (assert (equal? 'GET (hetzner-api-request-method request))) (assert (equal? "https://api.hetzner.cloud/v1/actions" (hetzner-api-request-url request))) (assert (unspecified? (hetzner-api-request-body request))) (assert (equal? `(("page" . 1) ("id" . ,(string-join (map (lambda (action) (number->string (assoc-ref action "id"))) actions) ","))) (hetzner-api-request-params request))) (hetzner-api-response (body `(("meta" . ,meta-page-alist) ("actions" . #(,action-create-server-alist ,action-delete-server-alist))))))) (hetzner-api-actions (hetzner-api) (map (lambda (action) (assoc-ref action "id")) actions))))) (test-equal "hetzner-api-locations-unit" (list location-falkenstein) (mock ((gnu machine hetzner http) hetzner-api-request-send (lambda* (request #:key expected) (assert (equal? 'GET (hetzner-api-request-method request))) (assert (equal? "https://api.hetzner.cloud/v1/locations" (hetzner-api-request-url request))) (assert (unspecified? (hetzner-api-request-body request))) (assert (equal? '(("page" . 1)) (hetzner-api-request-params request))) (hetzner-api-response (body `(("meta" . ,meta-page-alist) ("locations" . #(,location-falkenstein-alist))))))) (hetzner-api-locations (hetzner-api)))) (test-equal "hetzner-api-server-types-unit" (list server-type-cpx-11) (mock ((gnu machine hetzner http) hetzner-api-request-send (lambda* (request #:key expected) (assert (equal? 'GET (hetzner-api-request-method request))) (assert (equal? "https://api.hetzner.cloud/v1/server_types" (hetzner-api-request-url request))) (assert (unspecified? (hetzner-api-request-body request))) (assert (equal? '(("page" . 1)) (hetzner-api-request-params request))) (hetzner-api-response (body `(("meta" . ,meta-page-alist) ("server_types" . #(,server-type-cpx-11-alist))))))) (hetzner-api-server-types (hetzner-api)))) (test-equal "hetzner-api-server-create-unit" server-x86 (mock ((gnu machine hetzner http) hetzner-api-request-send (lambda* (request #:key expected) (cond ((equal? "https://api.hetzner.cloud/v1/servers" (hetzner-api-request-url request)) (assert (equal? 'POST (hetzner-api-request-method request))) (hetzner-api-response (body `(("action" . ,action-create-server-alist) ("server" . ,server-x86-alist))))) ((equal? "https://api.hetzner.cloud/v1/actions" (hetzner-api-request-url request)) (assert (equal? 'GET (hetzner-api-request-method request))) (hetzner-api-response (body `(("actions" . ,(vector (cons `("status" . "success") action-create-server-alist))) ("meta" . ,meta-page-alist)))))))) (hetzner-api-server-create (hetzner-api) %server-name (list ssh-key-root)))) (test-equal "hetzner-api-server-delete-unit" (make-hetzner-action "delete_server" #f *unspecified* 1896091928 0 (list (make-hetzner-resource 59570198 "server")) #(10 17 11 2 1 125 0 32 -1 0 #f) "success") (mock ((gnu machine hetzner http) hetzner-api-request-send (lambda* (request #:key expected) (cond ((equal? "https://api.hetzner.cloud/v1/servers/59570198" (hetzner-api-request-url request)) (assert (equal? 'DELETE (hetzner-api-request-method request))) (hetzner-api-response (body `(("action" . ,action-delete-server-alist))))) ((equal? "https://api.hetzner.cloud/v1/actions" (hetzner-api-request-url request)) (assert (equal? 'GET (hetzner-api-request-method request))) (hetzner-api-response (body `(("actions" . ,(vector (cons `("status" . "success") action-delete-server-alist))) ("meta" . ,meta-page-alist)))))))) (hetzner-api-server-delete (hetzner-api) server-x86))) (test-equal "hetzner-api-server-enable-rescue-system-unit" action-enable-rescue (mock ((gnu machine hetzner http) hetzner-api-request-send (lambda* (request #:key expected) (cond ((equal? "https://api.hetzner.cloud/v1/servers/59570198/actions/enable_rescue" (hetzner-api-request-url request)) (assert (equal? 'POST (hetzner-api-request-method request))) (hetzner-api-response (body `(("action" . ,action-enable-rescue-alist))))) ((equal? "https://api.hetzner.cloud/v1/actions" (hetzner-api-request-url request)) (assert (equal? 'GET (hetzner-api-request-method request))) (hetzner-api-response (body `(("actions" . ,(vector (cons `("status" . "success") action-enable-rescue-alist))) ("meta" . ,meta-page-alist)))))))) (hetzner-api-server-enable-rescue-system (hetzner-api) server-x86 (list ssh-key-root)))) (test-equal "hetzner-api-server-power-on-unit" action-power-on (mock ((gnu machine hetzner http) hetzner-api-request-send (lambda* (request #:key expected) (cond ((equal? "https://api.hetzner.cloud/v1/servers/59570198/actions/poweron" (hetzner-api-request-url request)) (assert (equal? 'POST (hetzner-api-request-method request))) (hetzner-api-response (body `(("action" . ,action-power-on-alist))))) ((equal? "https://api.hetzner.cloud/v1/actions" (hetzner-api-request-url request)) (assert (equal? 'GET (hetzner-api-request-method request))) (hetzner-api-response (body `(("actions" . ,(vector (cons `("status" . "success") action-power-on-alist))) ("meta" . ,meta-page-alist)))))))) (hetzner-api-server-power-on (hetzner-api) server-x86))) (test-equal "hetzner-api-server-power-off-unit" action-power-off (mock ((gnu machine hetzner http) hetzner-api-request-send (lambda* (request #:key expected) (cond ((equal? "https://api.hetzner.cloud/v1/servers/59570198/actions/poweroff" (hetzner-api-request-url request)) (assert (equal? 'POST (hetzner-api-request-method request))) (hetzner-api-response (body `(("action" . ,action-power-off-alist))))) ((equal? "https://api.hetzner.cloud/v1/actions" (hetzner-api-request-url request)) (assert (equal? 'GET (hetzner-api-request-method request))) (hetzner-api-response (body `(("actions" . ,(vector (cons `("status" . "success") action-power-off-alist))) ("meta" . ,meta-page-alist)))))))) (hetzner-api-server-power-off (hetzner-api) server-x86))) (test-equal "hetzner-api-server-reboot-unit" action-reboot (mock ((gnu machine hetzner http) hetzner-api-request-send (lambda* (request #:key expected) (cond ((equal? "https://api.hetzner.cloud/v1/servers/59570198/actions/reboot" (hetzner-api-request-url request)) (assert (equal? 'POST (hetzner-api-request-method request))) (hetzner-api-response (body `(("action" . ,action-reboot-alist))))) ((equal? "https://api.hetzner.cloud/v1/actions" (hetzner-api-request-url request)) (assert (equal? 'GET (hetzner-api-request-method request))) (hetzner-api-response (body `(("actions" . ,(vector (cons `("status" . "success") action-reboot-alist))) ("meta" . ,meta-page-alist)))))))) (hetzner-api-server-reboot (hetzner-api) server-x86))) (test-equal "hetzner-api-servers-unit" (list server-x86) (mock ((gnu machine hetzner http) hetzner-api-request-send (lambda* (request #:key expected) (hetzner-api-response (body `(("meta" . ,meta-page-alist) ("servers" . #(,server-x86-alist))))))) (hetzner-api-servers (hetzner-api)))) (test-equal "hetzner-api-ssh-key-create-unit" ssh-key-root (mock ((gnu machine hetzner http) hetzner-api-request-send (lambda* (request #:key expected) (assert (equal? 'POST (hetzner-api-request-method request))) (assert (equal? "https://api.hetzner.cloud/v1/ssh_keys" (hetzner-api-request-url request))) (assert (equal? `(("name" . "guix-hetzner-api-test-key") ("public_key" . "ssh-ed25519 ABCAC3NzaC1lZDI1NTE5AAAAIBT3lLYPfOZV9NNrNk0jGCufWmXbFSz+ORxowJdHoSIM") ("labels" . (("a" . "1")))) (hetzner-api-request-body request))) (assert (equal? `() (hetzner-api-request-params request))) (hetzner-api-response (body `(("ssh_key" . ,ssh-key-root-alist)))))) (hetzner-api-ssh-key-create (hetzner-api) "guix-hetzner-api-test-key" "ssh-ed25519 ABCAC3NzaC1lZDI1NTE5AAAAIBT3lLYPfOZV9NNrNk0jGCufWmXbFSz+ORxowJdHoSIM" #:labels '(("a" . "1"))))) (test-assert "hetzner-api-ssh-key-delete-unit" (mock ((gnu machine hetzner http) hetzner-api-request-send (lambda* (request #:key expected) (assert (equal? "https://api.hetzner.cloud/v1/ssh_keys/16510983" (hetzner-api-request-url request))) (assert (equal? 'DELETE (hetzner-api-request-method request))) (hetzner-api-response))) (hetzner-api-ssh-key-delete (hetzner-api) ssh-key-root))) (test-equal "hetzner-api-ssh-keys-unit" (list ssh-key-root) (mock ((gnu machine hetzner http) hetzner-api-request-send (lambda* (request #:key expected) (assert (equal? 'GET (hetzner-api-request-method request))) (assert (equal? "https://api.hetzner.cloud/v1/ssh_keys" (hetzner-api-request-url request))) (assert (unspecified? (hetzner-api-request-body request))) (assert (equal? '(("page" . 1)) (hetzner-api-request-params request))) (hetzner-api-response (body `(("meta" . ,meta-page-alist) ("ssh_keys" . #(,ssh-key-root-alist))))))) (hetzner-api-ssh-keys (hetzner-api)))) ;; Integration tests (test-skip %when-no-token) (test-assert "hetzner-api-actions-integration" (with-cleanup-api (api (hetzner-api)) (let* ((ssh-key (create-ssh-key api %ssh-key)) (server (create-server api ssh-key)) (action (hetzner-api-server-enable-rescue-system api server (list ssh-key)))) (member action (hetzner-api-actions api (list (hetzner-action-id action))))))) (test-skip %when-no-token) (test-assert "hetzner-api-locations-integration" (let ((locations (hetzner-api-locations (hetzner-api)))) (and (> (length locations) 0) (every hetzner-location? locations)))) (test-skip %when-no-token) (test-assert "hetzner-api-server-types-integration" (let ((server-types (hetzner-api-server-types (hetzner-api)))) (and (> (length server-types) 0) (every hetzner-server-type? server-types)))) (test-skip %when-no-token) (test-assert "hetzner-api-server-create-integration" (with-cleanup-api (api (hetzner-api)) (let* ((ssh-key (create-ssh-key api %ssh-key)) (server (create-server api ssh-key))) (and (hetzner-server? server) (equal? %server-name (hetzner-server-name server)))))) (test-skip %when-no-token) (test-assert "hetzner-api-server-delete-integration" (with-cleanup-api (api (hetzner-api)) (let* ((ssh-key (create-ssh-key api %ssh-key)) (server (create-server api ssh-key)) (action (hetzner-api-server-delete api server))) (and (hetzner-action? action) (equal? "delete_server" (hetzner-action-command action)))))) (test-skip %when-no-token) (test-assert "hetzner-api-server-enable-rescue-system-integration" (with-cleanup-api (api (hetzner-api)) (let* ((ssh-key (create-ssh-key api %ssh-key)) (server (create-server api ssh-key)) (action (hetzner-api-server-enable-rescue-system api server (list ssh-key)))) (and (hetzner-action? action) (equal? "enable_rescue" (hetzner-action-command action)))))) (test-skip %when-no-token) (test-assert "hetzner-api-server-power-on-integration" (with-cleanup-api (api (hetzner-api)) (let* ((ssh-key (create-ssh-key api %ssh-key)) (server (create-server api ssh-key)) (action (hetzner-api-server-power-on api server))) (and (hetzner-action? action) (equal? "start_server" (hetzner-action-command action)))))) (test-skip %when-no-token) (test-assert "hetzner-api-server-power-off-integration" (with-cleanup-api (api (hetzner-api)) (let* ((ssh-key (create-ssh-key api %ssh-key)) (server (create-server api ssh-key)) (action (hetzner-api-server-power-off api server))) (and (hetzner-action? action) (equal? "stop_server" (hetzner-action-command action)))))) (test-skip %when-no-token) (test-assert "hetzner-api-server-reboot-integration" (with-cleanup-api (api (hetzner-api)) (let* ((ssh-key (create-ssh-key api %ssh-key)) (server (create-server api ssh-key)) (action (hetzner-api-server-reboot api server))) (and (hetzner-action? action) (equal? "reboot_server" (hetzner-action-command action)))))) (test-skip %when-no-token) (test-assert "hetzner-api-servers-integration" (with-cleanup-api (api (hetzner-api)) (let* ((ssh-key (create-ssh-key api %ssh-key)) (server (create-server api ssh-key))) (member server (hetzner-api-servers api))))) (test-skip %when-no-token) (test-assert "hetzner-api-ssh-key-create-integration" (with-cleanup-api (api (hetzner-api)) (let ((ssh-key (create-ssh-key api %ssh-key))) (and (hetzner-ssh-key? ssh-key) (equal? (hetzner-ssh-key-fingerprint %ssh-key) (hetzner-ssh-key-fingerprint ssh-key)) (equal? (hetzner-ssh-key-name %ssh-key) (hetzner-ssh-key-name ssh-key)) (equal? (hetzner-ssh-key-public-key %ssh-key) (hetzner-ssh-key-public-key ssh-key)))))) (test-skip %when-no-token) (test-assert "hetzner-api-ssh-key-delete-integration" (with-cleanup-api (api (hetzner-api)) (let ((ssh-key (create-ssh-key api %ssh-key))) (and (equal? #t (hetzner-api-ssh-key-delete api ssh-key)) (not (member ssh-key (hetzner-api-ssh-keys api))))))) (test-skip %when-no-token) (test-assert "hetzner-api-ssh-keys-integration" (with-cleanup-api (api (hetzner-api)) (let ((ssh-key (create-ssh-key api %ssh-key))) (member ssh-key (hetzner-api-ssh-keys api))))) (test-end "machine-hetzner-api") ;; Local Variables: ;; eval: (put 'with-cleanup-api 'scheme-indent-function 1) ;; End: