diff --git a/.github/workflows/backend-docker-publish.yml b/.github/workflows/backend-docker-publish.yml index aa8b9ec..79d6d92 100644 --- a/.github/workflows/backend-docker-publish.yml +++ b/.github/workflows/backend-docker-publish.yml @@ -242,9 +242,6 @@ jobs: ssh "${SSH_OPTS[@]}" "$SSH_USERNAME@$TARGET_HOST" \ 'echo "SSH auth OK"; echo "Remote host: $(hostname)"' - # The release's compose files replace the server's copies, so the - # server only keeps .env. They are uploaded next to the current files - # and only take their place once the remote script validates them. echo "Uploading compose files from backend/v$IMAGE_TAG..." for file in docker-compose.yml docker-compose.prod.yml; do scp -i "$KEY_FILE" -P "$SSH_PORT" \ @@ -340,8 +337,6 @@ jobs: fi echo "Backend is running $RUNNING_IMAGE" - # Record the deployed version in .env so compose commands run by hand - # on the server keep this image instead of an older tag. if grep -q '^IMAGE_TAG=' .env; then sed -i "s/^IMAGE_TAG=.*/IMAGE_TAG=$IMAGE_TAG/" .env else diff --git a/.github/workflows/mcp-docker-publish.yml b/.github/workflows/mcp-docker-publish.yml index 52cdf2c..95f7816 100644 --- a/.github/workflows/mcp-docker-publish.yml +++ b/.github/workflows/mcp-docker-publish.yml @@ -188,6 +188,14 @@ jobs: environment: production steps: + - name: Checkout compose file + uses: actions/checkout@v6 + with: + ref: mcp-server/v${{ needs.validate.outputs.version }} + sparse-checkout: | + apps/mcp-server/docker-compose.yml + sparse-checkout-cone-mode: false + - name: Connect to Tailscale uses: tailscale/github-action@v4 with: @@ -219,21 +227,57 @@ jobs: printf '%s' "$SSH_PRIVATE_KEY" > "$KEY_FILE" chmod 600 "$KEY_FILE" - ssh -i "$KEY_FILE" \ - -p "$SSH_PORT" \ - -o BatchMode=yes \ - -o IdentitiesOnly=yes \ - -o StrictHostKeyChecking=yes \ + SSH_OPTS=( + -o BatchMode=yes + -o IdentitiesOnly=yes + -o StrictHostKeyChecking=yes + ) + + MCP_DEPLOY_PATH="$DEPLOY_PATH/mcp-server" + + echo "Uploading compose file from mcp-server/v$MCP_IMAGE_TAG..." + scp -i "$KEY_FILE" -P "$SSH_PORT" "${SSH_OPTS[@]}" \ + apps/mcp-server/docker-compose.yml \ + "$SSH_USERNAME@$TARGET_HOST:$MCP_DEPLOY_PATH/docker-compose.yml.new" + + printf -v REMOTE_MCP_IMAGE_TAG '%q' "$MCP_IMAGE_TAG" + printf -v REMOTE_MCP_DEPLOY_PATH '%q' "$MCP_DEPLOY_PATH" + + ssh -i "$KEY_FILE" -p "$SSH_PORT" "${SSH_OPTS[@]}" \ "$SSH_USERNAME@$TARGET_HOST" \ - "MCP_IMAGE_TAG='$MCP_IMAGE_TAG' DEPLOY_PATH='$DEPLOY_PATH' bash -s" <<'REMOTE' + "MCP_IMAGE_TAG=$REMOTE_MCP_IMAGE_TAG MCP_DEPLOY_PATH=$REMOTE_MCP_DEPLOY_PATH bash -s" <<'REMOTE' set -euo pipefail - COMPOSE_FILES=(-f docker-compose.yml -f docker-compose.prod.yml) - cd "$DEPLOY_PATH" + COMPOSE_FILES=(-f docker-compose.yml) + cd "$MCP_DEPLOY_PATH" + + for file in docker-compose.yml.new .env; do + if [ ! -f "$file" ]; then + echo "::error::Required deploy file is missing: $MCP_DEPLOY_PATH/$file" + exit 1 + fi + done + + # Compose only warns about variables missing from .env and then uses + # an empty value, so treat that warning as a failure too. + echo "Validating the release's compose file against .env..." + if ! CHECK_OUTPUT="$(docker compose -f docker-compose.yml.new config -q 2>&1)" \ + || grep -q 'variable is not set' <<<"$CHECK_OUTPUT"; then + printf '%s\n' "$CHECK_OUTPUT" + rm -f docker-compose.yml.new + echo "::error::The release's compose file does not work with the server's .env. The current compose file was left in place." + exit 1 + fi + + if [ -f docker-compose.yml ]; then + cp -p docker-compose.yml docker-compose.yml.prev + fi + mv docker-compose.yml.new docker-compose.yml + echo "Compose file updated from the release (previous version kept as docker-compose.yml.prev)" echo "Pulling MCP server image tag: $MCP_IMAGE_TAG" docker compose "${COMPOSE_FILES[@]}" pull bitfinance-mcp-server - docker compose "${COMPOSE_FILES[@]}" up -d --no-deps --no-build --wait --wait-timeout 120 bitfinance-mcp-server + docker compose "${COMPOSE_FILES[@]}" up -d --no-build --wait --wait-timeout 120 bitfinance-mcp-server EXPECTED_IMAGE="gustmrg/bitfinance-mcp-server:$MCP_IMAGE_TAG" RUNNING_IMAGE="$(docker inspect --format '{{.Config.Image}}' "$(docker compose "${COMPOSE_FILES[@]}" ps -q bitfinance-mcp-server)")" @@ -243,8 +287,6 @@ jobs: fi echo "MCP server is running $RUNNING_IMAGE" - # Record the deployed version in .env so compose commands run by hand - # on the server keep this image instead of an older tag. if grep -q '^MCP_IMAGE_TAG=' .env; then sed -i "s/^MCP_IMAGE_TAG=.*/MCP_IMAGE_TAG=$MCP_IMAGE_TAG/" .env else diff --git a/apps/backend/.env.prod.example b/apps/backend/.env.prod.example index 5efa82e..6673b4e 100644 --- a/apps/backend/.env.prod.example +++ b/apps/backend/.env.prod.example @@ -1,6 +1,5 @@ # Image Tag (e.g., 1.7.0, latest) IMAGE_TAG=latest -MCP_IMAGE_TAG=latest # Database Configuration (REQUIRED - no defaults in production) DB_USER= @@ -25,8 +24,6 @@ AZURE_CLIENT_SECRET= AZURE_TENANT_ID= # File storage (Amazon S3, REQUIRED in production) -# Credentials of the IAM user that can read, write and delete objects in the bucket. -# Leave Storage__ServiceUrl unset: it is only for S3-compatible servers such as local MinIO. S3_BUCKET_NAME= S3_REGION=us-east-1 AWS_ACCESS_KEY_ID= @@ -44,13 +41,3 @@ RESEND_API_TOKEN= RESEND_WEBHOOK_SECRET= NOTIFICATIONS_FROM_ADDRESS= NOTIFICATIONS_FRONTEND_BASE_URL= - -# MCP Server Configuration -MCP_API_BASE_URL=http://bitfinance-api:8080 -MCP_AGENT_EMAIL= -MCP_AGENT_PASSWORD= -MCP_DEFAULT_ORGANIZATION_ID= -MCP_API_VERSION=1 -MCP_BIND_ADDRESS= -MCP_PORT=8090 -MCP_BEARER_TOKEN= diff --git a/apps/backend/docker-compose.prod.yml b/apps/backend/docker-compose.prod.yml index 81d3afe..f9f8ce5 100644 --- a/apps/backend/docker-compose.prod.yml +++ b/apps/backend/docker-compose.prod.yml @@ -37,30 +37,6 @@ services: max-size: "10m" max-file: "3" - bitfinance-mcp-server: - image: gustmrg/bitfinance-mcp-server:${MCP_IMAGE_TAG:-latest} - container_name: bitfinance-mcp-server - restart: always - ports: - - "${MCP_BIND_ADDRESS}:${MCP_PORT:-8090}:8090" - environment: - - ASPNETCORE_URLS=http://+:8090 - - BITFINANCE_API_BASE_URL=${MCP_API_BASE_URL:-http://bitfinance-api:8080} - - BITFINANCE_AGENT_EMAIL=${MCP_AGENT_EMAIL} - - BITFINANCE_AGENT_PASSWORD=${MCP_AGENT_PASSWORD} - - BITFINANCE_DEFAULT_ORGANIZATION_ID=${MCP_DEFAULT_ORGANIZATION_ID:-} - - BITFINANCE_API_VERSION=${MCP_API_VERSION:-1} - - BITFINANCE_MCP_BEARER_TOKEN=${MCP_BEARER_TOKEN} - depends_on: - - bitfinance-api - networks: - - bitfinance-network - logging: - driver: "json-file" - options: - max-size: "10m" - max-file: "3" - bitfinance-db: restart: always ports: diff --git a/apps/backend/docker-compose.yml b/apps/backend/docker-compose.yml index 2862575..e8d0d91 100644 --- a/apps/backend/docker-compose.yml +++ b/apps/backend/docker-compose.yml @@ -74,6 +74,7 @@ services: networks: bitfinance-network: + name: bitfinance_bitfinance-network driver: bridge volumes: diff --git a/apps/mcp-server/.env.prod.example b/apps/mcp-server/.env.prod.example new file mode 100644 index 0000000..15654c3 --- /dev/null +++ b/apps/mcp-server/.env.prod.example @@ -0,0 +1,14 @@ +# Image Tag (e.g., 0.5.1, latest) +MCP_IMAGE_TAG=latest + +# BitFinance API +MCP_API_BASE_URL=http://bitfinance-api:8080 +MCP_API_VERSION=1 +MCP_AGENT_EMAIL= +MCP_AGENT_PASSWORD= +MCP_DEFAULT_ORGANIZATION_ID= + +# MCP endpoint +MCP_BIND_ADDRESS= +MCP_PORT=8090 +MCP_BEARER_TOKEN= diff --git a/apps/mcp-server/README.md b/apps/mcp-server/README.md index 2b56954..cbfb4c0 100644 --- a/apps/mcp-server/README.md +++ b/apps/mcp-server/README.md @@ -86,6 +86,8 @@ Example generic remote MCP configuration: For production, bind the Docker port to the VPS Tailscale address and firewall port `8090` so only the Raspberry Pi tailnet IP can reach it. Do not publish the BitFinance API publicly for Hermes; keep `BITFINANCE_API_BASE_URL=http://bitfinance-api:8080` inside the MCP container. +`docker-compose.yml` in this directory runs the server in production as its own compose project, separate from the backend's. It joins the backend's Docker network (`bitfinance_bitfinance-network`) to reach the API, so start the backend stack first. Copy `.env.prod.example` to `.env` next to it and fill in the values, then run `docker compose up --no-build -d`. See `docs/deployment.md` for how releases deploy it. + ## Tools - `bitfinance_list_organizations` diff --git a/apps/mcp-server/docker-compose.yml b/apps/mcp-server/docker-compose.yml new file mode 100644 index 0000000..9ea8c7b --- /dev/null +++ b/apps/mcp-server/docker-compose.yml @@ -0,0 +1,29 @@ +name: bitfinance-mcp + +services: + bitfinance-mcp-server: + image: gustmrg/bitfinance-mcp-server:${MCP_IMAGE_TAG:-latest} + container_name: bitfinance-mcp-server + restart: always + ports: + - "${MCP_BIND_ADDRESS}:${MCP_PORT:-8090}:8090" + environment: + - ASPNETCORE_URLS=http://+:8090 + - BITFINANCE_API_BASE_URL=${MCP_API_BASE_URL:-http://bitfinance-api:8080} + - BITFINANCE_AGENT_EMAIL=${MCP_AGENT_EMAIL} + - BITFINANCE_AGENT_PASSWORD=${MCP_AGENT_PASSWORD} + - BITFINANCE_DEFAULT_ORGANIZATION_ID=${MCP_DEFAULT_ORGANIZATION_ID:-} + - BITFINANCE_API_VERSION=${MCP_API_VERSION:-1} + - BITFINANCE_MCP_BEARER_TOKEN=${MCP_BEARER_TOKEN} + networks: + - bitfinance-network + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + +networks: + bitfinance-network: + external: true + name: bitfinance_bitfinance-network diff --git a/docs/deployment.md b/docs/deployment.md index c263648..89699f3 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -86,14 +86,24 @@ The deploy prints the service status and recent logs, including when it fails. ### MCP server 1. Builds `gustmrg/bitfinance-mcp-server` for `linux/amd64` and `linux/arm64` and pushes it to Docker Hub as `` and `latest`. -2. On the server, in `DEPLOY_PATH`, with `MCP_IMAGE_TAG=`: +2. Uploads `apps/mcp-server/docker-compose.yml` from the release tag to `DEPLOY_PATH/mcp-server`, checks it with `docker compose config` against that directory's `.env`, and only then puts it in place of the current file, which is kept as `docker-compose.yml.prev`. The deploy stops before touching anything if the check fails or reports a variable that is not set. +3. On the server, in `DEPLOY_PATH/mcp-server`, with `MCP_IMAGE_TAG=`: - pulls the new `bitfinance-mcp-server` image; - - recreates the `bitfinance-mcp-server` container from it. `--no-deps` leaves the API, database and cache untouched. - - records the version as `MCP_IMAGE_TAG` in the server's `.env`. + - recreates the `bitfinance-mcp-server` container from it; + - records the version as `MCP_IMAGE_TAG` in that directory's `.env`. -The backend and the MCP server share the compose project in `apps/backend` (`docker-compose.prod.yml` defines both services) and the same `DEPLOY_PATH`. +### Backend and MCP server compose projects -Only the backend deploy updates the compose files on the server. The MCP server deploy uses the files already there, so republishing an older MCP release cannot bring back older compose files. **Changes to either compose file reach production with the next backend release**, including changes that only affect the MCP server service; release the backend before an MCP release that depends on them. The backend deploy only recreates `bitfinance-api`; changes to other services apply when those containers are next recreated. +The backend and the MCP server are separate compose projects, so each one deploys, and rolls back, without the other: + +| App | Compose files | Directory on the server | Project | +|---|---|---|---| +| backend | `apps/backend/docker-compose.yml`, `docker-compose.prod.yml` | `DEPLOY_PATH` | `bitfinance` | +| mcp-server | `apps/mcp-server/docker-compose.yml` | `DEPLOY_PATH/mcp-server` | `bitfinance-mcp` | + +Each deploy only replaces its own compose files, taken from its own release tag, and each directory has its own `.env`. A change to a compose file reaches production with the next release of that app. + +The MCP server reaches the API at `http://bitfinance-api:8080` over the backend's Docker network, `bitfinance_bitfinance-network`, which it joins as an external network. The backend stack must therefore be running, and the network keeps that fixed name. The backend deploy only recreates `bitfinance-api`; changes to the database or cache services apply when those containers are next recreated. ### Frontend @@ -125,11 +135,11 @@ GitHub secrets used by the publish workflows: | `SSH_HOST` | All | Fallback server address | | `SSH_USERNAME`, `SSH_KEY` | All | SSH login | | `SSH_PORT` | All | Optional, defaults to `22` | -| `DEPLOY_PATH` | Backend, MCP server | Directory with the compose files and `.env` on the server | +| `DEPLOY_PATH` | Backend, MCP server | Directory with the backend's compose files and `.env` on the server; the MCP server uses its `mcp-server` subdirectory | Server-side requirements: -- `DEPLOY_PATH` contains `.env`, created from the `apps/backend/.env.prod.example` template. The backend deploy writes the compose files; `.env` is the only file kept by hand. Add any variable a release needs to `.env` before releasing it, or the deploy stops at the compose check. +- `DEPLOY_PATH` contains `.env`, created from `apps/backend/.env.prod.example`, and `DEPLOY_PATH/mcp-server` contains `.env`, created from `apps/mcp-server/.env.prod.example`. The deploys write the compose files; the `.env` files are the only files kept by hand. Add any variable a release needs to its app's `.env` before releasing it, or the deploy stops at the compose check. - The server's `.env` sets the S3 storage variables (`S3_BUCKET_NAME`, `S3_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`). See "File storage" in `apps/backend/README.md`. - The external Postgres volume (`POSTGRES_DATA_VOLUME`, default `bitfinance_postgres-data`) exists. See `apps/backend/README.md`. - The SSH user can run `docker compose` and can write to `/var/www/bitfinance` without `sudo`.