Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,6 @@ CF_ACCOUNT_ID=your-cloudflare-account-id
CF_API_TOKEN=your-cloudflare-api-token
# R2 S3 credentials: R2 → Manage R2 API Tokens → Create Account API token
R2_ACCESS_KEY_ID=your-r2-access-key-id
R2_SECRET_ACCESS_KEY=your-r2-secret-access-key
R2_SECRET_ACCESS_KEY=your-r2-secret-access-key
# R2 bucket name (shared between Cloud Run and Compute Engine for failover)
R2_BUCKET_NAME=openclaw-state
1 change: 1 addition & 0 deletions terraform/gcp_cloudrun/.envrc
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ export TF_VAR_cloudflare_account_id="${CF_ACCOUNT_ID:-}"
export TF_VAR_cloudflare_api_token="${CF_API_TOKEN:-}"
export TF_VAR_r2_access_key_id="${R2_ACCESS_KEY_ID:-}"
export TF_VAR_r2_secret_access_key="${R2_SECRET_ACCESS_KEY:-}"
export TF_VAR_r2_bucket_name="${R2_BUCKET_NAME:-}"
4 changes: 2 additions & 2 deletions terraform/gcp_cloudrun/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ resource "google_cloud_run_v2_service" "openclaw" {
}
env {
name = "R2_BUCKET"
value = cloudflare_r2_bucket.state.name
value = var.r2_bucket_name
}

