aboutsummaryrefslogtreecommitdiff
path: root/bin/hook/deploy.rb
blob: e7fcc1fcf70eb303d7db6a64135519a7f01b2423 (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
#!/usr/bin/env ruby
# frozen_string_literal: true

#
# deploy.rb: Site deployment script which does the following:
#
# 1. Verifies that the timestamp passed in RFC3339 format as the first
#    command-line argument is within 5 minutes of the current timestamp
#    (to prevent replay attacks).
# 2. Locks the repo directory with `flock` to prevent conflicts.
# 3. Executes `git pull` on the repo directory to pull any upstream
#    changes.
# 4. Executes `hugo --minify -d ...` to build the site to a unique
#    destination in the `builds' directory.
# 5. Updates the `htdocs` symlink to point at the destination directory.
# 6. Removes expired builds from the `DEPLOY_BUILDS_DIR` directory.
#
# Configuration is handled with the following environment variables:
#
# * `DEPLOY_REPO_DIR`: Required. Absolute path to clone of upstream Git
#   repository
# * `DEPLOY_BUILDS_DIR`: Required. Absolute path to directory for site
#   builds and the `current` symlink
# * `DEPLOY_SKEW_THRESHOLD`: Optional, defaults to 300 (5 minutes) if
#   unspecified.  Amount of clock skew to allow when verifying payload
#   timestamp.
# * `DEPLOY_BUILD_CACHE_SIZE`: Optional, defaults to 5 if unspecified.
#   Number of old site builds to keep in the builds directory.
#

# load libraries
require 'openssl'
require 'json'
require 'time'
require 'fileutils'

# absolute path to source directory (e.g., git repo clone)
SRC_DIR = ENV.fetch('DEPLOY_REPO_DIR')

# get absolute path to builds directory from environment
BUILDS_DIR = ENV.fetch('DEPLOY_BUILDS_DIR')

# build path to htdocs
#
# note: used to be pulled from DEPLOY_HTDOCS_PATH, but newer versions of
# git are pickier about permissions so now we require this to be
# "builds/current"
HTDOCS_PATH = File.join(BUILDS_DIR, 'current')

# verify that the timestamp from the command-line is within the given
# amount of time of the current system timestamp
NUM_SECONDS = ENV.fetch('DEPLOY_SKEW_THRESHOLD', '300').to_i

# number of site builds to keep in builds directory
BUILD_CACHE_SIZE = ENV.fetch('DEPLOY_BUILD_CACHE_SIZE', '5').to_i

# command paths (optional)
FLOCK = ENV.fetch('DEPLOY_FLOCK_PATH', '/usr/bin/flock')
GIT = ENV.fetch('DEPLOY_GIT_PATH', '/usr/bin/git')
HUGO = ENV.fetch('DEPLOY_HUGO_PATH', '/usr/bin/hugo')

# get current timestamp
NOW = Time.now
NOW_S = NOW.strftime('%Y%m%d-%H%M%S')

# build random suffix
SUFFIX = OpenSSL::Digest::SHA256.hexdigest(OpenSSL::Random.random_bytes(32))

# destination directory
DST_DIR = '%s/site-%s-%s' % [BUILDS_DIR, NOW_S, SUFFIX]

#
# Execute block and return the amount of time, in seconds, that the
# blook took to execute.
#
def timed(&block)
  # get start time
  t0 = Time.now

  # call block
  block.call

  # return time taken, in seconds
  Time.now - t0
end

#
# print and exec command.
#
def run(*cmd)
  # print command
  puts 'run: ' + JSON(cmd)

  # run command
  system(*cmd, exception: true)
end

# check command-line argument
raise 'missing RFC3339-formatted timestamp argument' unless ARGV.size > 0

# check timestamp
hook_time = Time.parse(ARGV.first)
delta = (NOW - hook_time).abs
if delta > NUM_SECONDS
  raise JSON({
    message: 'payload time delta exceeds threshold',
    hook_time: hook_time.iso8601,
    current_time: NOW.iso8601,
    threshold: NUM_SECONDS,
    delta: delta,
  })
end

# get lock, update git clone
Dir.chdir(SRC_DIR)
pull_time = timed do 
  # update git clone with lock
  #
  # note: we don't technically need '--rebase', but it silences a git
  # default merge strategy warning
  run(FLOCK, SRC_DIR, GIT, 'pull', '--rebase')
end

# build site
hugo_time = timed do
  run(HUGO, '--minify', '-d', DST_DIR)
end

# update htdocs symlink
#
# (note: HTDOCS_PATH is now always "builds/current" because recent
# git updates are pickier about repo permissions)
link_time = timed do
  File.unlink(HTDOCS_PATH) if File.symlink?(HTDOCS_PATH)
  File.symlink(DST_DIR, HTDOCS_PATH)
end

# clean up builds directory
rm_time = timed do
  # get sorted list of builds, excluding hidden directories and the
  # "current" symlink
  builds = Dir.entries(BUILDS_DIR).select do |name|
    name !~ /\A\./ && name != 'current'
  end.sort

  # have we exceeded the cache size threshold?
  if builds.size > BUILD_CACHE_SIZE
    # get list of builds to remove
    rms = builds.take(builds.size - BUILD_CACHE_SIZE)
    puts 'rms: ' + JSON(rms)

    # remove older builds in parallel
    rms.map do |name|
      Thread.new (name) do
        # build absolute path to old build
        path = File.join(BUILDS_DIR, name)

        # remove old build directory
        FileUtils.rm_rf(path)
      end
    end.each { |th| th.join }
  end
end

# print timing information
puts 'times: ' + JSON({
  total: Time.now - NOW,
  pull: pull_time,
  hugo: hugo_time,
  rm: rm_time,
})