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
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,89 @@ | ||
| # 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@v4 | ||
|
|
||
| - name: Sign in to Google Cloud | ||
| id: auth | ||
| uses: google-github-actions/auth@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@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 are repository secrets. GitHub masks them in the | ||
| # logs; Cloud Run keeps them as environment variables of the revision. | ||
| - name: Deploy to Cloud Run | ||
| id: deploy | ||
| uses: google-github-actions/deploy-cloudrun@v2 | ||
| with: | ||
| project_id: ${{ env.PROJECT_ID }} | ||
| region: ${{ env.REGION }} | ||
| service: ${{ env.SERVICE }} | ||
| image: ${{ steps.image.outputs.name }} | ||
| env_vars: | | ||
|
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 key and the password end up as plain environment variables of the revision, readable by anyone with viewer access on the project. Secret Manager ( |
||
| CLAUDE_API_KEY=${{ secrets.CLAUDE_API_KEY }} | ||
| WORKSHOP_CODE=${{ secrets.WORKSHOP_CODE }} | ||
| flags: >- | ||
| --service-account=${{ env.RUNTIME_SERVICE_ACCOUNT }} | ||
| --allow-unauthenticated | ||
| --max-instances=1 | ||
|
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. Cold start. With min instances at 0, the first request after a quiet spell waits for a cold start ( I'm not suggesting an always-on instance because of the cost. Cheaper options: a manually started workflow that pings |
||
| --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' | ||
| 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,33 @@ | ||
| # 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@v4 | ||
|
|
||
| - uses: actions/setup-node@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: Build the image | ||
| run: docker build --tag calaverita-runtime runtime |
| 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 |
||
|
|
||
| /// 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 |
||
| /// `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 |
|---|---|---|
|
|
@@ -35,7 +35,10 @@ node tool/setup.mjs | |
| It installs Flutter 3.47.0 through FVM, checks your machine, asks which step to | ||
| run and which backend to use, and starts the app in Chrome. The backend is one of: | ||
|
|
||
| - **The shared backend:** nothing to set up. | ||
| - **The shared backend:** the one we host for the workshop, at | ||
| `https://calaverita-runtime-641689536796.us-central1.run.app/api/copilotkit`. | ||
| Nothing to set up except the workshop password, which you get at the workshop. | ||
| It asks for it (hidden as you type), or pass `--code <password>`. | ||
| - **Your own runtime:** it asks for your Anthropic API key (hidden as you type) | ||
| and starts the runtime in `runtime/` for you. | ||
| - **A custom backend:** any CopilotKit runtime you have a URL for, for example one | ||
|
|
@@ -90,7 +93,7 @@ npm install | |
| ``` | ||
|
|
||
| Create your `runtime/.env` from the example, then open it and set | ||
| `ANTHROPIC_API_KEY=<your key>`: | ||
| `CLAUDE_API_KEY=<your key>`: | ||
|
|
||
| ```sh | ||
| cp .env.example .env # macOS and Linux | ||
|
|
@@ -167,7 +170,7 @@ Start the server in a first terminal. It uses port 4100, so the runtime of steps | |
|
|
||
| ```sh | ||
| cd 03-custom-server/server | ||
| cp .env.example .env # set ANTHROPIC_API_KEY | ||
| cp .env.example .env # set CLAUDE_API_KEY | ||
| npm install | ||
| npm start # http://localhost:4100/api/copilotkit | ||
| ``` | ||
|
|
@@ -187,6 +190,21 @@ The runtime URL is set in **one place per snapshot**: | |
| `AppConfig.defaultRuntimeUrl`, or run with | ||
| `--dart-define=RUNTIME_URL=<url>`. | ||
|
|
||
| The shared backend (`https://calaverita-runtime-641689536796.us-central1.run.app/api/copilotkit`) | ||
| also asks for a password. The app sends it, and a random id | ||
| of the run, as the `X-Workshop-Code` and `X-Workshop-Session` headers on every | ||
| request: | ||
|
|
||
| ```sh | ||
| fvm flutter run -d chrome \ | ||
|
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 |
||
| --dart-define=RUNTIME_URL=<url> --dart-define=WORKSHOP_CODE=<password> | ||
| ``` | ||
|
|
||
| A runtime on your machine ignores both headers, so leave `WORKSHOP_CODE` out | ||
| there. To host the shared backend, deploy `runtime/` and set `WORKSHOP_CODE` and | ||
| `CLAUDE_API_KEY` on the host. The runtime then answers `401` without the | ||
| password. `GET /info` and the CORS preflight stay open. | ||
|
|
||
| ## How the code is organized | ||
|
|
||
| Each step is a Dart workspace of small packages: | ||
|
|
@@ -225,6 +243,8 @@ packages. | |
| - **The runtime starts but every message fails**: the model or the key is wrong. | ||
| Send a run straight to the runtime and read the `RUN_ERROR` event (see the | ||
| [SDK example README](https://github.com/VGVentures/copilotkit_flutter_sdk/blob/main/example/README.md#troubleshooting)). | ||
| - **"The copilot could not answer" on the shared backend**: the password is | ||
| missing or wrong (`401`). | ||
| - **`pub get` can't fetch the SDK**: you need access to the SDK repository. | ||
|
|
||
| ## License | ||
|
|
||
| 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,7 @@ | ||
| # Copy this file to .env (git-ignored) and fill in your key. | ||
| COPILOTKIT_MODEL=anthropic:claude-sonnet-5-5 | ||
| ANTHROPIC_API_KEY= | ||
| CLAUDE_API_KEY= | ||
|
|
||
| # Only for a shared, hosted runtime: the password the participants send. Leave it | ||
| # out on your machine. | ||
| # WORKSHOP_CODE= |
| 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| # The image of the shared backend. Build it from this folder: | ||
| # docker build -t calaverita-runtime runtime | ||
| FROM node:22-slim | ||
|
|
||
| WORKDIR /app | ||
| ENV NODE_ENV=production | ||
|
|
||
| # `npm start` runs the TypeScript with tsx, a dev dependency, so install all of them. | ||
| COPY package.json package-lock.json ./ | ||
| RUN npm ci --include=dev --no-audit --no-fund | ||
|
|
||
| COPY server.ts env.ts ./ | ||
|
|
||
| USER node | ||
| # Cloud Run sets PORT; server.ts reads it and listens on 0.0.0.0. | ||
| CMD ["npm", "start", "--silent"] |
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.
Actions are pinned by tag (
@v4,@v2,@v3) in a workflow that hasid-token: writein a public repo, and tags can move. Suggestion: pin everyuses:to a commit SHA (keep the version in a comment) and add Dependabot forgithub-actionsso the pins keep moving.