mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(deploy): add experimental Cloudflare deployment template (#122768)
* feat(deploy): add experimental Cloudflare template * fix(deploy): keep container SSH debug access opt-in * fix(deploy): satisfy scripts tsgo lane and model wrangler entrypoint in knip * fix(deploy): model wrangler-consumed exports and isolated dependency in knip The Worker default export and Durable Object class are instantiated by wrangler from wrangler.jsonc, and @cloudflare/containers lives in the template's isolated package.json — modeled per the deadcode checks' own guidance rather than root-manifest changes. * docs(deploy): align SSH bootstrap flow with the disabled-by-default policy
This commit is contained in:
committed by
GitHub
parent
120a75da51
commit
5fda9d09f0
@@ -0,0 +1,24 @@
|
||||
# Replace the placeholder with an immutable linux/amd64 digest from the official
|
||||
# Docker Hub repository. Cloudflare Containers cannot pull OpenClaw from GHCR.
|
||||
ARG OPENCLAW_IMAGE=openclaw/openclaw@sha256:<official-openclaw-image-digest>
|
||||
FROM ${OPENCLAW_IMAGE}
|
||||
|
||||
USER root
|
||||
|
||||
ARG LITESTREAM_VERSION=0.5.16
|
||||
ARG LITESTREAM_SHA256=9e29112380a942e4a62ee07773684396cb8b308dc4d67e130bef41f75e937f0a
|
||||
|
||||
ADD --checksum=sha256:${LITESTREAM_SHA256} \
|
||||
https://github.com/benbjohnson/litestream/releases/download/v${LITESTREAM_VERSION}/litestream-${LITESTREAM_VERSION}-linux-x86_64.tar.gz \
|
||||
/tmp/litestream.tar.gz
|
||||
RUN tar -xzf /tmp/litestream.tar.gz -C /usr/local/bin litestream \
|
||||
&& chmod 0755 /usr/local/bin/litestream \
|
||||
&& rm /tmp/litestream.tar.gz
|
||||
|
||||
COPY litestream.yml /etc/litestream.yml
|
||||
COPY entrypoint.sh /usr/local/bin/cloudflare-entrypoint.sh
|
||||
RUN chmod 0755 /usr/local/bin/cloudflare-entrypoint.sh \
|
||||
&& chown root:root /etc/litestream.yml /usr/local/bin/cloudflare-entrypoint.sh
|
||||
|
||||
USER node
|
||||
ENTRYPOINT ["tini", "-s", "--", "/usr/local/bin/cloudflare-entrypoint.sh"]
|
||||
@@ -0,0 +1,164 @@
|
||||
# OpenClaw on Cloudflare Containers (experimental)
|
||||
|
||||
This template runs one OpenClaw installation behind a Cloudflare Worker and one named Durable Object. The Durable Object starts a `standard-2` Container from a public, digest-pinned Docker Hub image. Litestream continuously replicates the global and per-agent SQLite databases to R2 through its S3-compatible API.
|
||||
|
||||
This is an experimental deployment target. Read [Operational constraints](#operational-constraints) before using it with real credentials or relying on it for recovery.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
HTTP/WebSocket request
|
||||
|
|
||||
v
|
||||
Cloudflare Worker
|
||||
|
|
||||
v
|
||||
OpenClawContainer Durable Object (one stable name)
|
||||
|
|
||||
v
|
||||
OpenClaw + Litestream container :8080
|
||||
|
|
||||
+--> R2 S3 API (SQLite replicas)
|
||||
```
|
||||
|
||||
Every HTTP and WebSocket request is forwarded to port `8080`. The Container helper checks `GET /startupz` before admitting traffic. `max_instances: 1` and the single Durable Object name are the installation's outer single-writer fence.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Cloudflare account with Workers, Containers, and R2 available
|
||||
- Docker Buildx with `linux/amd64` support
|
||||
- A public Docker Hub repository for the derived image
|
||||
- Node.js and npm
|
||||
- Model-provider and channel credentials for the OpenClaw setup you choose
|
||||
|
||||
## 1. Create the R2 bucket and S3 credentials
|
||||
|
||||
From this directory:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx wrangler login
|
||||
npx wrangler whoami
|
||||
npx wrangler r2 bucket create openclaw-backups
|
||||
```
|
||||
|
||||
In the Cloudflare dashboard, create an R2 API token with object read/write access limited to this bucket. Record its access key ID and secret access key. Do not put either value in this checkout.
|
||||
|
||||
Edit `wrangler.jsonc`:
|
||||
|
||||
- replace `<account-id>` in `LITESTREAM_ENDPOINT`
|
||||
- change both `LITESTREAM_BUCKET` and `r2_buckets[].bucket_name` if you chose another bucket name
|
||||
|
||||
The R2 binding is present for Worker-side completeness. Litestream runs inside the Container and cannot consume a Worker binding directly, so it uses R2's S3 endpoint and Worker secrets passed through `envVars`.
|
||||
|
||||
## 2. Build and publish the image
|
||||
|
||||
Choose an immutable, architecture-compatible digest from the official [`openclaw/openclaw`](https://hub.docker.com/r/openclaw/openclaw) Docker Hub repository. Replace `<official-openclaw-image-digest>` in `Dockerfile`, then build and push the derived image:
|
||||
|
||||
```bash
|
||||
docker buildx build \
|
||||
--platform linux/amd64 \
|
||||
--tag docker.io/<docker-hub-user>/openclaw-cloudflare:<version> \
|
||||
--push \
|
||||
.
|
||||
```
|
||||
|
||||
Make the derived repository public. Resolve its pushed digest, then replace the `containers[].image` placeholder in `wrangler.jsonc`:
|
||||
|
||||
```bash
|
||||
docker buildx imagetools inspect docker.io/<docker-hub-user>/openclaw-cloudflare:<version>
|
||||
```
|
||||
|
||||
Use the resulting immutable `docker.io/<docker-hub-user>/openclaw-cloudflare@sha256:<digest>` reference. Cloudflare Containers can pull public Docker Hub images, but not GHCR images directly.
|
||||
|
||||
## 3. Deploy and set secrets
|
||||
|
||||
The first deploy creates the Worker, Durable Object migration, Container application, and R2 binding:
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
Immediately add the R2 and Gateway secrets. `wrangler secret put` prompts without writing the value to shell history:
|
||||
|
||||
```bash
|
||||
npx wrangler secret put LITESTREAM_ACCESS_KEY_ID
|
||||
npx wrangler secret put LITESTREAM_SECRET_ACCESS_KEY
|
||||
npx wrangler secret put OPENCLAW_GATEWAY_TOKEN
|
||||
```
|
||||
|
||||
Add the provider and channel variables needed by your installation, for example:
|
||||
|
||||
```bash
|
||||
npx wrangler secret put OPENAI_API_KEY
|
||||
npx wrangler secret put TELEGRAM_BOT_TOKEN
|
||||
```
|
||||
|
||||
`src/container.ts` passes the listed optional secret names to the Container. Add another explicit name there before using a different environment-backed provider or channel credential.
|
||||
|
||||
## 4. Bootstrap OpenClaw
|
||||
|
||||
Open the deployed Worker URL once to start the named instance. Then find the Container application and instance IDs:
|
||||
|
||||
```bash
|
||||
npx wrangler containers list
|
||||
npx wrangler containers instances <application-id> --json
|
||||
npx wrangler containers ssh <instance-id>
|
||||
```
|
||||
|
||||
Inside the Container, run the non-interactive SecretRef bootstrap. This example uses OpenAI and Telegram; select the provider and webhook-capable channel that match your secrets:
|
||||
|
||||
```bash
|
||||
cd /app
|
||||
node openclaw.mjs onboard --non-interactive --accept-risk --skip-health \
|
||||
--mode local \
|
||||
--auth-choice openai-api-key \
|
||||
--secret-input-mode ref \
|
||||
--gateway-auth token \
|
||||
--gateway-token-ref-env OPENCLAW_GATEWAY_TOKEN \
|
||||
--skip-channels \
|
||||
--no-install-daemon
|
||||
node openclaw.mjs channels add --channel telegram --use-env
|
||||
node openclaw.mjs doctor --json
|
||||
```
|
||||
|
||||
Keep the exact bootstrap recipe in a private, reproducible runbook. Litestream does not replicate `openclaw.json`, credential files, installed plugin files, or workspaces.
|
||||
|
||||
## Scale-to-zero policy
|
||||
|
||||
The template defaults `OPENCLAW_WEBHOOK_ONLY` to `false`. This keeps the Container alive across idle periods for Discord, Slack Socket Mode, WhatsApp, and every other channel that maintains a socket or polling process.
|
||||
|
||||
Set `OPENCLAW_WEBHOOK_ONLY` to `true` only when every enabled channel receives traffic through HTTP webhooks. The Container then stops after ten idle minutes and cold-starts on the next request. Because its disk is fresh after sleep, enable this only when an external process can reapply the declarative bootstrap above; Litestream alone restores SQLite, not the config files needed to activate channels.
|
||||
|
||||
## Operational constraints
|
||||
|
||||
- **Experimental:** Cloudflare Container lifecycle and rollout behavior can change. Test crash, sleep, rollout, and restore paths with non-production credentials first.
|
||||
- **Single-writer fence:** Cloudflare guarantees one live Durable Object instance for a given name, and all Worker requests use the same name. This is the fence around one Litestream replica. A brief old/new Container overlap during replacement or rollout remains an accepted experimental tradeoff; do not raise `max_instances` or route around the named object.
|
||||
- **Ephemeral disk:** Every Container restart or sleep starts with a fresh filesystem. The entrypoint lists R2 objects, derives the concrete SQLite restore manifest, restores each database, then starts OpenClaw under Litestream.
|
||||
- **Partial durability:** Litestream covers `/home/node/.openclaw/state/*.sqlite` and recursive per-agent SQLite databases only. Use a separate, private [`openclaw backup create`](https://docs.openclaw.ai/install/backups#full-archives) workflow for config, credential files, plugins, and workspaces.
|
||||
- **RPO:** `sync-interval: 1s` normally yields a seconds-scale recovery point, not zero data loss. Abrupt termination can lose writes that were not uploaded yet.
|
||||
- **Rollback is time travel:** Restoring older state can desynchronize ratcheting channel credentials (especially WhatsApp), roll back approvals, and roll back delivery/dedupe state. Relink affected channels and review pending approvals before resuming.
|
||||
- **WebSocket limit:** Cloudflare accepts received WebSocket messages up to 32 MiB. The Worker/Container proxy supports WebSockets; larger individual messages are closed by the platform.
|
||||
- **Egress identity:** outbound traffic comes from shared Cloudflare IP space. Providers that require a fixed source IP need another deployment target or an approved egress design.
|
||||
- **Not a `cloudWorkers` provider:** this is a hosting template. Operator SSH access is enabled for bootstrap, but the template does not implement OpenClaw's SSH-based cloud-worker provider contract.
|
||||
|
||||
## Updating
|
||||
|
||||
Build a new derived image from a new immutable official OpenClaw digest, push it, replace the derived digest in `wrangler.jsonc`, and run:
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
Treat rollbacks like restores: stop traffic where possible, preserve the current state first, and review credentials, approvals, and delivery state before activating older database bytes.
|
||||
|
||||
## Files
|
||||
|
||||
- `wrangler.jsonc`: Worker, Durable Object, Container application, and R2 binding
|
||||
- `src/index.ts`: routes all HTTP and WebSocket traffic to one named instance
|
||||
- `src/container.ts`: Container port, readiness, environment, and sleep policy
|
||||
- `Dockerfile`: official OpenClaw image plus pinned Litestream for `linux/amd64`
|
||||
- `entrypoint.sh`: R2 LIST restore discovery, containment checks, and restore-then-exec flow
|
||||
- `litestream.yml`: watched global and per-agent SQLite directory replicas
|
||||
@@ -0,0 +1,197 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
STATE_ROOT=/home/node/.openclaw
|
||||
CONFIG=/etc/litestream.yml
|
||||
|
||||
mkdir -p "$STATE_ROOT/state" "$STATE_ROOT/agents"
|
||||
|
||||
log() {
|
||||
printf '[cloudflare-entrypoint] %s\n' "$*"
|
||||
}
|
||||
|
||||
replica_url_for_db() {
|
||||
db_path=$1
|
||||
resolved_path=$(realpath -m "$db_path")
|
||||
case "$resolved_path" in
|
||||
"$STATE_ROOT"/state/*.sqlite)
|
||||
relative_path=${resolved_path#"$STATE_ROOT/state/"}
|
||||
replica_path="replicas/state/$relative_path"
|
||||
;;
|
||||
# case globs match "/" (fnmatch without FNM_PATHNAME), so this accepts the
|
||||
# nested canonical layout agents/<id>/agent/openclaw-agent.sqlite.
|
||||
"$STATE_ROOT"/agents/*.sqlite)
|
||||
relative_path=${resolved_path#"$STATE_ROOT/agents/"}
|
||||
replica_path="replicas/agents/$relative_path"
|
||||
;;
|
||||
*)
|
||||
log "refusing restore path outside configured directory roots: $db_path"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
printf 's3://%s/%s?endpoint=%s®ion=%s&forcePathStyle=true\n' \
|
||||
"$LITESTREAM_BUCKET" "$replica_path" "$LITESTREAM_ENDPOINT" "$LITESTREAM_REGION"
|
||||
}
|
||||
|
||||
list_replica_databases() {
|
||||
node --input-type=module <<'NODE'
|
||||
import { createHash, createHmac } from "node:crypto";
|
||||
|
||||
const {
|
||||
LITESTREAM_ACCESS_KEY_ID: accessKeyId,
|
||||
LITESTREAM_BUCKET: bucket,
|
||||
LITESTREAM_ENDPOINT: endpoint,
|
||||
LITESTREAM_REGION: region,
|
||||
LITESTREAM_SECRET_ACCESS_KEY: secretAccessKey,
|
||||
} = process.env;
|
||||
|
||||
for (const [name, value] of Object.entries({
|
||||
LITESTREAM_ACCESS_KEY_ID: accessKeyId,
|
||||
LITESTREAM_BUCKET: bucket,
|
||||
LITESTREAM_ENDPOINT: endpoint,
|
||||
LITESTREAM_REGION: region,
|
||||
LITESTREAM_SECRET_ACCESS_KEY: secretAccessKey,
|
||||
})) {
|
||||
if (!value) {
|
||||
throw new Error(`${name} is required`);
|
||||
}
|
||||
}
|
||||
|
||||
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
||||
const hmac = (key, value) => createHmac("sha256", key).update(value).digest();
|
||||
const encode = (value) =>
|
||||
encodeURIComponent(value).replace(/[!'()*]/g, (char) =>
|
||||
`%${char.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||
);
|
||||
|
||||
function decodeXml(value) {
|
||||
return value
|
||||
.replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(Number.parseInt(code, 16)))
|
||||
.replace(/&#([0-9]+);/g, (_, code) => String.fromCodePoint(Number.parseInt(code, 10)))
|
||||
.replaceAll(""", '"')
|
||||
.replaceAll("'", "'")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll("&", "&");
|
||||
}
|
||||
|
||||
async function listPage(continuationToken) {
|
||||
const query = [
|
||||
["encoding-type", "url"],
|
||||
["list-type", "2"],
|
||||
["prefix", "replicas/"],
|
||||
];
|
||||
if (continuationToken) {
|
||||
query.push(["continuation-token", continuationToken]);
|
||||
}
|
||||
query.sort(([left], [right]) => left.localeCompare(right));
|
||||
const canonicalQuery = query.map(([key, value]) => `${encode(key)}=${encode(value)}`).join("&");
|
||||
|
||||
const url = new URL(endpoint);
|
||||
url.pathname = `${url.pathname.replace(/\/$/, "")}/${encode(bucket)}`;
|
||||
url.search = canonicalQuery;
|
||||
|
||||
const now = new Date();
|
||||
const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
||||
const date = amzDate.slice(0, 8);
|
||||
const payloadHash = sha256("");
|
||||
const canonicalHeaders =
|
||||
`host:${url.host}\n` +
|
||||
`x-amz-content-sha256:${payloadHash}\n` +
|
||||
`x-amz-date:${amzDate}\n`;
|
||||
const signedHeaders = "host;x-amz-content-sha256;x-amz-date";
|
||||
const canonicalRequest = [
|
||||
"GET",
|
||||
url.pathname,
|
||||
canonicalQuery,
|
||||
canonicalHeaders,
|
||||
signedHeaders,
|
||||
payloadHash,
|
||||
].join("\n");
|
||||
const scope = `${date}/${region}/s3/aws4_request`;
|
||||
const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256(canonicalRequest)].join("\n");
|
||||
const dateKey = hmac(`AWS4${secretAccessKey}`, date);
|
||||
const regionKey = hmac(dateKey, region);
|
||||
const serviceKey = hmac(regionKey, "s3");
|
||||
const signingKey = hmac(serviceKey, "aws4_request");
|
||||
const signature = createHmac("sha256", signingKey).update(stringToSign).digest("hex");
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization:
|
||||
`AWS4-HMAC-SHA256 Credential=${accessKeyId}/${scope},` +
|
||||
`SignedHeaders=${signedHeaders},Signature=${signature}`,
|
||||
"x-amz-content-sha256": payloadHash,
|
||||
"x-amz-date": amzDate,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`R2 ListObjectsV2 failed with HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const xml = await response.text();
|
||||
const keys = [...xml.matchAll(/<Key>([\s\S]*?)<\/Key>/g)].map((match) =>
|
||||
decodeURIComponent(decodeXml(match[1])),
|
||||
);
|
||||
const tokenMatch = xml.match(/<NextContinuationToken>([\s\S]*?)<\/NextContinuationToken>/);
|
||||
return {
|
||||
keys,
|
||||
nextToken: tokenMatch ? decodeXml(tokenMatch[1]) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function localDatabasePath(key) {
|
||||
const match = /^replicas\/(state|agents)\/(.+\.sqlite)\/\d{4}\/[^/]+\.ltx$/.exec(key);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const [, root, relativePath] = match;
|
||||
const segments = relativePath.split("/");
|
||||
if (segments.some((segment) => !segment || segment === "." || segment === ".." || /\s/.test(segment))) {
|
||||
throw new Error(`unsafe replica database path in R2 listing: ${key}`);
|
||||
}
|
||||
return `/home/node/.openclaw/${root}/${segments.join("/")}`;
|
||||
}
|
||||
|
||||
const databasePaths = new Set();
|
||||
let continuationToken;
|
||||
do {
|
||||
const page = await listPage(continuationToken);
|
||||
for (const key of page.keys) {
|
||||
const databasePath = localDatabasePath(key);
|
||||
if (databasePath) {
|
||||
databasePaths.add(databasePath);
|
||||
}
|
||||
}
|
||||
continuationToken = page.nextToken;
|
||||
} while (continuationToken);
|
||||
|
||||
for (const databasePath of [...databasePaths].sort()) {
|
||||
console.log(databasePath);
|
||||
}
|
||||
NODE
|
||||
}
|
||||
|
||||
# Directory replication appends each database's relative path to the replica
|
||||
# prefix. Restore therefore uses an R2 ListObjectsV2 result as its manifest.
|
||||
if ! find "$STATE_ROOT/state" "$STATE_ROOT/agents" -type f -name '*.sqlite' -print -quit | grep -q .; then
|
||||
export AWS_ACCESS_KEY_ID="${AWS_ACCESS_KEY_ID:-$LITESTREAM_ACCESS_KEY_ID}"
|
||||
export AWS_SECRET_ACCESS_KEY="${AWS_SECRET_ACCESS_KEY:-$LITESTREAM_SECRET_ACCESS_KEY}"
|
||||
export AWS_REGION="${AWS_REGION:-$LITESTREAM_REGION}"
|
||||
|
||||
restore_databases=$(list_replica_databases)
|
||||
for db_path in $restore_databases; do
|
||||
replica_url=$(replica_url_for_db "$db_path")
|
||||
mkdir -p "$(dirname "$db_path")"
|
||||
log "restoring database: $db_path"
|
||||
litestream restore -if-replica-exists -integrity-check quick -o "$db_path" "$replica_url"
|
||||
done
|
||||
else
|
||||
log "sqlite state already present; restore skipped"
|
||||
fi
|
||||
|
||||
log "starting Litestream replication with OpenClaw gateway child"
|
||||
exec litestream replicate -config "$CONFIG" \
|
||||
-exec "node openclaw.mjs gateway --allow-unconfigured --bind lan --port 8080 --auth token"
|
||||
@@ -0,0 +1,36 @@
|
||||
# Both roots need watch:true because OpenClaw creates the shared and per-agent
|
||||
# databases after Litestream starts on a new ephemeral container.
|
||||
sync-interval: 1s
|
||||
logging:
|
||||
level: INFO
|
||||
type: text
|
||||
stderr: false
|
||||
|
||||
dbs:
|
||||
- dir: /home/node/.openclaw/state
|
||||
pattern: "*.sqlite"
|
||||
recursive: false
|
||||
watch: true
|
||||
replica:
|
||||
type: s3
|
||||
bucket: ${LITESTREAM_BUCKET}
|
||||
path: replicas/state
|
||||
endpoint: ${LITESTREAM_ENDPOINT}
|
||||
region: ${LITESTREAM_REGION}
|
||||
access-key-id: ${LITESTREAM_ACCESS_KEY_ID}
|
||||
secret-access-key: ${LITESTREAM_SECRET_ACCESS_KEY}
|
||||
force-path-style: true
|
||||
|
||||
- dir: /home/node/.openclaw/agents
|
||||
pattern: "*.sqlite"
|
||||
recursive: true
|
||||
watch: true
|
||||
replica:
|
||||
type: s3
|
||||
bucket: ${LITESTREAM_BUCKET}
|
||||
path: replicas/agents
|
||||
endpoint: ${LITESTREAM_ENDPOINT}
|
||||
region: ${LITESTREAM_REGION}
|
||||
access-key-id: ${LITESTREAM_ACCESS_KEY_ID}
|
||||
secret-access-key: ${LITESTREAM_SECRET_ACCESS_KEY}
|
||||
force-path-style: true
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "openclaw-cloudflare-template",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"check": "tsc --noEmit -p tsconfig.json",
|
||||
"deploy": "wrangler deploy"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cloudflare/containers": "0.3.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "6.0.3",
|
||||
"wrangler": "4.122.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Keep this deployment template type-checkable without adding Cloudflare packages
|
||||
// to the OpenClaw workspace. The isolated package.json supplies the runtime module.
|
||||
declare module "@cloudflare/containers" {
|
||||
export class Container<Env = unknown> {
|
||||
constructor(ctx: unknown, env: Env);
|
||||
defaultPort?: number;
|
||||
envVars: Record<string, string>;
|
||||
pingEndpoint: string;
|
||||
sleepAfter: string | number;
|
||||
fetch(request: Request): Promise<Response>;
|
||||
onActivityExpired(): Promise<void>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Container } from "@cloudflare/containers";
|
||||
|
||||
interface OpenClawContainerEnv {
|
||||
ANTHROPIC_API_KEY?: string;
|
||||
DISCORD_BOT_TOKEN?: string;
|
||||
LITESTREAM_ACCESS_KEY_ID: string;
|
||||
LITESTREAM_BUCKET: string;
|
||||
LITESTREAM_ENDPOINT: string;
|
||||
LITESTREAM_REGION: string;
|
||||
LITESTREAM_SECRET_ACCESS_KEY: string;
|
||||
OPENAI_API_KEY?: string;
|
||||
OPENCLAW_GATEWAY_TOKEN: string;
|
||||
OPENCLAW_WEBHOOK_ONLY: string;
|
||||
SLACK_APP_TOKEN?: string;
|
||||
SLACK_BOT_TOKEN?: string;
|
||||
TELEGRAM_BOT_TOKEN?: string;
|
||||
}
|
||||
|
||||
const OPTIONAL_SECRET_NAMES = [
|
||||
"ANTHROPIC_API_KEY",
|
||||
"DISCORD_BOT_TOKEN",
|
||||
"OPENAI_API_KEY",
|
||||
"SLACK_APP_TOKEN",
|
||||
"SLACK_BOT_TOKEN",
|
||||
"TELEGRAM_BOT_TOKEN",
|
||||
] as const;
|
||||
|
||||
function buildContainerEnv(env: OpenClawContainerEnv): Record<string, string> {
|
||||
const containerEnv: Record<string, string> = {
|
||||
LITESTREAM_ACCESS_KEY_ID: env.LITESTREAM_ACCESS_KEY_ID,
|
||||
LITESTREAM_BUCKET: env.LITESTREAM_BUCKET,
|
||||
LITESTREAM_ENDPOINT: env.LITESTREAM_ENDPOINT,
|
||||
LITESTREAM_REGION: env.LITESTREAM_REGION,
|
||||
LITESTREAM_SECRET_ACCESS_KEY: env.LITESTREAM_SECRET_ACCESS_KEY,
|
||||
OPENCLAW_GATEWAY_TOKEN: env.OPENCLAW_GATEWAY_TOKEN,
|
||||
};
|
||||
|
||||
for (const [name, value] of Object.entries(containerEnv)) {
|
||||
if (!value) {
|
||||
throw new Error(`missing required Worker variable or secret: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of OPTIONAL_SECRET_NAMES) {
|
||||
const value = env[name];
|
||||
if (value) {
|
||||
containerEnv[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return containerEnv;
|
||||
}
|
||||
|
||||
export class OpenClawContainer extends Container<OpenClawContainerEnv> {
|
||||
override defaultPort = 8080;
|
||||
override pingEndpoint = "localhost/startupz";
|
||||
override sleepAfter = "10m";
|
||||
|
||||
private readonly webhookOnly: boolean;
|
||||
|
||||
constructor(ctx: unknown, env: OpenClawContainerEnv) {
|
||||
super(ctx, env);
|
||||
this.envVars = buildContainerEnv(env);
|
||||
this.webhookOnly = env.OPENCLAW_WEBHOOK_ONLY === "true";
|
||||
}
|
||||
|
||||
override async onActivityExpired(): Promise<void> {
|
||||
// Socket channels need a continuously running process. Only an explicitly
|
||||
// webhook-only installation may let the Container helper stop the instance.
|
||||
if (this.webhookOnly) {
|
||||
await super.onActivityExpired();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export { OpenClawContainer } from "./container.js";
|
||||
|
||||
interface ContainerStub {
|
||||
fetch(request: Request): Promise<Response>;
|
||||
}
|
||||
|
||||
interface ContainerNamespace {
|
||||
getByName(name: string): ContainerStub;
|
||||
}
|
||||
|
||||
interface WorkerEnv {
|
||||
OPENCLAW_CONTAINER: ContainerNamespace;
|
||||
}
|
||||
|
||||
interface WorkerHandler {
|
||||
fetch(request: Request, env: WorkerEnv): Promise<Response>;
|
||||
}
|
||||
|
||||
// One stable name gives the installation one globally unique Durable Object.
|
||||
// That object is the outer single-writer fence for the Litestream replica.
|
||||
const INSTALLATION_INSTANCE = "openclaw-installation";
|
||||
|
||||
const worker: WorkerHandler = {
|
||||
async fetch(request, env) {
|
||||
return env.OPENCLAW_CONTAINER.getByName(INSTALLATION_INSTANCE).fetch(request);
|
||||
},
|
||||
};
|
||||
|
||||
export default worker;
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "WebWorker"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"noEmit": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"target": "ES2022",
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "openclaw-cloudflare",
|
||||
"main": "src/index.ts",
|
||||
"compatibility_date": "2026-08-12",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"observability": {
|
||||
"enabled": true,
|
||||
},
|
||||
"vars": {
|
||||
"LITESTREAM_BUCKET": "openclaw-backups",
|
||||
"LITESTREAM_ENDPOINT": "https://<account-id>.r2.cloudflarestorage.com",
|
||||
"LITESTREAM_REGION": "auto",
|
||||
// Keep false for Discord, Slack Socket Mode, WhatsApp, or any other socket channel.
|
||||
"OPENCLAW_WEBHOOK_ONLY": "false",
|
||||
},
|
||||
"durable_objects": {
|
||||
"bindings": [
|
||||
{
|
||||
"name": "OPENCLAW_CONTAINER",
|
||||
"class_name": "OpenClawContainer",
|
||||
},
|
||||
],
|
||||
},
|
||||
"migrations": [
|
||||
{
|
||||
"tag": "v1",
|
||||
"new_sqlite_classes": ["OpenClawContainer"],
|
||||
},
|
||||
],
|
||||
"r2_buckets": [
|
||||
{
|
||||
// Documentation/Worker access only. Litestream uses the R2 S3 API and
|
||||
// credentials supplied with `wrangler secret put`, not this binding.
|
||||
"binding": "OPENCLAW_BACKUPS",
|
||||
"bucket_name": "openclaw-backups",
|
||||
},
|
||||
],
|
||||
"containers": [
|
||||
{
|
||||
"name": "openclaw-cloudflare",
|
||||
"class_name": "OpenClawContainer",
|
||||
// Build scripts/cloudflare/Dockerfile for linux/amd64, publish it publicly
|
||||
// on Docker Hub, then replace this placeholder with its immutable digest.
|
||||
"image": "docker.io/<docker-hub-user>/openclaw-cloudflare@sha256:<derived-image-digest>",
|
||||
"instance_type": "standard-2",
|
||||
"max_instances": 1,
|
||||
// For debugging you can add `"ssh": { "enabled": true }` to allow
|
||||
// wrangler-mediated SSH for accounts with container write access.
|
||||
},
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user