;;; remote.scm -- Build on remote machines. ;;; Copyright © 2020 Mathieu Othacehe ;;; ;;; This file is part of Cuirass. ;;; ;;; 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 . (define-module (cuirass remote) #:use-module (cuirass logging) #:use-module (guix avahi) #:use-module (guix config) #:use-module (guix derivations) #:use-module (guix records) #:use-module (guix store) #:use-module (guix ui) #:use-module (guix utils) #:use-module (guix build download) #:use-module (guix build syscalls) #:use-module ((guix build utils) #:select (dump-port mkdir-p)) #:use-module (guix scripts publish) #:use-module (simple-zmq) #:use-module (zlib) #:use-module (rnrs bytevectors) #:use-module (srfi srfi-1) #:use-module (srfi srfi-11) #:use-module (srfi srfi-26) #:use-module (ice-9 match) #:use-module (ice-9 rdelim) #:use-module (ice-9 threads) #:export (worker worker? worker-address worker-name worker-publish-url worker-systems worker-last-seen worker->sexp sexp->worker generate-worker-name %worker-timeout server server? server-address server-port server-log-port server-publish-url publish-url avahi-service->server publish-server set-build-options* strip-store-prefix log-path receive-logs send-log zmq-poll* zmq-socket-ready? zmq-empty-delimiter zmq-build-request-message zmq-no-build-message zmq-build-started-message zmq-build-failed-message zmq-build-succeeded-message zmq-worker-ping zmq-worker-ready-message zmq-worker-request-work-message zmq-read-message remote-server-service-type)) ;;; ;;; Workers. ;;; (define-record-type* worker make-worker worker? (address worker-address) (name worker-name) (publish-url worker-publish-url (default #f)) (systems worker-systems) (last-seen worker-last-seen (default 0))) (define (worker->sexp worker) "Return an sexp describing WORKER." (let ((address (worker-address worker)) (name (worker-name worker)) (systems (worker-systems worker)) (last-seen (worker-last-seen worker))) `(worker (address ,address) (name ,name) (systems ,systems) (last-seen ,last-seen)))) (define (sexp->worker sexp) "Turn SEXP, an sexp as returned by 'worker->sexp', into a record." (match sexp (('worker ('address address) ('name name) ('systems systems) ('last-seen last-seen)) (worker (address address) (name name) (systems systems) (last-seen last-seen))))) (define %seed (seed->random-state (logxor (getpid) (car (gettimeofday))))) (define (integer->alphanumeric-char n) "Map N, an integer in the [0..62] range, to an alphanumeric character." (cond ((< n 10) (integer->char (+ (char->integer #\0) n))) ((< n 36) (integer->char (+ (char->integer #\A) (- n 10)))) ((< n 62) (integer->char (+ (char->integer #\a) (- n 36)))) (else (error "integer out of bounds" n)))) (define (random-string len) "Compute a random string of size LEN where each character is alphanumeric." (let loop ((chars '()) (len len)) (if (zero? len) (list->string chars) (let ((n (random 62 %seed))) (loop (cons (integer->alphanumeric-char n) chars) (- len 1)))))) (define (generate-worker-name) "Return the service name of the server." (string-append (gethostname) "-" (random-string 4))) (define %worker-timeout (make-parameter 120)) ;;; ;;; Server. ;;; (define-record-type* server make-server server? (address server-address) (port server-port) (log-port server-log-port) (publish-url server-publish-url)) (define (publish-url address port) "Return the publish url at ADDRESS and PORT." (string-append "http://" address ":" (number->string port))) (define (avahi-service->params service) "Return the URL of the publish server corresponding to the service with the given NAME." (define (service-txt->params txt) "Parse the service TXT record." (fold (lambda (param params) (match (string-split param #\=) ((key value) (cons (cons (string->symbol key) value) params)))) '() txt)) (define (number-param params param) (string->number (assq-ref params param))) (let* ((address (avahi-service-address service)) (txt (avahi-service-txt service)) (params (service-txt->params txt)) (log-port (number-param params 'log-port)) (publish-port (number-param params 'publish-port)) (publish-url (publish-url address publish-port))) `((#:log-port . ,log-port) (#:publish-url . ,publish-url)))) (define (avahi-service->server service) (let* ((address (avahi-service-address service)) (port (avahi-service-port service)) (params (avahi-service->params service)) (log-port (assq-ref params #:log-port)) (publish-url (assq-ref params #:publish-url))) (server (address address) (port port) (log-port log-port) (publish-url publish-url)))) ;;; ;;; Store publishing. ;;; (define* (set-build-options* store url #:key timeout max-silent) "Add URL to the list of STORE substitutes-urls." (set-build-options store #:use-substitutes? #t #:fallback? #t #:keep-going? #t #:timeout timeout #:max-silent-time max-silent #:verbosity 1 #:substitute-urls (cons url %default-substitute-urls))) (define* (publish-server port #:key public-key private-key) "This procedure starts a publishing server listening on PORT in a new process and returns the pid of the forked process. Use PUBLIC-KEY and PRIVATE-KEY to sign narinfos." (match (primitive-fork) (0 (parameterize ((%public-key public-key) (%private-key private-key)) (with-store store (let ((log-file (open-file "/tmp/publish.log" "w"))) (close-fdes 1) (close-fdes 2) (dup2 (fileno log-file) 1) (dup2 (fileno log-file) 2) (close-port log-file) (let* ((address (make-socket-address AF_INET INADDR_ANY 0)) (socket-address (make-socket-address (sockaddr:fam address) (sockaddr:addr address) port)) (socket (open-server-socket socket-address))) (run-publish-server socket store #:compressions (list %default-gzip-compression))))))) (pid pid))) ;;; ;;; Logs. ;;; (define (strip-store-prefix file) ; Given a file name like "/gnu/store/…-foo-1.2/bin/foo", return ;; "/bin/foo". (let* ((len (string-length %store-directory)) (base (string-drop file (+ 1 len)))) (match (string-index base #\/) (#f base) (index (string-drop base index))))) (define (log-path cache derivation) (let* ((store-hash (strip-store-prefix derivation)) (hash (and=> (string-index store-hash #\-) (cut string-take store-hash <>)))) (string-append cache "/" hash ".log.gz"))) (define (receive-logs port cache) (define (read-log port) (match (false-if-exception (read port)) (('log ('version 0) ('derivation derivation)) (let ((file (log-path cache derivation))) (call-with-output-file file (lambda (output) (dump-port port output))))) (_ (log-message "invalid log received.~%") #f))) (define (wait-for-client port proc) (let ((sock (socket AF_INET SOCK_STREAM 0))) (setsockopt sock SOL_SOCKET SO_REUSEADDR 1) (bind sock AF_INET INADDR_ANY port) (listen sock 1024) (while #t (match (select (list sock) '() '() 60) (((_) () ()) (match (accept sock) ((client . address) (write '(log-server (version 0)) client) (force-output client) (proc client)))) ((() () ()) #f))))) (define (client-handler client) (call-with-new-thread (lambda () (set-thread-name (string-append "log-server-" (number->string (port->fdes client)))) (and=> client read-log) (when client (close-port client))))) (call-with-new-thread (lambda () (set-thread-name "log-server") (wait-for-client port client-handler)))) (define* (send-log address port derivation log) (let* ((sock (socket AF_INET SOCK_STREAM 0)) (in-addr (inet-pton AF_INET address)) (addr (make-socket-address AF_INET in-addr port))) (connect sock addr) (match (select (list sock) '() '() 10) (((_) () ()) (match (read sock) (('log-server ('version version ...)) (let ((header `(log (version 0) (derivation ,derivation)))) (write header sock) (call-with-gzip-output-port sock (lambda (sock-compressed) (dump-port log sock-compressed))) (close-port sock))) (x (log-message "invalid handshake ~s.~%" x) (close-port sock) #f))) ((() () ()) ;timeout (log "timeout while sending files to ~a.~%" port) (close-port sock) #f)))) ;;; ;;; ZMQ. ;;; (define %zmq-context (zmq-create-context)) (define (EINTR-safe proc) "Return a variant of PROC that catches EINTR 'zmq-error' exceptions and retries a call to PROC." (define (safe . args) (catch 'zmq-error (lambda () (apply proc args)) (lambda (key errno . rest) (if (= errno EINTR) (apply safe args) (apply throw key errno rest))))) safe) (define zmq-poll* ;; Return a variant of ZMQ-POLL that catches EINTR errors. (EINTR-safe zmq-poll)) (define (zmq-socket-ready? items socket) "Return #t if the given SOCKET is part of ITEMS, a list returned by a 'zmq-poll' call, return #f otherwise." (find (lambda (item) (eq? (poll-item-socket item) socket)) items)) (define (zmq-read-message msg) (call-with-input-string msg read)) (define (zmq-empty-delimiter) "Return an empty ZMQ delimiter used to format message envelopes." (make-bytevector 0)) ;; ZMQ Messages. (define* (zmq-build-request-message drv #:key priority timeout max-silent timestamp system) "Return a message requesting the build of DRV for SYSTEM." (format #f "~s" `(build (drv ,drv) (priority ,priority) (timeout ,timeout) (max-silent ,max-silent) (timestamp ,timestamp) (system ,system)))) (define (zmq-no-build-message) "Return a message that indicates that no builds are available." (format #f "~s" `(no-build))) (define (zmq-build-started-message drv worker) "Return a message that indicates that the build of DRV has started." (format #f "~s" `(build-started (drv ,drv) (worker ,worker)))) (define* (zmq-build-failed-message drv url #:optional log) "Return a message that indicates that the build of DRV has failed." (format #f "~s" `(build-failed (drv ,drv) (url ,url) (log ,log)))) (define* (zmq-build-succeeded-message drv url #:optional log) "Return a message that indicates that the build of DRV is done." (format #f "~s" `(build-succeeded (drv ,drv) (url ,url) (log ,log)))) (define (zmq-worker-ping worker) "Return a message that indicates that WORKER is alive." (format #f "~s" `(worker-ping ,worker))) (define (zmq-worker-ready-message worker) "Return a message that indicates that WORKER is ready." (format #f "~s" `(worker-ready ,worker))) (define (zmq-worker-request-work-message name) "Return a message that indicates that WORKER is requesting work." (format #f "~s" `(worker-request-work ,name))) (define remote-server-service-type "_remote-server._tcp")