diff --git a/.github/actions/cobalt-deploy/action.yml b/.github/actions/cobalt-deploy/action.yml new file mode 100644 index 00000000..35aec53b --- /dev/null +++ b/.github/actions/cobalt-deploy/action.yml @@ -0,0 +1,78 @@ +name: 'Cobalt deploy (blue/green)' +description: >- + Trigger a zero-downtime Cobalt deployment of a prebuilt public image tag and + wait until it is live. Cobalt pulls the image, starts the new deployment, + HTTP-probes it from inside Caddy, then swaps the route atomically — the old + version keeps serving until the new one is healthy. +inputs: + cobalt-url: + description: 'Cobalt daemon base URL' + required: true + cobalt-api-key: + description: 'Cobalt API key' + required: true + project: + description: 'Cobalt project name' + required: true + image: + description: 'Prebuilt public image ref to deploy' + required: true + port: + description: 'Container port Caddy probes before cutover' + required: true +runs: + using: 'composite' + steps: + - shell: bash + env: + COBALT_URL: ${{ inputs.cobalt-url }} + COBALT_API_KEY: ${{ inputs.cobalt-api-key }} + PROJECT: ${{ inputs.project }} + IMAGE: ${{ inputs.image }} + PORT: ${{ inputs.port }} + run: | + set -euo pipefail + + # The image tag is the source of truth for the release; the rest of the + # cobaltfile (domains, env, volumes) lives in the cobalt-ops repo and is + # reconciled by Cobalt. Cobalt HTTP-probes the web service port from + # inside Caddy before the atomic route swap, so no health command needed. + cobaltfile=$(jq -nc \ + --arg project "$PROJECT" \ + --arg image "$IMAGE" \ + --argjson port "$PORT" \ + '{version:"1.0",name:$project,services:{web:{type:"container",image:$image,port:$port}}}') + + body=$(jq -nc --arg cf "$cobaltfile" '{cobaltfileOverride:$cf}') + + echo "Deploying $PROJECT -> $IMAGE (port $PORT)" + resp=$(curl -fsS -X POST "$COBALT_URL/api/projects/$PROJECT/deployments" \ + -H "Authorization: Bearer $COBALT_API_KEY" \ + -H 'Content-Type: application/json' \ + -d "$body") + + id=$(printf '%s' "$resp" | jq -r '.id') + echo "enqueued deployment id=$id number=$(printf '%s' "$resp" | jq -r '.number')" + + status="" + for _ in $(seq 1 60); do + status=$(curl -fsS "$COBALT_URL/api/deployments/$id" \ + -H "Authorization: Bearer $COBALT_API_KEY" | jq -r '.status') + echo " $PROJECT deployment status: $status" + case "$status" in + success) + echo "✅ $PROJECT is live" + exit 0 + ;; + failed|canceled|skipped) + echo "❌ $PROJECT deploy ended with '$status'" + curl -fsS --max-time 25 "$COBALT_URL/api/deployments/$id/output" \ + -H "Authorization: Bearer $COBALT_API_KEY" | tail -80 || true + exit 1 + ;; + esac + sleep 5 + done + + echo "❌ $PROJECT deploy timed out (last status: ${status:-unknown})" >&2 + exit 1 diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 4cce485e..2bcb8196 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -35,6 +35,11 @@ on: required: false default: true type: boolean + build_args: + description: 'Docker build args (KEY=VALUE, one per line)' + required: false + default: '' + type: string outputs: image: description: 'Fully qualified image reference that was published' @@ -81,6 +86,7 @@ jobs: file: ${{ inputs.dockerfile }} platforms: ${{ matrix.platform }} provenance: false + build-args: ${{ inputs.build_args }} cache-from: type=gha,scope=${{ inputs.slug }}-${{ matrix.arch }} cache-to: type=gha,mode=max,scope=${{ inputs.slug }}-${{ matrix.arch }} outputs: type=image,name=${{ inputs.image }},push-by-digest=true,name-canonical=true,push=true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b58c8fb8..50e0525e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,10 +41,10 @@ concurrency: group: release-${{ github.ref }} cancel-in-progress: false -env: - ECS_SERVICE_NEST: 'rushdb-ecs-service' - ECS_SERVICE_MCP: 'rushdb-mcp-service' - ECS_CLUSTER: 'rushdb-ecs-cluster' +# Deploy target is Cobalt on the VM (zero-downtime blue/green). +# Requires GitHub secrets: COBALT_URL (daemon base URL) and COBALT_API_KEY. +# Runtime env for core/mcp no longer ships with the release — it lives in +# Cobalt (`cobalt env set --project core …`). jobs: release: @@ -134,6 +134,10 @@ jobs: slug: platform dockerfile: platform/Dockerfile version: ${{ needs.release.outputs.platform_version }} + # Dashboard UI is served by core (RUSHDB_SERVE_STATIC=true); point its API + # calls at the API origin so app.rushdb.com calls api.rushdb.com. + build_args: | + VITE_BACKEND_BASE_URL=https://api.rushdb.com secrets: inherit mcp-image: @@ -148,178 +152,157 @@ jobs: version: ${{ needs.release.outputs.mcp_version }} secrets: inherit - deploy-platform: - name: Deploy platform to ECS + deploy-core: + name: Deploy core to Cobalt needs: [release, platform-image] - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest environment: production steps: - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v1 - with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: ${{ vars.AWS_REGION }} + - uses: actions/checkout@v4 - - name: Checkout - uses: actions/checkout@v4 - - - name: Render task definition + # GitHub is the source of truth for runtime env: sync secrets/vars to + # Cobalt on every deploy (upsert). Values are stored encrypted in Cobalt's + # rqlite and injected at runtime. `secrets.X || vars.X` reads either + # classification; defaults cover the constants. + - name: Sync runtime env to Cobalt + env: + COBALT_URL: ${{ secrets.COBALT_URL }} + COBALT_API_KEY: ${{ secrets.COBALT_API_KEY }} + NODE_ENV: ${{ secrets.NODE_ENV || vars.NODE_ENV || 'production' }} + LOG_LEVEL: ${{ secrets.LOG_LEVEL || vars.LOG_LEVEL || 'info' }} + RUSHDB_SELF_HOSTED: ${{ secrets.RUSHDB_SELF_HOSTED || vars.RUSHDB_SELF_HOSTED || 'false' }} + RUSHDB_SERVE_STATIC: ${{ secrets.RUSHDB_SERVE_STATIC || vars.RUSHDB_SERVE_STATIC || 'true' }} + SQL_DB_TYPE: ${{ secrets.SQL_DB_TYPE || vars.SQL_DB_TYPE || 'postgres' }} + SQL_DB_URL: ${{ secrets.SQL_DB_URL || vars.SQL_DB_URL }} + NEO4J_URL: ${{ secrets.NEO4J_URL || vars.NEO4J_URL }} + NEO4J_USERNAME: ${{ secrets.NEO4J_USERNAME || vars.NEO4J_USERNAME }} + NEO4J_PASSWORD: ${{ secrets.NEO4J_PASSWORD || vars.NEO4J_PASSWORD }} + RUSHDB_AES_256_ENCRYPTION_KEY: ${{ secrets.RUSHDB_AES_256_ENCRYPTION_KEY || vars.RUSHDB_AES_256_ENCRYPTION_KEY }} + RUSHDB_JWT_PRIVATE_KEY_BASE64: ${{ secrets.RUSHDB_JWT_PRIVATE_KEY_BASE64 || vars.RUSHDB_JWT_PRIVATE_KEY_BASE64 }} + RUSHDB_JWT_PUBLIC_KEY_BASE64: ${{ secrets.RUSHDB_JWT_PUBLIC_KEY_BASE64 || vars.RUSHDB_JWT_PUBLIC_KEY_BASE64 }} + RUSHDB_JWT_KID: ${{ secrets.RUSHDB_JWT_KID || vars.RUSHDB_JWT_KID }} + RUSHDB_DASHBOARD_URL: ${{ secrets.RUSHDB_DASHBOARD_URL || vars.RUSHDB_DASHBOARD_URL }} + RUSHDB_PUBLIC_URL: ${{ secrets.RUSHDB_PUBLIC_URL || vars.RUSHDB_PUBLIC_URL }} + RUSHDB_OAUTH_ISSUER: ${{ secrets.RUSHDB_OAUTH_ISSUER || vars.RUSHDB_OAUTH_ISSUER || vars.RUSHDB_PUBLIC_URL }} + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID || vars.GOOGLE_CLIENT_ID }} + GOOGLE_SECRET: ${{ secrets.GOOGLE_SECRET || vars.GOOGLE_SECRET }} + GH_CLIENT_ID: ${{ secrets.GH_CLIENT_ID || vars.GH_CLIENT_ID }} + GH_SECRET: ${{ secrets.GH_SECRET || vars.GH_SECRET }} + MAIL_HOST: ${{ secrets.MAIL_HOST || vars.MAIL_HOST }} + MAIL_USER: ${{ secrets.MAIL_USER || vars.MAIL_USER }} + MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD || vars.MAIL_PASSWORD }} + MAIL_FROM: ${{ secrets.MAIL_FROM || vars.MAIL_FROM }} + SERVICE_CAPTCHA_KEY: ${{ secrets.SERVICE_CAPTCHA_KEY || vars.SERVICE_CAPTCHA_KEY }} + BILLING_SERVICE_URL: ${{ secrets.BILLING_SERVICE_URL || vars.BILLING_SERVICE_URL }} + RUSHDB_BILLING_SECRET: ${{ secrets.RUSHDB_BILLING_SECRET || vars.RUSHDB_BILLING_SECRET }} + RUSHDB_EMBEDDING_BASE_URL: ${{ secrets.RUSHDB_EMBEDDING_BASE_URL || vars.RUSHDB_EMBEDDING_BASE_URL }} + RUSHDB_EMBEDDING_API_KEY: ${{ secrets.RUSHDB_EMBEDDING_API_KEY || vars.RUSHDB_EMBEDDING_API_KEY }} + RUSHDB_EMBEDDING_MODEL: ${{ secrets.RUSHDB_EMBEDDING_MODEL || vars.RUSHDB_EMBEDDING_MODEL }} + RUSHDB_EMBEDDING_DIMENSIONS: ${{ secrets.RUSHDB_EMBEDDING_DIMENSIONS || vars.RUSHDB_EMBEDDING_DIMENSIONS }} + RUSHDB_LLM_BASE_URL: ${{ secrets.RUSHDB_LLM_BASE_URL || vars.RUSHDB_LLM_BASE_URL }} + RUSHDB_LLM_API_KEY: ${{ secrets.RUSHDB_LLM_API_KEY || vars.RUSHDB_LLM_API_KEY }} + RUSHDB_LLM_MODEL: ${{ secrets.RUSHDB_LLM_MODEL || vars.RUSHDB_LLM_MODEL }} run: | - cat < task-definition.json - { - "containerDefinitions": [ - { - "name": "rushdb", - "image": "${{ needs.platform-image.outputs.image }}", - "cpu": 1024, - "memory": 2048, - "portMappings": [ - { - "containerPort": 3000, - "hostPort": 3000, - "protocol": "tcp", - "appProtocol": "http" - } - ], - "essential": true, - "logConfiguration": { - "logDriver": "awslogs", - "options": { - "awslogs-group": "/ecs/rushdb", - "awslogs-region": "${{ vars.AWS_REGION }}", - "awslogs-stream-prefix": "ecs-rushdb" - } - }, - "environment": [ - { "name": "NODE_ENV", "value": "production" }, - { "name": "LOG_LEVEL", "value": "info" }, - { "name": "RUSHDB_DASHBOARD_URL", "value": "${{ vars.RUSHDB_DASHBOARD_URL }}" }, - { "name": "RUSHDB_AES_256_ENCRYPTION_KEY", "value": "${{ secrets.RUSHDB_AES_256_ENCRYPTION_KEY }}" }, - { "name": "RUSHDB_JWT_PRIVATE_KEY_BASE64", "value": "${{ secrets.RUSHDB_JWT_PRIVATE_KEY_BASE64 }}" }, - { "name": "RUSHDB_JWT_PUBLIC_KEY_BASE64", "value": "${{ secrets.RUSHDB_JWT_PUBLIC_KEY_BASE64 }}" }, - { "name": "RUSHDB_JWT_KID", "value": "${{ secrets.RUSHDB_JWT_KID }}" }, - { "name": "RUSHDB_SELF_HOSTED", "value": "false" }, - { "name": "RUSHDB_SERVE_STATIC", "value": "false" }, - { "name": "NEO4J_URL", "value": "${{ secrets.NEO4J_URL }}" }, - { "name": "NEO4J_USERNAME", "value": "${{ secrets.NEO4J_USERNAME }}" }, - { "name": "NEO4J_PASSWORD", "value": "${{ secrets.NEO4J_PASSWORD }}" }, - { "name": "GOOGLE_CLIENT_ID", "value": "${{ secrets.GOOGLE_CLIENT_ID }}" }, - { "name": "GOOGLE_SECRET", "value": "${{ secrets.GOOGLE_SECRET }}" }, - { "name": "GH_CLIENT_ID", "value": "${{ secrets.GH_CLIENT_ID }}" }, - { "name": "GH_SECRET", "value": "${{ secrets.GH_SECRET }}" }, - { "name": "MAIL_HOST", "value": "${{ secrets.MAIL_HOST }}" }, - { "name": "MAIL_USER", "value": "${{ secrets.MAIL_USER }}" }, - { "name": "MAIL_PASSWORD", "value": "${{ secrets.MAIL_PASSWORD }}" }, - { "name": "MAIL_FROM", "value": "${{ secrets.MAIL_FROM }}" }, - { "name": "SERVICE_CAPTCHA_KEY", "value": "${{ secrets.SERVICE_CAPTCHA_KEY }}" }, - { "name": "BILLING_SERVICE_URL", "value": "${{ secrets.BILLING_SERVICE_URL }}" }, - { "name": "RUSHDB_BILLING_SECRET", "value": "${{ secrets.RUSHDB_BILLING_SECRET }}" }, - { "name": "SQL_DB_TYPE", "value": "postgres" }, - { "name": "SQL_DB_URL", "value": "${{ secrets.SQL_DB_URL }}" }, - { "name": "RUSHDB_PUBLIC_URL", "value": "${{ vars.RUSHDB_PUBLIC_URL }}" }, - { "name": "RUSHDB_OAUTH_ISSUER", "value": "${{ vars.RUSHDB_PUBLIC_URL }}" }, - { "name": "RUSHDB_EMBEDDING_BASE_URL", "value": "${{ vars.RUSHDB_EMBEDDING_BASE_URL }}" }, - { "name": "RUSHDB_EMBEDDING_API_KEY", "value": "${{ secrets.RUSHDB_EMBEDDING_API_KEY }}" }, - { "name": "RUSHDB_EMBEDDING_MODEL", "value": "${{ vars.RUSHDB_EMBEDDING_MODEL }}" }, - { "name": "RUSHDB_EMBEDDING_DIMENSIONS", "value": "${{ vars.RUSHDB_EMBEDDING_DIMENSIONS }}" }, - { "name": "RUSHDB_LLM_BASE_URL", "value": "${{ vars.RUSHDB_LLM_BASE_URL }}" }, - { "name": "RUSHDB_LLM_API_KEY", "value": "${{ secrets.RUSHDB_LLM_API_KEY }}" }, - { "name": "RUSHDB_LLM_MODEL", "value": "${{ vars.RUSHDB_LLM_MODEL }}" } - ] - } - ], - "family": "rushdb-task-definition", - "networkMode": "awsvpc", - "requiresCompatibilities": ["FARGATE"], - "cpu": "1024", - "memory": "2048", - "executionRoleArn": "${{ secrets.ECS_EXECUTION_ROLE_ARN }}" - } - EOF + set -euo pipefail + vars_json=$(jq -nc \ + --arg NODE_ENV "$NODE_ENV" \ + --arg LOG_LEVEL "$LOG_LEVEL" \ + --arg RUSHDB_SELF_HOSTED "$RUSHDB_SELF_HOSTED" \ + --arg RUSHDB_SERVE_STATIC "$RUSHDB_SERVE_STATIC" \ + --arg SQL_DB_TYPE "$SQL_DB_TYPE" \ + --arg SQL_DB_URL "$SQL_DB_URL" \ + --arg NEO4J_URL "$NEO4J_URL" \ + --arg NEO4J_USERNAME "$NEO4J_USERNAME" \ + --arg NEO4J_PASSWORD "$NEO4J_PASSWORD" \ + --arg RUSHDB_AES_256_ENCRYPTION_KEY "$RUSHDB_AES_256_ENCRYPTION_KEY" \ + --arg RUSHDB_JWT_PRIVATE_KEY_BASE64 "$RUSHDB_JWT_PRIVATE_KEY_BASE64" \ + --arg RUSHDB_JWT_PUBLIC_KEY_BASE64 "$RUSHDB_JWT_PUBLIC_KEY_BASE64" \ + --arg RUSHDB_JWT_KID "$RUSHDB_JWT_KID" \ + --arg RUSHDB_DASHBOARD_URL "$RUSHDB_DASHBOARD_URL" \ + --arg RUSHDB_PUBLIC_URL "$RUSHDB_PUBLIC_URL" \ + --arg RUSHDB_OAUTH_ISSUER "$RUSHDB_OAUTH_ISSUER" \ + --arg GOOGLE_CLIENT_ID "$GOOGLE_CLIENT_ID" \ + --arg GOOGLE_SECRET "$GOOGLE_SECRET" \ + --arg GH_CLIENT_ID "$GH_CLIENT_ID" \ + --arg GH_SECRET "$GH_SECRET" \ + --arg MAIL_HOST "$MAIL_HOST" \ + --arg MAIL_USER "$MAIL_USER" \ + --arg MAIL_PASSWORD "$MAIL_PASSWORD" \ + --arg MAIL_FROM "$MAIL_FROM" \ + --arg SERVICE_CAPTCHA_KEY "$SERVICE_CAPTCHA_KEY" \ + --arg BILLING_SERVICE_URL "$BILLING_SERVICE_URL" \ + --arg RUSHDB_BILLING_SECRET "$RUSHDB_BILLING_SECRET" \ + --arg RUSHDB_EMBEDDING_BASE_URL "$RUSHDB_EMBEDDING_BASE_URL" \ + --arg RUSHDB_EMBEDDING_API_KEY "$RUSHDB_EMBEDDING_API_KEY" \ + --arg RUSHDB_EMBEDDING_MODEL "$RUSHDB_EMBEDDING_MODEL" \ + --arg RUSHDB_EMBEDDING_DIMENSIONS "$RUSHDB_EMBEDDING_DIMENSIONS" \ + --arg RUSHDB_LLM_BASE_URL "$RUSHDB_LLM_BASE_URL" \ + --arg RUSHDB_LLM_API_KEY "$RUSHDB_LLM_API_KEY" \ + --arg RUSHDB_LLM_MODEL "$RUSHDB_LLM_MODEL" \ + '{NODE_ENV:$NODE_ENV,LOG_LEVEL:$LOG_LEVEL,RUSHDB_SELF_HOSTED:$RUSHDB_SELF_HOSTED,RUSHDB_SERVE_STATIC:$RUSHDB_SERVE_STATIC,SQL_DB_TYPE:$SQL_DB_TYPE,SQL_DB_URL:$SQL_DB_URL,NEO4J_URL:$NEO4J_URL,NEO4J_USERNAME:$NEO4J_USERNAME,NEO4J_PASSWORD:$NEO4J_PASSWORD,RUSHDB_AES_256_ENCRYPTION_KEY:$RUSHDB_AES_256_ENCRYPTION_KEY,RUSHDB_JWT_PRIVATE_KEY_BASE64:$RUSHDB_JWT_PRIVATE_KEY_BASE64,RUSHDB_JWT_PUBLIC_KEY_BASE64:$RUSHDB_JWT_PUBLIC_KEY_BASE64,RUSHDB_JWT_KID:$RUSHDB_JWT_KID,RUSHDB_DASHBOARD_URL:$RUSHDB_DASHBOARD_URL,RUSHDB_PUBLIC_URL:$RUSHDB_PUBLIC_URL,RUSHDB_OAUTH_ISSUER:$RUSHDB_OAUTH_ISSUER,GOOGLE_CLIENT_ID:$GOOGLE_CLIENT_ID,GOOGLE_SECRET:$GOOGLE_SECRET,GH_CLIENT_ID:$GH_CLIENT_ID,GH_SECRET:$GH_SECRET,MAIL_HOST:$MAIL_HOST,MAIL_USER:$MAIL_USER,MAIL_PASSWORD:$MAIL_PASSWORD,MAIL_FROM:$MAIL_FROM,SERVICE_CAPTCHA_KEY:$SERVICE_CAPTCHA_KEY,BILLING_SERVICE_URL:$BILLING_SERVICE_URL,RUSHDB_BILLING_SECRET:$RUSHDB_BILLING_SECRET,RUSHDB_EMBEDDING_BASE_URL:$RUSHDB_EMBEDDING_BASE_URL,RUSHDB_EMBEDDING_API_KEY:$RUSHDB_EMBEDDING_API_KEY,RUSHDB_EMBEDDING_MODEL:$RUSHDB_EMBEDDING_MODEL,RUSHDB_EMBEDDING_DIMENSIONS:$RUSHDB_EMBEDDING_DIMENSIONS,RUSHDB_LLM_BASE_URL:$RUSHDB_LLM_BASE_URL,RUSHDB_LLM_API_KEY:$RUSHDB_LLM_API_KEY,RUSHDB_LLM_MODEL:$RUSHDB_LLM_MODEL}') + body=$(jq -nc --argjson vars "$vars_json" '{vars:$vars,redeploy:false}') + curl -fsS -X POST "$COBALT_URL/api/projects/core/env" \ + -H "Authorization: Bearer $COBALT_API_KEY" \ + -H 'Content-Type: application/json' \ + -d "$body" + echo "runtime env synced to Cobalt (core)" - - name: Deploy platform to ECS - uses: aws-actions/amazon-ecs-deploy-task-definition@v1 + - name: Deploy core (zero-downtime blue/green) + uses: ./.github/actions/cobalt-deploy with: - task-definition: task-definition.json - service: ${{ env.ECS_SERVICE_NEST }} - cluster: ${{ env.ECS_CLUSTER }} - wait-for-service-stability: false + cobalt-url: ${{ secrets.COBALT_URL }} + cobalt-api-key: ${{ secrets.COBALT_API_KEY }} + project: core + image: ${{ needs.platform-image.outputs.image }} + port: 3000 deploy-mcp: - name: Deploy MCP server to ECS + name: Deploy MCP server to Cobalt needs: [release, mcp-image] - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest environment: production steps: - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v1 - with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: ${{ vars.AWS_REGION }} + - uses: actions/checkout@v4 - - name: Checkout - uses: actions/checkout@v4 - - - name: Render MCP task definition + - name: Sync runtime env to Cobalt + env: + COBALT_URL: ${{ secrets.COBALT_URL }} + COBALT_API_KEY: ${{ secrets.COBALT_API_KEY }} + MCP_TRANSPORT: ${{ secrets.MCP_TRANSPORT || vars.MCP_TRANSPORT || 'http' }} + PORT: ${{ secrets.PORT || vars.PORT || '3001' }} + RUSHDB_API_URL: ${{ secrets.RUSHDB_API_URL || vars.RUSHDB_API_URL || 'https://api.rushdb.com/api/v1' }} + MCP_RESOURCE_URL: ${{ secrets.MCP_RESOURCE_URL || vars.MCP_RESOURCE_URL || 'https://mcp.rushdb.com' }} + RUSHDB_OAUTH_ISSUER: ${{ secrets.RUSHDB_OAUTH_ISSUER || vars.RUSHDB_OAUTH_ISSUER || vars.RUSHDB_PUBLIC_URL }} run: | - cat < mcp-task-definition.json - { - "containerDefinitions": [ - { - "name": "rushdb-mcp", - "image": "${{ needs.mcp-image.outputs.image }}", - "cpu": 512, - "memory": 1024, - "portMappings": [ - { - "containerPort": 3001, - "hostPort": 3001, - "protocol": "tcp", - "appProtocol": "http" - } - ], - "essential": true, - "logConfiguration": { - "logDriver": "awslogs", - "options": { - "awslogs-group": "/ecs/rushdb-mcp", - "awslogs-region": "${{ vars.AWS_REGION }}", - "awslogs-stream-prefix": "ecs-rushdb-mcp" - } - }, - "environment": [ - { "name": "MCP_TRANSPORT", "value": "http" }, - { "name": "PORT", "value": "3001" }, - { "name": "RUSHDB_API_URL", "value": "https://api.rushdb.com/api/v1" }, - { "name": "MCP_RESOURCE_URL", "value": "https://mcp.rushdb.com" }, - { "name": "RUSHDB_OAUTH_ISSUER", "value": "${{ vars.RUSHDB_PUBLIC_URL }}" } - ] - } - ], - "family": "rushdb-mcp-task-definition", - "networkMode": "awsvpc", - "requiresCompatibilities": ["FARGATE"], - "cpu": "512", - "memory": "1024", - "executionRoleArn": "${{ secrets.ECS_EXECUTION_ROLE_ARN }}" - } - EOF + set -euo pipefail + vars_json=$(jq -nc \ + --arg MCP_TRANSPORT "$MCP_TRANSPORT" \ + --arg PORT "$PORT" \ + --arg RUSHDB_API_URL "$RUSHDB_API_URL" \ + --arg MCP_RESOURCE_URL "$MCP_RESOURCE_URL" \ + --arg RUSHDB_OAUTH_ISSUER "$RUSHDB_OAUTH_ISSUER" \ + '{MCP_TRANSPORT:$MCP_TRANSPORT,PORT:$PORT,RUSHDB_API_URL:$RUSHDB_API_URL,MCP_RESOURCE_URL:$MCP_RESOURCE_URL,RUSHDB_OAUTH_ISSUER:$RUSHDB_OAUTH_ISSUER}') + body=$(jq -nc --argjson vars "$vars_json" '{vars:$vars,redeploy:false}') + curl -fsS -X POST "$COBALT_URL/api/projects/mcp/env" \ + -H "Authorization: Bearer $COBALT_API_KEY" \ + -H 'Content-Type: application/json' \ + -d "$body" + echo "runtime env synced to Cobalt (mcp)" - - name: Deploy MCP server to ECS - uses: aws-actions/amazon-ecs-deploy-task-definition@v1 + - name: Deploy mcp (zero-downtime blue/green) + uses: ./.github/actions/cobalt-deploy with: - task-definition: mcp-task-definition.json - service: ${{ env.ECS_SERVICE_MCP }} - cluster: ${{ env.ECS_CLUSTER }} - wait-for-service-stability: false + cobalt-url: ${{ secrets.COBALT_URL }} + cobalt-api-key: ${{ secrets.COBALT_API_KEY }} + project: mcp + image: ${{ needs.mcp-image.outputs.image }} + port: 3001 notify-content: name: Notify content control plane - needs: [release, platform-image, mcp-image, deploy-platform, deploy-mcp] + needs: [release, platform-image, mcp-image, deploy-core, deploy-mcp] # Fires once the release has fully landed. A skipped artifact is fine (it had # no version bump); a failed or cancelled one is not — and a failed image # build must not slip through on the strength of its deploy being skipped. @@ -328,7 +311,7 @@ jobs: needs.release.outputs.any_changed == 'true' && !contains(fromJSON('["failure", "cancelled"]'), needs.platform-image.result) && !contains(fromJSON('["failure", "cancelled"]'), needs.mcp-image.result) && - !contains(fromJSON('["failure", "cancelled"]'), needs.deploy-platform.result) && + !contains(fromJSON('["failure", "cancelled"]'), needs.deploy-core.result) && !contains(fromJSON('["failure", "cancelled"]'), needs.deploy-mcp.result) runs-on: ubuntu-latest steps: diff --git a/platform/Dockerfile b/platform/Dockerfile index a40341dd..20065164 100644 --- a/platform/Dockerfile +++ b/platform/Dockerfile @@ -38,8 +38,12 @@ COPY ./platform/core/nest-cli.json ./platform/core/ # Build javascript-sdk first RUN cd packages/javascript-sdk && pnpm build -# Build dashboard -ENV VITE_BACKEND_BASE_URL="" +# Build dashboard. VITE_BACKEND_BASE_URL tells the dashboard where the API +# lives: empty = same-origin (self-host default); hosted builds pass an +# absolute URL (e.g. https://api.rushdb.com) via --build-arg so the UI at +# app.rushdb.com calls the API at api.rushdb.com. +ARG VITE_BACKEND_BASE_URL="" +ENV VITE_BACKEND_BASE_URL=$VITE_BACKEND_BASE_URL RUN cd platform/dashboard && pnpm build # Create public directory in core and copy dashboard build