aboutsummaryrefslogtreecommitdiff
path: root/bin/hook/deploy.rb
blob: 20b3523d2b10d9b36292686ef747c9eab3c544fe (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
#!/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_HTDOCS_PATH`: Required. Absolute path to `htdocs` symlink.
# * `DEPLOY_REPO_DIR`: Required. Absolute path to clone of upstream Git
#   repository
# * `DEPLOY_BUILDS_DIR`: Required. Absolute path to directory for site
#   builds.
# * `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'

# pass to htdocs symlink
HTDOCS_PATH = ENV.fetch('DEPLOY_HTDOCS_PATH')

# 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')

# 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

# 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

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('/usr/bin/flock', SRC_DIR, '/usr/bin/git', 'pull', '--rebase')
end

# build site
hugo_time = timed do
  run('/usr/bin/hugo', '--minify', '-d', DST_DIR)
end

link_time = timed do
  # update htdocs symlink
  File.unlink(HTDOCS_PATH) if File.symlink?(HTDOCS_PATH)
  File.symlink(DST_DIR, HTDOCS_PATH)
end

rm_time = timed do
  # get sorted list of builds
  builds = Dir.entries(BUILDS_DIR).select { |name| name !~ /\A\./ }.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)

    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,
})