env {
Expand Down Expand Up @@ -100,7 +100,7 @@ resource "google_cloud_run_v2_service" "openclaw" {

env {
name = "HOME"
value = "/tmp/openclaw-state"
value = "/home/node"
}

env {
Expand Down
6 changes: 5 additions & 1 deletion terraform/gcp_cloudrun/platform.tf
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
locals {
effective_container_image = "${var.region}-docker.pkg.dev/${var.project_id}/${var.ghcr_remote_repository_id}/${var.ghcr_image_path}:${var.ghcr_image_tag}"

openclaw_json_content = templatefile("${path.module}/openclaw.json.tpl", {
openclaw_json_content = templatefile("${path.module}/../shared/openclaw.json.tpl", {
openclaw_gateway_token = var.openclaw_gateway_token
openrouter_api_key = var.openrouter_api_key
brave_api_key = var.brave_api_key
telegram_bot_token = var.telegram_bot_token
slack_app_token = var.slack_app_token
slack_bot_token = var.slack_bot_token
slack_enabled = true # Cloud Run always provisions Slack secrets
bonjour_enabled = true # Cloud Run: disable bonjour discovery
use_plugin_load_paths = false # Cloud Run: extensions bundled in container image
telegram_owner_id = var.telegram_owner_id
})
}

Expand Down
8 changes: 0 additions & 8 deletions terraform/gcp_cloudrun/provider.tf
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,6 @@ terraform {
source = "hashicorp/google"
version = "~> 6.0"
}
cloudflare = {
source = "cloudflare/cloudflare"
version = "~> 5.0"
}
null = {
source = "hashicorp/null"
version = "~> 3.0"
Expand All @@ -25,7 +21,3 @@ provider "google" {
region = var.region
credentials = var.gcp_credentials_json != "" ? var.gcp_credentials_json : null
}

provider "cloudflare" {
api_token = var.cloudflare_api_token
}
16 changes: 4 additions & 12 deletions terraform/gcp_cloudrun/r2.tf
Original file line number Diff line number Diff line change
@@ -1,19 +1,11 @@
resource "cloudflare_r2_bucket" "state" {
account_id = var.cloudflare_account_id
name = var.r2_bucket_name
}
# R2 bucket is managed in terraform/shared/ (independent state).
# Cloud Run only uploads openclaw.json to the existing bucket.

# Write openclaw.json to a local temp file (sensitive: content won't appear in
# Terraform plan/apply output or process listings).
resource "local_sensitive_file" "openclaw_json" {
content = local.openclaw_json_content
filename = "${path.module}/.terraform/tmp/openclaw.json"
}

# Upload openclaw.json to R2 via aws CLI (S3-compatible endpoint).
# Reads from the temp file — no secrets are passed as command-line arguments.
# Requires: awscli installed locally.
# rclone-sync sidecar restores it into the container on each startup.
resource "null_resource" "openclaw_json_r2" {
triggers = {
content_hash = md5(local.openclaw_json_content)
Expand All @@ -22,7 +14,7 @@ resource "null_resource" "openclaw_json_r2" {
provisioner "local-exec" {
command = <<-EOT
aws s3 cp "${local_sensitive_file.openclaw_json.filename}" \
"s3://${cloudflare_r2_bucket.state.name}/openclaw.json" \
"s3://${var.r2_bucket_name}/openclaw.json" \
--endpoint-url "https://${var.cloudflare_account_id}.r2.cloudflarestorage.com" \
--content-type "application/json"
EOT
Expand All @@ -33,5 +25,5 @@ resource "null_resource" "openclaw_json_r2" {
}
}

depends_on = [cloudflare_r2_bucket.state, local_sensitive_file.openclaw_json]
depends_on = [local_sensitive_file.openclaw_json]
}
15 changes: 8 additions & 7 deletions terraform/gcp_cloudrun/rclone-sync.sh
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
#!/bin/sh
set -e

# openclaw.json is platform-specific (Cloud Run vs VM have different plugin config).
# Exclude it from all sync operations so platforms can share the same R2 bucket
# for soul/memory/sessions without overwriting each other's config.
RCLONE_EXCLUDE="--exclude openclaw.json --exclude openclaw.json.bak"

# ── Restore from R2 on startup ────────────────────────────────
rclone sync r2:$R2_BUCKET/ /data/ --create-empty-src-dirs 2>/dev/null || true
rclone sync r2:$R2_BUCKET/ /data/ --create-empty-src-dirs $RCLONE_EXCLUDE 2>/dev/null || true
# Fix permissions: rclone runs as root, openclaw runs as node (uid 1000).
# Make all restored files/dirs world-writable so openclaw can write to them.
chmod -R a+rwX /data/ 2>/dev/null || true
touch /tmp/rclone-ready
echo "rclone: initial restore complete"
Expand All @@ -15,16 +19,13 @@ echo "rclone: initial restore complete"
# ── Final sync on shutdown (SIGTERM) ─────────────────────────
cleanup() {
echo "rclone: final sync on shutdown..."
# Use copy (not sync) to avoid deleting files written by other instances
rclone copy /data/ r2:$R2_BUCKET/ 2>/dev/null || true
rclone copy /data/ r2:$R2_BUCKET/ --create-empty-src-dirs $RCLONE_EXCLUDE 2>/dev/null || true
exit 0
}
trap cleanup TERM INT

# ── Periodic sync every 60s ───────────────────────────────────
# Use copy (not sync): sync is destructive and would delete remote files
# written by concurrent instances when max_instances > 1.
while true; do
sleep 60
rclone copy /data/ r2:$R2_BUCKET/ 2>/dev/null || true
rclone copy /data/ r2:$R2_BUCKET/ --create-empty-src-dirs $RCLONE_EXCLUDE 2>/dev/null || true
done
12 changes: 6 additions & 6 deletions terraform/gcp_cloudrun/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ variable "telegram_bot_token" {
sensitive = true
}

variable "telegram_owner_id" {
description = "Telegram numeric user ID for owner-only DM access. Leave empty for open DMs."
type = string
default = ""
}

variable "openclaw_gateway_token" {
type = string
sensitive = true
Expand Down Expand Up @@ -104,12 +110,6 @@ variable "cloudflare_account_id" {
type = string
}

variable "cloudflare_api_token" {
description = "Cloudflare API Token with R2:Edit permission (for Terraform to create bucket)"
type = string
sensitive = true
}

variable "r2_access_key_id" {
description = "R2 S3-compatible Access Key ID (from R2 → Manage R2 API Tokens)"
type = string
Expand Down
4 changes: 4 additions & 0 deletions terraform/gcp_vm/.envrc
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,7 @@ export TF_VAR_brave_api_key="${BRAVE_API_KEY:-}"
export TF_VAR_telegram_owner_id="${TELEGRAM_OWNER_ID:-}"
export TF_VAR_slack_app_token="${SLACK_APP_TOKEN:-}"
export TF_VAR_slack_bot_token="${SLACK_BOT_TOKEN:-}"
export TF_VAR_cloudflare_account_id="${CF_ACCOUNT_ID:-}"
export TF_VAR_r2_access_key_id="${R2_ACCESS_KEY_ID:-}"
export TF_VAR_r2_secret_access_key="${R2_SECRET_ACCESS_KEY:-}"
export TF_VAR_r2_bucket_name="${R2_BUCKET_NAME:-}"
80 changes: 78 additions & 2 deletions terraform/gcp_vm/bootstrap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,30 @@ curl -fsSL https://openclaw.bot/install.sh | bash -s -- --install-method npm --n
npm install -g grammy @grammyjs/runner @grammyjs/transformer-throttler \
@slack/bolt @slack/socket-mode @slack/web-api

# ── 3. Write config ──────────────────────────────────────────
# ── 3. Install rclone + restore from R2 ─────────────────────
%{ if r2_bucket_name != "" ~}
curl https://rclone.org/install.sh | bash

mkdir -p /root/.config/rclone
cat > /root/.config/rclone/rclone.conf << 'RCLONEEOF'
[r2]
type = s3
provider = Cloudflare
access_key_id = ${r2_access_key_id}
secret_access_key = ${r2_secret_access_key}
endpoint = https://${cloudflare_account_id}.r2.cloudflarestorage.com
RCLONEEOF

echo "Restoring OpenClaw state from R2 (${r2_bucket_name})..."
# Exclude openclaw.json: each platform maintains its own config so the same
# R2 bucket can be shared between Cloud Run and Compute Engine for failover.
rclone sync r2:${r2_bucket_name}/ /root/.openclaw/ --create-empty-src-dirs \
--exclude "openclaw.json" --exclude "openclaw.json.bak" 2>/dev/null || true
chmod -R a+rX /root/.openclaw/ 2>/dev/null || true
echo "R2 restore complete"
%{ endif ~}

# ── 4. Write config ──────────────────────────────────────────
mkdir -p /root/.openclaw

# Write injected openclaw.json (provided by Terraform)
Expand All @@ -68,15 +91,22 @@ fi
cat > /root/.openclaw/.env << 'ENVEOF'
OPENROUTER_API_KEY=${openrouter_api_key}
TELEGRAM_BOT_TOKEN=${telegram_bot_token}
TELEGRAM_OWNER_ID=${telegram_owner_id}
OPENCLAW_GATEWAY_TOKEN=${openclaw_gateway_token}
BRAVE_API_KEY=${brave_api_key}
SLACK_APP_TOKEN=${slack_app_token}
SLACK_BOT_TOKEN=${slack_bot_token}
OPENCLAW_ONBOARD_NON_INTERACTIVE=1
ENVEOF
chmod 600 /root/.openclaw/.env

export OPENROUTER_API_KEY=${openrouter_api_key}
export TELEGRAM_BOT_TOKEN=${telegram_bot_token}
export TELEGRAM_OWNER_ID=${telegram_owner_id}
export OPENCLAW_GATEWAY_TOKEN=${openclaw_gateway_token}
export BRAVE_API_KEY=${brave_api_key}
export SLACK_APP_TOKEN=${slack_app_token}
export SLACK_BOT_TOKEN=${slack_bot_token}

# ── 5. Onboard ───────────────────────────────────────────────
openclaw doctor --fix || true
Expand Down Expand Up @@ -120,7 +150,50 @@ OVERRIDEEOF
systemctl --user daemon-reload
systemctl --user restart openclaw-gateway.service

# ── 7. Auto-approve operator.approvals scope ─────────────────
# ── 7. Setup R2 periodic sync ────────────────────────────────
%{ if r2_bucket_name != "" ~}
# Periodic sync service: copies /root/.openclaw → R2 every 60s
cat > /etc/systemd/system/openclaw-r2-sync.service << 'R2SYNCEOF'
[Unit]
Description=OpenClaw R2 state periodic sync
After=network.target

[Service]
Type=simple
Restart=always
RestartSec=5
ExecStart=/bin/sh -c 'while true; do rclone copy /root/.openclaw/ r2:${r2_bucket_name}/ --create-empty-src-dirs --exclude "openclaw.json" --exclude "openclaw.json.bak" 2>/dev/null; sleep 60; done'

[Install]
WantedBy=multi-user.target
R2SYNCEOF

# Final sync on shutdown: ensures no state is lost on VM stop/reboot
cat > /etc/systemd/system/openclaw-r2-final.service << 'R2FINALEOF'
[Unit]
Description=OpenClaw R2 final sync on shutdown
DefaultDependencies=no
Before=shutdown.target reboot.target halt.target
After=network.target

[Service]
Type=oneshot
ExecStart=/usr/bin/rclone copy /root/.openclaw/ r2:${r2_bucket_name}/ --create-empty-src-dirs --exclude "openclaw.json" --exclude "openclaw.json.bak"
TimeoutStartSec=30
RemainAfterExit=yes

[Install]
WantedBy=halt.target reboot.target shutdown.target
R2FINALEOF

systemctl daemon-reload
systemctl enable openclaw-r2-sync.service
systemctl start openclaw-r2-sync.service
systemctl enable openclaw-r2-final.service
echo "R2 sync configured: r2:${r2_bucket_name} (60s interval + shutdown hook)"
%{ endif ~}

# ── 8. Auto-approve operator.approvals scope ─────────────────
echo "Waiting for approval requests..."
sleep 120

Expand Down Expand Up @@ -178,5 +251,8 @@ LOGROTATEEOF
echo "=== GCP Bootstrap Complete ==="
echo "Swap: $${swap_size_gb}GB at $${swap_path}"
echo "Memory Limit: ${openclaw_memory_limit_mb}MB (systemd cgroup)"
%{ if r2_bucket_name != "" ~}
echo "R2 Persistence: r2:${r2_bucket_name} (60s sync + shutdown hook)"
%{ endif ~}
echo "OpenClaw Web: http://localhost:18789/health (test locally)"
echo "Check systemd: systemctl --user status openclaw-gateway"
12 changes: 11 additions & 1 deletion terraform/gcp_vm/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -17,25 +17,35 @@ locals {
if length(regexall(":", cidr)) > 0
]

openclaw_json_content = templatefile("${path.module}/openclaw.json.tpl", {
openclaw_json_content = templatefile("${path.module}/../shared/openclaw.json.tpl", {
openclaw_gateway_token = var.openclaw_gateway_token
openrouter_api_key = var.openrouter_api_key
brave_api_key = var.brave_api_key
telegram_bot_token = var.telegram_bot_token
slack_app_token = var.slack_app_token
slack_bot_token = var.slack_bot_token
slack_enabled = var.slack_app_token != "" && var.slack_bot_token != ""
bonjour_enabled = false # VM: no bonjour plugin needed
use_plugin_load_paths = true # VM: load extensions from npm global path
telegram_owner_id = var.telegram_owner_id
})

bootstrap_vars = {
openrouter_api_key = var.openrouter_api_key
telegram_bot_token = var.telegram_bot_token
telegram_owner_id = var.telegram_owner_id
openclaw_gateway_token = var.openclaw_gateway_token
brave_api_key = var.brave_api_key
slack_app_token = var.slack_app_token
slack_bot_token = var.slack_bot_token
swap_size = var.swap_size
openclaw_memory_limit_mb = var.openclaw_memory_limit_mb
approve_operator_script = file("${path.module}/approve_operator_approvals.py")
openclaw_json_content = local.openclaw_json_content
cloudflare_account_id = var.cloudflare_account_id
r2_access_key_id = var.r2_access_key_id
r2_secret_access_key = var.r2_secret_access_key
r2_bucket_name = var.r2_bucket_name
}
}

Expand Down
Loading
Loading