~egtann/cup

ref: ebaa412b7f86cc1c475b6972b1eeb4d1acb01901 cup/cup.go -rw-r--r-- 10.2 KiB
ebaa412b — Evan Tann use new upfile syntax 4 months ago
                                                                                
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
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
package main

import (
	"encoding/json"
	"flag"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strings"
)

// manifest describes the files to copy and how to start and stop the service.
type manifest struct {
	// name is populated using the name of the file
	name string

	// Files to rsync, copy, chmod and chown. By default partial paths will
	// be placed relative to your ssh user's home directory, but you can
	// override this in the Default section below.
	Files map[string]fileOpts `json:"files"`

	// Stop the service. This will run after rsync but before anything
	// else (mv, chown, start, etc.), so this is a good place to setup
	// needed resources, such as a new user account if not using your ssh
	// login user.
	Stop []string `json:"stop"`

	// Start the service using this series of steps.
	Start []string `json:"start"`

	// Vars defined here will be replaced using the `$substitution` syntax
	// from shell. Vars may be used in any of the other keys or values.
	Vars map[string]string `json:"vars"`

	// Default for generating the script allows you to override cup's
	// default settings.
	Default struct {
		// Remote by default is equivalent to your ssh user's
		// `$HOME/$MANIFEST_NAME`. You may want to override this if
		// your ssh user will not be the user running your service.
		Remote string `json:"remote"`

		// User by default is $UP_USER. Override this if the user
		// running your service does not match your ssh user.
		User string `json:"user"`

		// Group by default is the $user. Override this if you want to
		// chown files by default to a different group than $user.
		Group string `json:"group"`

		// SSH command to run. Use $cmd to indicate where the command
		// should go, such as:
		//
		// "ssh $user@$server $command"
		//
		// The command will be safely quoted for you.
		SSH string `json:"ssh"`

		// Rsync command to run. The default value of this changes
		// depending on the OS. OpenBSD prefers openrsync, whereas
		// other operating systems use rsync directly. Use $files to
		// indicate where the files should go, such as:
		//
		// "rsync -chazP --del $files $user@$server"
		Rsync string `json:"rsync"`

		// Mv command to move files and folders. Generally this is in
		// the form of `sudo mv` or `doas cp -R`.
		Mv string `json:"mv"`

		// Chown command to change ownership of files and folders.
		// Generally this in the form of `sudo chown -R`
		Chown string `json:"chown"`

		// Chmod command to change permission bits of files and
		// folders. Generally this in the form of `sudo chmod -R`
		Chmod string `json:"chmod"`

		// Mkdir command to create a directory and all needed
		// subdirectories. Generally this is in the form of
		// `sudo mkdir -p`
		Mkdir string `json:"mkdir"`
	} `json:"default"`
}

type fileOpts struct {
	Remote string
	Mod    string
	Own    string
}

