Repository navigation
feat: shared backend on Cloud Run with a workshop password #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
fb45e86
23427cf
80dec38
7dff95f
ee962a0
2408b42
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| version: 2 | ||
| updates: | ||
| - package-ecosystem: github-actions | ||
| directory: / | ||
| schedule: | ||
| interval: weekly |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| # Deploys the shared backend (runtime/) to Cloud Run on every change to it on main. | ||
| # One-time setup of Google Cloud: runtime/deploy/setup_gcp.sh, which also lists the | ||
| # GitHub secrets this workflow needs. | ||
| name: deploy_runtime | ||
|
|
||
| on: | ||
| push: | ||
| branches: [main] | ||
| paths: | ||
| - "runtime/**" | ||
| - ".github/workflows/deploy_runtime.yaml" | ||
| workflow_dispatch: | ||
|
|
||
| # Two deploys at once would race on the same service. | ||
| concurrency: | ||
| group: deploy_runtime | ||
| cancel-in-progress: false | ||
|
|
||
| permissions: | ||
| contents: read | ||
| id-token: write # lets the job sign in to Google Cloud without a key file | ||
|
|
||
| env: | ||
| PROJECT_ID: gen-lang-client-0563181971 | ||
| REGION: us-central1 | ||
| SERVICE: calaverita-runtime | ||
| REGISTRY_REPOSITORY: calaverita | ||
| RUNTIME_SERVICE_ACCOUNT: calaverita-runtime@gen-lang-client-0563181971.iam.gserviceaccount.com | ||
|
|
||
| jobs: | ||
| deploy: | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 15 | ||
| steps: | ||
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 | ||
|
|
||
| - name: Sign in to Google Cloud | ||
| id: auth | ||
| uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2 | ||
| with: | ||
| workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }} | ||
| service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }} | ||
| token_format: access_token | ||
|
|
||
| - name: Sign in to Artifact Registry | ||
| uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 | ||
| with: | ||
| registry: ${{ env.REGION }}-docker.pkg.dev | ||
| username: oauth2accesstoken | ||
| password: ${{ steps.auth.outputs.access_token }} | ||
|
|
||
| - name: Build and push the image | ||
| id: image | ||
| env: | ||
| IMAGE: ${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REGISTRY_REPOSITORY }}/runtime:${{ github.sha }} | ||
| run: | | ||
| docker build --tag "$IMAGE" runtime | ||
| docker push "$IMAGE" | ||
| echo "name=$IMAGE" >> "$GITHUB_OUTPUT" | ||
|
|
||
| # The key and the password stay in Secret Manager: Cloud Run hands them to the | ||
| # revision as environment variables, read as the runtime service account. | ||
| - name: Deploy to Cloud Run | ||
| id: deploy | ||
| uses: google-github-actions/deploy-cloudrun@251330ba9a8a34bfbc1622895f42e1d53fd14522 # v2 | ||
| with: | ||
| project_id: ${{ env.PROJECT_ID }} | ||
| region: ${{ env.REGION }} | ||
| service: ${{ env.SERVICE }} | ||
| image: ${{ steps.image.outputs.name }} | ||
| secrets: | | ||
| ANTHROPIC_API_KEY=anthropic-api-key:latest | ||
| WORKSHOP_CODE=workshop-code:latest | ||
| flags: >- | ||
| --service-account=${{ env.RUNTIME_SERVICE_ACCOUNT }} | ||
| --max-instances=1 | ||
| --cpu-boost | ||
| --memory=512Mi | ||
| --timeout=900 | ||
|
|
||
| # The password gate is the point of this deployment, so check it is on. | ||
| - name: Smoke test | ||
| env: | ||
| BASE: ${{ steps.deploy.outputs.url }}/api/copilotkit | ||
| run: | | ||
| curl --fail --silent --show-error --retry 5 --retry-delay 5 --retry-all-errors "$BASE/info" | jq -e '.agents.default' \ | ||
| || { echo "If this is a 403, the service is not public yet: run runtime/deploy/setup_gcp.sh again."; exit 1; } | ||
| status=$(curl --silent --output /dev/null --write-out '%{http_code}' --request POST "$BASE/agent/default/run" --data '{}') | ||
| test "$status" = 401 || { echo "Expected 401 without the workshop code, got $status"; exit 1; } | ||
| echo "Deployed: $BASE" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| # Checks a change to the shared backend before it can reach main. | ||
| name: runtime_ci | ||
|
|
||
| on: | ||
| pull_request: | ||
| paths: | ||
| - "runtime/**" | ||
| - ".github/workflows/runtime_ci.yaml" | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| check: | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 10 | ||
| steps: | ||
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 | ||
|
|
||
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 | ||
| with: | ||
| node-version: 22 | ||
| cache: npm | ||
| cache-dependency-path: runtime/package-lock.json | ||
|
|
||
| - name: Typecheck | ||
| working-directory: runtime | ||
| run: | | ||
| npm ci --no-audit --no-fund | ||
| npm run typecheck | ||
|
|
||
| - name: Test the password gate | ||
| working-directory: runtime | ||
| run: npm test | ||
|
|
||
| - name: Build the image | ||
| run: docker build --tag calaverita-runtime runtime |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| # Keeps the shared backend awake for a while, so the first participants don't wait | ||
| # for a cold start. Run it by hand from the Actions tab shortly before the event. | ||
| name: warm_runtime | ||
|
|
||
| on: | ||
| workflow_dispatch: | ||
| inputs: | ||
| url: | ||
| description: Runtime URL | ||
| default: https://calaverita-runtime-641689536796.us-central1.run.app/api/copilotkit | ||
| minutes: | ||
| description: How long to keep it warm | ||
| default: "120" | ||
|
|
||
| permissions: {} | ||
|
|
||
| jobs: | ||
| warm: | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 190 | ||
| steps: | ||
| - name: Ping /info every 4 minutes | ||
| env: | ||
| URL: ${{ inputs.url }} | ||
| MINUTES: ${{ inputs.minutes }} | ||
| run: | | ||
| end=$(( $(date +%s) + MINUTES * 60 )) | ||
| while [ "$(date +%s)" -lt "$end" ]; do | ||
| curl --silent --show-error --max-time 60 --output /dev/null --write-out '%{http_code}\n' "$URL/info" || true | ||
| sleep 240 | ||
| done |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,5 @@ | ||
| import 'dart:math'; | ||
|
|
||
| /// The one place that says where the agent lives. | ||
| /// | ||
| /// To use the shared hosted backend, change [defaultRuntimeUrl] (or run with | ||
|
|
@@ -24,4 +26,31 @@ class AppConfig { | |
| 'AGENT_ID', | ||
| defaultValue: 'default', | ||
| ); | ||
|
|
||
| /// The password of the shared backend, when it asks for one. | ||
| /// | ||
| /// Run with `--dart-define=WORKSHOP_CODE=<code>`. A local runtime asks for | ||
| /// nothing, so leave it out there. | ||
| static const workshopCode = String.fromEnvironment('WORKSHOP_CODE'); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The password is compiled into the build with
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We have no plan for hosted web builds, so I think it is safe. Also the password should only be given at the workshop, notice we have no password defined in the repository. |
||
|
|
||
| /// A random id for this run of the app, so a server can tell sessions apart. | ||
| static final String sessionId = _randomId(); | ||
|
|
||
| /// The headers sent with every run. | ||
| /// | ||
| /// Custom names on purpose: a runtime answers the browser's preflight with | ||
| /// `Access-Control-Allow-Headers: *`, which covers them but not | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Question, low priority: the preflight answers
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed! |
||
| /// `Authorization`. A server that does not know them ignores them. | ||
| static Future<Map<String, String>> headers() async => { | ||
| 'X-Workshop-Session': sessionId, | ||
| if (workshopCode.isNotEmpty) 'X-Workshop-Code': workshopCode, | ||
| }; | ||
|
|
||
| static String _randomId() { | ||
| final random = Random.secure(); | ||
| return List.generate( | ||
| 16, | ||
| (_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'), | ||
| ).join(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| # The key lives on the host, never in an image. | ||
| .env | ||
| node_modules | ||
| deploy | ||
| Dockerfile | ||
| .dockerignore |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,10 @@ | ||
| # Copy this file to .env (git-ignored) and fill in your key. | ||
| COPILOTKIT_MODEL=anthropic:claude-sonnet-5-5 | ||
| ANTHROPIC_API_KEY= | ||
|
|
||
| # Only for a shared, hosted runtime: the password the participants send. Leave it | ||
| # out on your machine. | ||
| # WORKSHOP_CODE= | ||
| # Caps on the runs the shared runtime accepts (the defaults): | ||
| # MAX_RUNS_PER_SESSION=100 | ||
| # MAX_RUNS_TOTAL=5000 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| # What `gcloud run deploy --source runtime` uploads. The key lives on the host, | ||
| # never in the upload, and the image installs its own node_modules. | ||
| * | ||
| !Dockerfile | ||
| !package.json | ||
| !package-lock.json | ||
| !server.ts | ||
| !env.ts |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cold start. With min instances at 0, the first request after a quiet spell waits for a cold start (
tsxplus the runtime). Here the first call took longer thandoctor.mjs's 8 second timeout and it reported "Can't reach the backend" before the instance was warm; workshop participants would see the same lag on their first message.I'm not suggesting an always-on instance because of the cost. Cheaper options: a manually started workflow that pings
/infoevery few minutes during the event window (Cloud Run stops idle instances after a while, so one warm-up isn't enough),--cpu-boost, and a longer timeout with a retry indoctor.mjs/setup.mjsfor hosted backends.