Last active
November 27, 2024 02:55
-
-
Save gistlyn/99b709307b32bd28b80cfc92d0a5bec1 to your computer and use it in GitHub Desktop.
kamal
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/bin/sh | |
echo "Docker set up on $KAMAL_HOSTS..." |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/bin/sh | |
echo "Rebooted kamal-proxy on $KAMAL_HOSTS" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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 ] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/bin/sh | |
echo "Rebooting kamal-proxy on $KAMAL_HOSTS..." |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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