func main() {
	if err := run(); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

// sshCommand returns an ssh command capable of running as-is.
func sshCommand(base, cmd string) (string, error) {
	const replace = "$command"
	if !strings.Contains(base, replace) {
		return "", fmt.Errorf("missing %s", replace)
	}
	return strings.Replace(base, replace, cmd, 1), nil
}

// rsync returns an rsync command capable of running as-is. Base is the rsync
// command itself to run, name is the name of the manifest file.
func rsync(base, name string, files []string) (string, error) {
	const (
		replace1 = "$files"
		replace2 = "$app"
	)
	if !strings.Contains(base, replace1) {
		return "", fmt.Errorf("missing %s", replace1)
	}
	if !strings.Contains(base, replace2) {
		return "", fmt.Errorf("missing %s", replace2)
	}
	for i, f := range files {
		files[i] = fmt.Sprintf("%q", f)
	}
	out := strings.NewReplacer(
		replace1, strings.Join(files, " "),
		replace2, name,
	).Replace(base)
	return out, nil
}

func defaultRemote(name string) string {
	return filepath.Join("/", "home", "$user", name)
}

func run() error {
	name := flag.String("f", "manifest.json", "manifest filepath")
	verbose := flag.Bool("v", false, "verbose upfile for debugging")
	flag.Parse()

	// Parse manifest.json
	fi, err := os.Open(*name)
	if err != nil {
		return fmt.Errorf("open manifest: %w", err)
	}
	defer fi.Close()

	var man manifest
	if err := json.NewDecoder(fi).Decode(&man); err != nil {
		return fmt.Errorf("decode manifest: %w", err)
	}

	// Set defaults per the documentation
	man.name = strings.TrimSuffix(filepath.Base(*name), filepath.Ext(*name))
	if man.Default.User == "" {
		man.Default.User = "$UP_USER"
	}
	if man.Default.Group == "" {
		man.Default.Group = "$UP_USER"
	}
	if man.Default.Remote == "" {
		man.Default.Remote = defaultRemote(man.name)
	}
	if man.Default.SSH == "" {
		man.Default.SSH = "ssh $user@$server $command"
	}
	if man.Default.Rsync == "" {
		man.Default.Rsync = "rsync -chazP --del --chmod=700 $files $user@$server:$app/"
	}
	if man.Default.Mv == "" {
		// `cp -R` is used in place of `mv` because it dramatically
		// speeds up rsync transfers, since the files are left in
		// place. This will accumulate "cruft" in your user's remote
		// directory which you may want to clear out.
		man.Default.Mv = "sudo cp -R"
	}
	if man.Default.Chown == "" {
		man.Default.Chown = "sudo chown -R"
	}
	if man.Default.Chmod == "" {
		man.Default.Chmod = "sudo chmod -R"
	}
	if man.Default.Mkdir == "" {
		man.Default.Mkdir = "sudo mkdir -p"
	}

	// Allow use of several vars if not already set explicitly in the
	// manifest
	if man.Vars["user"] == "" {
		man.Vars["user"] = man.Default.User
	}
	if man.Vars["remote"] == "" {
		man.Vars["remote"] = man.Default.Remote
	}
	if man.Vars["manifest"] == "" {
		man.Vars["manifest"] = man.name
	}

	// Add lines to the Upfile to stop the service, sync all files, then
	// start it again. We stop before transferring to allow for safely
	// using `rsync --del` and to prevent "Text file busy" errors when
	// overwriting files which are being used by a running process.
	var script string
	for _, line := range man.Stop {
		addLine(*verbose, &script, line, man.Default.SSH)
	}

	// Ensure we make each directory if it doesn't exist, but only once
	mkdirs := map[string]struct{}{}
	for file, opts := range man.Files {
		if opts.Remote == "" || !filepath.IsAbs(opts.Remote) {
			opts.Remote = filepath.Join(man.Default.Remote,
				filepath.Base(file))
		}
		remoteDir := filepath.Dir(opts.Remote)
		if remoteDir == "." {
			continue
		}
		if _, ok := mkdirs[remoteDir]; ok {
			continue
		}
		mkdirs[remoteDir] = struct{}{}
		mkdir := fmt.Sprintf("%s %q", man.Default.Mkdir, remoteDir)
		addLine(*verbose, &script, mkdir, man.Default.SSH)
	}

	// Ensure the remote directory is owned by the user
	line := fmt.Sprintf("%s $user:$user %q", man.Default.Chown,
		man.Default.Remote)
	addLine(*verbose, &script, line, man.Default.SSH)

	// Generate a script to cp, chmod, chown files. It's very important the
	// copying happens first, since we only want to update permissions on
	// the remote files.
	var files []string
	for file, opts := range man.Files {
		files = append(files, file)

		// Ensure we move the file to the correct location based on our
		// default remote location if we haven't specified a remote
		// explicitly.
		var skipCopy bool
		isDefaultRemote := man.Default.Remote == defaultRemote(man.name)
		if opts.Remote == "" && isDefaultRemote {
			skipCopy = true
		}
		mvRemote := man.Default.Remote
		if opts.Remote != "" {
			if filepath.IsAbs(opts.Remote) {
				mvRemote = opts.Remote
			} else {
				mvRemote = filepath.Join(man.Default.Remote,
					opts.Remote)
			}
		}
		if !filepath.IsAbs(opts.Remote) {
			remoteFile := file
			if opts.Remote != "" {
				remoteFile = opts.Remote
			}
			opts.Remote = filepath.Join(man.Default.Remote,
				filepath.Base(remoteFile))
		}
		if opts.Own == "" {
			opts.Own = fmt.Sprintf("%s:%s", man.Default.User,
				man.Default.User)
		}

		if !skipCopy {
			fileShort := filepath.Join(man.name,
				filepath.Base(file))
			line := fmt.Sprintf("%s %q %q", man.Default.Mv,
				fileShort, mvRemote)
			addLine(*verbose, &script, line, man.Default.SSH)
		}

		if opts.Mod != "" && man.Default.User != "" {
			line := fmt.Sprintf("%s %s %q", man.Default.Chmod,
				opts.Mod, opts.Remote)
			addLine(*verbose, &script, line, man.Default.SSH)
		}

		line := fmt.Sprintf("%s %s %q", man.Default.Chown, opts.Own,
			opts.Remote)
		addLine(*verbose, &script, line, man.Default.SSH)
	}

	var lines []string
	if len(files) > 0 {
		line, err := rsync(man.Default.Rsync, man.name, files)
		if err != nil {
			return fmt.Errorf("rsync: %w", err)
		}
		lines = append(lines, line)
	}
	for _, line := range man.Start {
		addLine(*verbose, &script, line, man.Default.SSH)
	}

	// Add to upfile a line to perform the above steps using ssh
	if script != "" && !*verbose {
		// Ensure we escape double-quotes in our SSH commands
		cmd := fmt.Sprintf("%q", script)
		script = strings.Replace(man.Default.SSH, "$command", cmd, 1)
	}
	lines = append(lines, script)
	script = strings.Join(lines, "\n\t")

	upfile := fmt.Sprintf(`%s:
	%s`, man.name, script)

	// Replace vars. Sort them by longest to shortest to prevent replacing
	// the wrong string. e.g. If both "$app" and "$app_dir" are defined,
	// then $app_dir must be replaced first.
	var i int
	sortedVars := make([]string, len(man.Vars))
	for k := range man.Vars {
		sortedVars[i] = k
		i++
	}
	sort.Slice(sortedVars, func(i, j int) bool {
		return len(sortedVars[j]) < len(sortedVars[i])
	})

	// Now that we have vars sorted by their lengths, we can assemble our
	// replacer
	i = 0
	replacements := make([]string, len(man.Vars)*2)
	for _, k := range sortedVars {
		replacements[i] = "$" + k
		replacements[i+1] = man.Vars[k]
		i += 2
	}
	replacer := strings.NewReplacer(replacements...)

	// Recursively replace vars within a reasonable limit
	prev := upfile
	for i := 0; i < 128; i++ {
		upfile = replacer.Replace(upfile)
		if prev == upfile {
			break
		}
		prev = upfile
	}
	fmt.Println(upfile)

	return nil
}

func addLine(verbose bool, script *string, line, ssh string) {
	if verbose {
		// Ensure we escape double-quotes in our SSH commands
		line = fmt.Sprintf("%q", line)
		line = strings.Replace(ssh, "$command", line, 1)
	}
	if *script == "" {
		*script = line
		return
	}
	if verbose {
		*script = fmt.Sprintf("%s\n\t%s", *script, line)
	} else {
		*script = fmt.Sprintf("%s && %s", *script, line)
	}
}