From 4ff4d1338b697a7a3a4580d762109901263e332a Mon Sep 17 00:00:00 2001 From: PCBZ Date: Tue, 21 Apr 2026 17:44:46 -0700 Subject: [PATCH 1/4] Add Terraform configuration for Azure B1s deployment and OpenClaw setup - Create .envrc for environment variable management - Implement bootstrap.sh for VM setup and OpenClaw installation - Define main.tf for Azure resources including VM, networking, and security groups - Add openclaw.json.tpl for OpenClaw configuration - Set up outputs.tf for VM details and health check URL - Introduce poll_b1s_availability.sh for SKU availability checks - Configure provider.tf and variables.tf for Azure authentication and resource parameters --- terraform/azure_b1ms/.envrc | 12 + terraform/azure_b1ms/bootstrap.sh | 229 ++++++++++++++++++ terraform/azure_b1ms/main.tf | 191 +++++++++++++++ terraform/azure_b1ms/openclaw.json.tpl | 90 +++++++ terraform/azure_b1ms/outputs.tf | 24 ++ terraform/azure_b1ms/poll_b1s_availability.sh | 125 ++++++++++ terraform/azure_b1ms/provider.tf | 18 ++ terraform/azure_b1ms/variables.tf | 129 ++++++++++ 8 files changed, 818 insertions(+) create mode 100644 terraform/azure_b1ms/.envrc create mode 100644 terraform/azure_b1ms/bootstrap.sh create mode 100644 terraform/azure_b1ms/main.tf create mode 100644 terraform/azure_b1ms/openclaw.json.tpl create mode 100644 terraform/azure_b1ms/outputs.tf create mode 100644 terraform/azure_b1ms/poll_b1s_availability.sh create mode 100644 terraform/azure_b1ms/provider.tf create mode 100644 terraform/azure_b1ms/variables.tf diff --git a/terraform/azure_b1ms/.envrc b/terraform/azure_b1ms/.envrc new file mode 100644 index 0000000..5707998 --- /dev/null +++ b/terraform/azure_b1ms/.envrc @@ -0,0 +1,12 @@ +# Auto-load secrets from .env into Terraform variables +# Requires: brew install direnv && eval "$(direnv hook zsh)" >> ~/.zshrc + +dotenv ../../.env + +export TF_VAR_openrouter_api_key="$OPENROUTER_API_KEY" +export TF_VAR_telegram_bot_token="$TELEGRAM_BOT_TOKEN" +export TF_VAR_openclaw_gateway_token="$OPENCLAW_GATEWAY_TOKEN" +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:-}" diff --git a/terraform/azure_b1ms/bootstrap.sh b/terraform/azure_b1ms/bootstrap.sh new file mode 100644 index 0000000..ce658f0 --- /dev/null +++ b/terraform/azure_b1ms/bootstrap.sh @@ -0,0 +1,229 @@ +#!/bin/bash +set -e + +export HOME=/root +export USER=root +export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +# ── 1. System setup ────────────────────────────────────────── +apt-get update -y + +# ── Create swap with temp-disk fallback ────────────────────── +# Some VM sizes (especially Arm variants) may not expose /mnt/resource. +# Prefer temp disk when present, otherwise fall back to root disk. +swap_size_gb=${swap_size} +swap_size_mb=$((swap_size_gb * 1024)) +swap_path="/mnt/resource/swapfile" +if [ ! -d "/mnt/resource" ]; then + swap_path="/swapfile" +fi + +echo "Setting up $${swap_size_gb}GB swap at $${swap_path}..." +if dd if=/dev/zero of="$${swap_path}" bs=1M count="$${swap_size_mb}"; then + chmod 600 "$${swap_path}" + mkswap "$${swap_path}" + swapon "$${swap_path}" + if ! grep -q "$${swap_path} none swap" /etc/fstab; then + echo "$${swap_path} none swap sw 0 0" >> /etc/fstab + fi +else + echo "WARNING: Swap creation failed; continuing without swap." +fi + +# ── Kernel swappiness tuning ───────────────────────────────── +# Lower swappiness = prioritize RAM, use swap only when necessary +# Prevents excessive swap thrashing when memory is tight +cat > /etc/sysctl.d/99-swappiness.conf << 'SYSCTLEOF' +vm.swappiness = 20 +vm.overcommit_memory = 1 +SYSCTLEOF +sysctl -p /etc/sysctl.d/99-swappiness.conf + +# ── Install Node.js 24.x ──────────────────────────────────── +curl -fsSL https://deb.nodesource.com/setup_24.x | bash - +apt-get install -y nodejs + +# ── 2. Install OpenClaw ────────────────────────────────────── +export OPENCLAW_ONBOARD_NON_INTERACTIVE=1 +export OPENCLAW_INSTALL_METHOD=npm +curl -fsSL https://openclaw.bot/install.sh | bash -s -- --install-method npm --no-onboard + +npm install -g grammy @grammyjs/runner @grammyjs/transformer-throttler \ + @slack/bolt @slack/socket-mode @slack/web-api + +# ── 3. Write config ────────────────────────────────────────── +mkdir -p /root/.openclaw + +# Write injected openclaw.json (provided by Terraform) +cat > /root/.openclaw/openclaw.json << 'JSONEOF' +${openclaw_json_content} +JSONEOF + +mkdir -p /root/.openclaw/workspace +if ! grep -q "ALWAYS_REPLY_IN_DM" /root/.openclaw/workspace/AGENTS.md 2>/dev/null; then +cat >> /root/.openclaw/workspace/AGENTS.md << 'AGENTSEOF' + +## Channel Output Rule (OpenClaw) + +- ALWAYS_REPLY_IN_DM: For any direct message on Telegram/Slack, always send at least one plain-text assistant message. +- Never end a DM turn with tool calls only, empty payload, or metadata-only output. +- If uncertain, send a brief fallback text: "I can help with that. Could you share a bit more detail?" +AGENTSEOF +fi + +# ── 4. Write .env and export secrets ───────────────────────── +cat > /root/.openclaw/.env << 'ENVEOF' +OPENROUTER_API_KEY=${openrouter_api_key} +TELEGRAM_BOT_TOKEN=${telegram_bot_token} +OPENCLAW_GATEWAY_TOKEN=${openclaw_gateway_token} +BRAVE_API_KEY=${brave_api_key} +OPENCLAW_ONBOARD_NON_INTERACTIVE=1 +ENVEOF + +export OPENROUTER_API_KEY=${openrouter_api_key} +export TELEGRAM_BOT_TOKEN=${telegram_bot_token} +export OPENCLAW_GATEWAY_TOKEN=${openclaw_gateway_token} +export BRAVE_API_KEY=${brave_api_key} + +# ── 5. Onboard ─────────────────────────────────────────────── +openclaw doctor --fix || true + +loginctl enable-linger root +export XDG_RUNTIME_DIR=/run/user/0 +mkdir -p "$XDG_RUNTIME_DIR" +systemctl start user@0.service || true + +openclaw onboard --non-interactive --accept-risk --install-daemon || true + +# Fix models.json baseUrl (onboard may have written wrong /v1 instead of /api/v1) +MODELS_JSON=/root/.openclaw/agents/main/agent/models.json +if [ -f "$MODELS_JSON" ]; then + sed -i 's|https://openrouter.ai/v1|https://openrouter.ai/api/v1|g' "$MODELS_JSON" + echo "Fixed models.json baseUrl: /v1 -> /api/v1" +fi + +# ── Write agent auth-profiles (OpenRouter key) ─────────────── +mkdir -p /root/.openclaw/agents/main/agent +cat > /root/.openclaw/agents/main/agent/auth-profiles.json << 'AUTHEOF' +{ + "openrouter": { + "apiKey": "${openrouter_api_key}" + } +} +AUTHEOF + +openclaw gateway install --force + +# ── 6. Create systemd service override with memory limits ──── +mkdir -p /root/.config/systemd/user/openclaw-gateway.service.d +cat > /root/.config/systemd/user/openclaw-gateway.service.d/override.conf << 'OVERRIDEEOF' +[Service] +TimeoutStartSec=180 +TimeoutStopSec=60 +RestartSec=5 +MemoryLimit=${openclaw_memory_limit_mb}M +OVERRIDEEOF + +systemctl --user daemon-reload +systemctl --user restart openclaw-gateway.service + +# ── 7. Auto-approve operator.approvals scope ───────────────── +echo "Waiting for approval requests..." +sleep 120 + +python3 << 'PYEOF' +import json, sys, time + +PENDING_FILE = "/root/.openclaw/devices/pending.json" +PAIRED_FILE = "/root/.openclaw/devices/paired.json" + +pending = {} +for _ in range(12): + try: + with open(PENDING_FILE) as f: + pending = json.load(f) + if pending: + break + except Exception: + pass + time.sleep(5) + +# Even if no pending requests, approve operator.approvals for all paired devices. +try: + with open(PAIRED_FILE) as f: + paired = json.load(f) +except Exception: + paired = {} + +for request in pending.values(): + device_id = request.get("deviceId") + if device_id in paired: + for key in ("scopes", "approvedScopes"): + if "operator.approvals" not in paired[device_id].get(key, []): + paired[device_id].setdefault(key, []).append("operator.approvals") + print(f"Approved via pending: {device_id}") + +# Ensure all paired operator devices have operator.approvals regardless of pending state +for device_id, device in paired.items(): + if "operator" in device.get("roles", []): + for key in ("scopes", "approvedScopes"): + if "operator.approvals" not in device.get(key, []): + device.setdefault(key, []).append("operator.approvals") + print(f"Granted operator.approvals to {device_id[:16]}...") + +if paired: + with open(PAIRED_FILE, "w") as f: + json.dump(paired, f, indent=2) +if pending: + with open(PENDING_FILE, "w") as f: + json.dump({}, f) +PYEOF + +# ── 8. Setup unattended security updates ───────────────────── +apt-get install -y unattended-upgrades + +cat > /etc/apt/apt.conf.d/50unattended-upgrades << 'UNATTENDEDEOF' +Unattended-Upgrade::Allowed-Origins { + "$${distro_id}:$${distro_codename}-security"; +}; +Unattended-Upgrade::AutoFixInterruptedDpkg "true"; +Unattended-Upgrade::MinimalSteps "true"; +Unattended-Upgrade::Remove-Unused-Kernel-Packages "true"; +Unattended-Upgrade::Remove-Unused-Dependencies "true"; +Unattended-Upgrade::Automatic-Reboot "true"; +Unattended-Upgrade::Automatic-Reboot-Time "03:00"; +UNATTENDEDEOF + +# ── 9. Setup cleanup cron jobs ─────────────────────────────── +cat > /etc/cron.d/openclaw-cleanup << 'CRONEOF' +# OpenClaw cleanup and maintenance tasks +SHELL=/bin/bash +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +# Weekly npm cache cleanup (Sunday 2AM UTC) +0 2 * * 0 root npm cache clean --force >> /var/log/openclaw-cleanup.log 2>&1 + +# Daily disk space logging (1AM UTC) +0 1 * * * root df -h > /var/log/openclaw-diskspace.log 2>&1 +CRONEOF + +# ── 10. Setup logrotate for OpenClaw logs ──────────────────── +mkdir -p /var/log/openclaw + +cat > /etc/logrotate.d/openclaw << 'LOGROTATEEOF' +/var/log/openclaw-*.log { + daily + rotate 7 + compress + delaycompress + notifempty + create 0644 root root + missingok +} +LOGROTATEEOF + +echo "=== Azure Bootstrap Complete ===" +echo "Swap: $${swap_size_gb}GB at $${swap_path}" +echo "Memory Limit: ${openclaw_memory_limit_mb}MB (systemd cgroup)" +echo "OpenClaw Web: http://localhost:18789/health (test locally)" +echo "Check systemd: systemctl --user status openclaw-gateway" diff --git a/terraform/azure_b1ms/main.tf b/terraform/azure_b1ms/main.tf new file mode 100644 index 0000000..804a055 --- /dev/null +++ b/terraform/azure_b1ms/main.tf @@ -0,0 +1,191 @@ +# ── Resource Group (must already exist) ────────────────────── +data "azurerm_resource_group" "main" { + name = var.resource_group_name +} + +# ── Virtual Network and Subnet ─────────────────────────────── +resource "azurerm_virtual_network" "main" { + name = "${var.vm_name}-vnet" + address_space = ["10.0.0.0/16"] + location = var.location + resource_group_name = data.azurerm_resource_group.main.name +} + +resource "azurerm_subnet" "internal" { + name = "${var.vm_name}-subnet" + resource_group_name = data.azurerm_resource_group.main.name + virtual_network_name = azurerm_virtual_network.main.name + address_prefixes = ["10.0.2.0/24"] +} + +# ── Network Interface (NIC) ────────────────────────────────── +resource "azurerm_network_interface" "main" { + name = "${var.vm_name}-nic" + location = var.location + resource_group_name = data.azurerm_resource_group.main.name + + ip_configuration { + name = "testconfiguration1" + subnet_id = azurerm_subnet.internal.id + private_ip_address_allocation = "Dynamic" + public_ip_address_id = azurerm_public_ip.main.id + } +} + +# ── Static Public IP ───────────────────────────────────────── +resource "azurerm_public_ip" "main" { + name = var.public_ip_name + location = var.location + resource_group_name = data.azurerm_resource_group.main.name + allocation_method = "Static" + sku = "Standard" +} + +# ── Network Security Group (NSG) ──────────────────────────── +resource "azurerm_network_security_group" "main" { + name = "${var.vm_name}-nsg" + location = var.location + resource_group_name = data.azurerm_resource_group.main.name + + # SSH access (port 22) - IPv4 + security_rule { + name = "AllowSSH_IPv4" + priority = 100 + direction = "Inbound" + access = "Allow" + protocol = "Tcp" + source_port_range = "*" + destination_port_range = "22" + source_address_prefix = "0.0.0.0/0" + destination_address_prefix = "*" + } + + # SSH access (port 22) - IPv6 + security_rule { + name = "AllowSSH_IPv6" + priority = 101 + direction = "Inbound" + access = "Allow" + protocol = "Tcp" + source_port_range = "*" + destination_port_range = "22" + source_address_prefix = "::/0" + destination_address_prefix = "::/0" + } + + # OpenClaw Gateway access (port 18789) - IPv4 + security_rule { + name = "AllowOpenClawGateway_IPv4" + priority = 102 + direction = "Inbound" + access = "Allow" + protocol = "Tcp" + source_port_range = "*" + destination_port_range = "18789" + source_address_prefix = "0.0.0.0/0" + destination_address_prefix = "*" + } + + # OpenClaw Gateway access (port 18789) - IPv6 + security_rule { + name = "AllowOpenClawGateway_IPv6" + priority = 103 + direction = "Inbound" + access = "Allow" + protocol = "Tcp" + source_port_range = "*" + destination_port_range = "18789" + source_address_prefix = "::/0" + destination_address_prefix = "::/0" + } + + # Deny all other inbound + security_rule { + name = "DenyAllInbound" + priority = 4096 + direction = "Inbound" + access = "Deny" + protocol = "*" + source_port_range = "*" + destination_port_range = "*" + source_address_prefix = "*" + destination_address_prefix = "*" + } + + # Allow all outbound + security_rule { + name = "AllowAllOutbound" + priority = 100 + direction = "Outbound" + access = "Allow" + protocol = "*" + source_port_range = "*" + destination_port_range = "*" + source_address_prefix = "*" + destination_address_prefix = "*" + } +} + +# ── Associate NSG with NIC ─────────────────────────────────── +resource "azurerm_network_interface_security_group_association" "main" { + network_interface_id = azurerm_network_interface.main.id + network_security_group_id = azurerm_network_security_group.main.id +} + +# ── B1s Virtual Machine ────────────────────────────────────── +resource "azurerm_linux_virtual_machine" "main" { + name = var.vm_name + location = var.location + resource_group_name = data.azurerm_resource_group.main.name + size = var.vm_size + + admin_username = var.admin_username + + admin_ssh_key { + username = var.admin_username + public_key = file(var.ssh_public_key_path) + } + + network_interface_ids = [ + azurerm_network_interface.main.id, + ] + + os_disk { + caching = "ReadWrite" + storage_account_type = "Standard_LRS" + disk_size_gb = var.os_disk_size_gb + } + + source_image_reference { + publisher = "Canonical" + offer = "0001-com-ubuntu-server-jammy" + sku = "22_04-lts-arm64" + version = "latest" + } + + # Temp disk is automatically allocated for B1s (4GB at /mnt/resource) + # No explicit configuration needed, but ensure it's not disabled by default + + custom_data = base64encode(templatefile("${path.module}/bootstrap.sh", { + openrouter_api_key = var.openrouter_api_key + telegram_bot_token = var.telegram_bot_token + openclaw_gateway_token = var.openclaw_gateway_token + brave_api_key = var.brave_api_key + swap_size = var.swap_size + openclaw_memory_limit_mb = var.openclaw_memory_limit_mb + openclaw_json_content = templatefile("${path.module}/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 != "" + }) + })) + + tags = { + Environment = "Production" + Application = "OpenClaw" + } +} diff --git a/terraform/azure_b1ms/openclaw.json.tpl b/terraform/azure_b1ms/openclaw.json.tpl new file mode 100644 index 0000000..bb53bcf --- /dev/null +++ b/terraform/azure_b1ms/openclaw.json.tpl @@ -0,0 +1,90 @@ +{ + "gateway": { + "bind": "lan", + "auth": { "mode": "token", "token": "${openclaw_gateway_token}" }, + "mode": "local", + "remote": { "token": "${openclaw_gateway_token}" } + }, + "agents": { + "defaults": { + "model": { + "primary": "openrouter/openai/gpt-4o-mini", + "fallbacks": [ + "openrouter/anthropic/claude-haiku-4.5", + "openrouter/meta-llama/llama-3.3-70b-instruct:free", + "openrouter/auto" + ] + }, + "models": { + "openrouter/anthropic/claude-opus-4.6": {"alias": "opus"}, + "openrouter/anthropic/claude-sonnet-4.6": {"alias": "sonnet"}, + "openrouter/anthropic/claude-haiku-4.5": {"alias": "haiku"}, + "openrouter/openai/gpt-5.4": {"alias": "gpt5"}, + "openrouter/openai/gpt-4o": {"alias": "gpt4o"}, + "openrouter/openai/gpt-4o-mini": {"alias": "mini"}, + "openrouter/google/gemini-2.5-pro": {"alias": "gemini-pro"}, + "openrouter/google/gemini-2.5-flash": {"alias": "flash"}, + "openrouter/deepseek/deepseek-r1": {"alias": "r1"}, + "openrouter/mistralai/devstral-small": {"alias": "devstral"}, + "openrouter/meta-llama/llama-3.3-70b-instruct:free": {"alias": "llama"}, + "openrouter/nvidia/nemotron-3-super-120b-a12b:free": {"alias": "nemotron"}, + "openrouter/qwen/qwen3-coder:free": {"alias": "coder"}, + "openrouter/cognitivecomputations/dolphin-mistral-24b-venice-edition:free": {"alias": "uncensored"}, + "openrouter/auto": {"alias": "auto"} + }, + "compaction": { "mode": "safeguard", "reserveTokensFloor": 4000 } + } + }, + "tools": { + "web": { + "search": { "enabled": true, "provider": "brave" }, + "fetch": { + "enabled": true, + "strip_images": true, + "strip_videos": true, + "strip_css": true, + "strip_fonts": true + } + }, + "deny": ["browser"] + }, + "plugins": { + "load": { + "paths": [ + "/usr/lib/node_modules/openclaw/dist/extensions/telegram"%{ if slack_enabled }, + "/usr/lib/node_modules/openclaw/dist/extensions/slack"%{ endif } + ] + }, + "entries": { + "telegram": { "enabled": true }, +%{ if slack_enabled } + "slack": { "enabled": true }, +%{ endif } + "openrouter": { "enabled": true }, + "brave": { + "enabled": true, + "config": { "webSearch": { "apiKey": "${brave_api_key}" } } + } + } + }, + "channels": { + "telegram": { + "enabled": true, + "accounts": { + "default": { + "botToken": "${telegram_bot_token}", + "dmPolicy": "open", + "groupPolicy": "open" + } + } + }%{ if slack_enabled }, + "slack": { + "enabled": true, + "mode": "socket", + "appToken": "${slack_app_token}", + "botToken": "${slack_bot_token}", + "dmPolicy": "open", + "groupPolicy": "open" + }%{ endif } + } +} diff --git a/terraform/azure_b1ms/outputs.tf b/terraform/azure_b1ms/outputs.tf new file mode 100644 index 0000000..4a31552 --- /dev/null +++ b/terraform/azure_b1ms/outputs.tf @@ -0,0 +1,24 @@ +output "vm_public_ip" { + description = "Public IP address of the B1s VM (use this for SSH)" + value = azurerm_public_ip.main.ip_address +} + +output "vm_name" { + description = "Name of the B1s VM" + value = azurerm_linux_virtual_machine.main.name +} + +output "vm_private_ip" { + description = "Private IP address of the B1s VM" + value = azurerm_network_interface.main.private_ip_address +} + +output "ssh_command" { + description = "SSH command to connect to the B1s VM" + value = "ssh ${var.admin_username}@${azurerm_public_ip.main.ip_address}" +} + +output "openclaw_gateway_url" { + description = "OpenClaw gateway health check URL (after bootstrap completes)" + value = "http://${azurerm_public_ip.main.ip_address}:18789/health" +} diff --git a/terraform/azure_b1ms/poll_b1s_availability.sh b/terraform/azure_b1ms/poll_b1s_availability.sh new file mode 100644 index 0000000..0704e14 --- /dev/null +++ b/terraform/azure_b1ms/poll_b1s_availability.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SKU="Standard_B2pts_v2" +REGION="centralus" +INTERVAL_SECONDS=7200 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +usage() { + cat <<'EOF' +Usage: + ./poll_b1s_availability.sh [options] + +Options: + -s, --sku VM SKU to check (default: Standard_B2pts_v2) + -r, --region Region to check and apply (default: centralus) + -i, --interval Poll interval seconds (default: 7200) + -h, --help Show help + +Examples: + ./poll_b1s_availability.sh + ./poll_b1s_availability.sh -r eastus -i 7200 +EOF +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + -s|--sku) + SKU="$2" + shift 2 + ;; + -r|--region) + REGION="$2" + shift 2 + ;; + -i|--interval) + INTERVAL_SECONDS="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 1 + ;; + esac + done +} + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Required command not found: $1" >&2 + exit 1 + fi +} + +check_region() { + local region="$1" + local reasons + + reasons=$(az vm list-skus \ + --location "$region" \ + --resource-type virtualMachines \ + --all \ + --query "[?name=='${SKU}'].restrictions[].reasonCode" \ + -o tsv 2>/dev/null | tr '\n' ',' | sed 's/,$//') + + if [[ -z "$reasons" ]]; then + echo "AVAILABLE" + return + fi + + if [[ "$reasons" == *"NotAvailableForSubscription"* ]]; then + echo "BLOCKED_BY_SUBSCRIPTION ($reasons)" + return + fi + + echo "RESTRICTED ($reasons)" +} + +run_apply_once() { + set +e + terraform apply --auto-approve -var "location=${REGION}" -var "vm_size=${SKU}" + local rc=$? + set -e + return "$rc" +} + +main() { + require_cmd az + require_cmd terraform + parse_args "$@" + cd "$SCRIPT_DIR" + + echo "Checking SKU: $SKU" + echo "Region: $REGION" + echo "Interval: ${INTERVAL_SECONDS}s" + + while true; do + local now + local status + now=$(date '+%Y-%m-%d %H:%M:%S') + echo + echo "[$now] Checking ${REGION} then running terraform apply..." + + status=$(check_region "$REGION") + echo " ${REGION}: ${status}" + + if run_apply_once; then + echo "Terraform apply succeeded. Exiting." + exit 0 + fi + + echo "Terraform apply failed. Will retry in ${INTERVAL_SECONDS}s." + + sleep "$INTERVAL_SECONDS" + done +} + +main "$@" diff --git a/terraform/azure_b1ms/provider.tf b/terraform/azure_b1ms/provider.tf new file mode 100644 index 0000000..e08aa1e --- /dev/null +++ b/terraform/azure_b1ms/provider.tf @@ -0,0 +1,18 @@ +terraform { + required_version = ">= 1.0" + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" + } + } +} + +provider "azurerm" { + features {} + + subscription_id = var.subscription_id + client_id = var.client_id + client_secret = var.client_secret + tenant_id = var.tenant_id +} diff --git a/terraform/azure_b1ms/variables.tf b/terraform/azure_b1ms/variables.tf new file mode 100644 index 0000000..9d36499 --- /dev/null +++ b/terraform/azure_b1ms/variables.tf @@ -0,0 +1,129 @@ +# ── Azure Authentication ──────────────────────────────────── +# These come from terraform.tfvars +variable "subscription_id" { + sensitive = true +} + +variable "client_id" { + sensitive = true +} + +variable "client_secret" { + sensitive = true +} + +variable "tenant_id" { + sensitive = true +} + +# ── Azure Resource Group ──────────────────────────────────── +variable "resource_group_name" { + description = "Azure resource group name (must already exist)" + type = string +} + +variable "location" { + description = "Azure region (e.g., eastus, canadaeast, westus)" + type = string +} + +# ── B1s VM Configuration ──────────────────────────────────── +variable "vm_name" { + description = "Name of the B1s VM" + type = string + default = "openclaw-b1s" +} + +variable "vm_size" { + description = "Azure VM SKU (B1s = free tier: 1vCPU, 1GB RAM)" + type = string + default = "Standard_B1s" +} + +variable "os_disk_size_gb" { + description = "OS disk size in GB (B1s includes 4GB temp disk at /mnt/resource)" + type = number + default = 30 +} + +# ── VM OS Configuration ──────────────────────────────────── +variable "admin_username" { + description = "VM admin username" + type = string + default = "azureuser" +} + +variable "ssh_public_key_path" { + description = "Path to SSH public key (~/.ssh/id_rsa.pub)" + type = string + default = "~/.ssh/id_rsa.pub" +} + +# ── Network Security Group (NSG) ──────────────────────────── +variable "ssh_allowed_cidrs" { + description = "CIDR blocks allowed SSH access (port 22)" + type = list(string) + default = ["0.0.0.0/0", "::/0"] +} + +variable "gateway_allowed_cidrs" { + description = "CIDR blocks allowed OpenClaw gateway access (port 18789)" + type = list(string) + default = ["0.0.0.0/0", "::/0"] +} + +# ── Static Public IP ──────────────────────────────────────── +variable "public_ip_name" { + description = "Name of static public IP" + type = string + default = "openclaw-b1s-public-ip" +} + +# ── OpenClaw Configuration ───────────────────────────────── +variable "swap_size" { + description = "Swap file size in GB (allocate from 4GB temp disk at /mnt/resource)" + type = number + default = 3 +} + +variable "openclaw_memory_limit_mb" { + description = "Hard memory limit for OpenClaw systemd service (MB)" + type = number + default = 800 +} + +# ── Secrets (from .env via .envrc) ───────────────────────── +variable "openrouter_api_key" { + sensitive = true +} + +variable "telegram_bot_token" { + sensitive = true +} + +variable "openclaw_gateway_token" { + sensitive = true +} + +variable "brave_api_key" { + description = "Brave Search API key (free tier: 1000 req/month). Leave empty to use DuckDuckGo fallback." + sensitive = true + default = "" +} + +variable "telegram_owner_id" { + description = "Your Telegram numeric user ID (get it from @userinfobot). Grants /model and other privileged commands." + default = "" +} + +variable "slack_app_token" { + description = "Slack App-Level Token for Socket Mode connection (starts with 'xapp-')" + sensitive = true + default = "" +} + +variable "slack_bot_token" { + description = "Slack Bot User OAuth Token for sending messages (starts with 'xoxb-')" + sensitive = true + default = "" +} From 60a75c71b9859db649d79199099a2b220f67ef18 Mon Sep 17 00:00:00 2001 From: PCBZ Date: Tue, 21 Apr 2026 22:02:13 -0700 Subject: [PATCH 2/4] Refactor Azure Terraform configuration to migrate from B1s to B2pts_v2 VM - Deleted old configuration files related to B1s VM including openclaw.json.tpl, outputs.tf, poll_b1s_availability.sh, provider.tf, and variables.tf. - Added new configuration files for B2pts_v2 VM including main.tf, outputs.tf, provider.tf, and variables.tf. - Updated bootstrap script to accommodate new VM specifications and configurations. - Introduced .envrc for environment variable management. - Adjusted OpenClaw configuration to reflect changes in VM type and resource allocation. --- terraform/azure_b1ms/poll_b1s_availability.sh | 125 ------------------ terraform/{azure_b1ms => azure_vm}/.envrc | 0 .../{azure_b1ms => azure_vm}/bootstrap.sh | 4 - terraform/{azure_b1ms => azure_vm}/main.tf | 0 .../openclaw.json.tpl | 0 terraform/{azure_b1ms => azure_vm}/outputs.tf | 8 +- .../{azure_b1ms => azure_vm}/provider.tf | 0 .../{azure_b1ms => azure_vm}/variables.tf | 18 +-- 8 files changed, 13 insertions(+), 142 deletions(-) delete mode 100644 terraform/azure_b1ms/poll_b1s_availability.sh rename terraform/{azure_b1ms => azure_vm}/.envrc (100%) rename terraform/{azure_b1ms => azure_vm}/bootstrap.sh (96%) rename terraform/{azure_b1ms => azure_vm}/main.tf (100%) rename terraform/{azure_b1ms => azure_vm}/openclaw.json.tpl (100%) rename terraform/{azure_b1ms => azure_vm}/outputs.tf (71%) rename terraform/{azure_b1ms => azure_vm}/provider.tf (100%) rename terraform/{azure_b1ms => azure_vm}/variables.tf (86%) diff --git a/terraform/azure_b1ms/poll_b1s_availability.sh b/terraform/azure_b1ms/poll_b1s_availability.sh deleted file mode 100644 index 0704e14..0000000 --- a/terraform/azure_b1ms/poll_b1s_availability.sh +++ /dev/null @@ -1,125 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -SKU="Standard_B2pts_v2" -REGION="centralus" -INTERVAL_SECONDS=7200 -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -usage() { - cat <<'EOF' -Usage: - ./poll_b1s_availability.sh [options] - -Options: - -s, --sku VM SKU to check (default: Standard_B2pts_v2) - -r, --region Region to check and apply (default: centralus) - -i, --interval Poll interval seconds (default: 7200) - -h, --help Show help - -Examples: - ./poll_b1s_availability.sh - ./poll_b1s_availability.sh -r eastus -i 7200 -EOF -} - -parse_args() { - while [[ $# -gt 0 ]]; do - case "$1" in - -s|--sku) - SKU="$2" - shift 2 - ;; - -r|--region) - REGION="$2" - shift 2 - ;; - -i|--interval) - INTERVAL_SECONDS="$2" - shift 2 - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "Unknown argument: $1" >&2 - usage - exit 1 - ;; - esac - done -} - -require_cmd() { - if ! command -v "$1" >/dev/null 2>&1; then - echo "Required command not found: $1" >&2 - exit 1 - fi -} - -check_region() { - local region="$1" - local reasons - - reasons=$(az vm list-skus \ - --location "$region" \ - --resource-type virtualMachines \ - --all \ - --query "[?name=='${SKU}'].restrictions[].reasonCode" \ - -o tsv 2>/dev/null | tr '\n' ',' | sed 's/,$//') - - if [[ -z "$reasons" ]]; then - echo "AVAILABLE" - return - fi - - if [[ "$reasons" == *"NotAvailableForSubscription"* ]]; then - echo "BLOCKED_BY_SUBSCRIPTION ($reasons)" - return - fi - - echo "RESTRICTED ($reasons)" -} - -run_apply_once() { - set +e - terraform apply --auto-approve -var "location=${REGION}" -var "vm_size=${SKU}" - local rc=$? - set -e - return "$rc" -} - -main() { - require_cmd az - require_cmd terraform - parse_args "$@" - cd "$SCRIPT_DIR" - - echo "Checking SKU: $SKU" - echo "Region: $REGION" - echo "Interval: ${INTERVAL_SECONDS}s" - - while true; do - local now - local status - now=$(date '+%Y-%m-%d %H:%M:%S') - echo - echo "[$now] Checking ${REGION} then running terraform apply..." - - status=$(check_region "$REGION") - echo " ${REGION}: ${status}" - - if run_apply_once; then - echo "Terraform apply succeeded. Exiting." - exit 0 - fi - - echo "Terraform apply failed. Will retry in ${INTERVAL_SECONDS}s." - - sleep "$INTERVAL_SECONDS" - done -} - -main "$@" diff --git a/terraform/azure_b1ms/.envrc b/terraform/azure_vm/.envrc similarity index 100% rename from terraform/azure_b1ms/.envrc rename to terraform/azure_vm/.envrc diff --git a/terraform/azure_b1ms/bootstrap.sh b/terraform/azure_vm/bootstrap.sh similarity index 96% rename from terraform/azure_b1ms/bootstrap.sh rename to terraform/azure_vm/bootstrap.sh index ce658f0..8b07c4e 100644 --- a/terraform/azure_b1ms/bootstrap.sh +++ b/terraform/azure_vm/bootstrap.sh @@ -9,8 +9,6 @@ export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin apt-get update -y # ── Create swap with temp-disk fallback ────────────────────── -# Some VM sizes (especially Arm variants) may not expose /mnt/resource. -# Prefer temp disk when present, otherwise fall back to root disk. swap_size_gb=${swap_size} swap_size_mb=$((swap_size_gb * 1024)) swap_path="/mnt/resource/swapfile" @@ -31,8 +29,6 @@ else fi # ── Kernel swappiness tuning ───────────────────────────────── -# Lower swappiness = prioritize RAM, use swap only when necessary -# Prevents excessive swap thrashing when memory is tight cat > /etc/sysctl.d/99-swappiness.conf << 'SYSCTLEOF' vm.swappiness = 20 vm.overcommit_memory = 1 diff --git a/terraform/azure_b1ms/main.tf b/terraform/azure_vm/main.tf similarity index 100% rename from terraform/azure_b1ms/main.tf rename to terraform/azure_vm/main.tf diff --git a/terraform/azure_b1ms/openclaw.json.tpl b/terraform/azure_vm/openclaw.json.tpl similarity index 100% rename from terraform/azure_b1ms/openclaw.json.tpl rename to terraform/azure_vm/openclaw.json.tpl diff --git a/terraform/azure_b1ms/outputs.tf b/terraform/azure_vm/outputs.tf similarity index 71% rename from terraform/azure_b1ms/outputs.tf rename to terraform/azure_vm/outputs.tf index 4a31552..04e3ed1 100644 --- a/terraform/azure_b1ms/outputs.tf +++ b/terraform/azure_vm/outputs.tf @@ -1,20 +1,20 @@ output "vm_public_ip" { - description = "Public IP address of the B1s VM (use this for SSH)" + description = "Public IP address of the VM (use this for SSH)" value = azurerm_public_ip.main.ip_address } output "vm_name" { - description = "Name of the B1s VM" + description = "Name of the VM" value = azurerm_linux_virtual_machine.main.name } output "vm_private_ip" { - description = "Private IP address of the B1s VM" + description = "Private IP address of the VM" value = azurerm_network_interface.main.private_ip_address } output "ssh_command" { - description = "SSH command to connect to the B1s VM" + description = "SSH command to connect to the VM" value = "ssh ${var.admin_username}@${azurerm_public_ip.main.ip_address}" } diff --git a/terraform/azure_b1ms/provider.tf b/terraform/azure_vm/provider.tf similarity index 100% rename from terraform/azure_b1ms/provider.tf rename to terraform/azure_vm/provider.tf diff --git a/terraform/azure_b1ms/variables.tf b/terraform/azure_vm/variables.tf similarity index 86% rename from terraform/azure_b1ms/variables.tf rename to terraform/azure_vm/variables.tf index 9d36499..6cc5803 100644 --- a/terraform/azure_b1ms/variables.tf +++ b/terraform/azure_vm/variables.tf @@ -27,21 +27,21 @@ variable "location" { type = string } -# ── B1s VM Configuration ──────────────────────────────────── +# ── VM Configuration ──────────────────────────────────────── variable "vm_name" { - description = "Name of the B1s VM" + description = "Name of the VM" type = string - default = "openclaw-b1s" + default = "openclaw-b2pts" } variable "vm_size" { - description = "Azure VM SKU (B1s = free tier: 1vCPU, 1GB RAM)" + description = "Azure VM SKU" type = string - default = "Standard_B1s" + default = "Standard_B2pts_v2" } variable "os_disk_size_gb" { - description = "OS disk size in GB (B1s includes 4GB temp disk at /mnt/resource)" + description = "OS disk size in GB" type = number default = 30 } @@ -76,14 +76,14 @@ variable "gateway_allowed_cidrs" { variable "public_ip_name" { description = "Name of static public IP" type = string - default = "openclaw-b1s-public-ip" + default = "openclaw-b2pts-public-ip" } # ── OpenClaw Configuration ───────────────────────────────── variable "swap_size" { - description = "Swap file size in GB (allocate from 4GB temp disk at /mnt/resource)" + description = "Swap file size in GB" type = number - default = 3 + default = 2 } variable "openclaw_memory_limit_mb" { From a1a5ae97202cec3a311e189f563f9d37cfe95747 Mon Sep 17 00:00:00 2001 From: PCBZ Date: Wed, 22 Apr 2026 19:08:15 -0700 Subject: [PATCH 3/4] Refactor bootstrap script and update Terraform configuration for OpenClaw - Removed deprecated inline Python code for approving operator approvals and replaced it with a separate script. - Updated the bootstrap script to create and execute the new approval script. - Adjusted cron job timings to reflect Pacific Time. - Cleaned up main.tf by removing old Azure resource configurations, streamlining the file for future updates. --- .DS_Store | Bin 8196 -> 6148 bytes .../azure_vm/approve_operator_approvals.py | 48 +++++ terraform/azure_vm/bootstrap.sh | 60 +----- terraform/azure_vm/compute.tf | 36 ++++ terraform/azure_vm/data.tf | 3 + terraform/azure_vm/locals.tf | 27 +++ terraform/azure_vm/main.tf | 198 +----------------- terraform/azure_vm/network.tf | 34 +++ terraform/azure_vm/security.tf | 78 +++++++ 9 files changed, 243 insertions(+), 241 deletions(-) create mode 100644 terraform/azure_vm/approve_operator_approvals.py create mode 100644 terraform/azure_vm/compute.tf create mode 100644 terraform/azure_vm/data.tf create mode 100644 terraform/azure_vm/locals.tf create mode 100644 terraform/azure_vm/network.tf create mode 100644 terraform/azure_vm/security.tf diff --git a/.DS_Store b/.DS_Store index 8088c651672dcc14df871ff60a0df76bf35f43d0..411b7a8b189e8cab2db0d5ccc68c6fa5a8450c0f 100644 GIT binary patch delta 107 zcmZp1XfcprU|?W$DortDU=RQ@Ie-{Mvv5r;6q~50$jG)aU^g=(+hiUA!_DOa0n8Ii tsur_za0oI36#;<&H;`}zsoz-moq009j3-Dp0~5p~kl_rQ<9X&V0|3KJ5fcCa delta 230 zcmZoMXmOBWU|?W$DortDU;r^WfEYvza8E20o2aMA$gweCH}hr%jz7$c**Q2SHn1>q zOy*%R)aGT#XDDDuWyoVlX2@YkWGH9QW5{AC28!l+=Hw?Q<>V)Ui~-_JoAp@YnY|?# zN`T6XfH09E4XCV$As48n1jsG}iWC90>H*~|fb3kL!8r`6sKzic0gcl84+awpMK`PS nJZI*T;06l1f?T^(JnZpbKx=AvT diff --git a/terraform/azure_vm/approve_operator_approvals.py b/terraform/azure_vm/approve_operator_approvals.py new file mode 100644 index 0000000..75a72fb --- /dev/null +++ b/terraform/azure_vm/approve_operator_approvals.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Grant operator.approvals on paired devices; clear processed pending requests.""" +import json +import time + +PENDING_FILE = "/root/.openclaw/devices/pending.json" +PAIRED_FILE = "/root/.openclaw/devices/paired.json" + +pending = {} +for _ in range(12): + try: + with open(PENDING_FILE) as f: + pending = json.load(f) + if pending: + break + except Exception: + pass + time.sleep(5) + +# Even if no pending requests, approve operator.approvals for all paired devices. +try: + with open(PAIRED_FILE) as f: + paired = json.load(f) +except Exception: + paired = {} + +for request in pending.values(): + device_id = request.get("deviceId") + if device_id in paired: + for key in ("scopes", "approvedScopes"): + if "operator.approvals" not in paired[device_id].get(key, []): + paired[device_id].setdefault(key, []).append("operator.approvals") + print(f"Approved via pending: {device_id}") + +# Ensure all paired operator devices have operator.approvals regardless of pending state +for device_id, device in paired.items(): + if "operator" in device.get("roles", []): + for key in ("scopes", "approvedScopes"): + if "operator.approvals" not in device.get(key, []): + device.setdefault(key, []).append("operator.approvals") + print(f"Granted operator.approvals to {device_id[:16]}...") + +if paired: + with open(PAIRED_FILE, "w") as f: + json.dump(paired, f, indent=2) +if pending: + with open(PENDING_FILE, "w") as f: + json.dump({}, f) diff --git a/terraform/azure_vm/bootstrap.sh b/terraform/azure_vm/bootstrap.sh index 8b07c4e..fd11cbd 100644 --- a/terraform/azure_vm/bootstrap.sh +++ b/terraform/azure_vm/bootstrap.sh @@ -127,53 +127,12 @@ systemctl --user restart openclaw-gateway.service echo "Waiting for approval requests..." sleep 120 -python3 << 'PYEOF' -import json, sys, time - -PENDING_FILE = "/root/.openclaw/devices/pending.json" -PAIRED_FILE = "/root/.openclaw/devices/paired.json" - -pending = {} -for _ in range(12): - try: - with open(PENDING_FILE) as f: - pending = json.load(f) - if pending: - break - except Exception: - pass - time.sleep(5) - -# Even if no pending requests, approve operator.approvals for all paired devices. -try: - with open(PAIRED_FILE) as f: - paired = json.load(f) -except Exception: - paired = {} - -for request in pending.values(): - device_id = request.get("deviceId") - if device_id in paired: - for key in ("scopes", "approvedScopes"): - if "operator.approvals" not in paired[device_id].get(key, []): - paired[device_id].setdefault(key, []).append("operator.approvals") - print(f"Approved via pending: {device_id}") - -# Ensure all paired operator devices have operator.approvals regardless of pending state -for device_id, device in paired.items(): - if "operator" in device.get("roles", []): - for key in ("scopes", "approvedScopes"): - if "operator.approvals" not in device.get(key, []): - device.setdefault(key, []).append("operator.approvals") - print(f"Granted operator.approvals to {device_id[:16]}...") - -if paired: - with open(PAIRED_FILE, "w") as f: - json.dump(paired, f, indent=2) -if pending: - with open(PENDING_FILE, "w") as f: - json.dump({}, f) -PYEOF +mkdir -p /root/.openclaw/bootstrap +cat > /root/.openclaw/bootstrap/approve_operator_approvals.py << 'APPROVEPYEOF' +${approve_operator_script} +APPROVEPYEOF +chmod 700 /root/.openclaw/bootstrap/approve_operator_approvals.py +python3 /root/.openclaw/bootstrap/approve_operator_approvals.py # ── 8. Setup unattended security updates ───────────────────── apt-get install -y unattended-upgrades @@ -195,12 +154,13 @@ cat > /etc/cron.d/openclaw-cleanup << 'CRONEOF' # OpenClaw cleanup and maintenance tasks SHELL=/bin/bash PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +CRON_TZ=America/Los_Angeles -# Weekly npm cache cleanup (Sunday 2AM UTC) +# Weekly npm cache cleanup (Sunday 2:00 Pacific) 0 2 * * 0 root npm cache clean --force >> /var/log/openclaw-cleanup.log 2>&1 -# Daily disk space logging (1AM UTC) -0 1 * * * root df -h > /var/log/openclaw-diskspace.log 2>&1 +# Daily disk space logging (2:00 Pacific) +0 2 * * * root df -h > /var/log/openclaw-diskspace.log 2>&1 CRONEOF # ── 10. Setup logrotate for OpenClaw logs ──────────────────── diff --git a/terraform/azure_vm/compute.tf b/terraform/azure_vm/compute.tf new file mode 100644 index 0000000..081abfc --- /dev/null +++ b/terraform/azure_vm/compute.tf @@ -0,0 +1,36 @@ +resource "azurerm_linux_virtual_machine" "main" { + name = var.vm_name + location = var.location + resource_group_name = data.azurerm_resource_group.main.name + size = var.vm_size + + admin_username = var.admin_username + + admin_ssh_key { + username = var.admin_username + public_key = file(var.ssh_public_key_path) + } + + network_interface_ids = [ + azurerm_network_interface.main.id, + ] + + os_disk { + caching = "ReadWrite" + storage_account_type = "Standard_LRS" + disk_size_gb = var.os_disk_size_gb + } + + source_image_reference { + publisher = "Canonical" + offer = "0001-com-ubuntu-server-jammy" + sku = "22_04-lts-arm64" + version = "latest" + } + + # Temp disk is automatically allocated for B1s (4GB at /mnt/resource) + # No explicit configuration needed, but ensure it's not disabled by default + custom_data = base64encode(templatefile("${path.module}/bootstrap.sh", local.bootstrap_vars)) + + tags = local.common_tags +} diff --git a/terraform/azure_vm/data.tf b/terraform/azure_vm/data.tf new file mode 100644 index 0000000..fbc8c98 --- /dev/null +++ b/terraform/azure_vm/data.tf @@ -0,0 +1,3 @@ +data "azurerm_resource_group" "main" { + name = var.resource_group_name +} diff --git a/terraform/azure_vm/locals.tf b/terraform/azure_vm/locals.tf new file mode 100644 index 0000000..cd06c75 --- /dev/null +++ b/terraform/azure_vm/locals.tf @@ -0,0 +1,27 @@ +locals { + openclaw_json_content = templatefile("${path.module}/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 != "" + }) + + bootstrap_vars = { + openrouter_api_key = var.openrouter_api_key + telegram_bot_token = var.telegram_bot_token + openclaw_gateway_token = var.openclaw_gateway_token + brave_api_key = var.brave_api_key + 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 + } + + common_tags = { + Environment = "Production" + Application = "OpenClaw" + } +} diff --git a/terraform/azure_vm/main.tf b/terraform/azure_vm/main.tf index 804a055..375121e 100644 --- a/terraform/azure_vm/main.tf +++ b/terraform/azure_vm/main.tf @@ -1,191 +1,7 @@ -# ── Resource Group (must already exist) ────────────────────── -data "azurerm_resource_group" "main" { - name = var.resource_group_name -} - -# ── Virtual Network and Subnet ─────────────────────────────── -resource "azurerm_virtual_network" "main" { - name = "${var.vm_name}-vnet" - address_space = ["10.0.0.0/16"] - location = var.location - resource_group_name = data.azurerm_resource_group.main.name -} - -resource "azurerm_subnet" "internal" { - name = "${var.vm_name}-subnet" - resource_group_name = data.azurerm_resource_group.main.name - virtual_network_name = azurerm_virtual_network.main.name - address_prefixes = ["10.0.2.0/24"] -} - -# ── Network Interface (NIC) ────────────────────────────────── -resource "azurerm_network_interface" "main" { - name = "${var.vm_name}-nic" - location = var.location - resource_group_name = data.azurerm_resource_group.main.name - - ip_configuration { - name = "testconfiguration1" - subnet_id = azurerm_subnet.internal.id - private_ip_address_allocation = "Dynamic" - public_ip_address_id = azurerm_public_ip.main.id - } -} - -# ── Static Public IP ───────────────────────────────────────── -resource "azurerm_public_ip" "main" { - name = var.public_ip_name - location = var.location - resource_group_name = data.azurerm_resource_group.main.name - allocation_method = "Static" - sku = "Standard" -} - -# ── Network Security Group (NSG) ──────────────────────────── -resource "azurerm_network_security_group" "main" { - name = "${var.vm_name}-nsg" - location = var.location - resource_group_name = data.azurerm_resource_group.main.name - - # SSH access (port 22) - IPv4 - security_rule { - name = "AllowSSH_IPv4" - priority = 100 - direction = "Inbound" - access = "Allow" - protocol = "Tcp" - source_port_range = "*" - destination_port_range = "22" - source_address_prefix = "0.0.0.0/0" - destination_address_prefix = "*" - } - - # SSH access (port 22) - IPv6 - security_rule { - name = "AllowSSH_IPv6" - priority = 101 - direction = "Inbound" - access = "Allow" - protocol = "Tcp" - source_port_range = "*" - destination_port_range = "22" - source_address_prefix = "::/0" - destination_address_prefix = "::/0" - } - - # OpenClaw Gateway access (port 18789) - IPv4 - security_rule { - name = "AllowOpenClawGateway_IPv4" - priority = 102 - direction = "Inbound" - access = "Allow" - protocol = "Tcp" - source_port_range = "*" - destination_port_range = "18789" - source_address_prefix = "0.0.0.0/0" - destination_address_prefix = "*" - } - - # OpenClaw Gateway access (port 18789) - IPv6 - security_rule { - name = "AllowOpenClawGateway_IPv6" - priority = 103 - direction = "Inbound" - access = "Allow" - protocol = "Tcp" - source_port_range = "*" - destination_port_range = "18789" - source_address_prefix = "::/0" - destination_address_prefix = "::/0" - } - - # Deny all other inbound - security_rule { - name = "DenyAllInbound" - priority = 4096 - direction = "Inbound" - access = "Deny" - protocol = "*" - source_port_range = "*" - destination_port_range = "*" - source_address_prefix = "*" - destination_address_prefix = "*" - } - - # Allow all outbound - security_rule { - name = "AllowAllOutbound" - priority = 100 - direction = "Outbound" - access = "Allow" - protocol = "*" - source_port_range = "*" - destination_port_range = "*" - source_address_prefix = "*" - destination_address_prefix = "*" - } -} - -# ── Associate NSG with NIC ─────────────────────────────────── -resource "azurerm_network_interface_security_group_association" "main" { - network_interface_id = azurerm_network_interface.main.id - network_security_group_id = azurerm_network_security_group.main.id -} - -# ── B1s Virtual Machine ────────────────────────────────────── -resource "azurerm_linux_virtual_machine" "main" { - name = var.vm_name - location = var.location - resource_group_name = data.azurerm_resource_group.main.name - size = var.vm_size - - admin_username = var.admin_username - - admin_ssh_key { - username = var.admin_username - public_key = file(var.ssh_public_key_path) - } - - network_interface_ids = [ - azurerm_network_interface.main.id, - ] - - os_disk { - caching = "ReadWrite" - storage_account_type = "Standard_LRS" - disk_size_gb = var.os_disk_size_gb - } - - source_image_reference { - publisher = "Canonical" - offer = "0001-com-ubuntu-server-jammy" - sku = "22_04-lts-arm64" - version = "latest" - } - - # Temp disk is automatically allocated for B1s (4GB at /mnt/resource) - # No explicit configuration needed, but ensure it's not disabled by default - - custom_data = base64encode(templatefile("${path.module}/bootstrap.sh", { - openrouter_api_key = var.openrouter_api_key - telegram_bot_token = var.telegram_bot_token - openclaw_gateway_token = var.openclaw_gateway_token - brave_api_key = var.brave_api_key - swap_size = var.swap_size - openclaw_memory_limit_mb = var.openclaw_memory_limit_mb - openclaw_json_content = templatefile("${path.module}/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 != "" - }) - })) - - tags = { - Environment = "Production" - Application = "OpenClaw" - } -} +# Terraform automatically loads all *.tf files in this directory. +# Resources are split by concern: +# - data.tf +# - locals.tf +# - network.tf +# - security.tf +# - compute.tf diff --git a/terraform/azure_vm/network.tf b/terraform/azure_vm/network.tf new file mode 100644 index 0000000..cd4fcd7 --- /dev/null +++ b/terraform/azure_vm/network.tf @@ -0,0 +1,34 @@ +resource "azurerm_virtual_network" "main" { + name = "${var.vm_name}-vnet" + address_space = ["10.0.0.0/16"] + location = var.location + resource_group_name = data.azurerm_resource_group.main.name +} + +resource "azurerm_subnet" "internal" { + name = "${var.vm_name}-subnet" + resource_group_name = data.azurerm_resource_group.main.name + virtual_network_name = azurerm_virtual_network.main.name + address_prefixes = ["10.0.2.0/24"] +} + +resource "azurerm_public_ip" "main" { + name = var.public_ip_name + location = var.location + resource_group_name = data.azurerm_resource_group.main.name + allocation_method = "Static" + sku = "Standard" +} + +resource "azurerm_network_interface" "main" { + name = "${var.vm_name}-nic" + location = var.location + resource_group_name = data.azurerm_resource_group.main.name + + ip_configuration { + name = "testconfiguration1" + subnet_id = azurerm_subnet.internal.id + private_ip_address_allocation = "Dynamic" + public_ip_address_id = azurerm_public_ip.main.id + } +} diff --git a/terraform/azure_vm/security.tf b/terraform/azure_vm/security.tf new file mode 100644 index 0000000..5843dea --- /dev/null +++ b/terraform/azure_vm/security.tf @@ -0,0 +1,78 @@ +locals { + ssh_rule_defs = { + for idx, cidr in var.ssh_allowed_cidrs : "ssh-${idx}" => { + name = "AllowSSH_${idx}" + priority = 100 + idx + port = "22" + source_cidr = cidr + dest_prefix = length(regexall(":", cidr)) > 0 ? "::/0" : "*" + } + } + + gateway_rule_defs = { + for idx, cidr in var.gateway_allowed_cidrs : "gateway-${idx}" => { + name = "AllowOpenClawGateway_${idx}" + priority = 200 + idx + port = "18789" + source_cidr = cidr + dest_prefix = length(regexall(":", cidr)) > 0 ? "::/0" : "*" + } + } + + inbound_allow_rules = merge(local.ssh_rule_defs, local.gateway_rule_defs) +} + +resource "azurerm_network_security_group" "main" { + name = "${var.vm_name}-nsg" + location = var.location + resource_group_name = data.azurerm_resource_group.main.name +} + +resource "azurerm_network_security_rule" "inbound_allow" { + for_each = local.inbound_allow_rules + + name = each.value.name + priority = each.value.priority + direction = "Inbound" + access = "Allow" + protocol = "Tcp" + source_port_range = "*" + destination_port_range = each.value.port + source_address_prefix = each.value.source_cidr + destination_address_prefix = each.value.dest_prefix + resource_group_name = data.azurerm_resource_group.main.name + network_security_group_name = azurerm_network_security_group.main.name +} + +resource "azurerm_network_security_rule" "deny_all_inbound" { + name = "DenyAllInbound" + priority = 4096 + direction = "Inbound" + access = "Deny" + protocol = "*" + source_port_range = "*" + destination_port_range = "*" + source_address_prefix = "*" + destination_address_prefix = "*" + resource_group_name = data.azurerm_resource_group.main.name + network_security_group_name = azurerm_network_security_group.main.name +} + +resource "azurerm_network_security_rule" "allow_all_outbound" { + name = "AllowAllOutbound" + priority = 100 + direction = "Outbound" + access = "Allow" + protocol = "*" + source_port_range = "*" + destination_port_range = "*" + source_address_prefix = "*" + destination_address_prefix = "*" + resource_group_name = data.azurerm_resource_group.main.name + network_security_group_name = azurerm_network_security_group.main.name +} + +resource "azurerm_network_interface_security_group_association" "main" { + network_interface_id = azurerm_network_interface.main.id + network_security_group_id = azurerm_network_security_group.main.id +} From fe6a37239b5a98de4e94f591cda96d1a4f1c72cc Mon Sep 17 00:00:00 2001 From: PCBZ Date: Wed, 22 Apr 2026 19:15:27 -0700 Subject: [PATCH 4/4] Update README to include Azure deployment instructions for OpenClaw - Added Azure as a deployment option alongside DigitalOcean. - Updated deployment instructions to reflect changes in supported platforms. - Included specific configuration details for Azure VM setup. --- README.md | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 407569f..c3c40c2 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,17 @@ -# OpenClaw on DigitalOcean +# OpenClaw on DigitalOcean + Azure [![Security Checks](https://github.com/PCBZ/OpenClaw_Docker/actions/workflows/security.yml/badge.svg)](https://github.com/PCBZ/OpenClaw_Docker/actions/workflows/security.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Last Commit](https://img.shields.io/github/last-commit/PCBZ/OpenClaw_Docker)](https://github.com/PCBZ/OpenClaw_Docker/commits/main) [![Terraform](https://img.shields.io/badge/Terraform-%3E%3D1.5-844fba?logo=terraform&logoColor=white)](https://www.terraform.io) [![DigitalOcean](https://img.shields.io/badge/DigitalOcean-Droplet-0080ff?logo=digitalocean&logoColor=white)](https://www.digitalocean.com) +[![Azure](https://img.shields.io/badge/Azure-VM-0078d4?logo=microsoft-azure&logoColor=white)](https://azure.microsoft.com) [![OpenRouter](https://img.shields.io/badge/OpenRouter-Free%20Tier-ff6b35?logoColor=white)](https://openrouter.ai) [![OpenClaw](https://img.shields.io/badge/OpenClaw-2026-00e5cc?logoColor=white)](https://openclaw.bot) [![Telegram](https://img.shields.io/badge/Telegram-Bot-26a5e4?logo=telegram&logoColor=white)](https://telegram.org) [![Slack](https://img.shields.io/badge/Slack-Bot-4a154b?logo=slack&logoColor=white)](https://slack.com) -One-command deployment of an [OpenClaw](https://openclaw.bot) AI agent on DigitalOcean with Telegram support and Slack support. After `terraform apply`, the bot is fully operational with no manual SSH steps required. +One-command deployment of an [OpenClaw](https://openclaw.bot) AI agent on DigitalOcean or Azure VM with Telegram and Slack support. After `terraform apply`, the bot is fully operational with no manual SSH steps required. ## Features @@ -25,7 +26,8 @@ One-command deployment of an [OpenClaw](https://openclaw.bot) AI agent on Digita - Terraform >= 1.5 - direnv (`brew install direnv`) - SSH key pair -- DigitalOcean account + API token +- DigitalOcean account + API token (for DO path) +- Azure subscription + service principal credentials (for Azure path) - OpenRouter API key - Telegram bot token (from [@BotFather](https://t.me/BotFather)) - Slack App-Level token (starts with `xapp-`) @@ -51,7 +53,9 @@ Edit `.env` and fill in your values: | `SLACK_APP_TOKEN` | Slack App-Level token (starts with `xapp-`) | | `SLACK_BOT_TOKEN` | Slack Bot User OAuth token (starts with `xoxb-`) | -### 2. Configure infrastructure +### 2. Choose deployment target + +#### Option A: DigitalOcean ```bash cd terraform/digitalOcean @@ -67,6 +71,31 @@ droplet_size = "s-1vcpu-1gb" # $6/mo — increase if OOM swap_size = "3G" ``` +#### Option B: Azure VM + +```bash +cd terraform/azure_vm +``` + +Create `terraform.tfvars` and set your Azure + VM values: + +```hcl +subscription_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +tenant_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +client_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +client_secret = "..." + +resource_group_name = "your-existing-rg" +location = "eastus" +ssh_public_key_path = "~/.ssh/id_rsa.pub" + +vm_name = "openclaw-b2pts" +vm_size = "Standard_B2pts_v2" +os_disk_size_gb = 30 +swap_size = 2 +openclaw_memory_limit_mb = 800 +``` + ### 3. Load secrets via direnv ```bash