diff --git a/.github/workflows/build-container.yml b/.github/workflows/build-container.yml
new file mode 100644
index 0000000..c226633
--- /dev/null
+++ b/.github/workflows/build-container.yml
@@ -0,0 +1,97 @@
+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###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 <> 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
index e4f972f..8c02ef6 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -3,202 +3,106 @@ permissions:
packages: write
contents: write
on:
- # Triggered on new GitHub Release
- release:
- types: [published]
- # Triggered on every successful Build action
workflow_run:
- workflows: ["Build"]
- branches: [main,master]
+ workflows: ["Build Container"]
types:
- completed
- # Manual trigger for rollback to specific release or redeploy latest
+ branches:
+ - main
+ - master
workflow_dispatch:
- inputs:
- version:
- default: latest
- description: Tag you want to release.
- required: true
+
+env:
+ DOCKER_BUILDKIT: 1
+ KAMAL_REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
+ KAMAL_REGISTRY_USERNAME: ${{ github.actor }}
jobs:
- push_to_registry:
+ release:
runs-on: ubuntu-latest
- if: ${{ github.event.workflow_run.conclusion != 'failure' }}
+ if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- # Checkout latest or specific tag
- - name: checkout
- if: ${{ github.event.inputs.version == '' || github.event.inputs.version == 'latest' }}
- uses: actions/checkout@v3
- - name: checkout tag
- if: ${{ github.event.inputs.version != '' && github.event.inputs.version != 'latest' }}
+ - name: Checkout code
uses: actions/checkout@v3
- with:
- ref: refs/tags/${{ github.event.inputs.version }}
-
- # Assign environment variables used in subsequent steps
- - name: Env variable assignment
- run: echo "image_repository_name=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
- # TAG_NAME defaults to 'latest' if not a release or manual deployment
- - name: Assign version
+
+ - name: Set up environment variables
run: |
- echo "TAG_NAME=latest" >> $GITHUB_ENV
- if [ "${{ github.event.release.tag_name }}" != "" ]; then
- echo "TAG_NAME=${{ github.event.release.tag_name }}" >> $GITHUB_ENV
- fi;
- if [ "${{ github.event.inputs.version }}" != "" ]; then
- echo "TAG_NAME=${{ github.event.inputs.version }}" >> $GITHUB_ENV
- fi;
- if [ ! -z "${{ secrets.APPSETTINGS_PATCH }}" ]; then
- echo "HAS_APPSETTINGS_PATCH=true" >> $GITHUB_ENV
+ 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_APPSETTINGS_PATCH=false" >> $GITHUB_ENV
- fi;
-
+ 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@v2
+ uses: docker/login-action@v3
with:
registry: ghcr.io
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
-
- - name: Setup dotnet
- uses: actions/setup-dotnet@v3
+ username: ${{ env.KAMAL_REGISTRY_USERNAME }}
+ password: ${{ env.KAMAL_REGISTRY_PASSWORD }}
+
+ - name: Set up SSH key
+ uses: webfactory/ssh-agent@v0.9.0
with:
- dotnet-version: '8.0'
-
- - name: Install x tool
- if: env.HAS_APPSETTINGS_PATCH == 'true'
- run: dotnet tool install -g x
-
- - name: Apply Production AppSettings
- if: env.HAS_APPSETTINGS_PATCH == 'true'
- working-directory: ./AiServer
- run: |
- cat <> appsettings.json.patch
- ${{ secrets.APPSETTINGS_PATCH }}
- EOF
- x patch appsettings.json.patch
-
-
- # Build and push new docker image, skip for manual redeploy other than 'latest'
- - 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=${{ env.TAG_NAME }} -p:ContainerPort=80
+ ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
- deploy_via_ssh:
- needs: push_to_registry
- runs-on: ubuntu-latest
- if: ${{ github.event.workflow_run.conclusion != 'failure' }}
- steps:
- # Checkout latest or specific tag
- - name: checkout
- if: ${{ github.event.inputs.version == '' || github.event.inputs.version == 'latest' }}
- uses: actions/checkout@v3
- - name: checkout tag
- if: ${{ github.event.inputs.version != '' && github.event.inputs.version != 'latest' }}
- uses: actions/checkout@v3
+ - name: Setup Ruby
+ uses: ruby/setup-ruby@v1
with:
- ref: refs/tags/${{ github.event.inputs.version }}
+ ruby-version: 3.3.0
+ bundler-cache: true
- - name: repository name fix and env
+ - 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: |
- echo "image_repository_name=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
- echo "TAG_NAME=latest" >> $GITHUB_ENV
- if [ "${{ github.event.release.tag_name }}" != "" ]; then
- echo "TAG_NAME=${{ github.event.release.tag_name }}" >> $GITHUB_ENV
- fi;
- if [ "${{ github.event.inputs.version }}" != "" ]; then
- echo "TAG_NAME=${{ github.event.inputs.version }}" >> $GITHUB_ENV
- fi;
+ 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: Create .env file
+ - name: Ensure file permissions
run: |
- echo "Generating .env file"
-
- echo "# Autogenerated .env file" > .deploy/.env
- echo "HOST_DOMAIN=${{ secrets.DEPLOY_HOST }}" >> .deploy/.env
- echo "LETSENCRYPT_EMAIL=${{ secrets.LETSENCRYPT_EMAIL }}" >> .deploy/.env
- echo "APP_NAME=${{ github.event.repository.name }}" >> .deploy/.env
- echo "IMAGE_REPO=${{ env.image_repository_name }}" >> .deploy/.env
- echo "RELEASE_VERSION=${{ env.TAG_NAME }}" >> .deploy/.env
- echo "CIVIT_AI_API_KEY=${{ secrets.CIVIT_AI_API_KEY }}" >> .deploy/.env
- echo "REPLICATE_API_KEY=${{ secrets.REPLICATE_API_KEY }}" >> .deploy/.env
- echo "GOOGLE_API_KEY=${{ secrets.GOOGLE_API_KEY }}" >> .deploy/.env
- echo "GROQ_API_KEY=${{ secrets.GROQ_API_KEY }}" >> .deploy/.env
- echo "MISTRAL_API_KEY=${{ secrets.MISTRAL_API_KEY }}" >> .deploy/.env
- echo "OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}" >> .deploy/.env
- echo "OPENROUTER_API_KEY=${{ secrets.OPENROUTER_API_KEY }}" >> .deploy/.env
-
- # Copy only the docker-compose.yml to remote server home folder
- - name: copy files to target server via scp
- uses: appleboy/scp-action@v0.1.3
- with:
- host: ${{ secrets.DEPLOY_HOST }}
- username: ${{ secrets.DEPLOY_USERNAME }}
- port: 22
- key: ${{ secrets.DEPLOY_KEY }}
- strip_components: 2
- source: "./.deploy/docker-compose.yml,./.deploy/.env"
- target: "~/.deploy/${{ github.event.repository.name }}/"
-
- - name: Setup App_Data volume directory
- uses: appleboy/ssh-action@v0.1.5
- env:
- APPTOKEN: ${{ secrets.GITHUB_TOKEN }}
- USERNAME: ${{ secrets.DEPLOY_USERNAME }}
- with:
- host: ${{ secrets.DEPLOY_HOST }}
- username: ${{ secrets.DEPLOY_USERNAME }}
- key: ${{ secrets.DEPLOY_KEY }}
- port: 22
- envs: APPTOKEN,USERNAME
- script: |
- set -e
- echo $APPTOKEN | docker login ghcr.io -u $USERNAME --password-stdin
- cd ~/.deploy/${{ github.event.repository.name }}
- docker compose pull
- export APP_ID=$(docker compose run --entrypoint "id -u" --rm app)
- docker compose run --entrypoint "chown $APP_ID:$APP_ID /app/App_Data" --user root --rm app
- docker compose run --entrypoint "chown $APP_ID:$APP_ID /app/artifacts" --user root --rm app
- docker compose run --entrypoint "chown $APP_ID:$APP_ID /app/files" --user root --rm app
+ kamal server exec --no-interactive "mkdir -p /opt/docker/${{ env.repository_name }}/App_Data && chown -R 1654:1654 /opt/docker/${{ env.repository_name }}"
- - name: Run remote db migrations
- uses: appleboy/ssh-action@v0.1.5
- env:
- APPTOKEN: ${{ secrets.GITHUB_TOKEN }}
- USERNAME: ${{ secrets.DEPLOY_USERNAME }}
- with:
- host: ${{ secrets.DEPLOY_HOST }}
- username: ${{ secrets.DEPLOY_USERNAME }}
- key: ${{ secrets.DEPLOY_KEY }}
- port: 22
- envs: APPTOKEN,USERNAME
- script: |
- set -e
- echo $APPTOKEN | docker login ghcr.io -u $USERNAME --password-stdin
- cd ~/.deploy/${{ github.event.repository.name }}
- docker compose pull
- export APP_ID=$(docker compose run --entrypoint "id -u" --rm app)
- docker compose run --entrypoint "chown $APP_ID:$APP_ID /app/App_Data" --user root --rm app
- docker compose run --entrypoint "chown $APP_ID:$APP_ID /app/artifacts" --user root --rm app
- docker compose run --entrypoint "chown $APP_ID:$APP_ID /app/files" --user root --rm app
- docker compose up app-migration --exit-code-from app-migration
+ - 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"
- # Deploy Docker image with your application using `docker compose up` remotely
- - name: remote docker-compose up via ssh
- uses: appleboy/ssh-action@v0.1.5
- env:
- APPTOKEN: ${{ secrets.GITHUB_TOKEN }}
- USERNAME: ${{ secrets.DEPLOY_USERNAME }}
- with:
- host: ${{ secrets.DEPLOY_HOST }}
- username: ${{ secrets.DEPLOY_USERNAME }}
- key: ${{ secrets.DEPLOY_KEY }}
- port: 22
- envs: APPTOKEN,USERNAME
- script: |
- echo $APPTOKEN | docker login ghcr.io -u $USERNAME --password-stdin
- cd ~/.deploy/${{ github.event.repository.name }}
- docker compose pull
- docker compose up app -d
+ - 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..aa38458
--- /dev/null
+++ b/.kamal/secrets
@@ -0,0 +1,23 @@
+# 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
+GOOGLE_API_KEY=$GOOGLE_API_KEY
+GROQ_API_KEY=$GROQ_API_KEY
+MISTRAL_API_KEY=$MISTRAL_API_KEY
+OPENAI_API_KEY=$OPENAI_API_KEY
+OPENROUTER_API_KEY=$OPENROUTER_API_KEY
+
+# 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/AiServer/AiServer.csproj b/AiServer/AiServer.csproj
index c1d3f21..66b1991 100644
--- a/AiServer/AiServer.csproj
+++ b/AiServer/AiServer.csproj
@@ -6,6 +6,11 @@
enable
DefaultContainer
+
+
+
+
+
diff --git a/AiServer/Configure.HealthChecks.cs b/AiServer/Configure.HealthChecks.cs
new file mode 100644
index 0000000..b881e4d
--- /dev/null
+++ b/AiServer/Configure.HealthChecks.cs
@@ -0,0 +1,37 @@
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+
+[assembly: HostingStartup(typeof(AiServer.HealthChecks))]
+
+namespace AiServer;
+
+public class HealthChecks : IHostingStartup
+{
+ public class HealthCheck : IHealthCheck
+ {
+ public async Task 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");
+
+ services.AddTransient();
+ });
+ }
+
+ public class StartupFilter : IStartupFilter
+ {
+ public Action Configure(Action next)
+ => app => {
+ app.UseHealthChecks("/up");
+ next(app);
+ };
+ }
+}
diff --git a/config/deploy.yml b/config/deploy.yml
new file mode 100644
index 0000000..5f3a524
--- /dev/null
+++ b/config/deploy.yml
@@ -0,0 +1,64 @@
+# Name of your application. Used to uniquely configure containers.
+service: ai-server
+
+# Name of the container image.
+image: servicestack/aiserver
+
+# Required for use of ASP.NET Core with Kamal-Proxy.
+env:
+ clear:
+ ASPNETCORE_FORWARDEDHEADERS_ENABLED: true
+ HTTPS_METHOD: noredirect
+ secret:
+ - GOOGLE_API_KEY
+ - GROQ_API_KEY
+ - MISTRAL_API_KEY
+ - OPENAI_API_KEY
+ - OPENROUTER_API_KEY
+
+# Deploy to these servers.
+servers:
+ # IP address of server, optionally use env variable.
+ web:
+ - 5.78.128.205
+# - <%= 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: openai.servicestack.net
+ # 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