From 2bfc685a929c3ad6af6251b9064746b6d9c1cf6f Mon Sep 17 00:00:00 2001 From: Rody Davis Date: Tue, 20 Oct 2026 20:39:51 -0700 Subject: [PATCH] feat(celld-deploy): add reusable Cloudflare Worker & Durable Objects celld deployment action --- README.md | 1 + celld-deploy/README.md | 54 ++++++++++ celld-deploy/action.yml | 107 +++++++++++++++++++ celld-deploy/deploy.sh | 225 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 387 insertions(+) create mode 100644 celld-deploy/README.md create mode 100644 celld-deploy/action.yml create mode 100755 celld-deploy/deploy.sh diff --git a/README.md b/README.md index ad0c150..c6dc874 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ This repository contains reusable composite actions for CI/CD workflows across o | Action | Description | Reference | | :--- | :--- | :--- | +| [Deploy Cloudflare Worker to celld](./celld-deploy) | Bundles a Cloudflare Worker / Durable Objects project (wrangler.json) and deploys it to a self-hosted celld fleet backed by RustFS S3 and TrueNAS Docker Ingress. | `rodydavis/shared-actions/celld-deploy@main` | | [Enforce Flutter Version Constraint](./enforce-flutter-version) | Scans all pubspec.yaml files to ensure they use the required Flutter SDK constraint. | `rodydavis/shared-actions/enforce-flutter-version@main` | | [Flutter Build Android](./flutter-build-android) | Configures Android keystore signing, builds release AAB/APK artifacts, and optionally deploys to Google Play Console and Shorebird. | `rodydavis/shared-actions/flutter-build-android@main` | | [Flutter Build iOS](./flutter-build-ios) | Installs Apple signing certificates and provisioning profiles in an isolated keychain, builds an iOS release IPA, and optionally uploads to TestFlight and Shorebird. | `rodydavis/shared-actions/flutter-build-ios@main` | diff --git a/celld-deploy/README.md b/celld-deploy/README.md new file mode 100644 index 0000000..7684756 --- /dev/null +++ b/celld-deploy/README.md @@ -0,0 +1,54 @@ +# Deploy Cloudflare Worker to `celld` Action + +Custom Gitea Action for bundling and deploying Cloudflare Workers, Durable Objects, D1, KV, Queues, Workflows, R2, and Static Assets (`wrangler.json`) to a self-hosted [`denoland/celld`](https://github.com/denoland/celld) fleet on TrueNAS SCALE backed by RustFS S3 (`s3.rodydavis.dev`). + +## Features + +- **Zero-Docker-Build Hot Deploys**: Bundles TypeScript/JS Worker projects via `esbuild` and publishes directly to `s3://celld-`. Running `celld` daemons on TrueNAS adopt new deployments in place with zero container restarts. +- **Auto-Bucket Provisioning**: Automatically creates the S3 fleet bucket on RustFS (`s3.rodydavis.dev`) on first deploy. +- **Sub-Millisecond Internal S3 Path**: Configures the TrueNAS `celld` daemon to communicate directly with `http://minio-s3:9000` over the internal `apps-net` Docker bridge. +- **Automated D1 Migrations**: Optionally applies `.sql` migrations from `./migrations` via `celld d1 migrations apply`. + +## Inputs + +| Name | Description | Default | Required | +| :--- | :--- | :--- | :---: | +| `ssh_key` | Private SSH key for authenticating to TrueNAS SCALE | — | **Yes** | +| `s3_access_key` | S3 Access Key ID (`secrets.CELLD_S3_ACCESS_KEY`) | — | **Yes** | +| `s3_secret_key` | S3 Secret Key (`secrets.CELLD_S3_SECRET_KEY`) | — | **Yes** | +| `working_directory` | Path to Wrangler project directory | `.` | No | +| `app_name` | Application subpath under `/mnt/jupiter/apps//` | `/` | No | +| `bucket` | S3 bucket name for the `celld` fleet | `celld-` | No | +| `domain` | Target domain(s) for Traefik router (comma-separated) | `.rodydavis.dev` | No | +| `d1_database` | Optional D1 database name to run `celld d1 migrations apply` | `""` | No | +| `env_file_content` | Optional additional `.env` variables for the TrueNAS container | `""` | No | +| `idle_evict_s` | Seconds before idle Durable Objects hibernate (`CELLD_IDLE_EVICT_S`) | `60` | No | +| `ltx_retention_secs` | Seconds to retain old LTX epochs before GC (`CELLD_LTX_RETENTION_SECS`) | `86400` | No | +| `deploy_poll_s` | Seconds between bucket deployment checks (`CELLD_DEPLOY_POLL_S`) | `5` | No | + +## Usage Example + +```yaml +name: Deploy Worker to celld + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Deploy to TrueNAS celld Fleet + uses: https://git.rodydavis.dev/rodydavis/shared-actions/celld-deploy@main + with: + ssh_key: ${{ secrets.TRUENAS_SSH_KEY }} + s3_access_key: ${{ secrets.CELLD_S3_ACCESS_KEY }} + s3_secret_key: ${{ secrets.CELLD_S3_SECRET_KEY }} + domain: my-worker.rodydavis.dev + # Optional if using D1 SQLite migrations in ./migrations: + # d1_database: main-db +``` diff --git a/celld-deploy/action.yml b/celld-deploy/action.yml new file mode 100644 index 0000000..5435335 --- /dev/null +++ b/celld-deploy/action.yml @@ -0,0 +1,107 @@ +name: 'Deploy Cloudflare Worker to celld' +description: 'Bundles a Cloudflare Worker / Durable Objects project (wrangler.json) and deploys it to a self-hosted celld fleet backed by RustFS S3 and TrueNAS Docker Ingress.' +inputs: + ssh_key: + description: 'Private SSH key for authenticating to TrueNAS SCALE' + required: true + s3_access_key: + description: 'S3 Access Key ID for the celld fleet bucket (defaults to CELLD_S3_ACCESS_KEY)' + required: true + s3_secret_key: + description: 'S3 Secret Access Key for the celld fleet bucket (defaults to CELLD_S3_SECRET_KEY)' + required: true + working_directory: + description: 'Path to the Wrangler project directory containing wrangler.json / wrangler.toml' + required: false + default: '.' + app_name: + description: 'Application subpath under /mnt/jupiter/apps// (defaults to /)' + required: false + default: '' + bucket: + description: 'S3 bucket name for the celld fleet (defaults to celld-)' + required: false + default: '' + domain: + description: 'Target domain(s) for Traefik router, comma-separated (defaults to .rodydavis.dev)' + required: false + default: '' + s3_endpoint: + description: 'External S3 endpoint used by the CI runner during celld deploy' + required: false + default: 'https://s3.rodydavis.dev' + internal_s3_endpoint: + description: 'Internal S3 endpoint used by the celld container on TrueNAS over apps-net' + required: false + default: 'http://minio-s3:9000' + s3_region: + description: 'S3 bucket region' + required: false + default: 'us-east-1' + d1_database: + description: 'Optional D1 database name to automatically apply migrations for (celld d1 migrations apply)' + required: false + default: '' + env_file_content: + description: 'Optional additional environment variables to append to the TrueNAS .env file' + required: false + default: '' + idle_evict_s: + description: 'Seconds before idle Durable Object cells hibernate from memory (CELLD_IDLE_EVICT_S)' + required: false + default: '60' + ltx_retention_secs: + description: 'Seconds to retain superseded LTX SQLite epoch logs before garbage collection (CELLD_LTX_RETENTION_SECS)' + required: false + default: '86400' + deploy_poll_s: + description: 'Seconds between bucket deployment pointer checks on the celld node (CELLD_DEPLOY_POLL_S)' + required: false + default: '5' + host: + description: 'TrueNAS SSH host IP or domain' + required: false + default: '10.0.0.218' + user: + description: 'TrueNAS SSH user' + required: false + default: 'root' + network: + description: 'Docker external network name for Traefik ingress' + required: false + default: 'apps-net' + traefik_entrypoints: + description: 'Traefik entrypoint (e.g. web)' + required: false + default: 'web' + +runs: + using: 'composite' + steps: + - name: Deploy Worker Bundle and Ensure celld Fleet on TrueNAS + shell: bash + env: + INPUT_SSH_KEY: ${{ inputs.ssh_key }} + INPUT_S3_ACCESS_KEY: ${{ inputs.s3_access_key }} + INPUT_S3_SECRET_KEY: ${{ inputs.s3_secret_key }} + INPUT_WORKING_DIRECTORY: ${{ inputs.working_directory }} + INPUT_APP_NAME: ${{ inputs.app_name }} + INPUT_BUCKET: ${{ inputs.bucket }} + INPUT_DOMAIN: ${{ inputs.domain }} + INPUT_S3_ENDPOINT: ${{ inputs.s3_endpoint }} + INPUT_INTERNAL_S3_ENDPOINT: ${{ inputs.internal_s3_endpoint }} + INPUT_S3_REGION: ${{ inputs.s3_region }} + INPUT_D1_DATABASE: ${{ inputs.d1_database }} + INPUT_ENV_CONTENT: ${{ inputs.env_file_content }} + INPUT_IDLE_EVICT_S: ${{ inputs.idle_evict_s }} + INPUT_LTX_RETENTION_SECS: ${{ inputs.ltx_retention_secs }} + INPUT_DEPLOY_POLL_S: ${{ inputs.deploy_poll_s }} + INPUT_HOST: ${{ inputs.host }} + INPUT_USER: ${{ inputs.user }} + INPUT_NETWORK: ${{ inputs.network }} + INPUT_ENTRYPOINTS: ${{ inputs.traefik_entrypoints }} + REPO_FULL_NAME: ${{ github.repository }} + REPO_OWNER: ${{ github.repository_owner }} + REPO_NAME: ${{ github.event.repository.name }} + run: | + bash "${{ github.action_path }}/deploy.sh" diff --git a/celld-deploy/deploy.sh b/celld-deploy/deploy.sh new file mode 100755 index 0000000..31d0b08 --- /dev/null +++ b/celld-deploy/deploy.sh @@ -0,0 +1,225 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "🚀 Starting celld Worker & Durable Objects Deployment..." + +# 1. Resolve repository, owner, and application directory +FULL_REPO="${REPO_FULL_NAME:-$GITHUB_REPOSITORY}" +OWNER="${REPO_OWNER:-$(dirname "$FULL_REPO")}" +if [ -z "${REPO_NAME:-}" ]; then + REPO_NAME=$(basename "$FULL_REPO") +fi + +if [ -n "${INPUT_APP_NAME:-}" ]; then + case "$INPUT_APP_NAME" in + */*) APP_DIR_NAME="$INPUT_APP_NAME" ;; + *) APP_DIR_NAME="${OWNER}/${INPUT_APP_NAME}" ;; + esac +else + APP_DIR_NAME="$FULL_REPO" +fi + +CLEAN_APP=$(basename "$APP_DIR_NAME" | tr '_' '-' | tr '[:upper:]' '[:lower:]') +ROUTER_NAME=$(echo "$CLEAN_APP" | tr -cd '[:alnum:]') + +# 2. Resolve S3 bucket name +RAW_BUCKET="${INPUT_BUCKET:-celld-${CLEAN_APP}}" +BUCKET="${RAW_BUCKET#s3://}" +BUCKET=$(echo "$BUCKET" | tr '_' '-' | tr '[:upper:]' '[:lower:]') + +S3_ENDPOINT="${INPUT_S3_ENDPOINT:-https://s3.rodydavis.dev}" +S3_ENDPOINT="${S3_ENDPOINT%/}" +INTERNAL_S3_ENDPOINT="${INPUT_INTERNAL_S3_ENDPOINT:-http://minio-s3:9000}" +INTERNAL_S3_ENDPOINT="${INTERNAL_S3_ENDPOINT%/}" +S3_REGION="${INPUT_S3_REGION:-us-east-1}" + +# 3. Resolve domain(s) and build Traefik Host() rule +RAW_DOMAINS="${INPUT_DOMAIN:-${CLEAN_APP}.rodydavis.dev}" +IFS=',' read -ra DOMAIN_ARR <<< "$RAW_DOMAINS" +TRAEFIK_RULE="" +PRIMARY_DOMAIN="" +for d in "${DOMAIN_ARR[@]}"; do + d_clean=$(echo "$d" | xargs | sed -E 's#^https?://##' | sed 's#/.*$##') + if [ -z "$d_clean" ]; then continue; fi + if [ -z "$PRIMARY_DOMAIN" ]; then PRIMARY_DOMAIN="$d_clean"; fi + if [ -z "$TRAEFIK_RULE" ]; then + TRAEFIK_RULE="Host(\`${d_clean}\`)" + else + TRAEFIK_RULE="${TRAEFIK_RULE} || Host(\`${d_clean}\`)" + fi +done + +echo "📦 Application: ${APP_DIR_NAME}" +echo "🪣 Fleet S3 Bucket: s3://${BUCKET} (${S3_ENDPOINT})" +echo "🌐 Routing Rule: ${TRAEFIK_RULE}" + +# 4. Ensure celld and esbuild binaries are on PATH +if ! command -v celld >/dev/null 2>&1; then + echo "⚙️ Installing celld CLI..." + if command -v docker >/dev/null 2>&1 && docker image inspect ghcr.io/denoland/celld:latest >/dev/null 2>&1; then + CID=$(docker create ghcr.io/denoland/celld:latest) + docker cp "${CID}:/usr/local/bin/celld" /usr/local/bin/celld + docker rm -f "$CID" >/dev/null + chmod +x /usr/local/bin/celld + else + curl -fsSL https://celld.dev/install.sh | sh + export PATH="$HOME/.local/bin:$PATH" + fi +fi +echo "✅ Using $(celld --version)" + +WORKDIR="${INPUT_WORKING_DIRECTORY:-.}" +cd "$WORKDIR" + +if [ -f "./node_modules/.bin/esbuild" ]; then + export PATH="$(pwd)/node_modules/.bin:$PATH" +fi + +if ! command -v esbuild >/dev/null 2>&1; then + echo "⚙️ Installing esbuild..." + npm install -g esbuild --no-audit --no-fund >/dev/null 2>&1 +fi +echo "✅ Using esbuild $(esbuild --version)" + +# 5. Export AWS credentials for S3 and ensure fleet bucket exists +export AWS_ACCESS_KEY_ID="$INPUT_S3_ACCESS_KEY" +export AWS_SECRET_ACCESS_KEY="$INPUT_S3_SECRET_KEY" +export AWS_DEFAULT_REGION="$S3_REGION" + +EMPTY_SHA256="e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" +HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + --aws-sigv4 "aws:amz:${S3_REGION}:s3" \ + --user "${AWS_ACCESS_KEY_ID}:${AWS_SECRET_ACCESS_KEY}" \ + -H "x-amz-content-sha256: ${EMPTY_SHA256}" \ + -I "${S3_ENDPOINT}/${BUCKET}" || echo "000") + +if [ "$HTTP_STATUS" = "404" ]; then + echo "🪣 Creating S3 bucket '${BUCKET}' on ${S3_ENDPOINT}..." + CREATE_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -X PUT \ + --aws-sigv4 "aws:amz:${S3_REGION}:s3" \ + --user "${AWS_ACCESS_KEY_ID}:${AWS_SECRET_ACCESS_KEY}" \ + -H "x-amz-content-sha256: ${EMPTY_SHA256}" \ + "${S3_ENDPOINT}/${BUCKET}") + if [ "$CREATE_STATUS" != "200" ] && [ "$CREATE_STATUS" != "204" ] && [ "$CREATE_STATUS" != "409" ]; then + echo "::error::Failed to create bucket '${BUCKET}' (HTTP ${CREATE_STATUS})" + exit 1 + fi + echo "✅ Created bucket s3://${BUCKET}" +elif [ "$HTTP_STATUS" = "200" ]; then + echo "✅ Verified existing bucket s3://${BUCKET}" +else + echo "⚠️ Bucket probe returned HTTP ${HTTP_STATUS}; proceeding with celld deploy..." +fi + +# 6. Deploy Worker bundle & assets directly to S3 bucket +echo "📤 Deploying Worker bundle to s3://${BUCKET}..." +celld deploy . \ + --bucket "s3://${BUCKET}" \ + --endpoint "${S3_ENDPOINT}" \ + --region "${S3_REGION}" + +# 7. Ensure celld Fleet Daemon is provisioned and running on TrueNAS +HOST="${INPUT_HOST:-10.0.0.218}" +USER="${INPUT_USER:-root}" +NETWORK="${INPUT_NETWORK:-apps-net}" +ENTRYPOINTS="${INPUT_ENTRYPOINTS:-web}" +IDLE_EVICT_S="${INPUT_IDLE_EVICT_S:-60}" +LTX_RETENTION_SECS="${INPUT_LTX_RETENTION_SECS:-86400}" +DEPLOY_POLL_S="${INPUT_DEPLOY_POLL_S:-5}" + +STAGING_DIR=$(mktemp -d) +trap 'rm -rf "$STAGING_DIR" ~/.ssh/truenas_deploy_key' EXIT + +{ + echo "AWS_ACCESS_KEY_ID=${INPUT_S3_ACCESS_KEY}" + echo "AWS_SECRET_ACCESS_KEY=${INPUT_S3_SECRET_KEY}" + if [ -n "${INPUT_ENV_CONTENT:-}" ]; then + printf "%s\n" "$INPUT_ENV_CONTENT" + fi +} > "${STAGING_DIR}/.env" + +if [ -f "docker-compose.yml" ]; then + cp "docker-compose.yml" "${STAGING_DIR}/docker-compose.yml" +elif [ -f "docker-compose.yaml" ]; then + cp "docker-compose.yaml" "${STAGING_DIR}/docker-compose.yml" +else + cat < "${STAGING_DIR}/docker-compose.yml" +services: + celld: + image: ghcr.io/denoland/celld:latest + container_name: ${CLEAN_APP}-celld + restart: unless-stopped + env_file: + - .env + environment: + - CELLD_NODE=${CLEAN_APP}-truenas-1 + - CELLD_WATCH=/var/lib/celld/state + - CELLD_IDLE_EVICT_S=${IDLE_EVICT_S} + - CELLD_LTX_RETENTION_SECS=${LTX_RETENTION_SECS} + - CELLD_DEPLOY_POLL_S=${DEPLOY_POLL_S} + volumes: + - ./data:/var/lib/celld + command: + - --bucket=s3://${BUCKET} + - --endpoint=${INTERNAL_S3_ENDPOINT} + - --region=${S3_REGION} + - --listen=0.0.0.0:8080 + - --internal-listen=127.0.0.1:8081 + - --advertise=127.0.0.1:8081 + networks: + - ${NETWORK} + labels: + - "traefik.enable=true" + - "traefik.http.routers.${ROUTER_NAME}.rule=${TRAEFIK_RULE}" + - "traefik.http.routers.${ROUTER_NAME}.entrypoints=${ENTRYPOINTS}" + - "traefik.http.services.${ROUTER_NAME}.loadbalancer.server.port=8080" + +networks: + ${NETWORK}: + external: true +EOF +fi + +mkdir -p ~/.ssh +echo "$INPUT_SSH_KEY" > ~/.ssh/truenas_deploy_key +chmod 600 ~/.ssh/truenas_deploy_key +ssh-keyscan -H "$HOST" >> ~/.ssh/known_hosts 2>/dev/null + +REMOTE_DIR="/mnt/jupiter/apps/${APP_DIR_NAME}" +ssh -i ~/.ssh/truenas_deploy_key -o StrictHostKeyChecking=no "${USER}@${HOST}" "mkdir -p ${REMOTE_DIR}/data" + +LOCAL_ENV_HASH=$(sha256sum "${STAGING_DIR}/.env" | awk '{print $1}') +LOCAL_COMPOSE_HASH=$(sha256sum "${STAGING_DIR}/docker-compose.yml" | awk '{print $1}') + +REMOTE_STATE=$(ssh -i ~/.ssh/truenas_deploy_key -o StrictHostKeyChecking=no "${USER}@${HOST}" " + ENV_H=\$(sha256sum ${REMOTE_DIR}/.env 2>/dev/null | awk '{print \$1}' || echo 'none') + COMP_H=\$(sha256sum ${REMOTE_DIR}/docker-compose.yml 2>/dev/null | awk '{print \$1}' || echo 'none') + RUNNING=\$(docker inspect -f '{{.State.Running}}' ${CLEAN_APP}-celld 2>/dev/null || echo 'false') + echo \"\${ENV_H}:\${COMP_H}:\${RUNNING}\" +") + +if [ "$REMOTE_STATE" != "${LOCAL_ENV_HASH}:${LOCAL_COMPOSE_HASH}:true" ]; then + echo "🚢 Updating TrueNAS celld container stack at ${REMOTE_DIR}..." + scp -i ~/.ssh/truenas_deploy_key -o StrictHostKeyChecking=no "${STAGING_DIR}/.env" "${USER}@${HOST}:${REMOTE_DIR}/.env" + scp -i ~/.ssh/truenas_deploy_key -o StrictHostKeyChecking=no "${STAGING_DIR}/docker-compose.yml" "${USER}@${HOST}:${REMOTE_DIR}/docker-compose.yml" + ssh -i ~/.ssh/truenas_deploy_key -o StrictHostKeyChecking=no "${USER}@${HOST}" "chmod 600 ${REMOTE_DIR}/.env && cd ${REMOTE_DIR} && docker compose up -d --remove-orphans" + sleep 3 +else + echo "⚡ TrueNAS container ${CLEAN_APP}-celld is already running with matching config; hot-reloading bundle in place!" +fi + +# 8. Optional D1 SQLite migrations +if [ -n "${INPUT_D1_DATABASE:-}" ] && [ -d "migrations" ]; then + echo "🗄️ Applying D1 migrations for database '${INPUT_D1_DATABASE}'..." + ssh -i ~/.ssh/truenas_deploy_key -o StrictHostKeyChecking=no "${USER}@${HOST}" "rm -rf ${REMOTE_DIR}/data/migrations && mkdir -p ${REMOTE_DIR}/data/migrations" + scp -r -i ~/.ssh/truenas_deploy_key -o StrictHostKeyChecking=no migrations/. "${USER}@${HOST}:${REMOTE_DIR}/data/migrations/" + ssh -i ~/.ssh/truenas_deploy_key -o StrictHostKeyChecking=no "${USER}@${HOST}" " + docker exec -w /var/lib/celld ${CLEAN_APP}-celld celld d1 migrations apply '${INPUT_D1_DATABASE}' \ + --bucket 's3://${BUCKET}' \ + --endpoint '${INTERNAL_S3_ENDPOINT}' \ + --region '${S3_REGION}' + " +fi + +echo "✅ Successfully deployed ${APP_DIR_NAME} to https://${PRIMARY_DOMAIN}"