diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..688d523 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Hetzner Cloud API Token +# Get from: https://console.hetzner.cloud/ → Security → API Tokens +HCLOUD_TOKEN=your-hetzner-api-token-here + +# SSH Public Key (optional, will use ~/.ssh/id_ed25519.pub if not set) +# SSH_PUBLIC_KEY=ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... user@host diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index 2f52a4b..0000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,75 +0,0 @@ -name: Deploy to GitHub Pages - -on: - push: - branches: - - main - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: "pages" - cancel-in-progress: false - -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - cache-dependency-path: website/package-lock.json - - - name: Install dependencies - working-directory: ./website - run: npm ci - - - name: Setup Next.js build cache - uses: actions/cache@v4 - with: - path: | - website/.next/cache - key: ${{ runner.os }}-nextjs-${{ hashFiles('website/package-lock.json') }}-${{ hashFiles('website/**/*.js', 'website/**/*.jsx', 'website/**/*.ts', 'website/**/*.tsx') }} - restore-keys: | - ${{ runner.os }}-nextjs-${{ hashFiles('website/package-lock.json') }}- - ${{ runner.os }}-nextjs- - - - name: Temporarily move API routes (not needed for static export) - working-directory: ./website - run: mv app/api app/api.bak || true - - - name: Build Next.js static site - working-directory: ./website - env: - NEXT_TELEMETRY_DISABLED: 1 - run: npm run build - - - name: Restore API routes - working-directory: ./website - run: mv app/api.bak app/api || true - if: always() - - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 - with: - path: ./website/out - - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - needs: build - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..76fda1a --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# Environment and secrets +.env + +# SSH keys (auto-generated, should not be committed) +hetzner_key +hetzner_key.pub +ssh-keys/ + +# Generated files +finland-instance-ip.txt +available-server-types.txt + +# Python virtualenv +venv/ +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Ansible +*.retry +.ansible/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Logs +*.log +deployment-logs/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..dbcc385 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "clawdbot-ansible"] + path = clawdbot-ansible + url = https://github.com/openclaw/clawdbot-ansible.git +[submodule "openclaw"] + path = openclaw + url = https://github.com/openclaw/openclaw.git diff --git a/CNAME b/CNAME new file mode 100644 index 0000000..e82eada --- /dev/null +++ b/CNAME @@ -0,0 +1 @@ +roboclaw.ai \ No newline at end of file diff --git a/HETZNER_SETUP.md b/HETZNER_SETUP.md new file mode 100644 index 0000000..6765146 --- /dev/null +++ b/HETZNER_SETUP.md @@ -0,0 +1,85 @@ +# Hetzner Finland Instance Setup + +Quick guide to create a server in Helsinki, Finland using Ansible. + +## Prerequisites + +1. **Hetzner Cloud Account** + - Sign up at https://console.hetzner.cloud/ + - Create a project + +2. **API Token** + - Go to your project → Security → API Tokens + - Generate a new token with Read & Write permissions + - Save it securely + +3. **SSH Key** + - Generate if you don't have one: `ssh-keygen -t ed25519 -C "your@email.com"` + - Your public key is at `~/.ssh/id_ed25519.pub` + +## Installation + +```bash +# Install Ansible (if not already installed) +pip3 install ansible + +# Install Hetzner collection +ansible-galaxy collection install -r hetzner-requirements.yml +``` + +## Usage + +```bash +# 1. Make sure your .env file has HCLOUD_TOKEN set +# (See .env.example for format) + +# 2. Run the playbook (credentials loaded from .env automatically) +./cli/cli/run-hetzner.sh + +# 3. Connect to your server (IP saved to finland-instance-ip.txt) +ssh root@$(cat finland-instance-ip.txt) +``` + +**Note**: Your `.env` file is automatically added to `.gitignore` to prevent token leaks. + +## Customization + +Edit `hetzner-finland.yml` to change: +- `server_type`: Instance size (cx22, cx32, cx42, etc.) +- `server_name`: Your server's name +- `image`: OS image (ubuntu-24.04, debian-12, rocky-9, etc.) + +## Pricing + +Default `cx22` instance costs ~€5.28/month: +- 2 vCPU +- 4GB RAM +- 40GB NVMe SSD +- 20TB traffic + +Smaller options: +- `cx11`: €4.15/month (1 vCPU, 2GB RAM) +- `cx21`: €4.71/month (2 vCPU, 4GB RAM) + +## Teardown + +```bash +# List all servers +source .env && source venv/bin/activate && \ + ansible-playbook hetzner-teardown.yml --tags list + +# Delete server (with confirmation prompt) +source .env && source venv/bin/activate && \ + ansible-playbook hetzner-teardown.yml --tags delete + +# Delete specific server +source .env && source venv/bin/activate && \ + ansible-playbook hetzner-teardown.yml --tags delete -e server_name=my-server + +# Delete server and SSH key +source .env && source venv/bin/activate && \ + ansible-playbook hetzner-teardown.yml --tags delete -e delete_ssh_key=true + +# Clean up local files +rm hetzner_key hetzner_key.pub finland-instance-ip.txt +``` diff --git a/PROVISION.md b/PROVISION.md new file mode 100644 index 0000000..d1324d2 --- /dev/null +++ b/PROVISION.md @@ -0,0 +1,502 @@ +# Automated Hetzner VPS Provisioning with RoboClaw + +A one-command Ansible playbook that provisions a VPS in Finland (Helsinki) and automatically installs RoboClaw with full security hardening. + +## What We Built + +An automated infrastructure-as-code solution that: + +1. **Provisions Infrastructure** - Creates a Hetzner Cloud VPS in Helsinki datacenter +2. **Installs RoboClaw** - Fully automated setup using the roboclaw-ansible playbook +3. **Security Hardening** - UFW firewall, Docker isolation +4. **One Command Deploy** - Everything runs from your local machine + +## Architecture + +``` +Local Machine Remote VPS (Helsinki) +├── hetzner-finland-fast.yml → ├── Ubuntu 24.04 ARM +├── roboclaw-ansible/ → ├── Docker CE +├── cli/run-hetzner.sh → ├── Node.js 22 + pnpm +├── .env (API token) → ├── UFW Firewall +└── hetzner_key (SSH) → └── RoboClaw 2026.1.24-3 +``` + +## How It Works + +The playbook runs **three sequential plays**: + +### Play 1: Provision VPS +```yaml +- Create SSH key in Hetzner Cloud +- Provision cax11 instance (ARM, €3.29/mo) +- Wait for SSH availability +- Add to in-memory inventory +``` + +### Play 2: Hello World +```yaml +- Display server information +- Create test file (/root/hello-ansible.txt) +- Verify connectivity +``` + +### Play 3: Install RoboClaw +```yaml +- Run roboclaw-ansible role from local machine +- Install: Docker, Node.js, UFW +- Create roboclaw user with systemd lingering +- Install RoboClaw via pnpm +- Configure environment +``` + +## File Structure + +``` +. +├── PROVISION.md # This file +├── HETZNER_SETUP.md # Quick start guide +├── ROBOCLAW_GUIDE.md # RoboClaw integration guide +├── hetzner-finland-fast.yml # Main playbook (3 plays) +├── hetzner-requirements.yml # Ansible Galaxy dependencies +├── cli/run-hetzner.sh # Wrapper script (virtualenv + .env) +├── list-server-types.sh # List available Hetzner instance types +├── .env # HCLOUD_TOKEN (gitignored) +├── .env.example # Template for .env +├── hetzner_key # Auto-generated SSH key (gitignored) +├── hetzner_key.pub # Public key +├── finland-instance-ip.txt # Saved IP address +├── available-server-types.txt # Cached server type list +├── instances/ # Instance artifacts (YAML) +├── venv/ # Python virtualenv for Ansible +└── roboclaw-ansible/ # RoboClaw installation playbook + ├── playbook.yml # RoboClaw installer + ├── requirements.yml # Ansible collections + └── roles/roboclaw/ # Main installation role + ├── tasks/ + │ ├── main.yml # Task orchestration + │ ├── system-tools.yml # Base packages + │ ├── user.yml # User creation + │ ├── docker.yml # Docker CE + │ ├── firewall.yml # UFW configuration + │ ├── nodejs.yml # Node.js + pnpm + │ └── roboclaw.yml # RoboClaw installation + └── defaults/main.yml # Default variables +``` + +## Current Server Details + +**Provisioned**: 2026-02-01 +**Location**: Helsinki, Finland (hel1) +**IP Address**: `65.21.149.78` +**Instance Type**: cax11 (ARM64) +**Specs**: 2 vCPU, 4GB RAM, 40GB SSD +**Cost**: €3.29/month + +**Installed Software**: +- OS: Ubuntu 24.04 LTS (ARM64) +- Kernel: 6.8.0-90-generic +- Docker: Latest CE +- Node.js: v22.22.0 +- pnpm: 10.28.2 +- RoboClaw: 2026.1.24-3 + +**Security**: +- UFW Firewall: Enabled + - Allowed: SSH (22) + - Default: Deny incoming, allow outgoing +- Docker: DOCKER-USER chain configured +- User: roboclaw (non-root, sudo access) + +## Usage + +### First Time Setup + +```bash +# 1. Get Hetzner API token +# Go to: https://console.hetzner.cloud/ → Security → API Tokens +# Create a Read & Write token + +# 2. Create .env file +cat > .env < 🚧 **PROJECT NOT FUNCTIONAL** — This project is currently in early development and is **not in a functional state**. -> -> **Want to get involved?** Join the [OpenClaw Discord community](https://discord.gg/8DaPXhRFfv) where active development is happening in the voice chat channels! Follow [@RoboClawX](https://x.com/RoboClawX) for updates. This is where the community is building RoboClaw together. +> **Join the community:** [OpenClaw Discord](https://discord.gg/8DaPXhRFfv) | Follow [@RoboClawX](https://x.com/RoboClawX) for updates -Deploy your own OpenClaw instance in minutes. Free, secure, and fully reversible. *(Coming soon)* +Automated deployment system for provisioning VPS instances and installing OpenClaw. -## Quick Start +**Two deployment modes:** +1. **Deploy to existing servers** - Use any server with SSH access (recommended for testing) +2. **Hetzner Cloud provisioning** - Provision new VPS instances automatically + +## Quick Start - Deploy to Existing Server ```bash -# 1. Deploy OpenClaw to your server via SSH -roboclaw deploy --ssh user@your-server-ip +# One command to deploy OpenClaw and auto-onboard +./cli/run-deploy.sh -k ~/.ssh/your_key + +# With custom instance name +./cli/run-deploy.sh -k ~/.ssh/your_key -n production + +# That's it! The script will: +# - Auto-install dependencies if needed (Python 3.12+, Ansible, collections) +# - Deploy OpenClaw + dependencies (~3-5 min) +# - Create instance artifact +# - Drop you into interactive onboarding wizard +``` + +**What the script does automatically:** +- Detects Python 3.12+ (checks python3.12, python3, python, pyenv) +- Creates virtual environment if needed +- Installs all Python dependencies +- Installs Ansible Hetzner collection +- Generates temporary inventory from IP +- Provides helpful errors if Python 3.12+ not found + +**No separate setup required!** The script handles everything automatically. + +## Quick Start - Hetzner Cloud Provisioning + +```bash +# 1. Get Hetzner API token from https://console.hetzner.cloud/ +# Project → Security → API Tokens → Generate (Read & Write) + +# 2. Create .env file +echo 'HCLOUD_TOKEN=your-64-char-token-here' > .env + +# 3. Provision VPS (~2-3 minutes) +./cli/run-hetzner.sh -# Example output: -# ✓ Connected via SSH -# ✓ Running Ansible playbook... -# ✓ OpenClaw installed -# ✓ RoboClaw features configured -# ✓ Your personal OpenClaw is ready! -# 🎉 Dashboard: https://your-server-ip:3000 +# 4. Validate installation (17 checks) +./cli/validate-instance.sh -# 2. Connect to your server and onboard RoboClaw -ssh user@your-server-ip +# 5. Connect +ssh -i hetzner_key root@$(cat finland-instance-ip.txt) + +# 6. Onboard RoboClaw sudo su - roboclaw openclaw onboard --install-daemon ``` -## What You Get +## Commands -- **Your Data Stays on Your Server** — Full control over your data -- **Your Secrets Stay on Your Computer** — API keys and passwords never leave your machine -- **No Vendor Lock-In** — Works with any cloud provider (AWS, DigitalOcean, Linode, Hetzner, or even your home server) -- **Automatic Backups** — Your configurations are automatically backed up -- **Activity Logging** — See everything your AI agents do -- **Secure Password Storage** — Credentials are encrypted and stored safely +### Deploy to Existing Server (CLI) -## How It Works +#### Deploy with Auto-Onboard (Recommended) -RoboClaw uses SSH and Ansible to deploy [OpenClaw](https://github.com/openclaw/openclaw) to your server. Powered by [openclaw/clawdbot-ansible](https://github.com/openclaw/clawdbot-ansible). +```bash +# One-command deployment (recommended) +./cli/run-deploy.sh -k -1. **Connect to your VPS** — Uses your SSH credentials to access your server -2. **Provision the Server** — Installs Docker, Node.js, and other dependencies -3. **Install OpenClaw** — Deploys the latest OpenClaw version -4. **Configure Security** — Sets up firewall rules and creates dedicated user accounts -5. **Enable RoboClaw Features** — Configures automatic updates, backups, and activity logging +# With custom instance name +./cli/run-deploy.sh -k -n production -Everything runs from your local machine. No manual SSH configuration required. +# Legacy: Using inventory file (still supported) +./cli/run-deploy.sh -k -i +``` -## Requirements +**What gets installed:** +- Ubuntu 24.04 (x86 or ARM) +- Docker CE +- Node.js 22 + pnpm +- UFW firewall (SSH only) +- OpenClaw latest version +- Gemini CLI +- ttyd (browser terminal) +- **Time: ~3-5 minutes** + +#### Skip Auto-Onboard + +```bash +# Deploy without launching onboarding wizard +./cli/run-deploy.sh -k --skip-onboard + +# Connect and onboard later +./cli/connect-instance.sh onboard +``` + +#### Create Inventory File (Advanced) + +```bash +# For advanced use cases with multiple servers +# Most users should use direct IP deployment instead +./cli/create-inventory.sh [output-file] + +# Example +./cli/create-inventory.sh 1.2.3.4 production.ini +``` + +#### Connect to Instance + +```bash +# Connect and run onboarding wizard +./cli/connect-instance.sh onboard + +# Connect to interactive shell +./cli/connect-instance.sh + +# Connect with custom IP/key +./cli/connect-instance.sh --ip 1.2.3.4 --key ~/.ssh/key onboard +``` + +### Provision New Server (Hetzner Cloud) + +```bash +# Provision and install RoboClaw (~2-3 minutes) +./cli/run-hetzner.sh +``` + +**Install includes:** +- Ubuntu 24.04 ARM (2 vCPU, 4GB RAM, 40GB SSD) +- Docker CE +- Node.js 22 + pnpm +- UFW firewall (SSH only) +- OpenClaw latest version +- Cost: €3.29/month +- **Time: ~2-3 minutes** + +### Validate Instance + +```bash +# Validate provisioning was successful +./cli/validate-instance.sh +``` -- A VPS or server with SSH access -- Ubuntu 24.04 (recommended) or similar Linux distribution -- Python 3.12+ (for Ansible) -- SSH key or password authentication +Runs 17 checks including: +- SSH connectivity +- Software versions +- RoboClaw installation +- Firewall configuration +- Docker setup + +### List Servers + +```bash +# Show all servers in your Hetzner account +./cli/run-hetzner.sh list +``` + +### Delete Server + +```bash +# Delete default server (finland-instance) with confirmation prompt +./cli/run-hetzner.sh delete + +# Delete specific server +./cli/run-hetzner.sh delete -e server_name=my-server + +# Delete server AND remove SSH key from Hetzner +./cli/run-hetzner.sh delete -e delete_ssh_key=true +``` + +### Clean Up Local Files + +```bash +# Remove SSH keys and IP file +rm hetzner_key hetzner_key.pub finland-instance-ip.txt + +# Remove deleted instance artifacts (optional - preserves history by default) +rm instances/*_deleted.yml +``` + +## Configuration + +Edit `hetzner-finland-fast.yml` to customize: + +```yaml +vars: + server_name: "finland-instance" # Server name + server_type: "cax11" # Instance type (see available-server-types.txt) + location: "hel1" # Helsinki (hel1), Falkenstein (fsn1), Nuremberg (nbg1) + image: "ubuntu-24.04" # OS image + roboclaw_install_mode: "release" # or "development" +``` + +### Available Instance Types + +```bash +# List all available instance types and prices +./cli/list-server-types.sh +cat available-server-types.txt +``` + +**Popular options:** +- `cax11` (ARM): €3.29/mo - 2 vCPU, 4GB RAM, 40GB disk (default) +- `cx23` (x86): €2.99/mo - 2 vCPU, 4GB RAM, 40GB disk +- `cax21` (ARM): €5.99/mo - 4 vCPU, 8GB RAM, 80GB disk +- `cpx22` (x86): €5.99/mo - 2 vCPU, 4GB RAM, 80GB disk + +## Instance Artifacts + +After successful provisioning, a YAML artifact is automatically created in `instances/.yml` containing: + +- Instance metadata (name, IP, server type, location) +- Installed software versions (Docker, Node.js, pnpm, Clawdbot) +- Configuration details (clawdbot user, firewall rules) +- Provisioning timestamp and install mode +- Deletion timestamp (added when server is deleted) + +**The validation script uses these artifacts** to verify that the actual server state matches what was provisioned. + +**Lifecycle tracking:** When you delete a server using `./cli/run-hetzner.sh delete`, the artifact is: +- Renamed from `.yml` to `_deleted.yml` +- Updated with `deleted_at` timestamp +- Updated with `status: deleted` flag + +This preserves the history of your instances and makes it easy to distinguish active from deleted instances. + +Example artifact (active instance) - `instances/finland-instance.yml`: +```yaml +instances: + - name: finland-instance + ip: 65.21.149.78 + server_type: cax11 + location: hel1 + provisioned_at: 2026-01-31T23:20:00Z + install_mode: fast + software: + os: Ubuntu 24.04 + docker: Docker version 29.2.0, build 0b9d198 + nodejs: v22.22.0 + pnpm: 10.28.2 + roboclaw: 2026.1.24-3 + firewall: + ufw_enabled: true +``` + +Example artifact (deleted instance) - `instances/finland-instance_deleted.yml`: +```yaml +instances: + - name: finland-instance + ip: 65.21.149.78 + provisioned_at: 2026-01-31T23:20:00Z + deleted_at: 2026-02-01T06:47:30Z + install_mode: fast + status: deleted +``` + +## Validate Provisioning + +After provisioning, validate that everything was installed correctly: + +```bash +# Validate default instance (finland-instance) +./cli/validate-instance.sh + +# Validate specific instance +./cli/validate-instance.sh my-server + +# Show help +./cli/validate-instance.sh --help +``` + +The validation script checks: +- SSH connectivity +- OS and kernel versions +- Software versions (Docker, Node.js, pnpm, RoboClaw) +- RoboClaw user and directory structure +- Docker group membership and access +- UFW firewall configuration +- Docker daemon status + +Example output: +``` +✓ All validation checks passed! + +Instance: finland-instance +IP Address: 65.21.149.78 +Checks Passed: 17 +Checks Failed: 0 +``` ## Post-Installation -After deploying, connect to your server and run the onboarding wizard: +After provisioning, connect and configure RoboClaw: ```bash -# 1. SSH into your server -ssh user@your-server-ip +# 1. SSH into server +ssh -i hetzner_key root@$(cat finland-instance-ip.txt) -# 2. Switch to the roboclaw user +# 2. Switch to roboclaw user sudo su - roboclaw # 3. Run onboarding wizard openclaw onboard --install-daemon # This will: -# - Configure messaging provider (WhatsApp/Telegram/Discord/Slack/Matrix) +# - Configure messaging provider (WhatsApp/Telegram/Signal) # - Create roboclaw.json config # - Install systemd service # - Start the daemon ``` +## File Structure + +``` +. +├── README.md # This file (quick start) +├── PROVISION.md # Detailed technical documentation +├── HETZNER_SETUP.md # Setup guide +├── ROBOCLAW_GUIDE.md # RoboClaw integration guide +├── LICENSE # GNU AGPL v3 license +├── cli/ # CLI scripts and Ansible playbooks +│ ├── run-deploy.sh # Deploy to existing servers +│ ├── setup.sh # One-command environment setup +│ ├── connect-instance.sh # Connect to instances and run OpenClaw +│ ├── create-inventory.sh # Generate inventory files from IP +│ ├── run-hetzner.sh # Provision new Hetzner servers +│ ├── validate-instance.sh # Validation script +│ ├── list-server-types.sh # List instance types +│ ├── cleanup-ssh-key.yml # Manage SSH keys in Hetzner +│ ├── reconfigure.yml # Software installation playbook +│ ├── hetzner-finland-fast.yml # Hetzner provision playbook +│ ├── hetzner-teardown.yml # Teardown playbook +│ ├── openclaw-service.yml # OpenClaw service management +│ ├── validate-openclaw.yml # OpenClaw validation +│ └── ansible.cfg # Ansible configuration +├── .env # Your API token (gitignored) +├── .env.example # Template +├── venv/ # Python virtual environment +├── requirements.txt # Python dependencies +├── hetzner_key # SSH private key (auto-generated, gitignored) +├── hetzner_key.pub # SSH public key +├── ssh-keys/ # SSH keys for deployments +├── instances/ # Instance artifacts (YAML) +├── website/ # Landing page and documentation +└── roboclaw/ # RoboClaw source code (submodule) +``` + +## Requirements + +**For CLI deployment (run-deploy.sh):** +- Python 3.12+ (required for Ansible 13+) +- SSH access to target server(s) + +**For Hetzner provisioning (run-hetzner.sh):** +- Python 3.12+ +- Hetzner Cloud account with API token + +### Automatic Setup + +```bash +# One command to install everything +./cli/setup.sh + +# That's it! The script will: +# - Find Python 3.12+ (checks python3.12, python3, python, pyenv) +# - Create virtual environment +# - Install all dependencies +# - Install Ansible collections +``` + +If you don't have Python 3.12+, the script will show you how to install it: +- **pyenv** (recommended): `pyenv install 3.12.0` +- **apt** (Ubuntu/Debian): `sudo apt install python3.12 python3.12-venv` +- **brew** (macOS): `brew install python@3.12` + +The deployment scripts automatically check prerequisites and suggest running `./cli/setup.sh` if anything is missing. + +## How It Works + +1. **Provision Play**: Creates VPS in Helsinki, uploads SSH key +2. **Configure Play**: Runs hello world, verifies connectivity +3. **Install Play**: Installs software stack from local machine + - Docker, Node.js, pnpm, UFW firewall + - Creates roboclaw user with Docker access + - Installs RoboClaw via pnpm + - Saves artifact to `instances/.yml` +4. **Validation** (optional): Verifies installation with 17 automated checks + +Everything runs from your local machine. No manual SSH required. + ## Security -- **Firewall Protection** — UFW blocks all incoming traffic except SSH (22) -- **Docker Isolation** — Containers are isolated and can't bypass the firewall -- **Non-root User** — OpenClaw runs as a dedicated `roboclaw` user -- **SSH Key Authentication** — Supports ed25519 and RSA keys -- **Encrypted Credentials** — API tokens and passwords are stored securely +- **Firewall**: UFW blocks all incoming except SSH (22) +- **Docker Isolation**: DOCKER-USER chain prevents containers bypassing firewall +- **Non-root**: Runs as dedicated `roboclaw` user +- **SSH Key**: Auto-generated ed25519 key, gitignored +- **API Token**: Stored in .env, gitignored + +## Troubleshooting + +### Check if provisioning was successful +```bash +# Run validation to diagnose issues +./cli/validate-instance.sh + +# Shows exactly which checks pass/fail: +# - SSH connectivity +# - Software versions +# - RoboClaw installation +# - Firewall configuration +# - Docker setup +``` + +### "Permission denied" when provisioning +Your API token is read-only. Create a new token with **Read & Write** permissions. + +### "Server type unavailable" +Run `./cli/list-server-types.sh` to see available types in Helsinki. + +### Can't SSH to server +```bash +# Wait 30-60 seconds after provisioning +# Test with verbose output +ssh -i hetzner_key -v root@$(cat finland-instance-ip.txt) + +# Or use validation script +./cli/validate-instance.sh +``` + +### Playbook fails mid-run +Re-run it. The playbook is idempotent (safe to run multiple times). + +```bash +# After re-running, validate the instance +./cli/run-hetzner.sh +./cli/validate-instance.sh +``` + +### Want to start fresh +```bash +# Delete server +./cli/run-hetzner.sh delete +# This renames the artifact to finland-instance_deleted.yml + +# Remove local files (optional) +rm hetzner_key hetzner_key.pub finland-instance-ip.txt + +# Remove deleted instance artifacts (optional) +rm instances/*_deleted.yml + +# Provision again +./cli/run-hetzner.sh + +# Validate +./cli/validate-instance.sh +``` + +## Complete Workflows + +### Deploy to Existing Server - Complete Example -## Join the Community +```bash +# One command to deploy and auto-onboard +./cli/run-deploy.sh 192.168.1.100 -k ~/.ssh/prod-key -n production + +# The script will: +# - Auto-install Python 3.12+, venv, Ansible, dependencies if needed +# - Deploy OpenClaw + all dependencies +# - Create artifact at ./instances/production.yml +# - Automatically launch 'openclaw onboard' wizard +# - Drop you into interactive configuration + +# Later, reconnect if needed +./cli/connect-instance.sh production onboard +``` + +### Deploy Multiple Servers + +```bash +# Server 1 +./cli/run-deploy.sh 192.168.1.100 -k ~/.ssh/key -n server1 + +# Server 2 +./cli/run-deploy.sh 192.168.1.101 -k ~/.ssh/key -n server2 + +# List all instances +ls -la ./instances/ +``` + +## Examples + +### Provision Multiple Servers (Hetzner) + +```bash +# Edit server name in hetzner-finland-fast.yml +vim hetzner-finland-fast.yml +# Change: server_name: "finland-instance-2" + +# Run provisioning +./cli/run-hetzner.sh + +# Validate the new instance +./cli/validate-instance.sh finland-instance-2 + +# List all servers +./cli/run-hetzner.sh list +``` + +### Use Different Instance Type + +```bash +# See available types +./cli/list-server-types.sh + +# Edit hetzner-finland-fast.yml +vim hetzner-finland-fast.yml +# Change: server_type: "cax21" # 4 vCPU, 8GB RAM -**🎙️ Active Development in Progress!** +# Provision +./cli/run-hetzner.sh -RoboClaw is being built live in the OpenClaw Discord community. Join us in the voice chat channels to: -- Watch development happen in real-time -- Contribute ideas and feedback -- Help shape the project -- Connect with other community members +# Validate +./cli/validate-instance.sh +``` + +### Validate Provisioning + +```bash +# Check if provisioning was successful +./cli/validate-instance.sh + +# Example successful output: +# ✓ All validation checks passed! +# Instance: finland-instance +# Checks Passed: 17 +# Checks Failed: 0 -**Links:** -- **Discord**: [discord.gg/8DaPXhRFfv](https://discord.gg/8DaPXhRFfv) — Join the voice chat! -- **X (Twitter)**: [@RoboClawX](https://x.com/RoboClawX) — Stay updated with the latest news -- **GitHub**: [github.com/hintjen/roboclaw](https://github.com/hintjen/roboclaw) -- **Website**: [roboclaw.ai](https://roboclaw.ai) +# Validate a specific instance +./cli/validate-instance.sh my-server -## Coming Soon +# If validation fails, it shows which checks failed +# Then you can re-provision or fix specific issues +``` + +### Delete Specific Server -- **RoboClaw UI** — Visual deployment interface (currently shown on website) -- **RoboClaw Cloud** — Managed hosting with zero infrastructure hassle -- **Community Marketplace** — Browse and deploy workflows, plugins, and skills from the OpenClaw community +```bash +# List servers first +./cli/run-hetzner.sh list + +# Delete by name +./cli/run-hetzner.sh delete -e server_name=finland-instance-2 +``` ## Documentation -Coming soon +- **README.md** (this file): Quick start and common commands +- **PROVISION.md**: Detailed technical documentation, architecture, design decisions +- **HETZNER_SETUP.md**: Original setup guide +- **roboclaw/**: RoboClaw source code (submodule) + +## Resources + +- Hetzner Cloud Console: https://console.hetzner.cloud/ +- Hetzner API Docs: https://docs.hetzner.cloud/ +- Hetzner Pricing: https://www.hetzner.com/cloud +- Ansible Docs: https://docs.ansible.com/ ## License -AGPL-3.0 +See roboclaw/ for RoboClaw licensing. ## Support -- For deployment issues, join our [Discord](https://discord.gg/8DaPXhRFfv) -- For updates and announcements, follow [@RoboClawX](https://x.com/RoboClawX) -- For OpenClaw issues, see the [OpenClaw repository](https://github.com/openclaw/openclaw) +For issues with: +- **Provisioning/teardown**: Check PROVISION.md +- **RoboClaw**: See roboclaw/README.md +- **Hetzner API**: Check Hetzner Cloud Console --- -Made with Love by [Hintjen](https://github.com/hintjen). Powered by ClawFleet and [OpenClaw](https://github.com/openclaw/openclaw). +## TLDR + +**Deploy to existing server (recommended):** +```bash +# One command - auto-installs dependencies, deploys, and onboards +./cli/run-deploy.sh -k ~/.ssh/key -n my-server +# ↑ Automatically launches 'openclaw onboard' wizard +``` + +**Or provision new Hetzner server:** +```bash +echo 'HCLOUD_TOKEN=your-token' > .env +./cli/run-hetzner.sh # Provision (~2-3 min) +./cli/validate-instance.sh # Validate (17 checks) +ssh -i hetzner_key root@$(cat finland-instance-ip.txt) +sudo su - roboclaw +openclaw onboard --install-daemon +``` diff --git a/clawdbot-ansible b/clawdbot-ansible new file mode 160000 index 0000000..6d3c52e --- /dev/null +++ b/clawdbot-ansible @@ -0,0 +1 @@ +Subproject commit 6d3c52e24fc896a5d100b633d15f7d76b63bc130 diff --git a/cli/ansible.cfg b/cli/ansible.cfg new file mode 100644 index 0000000..db4923f --- /dev/null +++ b/cli/ansible.cfg @@ -0,0 +1,23 @@ +[defaults] +# Disable SSH host key checking for dynamic cloud instances +host_key_checking = False + +# Don't create .retry files +retry_files_enabled = False + +# Increase SSH timeout for slower connections +timeout = 30 + +# Reduce fact gathering time +gathering = smart +fact_caching = memory + +[ssh_connection] +# Additional SSH args to ensure host key checking is disabled +ssh_args = -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ControlMaster=auto -o ControlPersist=60s + +# Enable SSH pipelining for faster execution (reduces number of SSH operations) +pipelining = True + +# Connection timeout +timeout = 10 diff --git a/cli/cleanup-ssh-key.yml b/cli/cleanup-ssh-key.yml new file mode 100644 index 0000000..6c05da3 --- /dev/null +++ b/cli/cleanup-ssh-key.yml @@ -0,0 +1,35 @@ +--- +- name: Cleanup SSH keys + hosts: localhost + gather_facts: false + + vars: + hcloud_token: "{{ lookup('env', 'HCLOUD_TOKEN') }}" + key_name: "{{ lookup('env', 'KEY_NAME') }}" + + tasks: + - name: Get all SSH keys + hetzner.hcloud.ssh_key_info: + api_token: "{{ hcloud_token }}" + register: ssh_keys + + - name: Display all SSH keys + ansible.builtin.debug: + msg: | + 📋 SSH Keys: + {% for key in ssh_keys.hcloud_ssh_key_info %} + • {{ key.name }} ({{ key.fingerprint }}) + {% endfor %} + + - name: Delete specific SSH key + hetzner.hcloud.ssh_key: + api_token: "{{ hcloud_token }}" + name: "{{ key_name }}" + state: absent + when: key_name != "" + register: delete_result + + - name: Show deletion result + ansible.builtin.debug: + msg: "✅ Deleted SSH key: {{ key_name }}" + when: key_name != "" and delete_result.changed diff --git a/cli/connect-instance.sh b/cli/connect-instance.sh new file mode 100755 index 0000000..1570376 --- /dev/null +++ b/cli/connect-instance.sh @@ -0,0 +1,134 @@ +#!/bin/bash +set -e + +# Change to script directory (cli/) to ensure relative paths work +cd "$(dirname "$0")" + +# Connect to RoboClaw instance and run OpenClaw commands +# +# Usage: +# ./connect-instance.sh # Connect using instance artifact +# ./connect-instance.sh setup # Run openclaw setup +# ./connect-instance.sh onboard # Run openclaw onboard +# ./connect-instance.sh --ip --key [command] # Connect using custom IP/key +# +# Examples: +# ./connect-instance.sh ROBOCLAW-INT-TEST setup +# ./connect-instance.sh ROBOCLAW-INT-TEST onboard +# ./connect-instance.sh ROBOCLAW-INT-TEST # Interactive shell +# ./connect-instance.sh --ip 77.42.73.229 --key ./ssh-keys/key setup + +# Parse arguments +INSTANCE_NAME="" +IP="" +SSH_KEY="" +OPENCLAW_CMD="" + +while [[ $# -gt 0 ]]; do + case $1 in + --ip) + IP="$2" + shift 2 + ;; + --key) + SSH_KEY="$2" + shift 2 + ;; + -h|--help) + echo "Connect to RoboClaw instance and run OpenClaw commands" + echo "" + echo "Usage:" + echo " ./connect-instance.sh [command]" + echo " ./connect-instance.sh --ip --key [command]" + echo "" + echo "Commands:" + echo " onboard Run 'openclaw onboard' - full interactive setup wizard (recommended)" + echo " setup Run 'openclaw setup' - minimal config initialization" + echo " (none) Open interactive shell as roboclaw user" + echo "" + echo "Examples:" + echo " ./connect-instance.sh ROBOCLAW-INT-TEST onboard" + echo " ./connect-instance.sh ROBOCLAW-INT-TEST" + echo " ./connect-instance.sh --ip 77.42.73.229 --key ./ssh-keys/key onboard" + exit 0 + ;; + setup|onboard|configure|status|help) + OPENCLAW_CMD="$1" + shift + ;; + *) + if [ -z "$INSTANCE_NAME" ]; then + INSTANCE_NAME="$1" + fi + shift + ;; + esac +done + +# Determine connection details +if [ -n "$INSTANCE_NAME" ]; then + # Read from instance artifact + ARTIFACT="../instances/${INSTANCE_NAME}.yml" + if [ ! -f "$ARTIFACT" ]; then + echo "Error: Instance artifact not found: $ARTIFACT" + echo "" + echo "Available instances:" + ls -1 ../instances/*.yml 2>/dev/null | xargs -n1 basename | sed 's/.yml$//' | sed 's/^/ - /' + exit 1 + fi + + IP=$(grep '^\s*ip:' "$ARTIFACT" | head -1 | awk '{print $2}') + SSH_KEY=$(grep '^\s*key_file:' "$ARTIFACT" | head -1 | awk '{print $2}' | tr -d '"' | sed 's/^"\(.*\)"$/\1/') + + if [ -z "$IP" ]; then + echo "Error: Could not extract IP from $ARTIFACT" + exit 1 + fi + + if [ -z "$SSH_KEY" ]; then + echo "Error: Could not extract key_file from $ARTIFACT" + exit 1 + fi +fi + +# Validate we have connection details +if [ -z "$IP" ]; then + echo "Error: No IP address specified. Use or --ip
" + exit 1 +fi + +if [ -z "$SSH_KEY" ]; then + echo "Error: No SSH key specified. Use or --key " + exit 1 +fi + +if [ ! -f "$SSH_KEY" ]; then + echo "Error: SSH key not found: $SSH_KEY" + exit 1 +fi + +# Display connection info +echo "🔗 Connecting to RoboClaw instance" +echo " IP: $IP" +echo " SSH Key: $SSH_KEY" +if [ -n "$OPENCLAW_CMD" ]; then + echo " Command: openclaw $OPENCLAW_CMD" +fi +echo "" + +# Build the remote command +if [ -n "$OPENCLAW_CMD" ]; then + # Run specific openclaw command + REMOTE_CMD="su - roboclaw -c 'openclaw $OPENCLAW_CMD'" +else + # Interactive shell as roboclaw user + REMOTE_CMD="su - roboclaw" +fi + +# Connect via SSH +ssh -i "$SSH_KEY" \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + -t \ + root@"$IP" \ + "$REMOTE_CMD" diff --git a/cli/create-inventory.sh b/cli/create-inventory.sh new file mode 100755 index 0000000..d898939 --- /dev/null +++ b/cli/create-inventory.sh @@ -0,0 +1,43 @@ +#!/bin/bash +set -e + +# Create Ansible inventory file from IP address +# +# Usage: +# ./create-inventory.sh [output-file] +# +# Examples: +# ./create-inventory.sh 1.2.3.4 +# ./create-inventory.sh 1.2.3.4 production-inventory.ini + +IP="$1" +OUTPUT_FILE="${2:-inventory.ini}" + +if [ -z "$IP" ]; then + echo "Error: IP address required" + echo "" + echo "Usage: ./create-inventory.sh [output-file]" + echo "" + echo "Examples:" + echo " ./create-inventory.sh 1.2.3.4" + echo " ./create-inventory.sh 1.2.3.4 production-inventory.ini" + exit 1 +fi + +# Validate IP format (basic check) +if ! echo "$IP" | grep -qE '^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$'; then + echo "Error: Invalid IP address format: $IP" + exit 1 +fi + +# Create inventory file +cat > "$OUTPUT_FILE" << EOF +[servers] +$IP ansible_user=root +EOF + +echo "✅ Created inventory file: $OUTPUT_FILE" +echo " IP: $IP" +echo "" +echo "Deploy with:" +echo " ./run-deploy.sh -k -i $OUTPUT_FILE" diff --git a/cli/hetzner-finland-fast.yml b/cli/hetzner-finland-fast.yml new file mode 100644 index 0000000..9ca220f --- /dev/null +++ b/cli/hetzner-finland-fast.yml @@ -0,0 +1,483 @@ +--- +- name: Create Hetzner Cloud instance in Finland (Helsinki) + hosts: localhost + gather_facts: false + + vars: + hcloud_token: "{{ lookup('env', 'HCLOUD_TOKEN') }}" + server_name: "{{ lookup('env', 'SERVER_NAME') | default('finland-instance', true) }}" + server_type: "{{ lookup('env', 'SERVER_TYPE') | default('cax11', true) }}" # 2 vCPU, 4GB RAM, 40GB disk (~€3.29/month, ARM) + location: "{{ lookup('env', 'LOCATION') | default('hel1', true) }}" # Helsinki, Finland + image: "{{ lookup('env', 'IMAGE') | default('ubuntu-24.04', true) }}" + ssh_key_name: "{{ server_name }}-key" + ssh_public_key: "{{ lookup('env', 'SSH_PUBLIC_KEY') | default('', true) }}" + ssh_private_key_path: "{{ lookup('env', 'SSH_PRIVATE_KEY_PATH') | default('./hetzner_key', true) }}" + + tasks: + - name: Debug provisioning parameters + ansible.builtin.debug: + msg: | + 🔍 Provisioning with: + Server Name: {{ server_name }} + Server Type: {{ server_type }} + Location: {{ location }} + Image: {{ image }} + + - name: Fail if HCLOUD_TOKEN is not set + ansible.builtin.fail: + msg: "Please set HCLOUD_TOKEN environment variable with your Hetzner API token" + when: hcloud_token == "" + + - name: Fail if SSH_PUBLIC_KEY is not set + ansible.builtin.fail: + msg: "Please set SSH_PUBLIC_KEY environment variable with your public key" + when: ssh_public_key == "" + + - name: Create or update SSH key in Hetzner + hetzner.hcloud.ssh_key: + api_token: "{{ hcloud_token }}" + name: "{{ ssh_key_name }}" + public_key: "{{ ssh_public_key }}" + state: present + register: ssh_key + + - name: Delete existing server if it exists (to force fresh deployment with new SSH key) + hetzner.hcloud.server: + api_token: "{{ hcloud_token }}" + name: "{{ server_name }}" + state: absent + ignore_errors: true + + - name: Create Hetzner Cloud server in Helsinki + hetzner.hcloud.server: + api_token: "{{ hcloud_token }}" + name: "{{ server_name }}" + server_type: "{{ server_type }}" + image: "{{ image }}" + location: "{{ location }}" + ssh_keys: + - "{{ ssh_key_name }}" + state: present + register: server + + - name: Display server information + ansible.builtin.debug: + msg: | + ✅ Server created successfully! + + Name: {{ server.hcloud_server.name }} + IPv4: {{ server.hcloud_server.ipv4_address }} + Type: {{ server.hcloud_server.server_type }} + Location: {{ server.hcloud_server.location }} (Finland) + + - name: Save server IP to file + ansible.builtin.copy: + content: "{{ server.hcloud_server.ipv4_address }}" + dest: "./finland-instance-ip.txt" + mode: '0644' + + - name: Add server to in-memory inventory + ansible.builtin.add_host: + name: "{{ server.hcloud_server.ipv4_address }}" + groups: finland_vps + ansible_user: root + ansible_ssh_private_key_file: "{{ ssh_private_key_path }}" + ansible_ssh_common_args: '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null' + # Store variables for later use in second play + deployment_ssh_key_path: "{{ ssh_private_key_path }}" + deployment_server_name: "{{ server.hcloud_server.name }}" + deployment_server_type: "{{ server.hcloud_server.server_type }}" + deployment_server_location: "{{ server.hcloud_server.location }}" + deployment_server_image: "{{ server.hcloud_server.image }}" + + - name: Wait for SSH to become available + ansible.builtin.wait_for: + host: "{{ server.hcloud_server.ipv4_address }}" + port: 22 + delay: 5 + timeout: 300 + state: started + +- name: Fast Install RoboClaw (Essentials Only) + hosts: finland_vps + gather_facts: true + become: true + + vars: + ansible_python_interpreter: /usr/bin/python3 + roboclaw_user: roboclaw + roboclaw_home: "/home/{{ roboclaw_user }}" + roboclaw_config_dir: "{{ roboclaw_home }}/.roboclaw" + nodejs_version: "22.x" + + tasks: + - name: Display fast install mode + ansible.builtin.debug: + msg: | + ⚡ FAST INSTALL MODE ⚡ + + Installing essentials only: + ✅ Docker CE + ✅ Node.js 22 + pnpm + ✅ UFW Firewall + ✅ RoboClaw + ✅ Gemini CLI + ✅ ttyd (browser terminal) + + Skipping (for speed): + ⏭️ Homebrew + ⏭️ oh-my-zsh + ⏭️ 46 extra system tools + ⏭️ Git aliases + ⏭️ dist-upgrade + + Expected time: ~2-3 minutes (vs ~10-15 minutes) + + - name: Update apt cache only (skip dist-upgrade) + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + + - name: Install minimal essential packages + ansible.builtin.apt: + name: + - curl + - wget + - git + - ca-certificates + - gnupg + - lsb-release + state: present + + # Create roboclaw user + - name: Create roboclaw system user + ansible.builtin.user: + name: "{{ roboclaw_user }}" + comment: "RoboClaw system user" + shell: /bin/bash + create_home: true + home: "{{ roboclaw_home }}" + + - name: Add roboclaw user to sudoers with NOPASSWD + ansible.builtin.copy: + content: "{{ roboclaw_user }} ALL=(ALL) NOPASSWD:ALL\n" + dest: "/etc/sudoers.d/{{ roboclaw_user }}" + mode: '0440' + validate: 'visudo -cf %s' + + - name: Enable lingering for roboclaw user + ansible.builtin.command: loginctl enable-linger {{ roboclaw_user }} + changed_when: false + + # Install Docker CE (fast - no extra config) + - name: Add Docker GPG key + ansible.builtin.shell: + cmd: | + install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg + chmod a+r /etc/apt/keyrings/docker.gpg + creates: /etc/apt/keyrings/docker.gpg + + - name: Add Docker repository + ansible.builtin.shell: + cmd: | + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null + creates: /etc/apt/sources.list.d/docker.list + + - name: Install Docker CE + ansible.builtin.apt: + name: + - docker-ce + - docker-ce-cli + - containerd.io + state: present + update_cache: true + + - name: Add roboclaw user to docker group + ansible.builtin.user: + name: "{{ roboclaw_user }}" + groups: docker + append: true + + - name: Start and enable Docker service + ansible.builtin.systemd: + name: docker + state: started + enabled: true + + # Configure UFW firewall (fast - minimal rules) + - name: Install UFW + ansible.builtin.apt: + name: ufw + state: present + + - name: Set UFW default policies + community.general.ufw: + direction: "{{ item.direction }}" + policy: "{{ item.policy }}" + loop: + - { direction: 'incoming', policy: 'deny' } + - { direction: 'outgoing', policy: 'allow' } + + - name: Allow SSH on port 22 + community.general.ufw: + rule: allow + port: '22' + proto: tcp + + - name: Enable UFW + community.general.ufw: + state: enabled + + # Install Node.js 22 (fast) + - name: Add NodeSource GPG key + ansible.builtin.shell: + cmd: | + curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /usr/share/keyrings/nodesource.gpg + creates: /usr/share/keyrings/nodesource.gpg + + - name: Add NodeSource repository + ansible.builtin.shell: + cmd: | + echo "deb [signed-by=/usr/share/keyrings/nodesource.gpg] https://deb.nodesource.com/node_{{ nodejs_version }} nodistro main" | tee /etc/apt/sources.list.d/nodesource.list + creates: /etc/apt/sources.list.d/nodesource.list + + - name: Install Node.js + ansible.builtin.apt: + name: nodejs + state: present + update_cache: true + + - name: Install pnpm globally + ansible.builtin.shell: + cmd: npm install -g pnpm + creates: /usr/bin/pnpm + + - name: Install ttyd for browser-based terminal + ansible.builtin.apt: + name: ttyd + state: present + + # Install RoboClaw (fast - from npm, minimal config) + - name: Create RoboClaw directories + ansible.builtin.file: + path: "{{ item.path }}" + state: directory + owner: "{{ roboclaw_user }}" + group: "{{ roboclaw_user }}" + mode: "{{ item.mode }}" + loop: + - { path: "{{ roboclaw_config_dir }}", mode: '0755' } + - { path: "{{ roboclaw_config_dir }}/sessions", mode: '0755' } + - { path: "{{ roboclaw_config_dir }}/credentials", mode: '0700' } + - { path: "{{ roboclaw_config_dir }}/data", mode: '0755' } + - { path: "{{ roboclaw_config_dir }}/logs", mode: '0755' } + - { path: "{{ roboclaw_home }}/.local/share/pnpm", mode: '0755' } + - { path: "{{ roboclaw_home }}/.local/bin", mode: '0755' } + + - name: Configure pnpm for roboclaw user + ansible.builtin.shell: + cmd: | + pnpm config set global-dir {{ roboclaw_home }}/.local/share/pnpm + pnpm config set global-bin-dir {{ roboclaw_home }}/.local/bin + executable: /bin/bash + become: true + become_user: "{{ roboclaw_user }}" + changed_when: true + + - name: Install OpenClaw globally from npm + ansible.builtin.shell: + cmd: pnpm install -g openclaw@latest + executable: /bin/bash + become: true + become_user: "{{ roboclaw_user }}" + environment: + HOME: "{{ roboclaw_home }}" + PNPM_HOME: "{{ roboclaw_home }}/.local/share/pnpm" + PATH: "{{ roboclaw_home }}/.local/bin:{{ ansible_env.PATH }}" + register: roboclaw_install + + - name: Configure minimal .bashrc for roboclaw user + ansible.builtin.blockinfile: + path: "{{ roboclaw_home }}/.bashrc" + marker: "# {mark} ANSIBLE MANAGED BLOCK - RoboClaw" + block: | + # pnpm configuration + export PNPM_HOME="{{ roboclaw_home }}/.local/share/pnpm" + export PATH="{{ roboclaw_home }}/.local/bin:$PNPM_HOME:$PATH" + create: true + owner: "{{ roboclaw_user }}" + group: "{{ roboclaw_user }}" + mode: '0644' + + - name: Configure .profile for roboclaw user (for non-interactive login shells) + ansible.builtin.blockinfile: + path: "{{ roboclaw_home }}/.profile" + marker: "# {mark} ANSIBLE MANAGED BLOCK - RoboClaw" + block: | + # pnpm configuration + export PNPM_HOME="{{ roboclaw_home }}/.local/share/pnpm" + export PATH="{{ roboclaw_home }}/.local/bin:$PNPM_HOME:$PATH" + create: false + owner: "{{ roboclaw_user }}" + group: "{{ roboclaw_user }}" + mode: '0644' + + - name: Install Gemini CLI globally + ansible.builtin.shell: + cmd: pnpm install -g @google/gemini-cli + executable: /bin/bash + become: true + become_user: "{{ roboclaw_user }}" + environment: + HOME: "{{ roboclaw_home }}" + PNPM_HOME: "{{ roboclaw_home }}/.local/share/pnpm" + PATH: "{{ roboclaw_home }}/.local/bin:{{ ansible_env.PATH }}" + register: gemini_install + + - name: Extract Gemini CLI OAuth credentials from pnpm installation + ansible.builtin.shell: | + OAUTH_FILE=$(find {{ roboclaw_home }}/.local/share/pnpm -path '*gemini-cli-core*/oauth2.js' 2>/dev/null | head -1) + if [ -f "$OAUTH_FILE" ]; then + CLIENT_ID=$(grep -oP "OAUTH_CLIENT_ID = '\K[^']+" "$OAUTH_FILE" || echo "") + CLIENT_SECRET=$(grep -oP "OAUTH_CLIENT_SECRET = '\K[^']+" "$OAUTH_FILE" || echo "") + if [ -n "$CLIENT_ID" ] && [ -n "$CLIENT_SECRET" ]; then + echo "CLIENT_ID=$CLIENT_ID" + echo "CLIENT_SECRET=$CLIENT_SECRET" + fi + fi + register: gemini_oauth_creds + changed_when: false + failed_when: false + + - name: Add Gemini OAuth credentials to .profile + ansible.builtin.blockinfile: + path: "{{ roboclaw_home }}/.profile" + marker: "# {mark} ANSIBLE MANAGED BLOCK - Gemini OAuth" + block: | + # Gemini CLI OAuth credentials (extracted from pnpm installation) + export GEMINI_CLI_OAUTH_CLIENT_ID='{{ gemini_oauth_creds.stdout_lines[0].split('=')[1] }}' + export GEMINI_CLI_OAUTH_CLIENT_SECRET='{{ gemini_oauth_creds.stdout_lines[1].split('=')[1] }}' + create: false + owner: "{{ roboclaw_user }}" + group: "{{ roboclaw_user }}" + mode: '0644' + when: gemini_oauth_creds.stdout_lines | length >= 2 + + - name: Verify roboclaw installation + ansible.builtin.shell: | + export PATH="{{ roboclaw_home }}/.local/bin:$PATH" + openclaw --version + become: true + become_user: "{{ roboclaw_user }}" + register: roboclaw_version + changed_when: false + + - name: Verify gemini installation + ansible.builtin.shell: | + export PATH="{{ roboclaw_home }}/.local/bin:$PATH" + gemini --version + become: true + become_user: "{{ roboclaw_user }}" + register: gemini_version + changed_when: false + failed_when: false + + - name: Display installation complete + ansible.builtin.debug: + msg: | + ⚡ FAST INSTALL COMPLETE! ⚡ + + Version: {{ roboclaw_version.stdout }} + Server: {{ ansible_default_ipv4.address }} + Install time: ~2-3 minutes (vs ~10-15 with full install) + + ✅ Installed: + • Docker CE + • Node.js {{ nodejs_version }} + • pnpm (latest) + • UFW Firewall + • RoboClaw {{ roboclaw_version.stdout }} + • Gemini CLI {{ gemini_version.stdout | default('installed', true) }} + • ttyd (browser terminal) + + ⏭️ Skipped for speed: + • Homebrew (not needed on Linux) + • oh-my-zsh (use bash instead) + • 46 extra packages (debugging tools, etc.) + • Git aliases + • System dist-upgrade + + Next steps: + Complete onboarding from the dashboard at http://localhost:3000/instances + + To install extras later: + apt install zsh tmux htop vim jq + + - name: Create instances directory on local machine + ansible.builtin.file: + path: "./instances" + state: directory + mode: '0755' + delegate_to: localhost + become: false + + - name: Get Docker version + ansible.builtin.command: docker --version + register: docker_version_output + changed_when: false + + - name: Get Node.js version + ansible.builtin.command: node --version + register: nodejs_version_output + changed_when: false + + - name: Get pnpm version + ansible.builtin.command: pnpm --version + register: pnpm_version_output + changed_when: false + + - name: Create instance artifact + ansible.builtin.copy: + content: | + # Instance provisioned on {{ ansible_date_time.iso8601 }} + instances: + - name: {{ deployment_server_name }} + ip: {{ ansible_default_ipv4.address }} + server_type: {{ deployment_server_type }} + location: {{ deployment_server_location }} + image: {{ deployment_server_image }} + provisioned_at: {{ ansible_date_time.iso8601 }} + install_mode: fast + onboarding_completed: false + software: + os: {{ ansible_distribution }} {{ ansible_distribution_version }} + kernel: {{ ansible_kernel }} + docker: {{ docker_version_output.stdout }} + nodejs: {{ nodejs_version_output.stdout }} + pnpm: {{ pnpm_version_output.stdout }} + roboclaw: {{ roboclaw_version.stdout }} + gemini: {{ gemini_version.stdout | default('installed', true) }} + ttyd: installed + configuration: + roboclaw_user: {{ roboclaw_user }} + roboclaw_home: {{ roboclaw_home }} + roboclaw_config_dir: {{ roboclaw_config_dir }} + firewall: + ufw_enabled: true + allowed_ports: + - port: 22 + proto: tcp + description: SSH + ssh: + key_file: "{{ deployment_ssh_key_path }}" + public_key_file: "{{ deployment_ssh_key_path }}.pub" + dest: "../instances/{{ deployment_server_name }}.yml" + mode: '0644' + delegate_to: localhost + become: false + + - name: Display artifact saved message + ansible.builtin.debug: + msg: | + 📝 Instance artifact saved to: instances/{{ deployment_server_name }}.yml diff --git a/cli/hetzner-requirements.yml b/cli/hetzner-requirements.yml new file mode 100644 index 0000000..87a250e --- /dev/null +++ b/cli/hetzner-requirements.yml @@ -0,0 +1,4 @@ +--- +collections: + - name: hetzner.hcloud + version: ">=3.0.0" diff --git a/cli/hetzner-teardown.yml b/cli/hetzner-teardown.yml new file mode 100644 index 0000000..f4772a3 --- /dev/null +++ b/cli/hetzner-teardown.yml @@ -0,0 +1,146 @@ +--- +- name: List and Teardown Hetzner Cloud instances + hosts: localhost + gather_facts: false + + vars: + hcloud_token: "{{ lookup('env', 'HCLOUD_TOKEN') }}" + target_server_name: "{{ server_name | default('finland-instance') }}" + remove_ssh_key: "{{ delete_ssh_key | default(false) }}" + ssh_key_name: "my-ssh-key" + auto_confirm: "{{ confirm_delete | default('no') }}" + + tasks: + - name: Fail if HCLOUD_TOKEN is not set + ansible.builtin.fail: + msg: "Please set HCLOUD_TOKEN in .env file" + when: hcloud_token == "" + + # List servers + - name: Get all servers + hetzner.hcloud.server_info: + api_token: "{{ hcloud_token }}" + register: all_servers + tags: [list, delete] + + - name: Display all servers + ansible.builtin.debug: + msg: | + 📋 Hetzner Cloud Servers: + + {% for server in all_servers.hcloud_server_info %} + • {{ server.name }} + IP: {{ server.ipv4_address }} + Type: {{ server.server_type }} + Location: {{ server.location }} + Status: {{ server.status }} + {% endfor %} + + Total: {{ all_servers.hcloud_server_info | length }} server(s) + tags: [list] + + # Delete server + - name: Get specific server information + ansible.builtin.set_fact: + server_to_delete: "{{ all_servers.hcloud_server_info | selectattr('name', 'equalto', target_server_name) | list | first }}" + when: all_servers.hcloud_server_info | selectattr('name', 'equalto', target_server_name) | list | length > 0 + tags: [delete] + + - name: Fail if server not found + ansible.builtin.fail: + msg: "Server '{{ target_server_name }}' not found in Hetzner Cloud" + when: server_to_delete is not defined + tags: [delete] + + - name: Display server to be deleted + ansible.builtin.debug: + msg: | + ⚠️ WARNING: About to DELETE: + + Name: {{ server_to_delete.name }} + IP: {{ server_to_delete.ipv4_address }} + Type: {{ server_to_delete.server_type }} + Location: {{ server_to_delete.location }} + tags: [delete] + + - name: Confirm deletion + ansible.builtin.pause: + prompt: "Type 'yes' to confirm deletion of {{ target_server_name }}" + register: confirm + when: auto_confirm != "yes" + tags: [delete] + + - name: Abort if not confirmed + ansible.builtin.fail: + msg: "Deletion cancelled by user" + when: auto_confirm != "yes" and confirm.user_input != "yes" + tags: [delete] + + - name: Delete Hetzner Cloud server + hetzner.hcloud.server: + api_token: "{{ hcloud_token }}" + name: "{{ target_server_name }}" + state: absent + register: server_deleted + tags: [delete] + + - name: Delete SSH key from Hetzner + hetzner.hcloud.ssh_key: + api_token: "{{ hcloud_token }}" + name: "{{ ssh_key_name }}" + state: absent + when: remove_ssh_key | bool + register: ssh_key_deleted + tags: [delete] + + - name: Remove local IP file + ansible.builtin.file: + path: "./finland-instance-ip.txt" + state: absent + tags: [delete] + + - name: Update instance artifact with deletion timestamp + ansible.builtin.lineinfile: + path: "../instances/{{ target_server_name }}.yml" + insertafter: "^ provisioned_at:" + line: " deleted_at: {{ ansible_date_time.iso8601 }}" + state: present + when: server_deleted.changed + ignore_errors: true + tags: [delete] + + - name: Mark instance as deleted in artifact + ansible.builtin.lineinfile: + path: "../instances/{{ target_server_name }}.yml" + insertafter: "^ install_mode:" + line: " status: deleted" + state: present + when: server_deleted.changed + ignore_errors: true + tags: [delete] + + - name: Rename artifact file to indicate deletion + ansible.builtin.command: + cmd: mv "../instances/{{ target_server_name }}.yml" "../instances/{{ target_server_name }}_deleted.yml" + when: server_deleted.changed + ignore_errors: true + tags: [delete] + + - name: Display teardown complete + ansible.builtin.debug: + msg: | + ✅ Teardown complete! + + Deleted: + - Server: {{ target_server_name }} + {% if ssh_key_deleted.changed %} + - SSH key: {{ ssh_key_name }} + {% endif %} + - Local IP file + + Updated: + - Instance artifact renamed: instances/{{ target_server_name }}_deleted.yml + + To remove local SSH keys: rm hetzner_key hetzner_key.pub + To remove artifact history: rm instances/{{ target_server_name }}_deleted.yml + tags: [delete] diff --git a/cli/list-server-types.sh b/cli/list-server-types.sh new file mode 100755 index 0000000..77ef9c9 --- /dev/null +++ b/cli/list-server-types.sh @@ -0,0 +1,82 @@ +#!/bin/bash +set -e + +# Load .env file +if [ -f .env ]; then + export $(grep -v '^#' .env | xargs) +fi + +if [ -z "$HCLOUD_TOKEN" ]; then + echo "Error: HCLOUD_TOKEN not set in .env" + exit 1 +fi + +echo "Fetching available server types from Hetzner Cloud..." +echo "" + +# Get all server types +curl -s -H "Authorization: Bearer $HCLOUD_TOKEN" \ + https://api.hetzner.cloud/v1/server_types | \ + python3 -c " +import json, sys + +data = json.load(sys.stdin) +output = [] + +output.append('=' * 80) +output.append('AVAILABLE HETZNER SERVER TYPES') +output.append('=' * 80) +output.append('') + +for st in data['server_types']: + if not st['deprecated']: + output.append(f\"Name: {st['name']}\") + output.append(f\" Description: {st['description']}\") + output.append(f\" vCPU: {st['cores']}\") + output.append(f\" RAM: {st['memory']}GB\") + output.append(f\" Disk: {st['disk']}GB\") + output.append(f\" Price/month: €{st['prices'][0]['price_monthly']['gross']}\") + output.append(f\" Architecture: {st['architecture']}\") + output.append('') + +print('\n'.join(output)) + +# Write to file +with open('available-server-types.txt', 'w') as f: + f.write('\n'.join(output)) + +print('✅ Server types saved to: available-server-types.txt') +" + +echo "" +echo "Checking which types are available in Helsinki (hel1)..." +echo "" + +# Get server types available in hel1 +curl -s -H "Authorization: Bearer $HCLOUD_TOKEN" \ + "https://api.hetzner.cloud/v1/server_types" | \ + python3 -c " +import json, sys + +data = json.load(sys.stdin) +output = [] + +output.append('=' * 80) +output.append('SERVER TYPES AVAILABLE IN HELSINKI (hel1)') +output.append('=' * 80) +output.append('') + +for st in data['server_types']: + if not st['deprecated']: + # Check if hel1 is in available locations + # Note: API doesn't directly provide per-location availability in server_types endpoint + # So we'll list all non-deprecated types + output.append(f\"{st['name']:15} - {st['cores']} vCPU, {st['memory']}GB RAM, {st['disk']}GB disk - €{st['prices'][0]['price_monthly']['gross']}/mo\") + +print('\n'.join(output)) + +# Append to file +with open('available-server-types.txt', 'a') as f: + f.write('\n\n') + f.write('\n'.join(output)) +" diff --git a/cli/openclaw-service.yml b/cli/openclaw-service.yml new file mode 100644 index 0000000..2adffb9 --- /dev/null +++ b/cli/openclaw-service.yml @@ -0,0 +1,132 @@ +--- +# Manage the openclaw systemd service on a remote instance +# Usage: ansible-playbook openclaw-service.yml -i "IP," --private-key=KEY -e "openclaw_state=started" + +- name: Manage OpenClaw service + hosts: all + remote_user: root + gather_facts: false + + vars: + openclaw_state: started # 'started' or 'stopped' + openclaw_enabled: true # whether to enable/disable at boot + openclaw_service_name: openclaw-gateway # actual systemd service name + instance_name: "" # instance name (passed from run-hetzner.sh) + + tasks: + - name: Get roboclaw user UID + ansible.builtin.command: id -u roboclaw + register: roboclaw_uid + changed_when: false + + - name: Check if openclaw service unit exists + ansible.builtin.stat: + path: /etc/systemd/system/{{ openclaw_service_name }}.service + register: openclaw_unit_file + + - name: Check if user systemd directory exists + ansible.builtin.stat: + path: /home/roboclaw/.config/systemd/user + register: user_systemd_dir + when: not openclaw_unit_file.stat.exists + + - name: Check user-level systemd unit + ansible.builtin.stat: + path: /home/roboclaw/.config/systemd/user/{{ openclaw_service_name }}.service + register: openclaw_user_unit_file + become: true + become_user: roboclaw + when: > + not openclaw_unit_file.stat.exists and + user_systemd_dir is defined and + user_systemd_dir.stat is defined and + user_systemd_dir.stat.exists + + - name: Fail gracefully if service unit not found + ansible.builtin.fail: + msg: > + The openclaw systemd service unit was not found at + /etc/systemd/system/{{ openclaw_service_name }}.service or + /home/roboclaw/.config/systemd/user/{{ openclaw_service_name }}.service. + Please run 'openclaw gateway install' to create the service. + when: > + not openclaw_unit_file.stat.exists and + (not user_systemd_dir is defined or + not user_systemd_dir.stat is defined or + not user_systemd_dir.stat.exists or + not openclaw_user_unit_file is defined or + not openclaw_user_unit_file.stat is defined or + not openclaw_user_unit_file.stat.exists) + + - name: Manage openclaw system service + ansible.builtin.systemd: + name: "{{ openclaw_service_name }}" + state: "{{ openclaw_state }}" + enabled: "{{ openclaw_enabled }}" + when: openclaw_unit_file.stat.exists + + - name: Manage openclaw user service + ansible.builtin.systemd: + name: "{{ openclaw_service_name }}" + state: "{{ openclaw_state }}" + enabled: "{{ openclaw_enabled }}" + scope: user + become: true + become_user: roboclaw + environment: + XDG_RUNTIME_DIR: "/run/user/{{ roboclaw_uid.stdout }}" + DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ roboclaw_uid.stdout }}/bus" + when: > + not openclaw_unit_file.stat.exists and + openclaw_user_unit_file is defined and + openclaw_user_unit_file.stat is defined and + openclaw_user_unit_file.stat.exists + + # Fetch gateway token and update instance YAML file (when starting service) + - name: Check if openclaw.json config exists + ansible.builtin.stat: + path: /home/roboclaw/.openclaw/openclaw.json + register: openclaw_config_file + when: openclaw_state == 'started' + + - name: Fetch openclaw.json from remote + ansible.builtin.slurp: + src: /home/roboclaw/.openclaw/openclaw.json + register: openclaw_config_content + when: openclaw_state == 'started' and openclaw_config_file.stat.exists + become: true + become_user: roboclaw + + - name: Extract gateway token from config + ansible.builtin.set_fact: + gateway_token: "{{ (openclaw_config_content.content | b64decode | from_json).gateway.auth.token | default('') }}" + when: > + openclaw_state == 'started' and + openclaw_config_file.stat.exists and + openclaw_config_content.content is defined + + - name: Build instance YAML path + ansible.builtin.set_fact: + instance_yaml_path: "../instances/{{ instance_name }}.yml" + when: > + openclaw_state == 'started' and + gateway_token is defined and + gateway_token != '' and + instance_name != '' + delegate_to: localhost + become: false + + - name: Update instance YAML with gateway token + ansible.builtin.lineinfile: + path: "{{ instance_yaml_path }}" + regexp: '^\s*gateway_token:' + line: " gateway_token: {{ gateway_token }}" + insertafter: '^\s*software:' + state: present + when: > + openclaw_state == 'started' and + gateway_token is defined and + gateway_token != '' and + instance_yaml_path is defined + delegate_to: localhost + become: false diff --git a/cli/quick-validate.sh b/cli/quick-validate.sh new file mode 100755 index 0000000..5b6b13b --- /dev/null +++ b/cli/quick-validate.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Quick validation script - tests openclaw on existing server +# Usage: ./quick-validate.sh [IP_ADDRESS] +# If no IP provided, uses finland-instance-ip.txt + +set -euo pipefail + +IP=${1:-$(cat finland-instance-ip.txt 2>/dev/null || echo "")} + +if [[ -z "$IP" ]]; then + echo "❌ Error: No IP address provided and finland-instance-ip.txt not found" + echo "Usage: $0 [IP_ADDRESS]" + exit 1 +fi + +echo "🔍 Validating OpenClaw on $IP..." +echo "" + +source venv/bin/activate +ansible-playbook validate-openclaw.yml -i "$IP," --private-key=hetzner_key diff --git a/cli/reconfigure.yml b/cli/reconfigure.yml new file mode 100644 index 0000000..6567768 --- /dev/null +++ b/cli/reconfigure.yml @@ -0,0 +1,269 @@ +--- +- name: Reconfigure existing RoboClaw instance + hosts: all + gather_facts: true + remote_user: root + become: true + + vars: + ansible_python_interpreter: /usr/bin/python3 + roboclaw_user: roboclaw + roboclaw_home: "/home/{{ roboclaw_user }}" + roboclaw_config_dir: "{{ roboclaw_home }}/.roboclaw" + nodejs_version: "22.x" + + tasks: + - name: Display reconfigure start message + ansible.builtin.debug: + msg: | + 🔄 RECONFIGURING INSTANCE 🔄 + + This playbook will apply configuration changes to your existing instance. + It is safe to re-run (idempotent). + + - name: Update apt cache + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + tags: always + + # User configuration + - name: Create roboclaw system user + ansible.builtin.user: + name: "{{ roboclaw_user }}" + comment: "RoboClaw system user" + shell: /bin/bash + create_home: true + home: "{{ roboclaw_home }}" + tags: [user, always] + + - name: Add roboclaw user to sudoers with NOPASSWD + ansible.builtin.copy: + content: "{{ roboclaw_user }} ALL=(ALL) NOPASSWD:ALL\n" + dest: "/etc/sudoers.d/{{ roboclaw_user }}" + mode: '0440' + validate: 'visudo -cf %s' + tags: [user, always] + + - name: Enable lingering for roboclaw user + ansible.builtin.command: loginctl enable-linger {{ roboclaw_user }} + changed_when: false + tags: [user, always] + + # Docker + - name: Install minimal essential packages for Docker + ansible.builtin.apt: + name: + - curl + - wget + - ca-certificates + - gnupg + - lsb-release + state: present + tags: [docker, always] + + - name: Add Docker GPG key + ansible.builtin.shell: + cmd: | + install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg + chmod a+r /etc/apt/keyrings/docker.gpg + creates: /etc/apt/keyrings/docker.gpg + tags: docker + + - name: Add Docker repository + ansible.builtin.shell: + cmd: | + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null + creates: /etc/apt/sources.list.d/docker.list + tags: docker + + - name: Install Docker CE + ansible.builtin.apt: + name: + - docker-ce + - docker-ce-cli + - containerd.io + state: present + update_cache: true + tags: docker + + - name: Add roboclaw user to docker group + ansible.builtin.user: + name: "{{ roboclaw_user }}" + groups: docker + append: true + tags: docker + + - name: Start and enable Docker service + ansible.builtin.systemd: + name: docker + state: started + enabled: true + tags: docker + + # Firewall + - name: Install UFW + ansible.builtin.apt: + name: ufw + state: present + tags: firewall + + - name: Set UFW default policies + community.general.ufw: + direction: "{{ item.direction }}" + policy: "{{ item.policy }}" + loop: + - { direction: 'incoming', policy: 'deny' } + - { direction: 'outgoing', policy: 'allow' } + tags: firewall + + - name: Allow SSH on port 22 + community.general.ufw: + rule: allow + port: '22' + proto: tcp + tags: firewall + + - name: Enable UFW + community.general.ufw: + state: enabled + tags: firewall + + # Node.js + pnpm + - name: Add NodeSource GPG key + ansible.builtin.shell: + cmd: | + curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /usr/share/keyrings/nodesource.gpg + creates: /usr/share/keyrings/nodesource.gpg + tags: nodejs + + - name: Add NodeSource repository + ansible.builtin.shell: + cmd: | + echo "deb [signed-by=/usr/share/keyrings/nodesource.gpg] https://deb.nodesource.com/node_{{ nodejs_version }} nodistro main" | tee /etc/apt/sources.list.d/nodesource.list + creates: /etc/apt/sources.list.d/nodesource.list + tags: nodejs + + - name: Install Node.js + ansible.builtin.apt: + name: nodejs + state: present + update_cache: true + tags: nodejs + + - name: Install pnpm globally + ansible.builtin.shell: + cmd: npm install -g pnpm + creates: /usr/bin/pnpm + tags: nodejs + + # ttyd + - name: Install ttyd for browser-based terminal + ansible.builtin.apt: + name: ttyd + state: present + tags: ttyd + + # RoboClaw + - name: Create RoboClaw directories + ansible.builtin.file: + path: "{{ item.path }}" + state: directory + owner: "{{ roboclaw_user }}" + group: "{{ roboclaw_user }}" + mode: "{{ item.mode }}" + loop: + - { path: "{{ roboclaw_config_dir }}", mode: '0755' } + - { path: "{{ roboclaw_config_dir }}/sessions", mode: '0755' } + - { path: "{{ roboclaw_config_dir }}/credentials", mode: '0700' } + - { path: "{{ roboclaw_config_dir }}/data", mode: '0755' } + - { path: "{{ roboclaw_config_dir }}/logs", mode: '0755' } + - { path: "{{ roboclaw_home }}/.local/share/pnpm", mode: '0755' } + - { path: "{{ roboclaw_home }}/.local/bin", mode: '0755' } + tags: [roboclaw, always] + + - name: Configure pnpm for roboclaw user + ansible.builtin.shell: + cmd: | + pnpm config set global-dir {{ roboclaw_home }}/.local/share/pnpm + pnpm config set global-bin-dir {{ roboclaw_home }}/.local/bin + executable: /bin/bash + become: true + become_user: "{{ roboclaw_user }}" + changed_when: true + tags: [roboclaw, always] + + - name: Install OpenClaw globally from npm + ansible.builtin.shell: + cmd: pnpm install -g openclaw@latest + executable: /bin/bash + become: true + become_user: "{{ roboclaw_user }}" + environment: + HOME: "{{ roboclaw_home }}" + PNPM_HOME: "{{ roboclaw_home }}/.local/share/pnpm" + PATH: "{{ roboclaw_home }}/.local/bin:{{ ansible_env.PATH }}" + register: roboclaw_install + tags: roboclaw + + - name: Configure minimal .bashrc for roboclaw user + ansible.builtin.blockinfile: + path: "{{ roboclaw_home }}/.bashrc" + marker: "# {mark} ANSIBLE MANAGED BLOCK - RoboClaw" + block: | + # pnpm configuration + export PNPM_HOME="{{ roboclaw_home }}/.local/share/pnpm" + export PATH="{{ roboclaw_home }}/.local/bin:$PNPM_HOME:$PATH" + create: true + owner: "{{ roboclaw_user }}" + group: "{{ roboclaw_user }}" + mode: '0644' + tags: [roboclaw, always] + + # Gemini CLI + - name: Install Gemini CLI globally + ansible.builtin.shell: + cmd: pnpm install -g @google/gemini-cli + executable: /bin/bash + become: true + become_user: "{{ roboclaw_user }}" + environment: + HOME: "{{ roboclaw_home }}" + PNPM_HOME: "{{ roboclaw_home }}/.local/share/pnpm" + PATH: "{{ roboclaw_home }}/.local/bin:{{ ansible_env.PATH }}" + register: gemini_install + tags: gemini + + # Verification + - name: Verify roboclaw installation + ansible.builtin.shell: | + export PATH="{{ roboclaw_home }}/.local/bin:$PATH" + openclaw --version + become: true + become_user: "{{ roboclaw_user }}" + register: roboclaw_version + changed_when: false + tags: [roboclaw, always] + + - name: Verify gemini installation + ansible.builtin.shell: | + export PATH="{{ roboclaw_home }}/.local/bin:$PATH" + gemini --version + become: true + become_user: "{{ roboclaw_user }}" + register: gemini_version + changed_when: false + failed_when: false + tags: [gemini, always] + + - name: Display reconfigure complete + ansible.builtin.debug: + msg: | + ✅ RECONFIGURATION COMPLETE ✅ + + RoboClaw: {{ roboclaw_version.stdout }} + Gemini CLI: {{ gemini_version.stdout | default('installed', true) }} + + The instance has been successfully reconfigured. + tags: always diff --git a/cli/run-deploy.sh b/cli/run-deploy.sh new file mode 100755 index 0000000..c9c7648 --- /dev/null +++ b/cli/run-deploy.sh @@ -0,0 +1,406 @@ +#!/bin/bash +set -e + +# Store original directory for resolving user-provided paths +ORIGINAL_DIR="$(pwd)" + +# Change to script directory (cli/) to ensure relative paths work +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +# Deploy OpenClaw to existing servers using SSH key and Ansible inventory +# +# Usage: +# ./cli/run-deploy.sh --ssh-key [options] +# ./cli/run-deploy.sh -k [options] +# ./cli/run-deploy.sh -k -i [options] (backward compatibility) +# +# Environment variables (alternative to flags): +# SSH_PRIVATE_KEY_PATH Path to SSH private key +# INVENTORY_PATH Path to Ansible inventory file +# +# Examples: +# ./run-deploy.sh 192.168.1.100 -k ~/.ssh/id_ed25519 +# ./run-deploy.sh 192.168.1.100 -k key -n production +# ./run-deploy.sh -k key -i hosts.ini (backward compatible) + +# Function to check Python version +check_python_version() { + local python_cmd="$1" + + if ! command -v "$python_cmd" &> /dev/null; then + return 1 + fi + + # Try to get version, suppress errors + local version + if ! version=$($python_cmd --version 2>&1); then + return 1 + fi + + version=$(echo "$version" | awk '{print $2}') + local major=$(echo "$version" | cut -d. -f1) + local minor=$(echo "$version" | cut -d. -f2) + + # Check if we got valid version numbers + if ! [[ "$major" =~ ^[0-9]+$ ]] || ! [[ "$minor" =~ ^[0-9]+$ ]]; then + return 1 + fi + + if [ "$major" -lt 3 ] || ([ "$major" -eq 3 ] && [ "$minor" -lt 12 ]); then + return 1 + fi + + return 0 +} + +# Function to find Python 3.12+ +find_python() { + # Try common Python commands + for cmd in python3.12 python3 python; do + if check_python_version "$cmd" 2>/dev/null; then + echo "$cmd" + return 0 + fi + done + + # Check pyenv if available + if command -v pyenv &> /dev/null; then + if [ -f ~/.pyenv/versions/3.12.0/bin/python3 ]; then + local pyenv_python=~/.pyenv/versions/3.12.0/bin/python3 + if check_python_version "$pyenv_python" 2>/dev/null; then + echo "$pyenv_python" + return 0 + fi + fi + fi + + return 1 +} + +# Function to auto-setup environment +auto_setup() { + echo "Setting up environment..." + echo "" + + # Find Python 3.12+ + echo "Checking for Python 3.12+..." + local python_cmd="" + if ! python_cmd=$(find_python); then + echo "❌ Error: Python 3.12+ not found" + echo "" + echo "Install Python 3.12+ using one of these methods:" + echo "" + echo "Using pyenv (recommended):" + echo " pyenv install 3.12.0" + echo "" + echo "Using apt (Ubuntu/Debian):" + echo " sudo apt update" + echo " sudo apt install python3.12 python3.12-venv" + echo "" + echo "Using brew (macOS):" + echo " brew install python@3.12" + echo "" + exit 1 + fi + + PYTHON_CMD="$python_cmd" + PYTHON_VERSION=$($PYTHON_CMD --version 2>&1 | awk '{print $2}') + echo "✓ Found Python $PYTHON_VERSION" + echo "" + + # Create venv if it doesn't exist + if [ ! -d "../venv" ]; then + echo "Creating virtual environment..." + $PYTHON_CMD -m venv ../venv + echo "✓ Virtual environment created" + echo "" + fi + + # Activate venv + source ../venv/bin/activate + + # Check if dependencies are installed + local need_install=0 + if ! command -v ansible-playbook &> /dev/null; then + need_install=1 + elif ! python -c "import dateutil" 2>/dev/null; then + need_install=1 + fi + + # Install dependencies if needed + if [ $need_install -eq 1 ]; then + echo "Installing dependencies..." + pip install --upgrade pip -q + pip install -r ../requirements.txt + echo "✓ Dependencies installed" + echo "" + fi + + # Check if Ansible collection is installed + if ! ansible-galaxy collection list | grep -q "hetzner.hcloud"; then + echo "Installing Ansible collections..." + ansible-galaxy collection install hetzner.hcloud + echo "✓ Ansible collections installed" + echo "" + fi + + echo "✓ Environment ready" + echo "" +} + +# Run auto-setup +auto_setup + +# Activate virtualenv +source ../venv/bin/activate + +# Parse arguments +SSH_KEY="${SSH_PRIVATE_KEY_PATH:-}" +INVENTORY="${INVENTORY_PATH:-}" +IP_ADDRESS="" +INSTANCE_NAME="${INSTANCE_NAME_OVERRIDE:-}" +AUTO_SETUP="onboard" # Default: auto-onboard after deployment +EXTRA_ARGS=() +TEMP_INVENTORY="" + +# Check if first arg is an IP address (positional) +if [[ $# -gt 0 ]] && [[ "$1" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then + IP_ADDRESS="$1" + shift +fi + +while [[ $# -gt 0 ]]; do + case $1 in + --ip) + IP_ADDRESS="$2" + shift 2 + ;; + -k|--ssh-key) + SSH_KEY="$2" + shift 2 + ;; + -i|--inventory) + INVENTORY="$2" + shift 2 + ;; + -n|--name) + INSTANCE_NAME="$2" + shift 2 + ;; + --skip-onboard|--no-onboard) + AUTO_SETUP="" + shift + ;; + -h|--help) + echo "Deploy OpenClaw to existing servers using SSH key and Ansible inventory" + echo "" + echo "Usage:" + echo " ./cli/run-deploy.sh -k [options] # Direct IP (recommended)" + echo " ./cli/run-deploy.sh -k -n # With instance name" + echo " ./cli/run-deploy.sh -k -i # Inventory file (advanced)" + echo "" + echo "Options:" + echo " -k, --ssh-key Path to SSH private key (required)" + echo " -n, --name Instance name (default: instance-)" + echo " -i, --inventory Ansible inventory file (alternative to IP)" + echo " --ip
IP address (alternative to positional)" + echo " --skip-onboard Skip automatic onboarding" + echo " --no-onboard Alias for --skip-onboard" + echo " -h, --help Show this help message" + echo "" + echo "Environment variables (alternative to flags):" + echo " SSH_PRIVATE_KEY_PATH Path to SSH private key" + echo " INVENTORY_PATH Path to Ansible inventory file" + echo " INSTANCE_NAME_OVERRIDE Override instance name in artifact" + echo "" + echo "Examples:" + echo " ./cli/run-deploy.sh 192.168.1.100 -k ~/.ssh/id_ed25519" + echo " ./cli/run-deploy.sh 192.168.1.100 -k ~/.ssh/key -n production" + echo " ./cli/run-deploy.sh 192.168.1.100 -k key -n prod --skip-onboard" + echo " ./cli/run-deploy.sh -k key -i hosts.ini # Backward compatible" + echo "" + echo "Note: By default, 'openclaw onboard' launches automatically after deployment." + echo " Use --skip-onboard if you want to onboard later manually." + exit 0 + ;; + *) + EXTRA_ARGS+=("$1") + shift + ;; + esac +done + +# Validate inputs +if [ -z "$SSH_KEY" ]; then + echo "Error: SSH key not provided. Use -k/--ssh-key or set SSH_PRIVATE_KEY_PATH" + exit 1 +fi + +# Resolve SSH key path relative to original directory +if [[ ! "$SSH_KEY" = /* ]]; then + # Relative path - resolve it from the original directory + SSH_KEY="$ORIGINAL_DIR/$SSH_KEY" +fi + +if [ ! -f "$SSH_KEY" ]; then + echo "Error: SSH key file not found: $SSH_KEY" + exit 1 +fi + +# Auto-generate inventory if IP provided, otherwise use inventory file +if [ -n "$IP_ADDRESS" ]; then + # Validate IP format + if ! echo "$IP_ADDRESS" | grep -qE '^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$'; then + echo "Error: Invalid IP address format: $IP_ADDRESS" + exit 1 + fi + + # Generate instance name if not provided + if [ -z "$INSTANCE_NAME" ]; then + INSTANCE_NAME="instance-${IP_ADDRESS//./-}" + fi + + # Create temporary inventory file in instances directory + mkdir -p ./instances + TEMP_INVENTORY="../instances/.temp-inventory-${INSTANCE_NAME}.ini" + cat > "$TEMP_INVENTORY" << EOF +[servers] +$IP_ADDRESS ansible_user=root +EOF + + INVENTORY="$TEMP_INVENTORY" + echo "Generated temporary inventory for: $IP_ADDRESS" + echo "" +elif [ -n "$INVENTORY" ]; then + # Resolve inventory path relative to original directory + if [[ ! "$INVENTORY" = /* ]]; then + # Relative path - resolve it from the original directory + INVENTORY="$ORIGINAL_DIR/$INVENTORY" + fi + + # Using inventory file - validate it exists + if [ ! -f "$INVENTORY" ]; then + echo "Error: Inventory file not found: $INVENTORY" + exit 1 + fi +else + echo "Error: Either IP address or inventory file required" + echo "" + echo "Usage:" + echo " ./cli/run-deploy.sh -k # Using IP" + echo " ./cli/run-deploy.sh -k -i # Using inventory" + echo "" + echo "Run with --help for more options" + exit 1 +fi + +# Run Ansible +echo "Deploying OpenClaw to servers in: $INVENTORY" +echo "Using SSH key: $SSH_KEY" +echo "" + +ansible-playbook reconfigure.yml \ + -i "$INVENTORY" \ + --private-key="$SSH_KEY" \ + -e "ansible_ssh_common_args='-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'" \ + "${EXTRA_ARGS[@]}" + +ANSIBLE_EXIT_CODE=$? + +if [ $ANSIBLE_EXIT_CODE -eq 0 ]; then + echo "" + echo "📝 Creating instance artifacts..." + + # Create instances directory if it doesn't exist + mkdir -p ../instances + + # Parse inventory file to extract hosts + # This handles simple INI format: "host ansible_host=ip" or just "ip" + FINAL_INSTANCE_NAME="" + while IFS= read -r line; do + # Skip comments and empty lines + [[ "$line" =~ ^[[:space:]]*# ]] && continue + [[ -z "${line// }" ]] && continue + # Skip section headers like [servers] + [[ "$line" =~ ^\[.*\]$ ]] && continue + + # Extract hostname/IP + HOST=$(echo "$line" | awk '{print $1}') + + # Check if there's an ansible_host variable + if echo "$line" | grep -q "ansible_host="; then + IP=$(echo "$line" | grep -oP 'ansible_host=\K[^ ]+') + ARTIFACT_INSTANCE_NAME=$(echo "$HOST" | tr '.' '-' | tr '_' '-') + else + # Host is the IP + IP="$HOST" + ARTIFACT_INSTANCE_NAME="instance-${IP//./-}" + fi + + # Use provided instance name, or INSTANCE_NAME_OVERRIDE, or derived name + if [ -n "$INSTANCE_NAME" ]; then + ARTIFACT_INSTANCE_NAME="$INSTANCE_NAME" + elif [ -n "$INSTANCE_NAME_OVERRIDE" ]; then + ARTIFACT_INSTANCE_NAME="$INSTANCE_NAME_OVERRIDE" + fi + + ARTIFACT_FILE="../instances/${ARTIFACT_INSTANCE_NAME}.yml" + TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + + # Get absolute path of SSH key + ABS_SSH_KEY=$(realpath "$SSH_KEY") + + # Create artifact file + cat > "$ARTIFACT_FILE" << EOF +# Instance deployed via run-deploy.sh on ${TIMESTAMP} +instances: + - name: ${ARTIFACT_INSTANCE_NAME} + ip: ${IP} + deployed_at: ${TIMESTAMP} + deployment_method: run-deploy.sh + inventory_file: ${INVENTORY} + ssh: + key_file: "${ABS_SSH_KEY}" + public_key_file: "${ABS_SSH_KEY}.pub" +EOF + + echo " ✓ Created artifact: ${ARTIFACT_FILE}" + echo " → Instance name: ${ARTIFACT_INSTANCE_NAME}" + echo " → IP: ${IP}" + + FINAL_INSTANCE_NAME="$ARTIFACT_INSTANCE_NAME" + + done < <(grep -v "^$" "$INVENTORY" 2>/dev/null || true) + + # Clean up temporary inventory if created + if [ -n "$TEMP_INVENTORY" ] && [ -f "$TEMP_INVENTORY" ]; then + rm -f "$TEMP_INVENTORY" + fi + + echo "" + echo "✅ Deployment complete!" + + # Launch interactive onboarding if requested + if [ -n "$AUTO_SETUP" ]; then + echo "" + echo "🚀 Launching OpenClaw interactive wizard..." + echo "" + sleep 1 + ./connect-instance.sh "${FINAL_INSTANCE_NAME}" "$AUTO_SETUP" + else + echo "" + echo "To complete setup, run:" + echo " ./cli/connect-instance.sh ${FINAL_INSTANCE_NAME} onboard" + fi + +else + # Clean up temporary inventory if created (even on failure) + if [ -n "$TEMP_INVENTORY" ] && [ -f "$TEMP_INVENTORY" ]; then + rm -f "$TEMP_INVENTORY" + fi + + echo "" + echo "❌ Deployment failed with exit code: $ANSIBLE_EXIT_CODE" + exit $ANSIBLE_EXIT_CODE +fi diff --git a/cli/run-hetzner.sh b/cli/run-hetzner.sh new file mode 100755 index 0000000..4fa833e --- /dev/null +++ b/cli/run-hetzner.sh @@ -0,0 +1,262 @@ +#!/bin/bash +set -e + +# Change to script directory (cli/) to ensure relative paths work +cd "$(dirname "$0")" + +# Function to check prerequisites +check_prerequisites() { + local errors=0 + + echo "Checking prerequisites..." + + # Check if venv exists + if [ ! -d "../venv" ]; then + echo "❌ Virtual environment not found" + echo " → Run: python3 -m venv venv" + echo " → Ensure you have Python 3.12+ installed" + echo " → With pyenv: pyenv install 3.12.0 && ~/.pyenv/versions/3.12.0/bin/python3 -m venv venv" + errors=1 + else + echo "✓ Virtual environment found" + + # Activate venv to check contents + source venv/bin/activate + + # Check Python version + PYTHON_VERSION=$(python --version 2>&1 | awk '{print $2}') + PYTHON_MAJOR=$(echo "$PYTHON_VERSION" | cut -d. -f1) + PYTHON_MINOR=$(echo "$PYTHON_VERSION" | cut -d. -f2) + + if [ "$PYTHON_MAJOR" -lt 3 ] || ([ "$PYTHON_MAJOR" -eq 3 ] && [ "$PYTHON_MINOR" -lt 12 ]); then + echo "❌ Python 3.12+ required, found: $PYTHON_VERSION" + echo " → Recreate venv with Python 3.12+" + echo " → Run: rm -rf venv && ~/.pyenv/versions/3.12.0/bin/python3 -m venv venv" + errors=1 + else + echo "✓ Python $PYTHON_VERSION" + fi + + # Check if ansible is installed + if ! command -v ansible-playbook &> /dev/null; then + echo "❌ Ansible not installed in virtual environment" + echo " → Run: source venv/bin/activate && pip install -r requirements.txt" + errors=1 + else + ANSIBLE_VERSION=$(ansible --version | head -1 | awk '{print $3}' | tr -d ']') + echo "✓ Ansible $ANSIBLE_VERSION" + fi + + # Check if python-dateutil is installed + if ! python -c "import dateutil" 2>/dev/null; then + echo "❌ python-dateutil not installed" + echo " → Run: source venv/bin/activate && pip install -r requirements.txt" + errors=1 + else + echo "✓ python-dateutil installed" + fi + + # Check if Hetzner Cloud collection is installed + if ! ansible-galaxy collection list | grep -q "hetzner.hcloud"; then + echo "❌ Hetzner Cloud Ansible collection not installed" + echo " → Run: source venv/bin/activate && ansible-galaxy collection install hetzner.hcloud" + errors=1 + else + HCLOUD_VERSION=$(ansible-galaxy collection list | grep hetzner.hcloud | awk '{print $2}') + echo "✓ Hetzner Cloud collection $HCLOUD_VERSION" + fi + fi + + echo "" + + if [ $errors -ne 0 ]; then + echo "Prerequisites not met." + echo "" + echo "Run automatic setup:" + echo " ./setup.sh" + echo "" + echo "Or manual setup:" + echo " 1. ~/.pyenv/versions/3.12.0/bin/python3 -m venv venv" + echo " 2. source venv/bin/activate" + echo " 3. pip install -r requirements.txt" + echo " 4. ansible-galaxy collection install hetzner.hcloud" + exit 1 + fi + + echo "✓ All prerequisites met" + echo "" +} + +# Run prerequisite checks +check_prerequisites + +# Activate virtualenv (already activated in check, but re-activate to be safe) +if [ -d "../venv" ]; then + source ../venv/bin/activate +fi + +# Load environment variables from .env file +if [ -f .env ]; then + echo "Loading credentials from .env..." + export $(grep -v '^#' .env | xargs) +else + echo "Error: .env file not found" + exit 1 +fi + +# Check required variables +if [ -z "$HCLOUD_TOKEN" ]; then + echo "Error: HCLOUD_TOKEN not set in .env" + exit 1 +fi + +# Skip SSH key generation for service and reconfigure commands (they read from artifacts) +if [ "${1:-provision}" != "service" ] && [ "${1:-provision}" != "reconfigure" ]; then + if [ -z "$SSH_PUBLIC_KEY" ]; then + echo "SSH_PUBLIC_KEY not set, generating SSH key..." + + # Use server-specific SSH key if SERVER_NAME is provided, otherwise use default + if [ -n "$SERVER_NAME" ]; then + SSH_KEY_PATH="./ssh-keys/${SERVER_NAME}_key" + mkdir -p ./ssh-keys + else + SSH_KEY_PATH="./hetzner_key" + fi + + # Always generate a new key for each deployment to avoid uniqueness errors + if [ -f "$SSH_KEY_PATH" ]; then + echo "Removing existing SSH key: $SSH_KEY_PATH" + rm -f "$SSH_KEY_PATH" "$SSH_KEY_PATH.pub" + fi + + echo "Creating new SSH key: $SSH_KEY_PATH" + ssh-keygen -t ed25519 -f "$SSH_KEY_PATH" -N "" -C "hetzner-${SERVER_NAME:-instance}" + echo "✅ SSH key created: $SSH_KEY_PATH" + + export SSH_PUBLIC_KEY="$(cat ${SSH_KEY_PATH}.pub)" + export SSH_PRIVATE_KEY_PATH="$SSH_KEY_PATH" + + # Add to .gitignore to prevent committing private keys + if ! grep -q "^hetzner_key$" .gitignore 2>/dev/null; then + echo "hetzner_key" >> .gitignore + echo "hetzner_key.pub" >> .gitignore + echo "ssh-keys/" >> .gitignore + fi + fi +fi + +# Check if first argument is a command +case "${1:-provision}" in + list) + echo "Listing all servers..." + ansible-playbook hetzner-teardown.yml --tags list + ;; + delete|teardown|destroy) + [ $# -gt 0 ] && shift + echo "Running teardown playbook..." + ansible-playbook hetzner-teardown.yml --tags delete "$@" + ;; + reconfigure) + shift + INSTANCE_NAME="${1:?Usage: run-hetzner.sh reconfigure [ansible args]}" + shift + + # Read IP and SSH key from instance artifact + ARTIFACT="../instances/${INSTANCE_NAME}.yml" + if [ ! -f "$ARTIFACT" ]; then + echo "Error: Instance artifact not found: $ARTIFACT" + exit 1 + fi + + IP=$(grep '^\s*ip:' "$ARTIFACT" | head -1 | awk '{print $2}') + KEY_FILE=$(grep '^\s*key_file:' "$ARTIFACT" | head -1 | awk '{print $2}' | tr -d '"' | sed 's/^"\(.*\)"$/\1/') + + if [ -z "$IP" ]; then + echo "Error: Could not extract IP from $ARTIFACT" + exit 1 + fi + + if [ -z "$KEY_FILE" ]; then + echo "Error: Could not extract key_file from $ARTIFACT" + exit 1 + fi + + echo "🔄 Reconfiguring instance: $INSTANCE_NAME" + echo " IP: $IP" + echo " SSH Key: $KEY_FILE" + echo "" + + ansible-playbook reconfigure.yml \ + -i "${IP}," \ + --private-key="${KEY_FILE}" \ + -e "ansible_ssh_common_args='-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'" \ + "$@" + ;; + service) + shift + INSTANCE_NAME="${1:?Usage: run-hetzner.sh service [--enable|--disable]}" + shift + OPENCLAW_STATE="${1:?Usage: run-hetzner.sh service }" + shift + + # Default: enable if starting, disable if stopping + OPENCLAW_ENABLED="true" + if [ "$OPENCLAW_STATE" = "stopped" ]; then + OPENCLAW_ENABLED="false" + fi + + # Override with explicit flag + while [ $# -gt 0 ]; do + case "$1" in + --enable) OPENCLAW_ENABLED="true"; shift ;; + --disable) OPENCLAW_ENABLED="false"; shift ;; + *) shift ;; + esac + done + + # Read IP and SSH key from instance artifact + ARTIFACT="../instances/${INSTANCE_NAME}.yml" + if [ ! -f "$ARTIFACT" ]; then + echo "Error: Instance artifact not found: $ARTIFACT" + exit 1 + fi + + IP=$(grep '^\s*ip:' "$ARTIFACT" | head -1 | awk '{print $2}') + KEY_FILE=$(grep '^\s*key_file:' "$ARTIFACT" | head -1 | awk '{print $2}' | tr -d '"' | sed 's/^"\(.*\)"$/\1/') + + if [ -z "$IP" ]; then + echo "Error: Could not extract IP from $ARTIFACT" + exit 1 + fi + + if [ -z "$KEY_FILE" ]; then + echo "Error: Could not extract key_file from $ARTIFACT" + exit 1 + fi + + # Verify SSH key exists + if [ ! -f "$KEY_FILE" ]; then + echo "Error: SSH key not found: $KEY_FILE" + exit 1 + fi + + echo "🔧 Managing openclaw service: $INSTANCE_NAME" + echo " Action: $OPENCLAW_STATE" + echo " Enabled: $OPENCLAW_ENABLED" + echo " IP: $IP" + echo "" + + ansible-playbook openclaw-service.yml \ + -i "${IP}," \ + --private-key="${KEY_FILE}" \ + -e "ansible_ssh_common_args='-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'" \ + -e "openclaw_state=${OPENCLAW_STATE}" \ + -e "openclaw_enabled=${OPENCLAW_ENABLED}" \ + -e "instance_name=${INSTANCE_NAME}" + ;; + provision|*) + [ $# -gt 0 ] && shift + echo "⚡ Provisioning and installing RoboClaw (~2-3 minutes)..." + ansible-playbook hetzner-finland-fast.yml "$@" + ;; +esac diff --git a/cli/setup.sh b/cli/setup.sh new file mode 100755 index 0000000..ae0fe71 --- /dev/null +++ b/cli/setup.sh @@ -0,0 +1,115 @@ +#!/bin/bash +set -e + +# Change to project root directory to ensure paths work correctly +cd "$(dirname "$0")/.." + +# Automatic setup script for RoboClaw deployment +# Checks for Python 3.12+, creates venv, installs dependencies + +echo "🔧 RoboClaw Deployment Setup" +echo "" + +# Function to check Python version +check_python_version() { + local python_cmd="$1" + + if ! command -v "$python_cmd" &> /dev/null; then + return 1 + fi + + local version=$($python_cmd --version 2>&1 | awk '{print $2}') + local major=$(echo "$version" | cut -d. -f1) + local minor=$(echo "$version" | cut -d. -f2) + + if [ "$major" -lt 3 ] || ([ "$major" -eq 3 ] && [ "$minor" -lt 12 ]); then + return 1 + fi + + echo "$python_cmd" + return 0 +} + +# Try to find Python 3.12+ +echo "Checking for Python 3.12+..." +PYTHON_CMD="" + +# Try common Python commands +for cmd in python3.12 python3 python; do + if PYTHON_CMD=$(check_python_version "$cmd" 2>/dev/null); then + PYTHON_VERSION=$($PYTHON_CMD --version 2>&1 | awk '{print $2}') + echo "✓ Found Python $PYTHON_VERSION at: $(which $PYTHON_CMD)" + break + fi +done + +# Check pyenv if available +if [ -z "$PYTHON_CMD" ] && command -v pyenv &> /dev/null; then + echo "Checking pyenv installations..." + if [ -f ~/.pyenv/versions/3.12.0/bin/python3 ]; then + PYTHON_CMD=~/.pyenv/versions/3.12.0/bin/python3 + PYTHON_VERSION=$($PYTHON_CMD --version 2>&1 | awk '{print $2}') + echo "✓ Found Python $PYTHON_VERSION via pyenv" + fi +fi + +if [ -z "$PYTHON_CMD" ]; then + echo "❌ Error: Python 3.12+ not found" + echo "" + echo "Install Python 3.12+ using one of these methods:" + echo "" + echo "Using pyenv (recommended):" + echo " pyenv install 3.12.0" + echo "" + echo "Using apt (Ubuntu/Debian):" + echo " sudo apt update" + echo " sudo apt install python3.12 python3.12-venv" + echo "" + echo "Using brew (macOS):" + echo " brew install python@3.12" + echo "" + exit 1 +fi + +echo "" + +# Create venv if it doesn't exist +if [ -d "venv" ]; then + echo "✓ Virtual environment already exists" +else + echo "Creating virtual environment..." + $PYTHON_CMD -m venv venv + echo "✓ Virtual environment created" +fi + +echo "" + +# Activate venv +echo "Activating virtual environment..." +source venv/bin/activate + +# Upgrade pip +echo "Upgrading pip..." +pip install --upgrade pip -q + +echo "" + +# Install requirements +echo "Installing Python dependencies..." +pip install -r requirements.txt + +echo "" + +# Install Ansible collections +echo "Installing Ansible collections..." +ansible-galaxy collection install hetzner.hcloud + +echo "" +echo "✅ Setup complete!" +echo "" +echo "Your environment is ready. You can now:" +echo " ./run-deploy.sh -k -i " +echo " ./run-hetzner.sh" +echo "" +echo "Note: The virtual environment is activated in this shell." +echo " To activate it in new shells, run: source venv/bin/activate" diff --git a/cli/validate-instance.sh b/cli/validate-instance.sh new file mode 100755 index 0000000..2878664 --- /dev/null +++ b/cli/validate-instance.sh @@ -0,0 +1,334 @@ +#!/usr/bin/env bash +set -e + +# Change to script directory (cli/) to ensure relative paths work +cd "$(dirname "$0")" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Counters +CHECKS_PASSED=0 +CHECKS_FAILED=0 + +# Print functions +print_header() { + echo -e "\n${BLUE}========================================${NC}" + echo -e "${BLUE}$1${NC}" + echo -e "${BLUE}========================================${NC}" +} + +print_check() { + echo -e "${YELLOW}[CHECK]${NC} $1" +} + +print_success() { + echo -e "${GREEN}[PASS]${NC} $1" + ((CHECKS_PASSED++)) || true +} + +print_fail() { + echo -e "${RED}[FAIL]${NC} $1" + ((CHECKS_FAILED++)) || true +} + +print_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +# Usage information +usage() { + echo "Usage: $0 [instance-name]" + echo "" + echo "Validates a provisioned Hetzner instance against its artifact file." + echo "" + echo "Arguments:" + echo " instance-name Name of the instance (default: finland-instance)" + echo "" + echo "Examples:" + echo " $0 # Validate finland-instance" + echo " $0 finland-instance # Validate specific instance" + echo " $0 my-server # Validate my-server instance" + exit 1 +} + +# Parse arguments +INSTANCE_NAME="${1:-finland-instance}" + +if [[ "$INSTANCE_NAME" == "-h" ]] || [[ "$INSTANCE_NAME" == "--help" ]]; then + usage +fi + +ARTIFACT_FILE="../instances/${INSTANCE_NAME}.yml" +SSH_KEY="hetzner_key" + +# Check if artifact file exists +if [[ ! -f "$ARTIFACT_FILE" ]]; then + # Check if a deleted version exists + DELETED_ARTIFACT="../instances/${INSTANCE_NAME}_deleted.yml" + if [[ -f "$DELETED_ARTIFACT" ]]; then + DELETED_AT=$(grep "^ deleted_at:" "$DELETED_ARTIFACT" | sed 's/.*deleted_at: //' | tr -d ' ') + echo -e "${RED}Error: Instance '$INSTANCE_NAME' was deleted${NC}" + if [[ -n "$DELETED_AT" ]]; then + echo -e "${RED}Deleted at: $DELETED_AT${NC}" + fi + echo "" + echo "Artifact file: $DELETED_ARTIFACT" + echo "" + echo "This instance no longer exists in Hetzner Cloud." + echo "To provision a new instance with this name, run:" + echo " ./run-hetzner.sh" + exit 1 + fi + + echo -e "${RED}Error: Artifact file not found: $ARTIFACT_FILE${NC}" + echo "" + echo "Available instances:" + if [[ -d "instances" ]] && [[ -n "$(ls -A ../instances/*.yml 2>/dev/null)" ]]; then + for f in ../instances/*.yml; do + basename "$f" .yml | sed 's/_deleted$//' + done + else + echo " (none)" + fi + exit 1 +fi + +# Check if SSH key exists +if [[ ! -f "$SSH_KEY" ]]; then + echo -e "${RED}Error: SSH key not found: $SSH_KEY${NC}" + exit 1 +fi + +print_header "VALIDATING INSTANCE: $INSTANCE_NAME" + +# Parse artifact file using grep/sed (simple YAML parsing) +print_info "Reading artifact file: $ARTIFACT_FILE" + +IP_ADDRESS=$(grep "^ ip:" "$ARTIFACT_FILE" | sed 's/.*ip: //' | tr -d ' ') +SERVER_TYPE=$(grep "^ server_type:" "$ARTIFACT_FILE" | sed 's/.*server_type: //' | tr -d ' ') +LOCATION=$(grep "^ location:" "$ARTIFACT_FILE" | sed 's/.*location: //' | tr -d ' ') +IMAGE=$(grep "^ image:" "$ARTIFACT_FILE" | sed 's/.*image: //' | tr -d ' ') +INSTALL_MODE=$(grep "^ install_mode:" "$ARTIFACT_FILE" | sed 's/.*install_mode: //' | tr -d ' ') + +# Expected versions (from artifact) +EXPECTED_OS=$(grep "^ os:" "$ARTIFACT_FILE" | sed 's/.*os: //' | sed 's/^ *//') +EXPECTED_KERNEL=$(grep "^ kernel:" "$ARTIFACT_FILE" | sed 's/.*kernel: //' | tr -d ' ') +EXPECTED_DOCKER=$(grep "^ docker:" "$ARTIFACT_FILE" | sed 's/.*docker: //' | sed 's/^ *//') +EXPECTED_NODEJS=$(grep "^ nodejs:" "$ARTIFACT_FILE" | sed 's/.*nodejs: //' | tr -d ' ') +EXPECTED_PNPM=$(grep "^ pnpm:" "$ARTIFACT_FILE" | sed 's/.*pnpm: //' | tr -d ' ') +EXPECTED_ROBOCLAW=$(grep "^ roboclaw:" "$ARTIFACT_FILE" | sed 's/.*roboclaw: //' | tr -d ' ') + +print_info "Instance IP: $IP_ADDRESS" +print_info "Server Type: $SERVER_TYPE" +print_info "Location: $LOCATION" +print_info "Install Mode: $INSTALL_MODE" + +# SSH helper function +ssh_exec() { + ssh -i "$SSH_KEY" \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + -o ConnectTimeout=10 \ + -o LogLevel=QUIET \ + "root@$IP_ADDRESS" "$@" +} + +# Test 1: SSH Connectivity +print_header "1. SSH CONNECTIVITY" +print_check "Testing SSH connection to $IP_ADDRESS" + +if ssh_exec "echo 'Connection successful'" &>/dev/null; then + print_success "SSH connection established" +else + print_fail "Cannot connect via SSH" + exit 1 +fi + +# Test 2: System Information +print_header "2. SYSTEM INFORMATION" + +print_check "Verifying OS version" +ACTUAL_OS=$(ssh_exec "cat /etc/os-release | grep PRETTY_NAME | cut -d'\"' -f2") +# Extract major.minor version (e.g., "24.04" from "ubuntu-24.04") +IMAGE_VERSION=$(echo "$IMAGE" | grep -oE '[0-9]+\.[0-9]+' || echo "$IMAGE") +if [[ "$ACTUAL_OS" == *"$IMAGE_VERSION"* ]]; then + print_success "OS version matches: $ACTUAL_OS" +else + print_fail "OS mismatch. Expected version $IMAGE_VERSION, Got: $ACTUAL_OS" +fi + +print_check "Verifying kernel version" +ACTUAL_KERNEL=$(ssh_exec "uname -r") +if [[ "$ACTUAL_KERNEL" == "$EXPECTED_KERNEL" ]]; then + print_success "Kernel version matches: $ACTUAL_KERNEL" +else + print_info "Kernel version: $ACTUAL_KERNEL (expected: $EXPECTED_KERNEL)" +fi + +# Test 3: Software Versions +print_header "3. SOFTWARE VERSIONS" + +print_check "Verifying Docker version" +ACTUAL_DOCKER=$(ssh_exec "docker --version") +if [[ "$ACTUAL_DOCKER" == *"$EXPECTED_DOCKER"* ]]; then + print_success "Docker version matches: $ACTUAL_DOCKER" +else + print_fail "Docker mismatch. Expected: $EXPECTED_DOCKER, Got: $ACTUAL_DOCKER" +fi + +print_check "Verifying Node.js version" +ACTUAL_NODEJS=$(ssh_exec "node --version") +if [[ "$ACTUAL_NODEJS" == "$EXPECTED_NODEJS" ]]; then + print_success "Node.js version matches: $ACTUAL_NODEJS" +else + print_fail "Node.js mismatch. Expected: $EXPECTED_NODEJS, Got: $ACTUAL_NODEJS" +fi + +print_check "Verifying pnpm version" +ACTUAL_PNPM=$(ssh_exec "pnpm --version") +if [[ "$ACTUAL_PNPM" == "$EXPECTED_PNPM" ]]; then + print_success "pnpm version matches: $ACTUAL_PNPM" +else + print_fail "pnpm mismatch. Expected: $EXPECTED_PNPM, Got: $ACTUAL_PNPM" +fi + +# Test 4: OpenClaw User & Installation +print_header "4. ROBOCLAW USER & INSTALLATION" + +print_check "Verifying roboclaw user exists" +if ssh_exec "id roboclaw" &>/dev/null; then + USER_INFO=$(ssh_exec "id roboclaw") + print_success "roboclaw user exists: $USER_INFO" +else + print_fail "roboclaw user not found" +fi + +print_check "Verifying roboclaw is in docker group" +if ssh_exec "groups roboclaw | grep -q docker"; then + print_success "roboclaw user is in docker group" +else + print_fail "roboclaw user is NOT in docker group" +fi + +print_check "Verifying roboclaw home directory" +if ssh_exec "test -d /home/roboclaw"; then + print_success "/home/roboclaw directory exists" +else + print_fail "/home/roboclaw directory not found" +fi + +print_check "Verifying roboclaw config directory structure" +MISSING_DIRS=() +for dir in .roboclaw .roboclaw/credentials .roboclaw/data .roboclaw/logs .roboclaw/sessions; do + if ! ssh_exec "test -d /home/roboclaw/$dir" &>/dev/null; then + MISSING_DIRS+=("$dir") + fi +done + +if [[ ${#MISSING_DIRS[@]} -eq 0 ]]; then + print_success "All roboclaw config directories exist" +else + print_fail "Missing directories: ${MISSING_DIRS[*]}" +fi + +print_check "Verifying roboclaw installation" +if ssh_exec "su - roboclaw -c 'which openclaw'" &>/dev/null; then + ROBOCLAW_PATH=$(ssh_exec "su - roboclaw -c 'which openclaw'") + ACTUAL_ROBOCLAW=$(ssh_exec "su - roboclaw -c 'openclaw --version'") + + if [[ "$ACTUAL_ROBOCLAW" == "$EXPECTED_ROBOCLAW" ]]; then + print_success "roboclaw version matches: $ACTUAL_ROBOCLAW" + print_info "Installed at: $ROBOCLAW_PATH" + else + print_fail "roboclaw version mismatch. Expected: $EXPECTED_ROBOCLAW, Got: $ACTUAL_ROBOCLAW" + fi +else + print_fail "roboclaw command not found for roboclaw user" +fi + +print_check "Verifying roboclaw can access Docker" +if ssh_exec "su - roboclaw -c 'docker ps'" &>/dev/null; then + print_success "roboclaw user can access Docker" +else + print_fail "roboclaw user cannot access Docker" +fi + +# Test 5: Firewall Configuration +print_header "5. FIREWALL CONFIGURATION" + +print_check "Verifying UFW is installed and active" +if UFW_STATUS=$(ssh_exec "ufw status" 2>/dev/null); then + if echo "$UFW_STATUS" | grep -q "Status: active"; then + print_success "UFW firewall is active" + else + print_fail "UFW firewall is NOT active" + fi +else + print_fail "UFW not found or not accessible" +fi + +print_check "Verifying SSH port (22) is allowed" +if ssh_exec "ufw status | grep -q '22/tcp.*ALLOW'"; then + print_success "SSH port (22/tcp) is allowed through firewall" +else + print_fail "SSH port (22/tcp) is NOT allowed through firewall" +fi + +print_check "Verifying default deny policy" +if ssh_exec "ufw status verbose | grep -q 'Default: deny (incoming)'"; then + print_success "Default deny policy for incoming traffic" +else + print_fail "Default deny policy NOT configured" +fi + +# Test 6: Docker Service +print_header "6. DOCKER SERVICE" + +print_check "Verifying Docker daemon is running" +if ssh_exec "systemctl is-active docker" &>/dev/null; then + print_success "Docker daemon is running" +else + print_fail "Docker daemon is NOT running" +fi + +print_check "Verifying Docker is enabled on boot" +if ssh_exec "systemctl is-enabled docker" &>/dev/null; then + print_success "Docker is enabled on boot" +else + print_fail "Docker is NOT enabled on boot" +fi + +# Final Summary +print_header "VALIDATION SUMMARY" + +echo "" +echo -e "Instance: ${BLUE}$INSTANCE_NAME${NC}" +echo -e "IP Address: ${BLUE}$IP_ADDRESS${NC}" +echo -e "Checks Passed: ${GREEN}$CHECKS_PASSED${NC}" +echo -e "Checks Failed: ${RED}$CHECKS_FAILED${NC}" +echo "" + +if [[ $CHECKS_FAILED -eq 0 ]]; then + echo -e "${GREEN}✓ All validation checks passed!${NC}" + echo "" + echo -e "${BLUE}Next steps:${NC}" + echo " 1. Complete onboarding from the dashboard:" + echo " http://localhost:3000/instances" + echo "" + echo " 2. Or manually via SSH:" + echo " ssh -i $SSH_KEY root@$IP_ADDRESS" + echo " sudo su - roboclaw" + echo " openclaw onboard" + echo "" + exit 0 +else + echo -e "${RED}✗ Validation failed with $CHECKS_FAILED error(s)${NC}" + echo "" + echo "Please review the failed checks above and re-provision if necessary." + exit 1 +fi diff --git a/cli/validate-openclaw.yml b/cli/validate-openclaw.yml new file mode 100644 index 0000000..396dfc4 --- /dev/null +++ b/cli/validate-openclaw.yml @@ -0,0 +1,88 @@ +--- +# Quick validation playbook - tests openclaw installation on existing server +# Usage: ansible-playbook validate-openclaw.yml -i "IP_ADDRESS," --private-key=hetzner_key + +- name: Validate OpenClaw Installation + hosts: all + remote_user: root + gather_facts: false + + vars: + roboclaw_user: roboclaw + roboclaw_home: /home/roboclaw + + tasks: + - name: Check if roboclaw user exists + ansible.builtin.command: id {{ roboclaw_user }} + register: user_check + changed_when: false + failed_when: false + + - name: Display user check result + ansible.builtin.debug: + msg: "{{ 'User exists' if user_check.rc == 0 else 'User does not exist' }}" + + - name: Check pnpm global configuration + ansible.builtin.shell: | + pnpm config get global-bin-dir + pnpm config get global-dir + become: true + become_user: "{{ roboclaw_user }}" + register: pnpm_config + changed_when: false + when: user_check.rc == 0 + + - name: Display pnpm config + ansible.builtin.debug: + msg: "{{ pnpm_config.stdout_lines }}" + when: user_check.rc == 0 + + - name: List globally installed pnpm packages + ansible.builtin.shell: | + export PATH="{{ roboclaw_home }}/.local/bin:$PATH" + pnpm list -g + become: true + become_user: "{{ roboclaw_user }}" + register: pnpm_list + changed_when: false + when: user_check.rc == 0 + + - name: Display installed packages + ansible.builtin.debug: + msg: "{{ pnpm_list.stdout_lines }}" + when: user_check.rc == 0 + + - name: Check if openclaw binary exists + ansible.builtin.shell: | + export PATH="{{ roboclaw_home }}/.local/bin:$PATH" + which openclaw || echo "NOT FOUND" + become: true + become_user: "{{ roboclaw_user }}" + register: which_openclaw + changed_when: false + when: user_check.rc == 0 + + - name: Display openclaw location + ansible.builtin.debug: + msg: "{{ which_openclaw.stdout }}" + when: user_check.rc == 0 + + - name: Verify openclaw installation + ansible.builtin.shell: | + export PATH="{{ roboclaw_home }}/.local/bin:$PATH" + openclaw --version + become: true + become_user: "{{ roboclaw_user }}" + register: openclaw_version + changed_when: false + when: user_check.rc == 0 + + - name: Display openclaw version + ansible.builtin.debug: + msg: "✅ OpenClaw installed: {{ openclaw_version.stdout }}" + when: user_check.rc == 0 and openclaw_version.rc == 0 + + - name: Display failure if openclaw not working + ansible.builtin.debug: + msg: "❌ OpenClaw command failed" + when: user_check.rc == 0 and openclaw_version.rc != 0 diff --git a/instances/.gitkeep b/instances/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/instances/ROBOCLAW-INT-TEST.yml b/instances/ROBOCLAW-INT-TEST.yml new file mode 100644 index 0000000..4ab37e0 --- /dev/null +++ b/instances/ROBOCLAW-INT-TEST.yml @@ -0,0 +1,10 @@ +# Instance deployed via run-deploy.sh on 2026-02-03T20:00:33Z +instances: + - name: ROBOCLAW-INT-TEST + ip: 77.42.73.229 + deployed_at: 2026-02-03T20:00:33Z + deployment_method: run-deploy.sh + inventory_file: test-inventory.ini + ssh: + key_file: "/home/justin/Documents/RoboClaw/ssh-keys/ROBOCLAW-INT-TEST_key" + public_key_file: "/home/justin/Documents/RoboClaw/ssh-keys/ROBOCLAW-INT-TEST_key.pub" diff --git a/instances/instance-77-42-73-229.yml b/instances/instance-77-42-73-229.yml new file mode 100644 index 0000000..9a90ea7 --- /dev/null +++ b/instances/instance-77-42-73-229.yml @@ -0,0 +1,10 @@ +# Instance deployed via run-deploy.sh on 2026-02-03T20:26:52Z +instances: + - name: instance-77-42-73-229 + ip: 77.42.73.229 + deployed_at: 2026-02-03T20:26:52Z + deployment_method: run-deploy.sh + inventory_file: test-inventory.ini + ssh: + key_file: "/home/justin/Documents/RoboClaw/ssh-keys/ROBOCLAW-INT-TEST_key" + public_key_file: "/home/justin/Documents/RoboClaw/ssh-keys/ROBOCLAW-INT-TEST_key.pub" diff --git a/openclaw b/openclaw new file mode 160000 index 0000000..35dc417 --- /dev/null +++ b/openclaw @@ -0,0 +1 @@ +Subproject commit 35dc417b1887e7a786b407122a869047b5087e68 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b25d50d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,17 @@ +# Python dependencies for Hetzner VPS provisioning with Ansible +# Install with: pip install -r requirements.txt + +# Core +ansible==13.3.0 +ansible-core==2.20.2 + +# Required by Hetzner Cloud Ansible modules +requests>=2.32.0 +python-dateutil>=2.9.0 + +# Ansible dependencies +jinja2>=3.1.0 +PyYAML>=6.0 +cryptography>=46.0 +packaging>=26.0 +resolvelib>=1.2.0 diff --git a/test-inventory.ini b/test-inventory.ini new file mode 100644 index 0000000..cf8867c --- /dev/null +++ b/test-inventory.ini @@ -0,0 +1,2 @@ +[servers] +77.42.73.229 ansible_user=root diff --git a/website/next.config.ts b/website/next.config.ts index 88a297c..d1eb338 100644 --- a/website/next.config.ts +++ b/website/next.config.ts @@ -1,17 +1,8 @@ import type { NextConfig } from 'next' const nextConfig: NextConfig = { - output: 'export', - images: { - unoptimized: true, - }, - // For GitHub Pages project sites, set BASE_PATH env var to /repository-name - // For user/org pages (username.github.io), leave BASE_PATH unset - basePath: process.env.BASE_PATH || '', - // Next.js 16 uses Turbopack by default - turbopack: { - root: process.cwd(), - }, + // Mark server-only packages to be excluded from client bundle + serverExternalPackages: ['ssh2'], } export default nextConfig