#!/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, })