name: gowalk-cicd backend deploy
description: >
  Build and deploy a Dockerized Python+Postgres backend to the gowalk deploy
  host (138.197.36.107) — rsync the backend dir, `docker compose up -d --build`,
  wire an nginx vhost + Let's Encrypt cert for the API domain, and health-check.
  The deploy host is publicly reachable, so this runs on a GitHub-hosted runner.

inputs:
  operation:
    description: deploy (default), identity-diagnostics, or read-only network-diagnostics.
    required: false
    default: deploy
  diagnostic-service:
    description: Exact Compose service to inspect; used only for identity-diagnostics.
    required: false
    default: api
  diagnostic-since:
    description: UTC YYYY-MM-DDTHH:MM:SSZ within the previous 24 hours; required for diagnostics.
    required: false
    default: ""
  diagnostic-provider:
    description: Optional fixed metadata source for network-diagnostics (none or digitalocean).
    required: false
    default: none
  host:
    description: SSH host of the deploy server.
    required: false
    default: "138.197.36.107"
  ssh-user:
    description: SSH user on the deploy server.
    required: false
    default: "root"
  ssh-key:
    description: Private SSH key with access to the deploy host (from a secret).
    required: true
  app-name:
    description: Stable slug — the compose project and /opt/gowalk-backends/<app-name> dir.
    required: true
  api-domain:
    description: >
      Public domain for the API (e.g. api.myapp.gowalk.com). Its DNS A record
      must already point at the deploy host. Empty = deploy the container only,
      skip nginx + cert.
    required: false
    default: ""
  backend-dir:
    description: Path to the backend source directory.
    required: false
    default: "backend"
  compose-file:
    description: >
      Compose file path: compose.yaml, compose.yml, docker-compose.yaml or docker-compose.yml
      at the root or inside backend-dir. Empty selects the one unambiguous supported file.
    required: false
    default: ""
  health-path:
    description: HTTP path the service answers 2xx on once healthy.
    required: false
    default: "/health"
  public-health-url:
    description: App-owned HTTPS edge health URL; empty preserves the infrastructure health probe.
    required: false
    default: ""
  public-base-url:
    description: Canonical client API base supplied as BACKEND_PUBLIC_BASE_URL to Compose interpolation.
    required: false
    default: ""
  expected-build-sha:
    description: Exact 40-character lowercase commit SHA required in health JSON build_sha.
    required: false
    default: ""
  ingress-required:
    description: Protect a distinct edge-only infrastructure vhost, retaining an existing legacy vhost.
    required: false
    default: "false"
  ingress-token:
    description: Backend-only per-app token sent by the edge in X-App-Robot-Backend-Token.
    required: false
    default: ""
  cert-email:
    description: Email for the Let's Encrypt registration.
    required: false
    default: "admin@gowalk.com"
  runtime-env:
    description: >
      Optional newline-delimited KEY=value secrets written to a mode-0600
      host-side env file and supplied to Docker Compose. Pass from one
      encrypted secret such as BACKEND_RUNTIME_ENV; never commit the values.
    required: false
    default: ""

