1
0
mirror of https://git.savannah.gnu.org/git/guix.git synced 2026-04-06 13:10:33 +02:00

machine: Implement 'hetzner-environment-type'.

* Makefile.am (SCM_TESTS): Add test modules.
* doc/guix.texi: Add documentation.
* gnu/local.mk (GNU_SYSTEM_MODULES): Add modules.
* gnu/machine/hetzner.scm: Add hetzner-environment-type.
* gnu/machine/hetzner/http.scm: Add HTTP API.
* po/guix/POTFILES.in: Add Hetzner modules.
* tests/machine/hetzner.scm: Add machine tests.
* tests/machine/hetzner/http.scm Add HTTP API tests.

Change-Id: I276ed5afed676bbccc6c852c56ee4db57ce3c1ea
Signed-off-by: Ludovic Courtès <ludo@gnu.org>
This commit is contained in:
Roman Scherer
2025-02-04 20:01:14 +01:00
committed by Ludovic Courtès
parent 96f05f003a
commit 0753a17ddf
8 changed files with 2402 additions and 0 deletions

705
gnu/machine/hetzner.scm Normal file
View File

@@ -0,0 +1,705 @@
;;; 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 (gnu machine hetzner)
#:use-module (gnu bootloader grub)
#:use-module (gnu bootloader)
#:use-module (gnu machine hetzner http)
#:use-module (gnu machine ssh)
#:use-module (gnu machine)
#:use-module (gnu packages ssh)
#:use-module (gnu services base)
#:use-module (gnu services networking)
#:use-module (gnu services ssh)
#:use-module (gnu services)
#:use-module (gnu system file-systems)
#:use-module (gnu system image)
#:use-module (gnu system linux-initrd)
#:use-module (gnu system pam)
#:use-module (gnu system)
#:use-module (guix base32)
#:use-module (guix colors)
#:use-module (guix derivations)
#:use-module (guix diagnostics)
#:use-module (guix gexp)
#:use-module (guix i18n)
#:use-module (guix import json)
#:use-module (guix monads)
#:use-module (guix packages)
#:use-module (guix pki)
#:use-module (guix records)
#:use-module (guix ssh)
#:use-module (guix store)
#:use-module (ice-9 format)
#:use-module (ice-9 iconv)
#:use-module (ice-9 match)
#:use-module (ice-9 popen)
#:use-module (ice-9 rdelim)
#:use-module (ice-9 string-fun)
#:use-module (ice-9 textual-ports)
#:use-module (json)
#:use-module (srfi srfi-1)
#:use-module (srfi srfi-2)
#:use-module (srfi srfi-34)
#:use-module (srfi srfi-35)
#:use-module (srfi srfi-71)
#:use-module (ssh channel)
#:use-module (ssh key)
#:use-module (ssh popen)
#:use-module (ssh session)
#:use-module (ssh sftp)
#:use-module (ssh shell)
#:export (%hetzner-os-arm
%hetzner-os-x86
deploy-hetzner
hetzner-configuration
hetzner-configuration-allow-downgrades?
hetzner-configuration-api
hetzner-configuration-authorize?
hetzner-configuration-build-locally?
hetzner-configuration-delete?
hetzner-configuration-labels
hetzner-configuration-location
hetzner-configuration-server-type
hetzner-configuration-ssh-key
hetzner-configuration?
hetzner-environment-type))
;;; Commentary:
;;;
;;; This module implements a high-level interface for provisioning machines on
;;; the Hetzner Cloud service https://docs.hetzner.cloud.
;;;
;;;
;;; Hetzner operating systems.
;;;
;; Operating system for arm servers using UEFI boot mode.
(define %hetzner-os-arm
(operating-system
(host-name "guix-arm")
(bootloader
(bootloader-configuration
(bootloader grub-efi-bootloader)
(targets (list "/boot/efi"))
(terminal-outputs '(console))))
(file-systems
(cons* (file-system
(mount-point "/")
(device "/dev/sda1")
(type "ext4"))
(file-system
(mount-point "/boot/efi")
(device "/dev/sda15")
(type "vfat"))
%base-file-systems))
(initrd-modules
(cons* "sd_mod" "virtio_scsi" %base-initrd-modules))
(services
(cons* (service dhcp-client-service-type)
(service openssh-service-type
(openssh-configuration
(openssh openssh-sans-x)
(permit-root-login 'prohibit-password)))
%base-services))))
;; Operating system for x86 servers using BIOS boot mode.
(define %hetzner-os-x86
(operating-system
(inherit %hetzner-os-arm)
(host-name "guix-x86")
(bootloader
(bootloader-configuration
(bootloader grub-bootloader)
(targets (list "/dev/sda"))
(terminal-outputs '(console))))
(initrd-modules
(cons "virtio_scsi" %base-initrd-modules))
(file-systems
(cons (file-system
(mount-point "/")
(device "/dev/sda1")
(type "ext4"))
%base-file-systems))))
(define (operating-system-authorize os)
"Authorize the OS with the public signing key of the current machine."
(if (file-exists? %public-key-file)
(operating-system
(inherit os)
(services
(modify-services (operating-system-user-services os)
(guix-service-type
config => (guix-configuration
(inherit config)
(authorized-keys
(cons*
(local-file %public-key-file)
(guix-configuration-authorized-keys config))))))))
(raise-exception
(formatted-message (G_ "no signing key '~a'. \
Have you run 'guix archive --generate-key'?")
%public-key-file))))
(define (operating-system-root-file-system-type os)
"Return the root file system type of the operating system OS."
(let ((root-fs (find (lambda (file-system)
(equal? "/" (file-system-mount-point file-system)))
(operating-system-file-systems os))))
(if (file-system? root-fs)
(file-system-type root-fs)
(raise-exception
(formatted-message
(G_ "could not determine root file system type"))))))
;;;
;;; Helper functions.
;;;
(define (escape-backticks str)
"Escape all backticks in STR."
(string-replace-substring str "`" "\\`"))
;;;
;;; Hetzner configuration.
;;;
(define-record-type* <hetzner-configuration> hetzner-configuration
make-hetzner-configuration hetzner-configuration? this-hetzner-configuration
(allow-downgrades? hetzner-configuration-allow-downgrades? ; boolean
(default #f))
(api hetzner-configuration-api ; <hetzner-api>
(default (hetzner-api)))
(authorize? hetzner-configuration-authorize? ; boolean
(default #t))
(build-locally? hetzner-configuration-build-locally? ; boolean
(default #t))
(delete? hetzner-configuration-delete? ; boolean
(default #f))
(labels hetzner-configuration-labels ; list of strings
(default '()))
(location hetzner-configuration-location ; #f | string
(default "fsn1"))
(server-type hetzner-configuration-server-type ; string
(default "cx42"))
(ssh-key hetzner-configuration-ssh-key)) ; string
(define (hetzner-configuration-ssh-key-fingerprint config)
"Return the SSH public key fingerprint of CONFIG as a string."
(and-let* ((file-name (hetzner-configuration-ssh-key config))
(privkey (private-key-from-file file-name))
(pubkey (private-key->public-key privkey))
(hash (get-public-key-hash pubkey 'md5)))
(bytevector->hex-string hash)))
(define (hetzner-configuration-ssh-key-public config)
"Return the SSH public key of CONFIG as a string."
(and-let* ((ssh-key (hetzner-configuration-ssh-key config))
(public-key (public-key-from-file ssh-key)))
(format #f "ssh-~a ~a" (get-key-type public-key)
(public-key->string public-key))))
;;;
;;; Hetzner Machine.
;;;
(define (hetzner-machine-delegate target server)
"Return the delagate machine that uses SSH for deployment."
(let* ((config (machine-configuration target))
;; Get the operating system WITHOUT the provenance service to avoid a
;; duplicate symlink conflict in the store.
(os ((@@ (gnu machine) %machine-operating-system) target)))
(machine
(inherit target)
(operating-system
(if (hetzner-configuration-authorize? config)
(operating-system-authorize os)
os))
(environment managed-host-environment-type)
(configuration
(machine-ssh-configuration
(allow-downgrades? (hetzner-configuration-allow-downgrades? config))
(authorize? (hetzner-configuration-authorize? config))
(build-locally? (hetzner-configuration-build-locally? config))
(host-name (hetzner-server-public-ipv4 server))
(identity (hetzner-configuration-ssh-key config))
(system (hetzner-server-system server)))))))
(define (hetzner-machine-location machine)
"Find the location of MACHINE on the Hetzner API."
(let* ((config (machine-configuration machine))
(expected (hetzner-configuration-location config)))
(find (lambda (location)
(equal? expected (hetzner-location-name location)))
(hetzner-api-locations
(hetzner-configuration-api config)
#:params `(("name" . ,expected))))))
(define (hetzner-machine-server-type machine)
"Find the server type of MACHINE on the Hetzner API."
(let* ((config (machine-configuration machine))
(expected (hetzner-configuration-server-type config)))
(find (lambda (server-type)
(equal? expected (hetzner-server-type-name server-type)))
(hetzner-api-server-types
(hetzner-configuration-api config)
#:params `(("name" . ,expected))))))
(define (hetzner-machine-validate-api-token machine)
"Validate the Hetzner API authentication token of MACHINE."
(let* ((config (machine-configuration machine))
(api (hetzner-configuration-api config)))
(unless (hetzner-api-token api)
(raise-exception
(formatted-message
(G_ "Hetzner Cloud access token was not provided. \
This may be fixed by setting the environment variable GUIX_HETZNER_API_TOKEN \
to one procured from \
https://docs.hetzner.com/cloud/api/getting-started/generating-api-token"))))))
(define (hetzner-machine-validate-configuration-type machine)
"Raise an error if MACHINE's configuration is not an instance of
<hetzner-configuration>."
(let ((config (machine-configuration machine))
(environment (environment-type-name (machine-environment machine))))
(unless (and config (hetzner-configuration? config))
(raise-exception
(formatted-message (G_ "unsupported machine configuration '~a' \
for environment of type '~a'")
config
environment)))))
(define (hetzner-machine-validate-server-type machine)
"Raise an error if the server type of MACHINE is not supported."
(unless (hetzner-machine-server-type machine)
(let* ((config (machine-configuration machine))
(api (hetzner-configuration-api config)))
(raise-exception
(formatted-message
(G_ "server type '~a' not supported~%~%\
Available server types:~%~%~a~%~%For more details and prices, see: ~a")
(hetzner-configuration-server-type config)
(string-join
(map (lambda (type)
(format #f " - ~a: ~a, ~a ~a cores, ~a GB mem, ~a GB disk"
(colorize-string
(hetzner-server-type-name type)
(color BOLD))
(hetzner-server-type-architecture type)
(hetzner-server-type-cores type)
(hetzner-server-type-cpu-type type)
(hetzner-server-type-memory type)
(hetzner-server-type-disk type)))
(hetzner-api-server-types api))
"\n")
"https://www.hetzner.com/cloud#pricing")))))
(define (hetzner-machine-validate-location machine)
"Raise an error if the location of MACHINE is not supported."
(unless (hetzner-machine-location machine)
(let* ((config (machine-configuration machine))
(api (hetzner-configuration-api config)))
(raise-exception
(formatted-message
(G_ "server location '~a' not supported~%~%\
Available locations:~%~%~a~%~%For more details, see: ~a")
(hetzner-configuration-location config)
(string-join
(map (lambda (location)
(format #f " - ~a: ~a, ~a"
(colorize-string
(hetzner-location-name location)
(color BOLD))
(hetzner-location-description location)
(hetzner-location-country location)))
(hetzner-api-locations api))
"\n")
"https://www.hetzner.com/cloud#locations")))))
(define (hetzner-machine-validate machine)
"Validate the Hetzner MACHINE."
(hetzner-machine-validate-configuration-type machine)
(hetzner-machine-validate-api-token machine)
(hetzner-machine-validate-location machine)
(hetzner-machine-validate-server-type machine))
(define (hetzner-machine-bootstrap-os-form machine server)
"Return the form to bootstrap an operating system on SERVER."
(let* ((os (machine-operating-system machine))
(system (hetzner-server-system server))
(arm? (equal? "arm" (hetzner-server-architecture server)))
(x86? (equal? "x86" (hetzner-server-architecture server)))
(root-fs-type (operating-system-root-file-system-type os)))
`(operating-system
(host-name ,(operating-system-host-name os))
(timezone "Etc/UTC")
(bootloader (bootloader-configuration
(bootloader ,(cond (arm? 'grub-efi-bootloader)
(x86? 'grub-bootloader)))
(targets ,(cond (arm? '(list "/boot/efi"))
(x86? '(list "/dev/sda"))))
(terminal-outputs '(console))))
(initrd-modules (append
,(cond (arm? '(list "sd_mod" "virtio_scsi"))
(x86? '(list "virtio_scsi")))
%base-initrd-modules))
(file-systems ,(cond
(arm? `(cons* (file-system
(mount-point "/")
(device "/dev/sda1")
(type ,root-fs-type))
(file-system
(mount-point "/boot/efi")
(device "/dev/sda15")
(type "vfat"))
%base-file-systems))
(x86? `(cons* (file-system
(mount-point "/")
(device "/dev/sda1")
(type ,root-fs-type))
%base-file-systems))))
(services
(cons* (service dhcp-client-service-type)
(service openssh-service-type
(openssh-configuration
(openssh openssh-sans-x)
(permit-root-login 'prohibit-password)))
%base-services)))))
(define (rexec-verbose session cmd)
"Execute a command CMD on the remote side and print output. Return two
values: list of output lines returned by CMD and its exit code."
(let* ((channel (open-remote-input-pipe session cmd))
(result (let loop ((line (read-line channel))
(result '()))
(if (eof-object? line)
(reverse result)
(begin
(display line)
(newline)
(loop (read-line channel)
(cons line result))))))
(exit-status (channel-get-exit-status channel)))
(close channel)
(values result exit-status)))
(define (hetzner-machine-ssh-key machine)
"Find the SSH key for MACHINE on the Hetzner API."
(let* ((config (machine-configuration machine))
(expected (hetzner-configuration-ssh-key-fingerprint config)))
(find (lambda (ssh-key)
(equal? expected (hetzner-ssh-key-fingerprint ssh-key)))
(hetzner-api-ssh-keys
(hetzner-configuration-api config)
#:params `(("fingerprint" . ,expected))))))
(define (hetzner-machine-ssh-key-create machine)
"Create the SSH key for MACHINE on the Hetzner API."
(let ((name (machine-display-name machine)))
(format #t "creating ssh key for '~a'...\n" name)
(let* ((config (machine-configuration machine))
(api (hetzner-configuration-api config))
(ssh-key (hetzner-api-ssh-key-create
(hetzner-configuration-api config)
(hetzner-configuration-ssh-key-fingerprint config)
(hetzner-configuration-ssh-key-public config)
#:labels (hetzner-configuration-labels config))))
(format #t "successfully created ssh key for '~a'\n" name)
ssh-key)))
(define (hetzner-machine-server machine)
"Find the Hetzner server for MACHINE."
(let ((config (machine-configuration machine)))
(find (lambda (server)
(equal? (machine-display-name machine)
(hetzner-server-name server)))
(hetzner-api-servers
(hetzner-configuration-api config)
#:params `(("name" . ,(machine-display-name machine)))))))
(define (hetzner-machine-create-server machine)
"Create the Hetzner server for MACHINE."
(let* ((config (machine-configuration machine))
(name (machine-display-name machine))
(server-type (hetzner-configuration-server-type config)))
(format #t "creating '~a' server for '~a'...\n" server-type name)
(let* ((ssh-key (hetzner-machine-ssh-key machine))
(api (hetzner-configuration-api config))
(server (hetzner-api-server-create
api
(machine-display-name machine)
(list ssh-key)
#:labels (hetzner-configuration-labels config)
#:location (hetzner-configuration-location config)
#:server-type (hetzner-configuration-server-type config)))
(architecture (hetzner-server-architecture server)))
(format #t "successfully created '~a' ~a server for '~a'\n"
server-type architecture name)
server)))
(define (wait-for-ssh address ssh-key)
"Block until a SSH session can be made as 'root' with SSH-KEY at ADDRESS."
(format #t "connecting via SSH to '~a' using '~a'...\n" address ssh-key)
(let loop ()
(catch #t
(lambda ()
(open-ssh-session address #:user "root" #:identity ssh-key
#:strict-host-key-check? #f))
(lambda args
(let ((msg (cadr args)))
(if (formatted-message? msg)
(format #t "~a\n"
(string-trim-right
(apply format #f
(formatted-message-string msg)
(formatted-message-arguments msg))
#\newline))
(format #t "~a" args))
(sleep 5)
(loop))))))
(define (hetzner-machine-wait-for-ssh machine server)
"Wait for SSH connection to be established with the specified machine."
(wait-for-ssh (hetzner-server-public-ipv4 server)
(hetzner-configuration-ssh-key
(machine-configuration machine))))
(define (hetzner-machine-authenticate-host machine server)
"Add the host key of MACHINE to the list of known hosts."
(let ((ssh-session (hetzner-machine-wait-for-ssh machine server)))
(write-known-host! ssh-session)))
(define (hetzner-machine-enable-rescue-system machine server)
"Enable the rescue system on the Hetzner SERVER for MACHINE."
(let* ((name (machine-display-name machine))
(config (machine-configuration machine))
(api (hetzner-configuration-api config))
(ssh-keys (list (hetzner-machine-ssh-key machine))))
(format #t "enabling rescue system on '~a'...\n" name)
(let ((action (hetzner-api-server-enable-rescue-system api server ssh-keys)))
(format #t "successfully enabled rescue system on '~a'\n" name)
action)))
(define (hetzner-machine-power-on machine server)
"Power on the Hetzner SERVER for MACHINE."
(let* ((name (machine-display-name machine))
(config (machine-configuration machine))
(api (hetzner-configuration-api config)))
(format #t "powering on server for '~a'...\n" name)
(let ((action (hetzner-api-server-power-on api server)))
(format #t "successfully powered on server for '~a'\n" name)
action)))
(define (hetzner-machine-ssh-run-script ssh-session name content)
(let ((sftp-session (make-sftp-session ssh-session)))
(rexec ssh-session (format #f "rm -f ~a" name))
(rexec ssh-session (format #f "mkdir -p ~a" (dirname name)))
(call-with-remote-output-file
sftp-session name
(lambda (port)
(display content port)))
(sftp-chmod sftp-session name 755)
(let ((lines exit-code (rexec-verbose ssh-session
(format #f "~a 2>&1" name))))
(if (zero? exit-code)
lines
(raise-exception
(formatted-message
(G_ "failed to run script '~a' on machine, exit code: '~a'")
name exit-code))))))
;; Prevent compiler from inlining this function, so we can mock it in tests.
(set! hetzner-machine-ssh-run-script hetzner-machine-ssh-run-script)
(define (hetzner-machine-rescue-install-os machine ssh-session server)
(let ((name (machine-display-name machine))
(os (hetzner-machine-bootstrap-os-form machine server)))
(format #t "installing guix operating system on '~a'...\n" name)
(hetzner-machine-ssh-run-script
ssh-session "/tmp/guix/deploy/hetzner-machine-rescue-install-os"
(format #f "#!/usr/bin/env bash
set -eo pipefail
mount /dev/sda1 /mnt
mkdir -p /mnt/boot/efi
mount /dev/sda15 /mnt/boot/efi
mkdir --parents /mnt/root/.ssh
chmod 700 /mnt/root/.ssh
cp /root/.ssh/authorized_keys /mnt/root/.ssh/authorized_keys
chmod 600 /mnt/root/.ssh/authorized_keys
cat > /tmp/guix/deploy/hetzner-os.scm << EOF
(use-modules (gnu) (guix utils))
(use-package-modules ssh)
(use-service-modules base networking ssh)
(use-system-modules linux-initrd)
~a
EOF
guix system init --verbosity=2 /tmp/guix/deploy/hetzner-os.scm /mnt"
(escape-backticks (format #f "~y" os))))
(format #t "successfully installed guix operating system on '~a'\n" name)))
(define (hetzner-machine-reboot machine server)
"Reboot the Hetzner SERVER for MACHINE."
(let* ((name (machine-display-name machine))
(config (machine-configuration machine))
(api (hetzner-configuration-api config)))
(format #t "rebooting server for '~a'...\n" name)
(let ((action (hetzner-api-server-reboot api server)))
(format #t "successfully rebooted server for '~a'\n" name)
action)))
(define (hetzner-machine-rescue-partition machine ssh-session)
"Setup the partitions of the Hetzner server for MACHINE using SSH-SESSION."
(let* ((name (machine-display-name machine))
(os (machine-operating-system machine))
(root-fs-type (operating-system-root-file-system-type os)))
(format #t "setting up partitions on '~a'...\n" name)
(hetzner-machine-ssh-run-script
ssh-session "/tmp/guix/deploy/hetzner-machine-rescue-partition"
(format #f "#!/usr/bin/env bash
set -eo pipefail
growpart /dev/sda 1 || true
~a
fdisk -l /dev/sda"
(cond
((equal? "btrfs" root-fs-type)
(format #f "mkfs.btrfs -L ~a -f /dev/sda1" root-label))
((equal? "ext4" root-fs-type)
(format #f "mkfs.ext4 -L ~a -F /dev/sda1" root-label))
(else (raise-exception
(formatted-message
(G_ "unsupported root file system type '~a'")
root-fs-type))))))
(format #t "successfully setup partitions on '~a'\n" name)))
(define (hetzner-machine-rescue-install-packages machine ssh-session)
"Install packages on the Hetzner server for MACHINE using SSH-SESSION."
(let ((name (machine-display-name machine)))
(format #t "installing rescue system packages on '~a'...\n" name)
(hetzner-machine-ssh-run-script
ssh-session "/tmp/guix/deploy/hetzner-machine-rescue-install-packages"
(format #f "#!/usr/bin/env bash
set -eo pipefail
apt-get update
apt-get install guix cloud-initramfs-growroot --assume-yes"))
(format #t "successfully installed rescue system packages on '~a'\n" name)))
(define (hetzner-machine-delete machine server)
"Delete the Hetzner server for MACHINE."
(let* ((name (machine-display-name machine))
(config (machine-configuration machine))
(api (hetzner-configuration-api config)))
(format #t "deleting server for '~a'...\n" name)
(let ((action (hetzner-api-server-delete api server)))
(format #t "successfully deleted server for '~a'\n" name)
action)))
(define (hetzner-machine-provision machine)
"Provision a server for MACHINE on the Hetzner Cloud service."
(with-exception-handler
(lambda (exception)
(let ((config (machine-configuration machine))
(server (hetzner-machine-server machine)))
(when (and server (hetzner-configuration-delete? config))
(hetzner-machine-delete machine server))
(raise-exception exception)))
(lambda ()
(let ((server (hetzner-machine-create-server machine)))
(hetzner-machine-enable-rescue-system machine server)
(hetzner-machine-power-on machine server)
(let ((ssh-session (hetzner-machine-wait-for-ssh machine server)))
(hetzner-machine-rescue-install-packages machine ssh-session)
(hetzner-machine-rescue-partition machine ssh-session)
(hetzner-machine-rescue-install-os machine ssh-session server)
(hetzner-machine-reboot machine server)
(sleep 5)
(hetzner-machine-authenticate-host machine server)
server)))
#:unwind? #t))
(define (machine-not-provisioned machine)
(formatted-message
(G_ "no server provisioned for machine '~a' on the Hetzner Cloud service")
(machine-display-name machine)))
;;;
;;; Remote evaluation.
;;;
(define (hetzner-remote-eval machine exp)
"Internal implementation of 'machine-remote-eval' for MACHINE instances with
an environment type of 'hetzner-environment-type'."
(hetzner-machine-validate machine)
(let ((server (hetzner-machine-server machine)))
(unless server (raise-exception (machine-not-provisioned machine)))
(machine-remote-eval (hetzner-machine-delegate machine server) exp)))
;;;
;;; System deployment.
;;;
(define (deploy-hetzner machine)
"Internal implementation of 'deploy-machine' for 'machine' instances with an
environment type of 'hetzner-environment-type'."
(hetzner-machine-validate machine)
(unless (hetzner-machine-ssh-key machine)
(hetzner-machine-ssh-key-create machine))
(let ((server (or (hetzner-machine-server machine)
(hetzner-machine-provision machine))))
(deploy-machine (hetzner-machine-delegate machine server))))
;;;
;;; Roll-back.
;;;
(define (roll-back-hetzner machine)
"Internal implementation of 'roll-back-machine' for MACHINE instances with an
environment type of 'hetzner-environment-type'."
(hetzner-machine-validate machine)
(let ((server (hetzner-machine-server machine)))
(unless server (raise-exception (machine-not-provisioned machine)))
(roll-back-machine (hetzner-machine-delegate machine server))))
;;;
;;; Environment type.
;;;
(define hetzner-environment-type
(environment-type
(machine-remote-eval hetzner-remote-eval)
(deploy-machine deploy-hetzner)
(roll-back-machine roll-back-hetzner)
(name 'hetzner-environment-type)
(description "Provisioning of virtual machine servers on the Hetzner Cloud
service.")))

View File

@@ -0,0 +1,664 @@
;;; 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 (gnu machine hetzner http)
#:use-module (guix diagnostics)
#:use-module (guix i18n)
#:use-module (guix records)
#:use-module (ice-9 iconv)
#:use-module (ice-9 match)
#:use-module (ice-9 pretty-print)
#:use-module (ice-9 textual-ports)
#:use-module (json)
#:use-module (rnrs bytevectors)
#:use-module (srfi srfi-1)
#:use-module (srfi srfi-2)
#:use-module (ssh key)
#:use-module (web client)
#:use-module (web request)
#:use-module (web response)
#:use-module (web uri)
#:export (%hetzner-default-api-token
%hetzner-default-server-image
%hetzner-default-server-location
%hetzner-default-server-type
hetzner-action
hetzner-action-command
hetzner-action-error
hetzner-action-finished
hetzner-action-id
hetzner-action-progress
hetzner-action-resources
hetzner-action-started
hetzner-action-status
hetzner-action?
hetzner-api
hetzner-api-action-wait
hetzner-api-actions
hetzner-api-create-ssh-key
hetzner-api-locations
hetzner-api-request-body
hetzner-api-request-headers
hetzner-api-request-method
hetzner-api-request-params
hetzner-api-request-send
hetzner-api-request-url
hetzner-api-request?
hetzner-api-response
hetzner-api-response-body
hetzner-api-response-headers
hetzner-api-response-status
hetzner-api-response?
hetzner-api-server-create
hetzner-api-server-delete
hetzner-api-server-enable-rescue-system
hetzner-api-server-power-off
hetzner-api-server-power-on
hetzner-api-server-reboot
hetzner-api-server-types
hetzner-api-servers
hetzner-api-ssh-key-create
hetzner-api-ssh-key-delete
hetzner-api-ssh-keys
hetzner-api-token
hetzner-api?
hetzner-error-code
hetzner-error-message
hetzner-error?
hetzner-ipv4-blocked?
hetzner-ipv4-dns-ptr
hetzner-ipv4-id
hetzner-ipv4-ip
hetzner-ipv4?
hetzner-ipv6-blocked?
hetzner-ipv6-dns-ptr
hetzner-ipv6-id
hetzner-ipv6-ip
hetzner-ipv6?
hetzner-location
hetzner-location-city
hetzner-location-country
hetzner-location-description
hetzner-location-id
hetzner-location-latitude
hetzner-location-longitude
hetzner-location-name
hetzner-location-network-zone
hetzner-location?
hetzner-public-net
hetzner-public-net-ipv4
hetzner-public-net-ipv6
hetzner-resource
hetzner-resource-id
hetzner-resource-type
hetzner-resource?
hetzner-server-architecture
hetzner-server-created
hetzner-server-id
hetzner-server-labels
hetzner-server-name
hetzner-server-public-ipv4
hetzner-server-public-net
hetzner-server-rescue-enabled?
hetzner-server-system
hetzner-server-type
hetzner-server-type-architecture
hetzner-server-type-cores
hetzner-server-type-cpu-type
hetzner-server-type-deprecated
hetzner-server-type-deprecation
hetzner-server-type-description
hetzner-server-type-disk
hetzner-server-type-id
hetzner-server-type-memory
hetzner-server-type-name
hetzner-server-type-storage-type
hetzner-server-type?
hetzner-server?
hetzner-ssh-key-created
hetzner-ssh-key-fingerprint
hetzner-ssh-key-id
hetzner-ssh-key-labels
hetzner-ssh-key-name
hetzner-ssh-key-public-key
hetzner-ssh-key-read-file
hetzner-ssh-key?
make-hetzner-action
make-hetzner-error
make-hetzner-ipv4
make-hetzner-ipv6
make-hetzner-location
make-hetzner-public-net
make-hetzner-resource
make-hetzner-server
make-hetzner-server-type
make-hetzner-ssh-key))
;;; Commentary:
;;;
;;; This module implements a lower-level interface for interacting with the
;;; Hetzner Cloud API https://docs.hetzner.cloud.
;;;
(define %hetzner-default-api-token
(make-parameter (getenv "GUIX_HETZNER_API_TOKEN")))
;; Ideally this would be a Guix image. Maybe one day.
(define %hetzner-default-server-image "debian-11")
;; Falkenstein, Germany
(define %hetzner-default-server-location "fsn1")
;; x86, 8 VCPUs, 16 GB mem, 160 GB disk
(define %hetzner-default-server-type "cx42")
;;;
;;; Helper functions.
;;;
(define (format-query-param param)
"Format the query PARAM as a string."
(string-append (uri-encode (format #f "~a" (car param))) "="
(uri-encode (format #f "~a" (cdr param)))))
(define (format-query-params params)
"Format the query PARAMS as a string."
(if (> (length params) 0)
(string-append
"?"
(string-join
(map format-query-param params)
"&"))
""))
(define (json->maybe-hetzner-error json)
(and (list? json) (json->hetzner-error json)))
(define (string->time s)
(when (string? s) (car (strptime "%FT%T%z" s))))
(define (json->hetzner-dnses vector)
(map json->hetzner-dns (vector->list vector)))
(define (json->hetzner-resources vector)
(map json->hetzner-resource (vector->list vector)))
;;;
;;; Domain models.
;;;
(define-json-mapping <hetzner-action>
make-hetzner-action hetzner-action? json->hetzner-action
(command hetzner-action-command) ; string
(error hetzner-action-error "error"
json->maybe-hetzner-error) ; <hetzner-error> | #f
(finished hetzner-action-finished "finished" string->time) ; time
(id hetzner-action-id) ; integer
(progress hetzner-action-progress) ; integer
(resources hetzner-action-resources "resources"
json->hetzner-resources) ; list of <hetzner-resource>
(started hetzner-action-started "started" string->time) ; time
(status hetzner-action-status))
(define-json-mapping <hetzner-deprecation>
make-hetzner-deprecation hetzner-deprecation? json->hetzner-deprecation
(announced hetzner-deprecation-announced) ; string
(unavailable-after hetzner-deprecation-unavailable-after
"unavailable_after")) ; string
(define-json-mapping <hetzner-dns>
make-hetzner-dns hetzner-dns? json->hetzner-dns
(ip hetzner-dns-ip) ; string
(ptr hetzner-dns-ptr "dns_ptr")) ; string
(define-json-mapping <hetzner-error>
make-hetzner-error hetzner-error? json->hetzner-error
(code hetzner-error-code) ; string
(message hetzner-error-message)) ; <string>
(define-json-mapping <hetzner-ipv4>
make-hetzner-ipv4 hetzner-ipv4? json->hetzner-ipv4
(blocked? hetzner-ipv4-blocked? "blocked") ; boolean
(dns-ptr hetzner-ipv4-dns-ptr "dns_ptr") ; string
(id hetzner-ipv4-id) ; integer
(ip hetzner-ipv4-ip)) ; string
(define-json-mapping <hetzner-ipv6>
make-hetzner-ipv6 hetzner-ipv6? json->hetzner-ipv6
(blocked? hetzner-ipv6-blocked? "blocked") ; boolean
(dns-ptr hetzner-ipv6-dns-ptr "dns_ptr"
json->hetzner-dnses) ; list of <hetzner-dns>
(id hetzner-ipv6-id) ; integer
(ip hetzner-ipv6-ip)) ; string
(define-json-mapping <hetzner-location>
make-hetzner-location hetzner-location? json->hetzner-location
(city hetzner-location-city) ; string
(country hetzner-location-country) ; string
(description hetzner-location-description) ; string
(id hetzner-location-id) ; integer
(latitude hetzner-location-latitude) ; decimal
(longitude hetzner-location-longitude) ; decimal
(name hetzner-location-name) ; string
(network-zone hetzner-location-network-zone "network_zone"))
(define-json-mapping <hetzner-public-net>
make-hetzner-public-net hetzner-public-net? json->hetzner-public-net
(ipv4 hetzner-public-net-ipv4 "ipv4" json->hetzner-ipv4) ; <hetzner-ipv4>
(ipv6 hetzner-public-net-ipv6 "ipv6" json->hetzner-ipv6)) ; <hetzner-ipv6>
(define-json-mapping <hetzner-resource>
make-hetzner-resource hetzner-resource? json->hetzner-resource
(id hetzner-resource-id) ; integer
(type hetzner-resource-type)) ; string
(define-json-mapping <hetzner-server>
make-hetzner-server hetzner-server? json->hetzner-server
(created hetzner-server-created) ; time
(id hetzner-server-id) ; integer
(labels hetzner-server-labels) ; alist of string/string
(name hetzner-server-name) ; string
(public-net hetzner-server-public-net "public_net"
json->hetzner-public-net) ; <hetzner-public-net>
(rescue-enabled? hetzner-server-rescue-enabled? "rescue_enabled") ; boolean
(server-type hetzner-server-type "server_type"
json->hetzner-server-type)) ; <hetzner-server-type>
(define-json-mapping <hetzner-server-type>
make-hetzner-server-type hetzner-server-type? json->hetzner-server-type
(architecture hetzner-server-type-architecture) ; string
(cores hetzner-server-type-cores) ; integer
(cpu-type hetzner-server-type-cpu-type "cpu_type") ; string
(deprecated hetzner-server-type-deprecated) ; boolean
(deprecation hetzner-server-type-deprecation
json->hetzner-deprecation) ; <hetzner-deprecation>
(description hetzner-server-type-description) ; string
(disk hetzner-server-type-disk) ; integer
(id hetzner-server-type-id) ; integer
(memory hetzner-server-type-memory) ; integer
(name hetzner-server-type-name) ; string
(storage-type hetzner-server-type-storage-type "storage_type")) ; string
(define-json-mapping <hetzner-ssh-key>
make-hetzner-ssh-key hetzner-ssh-key? json->hetzner-ssh-key
(created hetzner-ssh-key-created "created" string->time) ; time
(fingerprint hetzner-ssh-key-fingerprint) ; string
(id hetzner-ssh-key-id) ; integer
(labels hetzner-ssh-key-labels) ; alist of string/string
(name hetzner-ssh-key-name) ; string
(public_key hetzner-ssh-key-public-key "public_key")) ; string
(define (hetzner-server-architecture server)
"Return the architecture of the Hetzner SERVER."
(hetzner-server-type-architecture (hetzner-server-type server)))
(define* (hetzner-server-path server #:optional (path ""))
"Return the PATH of the Hetzner SERVER."
(format #f "/servers/~a~a" (hetzner-server-id server) path))
(define (hetzner-server-public-ipv4 server)
"Return the public IPv4 address of the SERVER."
(and-let* ((public-net (hetzner-server-public-net server))
(ipv4 (hetzner-public-net-ipv4 public-net)))
(hetzner-ipv4-ip ipv4)))
(define (hetzner-server-system server)
"Return the Guix system architecture of the Hetzner SERVER."
(match (hetzner-server-architecture server)
("arm" "aarch64-linux")
("x86" "x86_64-linux")))
(define* (hetzner-ssh-key-path ssh-key #:optional (path ""))
"Return the PATH of the Hetzner SSH-KEY."
(format #f "/ssh_keys/~a~a" (hetzner-ssh-key-id ssh-key) path))
(define (hetzner-ssh-key-read-file file)
"Read the SSH private key from FILE and return a Hetzner SSH key."
(let* ((privkey (private-key-from-file file))
(pubkey (private-key->public-key privkey))
(hash (get-public-key-hash pubkey 'md5))
(fingerprint (bytevector->hex-string hash))
(public-key (format #f "ssh-~a ~a" (get-key-type pubkey)
(public-key->string pubkey))))
(make-hetzner-ssh-key #f fingerprint #f '() (basename file) public-key)))
;;;
;;; Hetzner API response.
;;;
(define-record-type* <hetzner-api-response>
hetzner-api-response make-hetzner-api-response hetzner-api-response?
(body hetzner-api-response-body (default *unspecified*))
(headers hetzner-api-response-headers (default '()))
(status hetzner-api-response-status (default 200)))
(define (hetzner-api-response-meta response)
"Return the meta information of the Hetzner API response."
(assoc-ref (hetzner-api-response-body response) "meta"))
(define (hetzner-api-response-pagination response)
"Return the meta information of the Hetzner API response."
(assoc-ref (hetzner-api-response-meta response) "pagination"))
(define (hetzner-api-response-pagination-combine resource responses)
"Combine multiple Hetzner API pagination responses into a single response."
(if (positive? (length responses))
(let* ((response (car responses))
(pagination (hetzner-api-response-pagination response))
(total-entries (assoc-ref pagination "total_entries")))
(hetzner-api-response
(inherit response)
(body `(("meta"
("pagination"
("last_page" . 1)
("next_page" . null)
("page" . 1)
("per_page" . ,total-entries)
("previous_page" . null)
("total_entries" . ,total-entries)))
(,resource . ,(append-map
(lambda (body)
(vector->list (assoc-ref body resource)))
(map hetzner-api-response-body responses)))))))
(raise-exception
(formatted-message
(G_ "expected a list of Hetzner API responses")))))
(define (hetzner-api-body-action body)
"Return the Hetzner API action from BODY."
(let ((json (assoc-ref body "action")))
(and json (json->hetzner-action json))))
(define (hetzner-api-response-read port)
"Read the Hetzner API response from PORT."
(let* ((response (read-response port))
(body (read-response-body response)))
(hetzner-api-response
(body (and body (json-string->scm (utf8->string body))))
(headers (response-headers response))
(status (response-code response)))))
(define (hetzner-api-response-validate-status response expected)
"Raise an error if the HTTP status code of RESPONSE is not in EXPECTED."
(when (not (member (hetzner-api-response-status response) expected))
(raise-exception
(formatted-message
(G_ "unexpected HTTP status code: ~a, expected: ~a~%~a")
(hetzner-api-response-status response)
expected
(with-output-to-string
(lambda ()
(pretty-print (hetzner-api-response-body response))))))))
;;;
;;; Hetzner API request.
;;;
(define-record-type* <hetzner-api-request>
hetzner-api-request make-hetzner-api-request hetzner-api-request?
(body hetzner-api-request-body (default *unspecified*))
(headers hetzner-api-request-headers (default '()))
(method hetzner-api-request-method (default 'GET))
(params hetzner-api-request-params (default '()))
(url hetzner-api-request-url))
(define (hetzner-api-request-uri request)
"Return the URI object of the Hetzner API request."
(let ((params (hetzner-api-request-params request)))
(string->uri (string-append (hetzner-api-request-url request)
(format-query-params params)))))
(define (hetzner-api-request-body-bytevector request)
"Return the body of the Hetzner API REQUEST as a bytevector."
(let ((body (hetzner-api-request-body request)))
(string->utf8 (if (unspecified? body) "" (scm->json-string body)))))
(define (hetzner-api-request-write port request)
"Write the Hetzner API REQUEST to PORT."
(let* ((body (hetzner-api-request-body-bytevector request))
(request (build-request
(hetzner-api-request-uri request)
#:method (hetzner-api-request-method request)
#:version '(1 . 1)
#:headers (cons* `(Content-Length
. ,(number->string
(if (unspecified? body)
0 (bytevector-length body))))
(hetzner-api-request-headers request))
#:port port))
(request (write-request request port)))
(unless (unspecified? body)
(write-request-body request body))
(force-output (request-port request))))
(define* (hetzner-api-request-send request #:key (expected (list 200 201 204)))
"Send the Hetzner API REQUEST via HTTP."
(let ((port (open-socket-for-uri (hetzner-api-request-uri request))))
(hetzner-api-request-write port request)
(let ((response (hetzner-api-response-read port)))
(close-port port)
(hetzner-api-response-validate-status response expected)
response)))
;; Prevent compiler from inlining this function, so we can mock it in tests.
(set! hetzner-api-request-send hetzner-api-request-send)
(define (hetzner-api-request-next-params request)
"Return the pagination params for the next page of the REQUEST."
(let* ((params (hetzner-api-request-params request))
(page (or (assoc-ref params "page") 1)))
(map (lambda (param)
(if (equal? "page" (car param))
(cons (car param) (+ page 1))
param))
params)))
(define (hetzner-api-request-paginate request)
"Fetch all pages of the REQUEST via pagination and return all responses."
(let* ((response (hetzner-api-request-send request))
(pagination (hetzner-api-response-pagination response))
(next-page (assoc-ref pagination "next_page")))
(if (number? next-page)
(cons response
(hetzner-api-request-paginate
(hetzner-api-request
(inherit request)
(params (hetzner-api-request-next-params request)))))
(list response))))
;;;
;;; Hetzner API.
;;;
(define-record-type* <hetzner-api>
hetzner-api make-hetzner-api hetzner-api?
(base-url hetzner-api-base-url ; string
(default "https://api.hetzner.cloud/v1"))
(token hetzner-api-token ; string
(default (%hetzner-default-api-token))))
(define (hetzner-api-authorization-header api)
"Return the authorization header for the Hetzner API."
(format #f "Bearer ~a" (hetzner-api-token api)))
(define (hetzner-api-default-headers api)
"Returns the default headers of the Hetzner API."
`((user-agent . "Guix Deploy")
(Accept . "application/json")
(Authorization . ,(hetzner-api-authorization-header api))
(Content-Type . "application/json")))
(define (hetzner-api-url api path)
"Append PATH to the base url of the Hetzner API."
(string-append (hetzner-api-base-url api) path))
(define (hetzner-api-delete api path)
"Delelte the resource at PATH with the Hetzner API."
(hetzner-api-response-body
(hetzner-api-request-send
(hetzner-api-request
(headers (hetzner-api-default-headers api))
(method 'DELETE)
(url (hetzner-api-url api path))))))
(define* (hetzner-api-list api path resources json->object #:key (params '()))
"Fetch all objects of RESOURCE from the Hetzner API."
(let ((body (hetzner-api-response-body
(hetzner-api-response-pagination-combine
resources (hetzner-api-request-paginate
(hetzner-api-request
(url (hetzner-api-url api path))
(headers (hetzner-api-default-headers api))
(params (cons '("page" . 1) params))))))))
(map json->object (assoc-ref body resources))))
(define* (hetzner-api-post api path #:key (body *unspecified*))
"Send a POST request to the Hetzner API at PATH using BODY."
(hetzner-api-response-body
(hetzner-api-request-send
(hetzner-api-request
(body body)
(method 'POST)
(url (hetzner-api-url api path))
(headers (hetzner-api-default-headers api))))))
(define (hetzner-api-actions api ids)
"Get actions from the Hetzner API."
(if (zero? (length ids))
(raise-exception
(formatted-message
(G_ "expected at least one action id, but got '~a'")
(length ids)))
(hetzner-api-list
api "/actions" "actions" json->hetzner-action
#:params `(("id" . ,(string-join (map number->string ids) ","))))))
(define* (hetzner-api-action-wait api action #:optional (status "success"))
"Wait until the ACTION has reached STATUS on the Hetzner API."
(let ((id (hetzner-action-id action)))
(let loop ()
(let ((actions (hetzner-api-actions api (list id))))
(cond
((zero? (length actions))
(raise-exception
(formatted-message (G_ "server action '~a' not found") id)))
((not (= 1 (length actions)))
(raise-exception
(formatted-message
(G_ "expected one server action, but got '~a'")
(length actions))))
((string= status (hetzner-action-status (car actions)))
(car actions))
(else
(sleep 5)
(loop)))))))
(define* (hetzner-api-locations api . options)
"Get deployment locations from the Hetzner API."
(apply hetzner-api-list api "/locations" "locations" json->hetzner-location options))
(define* (hetzner-api-server-create
api name ssh-keys
#:key
(enable-ipv4? #t)
(enable-ipv6? #t)
(image %hetzner-default-server-image)
(labels '())
(location %hetzner-default-server-location)
(public-net #f)
(server-type %hetzner-default-server-type)
(start-after-create? #f))
"Create a server with the Hetzner API."
(let ((body (hetzner-api-post
api "/servers"
#:body `(("image" . ,image)
("labels" . ,labels)
("name" . ,name)
("public_net"
. (("enable_ipv4" . ,enable-ipv4?)
("enable_ipv6" . ,enable-ipv6?)))
("location" . ,location)
("server_type" . ,server-type)
("ssh_keys" . ,(apply vector (map hetzner-ssh-key-id ssh-keys)))
("start_after_create" . ,start-after-create?)))))
(hetzner-api-action-wait api (hetzner-api-body-action body))
(json->hetzner-server (assoc-ref body "server"))))
(define (hetzner-api-server-delete api server)
"Delete the SERVER with the Hetzner API."
(let ((body (hetzner-api-delete api (hetzner-server-path server))))
(hetzner-api-action-wait api (hetzner-api-body-action body))))
(define* (hetzner-api-server-enable-rescue-system
api server ssh-keys #:key (type "linux64"))
"Enable the rescue system for SERVER with the Hetzner API."
(let* ((ssh-keys (apply vector (map hetzner-ssh-key-id ssh-keys)))
(body (hetzner-api-post
api (hetzner-server-path server "/actions/enable_rescue")
#:body `(("ssh_keys" . ,ssh-keys)
("type" . ,type)))))
(hetzner-api-action-wait api (hetzner-api-body-action body))))
(define* (hetzner-api-servers api . options)
"Get servers from the Hetzner API."
(apply hetzner-api-list api "/servers" "servers" json->hetzner-server options))
(define (hetzner-api-server-power-on api server)
"Send a power on request for SERVER to the Hetzner API."
(let ((body (hetzner-api-post api (hetzner-server-path server "/actions/poweron"))))
(hetzner-api-action-wait api (hetzner-api-body-action body))))
(define (hetzner-api-server-power-off api server)
"Send a power off request for SERVER to the Hetzner API."
(let ((body (hetzner-api-post api (hetzner-server-path server "/actions/poweroff"))))
(hetzner-api-action-wait api (hetzner-api-body-action body))))
(define (hetzner-api-server-reboot api server)
"Send a reboot request for SERVER to the Hetzner API."
(let ((body (hetzner-api-post api (hetzner-server-path server "/actions/reboot"))))
(hetzner-api-action-wait api (hetzner-api-body-action body))))
(define* (hetzner-api-ssh-key-create api name public-key #:key (labels '()))
"Create a SSH key with the Hetzner API."
(let ((body (hetzner-api-post
api "/ssh_keys"
#:body `(("name" . ,name)
("public_key" . ,public-key)
("labels" . ,labels)))))
(json->hetzner-ssh-key (assoc-ref body "ssh_key"))))
(define (hetzner-api-ssh-key-delete api ssh-key)
"Delete the SSH key on the Hetzner API."
(hetzner-api-delete api (hetzner-ssh-key-path ssh-key))
#t)
(define* (hetzner-api-ssh-keys api . options)
"Get SSH keys from the Hetzner API."
(apply hetzner-api-list api "/ssh_keys" "ssh_keys"
json->hetzner-ssh-key options))
(define* (hetzner-api-server-types api . options)
"Get server types from the Hetzner API."
(apply hetzner-api-list api "/server_types" "server_types"
json->hetzner-server-type options))