diff options
Diffstat (limited to 'bin/hook/deploy.rb')
-rwxr-xr-x | bin/hook/deploy.rb | 153 |
1 files changed, 153 insertions, 0 deletions
diff --git a/bin/hook/deploy.rb b/bin/hook/deploy.rb new file mode 100755 index 0000000..20b3523 --- /dev/null +++ b/bin/hook/deploy.rb @@ -0,0 +1,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, +}) |