runs:
  using: composite
  steps:
    - name: Validate operation
      shell: bash
      env:
        OPERATION: ${{ inputs.operation }}
      run: |
        case "$OPERATION" in deploy|identity-diagnostics|network-diagnostics) ;; *) exit 1;; esac

    - name: Validate inputs
      if: inputs.operation == 'deploy'
      id: layout
      shell: bash
      env:
        BACKEND_DIR_INPUT: ${{ inputs.backend-dir }}
        COMPOSE_FILE_INPUT: ${{ inputs.compose-file }}
        BACKEND_LAYOUT_REQUIRED: "true"
        BACKEND_PUBLIC_HEALTH_URL: ${{ inputs.public-health-url }}
        BACKEND_PUBLIC_BASE_URL: ${{ inputs.public-base-url }}
        BACKEND_EXPECTED_BUILD_SHA: ${{ inputs.expected-build-sha }}
        BACKEND_INGRESS_REQUIRED: ${{ inputs.ingress-required }}
        BACKEND_INGRESS_TOKEN: ${{ inputs.ingress-token }}
      run: |
        set -euo pipefail
        source "${{ github.action_path }}/deploy_evidence.sh"
        BACKEND_DEPLOY_PHASE=validate_inputs
        python3 "${{ github.action_path }}/edge_inputs.py" check
        case "${{ inputs.app-name }}" in
          *[!a-z0-9-]*|"") echo "::error::app-name must be [a-z0-9-]"; exit 1;;
        esac
        node "${{ github.action_path }}/layout.cjs"

    - name: Configure SSH
      shell: bash
      env:
        DEPLOY_SSH_KEY: ${{ inputs.ssh-key }}
      run: |
        set -euo pipefail
        source "${{ github.action_path }}/deploy_evidence.sh"
        BACKEND_DEPLOY_PHASE=configure_ssh
        mkdir -p ~/.ssh && chmod 700 ~/.ssh
        printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/backend_deploy
        chmod 600 ~/.ssh/backend_deploy
        ssh-keyscan -t ed25519,rsa,ecdsa "${{ inputs.host }}" >> ~/.ssh/known_hosts 2>/dev/null
        chmod 644 ~/.ssh/known_hosts

    - name: Sync backend to the host
      if: inputs.operation == 'deploy'
      shell: bash
      env:
        BACKEND_DIR: ${{ steps.layout.outputs.path }}
        COMPOSE_FILE: ${{ steps.layout.outputs.compose-file }}
      run: |
        set -euo pipefail
        source "${{ github.action_path }}/deploy_evidence.sh"
        BACKEND_DEPLOY_PHASE=sync_backend
        SSH="ssh -i ~/.ssh/backend_deploy -o IdentitiesOnly=yes"
        DEST="/opt/gowalk-backends/${{ inputs.app-name }}"
        $SSH "${{ inputs.ssh-user }}@${{ inputs.host }}" "mkdir -p '$DEST'"
        # --delete keeps the mirror true; preserve the private server-side env files
        # until their replacement validates so failed credential updates retain the old file.
        if [ "${COMPOSE_FILE%/*}" = "$BACKEND_DIR" ]; then
          rsync -az --delete --exclude '.env' --exclude '.runtime.env' --exclude '.git' \
            --exclude '.backend-edge.json' --exclude '.backend-ingress.conf' --exclude '.backend-public.env' \
            -e "$SSH" "$BACKEND_DIR/" \
            "${{ inputs.ssh-user }}@${{ inputs.host }}:$DEST/"
        else
          $SSH "${{ inputs.ssh-user }}@${{ inputs.host }}" \
            "mkdir -p '$DEST/$BACKEND_DIR'"
          rsync -az --delete --exclude '.env' --exclude '.runtime.env' --exclude '.git' \
            -e "$SSH" "$BACKEND_DIR/" \
            "${{ inputs.ssh-user }}@${{ inputs.host }}:$DEST/$BACKEND_DIR/"
          rsync -az -e "$SSH" "$COMPOSE_FILE" \
            "${{ inputs.ssh-user }}@${{ inputs.host }}:$DEST/${COMPOSE_FILE##*/}"
        fi
        # Ship remote helpers after the application mirror. They are package
        # owned and kept out of the consumer's source tree.
        rsync -az -e "$SSH" \
          "${{ github.action_path }}/remote_deploy.sh" \
          "${{ github.action_path }}/select_certbot_account.sh" \
          "${{ github.action_path }}/ensure_certificate.sh" \
          "${{ github.action_path }}/certificate_vhost.py" \
          "${{ github.action_path }}/configure_vhost.py" \
          "${{ github.action_path }}/edge_inputs.py" \
          "${{ github.action_path }}/runtime_env.py" \
          "${{ github.action_path }}/runtime_env_parse.py" \
          "${{ github.action_path }}/edge_vhost.py" \
          "${{ github.action_path }}/edge_health.py" \
          "${{ github.action_path }}/identity_diagnostics.py" \
          "${{ github.action_path }}/network_runtime.py" \
          "${{ github.action_path }}/network_contract.py" \
          "${{ github.action_path }}/network_inventory.py" \
          "${{ github.action_path }}/network_owned_inventory.py" \
          "${{ github.action_path }}/network_readback.py" \
          "${{ github.action_path }}/network_pools.py" \
          "${{ github.action_path }}/network_prepare.py" \
          "${{ github.action_path }}/deploy_evidence.sh" \
          "${{ inputs.ssh-user }}@${{ inputs.host }}:$DEST/"

    - name: Deploy on the host
      if: inputs.operation == 'deploy'
      shell: bash
      env:
        RUNTIME_ENV: ${{ inputs.runtime-env }}
        COMPOSE_FILE: ${{ steps.layout.outputs.compose-file }}
        BACKEND_PUBLIC_HEALTH_URL: ${{ inputs.public-health-url }}
        BACKEND_PUBLIC_BASE_URL: ${{ inputs.public-base-url }}
        BACKEND_EXPECTED_BUILD_SHA: ${{ inputs.expected-build-sha }}
        BACKEND_INGRESS_REQUIRED: ${{ inputs.ingress-required }}
        BACKEND_INGRESS_TOKEN: ${{ inputs.ingress-token }}
      run: |
        set -euo pipefail
        source "${{ github.action_path }}/deploy_evidence.sh"
        BACKEND_DEPLOY_PHASE=write_runtime_env
        SSH="ssh -i ~/.ssh/backend_deploy -o IdentitiesOnly=yes"
        DEST="/opt/gowalk-backends/${{ inputs.app-name }}"
        EDGE_DIR="/opt/gowalk-backend-ingress/${{ inputs.app-name }}"
        python3 "${{ github.action_path }}/edge_inputs.py" export | $SSH \
          "${{ inputs.ssh-user }}@${{ inputs.host }}" \
          "umask 077; mkdir -p '$EDGE_DIR'; python3 '$DEST/edge_inputs.py' receive '$EDGE_DIR/.backend-edge.json'"
        if [ -n "${RUNTIME_ENV:-}" ]; then
          python3 "${{ github.action_path }}/runtime_env.py" export | $SSH \
            "${{ inputs.ssh-user }}@${{ inputs.host }}" \
            "python3 '$DEST/runtime_env.py' receive '$DEST/.runtime.env'"
        fi
        BACKEND_DEPLOY_PHASE=remote_deploy
        $SSH "${{ inputs.ssh-user }}@${{ inputs.host }}" \
          "bash '$DEST/remote_deploy.sh' \
             '${{ inputs.app-name }}' '${{ inputs.api-domain }}' \
             '${{ inputs.health-path }}' '${{ inputs.cert-email }}' '${COMPOSE_FILE##*/}'"

    - name: Read fixed backend identity diagnostics
      id: identity_diagnostics
      if: inputs.operation == 'identity-diagnostics'
      shell: bash
      env:
        DIAGNOSTIC_APP: ${{ inputs.app-name }}
        DIAGNOSTIC_SERVICE: ${{ inputs.diagnostic-service }}
        DIAGNOSTIC_SINCE: ${{ inputs.diagnostic-since }}
        DIAGNOSTIC_HOST: ${{ inputs.host }}
        DIAGNOSTIC_USER: ${{ inputs.ssh-user }}
      run: |
        RESULT_DIR="$(mktemp -d "$RUNNER_TEMP/backend-identity-diagnostics.XXXXXX")"
        echo "result=$RESULT_DIR/result.json" >> "$GITHUB_OUTPUT"
        python3 "${{ github.action_path }}/identity_diagnostics.py" \
          --app "$DIAGNOSTIC_APP" --service "$DIAGNOSTIC_SERVICE" --since "$DIAGNOSTIC_SINCE" \
          --host "$DIAGNOSTIC_HOST" --user "$DIAGNOSTIC_USER" \
          --output "$RESULT_DIR/result.json"

    - name: Retain fixed backend identity result
      if: always() && inputs.operation == 'identity-diagnostics'
      uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
      env:
        NODE_OPTIONS: --require "${{ github.action_path }}/scripts/artifact_env.cjs"
      with:
        name: backend-identity-diagnostics-${{ github.job }}-${{ github.run_attempt }}
        path: ${{ steps.identity_diagnostics.outputs.result }}
        if-no-files-found: error

    - name: Read backend network diagnostics
      id: network_diagnostics
      if: inputs.operation == 'network-diagnostics'
      shell: bash
      env:
        DIAGNOSTIC_APP: ${{ inputs.app-name }}
        DIAGNOSTIC_HOST: ${{ inputs.host }}
        DIAGNOSTIC_USER: ${{ inputs.ssh-user }}
        DIAGNOSTIC_PROVIDER: ${{ inputs.diagnostic-provider }}
      run: |
        RESULT_DIR="$(mktemp -d "$RUNNER_TEMP/backend-network-diagnostics.XXXXXX")"
        echo "result=$RESULT_DIR/result.json" >> "$GITHUB_OUTPUT"
        python3 "${{ github.action_path }}/network_diagnostics.py" \
          --app "$DIAGNOSTIC_APP" --host "$DIAGNOSTIC_HOST" --user "$DIAGNOSTIC_USER" \
          --provider "$DIAGNOSTIC_PROVIDER" \
          --output "$RESULT_DIR/result.json"

    - name: Retain backend network diagnostics
      if: always() && inputs.operation == 'network-diagnostics'
      uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
      env:
        NODE_OPTIONS: --require "${{ github.action_path }}/scripts/artifact_env.cjs"
      with:
        name: backend-network-diagnostics-${{ github.job }}-${{ github.run_attempt }}
        path: ${{ steps.network_diagnostics.outputs.result }}
        if-no-files-found: error

    - name: Cleanup key
      if: always()
      shell: bash
      run: |
        source "${{ github.action_path }}/deploy_evidence.sh"
        BACKEND_DEPLOY_PHASE=cleanup_key
        rm -f ~/.ssh/backend_deploy
