diff --git a/.github/workflows/build-container.yml b/.github/workflows/build-container.yml new file mode 100644 index 0000000..82925e1 --- /dev/null +++ b/.github/workflows/build-container.yml @@ -0,0 +1,94 @@ +name: Build Container +permissions: + packages: write + contents: write +on: + workflow_run: + workflows: ["Build"] + types: + - completed + 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###g' MyApp/MyApp.csproj + + - name: Check for Client directory + id: check_client + run: | + if [ -d "MyApp.Client" ]; 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 <> 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6a41086 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,101 @@ +name: Release +permissions: + packages: write + contents: write +on: + workflow_run: + workflows: ["Build Container"] + types: + - completed + 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/ssh-agent@v0.9.0 + 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.2.2 + + - 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 app exec --no-reuse --no-interactive --version=latest "--AppTasks=migrate" + + - name: Deploy with Kamal + run: | + kamal lock release -v + kamal deploy -P --version latest \ No newline at end of file diff --git a/.kamal/hooks/docker-setup.sample b/.kamal/hooks/docker-setup.sample new file mode 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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..690aa96 --- /dev/null +++ b/.kamal/secrets @@ -0,0 +1,18 @@ +# 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) diff --git a/MyApp/MyApp.csproj b/MyApp/MyApp.csproj index 73e8615..ef817d4 100644 --- a/MyApp/MyApp.csproj +++ b/MyApp/MyApp.csproj @@ -2,10 +2,14 @@ DefaultContainer + + + + + net8.0 enable enable - diff --git a/config/deploy.yml b/config/deploy.yml new file mode 100644 index 0000000..56e655c --- /dev/null +++ b/config/deploy.yml @@ -0,0 +1,57 @@ +# Name of your application. Used to uniquely configure containers. +service: my-app + +# Name of the container image. +image: my-user/myapp + +# 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 + + healthcheck: + interval: 3 + path: /metadata + timeout: 3 + +# 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