-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathindex.coffee
407 lines (362 loc) · 17.1 KB
/
index.coffee
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
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
os = require 'os'
fs = require 'fs'
path = require 'path'
crypto = require 'crypto'
TemplateRenderer = require './template_renderer'
delay = (s, f) -> setTimeout f, s
bash_esc = (s) -> (''+s).replace `/([^0-9a-z-])/gi`, '\\$1'
bash_prefix = (pre, val) -> if val then " #{pre}#{val}" else ''
tmp_file = (seed) ->
ver = crypto
.createHash('sha1')
.update(seed)
.digest('hex')+Math.random().toString(36).substring(2,8)
module.exports = -> _.assign @,
die = (reason) => @die(reason)() # immediate death; doesn't want for 2nd-pass
# use with resources that accept multiple values in the name argument
getNames: (names) =>
names = if Array.isArray names then names else names.compact().split ' '
die "One or more names are required." if names.length is 0
return names
# used to asynchronously iterate an array or an object in series
each: (o, done_cb, each_cb, i=0) =>
if Array.isArray o
if o[i]?
each_cb o[i], => @each o, done_cb, each_cb, i+1
else
done_cb()
else if typeof o is 'object'
tuples = []
for own k of o
tuples.push [k, o[k]]
@each tuples, done_cb, each_cb
else # empty
done_cb() # carry on
# use when you are sure the cmd does not need to be os agnostic,
# or when you are sure you will only ever operate on one os
execute: (cmd, [o]...) => (cb) =>
o ||= {}
sudo = ''
if o.sudo
if typeof o.sudo is 'boolean' and o.sudo is true
sudo = 'sudo '
else if typeof o.sudo is 'string'
sudo = 'sudo '
sudo += "-u#{o.sudo} " if o.sudo isnt 'root'
cmd = "#{if o.cwd then "cd #{o.cwd} && " else ""}#{sudo}#{cmd}"
else if o.su
cmd = if o.cwd then "cd #{o.cwd} && #{cmd}" else cmd
cmd = "sudo su - #{if typeof o.su is 'string' and o.su isnt 'root' then o.su+' ' else ''}-c #{bash_esc cmd}"
tries_remaining = o.retry
try_again = null; try_again = =>
error = null; out = ''; o.data ||= (data) => out += data
@ssh.cmd cmd, o, (code) =>
# use test in situations where a single test command could avoid
# additional, long-running, or potentially destructive commands;
# for when you want to do your own custom logic to handle and react
# to the result of the execution.
if typeof o.test is 'function'
@inject_flow(=> o.test code: code, out: out)(cb)
return
o.expect ||= 0 unless o.ignore_errors
if 'expect' of o
error = switch typeof o.expect
when 'object' then if null is out.match o.expect # rx match output
"Expected regex #{o.expect} to match output, but it doesn't."
when 'number' then if code isnt o.expect # int match exit code
"Expected exit code #{o.expect}, but got #{code}."
when 'string' then if -1 is out.indexOf o.expect # case-sensitive string match output
"Expected string #{JSON.stringify o.expect} to match case-sensitive output, but it doesn't."
else
"Unexpected typeof expect passed to @execute(). Cannot continue."
if code isnt 0
if not error
@log("NOTICE: Non-zero exit code was expected. Will continue.") =>
else if o.ignore_errors
@log("NOTICE: Non-zero exit code can be ignored. Will continue.") =>
if error
if o.retry
if --tries_remaining > 0
@log(type: 'err', "#{error} Will try again...") =>
try_again()
else
@then @die "#{error} Tried #{o.retry} times. Giving up."
else
@then @die error
else
cb (if o.ignore_errors then null else error), code: code, out: out
try_again()
# appends line only if no matching line is found
append_line_to_file: (file, [o]...) => @inject_flow =>
die "@append_line_to_file() unless_find: and append: are required. you passed: "+JSON.stringify(arguments) unless o?.unless_find and o?.append
@then @execute "grep #{bash_esc o.unless_find} #{bash_esc file}", _.merge o, test: ({code}) =>
if code is 0
@then @log "Matching line found, not appending"
else
@then @log "Matching line not found, appending..."
@then @execute "echo #{bash_esc o.append} | sudo tee -a #{bash_esc file}", _.merge o, test: ({code}) =>
@then @die "FATAL ERROR: unable to append line." unless code is 0
# replaces line only when/where matching line is found
replace_line_in_file: (file, [o]...) => @inject_flow =>
die "@replace_line_in_file() find: and replace: are required. you passed: "+JSON.stringify arguments unless o?.find and o?.replace
@then @execute "grep #{bash_esc o.find} #{bash_esc file}", _.merge o, test: ({code}) =>
if code is 0
@then @log "Matching line found, replacing..."
ver = tmp_file file
@then @execute "sed #{bash_esc "s/#{o.find}.*/#{bash_esc o.replace}/"} #{bash_esc file} > #{bash_esc "/tmp/remote-#{ver}"}", _.merge o, test: ({code}) =>
@then @execute "mv #{bash_esc "/tmp/remote-#{ver}"} #{bash_esc file}", _.merge o, test: ({code}) =>
@then @die "FATAL ERROR: unable to replace line." unless code is 0
else
@then @log "Matching line not found, not replacing"
package_update: => @inject_flow =>
# TODO: save .dotfile on remote host remembering last update date between sessions,
# and then check it and only run when its not there or has been >24hrs
@then @execute 'apt-get update', sudo: true, retry: 3, expect: 0
# also update packages to latest releases
@then @execute 'DEBIAN_FRONTEND=noninteractive apt-get dist-upgrade -y', sudo: true, retry: 3, expect: 0
install: (pkgs, [o]...) => @inject_flow =>
@then @execute "dpkg -s #{@getNames(pkgs).join ' '} 2>&1 | grep 'is not installed and'", test: ({code}) =>
return @then @log "Skipping package(s) already installed." if code isnt 0
@then @execute "DEBIAN_FRONTEND=noninteractive apt-get install -y "+
"#{@getNames(pkgs).join ' '}", sudo: true, retry: 3, expect: 0
uninstall: (pkgs, [o]...) => @inject_flow =>
@then @execute "dpkg -s #{@getNames(pkgs).join ' '} 2>&1 | grep 'install ok installed'", test: ({code}) =>
return @then @log "Skipping package(s) already uninstalled." if code isnt 0
@then @execute "DEBIAN_FRONTEND=noninteractive apt-get "+
"#{if o?.purge then 'purge' else 'uninstall'}"+
" -y #{@getNames(pkgs).join ' '}", sudo: true, expect: 0
service: (pkgs, [o]...) => @inject_flow =>
for pkg in @getNames(pkgs)
@then @execute "service "+
"#{pkg}"+
" #{o?.action or 'start'}", sudo: true, expect: 0
chown: (paths, [o]...) => @inject_flow =>
die "@chown() owner and/or group are required." unless o?.owner or o?.group
for _path in @getNames(paths)
@then @execute "chown "+
"#{if o?.recursive then '-R ' else ''}"+
"#{o?.owner}"+
".#{o?.group}"+
" #{_path}", o, expect: 0
chmod: (paths, o) => @inject_flow =>
die "mode is required." unless o?.mode
for _path in @getNames(paths)
@then @execute "chmod "+
"#{if o?.recursive then '-R ' else ''}"+
"#{o?.mode}"+
" #{_path}", o, expect: 0
directory: (paths, [o]...) => @inject_flow =>
o ||= {}; o.mode ||= '0755'
recursive = o.recursive
for _path in @getNames(paths)
do (_path) =>
@then @execute "test -d #{_path}", test: ({code}) =>
if code is 0
@then @log 'Skipping existing directory.'
else
@then @execute "mkdir #{if recursive then ' -p' else ''} #{_path}", o
delete o.recursive # creating directories recursively != chown/chmod recursively
@then @chown [_path], o if o.owner or o.group
@then @chmod [_path], o if o.mode
template: (paths, [o]...) => @inject_flow =>
if o?.hasOwnProperty 'content'
# template from string
o.to = paths
template = o.content
template = @decrypt o.content if o?.decrypt
else
# template from disk
paths = path.join.apply null, @getNames paths
template = fs.readFileSync "#{paths}.coffee", encoding: if o?.decrypt then 'binary' else 'utf-8'
template = @decrypt template if o?.decrypt
die "to is required." unless o?.to
# compile template variables
# from @server attributes
variables = server: @server, networks: @networks
# and provided variables key
if o?.variables
variables = _.merge o.variables, variables
o.variables = null # prevent template from accidentally modifying provided object
# render template from variables
output = TemplateRenderer.render.apply variables, [template]
# write template temporarily to local disk
@then @log "rendering file #{o.to} version #{ver}"
@then -> console.log "---- BEGIN FILE ----\n#{output}\n--- END FILE ---"
# write string to file on local disk
# NOTICE: for windows compatibility this could go into __dirname locally
ver = tmp_file output
tmpFile = path.join os.tmpdir()+'/', 'local-'+ver
o.final_to = o.to; o.to = '/tmp/remote-'+ver
@then @call fs.writeFile, tmpFile, output
# upload file to remote disk
@then @upload tmpFile, o
# delete temporary file from local disk
@then @call fs.unlink, tmpFile, err: ->
remote_file_exists: (file, [o]...) => @inject_flow =>
die "@remote_file_exists true: or false: callback function is required." unless o?.true or o?.false
unless o.compare_local_file or o.compare_checksum
@then @execute "stat #{file}", sudo: o.sudo, test: ({code}) =>
if code is 0
@then @log "Remote file #{file} exists."
o.true?()
else
o.false?()
else
if o.compare_local_file
local_checksum = ''
@then (cb) => fs.readFile o.compare_local_file, (err, data) =>
@then @die err if err
local_checksum = @checksum data, 'sha256'
cb()
else if o.compare_checksum
local_checksum = o.compare_checksum
@then @execute "sha256sum #{file}", sudo: o.sudo, test: ({out}) =>
if null isnt matches = out.match /[a-f0-9]{64}/
if matches[0] is local_checksum
@then @log "Remote file checksum #{matches[0]} matches expected checksum #{local_checksum}."
o.true?()
else
@then @log "Remote file checksum #{matches[0]} does not match expected checksum #{local_checksum}."
o.false?()
else
@then @log "Unexpected problem reading remote file checksum. Assuming remote file checksum does not match expected checksum #{local_checksum}."
o.false?()
# upload a file from localhost to the remote host with sftp
upload: (paths, [o]...) => @inject_flow (end) =>
if Array.isArray paths
_path = path.join.apply null, paths
else
_path = paths
die "to is required." unless o?.to
ver = tmp_file _path
if o.decrypt
local_tmp = os.tmpdir()+"/local-#{ver}"
# decrypt file to temporary location on local disk for easy upload
@then @log "Decrypting file #{_path}..."
@then (cb) => fs.writeFileSync local_tmp, @decrypt fs.readFileSync _path; cb()
else
local_tmp = _path
unless o.final_to
final_to = o.to
to = "/tmp/remote-#{ver}"
else
final_to = o.final_to
to = o.to
@then @remote_file_exists final_to, sudo: o.sudo, true: =>
@then @remote_file_exists final_to, compare_local_file: local_tmp, sudo: o.sudo
, true: =>
@then @log "Upload would be pointless since checksums match; skipping to save time."
@then @chown [final_to], o
@then @chmod [final_to], o
end()
@then (cb) =>
@log("SFTP uploading #{fs.statSync(local_tmp).size} #{if o.decrypt then 'decrypted ' else ''}bytes from #{JSON.stringify _path} to #{JSON.stringify final_to}#{if final_to isnt o.to then " through temporary file #{JSON.stringify to}" else ""}...")(cb)
@then @call @ssh.put, local_tmp, to, err: (err) =>
die "error during SFTP file transfer: #{err}" if err
@then @log "SFTP upload complete."
if o.decrypt
# delete temporarily decrypted version of the file from local disk
@then @call fs.unlink, local_tmp, err: ->
# set ownership and permissions
@then @chown to, o
@then @chmod to, o
# move into final location
@then @execute "mv #{to} #{final_to}", sudo: o.sudo, expect: 0
# download a file from the internet to the remote host with wget
download: (uris, [o]...) => @inject_flow (end) =>
die "to is required." unless o?.to
for uri in @getNames uris
do (uri) =>
@then @remote_file_exists o.to, sudo: o.sudo, true: =>
if o.checksum
@then @remote_file_exists o.to, compare_checksum: o.checksum, sudo: o.sudo
, true: =>
@then @log "Download would be pointless since checksums match; skipping to save time."
end()
# download
@then @execute "wget -nv #{uri}"+
(bash_prefix '-P ', o.path)+
(bash_prefix '-O ', o.to), o
# verify download
@then @remote_file_exists o.to, compare_checksum: o.checksum, sudo: o.sudo, false: =>
@then @die "Download failed; the checksum for the data we download doesn't match is was expected."
# set ownership and permissions
@then @chown o.to, o
@then @chmod o.to, o
link: (src, [o]...) => @inject_flow =>
die "target is required." unless o?.target
@then @execute "test -L #{o.target}", test: ({code}) =>
unless code is 1
@then @execute "rm #{o.target}", o
@then @execute "ln -s #{src} #{o.target}", o, expect: 0
user: (name, [o]...) => @inject_flow (end) =>
@then @execute "id #{name}", test: ({code}) =>
return end "user #{name} exists." if code is 0
@then @execute "useradd #{name} \\\n"+
" --create-home \\\n"+
" --user-group \\\n"+
(if o?.comment then " --comment #{bash_esc o.comment} \\\n" else "")+
(if o?.password then " --password #{bash_esc o.password} \\\n" else "")+
" --shell #{o.shell or "/bin/bash"} \\\n"
, sudo: o?.sudo
if o?.group_name
@then @execute "usermod -g #{o.group_name} #{name}", sudo: o?.sudo
if o?.groups?.length > 0
for group in o.groups
@then @execute "usermod -a -G #{group} #{name}", sudo: o?.sudo
if o?.ssh_keys?.length > 0
for key in o.ssh_keys
@then @directory ["$(echo ~#{name})/.ssh/"],
recursive: true
mode: '0700'
sudo: o?.sudo
@then @execute "touch $(echo ~#{name})/.ssh/authorized_keys", sudo: o?.sudo
@then @chmod ["$(echo ~#{name})/.ssh/authorized_keys"],
mode: '0600'
sudo: o?.sudo
@then @execute "echo #{bash_esc key} | sudo tee -a $(echo ~#{name})/.ssh/authorized_keys >/dev/null"
@then @chown ["$(echo ~#{name})/.ssh"],
recursive: true
owner: name
group: name
sudo: o?.sudo
group: (name, [o]...) => @inject_flow =>
@then @execute "groupadd #{name}", _.merge ignore_errors: true, o
deploy: (name, [o]...) => @inject_flow =>
# TODO: support shared dir, cached-copy, and symlinking logs and other stuff
# TODO: support keep_releases
o.sudo = o.owner
o.keep_releases ||= 3
privateKeyPath = "$(echo ~#{o.owner})/.ssh/id_rsa" # TODO: make this a safer name; to avoid overwriting existing file
@then @directory ["$(echo ~#{o.owner})/"], owner: o.owner, group: o.group, sudo: true, recursive: true, mode: '0700'
@then @directory ["$(echo ~#{o.owner})/.ssh/"], owner: o.owner, group: o.group, sudo: true, recursive: true, mode: '0700'
# write ssh key to ~/.ssh/
@then @template privateKeyPath,
content: o.git.deployKey
owner: o.owner
group: o.group
mode: '0600'
sudo: true
# create the release dir
@then @execute 'echo -e "Host github.com\\n\\tStrictHostKeyChecking no\\n" | '+"sudo -u #{o.sudo} tee -a $(echo ~#{o.owner})/.ssh/config" # TODO: find a better alternative
@then @execute "git ls-remote #{o.git.repo} #{o.git.branch}", sudo: o.sudo, test: ({out}) =>
if null is matches = out.match `/[a-f0-9]{40}/`
@then @die "github repo didn't have the branch we're expecting #{o.git.branch}"
remoteRef = matches[0]
@then @directory o.deploy_to, owner: o.owner, group: o.group, sudo: true, recursive: true
release_dir = "#{o.deploy_to}/releases/#{remoteRef}"
@then @directory release_dir, owner: o.owner, group: o.group, sudo: true, recursive: true
@then @execute "git clone -b #{o.git.branch} #{o.git.repo} #{release_dir}", sudo: o.sudo, ignore_errors: true
@then @link release_dir, target: "#{o.deploy_to}/current", sudo: o.sudo
reboot: ([o]...) => @inject_flow =>
o ||= {}; o.wait ||= 60*1000
@then @log '''
###############################
##### REBOOTING SERVER ####### (╯°o°)╯ ︵ ┻━┻
###############################
'''
@then @execute "reboot", sudo: true
@then @log "waiting #{o.wait}ms for server to reboot..."
@then (cb) => delay o.wait, cb
@then @log "attempting to re-establish ssh connection"
@then (cb) => @ssh.connect cb