diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb08059..54d2b28 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,7 @@ on: - 'docker-compose.*' - 'dockerfiles/**' - '.github/workflows/ci.yml' + - '.github/workflows/deploy.yaml' - '.github/workflows/publish-containers.yml' - '.pre-commit-config.yaml' pull_request: @@ -121,48 +122,14 @@ jobs: deploy: name: Deploy - environment: dev needs: [tests] - permissions: - id-token: write - contents: read - runs-on: ubuntu-latest if: >- github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) - env: - STACK_NAME: eoapi-dev - - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - - - name: Create config file - run: | - echo "${{ vars.CONFIG_YAML }}" > config.yaml - - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@f79bc868ee9ca4687c7db10f05fe6a46cdcda892 # v6 - with: - role-to-assume: arn:aws:iam::390960605471:role/eoapi-devseed - role-session-name: eoapi-devseed - aws-region: us-west-2 - - - name: Set up node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 - with: - node-version: 20 - - - name: Install uv - uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9 # v7 - - - name: Install dependencies - run: | - uv sync --only-group deploy - uv run --only-group deploy npm install - - - name: CDK Synth - run: uv run --only-group deploy npx cdk synth --all - - - name: CDK Deploy - run: | - uv run --only-group deploy npx cdk deploy --all --require-approval never + uses: ./.github/workflows/deploy.yaml + with: + job: all + ref: ${{ github.event.pull_request.head.sha || github.ref_name }} + permissions: + id-token: write + contents: read diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml new file mode 100644 index 0000000..b3e6d95 --- /dev/null +++ b/.github/workflows/deploy.yaml @@ -0,0 +1,221 @@ +name: Deploy + +on: + workflow_call: + inputs: + ref: + description: Git ref to deploy + required: false + type: string + default: '' + job: + description: Job to run (aws, hetzner, or all) + required: false + type: string + default: all + workflow_dispatch: + inputs: + ref: + description: Git ref to deploy (branch, tag, or SHA) + required: false + default: main + job: + description: Job to run (aws, hetzner, or all) + required: false + type: string + default: all + +jobs: + hetzner: + name: Hetzner + if: inputs.job == 'all' || inputs.job == 'hetzner' + runs-on: ubuntu-latest + environment: hetzner + env: + ACME_EMAIL: ${{ vars.ACME_EMAIL }} + COMPOSE_FILE: docker-compose.yml:docker-compose.mock-oidc.yml:infrastructure/docker-compose.hetzner.yml + DEPLOY_PATH: ${{ vars.HETZNER_DEPLOY_PATH || '/home/user/eoapi-devseed' }} + EOAPI_DOMAIN: ${{ vars.EOAPI_DOMAIN }} + HETZNER_HOST: ${{ vars.HETZNER_HOST }} + HETZNER_USER: ${{ vars.HETZNER_USER || 'user' }} + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: ${{ inputs.ref || github.ref }} + + - name: Validate deployment configuration + run: | + missing=0 + for name in HETZNER_HOST EOAPI_DOMAIN ACME_EMAIL; do + if [ -z "${!name}" ]; then + echo "::error::$name is not set (configure it in the hetzner environment variables)" + missing=1 + fi + done + if [ -z "${{ secrets.HETZNER_SSH_KEY }}" ]; then + echo "::error::HETZNER_SSH_KEY is not set (configure it in the hetzner environment secrets)" + missing=1 + fi + [ "$missing" -eq 0 ] + + - name: Setup SSH key + env: + SSH_PRIVATE_KEY: ${{ secrets.HETZNER_SSH_KEY }} + run: | + if [ -z "$SSH_PRIVATE_KEY" ]; then + echo "::error::HETZNER_SSH_KEY is empty" + exit 1 + fi + + mkdir -p ~/.ssh + chmod 700 ~/.ssh + key_file=~/.ssh/deploy_key + + write_key() { + tr -d '\r' > "$key_file" + chmod 600 "$key_file" + } + + validate_key() { + ssh-keygen -y -f "$key_file" >/dev/null 2>&1 + } + + printf '%s' "$SSH_PRIVATE_KEY" | write_key + if validate_key; then + echo "SSH key loaded (raw multiline format)" + else + printf '%s' "$SSH_PRIVATE_KEY" | sed 's/\\n/\n/g' | write_key + if validate_key; then + echo "SSH key loaded (escaped newline format)" + else + if printf '%s' "$SSH_PRIVATE_KEY" | tr -d '\n\r ' | base64 -d 2>/dev/null | write_key && validate_key; then + echo "SSH key loaded (base64 format)" + else + first_line="$(printf '%s' "$SSH_PRIVATE_KEY" | head -n1)" + line_count="$(printf '%s' "$SSH_PRIVATE_KEY" | wc -l | tr -d ' ')" + echo "::error::HETZNER_SSH_KEY is not a valid private key." + echo "Secret diagnostics: lines=${line_count}, starts_with=${first_line:0:40}..." + echo + echo "Store the key in the hetzner environment secret using one of:" + echo " A) Raw key (paste entire file, including BEGIN/END lines)" + echo " B) Base64 (recommended): base64 -w0 < ~/.ssh/eoapi-deploy" + exit 1 + fi + fi + fi + + echo "SSH_OPTS=-i $key_file -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes" >> "$GITHUB_ENV" + + - name: Add server to known hosts + run: ssh-keyscan -H "$HETZNER_HOST" >> ~/.ssh/known_hosts + + - name: Verify Docker on server + run: | + ssh $SSH_OPTS "${HETZNER_USER}@${HETZNER_HOST}" \ + "command -v docker >/dev/null && docker compose version" \ + || { + echo "::error::Docker is not installed for ${HETZNER_USER}@${HETZNER_HOST}." + echo "Run scripts/hetzner-server-setup.sh on the server as root first." + exit 1 + } + + - name: Sync project to server + run: | + rsync -az --delete \ + --exclude '.git' \ + --exclude '.pgdata' \ + --exclude '.env' \ + --exclude '.venv' \ + --exclude '__pycache__' \ + -e "ssh $SSH_OPTS" \ + ./ "${HETZNER_USER}@${HETZNER_HOST}:${DEPLOY_PATH}/" + + - name: Upload .env + env: + HETZNER_ENV: ${{ secrets.HETZNER_ENV }} + run: | + if [ -n "$HETZNER_ENV" ]; then + printf '%s\n' "$HETZNER_ENV" > .env.deploy + scp $SSH_OPTS .env.deploy "${HETZNER_USER}@${HETZNER_HOST}:${DEPLOY_PATH}/.env" + rm .env.deploy + fi + + - name: Deploy services + run: | + ssh $SSH_OPTS "${HETZNER_USER}@${HETZNER_HOST}" \ + "cd '${DEPLOY_PATH}' && \ + export COMPOSE_FILE='${COMPOSE_FILE}' && \ + export EOAPI_DOMAIN='${EOAPI_DOMAIN}' && \ + export ACME_EMAIL='${ACME_EMAIL}' && \ + docker compose pull --ignore-pull-failures && \ + docker compose up -d --build --remove-orphans && \ + docker compose ps" + + - name: Wait for services + run: | + wait_for_service() { + name="$1" + url="$2" + + for _ in $(seq 1 90); do + if curl --fail --silent --output /dev/null "$url"; then + echo "$name is ready" + return 0 + fi + sleep 2 + done + + echo "$name did not become ready at $url" + return 1 + } + + base="https://${EOAPI_DOMAIN}" + wait_for_service stac-auth-proxy "${base}/stac/_mgmt/ping" + wait_for_service raster "${base}/raster/healthz" + wait_for_service vector "${base}/vector/" + shell: bash + + aws: + name: AWS (CDK) + if: inputs.job == 'all' || inputs.job == 'aws' + environment: dev + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + env: + STACK_NAME: eoapi-dev + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: ${{ inputs.ref || github.ref }} + + - name: Create config file + run: | + echo "${{ vars.CONFIG_YAML }}" > config.yaml + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@f79bc868ee9ca4687c7db10f05fe6a46cdcda892 # v6 + with: + role-to-assume: arn:aws:iam::390960605471:role/eoapi-devseed + role-session-name: eoapi-devseed + aws-region: us-west-2 + + - name: Set up node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + + - name: Install uv + uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9 # v7 + + - name: Install dependencies + run: | + uv sync --only-group deploy + uv run --only-group deploy npm install + + - name: CDK Synth + run: uv run --only-group deploy npx cdk synth --all + + - name: CDK Deploy + run: uv run --only-group deploy npx cdk deploy --all --require-approval never diff --git a/infrastructure/docker-compose.hetzner.yml b/infrastructure/docker-compose.hetzner.yml new file mode 100644 index 0000000..6adc617 --- /dev/null +++ b/infrastructure/docker-compose.hetzner.yml @@ -0,0 +1,94 @@ +# Production overlay: HTTPS on 80/443 via Traefik + Let's Encrypt. +# +# Requires DNS A records for EOAPI_DOMAIN and auth.EOAPI_DOMAIN pointing at the server. +# +# COMPOSE_FILE=docker-compose.yml:docker-compose.mock-oidc.yml:infrastructure/docker-compose.hetzner.yml \ +# EOAPI_DOMAIN=eoapi.centroi.de \ +# ACME_EMAIL=you@example.com \ +# docker compose up -d + +services: + traefik: + command: !override + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --providers.docker.network=eoapi-network + - --entrypoints.web.address=:80 + - --entrypoints.websecure.address=:443 + - --entrypoints.web.http.redirections.entrypoint.to=websecure + - --entrypoints.web.http.redirections.entrypoint.scheme=https + - --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL:?set ACME_EMAIL} + - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json + - --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web + ports: !override + - "80:80" + - "443:443" + volumes: + - traefik-letsencrypt:/letsencrypt + + doc-server: + labels: !override + - traefik.enable=true + - traefik.http.routers.doc-server.rule=Host(`${EOAPI_DOMAIN:?set EOAPI_DOMAIN}`) && (Path(`/`) || PathPrefix(`/assets/`) || PathPrefix(`/img/`)) + - traefik.http.routers.doc-server.entrypoints=websecure + - traefik.http.routers.doc-server.tls.certresolver=letsencrypt + - traefik.http.services.doc-server.loadbalancer.server.port=8080 + + stac-browser: + labels: !override + - traefik.enable=true + - traefik.http.routers.stac-browser.rule=Host(`${EOAPI_DOMAIN}`) && PathPrefix(`/browser`) + - traefik.http.routers.stac-browser.entrypoints=websecure + - traefik.http.routers.stac-browser.tls.certresolver=letsencrypt + - traefik.http.services.stac-browser.loadbalancer.server.port=8080 + environment: + SB_authConfig: '{"type":"openIdConnect","openIdConnectUrl":"https://auth.${EOAPI_DOMAIN}/.well-known/openid-configuration","oidcConfig":{"client_id":"stac-browser"}}' + EOAPI_TILE_SERVER_BASE_URL: https://${EOAPI_DOMAIN}/raster + + stac: + environment: + TITILER_ENDPOINT: https://${EOAPI_DOMAIN}/raster + + stac-auth-proxy: + labels: !override + - traefik.enable=true + - traefik.http.routers.stac.rule=Host(`${EOAPI_DOMAIN}`) && PathPrefix(`/stac`) + - traefik.http.routers.stac.entrypoints=websecure + - traefik.http.routers.stac.tls.certresolver=letsencrypt + - traefik.http.services.stac.loadbalancer.server.port=8000 + environment: + OIDC_DISCOVERY_URL: https://auth.${EOAPI_DOMAIN}/.well-known/openid-configuration + + raster: + labels: !override + - traefik.enable=true + - traefik.http.routers.raster.rule=Host(`${EOAPI_DOMAIN}`) && PathPrefix(`/raster`) + - traefik.http.routers.raster.entrypoints=websecure + - traefik.http.routers.raster.middlewares=raster-stripprefix + - traefik.http.routers.raster.tls.certresolver=letsencrypt + - traefik.http.middlewares.raster-stripprefix.stripprefix.prefixes=/raster + - traefik.http.services.raster.loadbalancer.server.port=8082 + + vector: + labels: !override + - traefik.enable=true + - traefik.http.routers.vector.rule=Host(`${EOAPI_DOMAIN}`) && PathPrefix(`/vector`) + - traefik.http.routers.vector.entrypoints=websecure + - traefik.http.routers.vector.middlewares=vector-stripprefix + - traefik.http.routers.vector.tls.certresolver=letsencrypt + - traefik.http.middlewares.vector-stripprefix.stripprefix.prefixes=/vector + - traefik.http.services.vector.loadbalancer.server.port=8083 + + mock-oidc: + ports: !reset [] + environment: + ISSUER: https://auth.${EOAPI_DOMAIN} + labels: + - traefik.enable=true + - traefik.http.routers.mock-oidc.rule=Host(`auth.${EOAPI_DOMAIN}`) + - traefik.http.routers.mock-oidc.entrypoints=websecure + - traefik.http.routers.mock-oidc.tls.certresolver=letsencrypt + - traefik.http.services.mock-oidc.loadbalancer.server.port=8888 + +volumes: + traefik-letsencrypt: