aboutsummaryrefslogtreecommitdiff
path: root/bin/hook/deploy.rb
diff options
context:
space:
mode:
Diffstat (limited to 'bin/hook/deploy.rb')
-rwxr-xr-xbin/hook/deploy.rb153
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,
+})