aboutsummaryrefslogtreecommitdiff
path: root/src/connection.lisp
blob: 7963c00e313572cbce8fad76d7ce4a96bb465052 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
;;; Consfigurator -- Lisp declarative configuration management system

;;; Copyright (C) 2020-2021  Sean Whitton <spwhitton@spwhitton.name>

;;; This file 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, or (at your option)
;;; any later version.

;;; This file 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 this program.  If not, see <http://www.gnu.org/licenses/>.

(in-package :consfigurator)

;;;; Connections

;; global value gets set in connection/local.lisp, but the symbol is not
;; exported as it should only get bound by DEPLOY*
(defvar *connection* nil
  "Object representing the currently active connection.
Connections dynamically bind this variable and then apply properties.  Its
global value should be regarded as a constant.")

;; generic function operating on keywords which identify connection types
(defgeneric establish-connection (type remaining &key)
  (:documentation
   "Within the context of the current connection, connect to HOST by
establishing a new connection of type TYPE.
Either starts a Lisp image somewhere else, tells it to continue establishing
REMAINING (by telling it to call DEPLOY* with arguments obtained by (locally)
evaluating (list (or REMAINING '(:local)) *host*)), and returns nil, or
returns a object suitable to be the value of *CONNECTION*.

Any implementation which hands over to a remote Lisp image will need to
upload any prerequisite data required by the deployment."))

(defgeneric preprocess-connection-args (type &key)
  (:documentation
   "Hook to allow connection types to do work in the root Lisp before
Consfigurator begins the attempt to establish the connection chain.  The
return value is used as replacement keyword arguments to the connection.

For an example of usage, see the :SUDO connection type."))

(defmethod preprocess-connection-args ((type symbol) &rest args &key)
  (cons type args))

(defclass connection ()
  ((parent
    :initform *connection*
    :documentation
    "The value of *CONNECTION* at the time this connection was established.")))

(defclass lisp-connection (connection) ())

(defclass posix-connection (connection) ())

;;; generic functions to operate on subclasses of CONNECTION

(defgeneric connection-run (connection cmd &optional input)
  (:documentation "Subroutine to run shell commands on the host.

INPUT is a string to send to the shell command's stdin, or a stream which will
be emptied into the shell command's stdin.

Implementations can specialise on both the CONNECTION and INPUT arguments, if
they need to handle streams and strings differently.

Returns (values OUT EXIT) where OUT is either merged stdout and stderr or
stderr followed by stdout, and EXIT is the exit code.  Should not signal any
error condition just because EXIT is non-zero."))

(defmethod connection-run :around ((connection connection) cmd &optional input)
  (declare (ignore cmd input))
  (let ((*connection* (slot-value connection 'parent)))
    (call-next-method)))

(defgeneric connection-readfile (connection path)
  (:documentation "Subroutine to read the contents of files on the host."))

(defmethod connection-readfile :around ((connection connection) path)
  (declare (ignore path))
  (let ((*connection* (slot-value connection 'parent)))
    (call-next-method)))

;; only functional difference between WRITEFILE AND upload is what args they
;; take: a string vs. a path.  for a given connection type, they may have same
;; or different implementations.

(defgeneric connection-writefile (connection path input)
  (:documentation
   "Subroutine to replace/create the contents of files on the host.

INPUT is the new contents of the file or a stream which will produce it.

Implementations can specialise on both the CONNECTION and INPUT arguments, if
they need to handle streams and strings differently."))

(defmethod connection-writefile :around ((connection connection) path contents)
  (declare (ignore path contents))
  (let ((*connection* (slot-value connection 'parent)))
    (call-next-method)))

(defgeneric connection-upload (connection from to)
  (:documentation "Subroutine to upload files to the host.

Only used for uploading prerequisite data, only across the first hop of a
connection, and only to caches.  The point of this function is to allow
specifying a more efficient alternative to CONNECTION-WRITEFILE when data is
in a file on disc rather than in memory, and we are uploading directly from
the root Lisp's machine.  For example, using rsync(1) over SSH."))

(defgeneric connection-teardown (connection)
  (:documentation "Subroutine to disconnect from the host."))

(defmethod connection-teardown :around ((connection connection))
  (let ((*connection* (slot-value connection 'parent)))
    (call-next-method)))

;; many connection types don't need anything to be done to disconnect
(defmethod connection-teardown ((connection connection))
   (values))


;;;; Functions to access the slots of the current connection

;; Used by properties and by implementations of ESTABLISH-CONNECTION.  This is
;; the only code that ever call CONNECTION-RUN, CONNECTION-READFILE and
;; CONNECTION-WRITEFILE directly (except that it might make sense for
;; implementations of CONNECTION-READFILE and CONNECTION-WRITEFILE to call
;; their corresponding implementation of CONNECTION-RUN).

(define-condition run-failed (error)
  ((cmd :initarg :cmd :reader failed-cmd)
   (stdout :initarg :stdout :reader failed-stdout)
   (stderr :initarg :stderr :reader failed-stderr)
   (exit-code :initarg :exit-code :reader failed-exit-code)))

(defmacro with-remote-temporary-file ((file) &body body)
  `(let ((,file (mktemp)))
     (unwind-protect
	  (progn ,@body)
       (connection-run *connection* (format nil "rm -f ~A"
					    (escape-sh-token ,file))))))

(defun mktemp ()
  "Make a temporary file on the remote side."
  (multiple-value-bind (out exit)
      ;; mktemp(1) is not POSIX; the only POSIX way is this m4 way,
      ;; apparently, but even though m4(1) is POSIX it seems like it could
      ;; often be absent, so have a fallback.  Avoid passing any arguments to
      ;; mktemp(1) as these may differ on different platforms.
      (connection-run
       *connection*
       "echo 'mkstemp('${TMPDIR:-/tmp}'/tmp.XXXXXX)' | m4 2>/dev/null || mktemp")
    (if (= exit 0)
	(car (lines out))
	(error 'run-failed :cmd "(attempt to make a temporary file on remote)"
			   :stdout out
			   :stderr "(merged with stdout)"
			   :exit-code exit))))

(defmacro %process-run-args (&body forms)
  `(let (cmd input may-fail for-exit env)
    (loop for arg = (pop args)
	  do (case arg
	       (:for-exit (setq may-fail t for-exit t))
	       (:may-fail (setq may-fail t))
	       (:input (setq input (pop args)))
	       (:env (setq env (pop args)))
	       (t (push (typecase arg (pathname (unix-namestring arg)) (t arg))
			cmd)))
	  while args
	  finally (nreversef cmd))
    (setq cmd (if (cdr cmd) (escape-sh-command cmd) (car cmd)))
    (loop while env
	  collect (format nil "~A=~A" (symbol-name (pop env)) (pop env))
	    into accum
	  finally
	     (when accum
	       (setq cmd (format nil "env ~{~A~^ ~} ~A"
				 (escape-sh-command accum)
				 cmd))))
     ,@forms))

(defun run (&rest args)
  "Synchronous execution of shell commands using the current connection.
ARGS can contain keyword-value pairs (and singular keywords) to specify
aspects of this function's behaviour, and remaining elements of ARGS are the
shell command and its parameters, or, as a special case, a single string
specifying the shell command, with any necessary escaping already performed.
It is recommended that all keywords and corresponding values come first,
followed by argument(s) specifying the shell command to execute.

Keyword arguments accepted:

  - :FOR-EXIT / :MAY-FAIL -- don't signal an error condition if the command
    does not exit nonzero, usually because it is being called partly or only
    for its exit code

  - :INPUT INPUT -- pass the content of the string or stream INPUT on stdin

  - :ENV ENVIRONMENT -- where ENVIRONMENT is a plist specifying environment
    variable names and values, use env(1) to set these variables when running
    the command.

Returns command's stdout, stderr and exit code, unless :FOR-EXIT, in which
case return only the exit code."
  (%process-run-args
    (with-remote-temporary-file (stdout)
      (setq cmd (format nil "( ~A ) >~A" cmd stdout))
      (multiple-value-bind (err exit)
	  (connection-run *connection* cmd input)
	(let ((out (readfile stdout)))
	  (if (or may-fail (= exit 0))
	      (if for-exit exit (values out err exit))
	      (error 'run-failed
		     :cmd cmd :stdout out :stderr err :exit-code exit)))))))

(defun mrun (&rest args)
  "Like RUN but don't separate stdout and stderr (\"m\" for \"merged\"; note
that this might mean interleaved or simply concatenated, depending on the
connection chain).

Some (but not all) connection types will want to use this when implementing
ESTABLISH-CONNECTION, CONNECTION-RUN, CONNECTION-WRITEFILE etc. to avoid the
overhead of splitting the output streams only to immediately recombine them.

Some :POSIX properties which want to run a lot of commands and don't need to
separate the streams might want to use this too, but usually it is best to
start with RUN."
  (%process-run-args
    (multiple-value-bind (out exit)
	  (connection-run *connection* cmd input)
      (if (or may-fail (= exit 0))
	  (if for-exit exit (values out exit))
	  (error 'run-failed
		 :cmd cmd
		 :stdout out
		 :stderr "(merged with stdout)"
		 :exit-code exit)))))

(defun runlines (&rest args)
  (lines (apply #'run args)))

(defun test (&rest args)
  (= 0 (apply #'run :for-exit "test" args)))

(defun readfile (&rest args)
  (apply #'connection-readfile *connection* args))

(defun writefile (&rest args)
  (apply #'connection-writefile *connection* args))

(defvar *host* nil
  "Object representing the host at the end of the current connection chain.
Deployments bind this variable.  Its global value should remain nil.

The main point of this is to allow properties to access the context in which
they're being applied.")