From a456212ccfa75e9c6dae3426473fe1eebc616b77 Mon Sep 17 00:00:00 2001 From: Trevor Vallender Date: Wed, 19 Jun 2024 11:16:56 +0100 Subject: [PATCH] Set up Kamal --- .kamal/hooks/docker-setup | 7 ++ .kamal/hooks/docker-setup.sample | 7 ++ .kamal/hooks/post-deploy.sample | 14 +++ .kamal/hooks/post-traefik-reboot.sample | 3 + .kamal/hooks/pre-build.sample | 51 +++++++++++ .kamal/hooks/pre-connect.sample | 47 ++++++++++ .kamal/hooks/pre-deploy.sample | 109 ++++++++++++++++++++++++ .kamal/hooks/pre-traefik-reboot.sample | 3 + Dockerfile | 6 +- config/credentials.yml.enc | 2 +- config/database.yml | 2 +- config/deploy.yml | 72 ++++++++++++++++ config/environments/production.rb | 9 +- 13 files changed, 324 insertions(+), 8 deletions(-) create mode 100755 .kamal/hooks/docker-setup create mode 100755 .kamal/hooks/docker-setup.sample create mode 100755 .kamal/hooks/post-deploy.sample create mode 100755 .kamal/hooks/post-traefik-reboot.sample create mode 100755 .kamal/hooks/pre-build.sample create mode 100755 .kamal/hooks/pre-connect.sample create mode 100755 .kamal/hooks/pre-deploy.sample create mode 100755 .kamal/hooks/pre-traefik-reboot.sample create mode 100644 config/deploy.yml diff --git a/.kamal/hooks/docker-setup b/.kamal/hooks/docker-setup new file mode 100755 index 0000000..48bc545 --- /dev/null +++ b/.kamal/hooks/docker-setup @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +# A sample docker-setup hook +# +# Sets up a Docker network which can then be used by the application’s containers + +ssh root@ttcompanion.com "docker network inspect kamal || docker network create kamal" diff --git a/.kamal/hooks/docker-setup.sample b/.kamal/hooks/docker-setup.sample new file mode 100755 index 0000000..fe68b93 --- /dev/null +++ b/.kamal/hooks/docker-setup.sample @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby + +# A sample docker-setup hook +# +# Sets up a Docker network which can then be used by the application’s containers + +ssh user@example.com docker network create kamal diff --git a/.kamal/hooks/post-deploy.sample b/.kamal/hooks/post-deploy.sample new file mode 100755 index 0000000..75efafc --- /dev/null +++ b/.kamal/hooks/post-deploy.sample @@ -0,0 +1,14 @@ +#!/bin/sh + +# A sample post-deploy hook +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLE (if set) +# KAMAL_DESTINATION (if set) +# KAMAL_RUNTIME + +echo "$KAMAL_PERFORMER deployed $KAMAL_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds" diff --git a/.kamal/hooks/post-traefik-reboot.sample b/.kamal/hooks/post-traefik-reboot.sample new file mode 100755 index 0000000..e3d9e3c --- /dev/null +++ b/.kamal/hooks/post-traefik-reboot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Rebooted Traefik on $KAMAL_HOSTS" diff --git a/.kamal/hooks/pre-build.sample b/.kamal/hooks/pre-build.sample new file mode 100755 index 0000000..f87d811 --- /dev/null +++ b/.kamal/hooks/pre-build.sample @@ -0,0 +1,51 @@ +#!/bin/sh + +# A sample pre-build hook +# +# Checks: +# 1. We have a clean checkout +# 2. A remote is configured +# 3. The branch has been pushed to the remote +# 4. The version we are deploying matches the remote +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLE (if set) +# KAMAL_DESTINATION (if set) + +if [ -n "$(git status --porcelain)" ]; then + echo "Git checkout is not clean, aborting..." >&2 + git status --porcelain >&2 + exit 1 +fi + +first_remote=$(git remote) + +if [ -z "$first_remote" ]; then + echo "No git remote set, aborting..." >&2 + exit 1 +fi + +current_branch=$(git branch --show-current) + +if [ -z "$current_branch" ]; then + echo "Not on a git branch, aborting..." >&2 + exit 1 +fi + +remote_head=$(git ls-remote $first_remote --tags $current_branch | cut -f1) + +if [ -z "$remote_head" ]; then + echo "Branch not pushed to remote, aborting..." >&2 + exit 1 +fi + +if [ "$KAMAL_VERSION" != "$remote_head" ]; then + echo "Version ($KAMAL_VERSION) does not match remote HEAD ($remote_head), aborting..." >&2 + exit 1 +fi + +exit 0 diff --git a/.kamal/hooks/pre-connect.sample b/.kamal/hooks/pre-connect.sample new file mode 100755 index 0000000..18e61d7 --- /dev/null +++ b/.kamal/hooks/pre-connect.sample @@ -0,0 +1,47 @@ +#!/usr/bin/env ruby + +# A sample pre-connect check +# +# Warms DNS before connecting to hosts in parallel +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLE (if set) +# KAMAL_DESTINATION (if set) +# KAMAL_RUNTIME + +hosts = ENV["KAMAL_HOSTS"].split(",") +results = nil +max = 3 + +elapsed = Benchmark.realtime do + results = hosts.map do |host| + Thread.new do + tries = 1 + + begin + Socket.getaddrinfo(host, 0, Socket::AF_UNSPEC, Socket::SOCK_STREAM, nil, Socket::AI_CANONNAME) + rescue SocketError + if tries < max + puts "Retrying DNS warmup: #{host}" + tries += 1 + sleep rand + retry + else + puts "DNS warmup failed: #{host}" + host + end + end + + tries + end + end.map(&:value) +end + +retries = results.sum - hosts.size +nopes = results.count { |r| r == max } + +puts "Prewarmed %d DNS lookups in %.2f sec: %d retries, %d failures" % [ hosts.size, elapsed, retries, nopes ] diff --git a/.kamal/hooks/pre-deploy.sample b/.kamal/hooks/pre-deploy.sample new file mode 100755 index 0000000..1b280c7 --- /dev/null +++ b/.kamal/hooks/pre-deploy.sample @@ -0,0 +1,109 @@ +#!/usr/bin/env ruby + +# A sample pre-deploy hook +# +# Checks the Github status of the build, waiting for a pending build to complete for up to 720 seconds. +# +# Fails unless the combined status is "success" +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_COMMAND +# KAMAL_SUBCOMMAND +# KAMAL_ROLE (if set) +# KAMAL_DESTINATION (if set) + +# Only check the build status for production deployments +if ENV["KAMAL_COMMAND"] == "rollback" || ENV["KAMAL_DESTINATION"] != "production" + exit 0 +end + +require "bundler/inline" + +# true = install gems so this is fast on repeat invocations +gemfile(true, quiet: true) do + source "https://rubygems.org" + + gem "octokit" + gem "faraday-retry" +end + +MAX_ATTEMPTS = 72 +ATTEMPTS_GAP = 10 + +def exit_with_error(message) + $stderr.puts message + exit 1 +end + +class GithubStatusChecks + attr_reader :remote_url, :git_sha, :github_client, :combined_status + + def initialize + @remote_url = `git config --get remote.origin.url`.strip.delete_prefix("https://github.com/") + @git_sha = `git rev-parse HEAD`.strip + @github_client = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"]) + refresh! + end + + def refresh! + @combined_status = github_client.combined_status(remote_url, git_sha) + end + + def state + combined_status[:state] + end + + def first_status_url + first_status = combined_status[:statuses].find { |status| status[:state] == state } + first_status && first_status[:target_url] + end + + def complete_count + combined_status[:statuses].count { |status| status[:state] != "pending"} + end + + def total_count + combined_status[:statuses].count + end + + def current_status + if total_count > 0 + "Completed #{complete_count}/#{total_count} checks, see #{first_status_url} ..." + else + "Build not started..." + end + end +end + + +$stdout.sync = true + +puts "Checking build status..." +attempts = 0 +checks = GithubStatusChecks.new + +begin + loop do + case checks.state + when "success" + puts "Checks passed, see #{checks.first_status_url}" + exit 0 + when "failure" + exit_with_error "Checks failed, see #{checks.first_status_url}" + when "pending" + attempts += 1 + end + + exit_with_error "Checks are still pending, gave up after #{MAX_ATTEMPTS * ATTEMPTS_GAP} seconds" if attempts == MAX_ATTEMPTS + + puts checks.current_status + sleep(ATTEMPTS_GAP) + checks.refresh! + end +rescue Octokit::NotFound + exit_with_error "Build status could not be found" +end diff --git a/.kamal/hooks/pre-traefik-reboot.sample b/.kamal/hooks/pre-traefik-reboot.sample new file mode 100755 index 0000000..8cfda6d --- /dev/null +++ b/.kamal/hooks/pre-traefik-reboot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Rebooting Traefik on $KAMAL_HOSTS..." diff --git a/Dockerfile b/Dockerfile index eba01ff..425ffe0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,16 +13,18 @@ ENV RAILS_ENV="production" \ BUNDLE_PATH="/usr/local/bundle" \ BUNDLE_WITHOUT="development" +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y libvips libpq-dev # Throw-away build stage to reduce size of final image FROM base as build # Install packages needed to build gems RUN apt-get update -qq && \ - apt-get install --no-install-recommends -y build-essential git libvips pkg-config + apt-get install --no-install-recommends -y build-essential git pkg-config # Install application gems -COPY Gemfile Gemfile.lock ./ +COPY Gemfile Gemfile.lock .ruby-version ./ RUN bundle install && \ rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ bundle exec bootsnap precompile --gemfile diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc index cf68c97..1e1250f 100644 --- a/config/credentials.yml.enc +++ b/config/credentials.yml.enc @@ -1 +1 @@ -EI74C8uxmka2Mejn5kTdq8qxetc8d8SJd4w+VNKkQdMUa6NgzZnz6Tf7RJZaQnUUtHPfVtFiltrkPcIiOzY1a5meO1TeydtWl+5ij2vpKFDP9tpcmVswhWJYvkuf6LRh/L8Yy1HeMBh5zM94JVc+s2X6aqfMjd19ORJEenyR4C6VogSUMWOOs+C9f9+8z+O3hptwfRlj6AwM5k5sn6PEKaUVUrnXa7TRLvVnK5Sm7s2WXTrTI6I3n+CaGp2iNRUf2Y3w34YE3QydWJ4YsmoFByMQIqkUtmasBuGYGX7CG4umd2/uUD/HJNnB/BriwsqbSV8QM4ErVO+8pSzKu7RJGH2SVZdYiz8WeVvoKHXPJ8cwVrW4Q45beYtUFMCPiYkUl4oZbzx1h94k0+AM4rRf6OCut9BW--3rZrEMdP2rRRfpnL--GFSUvgc2V69L+ERe1xIkiQ== \ No newline at end of file +RWzqbXYOBdxgpheefubYOD+li8SgrYjDuQjZ3SxRbSI8HhMmEi0iSuGUFBl/OlRTmhmUcswbpO4QXmcuS/Ej8dxz2WZ03RnozYO3ftYGSMQy23mqJx4WGxG+Z3p5hxSmeJ4bLoM91bQPkwYJGGi2ZLZXb6+QLopTtBHCZ17dmlvCE22F6zz70KZVgLN76jMTtjfLTQc5XrIdg23rFQfkgsSTrX9spKr/6mgUAILRQ/baPzlT43v9z13YfA8xe3BM/Giv7tBTmnZ6SVs12TT7jcsp5RfdWyxHHgpKUf0E1Mz5P98xOabo/pWYL3TQ8AzkW2y7G8q3WaR8FLpS80UOSBTgx0/iE6Josoc8Kql22VInLJMUz4+Haxt3t22dU2DE4ERmEjvhgCBTFgw6NyBy+tPxmdIh--mZf16ejQbkcvs4cT--V5RGldMxw37X+LXoyc8XYw== \ No newline at end of file diff --git a/config/database.yml b/config/database.yml index 85d0ff9..40b4f64 100644 --- a/config/database.yml +++ b/config/database.yml @@ -3,7 +3,7 @@ default: &default encoding: unicode pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> timeout: 5000 - host: localhost + host: <%= ENV.fetch("DB_HOST") { "localhost" } %> username: <%= ENV.fetch("POSTGRES_USER") { "postgres" } %> password: <%= ENV.fetch("POSTGRES_PASSWORD") { "postgres" } %> diff --git a/config/deploy.yml b/config/deploy.yml new file mode 100644 index 0000000..d94f463 --- /dev/null +++ b/config/deploy.yml @@ -0,0 +1,72 @@ +service: ttcompanion + +image: tsvallender/ttcompanion + +traefik: + options: + publish: + - "443:443" + volume: + - "/letsencrypt/acme.json:/letsencrypt/acme.json" # To save the configuration file. + network: kamal + args: + entryPoints.web.address: ":80" + entryPoints.websecure.address: ":443" + entryPoints.web.http.redirections.entryPoint.to: websecure # We want to force https + entryPoints.web.http.redirections.entryPoint.scheme: https + entryPoints.web.http.redirections.entrypoint.permanent: true + certificatesResolvers.letsencrypt.acme.email: "trevor@ttcompanion.com" + certificatesResolvers.letsencrypt.acme.storage: "/letsencrypt/acme.json" + certificatesResolvers.letsencrypt.acme.httpchallenge: true + certificatesResolvers.letsencrypt.acme.httpchallenge.entrypoint: web # + +servers: + web: + hosts: + - ttcompanion.com + labels: + traefik.http.routers.ttcompanion.entrypoints: websecure + traefik.http.routers.ttcompanion.rule: Host(`ttcompanion.com`) + traefik.http.routers.ttcompanion.tls.certresolver: letsencrypt + options: + network: kamal + +volumes: + - /srv/ttcompanion/storage:/rails/storage + +registry: + username: tsvallender + password: + - KAMAL_REGISTRY_PASSWORD + +env: + clear: + APPLICATION_HOST: ttcompanion.com + DB_HOST: ttcompanion-postgres + RAILS_LOG_TO_STDOUT: true + RAILS_SERVE_STATIC_FILES: true + secret: + - RAILS_MASTER_KEY + - POSTGRES_PASSWORD + +builder: + multiarch: false + +accessories: + postgres: + image: postgres:16 + host: ttcompanion.com + env: + clear: + POSTGRES_USER: postgres + secret: + - POSTGRES_PASSWORD + directories: + - /srv/ttcompanion/data:/var/lib/postgresql/data + options: + network: kamal + +healthcheck: + log_lines: 1000 + +asset_path: /rails/public/assets diff --git a/config/environments/production.rb b/config/environments/production.rb index b913262..81af9f7 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -90,10 +90,11 @@ Rails.application.configure do config.active_record.dump_schema_after_migration = false # Enable DNS rebinding protection and other `Host` header attacks. - # config.hosts = [ - # "example.com", # Allow requests from example.com - # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` - # ] + config.hosts = [ + "localhost", + "ttcompanion.com", + /.*\.ttcompanion\.com/ + ] # Skip DNS rebinding protection for the default health check endpoint. # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } end