mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 15:09:18 -07:00
[studio] move cloud_build_key to secrets
This commit is contained in:
@@ -292,3 +292,24 @@ RECFLARE_DOMAIN=rec.example.com
|
|||||||
# account links its Discord, so a new supporter isn't waiting for the next run. Unset — or
|
# account links its Discord, so a new supporter isn't waiting for the next run. Unset — or
|
||||||
# parsing to no roles — leaves the cron a logged no-op and the first-link gift off.
|
# parsing to no roles — leaves the cron a logged no-op and the first-link gift off.
|
||||||
# RECFLARE_DISCORD_ROLE_TOKENS=1077000000000000001=2500,1077000000000000002=10000
|
# RECFLARE_DISCORD_ROLE_TOKENS=1077000000000000001=2500,1077000000000000002=10000
|
||||||
|
|
||||||
|
# Rec Room Studio (`studio`). Cloud builds are configured OUTSIDE this file: the credential
|
||||||
|
# the build host (recflare/cloud-building, its STUDIO_WORKER_KEY) presents to claim jobs
|
||||||
|
# lives in the shared Secrets Store, like JWT_SECRET above — not as a var, since every var
|
||||||
|
# here reaches every worker in plaintext. The binding has to resolve for `studio` to
|
||||||
|
# deploy, so create it even if you run no build host: the literal `off` disables cloud
|
||||||
|
# builds (the editor is told "Cloud building is not configured").
|
||||||
|
#
|
||||||
|
# wrangler secrets-store secret create <store-id> --name CLOUD_BUILD_KEY \
|
||||||
|
# --scopes workers --remote
|
||||||
|
#
|
||||||
|
# The knobs ARE vars. TARGETS is the Unity build targets a job is built for (0 Windows,
|
||||||
|
# 2 Android), commas, no spaces; the build host needs the matching editor modules.
|
||||||
|
# TEMPLATE_VERSION is the Studio template a job is built against, and the build host
|
||||||
|
# claims ONLY jobs for its own configured version (171 by default there, 169 here), so
|
||||||
|
# a mismatch leaves jobs queued forever with no error on either side — set this to the
|
||||||
|
# template the host has installed. DISTRIBUTION_MANIFEST is the R2 key of the template
|
||||||
|
# catalog the editor downloads from; unset disables downloads (see apps/studio/README.md).
|
||||||
|
# RECFLARE_CLOUD_BUILD_TARGETS=0,2
|
||||||
|
# RECFLARE_CLOUD_BUILD_TEMPLATE_VERSION=169
|
||||||
|
# RECFLARE_STUDIO_DISTRIBUTION_MANIFEST=roomunityasset/studio-distribution/catalog-v171.json
|
||||||
|
|||||||
@@ -140,6 +140,17 @@ wrangler secrets-store secret create <store-id> --name META_APP_SECRET --scopes
|
|||||||
> with `wrangler secrets-store secret update` — no redeploy needed, the worker reads
|
> with `wrangler secrets-store secret update` — no redeploy needed, the worker reads
|
||||||
> the secret per request.
|
> the secret per request.
|
||||||
|
|
||||||
|
The `studio` worker binds one more, `CLOUD_BUILD_KEY`: the credential the Rec Room
|
||||||
|
Studio cloud-building host ([recflare/cloud-building](https://github.com/recflare/cloud-building),
|
||||||
|
its `STUDIO_WORKER_KEY`) presents to claim queued builds. Same rule — it must exist for
|
||||||
|
`studio` to deploy. Create it as the literal `off` to run without cloud builds; put a
|
||||||
|
long random string in later when you set up a build host, and give that host the same
|
||||||
|
string.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wrangler secrets-store secret create <store-id> --name CLOUD_BUILD_KEY --scopes workers --remote
|
||||||
|
```
|
||||||
|
|
||||||
Then apply the schema. `just migrate` will set up the database and populate it with data. This runs non-interactively, so be careful!
|
Then apply the schema. `just migrate` will set up the database and populate it with data. This runs non-interactively, so be careful!
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+10
-6
@@ -83,12 +83,16 @@ build ID with a null completion time. Repeated requests for an in-progress save
|
|||||||
reuse its job. `GET /cloud-builds?cloudBuildId=...` reads the status;
|
reuse its job. `GET /cloud-builds?cloudBuildId=...` reads the status;
|
||||||
`PUT /cloud-builds/cancel` cancels a queued or running job.
|
`PUT /cloud-builds/cancel` cancels a queued or running job.
|
||||||
|
|
||||||
Apply migration `0003_studio_cloud_build_job.sql`. Configure a private
|
Apply migration `0003_studio_cloud_build_job.sql`. Create the private
|
||||||
`CLOUD_BUILD_KEY` and run the outbound worker from `recflare/cloud-building` with
|
`CLOUD_BUILD_KEY` in the shared Secrets Store (the one `JWT_SECRET` lives in — see
|
||||||
the matching credential. Without a key, creation returns an explicit unavailable
|
`.env.example`) and run the outbound worker from `recflare/cloud-building` with the
|
||||||
response. `CLOUD_BUILD_TARGETS` defaults to `0,2` (Windows and Android), and
|
same value as its `STUDIO_WORKER_KEY`. The binding must exist for the worker to
|
||||||
`CLOUD_BUILD_TEMPLATE_VERSION` defaults to 169. Match installed editor modules to
|
deploy; a value of `off` disables cloud building, and creation then returns an
|
||||||
the enabled targets.
|
explicit unavailable response. `CLOUD_BUILD_TARGETS` defaults to `0,2` (Windows and
|
||||||
|
Android), and `CLOUD_BUILD_TEMPLATE_VERSION` defaults to 169 — the cloud-building
|
||||||
|
worker claims only jobs for its own configured template version (171 by default),
|
||||||
|
so set this to match or jobs queue and are never claimed. Match installed editor
|
||||||
|
modules to the enabled targets.
|
||||||
|
|
||||||
The private `/internal/cloud-builds/claim`, `/:id/heartbeat`, and `/:id/finish`
|
The private `/internal/cloud-builds/claim`, `/:id/heartbeat`, and `/:id/finish`
|
||||||
routes lease jobs to that worker. Only validated UnityFS artifacts for the
|
routes lease jobs to that worker. Only validated UnityFS artifacts for the
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
import { parseMultipart } from './multipart'
|
import { parseMultipart } from './multipart'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import type { App } from './context'
|
import type { App, Env } from './context'
|
||||||
|
|
||||||
export const CLOUD_JOB_SCHEMA = `CREATE TABLE IF NOT EXISTS studio_cloud_build_job (
|
export const CLOUD_JOB_SCHEMA = `CREATE TABLE IF NOT EXISTS studio_cloud_build_job (
|
||||||
cloud_build_id TEXT PRIMARY KEY, payload TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'queued',
|
cloud_build_id TEXT PRIMARY KEY, payload TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'queued',
|
||||||
@@ -122,7 +122,21 @@ function wire(row: CloudBuildRecord) {
|
|||||||
: null,
|
: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function privateAuth(request: Request, key: string | undefined) {
|
/**
|
||||||
|
* The pull worker's credential, resolved per request from the Secrets Store. Returns ''
|
||||||
|
* when cloud building is off: the secret holds the literal `off` (the binding has to
|
||||||
|
* resolve for the worker to deploy, so that is how an operator opts out) or can't be read.
|
||||||
|
* Every gate on "is cloud building configured" goes through here.
|
||||||
|
*/
|
||||||
|
export async function cloudBuildKey(env: Env): Promise<string> {
|
||||||
|
try {
|
||||||
|
const key = (await env.CLOUD_BUILD_KEY.get()) ?? ''
|
||||||
|
return key === 'off' ? '' : key
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function privateAuth(request: Request, key: string) {
|
||||||
if (!key) return false
|
if (!key) return false
|
||||||
const wanted = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`Bearer ${key}`))
|
const wanted = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`Bearer ${key}`))
|
||||||
const got = await crypto.subtle.digest(
|
const got = await crypto.subtle.digest(
|
||||||
@@ -188,7 +202,7 @@ const app = new Hono<App>()
|
|||||||
if (accountId === null) return c.body(null, 401)
|
if (accountId === null) return c.body(null, 401)
|
||||||
if (!roles?.includes('betastudio'))
|
if (!roles?.includes('betastudio'))
|
||||||
return c.json(fail('Studio upload authorization is required.'), 403)
|
return c.json(fail('Studio upload authorization is required.'), 403)
|
||||||
if (!c.env.CLOUD_BUILD_KEY) return c.json(fail('Cloud building is not configured.'), 503)
|
if (!(await cloudBuildKey(c.env))) return c.json(fail('Cloud building is not configured.'), 503)
|
||||||
const form = await c.req.parseBody()
|
const form = await c.req.parseBody()
|
||||||
const ids = ['roomId', 'subRoomId', 'subRoomDataSaveId'].map((key) => textField(form[key]))
|
const ids = ['roomId', 'subRoomId', 'subRoomDataSaveId'].map((key) => textField(form[key]))
|
||||||
if (ids.some((id) => !ID.test(id) || !Number.isSafeInteger(Number(id))))
|
if (ids.some((id) => !ID.test(id) || !Number.isSafeInteger(Number(id))))
|
||||||
@@ -358,7 +372,7 @@ const app = new Hono<App>()
|
|||||||
return c.json(ok(null))
|
return c.json(ok(null))
|
||||||
})
|
})
|
||||||
.use('/internal/cloud-builds/*', async (c, next) => {
|
.use('/internal/cloud-builds/*', async (c, next) => {
|
||||||
if (!(await privateAuth(c.req.raw, c.env.CLOUD_BUILD_KEY))) return c.body(null, 401)
|
if (!(await privateAuth(c.req.raw, await cloudBuildKey(c.env)))) return c.body(null, 401)
|
||||||
return next()
|
return next()
|
||||||
})
|
})
|
||||||
.post('/internal/cloud-builds/claim', async (c) => {
|
.post('/internal/cloud-builds/claim', async (c) => {
|
||||||
@@ -684,7 +698,7 @@ async function enqueueAvatar(
|
|||||||
!((await avatarStudioFlags(c.env.DB, accountId)) & avatarItemAccessBit(item.OutfitType))
|
!((await avatarStudioFlags(c.env.DB, accountId)) & avatarItemAccessBit(item.OutfitType))
|
||||||
)
|
)
|
||||||
return c.body(null, 403)
|
return c.body(null, 403)
|
||||||
if (!c.env.CLOUD_BUILD_KEY) return c.json(fail('Cloud building is not configured.'), 503)
|
if (!(await cloudBuildKey(c.env))) return c.json(fail('Cloud building is not configured.'), 503)
|
||||||
const pending = await pendingAvatarBuild(c.env.DB, itemId, saveId)
|
const pending = await pendingAvatarBuild(c.env.DB, itemId, saveId)
|
||||||
if (pending) return c.json(ok(wire(pending)))
|
if (pending) return c.json(ok(wire(pending)))
|
||||||
const object = await c.env.CDN_ASSETS.get('avatarunityasset/' + save.source_filename)
|
const object = await c.env.CDN_ASSETS.get('avatarunityasset/' + save.source_filename)
|
||||||
|
|||||||
@@ -17,8 +17,12 @@ export type Env = SharedHonoEnv & {
|
|||||||
// Shared `recflare-cdn` bucket. Local Studio builds land under
|
// Shared `recflare-cdn` bucket. Local Studio builds land under
|
||||||
// `room/studio/<roomId>/`. The same bucket name the rooms and cdn workers bind.
|
// `room/studio/<roomId>/`. The same bucket name the rooms and cdn workers bind.
|
||||||
CDN_ASSETS: R2Bucket
|
CDN_ASSETS: R2Bucket
|
||||||
/** Private pull-worker credential; leave unset to disable cloud build dispatch. */
|
/**
|
||||||
CLOUD_BUILD_KEY?: string
|
* Private pull-worker credential, out of the shared Secrets Store (same store as
|
||||||
|
* JWT_SECRET). Read it through `cloudBuildKey()` in cloud-builds.ts, never `.get()`
|
||||||
|
* directly: the value `off` (or an unreadable secret) means cloud building is disabled.
|
||||||
|
*/
|
||||||
|
CLOUD_BUILD_KEY: SecretsStoreSecret
|
||||||
CLOUD_BUILD_TARGETS?: string
|
CLOUD_BUILD_TARGETS?: string
|
||||||
CLOUD_BUILD_TEMPLATE_VERSION?: string
|
CLOUD_BUILD_TEMPLATE_VERSION?: string
|
||||||
/** R2 key of an operator-published template catalog; unset disables downloads. */
|
/** R2 key of an operator-published template catalog; unset disables downloads. */
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const manifest = {
|
|||||||
}
|
}
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await adminSecretsStore(env.JWT_SECRET).create('test-key')
|
await adminSecretsStore(env.JWT_SECRET).create('test-key')
|
||||||
env.CLOUD_BUILD_KEY = 'private-avatar-worker-key'
|
await adminSecretsStore(env.CLOUD_BUILD_KEY).create('private-avatar-worker-key')
|
||||||
env.CLOUD_BUILD_TARGETS = '0,2'
|
env.CLOUD_BUILD_TARGETS = '0,2'
|
||||||
for (const sql of [
|
for (const sql of [
|
||||||
...SCHEMA_DDL,
|
...SCHEMA_DDL,
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ async function auth(id = 42, studio = true) {
|
|||||||
}
|
}
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await adminSecretsStore(env.JWT_SECRET).create('test-key')
|
await adminSecretsStore(env.JWT_SECRET).create('test-key')
|
||||||
env.CLOUD_BUILD_KEY = key
|
await adminSecretsStore(env.CLOUD_BUILD_KEY).create(key)
|
||||||
env.CLOUD_BUILD_TARGETS = '0'
|
env.CLOUD_BUILD_TARGETS = '0'
|
||||||
for (const sql of [
|
for (const sql of [
|
||||||
...ROOM_SCHEMA_DDL,
|
...ROOM_SCHEMA_DDL,
|
||||||
|
|||||||
@@ -37,6 +37,20 @@
|
|||||||
"binding": "JWT_SECRET",
|
"binding": "JWT_SECRET",
|
||||||
"store_id": "local",
|
"store_id": "local",
|
||||||
"secret_name": "JWT_SECRET"
|
"secret_name": "JWT_SECRET"
|
||||||
|
},
|
||||||
|
// The credential the cloud-building pull worker (recflare/cloud-building, its
|
||||||
|
// STUDIO_WORKER_KEY) presents as a bearer token on /internal/cloud-builds/*. A real
|
||||||
|
// secret, so it lives in the same store rather than riding along as a `--var` from
|
||||||
|
// .env, which would put it in plaintext on every worker. The binding must resolve
|
||||||
|
// for this worker to deploy: create it with the value `off` to run without cloud
|
||||||
|
// builds (creation then answers 503 and the private routes refuse everyone).
|
||||||
|
//
|
||||||
|
// wrangler secrets-store secret create <store-id> --name CLOUD_BUILD_KEY \
|
||||||
|
// --scopes workers --remote
|
||||||
|
{
|
||||||
|
"binding": "CLOUD_BUILD_KEY",
|
||||||
|
"store_id": "local",
|
||||||
|
"secret_name": "CLOUD_BUILD_KEY"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"upload_source_maps": true,
|
"upload_source_maps": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user