From c536dcb6233b9e8e4bdb124d065c5c0c42f12490 Mon Sep 17 00:00:00 2001 From: Leon Morival Date: Tue, 19 May 2026 18:40:20 +0200 Subject: [PATCH] feat: deploy --- .gitea/workflows/deploy.yml | 139 +++++++++++---------- .gitignore | 1 + .kamal/hooks/docker-setup.sample | 3 + .kamal/hooks/post-app-boot.sample | 3 + .kamal/hooks/post-deploy.sample | 14 +++ .kamal/hooks/post-proxy-reboot.sample | 3 + .kamal/hooks/pre-app-boot.sample | 3 + .kamal/hooks/pre-build.sample | 51 ++++++++ .kamal/hooks/pre-connect.sample | 47 +++++++ .kamal/hooks/pre-deploy.sample | 122 ++++++++++++++++++ .kamal/hooks/pre-proxy-reboot.sample | 3 + config/deploy.yml | 66 ++++++++++ docker-compose.prod.yml | 172 +------------------------- 13 files changed, 399 insertions(+), 228 deletions(-) create mode 100755 .kamal/hooks/docker-setup.sample create mode 100755 .kamal/hooks/post-app-boot.sample create mode 100755 .kamal/hooks/post-deploy.sample create mode 100755 .kamal/hooks/post-proxy-reboot.sample create mode 100755 .kamal/hooks/pre-app-boot.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 config/deploy.yml diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index e8f4e82..96f2c6c 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -5,14 +5,9 @@ on: branches: ["main"] env: - REGISTRY: gitea.leonmorival.com - IMAGE_NAME: daily_meal/bemeal-api + KAMAL_IMAGE: ghcr.io/basecamp/kamal:v2.11.0 jobs: - - # ========================= - # TESTS - # ========================= test: name: Tests Unitaires runs-on: ubuntu-latest @@ -31,13 +26,10 @@ jobs: with: args: > bash -c "composer install --no-interaction --ignore-platform-req=ext-intl - && php artisan test" + && php artisan test --compact" - # ========================= - # BUILD & PUSH - # ========================= - build: - name: Build & Push Docker + deploy: + name: Deploy with Kamal needs: test runs-on: ubuntu-latest @@ -45,61 +37,84 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Login to Gitea Registry - run: | - echo "${{ secrets.TOKEN_GITEA }}" \ - | docker login $REGISTRY \ - -u "${{ github.actor }}" \ - --password-stdin - - - name: Build & Push image - run: | - IMAGE=$REGISTRY/$IMAGE_NAME - docker build -t "$IMAGE:${{ github.sha }}" -t "$IMAGE:latest" . - docker push "$IMAGE:${{ github.sha }}" - docker push "$IMAGE:latest" - - # ========================= - # DEPLOY (SIMPLE) - # ========================= - deploy: - name: Deploy - needs: build - runs-on: ubuntu-latest - - steps: - - name: Deploy via SSH - uses: appleboy/ssh-action@v1.0.3 + - name: Prepare SSH agent env: - IMAGE_TAG: ${{ github.sha }} - REGISTRY: gitea.leonmorival.com - IMAGE_NAME: daily_meal/bemeal-api - with: - host: ${{ secrets.SSH_HOST }} - username: ${{ secrets.SSH_USER }} - key: ${{ secrets.SSH_PRIVATE_KEY }} - envs: IMAGE_TAG,REGISTRY,IMAGE_NAME - script: | - set -eu + SSH_HOST: ${{ secrets.SSH_HOST }} + SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + run: | + set -eu - APP_DIR="/opt/bemeal" - cd "$APP_DIR" + eval "$(ssh-agent -s)" + echo "SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> "$GITHUB_ENV" - COMPOSE="docker compose \ - --project-directory $APP_DIR \ - --env-file $APP_DIR/.env.prod \ - -f $APP_DIR/docker-compose.prod.yml" + printf '%s\n' "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - - IMAGE="$REGISTRY/$IMAGE_NAME:$IMAGE_TAG" + mkdir -p ~/.ssh + chmod 700 ~/.ssh + ssh-keyscan -H "${SSH_HOST:-89.167.35.217}" >> ~/.ssh/known_hosts - echo "Pull image" - docker pull "$IMAGE" + - name: Prepare Kamal network + env: + SSH_HOST: ${{ secrets.SSH_HOST }} + SSH_USER: ${{ secrets.SSH_USER }} + run: | + set -eu - echo "Start infra services" - $COMPOSE up -d pgsql redis meilisearch + REMOTE_HOST="${SSH_HOST:-89.167.35.217}" + REMOTE_USER="${SSH_USER:-root}" - echo "Deploy API (blue/green handled by Caddy)" - IMAGE="$IMAGE" $COMPOSE up -d --no-deps --force-recreate bemeal-api-blue - IMAGE="$IMAGE" $COMPOSE up -d --no-deps --force-recreate bemeal-api-green + ssh "$REMOTE_USER@$REMOTE_HOST" <<'EOF' + set -eu - echo "Deployment done" + docker network inspect kamal >/dev/null 2>&1 || docker network create kamal + + for container in bemeal-pgsql bemeal-redis bemeal-meilisearch bemeal-adminer; do + if docker inspect "$container" >/dev/null 2>&1; then + docker network connect kamal "$container" >/dev/null 2>&1 || true + fi + done + EOF + + - name: Write Kamal secrets + run: | + set -eu + + mkdir -p .kamal + cat > .kamal/secrets <<'EOF' + GITEA_TOKEN=$GITEA_TOKEN + APP_KEY=$APP_KEY + DB_DATABASE=$DB_DATABASE + DB_USERNAME=$DB_USERNAME + DB_PASSWORD=$DB_PASSWORD + MEILISEARCH_KEY=$MEILISEARCH_KEY + EOF + chmod 600 .kamal/secrets + + - name: Deploy + env: + GITEA_TOKEN: ${{ secrets.TOKEN_GITEA }} + APP_KEY: ${{ secrets.APP_KEY }} + DB_DATABASE: ${{ secrets.DB_DATABASE }} + DB_USERNAME: ${{ secrets.DB_USERNAME }} + DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + MEILISEARCH_KEY: ${{ secrets.MEILISEARCH_KEY }} + SSH_HOST: ${{ secrets.SSH_HOST }} + SSH_USER: ${{ secrets.SSH_USER }} + run: | + set -eu + + docker run --rm \ + -v "$PWD:/workdir" \ + -v "$SSH_AUTH_SOCK:/ssh-agent" \ + -v "$HOME/.ssh:/root/.ssh:ro" \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -e SSH_AUTH_SOCK=/ssh-agent \ + -e GITEA_TOKEN \ + -e APP_KEY \ + -e DB_DATABASE \ + -e DB_USERNAME \ + -e DB_PASSWORD \ + -e MEILISEARCH_KEY \ + -e SSH_HOST \ + -e SSH_USER \ + "$KAMAL_IMAGE" deploy diff --git a/.gitignore b/.gitignore index 278fa39..568cf9d 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ Homestead.json Homestead.yaml Thumbs.db .env.prod +.kamal/secrets 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-app-boot.sample b/.kamal/hooks/post-app-boot.sample new file mode 100755 index 0000000..70f9c4b --- /dev/null +++ b/.kamal/hooks/post-app-boot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Booted app version $KAMAL_VERSION on $KAMAL_HOSTS..." diff --git a/.kamal/hooks/post-deploy.sample b/.kamal/hooks/post-deploy.sample new file mode 100755 index 0000000..fd364c2 --- /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_ROLES (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-app-boot.sample b/.kamal/hooks/pre-app-boot.sample new file mode 100755 index 0000000..45f7355 --- /dev/null +++ b/.kamal/hooks/pre-app-boot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Booting app version $KAMAL_VERSION on $KAMAL_HOSTS..." diff --git a/.kamal/hooks/pre-build.sample b/.kamal/hooks/pre-build.sample new file mode 100755 index 0000000..c5a5567 --- /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_ROLES (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..77744bd --- /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_ROLES (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..05b3055 --- /dev/null +++ b/.kamal/hooks/pre-deploy.sample @@ -0,0 +1,122 @@ +#!/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_ROLES (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 = github_repo_from_remote_url + @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 + + private + def github_repo_from_remote_url + url = `git config --get remote.origin.url`.strip.delete_suffix(".git") + if url.start_with?("https://github.com/") + url.delete_prefix("https://github.com/") + elsif url.start_with?("git@github.com:") + url.delete_prefix("git@github.com:") + else + url + end + end +end + + +$stdout.sync = true + +begin + puts "Checking build status..." + + attempts = 0 + checks = GithubStatusChecks.new + + 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/config/deploy.yml b/config/deploy.yml new file mode 100644 index 0000000..84c2d9f --- /dev/null +++ b/config/deploy.yml @@ -0,0 +1,66 @@ +service: bemeal +image: daily_meal/bemeal-api + +x-hosts: &hosts + - <%= ENV.fetch("SSH_HOST", "89.167.35.217") %> + +servers: + web: + hosts: *hosts + horizon: + hosts: *hosts + cmd: php artisan horizon + scheduler: + hosts: *hosts + cmd: php artisan schedule:work + +proxy: + host: bemeal.leonmorival.com + ssl: true + app_port: 80 + healthcheck: + path: /up + +registry: + server: gitea.leonmorival.com + username: leon.morival@gmail.com + password: + - GITEA_TOKEN + +ssh: + user: <%= ENV.fetch("SSH_USER", "root") %> + +builder: + arch: amd64 + +env: + clear: + APP_LOCALE: fr + APP_ENV: production + APP_DEBUG: "false" + APP_URL: https://bemeal.leonmorival.com + SERVER_NAME: ":80" + APP_RUNTIME: "Laravel\\FrankenPHP\\Runtime" + FILESYSTEM_DISK: public + DB_CONNECTION: pgsql + DB_HOST: bemeal-pgsql + DB_PORT: "5432" + REDIS_HOST: bemeal-redis + REDIS_CLIENT: predis + QUEUE_CONNECTION: redis + SCOUT_DRIVER: meilisearch + MEILISEARCH_HOST: http://bemeal-meilisearch:7700 + secret: + - APP_KEY + - DB_DATABASE + - DB_USERNAME + - DB_PASSWORD + - MEILISEARCH_KEY + +volumes: + - bemeal_storage_data:/var/www/html/storage/app + - bemeal_storage_public_data:/var/www/html/storage/app/public + +aliases: + artisan: app exec --reuse "php artisan" + shell: app exec --interactive --reuse "sh" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index ce3f46a..c23ab68 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,105 +1,6 @@ name: bemeal -# ========================= -# IMAGE -# ========================= -x-app-image: &app-image gitea.leonmorival.com/daily_meal/bemeal-api:${IMAGE_TAG:-latest} - -# ========================= -# ENV -# ========================= -x-app-environment: &app-environment - APP_LOCALE: "fr" - APP_ENV: "production" - APP_KEY: "${APP_KEY:?APP_KEY is required}" - APP_URL: "${APP_URL:?APP_URL is required}" - SERVER_NAME: ":80" - APP_RUNTIME: "Laravel\\FrankenPHP\\Runtime" - APP_DEBUG: "${APP_DEBUG:-false}" - - FILESYSTEM_DISK: "public" - - DB_CONNECTION: "pgsql" - DB_HOST: "bemeal-pgsql" - DB_PORT: "5432" - DB_DATABASE: "${DB_DATABASE:?DB_DATABASE is required}" - DB_USERNAME: "${DB_USERNAME:?DB_USERNAME is required}" - DB_PASSWORD: "${DB_PASSWORD:?DB_PASSWORD is required}" - - REDIS_HOST: "bemeal-redis" - REDIS_CLIENT: "predis" - QUEUE_CONNECTION: "redis" - - SCOUT_DRIVER: "meilisearch" - MEILISEARCH_HOST: "http://bemeal-meilisearch:7700" - MEILISEARCH_KEY: "${MEILISEARCH_KEY:?MEILISEARCH_KEY is required}" - -# ========================= -# VOLUMES -# ========================= -x-app-volumes: &app-volumes - - storage-data:/var/www/html/storage/app - - storage-public-data:/var/www/html/storage/app/public - -# ========================= -# DEPENDS -# ========================= -x-app-depends-on: &app-depends-on - pgsql: - condition: service_healthy - redis: - condition: service_started - meilisearch: - condition: service_started - -# ========================= -# HEALTHCHECK -# ========================= -x-app-healthcheck: &app-healthcheck - test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/api/health >/dev/null 2>&1"] - start_period: 30s - interval: 5s - timeout: 5s - retries: 12 - -# ========================= -# APP BASE -# ========================= -x-app-service: &app-service - image: *app-image - restart: unless-stopped - depends_on: *app-depends-on - environment: *app-environment - volumes: *app-volumes - networks: - - internal - - public - stop_grace_period: 30s - -# ========================= -# SERVICES -# ========================= services: - - # ========================= - # BLUE - # ========================= - bemeal-api-blue: - <<: *app-service - container_name: bemeal-api-blue - healthcheck: *app-healthcheck - - # ========================= - # GREEN - # ========================= - bemeal-api-green: - <<: *app-service - container_name: bemeal-api-green - healthcheck: *app-healthcheck - - # ========================= - # DATABASE - # ========================= pgsql: image: postgres:15-alpine container_name: bemeal-pgsql @@ -117,11 +18,8 @@ services: timeout: 5s retries: 10 networks: - - internal + - kamal - # ========================= - # REDIS - # ========================= redis: image: redis:alpine container_name: bemeal-redis @@ -129,47 +27,8 @@ services: volumes: - redis-data:/data networks: - - internal + - kamal - # ========================= - # HORIZON - # ========================= - horizon: - image: *app-image - container_name: bemeal-horizon - restart: unless-stopped - depends_on: - pgsql: - condition: service_healthy - redis: - condition: service_started - environment: *app-environment - volumes: *app-volumes - command: php artisan horizon - networks: - - internal - - # ========================= - # SCHEDULER - # ========================= - scheduler: - image: *app-image - container_name: bemeal-scheduler - restart: unless-stopped - depends_on: - pgsql: - condition: service_healthy - redis: - condition: service_started - environment: *app-environment - volumes: *app-volumes - command: php artisan schedule:work - networks: - - internal - - # ========================= - # ADMINER - # ========================= adminer: image: adminer:latest container_name: bemeal-adminer @@ -177,11 +36,8 @@ services: environment: ADMINER_DEFAULT_SERVER: bemeal-pgsql networks: - - internal + - kamal - # ========================= - # MEILISEARCH - # ========================= meilisearch: image: getmeili/meilisearch:latest container_name: bemeal-meilisearch @@ -192,11 +48,8 @@ services: volumes: - meilisearch-data:/meili_data networks: - - internal + - kamal -# ========================= -# VOLUMES -# ========================= volumes: pgsql-data: external: true @@ -205,23 +58,10 @@ volumes: redis-data: name: bemeal_redis_data - storage-data: - name: bemeal_storage_data - - storage-public-data: - name: bemeal_storage_public_data - meilisearch-data: name: bemeal_meilisearch_data -# ========================= -# NETWORKS -# ========================= networks: - internal: - name: bemeal_internal - driver: bridge - - public: + kamal: external: true - name: bemeal_public + name: kamal