digital-ocean.scm 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  1. ;;; GNU Guix --- Functional package management for GNU
  2. ;;; Copyright © 2019 Jakob L. Kreuze <zerodaysfordays@sdf.org>
  3. ;;; Copyright © 2020 Brice Waegeneire <brice@waegenei.re>
  4. ;;; Copyright © 2022 Matthew James Kraai <kraai@ftbfs.org>
  5. ;;; Copyright © 2022 Ricardo Wurmus <rekado@elephly.net>
  6. ;;;
  7. ;;; This file is part of GNU Guix.
  8. ;;;
  9. ;;; GNU Guix is free software; you can redistribute it and/or modify it
  10. ;;; under the terms of the GNU General Public License as published by
  11. ;;; the Free Software Foundation; either version 3 of the License, or (at
  12. ;;; your option) any later version.
  13. ;;;
  14. ;;; GNU Guix is distributed in the hope that it will be useful, but
  15. ;;; WITHOUT ANY WARRANTY; without even the implied warranty of
  16. ;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  17. ;;; GNU General Public License for more details.
  18. ;;;
  19. ;;; You should have received a copy of the GNU General Public License
  20. ;;; along with GNU Guix. If not, see <http://www.gnu.org/licenses/>.
  21. (define-module (gnu machine digital-ocean)
  22. #:use-module (gnu machine ssh)
  23. #:use-module (gnu machine)
  24. #:use-module (gnu services)
  25. #:use-module (gnu services base)
  26. #:use-module (gnu services networking)
  27. #:use-module (gnu system)
  28. #:use-module (gnu system pam)
  29. #:use-module (guix base32)
  30. #:use-module (guix derivations)
  31. #:use-module (guix i18n)
  32. #:use-module ((guix diagnostics) #:select (formatted-message))
  33. #:use-module (guix import json)
  34. #:use-module (guix monads)
  35. #:use-module (guix records)
  36. #:use-module (guix ssh)
  37. #:use-module (guix store)
  38. #:use-module (ice-9 format)
  39. #:use-module (ice-9 iconv)
  40. #:use-module (ice-9 string-fun)
  41. #:use-module (json)
  42. #:use-module (rnrs bytevectors)
  43. #:use-module (srfi srfi-1)
  44. #:use-module (srfi srfi-2)
  45. #:use-module (srfi srfi-34)
  46. #:use-module (srfi srfi-35)
  47. #:use-module (ssh key)
  48. #:use-module (ssh sftp)
  49. #:use-module (ssh shell)
  50. #:use-module (web client)
  51. #:use-module (web request)
  52. #:use-module (web response)
  53. #:use-module (web uri)
  54. #:export (digital-ocean-configuration
  55. digital-ocean-configuration?
  56. digital-ocean-configuration-ssh-key
  57. digital-ocean-configuration-tags
  58. digital-ocean-configuration-region
  59. digital-ocean-configuration-size
  60. digital-ocean-configuration-enable-ipv6?
  61. digital-ocean-environment-type))
  62. ;;; Commentary:
  63. ;;;
  64. ;;; This module implements a high-level interface for provisioning "droplets"
  65. ;;; from the Digital Ocean virtual private server (VPS) service.
  66. ;;;
  67. ;;; Code:
  68. (define %api-base "https://api.digitalocean.com")
  69. (define %digital-ocean-token
  70. (make-parameter (getenv "GUIX_DIGITAL_OCEAN_TOKEN")))
  71. (define* (post-endpoint endpoint body)
  72. "Encode BODY as JSON and send it to the Digital Ocean API endpoint
  73. ENDPOINT. This procedure is quite a bit more specialized than 'http-post', as
  74. it takes care to set headers such as 'Content-Type', 'Content-Length', and
  75. 'Authorization' appropriately."
  76. (let* ((uri (string->uri (string-append %api-base endpoint)))
  77. (body (string->bytevector (scm->json-string body) "UTF-8"))
  78. (headers `((User-Agent . "Guix Deploy")
  79. (Accept . "application/json")
  80. (Content-Type . "application/json")
  81. (Authorization . ,(format #f "Bearer ~a"
  82. (%digital-ocean-token)))
  83. (Content-Length . ,(number->string
  84. (bytevector-length body)))))
  85. (port (open-socket-for-uri uri))
  86. (request (build-request uri
  87. #:method 'POST
  88. #:version '(1 . 1)
  89. #:headers headers
  90. #:port port))
  91. (request (write-request request port)))
  92. (write-request-body request body)
  93. (force-output (request-port request))
  94. (let* ((response (read-response port))
  95. (body (read-response-body response)))
  96. (unless (= 2 (floor/ (response-code response) 100))
  97. (raise
  98. (condition (&message
  99. (message (format
  100. #f
  101. (G_ "~a: HTTP post failed: ~a (~s)")
  102. (uri->string uri)
  103. (response-code response)
  104. (response-reason-phrase response)))))))
  105. (close-port port)
  106. (bytevector->string body "UTF-8"))))
  107. (define (fetch-endpoint endpoint)
  108. "Return the contents of the Digital Ocean API endpoint ENDPOINT as an
  109. alist. This procedure is quite a bit more specialized than 'json-fetch', as it
  110. takes care to set headers such as 'Accept' and 'Authorization' appropriately."
  111. (define headers
  112. `((user-agent . "Guix Deploy")
  113. (Accept . "application/json")
  114. (Authorization . ,(format #f "Bearer ~a" (%digital-ocean-token)))))
  115. (json-fetch (string-append %api-base endpoint) #:headers headers))
  116. ;;;
  117. ;;; Parameters for droplet creation.
  118. ;;;
  119. (define-record-type* <digital-ocean-configuration> digital-ocean-configuration
  120. make-digital-ocean-configuration
  121. digital-ocean-configuration?
  122. this-digital-ocean-configuration
  123. (ssh-key digital-ocean-configuration-ssh-key) ; string
  124. (tags digital-ocean-configuration-tags) ; list of strings
  125. (region digital-ocean-configuration-region) ; string
  126. (size digital-ocean-configuration-size) ; string
  127. (enable-ipv6? digital-ocean-configuration-enable-ipv6?)) ; boolean
  128. (define (read-key-fingerprint file-name)
  129. "Read the private key at FILE-NAME and return the key's fingerprint as a hex
  130. string."
  131. (let* ((privkey (private-key-from-file file-name))
  132. (pubkey (private-key->public-key privkey))
  133. (hash (get-public-key-hash pubkey 'md5)))
  134. (bytevector->hex-string hash)))
  135. (define (machine-droplet machine)
  136. "Return an alist describing the droplet allocated to MACHINE."
  137. (let ((tags (digital-ocean-configuration-tags
  138. (machine-configuration machine))))
  139. (find (lambda (droplet)
  140. (equal? (assoc-ref droplet "tags") (list->vector tags)))
  141. (vector->list
  142. (assoc-ref (fetch-endpoint "/v2/droplets") "droplets")))))
  143. (define (machine-public-ipv4-network machine)
  144. "Return the public IPv4 network interface of the droplet allocated to
  145. MACHINE as an alist. The expected fields are 'ip_address', 'netmask', and
  146. 'gateway'."
  147. (and-let* ((droplet (machine-droplet machine))
  148. (networks (assoc-ref droplet "networks"))
  149. (network (find (lambda (network)
  150. (string= "public" (assoc-ref network "type")))
  151. (vector->list (assoc-ref networks "v4")))))
  152. network))
  153. ;;;
  154. ;;; Remote evaluation.
  155. ;;;
  156. (define (digital-ocean-remote-eval target exp)
  157. "Internal implementation of 'machine-remote-eval' for MACHINE instances with
  158. an environment type of 'digital-ocean-environment-type'."
  159. (let* ((network (machine-public-ipv4-network target))
  160. (address (assoc-ref network "ip_address"))
  161. (ssh-key (digital-ocean-configuration-ssh-key
  162. (machine-configuration target)))
  163. (delegate (machine
  164. (inherit target)
  165. (environment managed-host-environment-type)
  166. (configuration
  167. (machine-ssh-configuration
  168. (host-name address)
  169. (identity ssh-key)
  170. (system "x86_64-linux"))))))
  171. (machine-remote-eval delegate exp)))
  172. ;;;
  173. ;;; System deployment.
  174. ;;;
  175. ;; XXX Copied from (gnu services base)
  176. (define* (ip+netmask->cidr ip netmask #:optional (family AF_INET))
  177. "Return the CIDR notation (a string) for @var{ip} and @var{netmask}, two
  178. @var{family} address strings, where @var{family} is @code{AF_INET} or
  179. @code{AF_INET6}."
  180. (let* ((netmask (inet-pton family netmask))
  181. (bits (logcount netmask)))
  182. (string-append ip "/" (number->string bits))))
  183. ;; The following script was adapted from the guide available at
  184. ;; <https://wiki.pantherx.org/Installation-digital-ocean/>.
  185. (define (guix-infect network)
  186. "Given NETWORK, an alist describing the Droplet's public IPv4 network
  187. interface, return a Bash script that will install the Guix system."
  188. (define os
  189. `(operating-system
  190. (host-name "gnu-bootstrap")
  191. (timezone "Etc/UTC")
  192. (bootloader (bootloader-configuration
  193. (bootloader grub-bootloader)
  194. (targets '("/dev/vda"))
  195. (terminal-outputs '(console))))
  196. (file-systems (cons (file-system
  197. (mount-point "/")
  198. (device "/dev/vda1")
  199. (type "ext4"))
  200. %base-file-systems))
  201. (services
  202. (append (list (service static-networking-service-type
  203. (list (static-networking
  204. (addresses
  205. (list (network-address
  206. (device "eth0")
  207. (value ,(ip+netmask->cidr
  208. (assoc-ref network "ip_address")
  209. (assoc-ref network "netmask"))))))
  210. (routes
  211. (list (network-route
  212. (destination "default")
  213. (gateway ,(assoc-ref network "gateway")))))
  214. (name-servers '("84.200.69.80" "84.200.70.40")))))
  215. (simple-service 'guile-load-path-in-global-env
  216. session-environment-service-type
  217. `(("GUILE_LOAD_PATH"
  218. . "/run/current-system/profile/share/guile/site/3.0")
  219. ("GUILE_LOAD_COMPILED_PATH"
  220. . ,(string-append "/run/current-system/profile/lib/guile/3.0/site-ccache:"
  221. "/run/current-system/profile/share/guile/site/3.0"))))
  222. (service openssh-service-type
  223. (openssh-configuration
  224. (log-level 'debug)
  225. (permit-root-login 'prohibit-password))))
  226. %base-services))))
  227. (format #f "#!/bin/bash
  228. apt-get update
  229. apt-get install xz-utils -y
  230. wget -nv https://ci.guix.gnu.org/search/latest/archive?query=spec:tarball+status:success+system:x86_64-linux+guix-binary.tar.xz -O guix-binary-nightly.x86_64-linux.tar.xz
  231. cd /tmp
  232. tar --warning=no-timestamp -xf ~~/guix-binary-nightly.x86_64-linux.tar.xz
  233. mv var/guix /var/ && mv gnu /
  234. mkdir -p ~~root/.config/guix
  235. ln -sf /var/guix/profiles/per-user/root/current-guix ~~root/.config/guix/current
  236. export GUIX_PROFILE=\"`echo ~~root`/.config/guix/current\" ;
  237. source $GUIX_PROFILE/etc/profile
  238. groupadd --system guixbuild
  239. for i in `seq -w 1 10`; do
  240. useradd -g guixbuild -G guixbuild \
  241. -d /var/empty -s `which nologin` \
  242. -c \"Guix build user $i\" --system \
  243. guixbuilder$i;
  244. done;
  245. cp ~~root/.config/guix/current/lib/systemd/system/guix-daemon.service /etc/systemd/system/
  246. systemctl start guix-daemon && systemctl enable guix-daemon
  247. mkdir -p /usr/local/bin
  248. cd /usr/local/bin
  249. ln -s /var/guix/profiles/per-user/root/current-guix/bin/guix
  250. mkdir -p /usr/local/share/info
  251. cd /usr/local/share/info
  252. for i in /var/guix/profiles/per-user/root/current-guix/share/info/*; do
  253. ln -s $i;
  254. done
  255. guix archive --authorize < ~~root/.config/guix/current/share/guix/ci.guix.gnu.org.pub
  256. # guix pull
  257. guix package -i glibc-utf8-locales
  258. export GUIX_LOCPATH=\"$HOME/.guix-profile/lib/locale\"
  259. guix package -i openssl
  260. cat > /etc/bootstrap-config.scm << EOF
  261. (use-modules (gnu))
  262. (use-service-modules base networking ssh)
  263. ~a
  264. EOF
  265. # guix pull
  266. guix system build /etc/bootstrap-config.scm
  267. guix system reconfigure /etc/bootstrap-config.scm
  268. mv /etc /old-etc
  269. mkdir /etc
  270. cp -r /old-etc/{passwd,group,shadow,gshadow,mtab,guix,bootstrap-config.scm} /etc/
  271. guix system reconfigure /etc/bootstrap-config.scm"
  272. ;; Escape the bare backtick to avoid having it interpreted by Bash.
  273. (string-replace-substring
  274. (format #f "~y" os) "`" "\\`")))
  275. (define (machine-wait-until-available machine)
  276. "Block until the initial Debian image has been installed on the droplet
  277. named DROPLET-NAME."
  278. (and-let* ((droplet (machine-droplet machine))
  279. (droplet-id (assoc-ref droplet "id"))
  280. (endpoint (format #f "/v2/droplets/~a/actions" droplet-id)))
  281. (let loop ()
  282. (let ((actions (assoc-ref (fetch-endpoint endpoint) "actions")))
  283. (unless (every (lambda (action)
  284. (string= "completed" (assoc-ref action "status")))
  285. (vector->list actions))
  286. (sleep 5)
  287. (loop))))))
  288. (define (wait-for-ssh address ssh-key)
  289. "Block until the an SSH session can be made as 'root' with SSH-KEY at ADDRESS."
  290. (let loop ()
  291. (catch #t
  292. (lambda ()
  293. (open-ssh-session address #:user "root" #:identity ssh-key))
  294. (lambda args
  295. (sleep 5)
  296. (loop)))))
  297. (define (add-static-networking target network)
  298. "Return an <operating-system> based on TARGET with a static networking
  299. configuration for the public IPv4 network described by the alist NETWORK."
  300. (operating-system
  301. (inherit (machine-operating-system target))
  302. (services (cons* (service static-networking-service-type
  303. (list (static-networking
  304. (addresses
  305. (list (network-address
  306. (device "eth0")
  307. (value (ip+netmask->cidr
  308. (assoc-ref network "ip_address")
  309. (assoc-ref network "netmask"))))))
  310. (routes
  311. (list (network-route
  312. (destination "default")
  313. (gateway (assoc-ref network "gateway")))))
  314. (name-servers '("84.200.69.80" "84.200.70.40")))))
  315. (simple-service 'guile-load-path-in-global-env
  316. session-environment-service-type
  317. `(("GUILE_LOAD_PATH"
  318. . "/run/current-system/profile/share/guile/site/3.0")
  319. ("GUILE_LOAD_COMPILED_PATH"
  320. . ,(string-append "/run/current-system/profile/lib/guile/3.0/site-ccache:"
  321. "/run/current-system/profile/share/guile/site/3.0"))))
  322. (operating-system-user-services
  323. (machine-operating-system target))))))
  324. (define (deploy-digital-ocean target)
  325. "Internal implementation of 'deploy-machine' for 'machine' instances with an
  326. environment type of 'digital-ocean-environment-type'."
  327. (maybe-raise-missing-api-key-error)
  328. (maybe-raise-unsupported-configuration-error target)
  329. (let* ((config (machine-configuration target))
  330. (name (machine-display-name target))
  331. (region (digital-ocean-configuration-region config))
  332. (size (digital-ocean-configuration-size config))
  333. (ssh-key (digital-ocean-configuration-ssh-key config))
  334. (fingerprint (read-key-fingerprint ssh-key))
  335. (enable-ipv6? (digital-ocean-configuration-enable-ipv6? config))
  336. (tags (digital-ocean-configuration-tags config))
  337. (request-body `(("name" . ,name)
  338. ("region" . ,region)
  339. ("size" . ,size)
  340. ("image" . "debian-9-x64")
  341. ("ssh_keys" . ,(vector fingerprint))
  342. ("backups" . #f)
  343. ("ipv6" . ,enable-ipv6?)
  344. ("user_data" . #nil)
  345. ("private_networking" . #nil)
  346. ("volumes" . #nil)
  347. ("tags" . ,(list->vector tags))))
  348. (response (post-endpoint "/v2/droplets" request-body)))
  349. (machine-wait-until-available target)
  350. (let* ((network (machine-public-ipv4-network target))
  351. (address (assoc-ref network "ip_address")))
  352. (wait-for-ssh address ssh-key)
  353. (let* ((ssh-session (open-ssh-session address #:user "root" #:identity ssh-key))
  354. (sftp-session (make-sftp-session ssh-session)))
  355. (call-with-remote-output-file sftp-session "/tmp/guix-infect.sh"
  356. (lambda (port)
  357. (display (guix-infect network) port)))
  358. (rexec ssh-session "/bin/bash /tmp/guix-infect.sh")
  359. ;; Session will close upon rebooting, which will raise 'guile-ssh-error.
  360. (catch 'guile-ssh-error
  361. (lambda () (rexec ssh-session "reboot"))
  362. (lambda args #t)))
  363. (wait-for-ssh address ssh-key)
  364. (let ((delegate (machine
  365. (operating-system (add-static-networking target network))
  366. (environment managed-host-environment-type)
  367. (configuration
  368. (machine-ssh-configuration
  369. (host-name address)
  370. (identity ssh-key)
  371. (system "x86_64-linux"))))))
  372. (deploy-machine delegate)))))
  373. ;;;
  374. ;;; Roll-back.
  375. ;;;
  376. (define (roll-back-digital-ocean target)
  377. "Internal implementation of 'roll-back-machine' for MACHINE instances with an
  378. environment type of 'digital-ocean-environment-type'."
  379. (let* ((network (machine-public-ipv4-network target))
  380. (address (assoc-ref network "ip_address"))
  381. (ssh-key (digital-ocean-configuration-ssh-key
  382. (machine-configuration target)))
  383. (delegate (machine
  384. (inherit target)
  385. (environment managed-host-environment-type)
  386. (configuration
  387. (machine-ssh-configuration
  388. (host-name address)
  389. (identity ssh-key)
  390. (system "x86_64-linux"))))))
  391. (roll-back-machine delegate)))
  392. ;;;
  393. ;;; Environment type.
  394. ;;;
  395. (define digital-ocean-environment-type
  396. (environment-type
  397. (machine-remote-eval digital-ocean-remote-eval)
  398. (deploy-machine deploy-digital-ocean)
  399. (roll-back-machine roll-back-digital-ocean)
  400. (name 'digital-ocean-environment-type)
  401. (description "Provisioning of \"droplets\": virtual machines
  402. provided by the Digital Ocean virtual private server (VPS) service.")))
  403. (define (maybe-raise-missing-api-key-error)
  404. (unless (%digital-ocean-token)
  405. (raise (condition
  406. (&message
  407. (message (G_ "No Digital Ocean access token was provided. This \
  408. may be fixed by setting the environment variable GUIX_DIGITAL_OCEAN_TOKEN to \
  409. one procured from https://cloud.digitalocean.com/account/api/tokens.")))))))
  410. (define (maybe-raise-unsupported-configuration-error machine)
  411. "Raise an error if MACHINE's configuration is not an instance of
  412. <digital-ocean-configuration>."
  413. (let ((config (machine-configuration machine))
  414. (environment (environment-type-name (machine-environment machine))))
  415. (unless (and config (digital-ocean-configuration? config))
  416. (raise (formatted-message (G_ "unsupported machine configuration '~a' \
  417. for environment of type '~a'")
  418. config
  419. environment)))))