Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 8 additions & 41 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
221 changes: 221 additions & 0 deletions .github/workflows/deploy.yaml
Original file line number Diff line number Diff line change
@@ -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
94 changes: 94 additions & 0 deletions infrastructure/docker-compose.hetzner.yml
Original file line number Diff line number Diff line change
@@ -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: