From 9705561bd14e8edab0f7d083a7cff2d7c5fcda22 Mon Sep 17 00:00:00 2001 From: James Couball Date: Thu, 12 Dec 2024 16:45:52 -0800 Subject: [PATCH] chore: deploy notes with kamal --- .devcontainer/{build => docker-build} | 0 .devcontainer/{run => docker-run} | 0 .kamal/hooks/docker-setup.sample | 3 + .kamal/hooks/post-deploy.sample | 14 ++++ .kamal/hooks/post-proxy-reboot.sample | 3 + .kamal/hooks/pre-build.sample | 51 ++++++++++++ .kamal/hooks/pre-connect.sample | 47 +++++++++++ .kamal/hooks/pre-deploy.sample | 109 ++++++++++++++++++++++++++ .kamal/hooks/pre-proxy-reboot.sample | 3 + .kamal/secrets | 17 ++++ Dockerfile | 26 ++++++ README.md | 17 ++++ config/deploy.yml | 22 ++++++ config/provision.yml | 38 +++++++++ docker-build | 2 + docker-run | 3 + docs/healthcheck.html | 1 + 17 files changed, 356 insertions(+) rename .devcontainer/{build => docker-build} (100%) rename .devcontainer/{run => docker-run} (100%) create mode 100755 .kamal/hooks/docker-setup.sample create mode 100755 .kamal/hooks/post-deploy.sample create mode 100755 .kamal/hooks/post-proxy-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-proxy-reboot.sample create mode 100644 .kamal/secrets create mode 100644 Dockerfile create mode 100644 config/deploy.yml create mode 100644 config/provision.yml create mode 100755 docker-build create mode 100755 docker-run create mode 100644 docs/healthcheck.html diff --git a/.devcontainer/build b/.devcontainer/docker-build similarity index 100% rename from .devcontainer/build rename to .devcontainer/docker-build diff --git a/.devcontainer/run b/.devcontainer/docker-run similarity index 100% rename from .devcontainer/run rename to .devcontainer/docker-run diff --git a/.kamal/hooks/docker-setup.sample b/.kamal/hooks/docker-setup.sample new file mode 100755 index 0000000..2fb07d7 --- /dev/null +++ b/.kamal/hooks/docker-setup.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Docker set up on $KAMAL_HOSTS..." 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-proxy-reboot.sample b/.kamal/hooks/post-proxy-reboot.sample new file mode 100755 index 0000000..1435a67 --- /dev/null +++ b/.kamal/hooks/post-proxy-reboot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Rebooted kamal-proxy 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-proxy-reboot.sample b/.kamal/hooks/pre-proxy-reboot.sample new file mode 100755 index 0000000..061f805 --- /dev/null +++ b/.kamal/hooks/pre-proxy-reboot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Rebooting kamal-proxy on $KAMAL_HOSTS..." diff --git a/.kamal/secrets b/.kamal/secrets new file mode 100644 index 0000000..b136660 --- /dev/null +++ b/.kamal/secrets @@ -0,0 +1,17 @@ +# Secrets defined here are available for reference under registry/password, env/secret, builder/secrets, +# and accessories/*/env/secret in config/deploy.yml. All secrets should be pulled from either +# password manager, ENV, or a file. DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git. + +# Option 1: Read secrets from the environment +KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD + +# Option 2: Read secrets via a command +# RAILS_MASTER_KEY=$(cat config/master.key) + +# Option 3: Read secrets via kamal secrets helpers +# These will handle logging in and fetching the secrets in as few calls as possible +# There are adapters for 1Password, LastPass + Bitwarden +# +# SECRETS=$(kamal secrets fetch --adapter 1password --account my-account --from MyVault/MyItem KAMAL_REGISTRY_PASSWORD RAILS_MASTER_KEY) +# KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract KAMAL_REGISTRY_PASSWORD $SECRETS) +# RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5d6c741 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +# Use a Python image to build the MkDocs documentation +FROM python:3.12-slim AS builder + +# Install MkDocs and any plugins (update with your specific requirements) +RUN pip install --no-cache-dir mkdocs mkdocs-material markdown-include pymdown-extensions + +# Set working directory +WORKDIR /docs + +# Copy the documentation source into the container +COPY . . + +# Build the MkDocs site +RUN mkdocs build + +# Use a lightweight web server for the final stage +FROM nginx:stable-alpine + +# Copy built MkDocs site to the Nginx default html directory +COPY --from=builder /docs/site /usr/share/nginx/html + +# Expose port 80 +EXPOSE 80 + +# Start Nginx +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/README.md b/README.md index 3676c85..f480c2e 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ subjects. * [Getting Started](#getting-started) * [Without a devcontainer](#without-a-devcontainer) * [In VS Code](#in-vs-code) + * [Deploying](#deploying) ## Getting Started @@ -41,3 +42,19 @@ The following npm scripts are available to lint, build and serve the docs: If you use VS Code (locally, in a devcontainer, or in a GitHub Codespace), you can run the document server by selecting "Run without Debugging" from the VS Code "Run" menu (or use the Ctrl-F5 shortcut). + +### Deploying + +The deployment is configured with these files: + +* config/provision.yaml: Proxmox provisioning configuration +* Dockerfile: creates the production deployment image which is deployed to docker hub + to [jcouball/notes](https://hub.docker.com/repository/docker/jcouball/notes/general) + * docker-build: a script to build the docker image locally + * docker-run: a script to run the docker image locally +* config/deploy.yaml: Kamal 2 deployment configuration + +Currently, Proxmox configuration must be done manaully (see the commands at the +bottom of the file). + +For deployment, use kamal 2 commands. diff --git a/config/deploy.yml b/config/deploy.yml new file mode 100644 index 0000000..74da76c --- /dev/null +++ b/config/deploy.yml @@ -0,0 +1,22 @@ +service: notes + +image: jcouball/notes + +# Deploy to these servers. +servers: + web: + - 192.168.1.29 + +proxy: + ssl: false + healthcheck: + path: /healthcheck.html + +registry: + username: jcouball + password: + - KAMAL_REGISTRY_PASSWORD + +# Configure builder setup. +builder: + arch: amd64 diff --git a/config/provision.yml b/config/provision.yml new file mode 100644 index 0000000..dd12cd9 --- /dev/null +++ b/config/provision.yml @@ -0,0 +1,38 @@ +proxmox: + address: "192.168.1.20" + +service: + name: "notes" + +container_defaults: + template: "local:vztmpl/ubuntu-24.04-standard_24.04-2_amd64.tar.zst" + rootfs_size: 16 + swap: 512 + password: "$HOST_PASSWORD" + start: 1 + features: "nesting=1" + bridge: "vmbr0" + gateway: "192.168.1.1" + +containers: + - { id: "121", hostname: "notes", address: "192.168.1.29", cores: 1, memory: 256 } + + +# pvesh create /pools --poolid "notes" + +# pct create "129" "local:vztmpl/ubuntu-24.04-standard_24.04-2_amd64.tar.zst" \ +# --swap 512 \ +# --password "$HOST_PASSWORD" \ +# --start "1" \ +# --hostname "notes" \ +# --features "nesting=1" \ +# --ssh-public-keys "/root/.ssh/id_rsa_notes_admin.pub" \ +# --cores 1 \ +# --memory 256 \ +# --rootfs "local-lvm:16" \ +# --net0 "name=eth0,bridge=vmbr0,ip=192.168.1.29/24,gw=192.168.1.1" \ +# --pool "notes" + +# pct destroy 129 + +# pvesh delete /pools --poolid "notes" diff --git a/docker-build b/docker-build new file mode 100755 index 0000000..b939611 --- /dev/null +++ b/docker-build @@ -0,0 +1,2 @@ +#!/bin/bash +docker build -t notes-production:latest . \ No newline at end of file diff --git a/docker-run b/docker-run new file mode 100755 index 0000000..2031332 --- /dev/null +++ b/docker-run @@ -0,0 +1,3 @@ +#!/bin/bash +# Browse docs at http://localhost:80 +docker run -p 80:80 notes-production:latest \ No newline at end of file diff --git a/docs/healthcheck.html b/docs/healthcheck.html new file mode 100644 index 0000000..d86bac9 --- /dev/null +++ b/docs/healthcheck.html @@ -0,0 +1 @@ +OK