Skip to content

Instantly share code, notes, and snippets.

@gistlyn
Last active November 27, 2024 02:55
Show Gist options
  • Save gistlyn/99b709307b32bd28b80cfc92d0a5bec1 to your computer and use it in GitHub Desktop.
Save gistlyn/99b709307b32bd28b80cfc92d0a5bec1 to your computer and use it in GitHub Desktop.
kamal
name: Build Container
permissions:
packages: write
contents: write
on:
workflow_run:
workflows: ["Build"]
types:
- completed
branches:
- main
- master
workflow_dispatch:
env:
DOCKER_BUILDKIT: 1
KAMAL_REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
KAMAL_REGISTRY_USERNAME: ${{ github.actor }}
jobs:
build-container:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up environment variables
run: |
echo "image_repository_name=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
echo "repository_name=$(echo ${{ github.repository }} | cut -d '/' -f 2)" >> $GITHUB_ENV
echo "repository_name_lower=$(echo ${{ github.repository }} | cut -d '/' -f 2 | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
echo "org_name=$(echo ${{ github.repository }} | cut -d '/' -f 1)" >> $GITHUB_ENV
if [ -n "${{ secrets.APPSETTINGS_PATCH }}" ]; then
echo "HAS_APPSETTINGS_PATCH=true" >> $GITHUB_ENV
else
echo "HAS_APPSETTINGS_PATCH=false" >> $GITHUB_ENV
fi
if [ -n "${{ secrets.KAMAL_DEPLOY_IP }}" ]; then
echo "HAS_DEPLOY_ACTION=true" >> $GITHUB_ENV
else
echo "HAS_DEPLOY_ACTION=false" >> $GITHUB_ENV
fi
# This step is for the deployment of the templates only, safe to delete
- name: Modify csproj for template deploy
if: env.HAS_DEPLOY_ACTION == 'true'
run: |
sed -i 's#<ContainerLabel Include="service" Value="my-app" />#<ContainerLabel Include="service" Value="${{ env.repository_name_lower }}" />#g' MyApp/MyApp.csproj
- name: Check for Client directory and package.json
id: check_client
run: |
if [ -d "MyApp.Client" ] && [ -f "MyApp.Client/package.json" ]; then
echo "client_exists=true" >> $GITHUB_OUTPUT
else
echo "client_exists=false" >> $GITHUB_OUTPUT
fi
- name: Setup Node.js
if: steps.check_client.outputs.client_exists == 'true'
uses: actions/setup-node@v3
with:
node-version: 22
- name: Install npm dependencies
if: steps.check_client.outputs.client_exists == 'true'
working-directory: ./MyApp.Client
run: npm install
- name: Install x tool
run: dotnet tool install -g x
- name: Apply Production AppSettings
if: env.HAS_APPSETTINGS_PATCH == 'true'
working-directory: ./MyApp
run: |
cat <<EOF >> appsettings.json.patch
${{ secrets.APPSETTINGS_PATCH }}
EOF
x patch appsettings.json.patch
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ env.KAMAL_REGISTRY_USERNAME }}
password: ${{ env.KAMAL_REGISTRY_PASSWORD }}
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: '8.0'
- name: Build and push Docker image
run: |
dotnet publish --os linux --arch x64 -c Release -p:ContainerRepository=${{ env.image_repository_name }} -p:ContainerRegistry=ghcr.io -p:ContainerImageTags=latest -p:ContainerPort=80
name: Release
permissions:
packages: write
contents: write
on:
workflow_run:
workflows: ["Build Container"]
types:
- completed
branches:
- main
- master
workflow_dispatch:
env:
DOCKER_BUILDKIT: 1
KAMAL_REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
KAMAL_REGISTRY_USERNAME: ${{ github.actor }}
jobs:
release:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up environment variables
run: |
echo "image_repository_name=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
echo "repository_name=$(echo ${{ github.repository }} | cut -d '/' -f 2)" >> $GITHUB_ENV
echo "repository_name_lower=$(echo ${{ github.repository }} | cut -d '/' -f 2 | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
echo "org_name=$(echo ${{ github.repository }} | cut -d '/' -f 1)" >> $GITHUB_ENV
if find . -maxdepth 2 -type f -name "Configure.Db.Migrations.cs" | grep -q .; then
echo "HAS_MIGRATIONS=true" >> $GITHUB_ENV
else
echo "HAS_MIGRATIONS=false" >> $GITHUB_ENV
fi
if [ -n "${{ secrets.KAMAL_DEPLOY_IP }}" ]; then
echo "HAS_DEPLOY_ACTION=true" >> $GITHUB_ENV
else
echo "HAS_DEPLOY_ACTION=false" >> $GITHUB_ENV
fi
# This step is for the deployment of the templates only, safe to delete
- name: Modify deploy.yml
if: env.HAS_DEPLOY_ACTION == 'true'
run: |
sed -i "s/service: my-app/service: ${{ env.repository_name_lower }}/g" config/deploy.yml
sed -i "s#image: my-user/myapp#image: ${{ env.image_repository_name }}#g" config/deploy.yml
sed -i "s/- 192.168.0.1/- ${{ secrets.KAMAL_DEPLOY_IP }}/g" config/deploy.yml
sed -i "s/host: my-app.example.com/host: ${{ secrets.KAMAL_DEPLOY_HOST }}/g" config/deploy.yml
sed -i "s/MyApp/${{ env.repository_name }}/g" config/deploy.yml
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ env.KAMAL_REGISTRY_USERNAME }}
password: ${{ env.KAMAL_REGISTRY_PASSWORD }}
- name: Set up SSH key
uses: webfactory/[email protected]
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.3.0
bundler-cache: true
- name: Install Kamal
run: gem install kamal -v 2.3.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: image=moby/buildkit:master
- name: Kamal bootstrap
run: kamal server bootstrap
- name: Check if first run and execute kamal app boot if necessary
run: |
FIRST_RUN_FILE=".${{ env.repository_name }}"
if ! kamal server exec --no-interactive -q "test -f $FIRST_RUN_FILE"; then
kamal server exec --no-interactive -q "touch $FIRST_RUN_FILE" || true
kamal deploy -q -P --version latest || true
else
echo "Not first run, skipping kamal app boot"
fi
- name: Ensure file permissions
run: |
kamal server exec --no-interactive "mkdir -p /opt/docker/${{ env.repository_name }}/App_Data && chown -R 1654:1654 /opt/docker/${{ env.repository_name }}"
- name: Migration
if: env.HAS_MIGRATIONS == 'true'
run: |
kamal server exec --no-interactive 'echo "${{ env.KAMAL_REGISTRY_PASSWORD }}" | docker login ghcr.io -u ${{ env.KAMAL_REGISTRY_USERNAME }} --password-stdin'
kamal server exec --no-interactive "docker pull ghcr.io/${{ env.image_repository_name }}:latest || true"
kamal app exec --no-reuse --no-interactive --version=latest "--AppTasks=migrate"
- name: Deploy with Kamal
run: |
kamal lock release -v
kamal deploy -P --version latest
#!/bin/sh
echo "Docker set up on $KAMAL_HOSTS..."
#!/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"
#!/bin/sh
echo "Rebooted kamal-proxy on $KAMAL_HOSTS"
#!/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
#!/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 ]
#!/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
#!/bin/sh
echo "Rebooting kamal-proxy on $KAMAL_HOSTS..."
# 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
KAMAL_REGISTRY_USERNAME=$KAMAL_REGISTRY_USERNAME
# 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)
# Name of your application. Used to uniquely configure containers.
service: my-app
# Name of the container image.
image: my-user/myapp
# Required for use of ASP.NET Core with Kamal-Proxy.
env:
ASPNETCORE_FORWARDEDHEADERS_ENABLED: true
# Deploy to these servers.
servers:
# IP address of server, optionally use env variable.
web:
- 192.168.0.1
# - <%= ENV['KAMAL_DEPLOY_IP'] %>
# Enable SSL auto certification via Let's Encrypt (and allow for multiple apps on one server).
# If using something like Cloudflare, it is recommended to set encryption mode
# in Cloudflare's SSL/TLS setting to "Full" to enable end-to-end encryption.
proxy:
ssl: true
host: my-app.example.com
# kamal-proxy connects to your container over port 80, use `app_port` to specify a different port.
app_port: 8080
# Credentials for your image host.
registry:
# Specify the registry server, if you're not using Docker Hub
server: ghcr.io
username:
- KAMAL_REGISTRY_USERNAME
# Always use an access token rather than real password (pulled from .kamal/secrets).
password:
- KAMAL_REGISTRY_PASSWORD
# Configure builder setup.
builder:
arch: amd64
volumes:
- "/opt/docker/MyApp/App_Data:/app/App_Data"
#accessories:
# litestream:
# roles: ["web"]
# image: litestream/litestream
# files: ["config/litestream.yml:/etc/litestream.yml"]
# volumes: ["/opt/docker/MyApp/App_Data:/data"]
# cmd: replicate
# env:
# secret:
# - ACCESS_KEY_ID
# - SECRET_ACCESS_KEY
using Microsoft.Extensions.Diagnostics.HealthChecks;
[assembly: HostingStartup(typeof(MyApp.HealthChecks))]
namespace MyApp;
public class HealthChecks : IHostingStartup
{
public class HealthCheck : IHealthCheck
{
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken token = default)
{
// Perform health check logic here
return HealthCheckResult.Healthy();
}
}
public void Configure(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
services.AddHealthChecks()
.AddCheck<HealthCheck>("HealthCheck");
services.AddTransient<IStartupFilter, StartupFilter>();
});
}
public class StartupFilter : IStartupFilter
{
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
=> app => {
app.UseHealthChecks("/up");
next(app);
};
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment