mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-07 23:54:37 +00:00
feat(updates): log requests to the shared data lake
This commit is contained in:
parent
b605f355ca
commit
a085bf62a4
5 changed files with 47 additions and 112 deletions
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
|
|
@ -47,7 +47,7 @@ jobs:
|
|||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Deploy update service
|
||||
if: github.ref_name == 'v2' || github.ref_name == 'beta'
|
||||
if: github.ref_name == 'v2'
|
||||
working-directory: packages/updates
|
||||
run: bun run deploy
|
||||
env:
|
||||
|
|
|
|||
|
|
@ -16,6 +16,26 @@ The `/admin*` route must be protected by a Cloudflare Access self-hosted applica
|
|||
|
||||
The Worker has `workers_dev` and preview URLs disabled so the custom hostname is its only public entry point.
|
||||
|
||||
## Request logging
|
||||
|
||||
Every request reaching the Worker emits an unsampled event at request start to the shared production
|
||||
Cloudflare lake stream through the `EVENTS` Pipelines binding. Events use
|
||||
`source: "update"`, `type: "request"`, an ISO `timestamp`, and a `payload` containing
|
||||
the method, path, `user_agent`, country,
|
||||
and Cloudflare colo. Query strings, request bodies, cookies, authorization headers,
|
||||
and IP addresses are not included. Response status and duration are not recorded.
|
||||
|
||||
Delivery runs in `waitUntil` without delaying the response. Delivery failures are
|
||||
logged but do not fail requests or retry; this is not lossless audit logging.
|
||||
Requests blocked before reaching the Worker are not recorded.
|
||||
|
||||
The stream ID in `wrangler.jsonc` comes from the `lake.stream` output of the
|
||||
`anomalyco/platform/production` Pulumi stack. Update the binding if that stream is
|
||||
replaced. The stream is shared across release channels because the update service
|
||||
has a single public deployment.
|
||||
|
||||
## Publishing
|
||||
|
||||
GitHub Actions publishes artifacts through `POST /api/publish` using a short-lived OIDC token with audience `https://update.opencode.ai`. The Worker accepts only tokens signed by GitHub for repository ID `975734319`, owner ID `66570915`, and `.github/workflows/publish.yml` on configured publishing refs.
|
||||
|
||||
Apply migrations and deploy from this directory:
|
||||
|
|
|
|||
|
|
@ -1,110 +0,0 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import worker, { channelsForRef, resolveChannel, validGitHubClaims } from "./index"
|
||||
|
||||
const claims = {
|
||||
repository: "anomalyco/opencode",
|
||||
repository_id: "975734319",
|
||||
repository_owner_id: "66570915",
|
||||
workflow_ref: "anomalyco/opencode/.github/workflows/publish.yml@refs/heads/dev",
|
||||
ref: "refs/heads/dev",
|
||||
sha: "abc123",
|
||||
run_id: "123",
|
||||
run_attempt: "1",
|
||||
actor: "opencode-agent",
|
||||
}
|
||||
|
||||
describe("GitHub publish authorization", () => {
|
||||
test("allows the publish workflow from the repository", () => {
|
||||
expect(validGitHubClaims(claims)).toBe(true)
|
||||
expect(channelsForRef(claims.ref)).toEqual(["dev", "latest"])
|
||||
})
|
||||
|
||||
test("maps V2 development to the dev channel", () => {
|
||||
expect(channelsForRef("refs/heads/v2")).toEqual(["dev"])
|
||||
})
|
||||
|
||||
test("rejects another repository or workflow", () => {
|
||||
expect(validGitHubClaims({ ...claims, repository_id: "1" })).toBe(false)
|
||||
expect(
|
||||
validGitHubClaims({ ...claims, workflow_ref: "anomalyco/opencode/.github/workflows/other.yml@refs/heads/dev" }),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects unconfigured refs", () => {
|
||||
const ref = "refs/heads/untrusted"
|
||||
expect(
|
||||
validGitHubClaims({ ...claims, ref, workflow_ref: `anomalyco/opencode/.github/workflows/publish.yml@${ref}` }),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test("routes the retired next channel to beta", () => {
|
||||
expect(resolveChannel("next")).toBe("beta")
|
||||
expect(resolveChannel("dev")).toBe("dev")
|
||||
})
|
||||
|
||||
const artifact = {
|
||||
channel: "beta",
|
||||
name: "opencode",
|
||||
distribution: "darwin-arm64",
|
||||
version: "1.0.0",
|
||||
metadata: "{}",
|
||||
active: 1,
|
||||
time_created: 1,
|
||||
time_updated: 2,
|
||||
}
|
||||
|
||||
test.each([
|
||||
["/api/next", ["beta"], { channel: "beta", artifacts: [{ ...artifact, metadata: {}, active: true }] }],
|
||||
[
|
||||
"/api/next/opencode",
|
||||
["beta", "opencode"],
|
||||
{ channel: "beta", name: "opencode", artifacts: [{ ...artifact, metadata: {}, active: true }] },
|
||||
],
|
||||
[
|
||||
"/api/next/opencode/darwin-arm64",
|
||||
["beta", "opencode", "darwin-arm64"],
|
||||
{ ...artifact, metadata: {}, active: true },
|
||||
],
|
||||
])("routes GET %s", async (path, expectedBindings, expectedBody) => {
|
||||
const bindings: unknown[][] = []
|
||||
const statement = {
|
||||
bind(...values: unknown[]) {
|
||||
bindings.push(values)
|
||||
return statement
|
||||
},
|
||||
async all() {
|
||||
return { results: [artifact] }
|
||||
},
|
||||
async first() {
|
||||
return artifact
|
||||
},
|
||||
}
|
||||
const db = {
|
||||
prepare() {
|
||||
return statement
|
||||
},
|
||||
} as unknown as D1Database
|
||||
|
||||
const response = await worker.fetch(new Request(`https://update.opencode.ai${path}`), { DB: db })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.text()).toBe(JSON.stringify(expectedBody))
|
||||
expect(bindings).toEqual([expectedBindings])
|
||||
})
|
||||
|
||||
test.each(["/api", "/v1/dev", "/api/dev/opencode/darwin-arm64/extra", "/api/dev/opencode/darwin$arm64"])(
|
||||
"returns 404 for GET %s",
|
||||
async (path) => {
|
||||
const db = {
|
||||
prepare() {
|
||||
throw new Error("Invalid routes must not query the database")
|
||||
},
|
||||
} as unknown as D1Database
|
||||
|
||||
const response = await worker.fetch(new Request(`https://update.opencode.ai${path}`), { DB: db })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(await response.text()).toBe("Not found")
|
||||
},
|
||||
)
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose"
|
||||
import type { Pipeline } from "cloudflare:pipelines"
|
||||
|
||||
interface Env {
|
||||
DB: D1Database
|
||||
EVENTS: Pipeline
|
||||
}
|
||||
|
||||
type ArtifactRow = {
|
||||
|
|
@ -31,8 +33,24 @@ const audience = "https://update.opencode.ai"
|
|||
const githubKeys = createRemoteJWKSet(new URL("https://token.actions.githubusercontent.com/.well-known/jwks"))
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
async fetch(request: Request, env: Env, ctx: Pick<ExecutionContext, "waitUntil">): Promise<Response> {
|
||||
const url = new URL(request.url)
|
||||
ctx.waitUntil(
|
||||
env.EVENTS.send([
|
||||
{
|
||||
source: "update",
|
||||
type: "request",
|
||||
timestamp: new Date().toISOString(),
|
||||
payload: {
|
||||
method: request.method,
|
||||
path: url.pathname,
|
||||
user_agent: request.headers.get("user-agent"),
|
||||
cf_country: request.cf?.country,
|
||||
cf_colo: request.cf?.colo,
|
||||
},
|
||||
},
|
||||
]).catch(() => console.error("Failed to send update request event to the data lake")),
|
||||
)
|
||||
|
||||
if (url.pathname === "/") return json({ service: "opencode-updates" })
|
||||
if (url.pathname === "/admin" && request.method === "GET") return admin(request, env)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,13 @@
|
|||
"compatibility_date": "2026-07-21",
|
||||
"workers_dev": false,
|
||||
"preview_urls": false,
|
||||
"pipelines": [
|
||||
{
|
||||
"binding": "EVENTS",
|
||||
// Shared lake stream from anomalyco/platform/production.
|
||||
"stream": "251a89241c3a461c9007f6b6f345ed8b",
|
||||
},
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "update.opencode.ai",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue