From 3fcda9d0134ccc605a8d0318bfc769a22fcf7da6 Mon Sep 17 00:00:00 2001 From: Mo Balaa Date: Sun, 1 Feb 2026 21:24:40 -0600 Subject: [PATCH 1/8] Fix turbopack --- .github/README.md | 22 -- .github/workflows/deploy.yml | 75 ------- .gitignore | 2 - website/components/ui/Footer.tsx | 118 ++++++++++ website/lib/deployment-manager.ts | 306 ++++++++++++++++++++++++++ website/lib/hetzner.ts | 209 ++++++++++++++++++ website/lib/keygen.ts | 47 ++++ website/lib/ssh-provisioner.ts | 345 ++++++++++++++++++++++++++++++ website/lib/tunnel-manager.ts | 334 +++++++++++++++++++++++++++++ website/lib/types.ts | 167 +++++++++++++++ website/next.config.ts | 8 +- website/tsconfig.json | 5 +- 12 files changed, 1530 insertions(+), 108 deletions(-) delete mode 100644 .github/README.md delete mode 100644 .github/workflows/deploy.yml create mode 100644 website/components/ui/Footer.tsx create mode 100644 website/lib/deployment-manager.ts create mode 100644 website/lib/hetzner.ts create mode 100644 website/lib/keygen.ts create mode 100644 website/lib/ssh-provisioner.ts create mode 100644 website/lib/tunnel-manager.ts create mode 100644 website/lib/types.ts diff --git a/.github/README.md b/.github/README.md deleted file mode 100644 index e08c5dd..0000000 --- a/.github/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# GitHub Actions - -## Deploy to GitHub Pages - -The `deploy.yml` workflow automatically deploys the website to GitHub Pages when changes are pushed to the main branch. - -### Setup Instructions - -1. Go to your repository Settings > Pages -2. Under "Build and deployment", select: - - Source: **GitHub Actions** -3. The workflow will automatically run on every push to main - -### What Gets Deployed - -- The website is built as a static site (API routes are excluded) -- Only the marketing/documentation pages are deployed -- The site is served from the `out` directory after build - -### Manual Deployment - -You can manually trigger a deployment from the Actions tab by selecting "Deploy to GitHub Pages" and clicking "Run workflow". 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 index 83258c0..76fda1a 100644 --- a/.gitignore +++ b/.gitignore @@ -23,8 +23,6 @@ dist/ downloads/ eggs/ .eggs/ -lib/ -lib64/ parts/ sdist/ var/ diff --git a/website/components/ui/Footer.tsx b/website/components/ui/Footer.tsx new file mode 100644 index 0000000..461217d --- /dev/null +++ b/website/components/ui/Footer.tsx @@ -0,0 +1,118 @@ +'use client' + +export default function Footer() { + const currentYear = new Date().getFullYear() + + return ( + + ) +} diff --git a/website/lib/deployment-manager.ts b/website/lib/deployment-manager.ts new file mode 100644 index 0000000..b885c2a --- /dev/null +++ b/website/lib/deployment-manager.ts @@ -0,0 +1,306 @@ +import { generateOpenSSHKeypair } from './keygen' +import * as hetzner from './hetzner' +import { provisionServer } from './ssh-provisioner' +import type { + DeployConfig, + DeployPhase, + LogEntry, + PhaseUpdate, + ProgressUpdate, + DeployResult, + DeployError, + DeploymentState, + LogLevel, +} from './types' + +type EventEmitter = ( + event: 'log' | 'phase' | 'progress' | 'success' | 'error', + data: unknown +) => void + +export class DeploymentManager { + private state: DeploymentState + private startTime: number = 0 + + constructor(config: DeployConfig) { + this.state = { + config: { + serverName: + config.serverName || `roboclaw-${Date.now().toString(36)}`, + serverType: config.serverType || 'cax11', + location: config.location || 'hel1', + image: config.image || 'ubuntu-24.04', + token: config.token, + }, + } + } + + /** + * Main execution method that orchestrates the entire deployment + */ + async execute(emit: EventEmitter): Promise { + this.startTime = Date.now() + + try { + // Phase 1: Generate SSH keypair + await this.runPhase( + 'keygen', + 1, + 12, + 'Generating SSH keypair', + emit, + async (log) => { + log('info', 'Generating Ed25519 SSH keypair...') + this.state.sshKeypair = generateOpenSSHKeypair() + log('success', 'SSH keypair generated') + } + ) + emit('progress', { percent: 5 }) + + // Phase 2: Upload SSH key to Hetzner + await this.runPhase( + 'ssh_key', + 2, + 12, + 'Uploading SSH key to Hetzner', + emit, + async (log) => { + log('info', 'Creating SSH key in Hetzner Cloud...') + const sshKey = await hetzner.createSSHKey( + this.state.config.token, + `${this.state.config.serverName}-key`, + this.state.sshKeypair!.publicKey + ) + this.state.sshKeyId = sshKey.id + log('success', `SSH key created (ID: ${sshKey.id})`) + } + ) + emit('progress', { percent: 10 }) + + // Phase 3: Create server + await this.runPhase( + 'provisioning', + 3, + 12, + 'Creating VPS instance', + emit, + async (log) => { + log('info', `Creating ${this.state.config.serverType} server in ${this.state.config.location}...`) + const server = await hetzner.createServer(this.state.config.token, { + name: this.state.config.serverName!, + serverType: this.state.config.serverType!, + image: this.state.config.image!, + location: this.state.config.location!, + sshKeyIds: [this.state.sshKeyId!], + }) + this.state.serverId = server.id + this.state.serverIp = server.public_net.ipv4.ip + log('success', `Server created (ID: ${server.id}, IP: ${this.state.serverIp})`) + + // Wait for server to be running + log('info', 'Waiting for server to start...') + await hetzner.waitForServerRunning( + this.state.config.token, + this.state.serverId, + (msg) => log('info', msg) + ) + log('success', 'Server is running') + } + ) + emit('progress', { percent: 25 }) + + // Phase 4-11: Provision server via SSH + await this.runPhase( + 'ssh_wait', + 4, + 12, + 'Waiting for SSH', + emit, + async (log) => { + log('info', 'Waiting for SSH to become available...') + await this.waitForSSH(this.state.serverIp!, 120000, log) + log('success', 'SSH is ready') + } + ) + emit('progress', { percent: 30 }) + + // Run all provisioning phases + await this.runPhase( + 'install_packages', + 5, + 12, + 'Installing base packages', + emit, + async (log) => { + await provisionServer( + this.state.serverIp!, + this.state.sshKeypair!.privateKey, + (level, message) => log(level, message) + ) + } + ) + emit('progress', { percent: 100 }) + + // Phase 12: Success + const result: DeployResult = { + ip: this.state.serverIp!, + serverName: this.state.config.serverName!, + sshPrivateKey: this.state.sshKeypair!.privateKey, + sshUser: 'root', + nextSteps: [ + `ssh -i roboclaw_key root@${this.state.serverIp}`, + 'sudo su - roboclaw', + 'openclaw onboard --install-daemon', + ], + } + + const duration = ((Date.now() - this.startTime) / 1000).toFixed(0) + this.emitLog(emit, 'success', `Deployment completed in ${duration}s`) + emit('success', result) + } catch (error) { + await this.handleError(error, emit) + } + } + + /** + * Runs a single deployment phase + */ + private async runPhase( + phase: DeployPhase, + step: number, + totalSteps: number, + label: string, + emit: EventEmitter, + fn: (log: (level: LogLevel, message: string) => void) => Promise + ): Promise { + const phaseUpdate: PhaseUpdate = { phase, step, totalSteps, label } + emit('phase', phaseUpdate) + + const log = (level: LogLevel, message: string) => { + this.emitLog(emit, level, message, phase) + } + + await fn(log) + } + + /** + * Emits a log event + */ + private emitLog( + emit: EventEmitter, + level: LogLevel, + message: string, + phase?: DeployPhase + ): void { + const logEntry: LogEntry = { + timestamp: new Date().toISOString(), + level, + message, + phase, + } + emit('log', logEntry) + } + + /** + * Handles errors during deployment + */ + private async handleError(error: unknown, emit: EventEmitter): Promise { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error occurred' + + this.emitLog(emit, 'error', errorMessage) + + const deployError: DeployError = { + message: errorMessage, + serverId: this.state.serverId, + recoverable: true, + } + + emit('error', deployError) + + // Attempt cleanup + if (this.state.serverId || this.state.sshKeyId) { + this.emitLog(emit, 'warning', 'Cleaning up resources...') + try { + if (this.state.serverId) { + await hetzner.deleteServer( + this.state.config.token, + this.state.serverId + ) + this.emitLog(emit, 'info', 'Server deleted') + } + if (this.state.sshKeyId) { + await hetzner.deleteSSHKey( + this.state.config.token, + this.state.sshKeyId + ) + this.emitLog(emit, 'info', 'SSH key deleted') + } + } catch (cleanupError) { + this.emitLog( + emit, + 'warning', + `Cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : 'Unknown error'}` + ) + } + } + } + + /** + * Waits for SSH to become available + */ + private async waitForSSH( + host: string, + timeoutMs: number, + log: (level: LogLevel, message: string) => void + ): Promise { + const startTime = Date.now() + const pollInterval = 3000 + + while (Date.now() - startTime < timeoutMs) { + try { + const available = await this.checkSSHPort(host, 22, 5000) + if (available) { + return + } + log('info', 'SSH not ready yet, retrying...') + } catch { + // Ignore errors + } + + await new Promise((resolve) => setTimeout(resolve, pollInterval)) + } + + throw new Error(`SSH did not become available within ${timeoutMs / 1000}s`) + } + + /** + * Checks if SSH port is open + */ + private checkSSHPort( + host: string, + port: number, + timeout: number + ): Promise { + return new Promise((resolve) => { + const net = require('net') + const socket = new net.Socket() + + socket.setTimeout(timeout) + socket.on('connect', () => { + socket.destroy() + resolve(true) + }) + socket.on('timeout', () => { + socket.destroy() + resolve(false) + }) + socket.on('error', () => { + socket.destroy() + resolve(false) + }) + + socket.connect(port, host) + }) + } +} diff --git a/website/lib/hetzner.ts b/website/lib/hetzner.ts new file mode 100644 index 0000000..adb30ad --- /dev/null +++ b/website/lib/hetzner.ts @@ -0,0 +1,209 @@ +import type { + HetznerSSHKey, + HetznerServer, + HetznerAPIError as APIError, +} from './types' + +const HETZNER_API_BASE = 'https://api.hetzner.cloud/v1' + +export class HetznerAPIError extends Error { + constructor( + public statusCode: number, + public code: string, + message: string, + public details?: unknown + ) { + super(message) + this.name = 'HetznerAPIError' + } +} + +async function makeRequest( + token: string, + method: string, + endpoint: string, + body?: unknown +): Promise { + const url = `${HETZNER_API_BASE}${endpoint}` + + const response = await fetch(url, { + method, + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: body ? JSON.stringify(body) : undefined, + }) + + const data = await response.json() + + if (!response.ok) { + const error = data.error as APIError + throw new HetznerAPIError( + response.status, + error.code, + error.message, + error.details + ) + } + + return data as T +} + +/** + * Validates a Hetzner API token by making a lightweight API call + */ +export async function validateToken(token: string): Promise { + try { + await makeRequest(token, 'GET', '/servers?per_page=1') + return true + } catch { + return false + } +} + +/** + * Creates an SSH key in Hetzner Cloud + * Handles conflict (409) if key already exists by retrieving it + */ +export async function createSSHKey( + token: string, + name: string, + publicKey: string +): Promise { + try { + const response = await makeRequest<{ ssh_key: HetznerSSHKey }>( + token, + 'POST', + '/ssh_keys', + { + name, + public_key: publicKey, + } + ) + return response.ssh_key + } catch (error) { + if ( + error instanceof HetznerAPIError && + error.code === 'uniqueness_error' + ) { + // Key already exists, retrieve it + const response = await makeRequest<{ ssh_keys: HetznerSSHKey[] }>( + token, + 'GET', + '/ssh_keys' + ) + const existingKey = response.ssh_keys.find((key) => key.name === name) + if (existingKey) { + return existingKey + } + } + throw error + } +} + +/** + * Creates a server in Hetzner Cloud + */ +export async function createServer( + token: string, + opts: { + name: string + serverType: string + image: string + location: string + sshKeyIds: number[] + } +): Promise { + const response = await makeRequest<{ server: HetznerServer }>( + token, + 'POST', + '/servers', + { + name: opts.name, + server_type: opts.serverType, + image: opts.image, + location: opts.location, + ssh_keys: opts.sshKeyIds, + start_after_create: true, + } + ) + + return response.server +} + +/** + * Gets server information + */ +export async function getServer( + token: string, + serverId: number +): Promise { + const response = await makeRequest<{ server: HetznerServer }>( + token, + 'GET', + `/servers/${serverId}` + ) + return response.server +} + +/** + * Polls server status until it's running + * @param onProgress Callback for progress messages + */ +export async function waitForServerRunning( + token: string, + serverId: number, + onProgress: (msg: string) => void, + timeoutMs = 120000 +): Promise { + const startTime = Date.now() + const pollInterval = 3000 // 3 seconds + + while (Date.now() - startTime < timeoutMs) { + const server = await getServer(token, serverId) + onProgress(`Server status: ${server.status}`) + + if (server.status === 'running') { + return + } + + await new Promise((resolve) => setTimeout(resolve, pollInterval)) + } + + throw new Error( + `Server failed to start within ${timeoutMs / 1000} seconds` + ) +} + +/** + * Deletes a server + */ +export async function deleteServer( + token: string, + serverId: number +): Promise { + await makeRequest(token, 'DELETE', `/servers/${serverId}`) +} + +/** + * Deletes an SSH key + */ +export async function deleteSSHKey( + token: string, + keyId: number +): Promise { + await makeRequest(token, 'DELETE', `/ssh_keys/${keyId}`) +} + +/** + * Lists all servers + */ +export async function listServers(token: string): Promise { + const response = await makeRequest<{ servers: HetznerServer[] }>( + token, + 'GET', + '/servers' + ) + return response.servers +} diff --git a/website/lib/keygen.ts b/website/lib/keygen.ts new file mode 100644 index 0000000..c3ff3f4 --- /dev/null +++ b/website/lib/keygen.ts @@ -0,0 +1,47 @@ +import { generateKeyPairSync, KeyObject } from 'crypto' +import type { SSHKeypair } from './types' + +/** + * Generates an Ed25519 SSH keypair in OpenSSH format + * Uses crypto's built-in OpenSSH support + */ +export function generateOpenSSHKeypair(): SSHKeypair { + // Generate with both keys - private in OpenSSH format, public in DER for conversion + const pair = generateKeyPairSync('ed25519', { + privateKeyEncoding: { + type: 'openssh', + format: 'pem', + }, + publicKeyEncoding: { + type: 'spki', + format: 'der', + }, + }) + + const privateKey = pair.privateKey as unknown as string + const publicKeyDer = pair.publicKey as unknown as Buffer + + // Convert DER public key to OpenSSH format + const rawKey = publicKeyDer.subarray(12) // Skip ASN.1 header + + const keyTypeBuffer = Buffer.from('ssh-ed25519') + const keyTypeLength = Buffer.allocUnsafe(4) + keyTypeLength.writeUInt32BE(keyTypeBuffer.length, 0) + + const keyLength = Buffer.allocUnsafe(4) + keyLength.writeUInt32BE(rawKey.length, 0) + + const wireFormat = Buffer.concat([ + keyTypeLength, + keyTypeBuffer, + keyLength, + rawKey, + ]) + + const opensshPublicKey = `ssh-ed25519 ${wireFormat.toString('base64')} roboclaw-deploy@generated` + + return { + privateKey, + publicKey: opensshPublicKey, + } +} diff --git a/website/lib/ssh-provisioner.ts b/website/lib/ssh-provisioner.ts new file mode 100644 index 0000000..ea5bdd0 --- /dev/null +++ b/website/lib/ssh-provisioner.ts @@ -0,0 +1,345 @@ +import { Client, type ClientChannel } from 'ssh2' +import type { LogLevel } from './types' + +export type LogCallback = (level: LogLevel, message: string) => void + +export class SSHProvisioner { + private client: Client | null = null + private connected = false + + /** + * Connects to the remote server via SSH + */ + async connect( + host: string, + privateKey: string, + onLog: LogCallback, + retries = 3 + ): Promise { + for (let attempt = 1; attempt <= retries; attempt++) { + try { + await this.attemptConnection(host, privateKey, onLog) + this.connected = true + onLog('success', `Connected to ${host}`) + return + } catch (error) { + onLog( + 'warning', + `Connection attempt ${attempt}/${retries} failed: ${error instanceof Error ? error.message : 'Unknown error'}` + ) + if (attempt < retries) { + await new Promise((resolve) => setTimeout(resolve, 5000)) + } + } + } + throw new Error(`Failed to connect after ${retries} attempts`) + } + + private attemptConnection( + host: string, + privateKey: string, + onLog: LogCallback + ): Promise { + return new Promise((resolve, reject) => { + this.client = new Client() + + this.client + .on('ready', () => { + resolve() + }) + .on('error', (err) => { + reject(err) + }) + .connect({ + host, + port: 22, + username: 'root', + privateKey, + readyTimeout: 30000, + algorithms: { + kex: ['curve25519-sha256@libssh.org', 'curve25519-sha256'], + }, + }) + }) + } + + /** + * Executes a single command and streams output + */ + async exec(command: string, onLog: LogCallback): Promise { + if (!this.client || !this.connected) { + throw new Error('Not connected to SSH server') + } + + return new Promise((resolve, reject) => { + this.client!.exec(command, (err, stream: ClientChannel) => { + if (err) { + reject(err) + return + } + + let exitCode = 0 + + stream + .on('close', (code: number) => { + exitCode = code + resolve(exitCode) + }) + .on('data', (data: Buffer) => { + const lines = data.toString().trim().split('\n') + lines.forEach((line) => { + if (line) onLog('info', line) + }) + }) + .stderr.on('data', (data: Buffer) => { + const lines = data.toString().trim().split('\n') + lines.forEach((line) => { + if (line) onLog('warning', line) + }) + }) + }) + }) + } + + /** + * Executes multiple commands sequentially + */ + async execScript(commands: string[], onLog: LogCallback): Promise { + for (const command of commands) { + onLog('command', `$ ${command}`) + const exitCode = await this.exec(command, onLog) + if (exitCode !== 0) { + throw new Error(`Command failed with exit code ${exitCode}: ${command}`) + } + } + } + + /** + * Disconnects from the server + */ + disconnect(): void { + if (this.client) { + this.client.end() + this.client = null + this.connected = false + } + } +} + +/** + * Phase 1: Install base packages + * Replicates hetzner-finland-fast.yml lines 111-125 + */ +export const INSTALL_PACKAGES_COMMANDS = [ + 'export DEBIAN_FRONTEND=noninteractive', + 'apt-get update -qq', + 'apt-get install -y -qq curl wget git ca-certificates gnupg lsb-release', +] + +/** + * Phase 2: Create roboclaw user + * Replicates hetzner-finland-fast.yml lines 128-145 + */ +export const CREATE_USER_COMMANDS = [ + 'useradd -m -s /bin/bash -c "RoboClaw system user" roboclaw || true', + 'echo "roboclaw ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/roboclaw', + 'chmod 0440 /etc/sudoers.d/roboclaw', + 'visudo -cf /etc/sudoers.d/roboclaw', + 'loginctl enable-linger roboclaw || true', +] + +/** + * Phase 3: Install Docker CE + * Replicates hetzner-finland-fast.yml lines 148-181 + */ +export const INSTALL_DOCKER_COMMANDS = [ + 'install -m 0755 -d /etc/apt/keyrings', + 'curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --batch --yes --dearmor -o /etc/apt/keyrings/docker.gpg', + 'chmod a+r /etc/apt/keyrings/docker.gpg', + 'ARCH=$(dpkg --print-architecture) && CODENAME=$(. /etc/os-release && echo "$VERSION_CODENAME") && echo "deb [arch=${ARCH} signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu ${CODENAME} stable" > /etc/apt/sources.list.d/docker.list', + 'apt-get update -qq', + 'apt-get install -y -qq docker-ce docker-ce-cli containerd.io', + 'usermod -aG docker roboclaw', + 'systemctl start docker', + 'systemctl enable docker', +] + +/** + * Phase 4: Configure UFW firewall + * Replicates hetzner-finland-fast.yml lines 183-205 + */ +export const CONFIGURE_FIREWALL_COMMANDS = [ + 'apt-get install -y -qq ufw', + 'ufw default deny incoming', + 'ufw default allow outgoing', + 'ufw allow 22/tcp', + 'ufw --force enable', +] + +/** + * Phase 5: Install Node.js 22 + * Replicates hetzner-finland-fast.yml lines 207-229 + */ +export const INSTALL_NODEJS_COMMANDS = [ + 'curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --batch --yes --dearmor -o /usr/share/keyrings/nodesource.gpg', + 'echo "deb [signed-by=/usr/share/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" > /etc/apt/sources.list.d/nodesource.list', + 'apt-get update -qq', + 'apt-get install -y -qq nodejs', + 'npm install -g pnpm', +] + +/** + * Phase 6: Install RoboClaw + * Replicates hetzner-finland-fast.yml lines 232-281 + */ +export const INSTALL_ROBOCLAW_COMMANDS = [ + 'mkdir -p /home/roboclaw/.roboclaw/sessions', + 'mkdir -p /home/roboclaw/.roboclaw/credentials', + 'mkdir -p /home/roboclaw/.roboclaw/data', + 'mkdir -p /home/roboclaw/.roboclaw/logs', + 'mkdir -p /home/roboclaw/.local/share/pnpm', + 'mkdir -p /home/roboclaw/.local/bin', + 'chown -R roboclaw:roboclaw /home/roboclaw/.roboclaw', + 'chown -R roboclaw:roboclaw /home/roboclaw/.local', + 'chmod 0700 /home/roboclaw/.roboclaw/credentials', + 'su - roboclaw -c "pnpm config set global-dir /home/roboclaw/.local/share/pnpm"', + 'su - roboclaw -c "pnpm config set global-bin-dir /home/roboclaw/.local/bin"', + 'su - roboclaw -c "PNPM_HOME=/home/roboclaw/.local/share/pnpm PATH=/home/roboclaw/.local/bin:\\$PATH pnpm install -g roboclaw@latest"', + `cat >> /home/roboclaw/.bashrc << 'BASHRC_EOF' + +# BEGIN ANSIBLE MANAGED BLOCK - RoboClaw +# pnpm configuration +export PNPM_HOME="/home/roboclaw/.local/share/pnpm" +export PATH="/home/roboclaw/.local/bin:\\$PNPM_HOME:\\$PATH" +# END ANSIBLE MANAGED BLOCK - RoboClaw +BASHRC_EOF`, + 'chown roboclaw:roboclaw /home/roboclaw/.bashrc', +] + +/** + * Phase 7: Verify installation + * Replicates hetzner-finland-fast.yml lines 283-286 + */ +export const VERIFY_COMMANDS = [ + 'su - roboclaw -c "roboclaw --version"', + 'docker --version', + 'node --version', + 'pnpm --version', + 'ufw status', +] + +/** + * Complete provisioning workflow + */ +export async function provisionServer( + host: string, + privateKey: string, + onLog: LogCallback +): Promise { + const provisioner = new SSHProvisioner() + + try { + // Wait for SSH to be ready + onLog('info', 'Waiting for SSH to become available...') + await waitForSSH(host, 120000) + onLog('success', 'SSH is ready') + + // Connect + onLog('info', 'Connecting to server...') + await provisioner.connect(host, privateKey, onLog) + + // Phase 1: Install packages + onLog('info', 'Installing base packages...') + await provisioner.execScript(INSTALL_PACKAGES_COMMANDS, onLog) + onLog('success', 'Base packages installed') + + // Phase 2: Create user + onLog('info', 'Creating roboclaw user...') + await provisioner.execScript(CREATE_USER_COMMANDS, onLog) + onLog('success', 'RoboClaw user created') + + // Phase 3: Install Docker + onLog('info', 'Installing Docker CE...') + await provisioner.execScript(INSTALL_DOCKER_COMMANDS, onLog) + onLog('success', 'Docker installed') + + // Phase 4: Configure firewall + onLog('info', 'Configuring UFW firewall...') + await provisioner.execScript(CONFIGURE_FIREWALL_COMMANDS, onLog) + onLog('success', 'Firewall configured') + + // Phase 5: Install Node.js + onLog('info', 'Installing Node.js 22 and pnpm...') + await provisioner.execScript(INSTALL_NODEJS_COMMANDS, onLog) + onLog('success', 'Node.js and pnpm installed') + + // Phase 6: Install RoboClaw + onLog('info', 'Installing RoboClaw...') + await provisioner.execScript(INSTALL_ROBOCLAW_COMMANDS, onLog) + onLog('success', 'RoboClaw installed') + + // Phase 7: Verify + onLog('info', 'Verifying installation...') + await provisioner.execScript(VERIFY_COMMANDS, onLog) + onLog('success', 'Installation verified') + } finally { + provisioner.disconnect() + } +} + +/** + * Waits for SSH port to become available + */ +async function waitForSSH( + host: string, + timeoutMs = 120000 +): Promise { + const startTime = Date.now() + const pollInterval = 3000 + + while (Date.now() - startTime < timeoutMs) { + try { + // Try to connect to port 22 + const connected = await checkSSHPort(host, 22, 5000) + if (connected) { + return + } + } catch { + // Ignore errors and retry + } + + await new Promise((resolve) => setTimeout(resolve, pollInterval)) + } + + throw new Error(`SSH did not become available within ${timeoutMs / 1000}s`) +} + +/** + * Checks if SSH port is open + */ +function checkSSHPort( + host: string, + port: number, + timeout: number +): Promise { + return new Promise((resolve) => { + const net = require('net') + const socket = new net.Socket() + + socket.setTimeout(timeout) + socket.on('connect', () => { + socket.destroy() + resolve(true) + }) + socket.on('timeout', () => { + socket.destroy() + resolve(false) + }) + socket.on('error', () => { + socket.destroy() + resolve(false) + }) + + socket.connect(port, host) + }) +} diff --git a/website/lib/tunnel-manager.ts b/website/lib/tunnel-manager.ts new file mode 100644 index 0000000..6824421 --- /dev/null +++ b/website/lib/tunnel-manager.ts @@ -0,0 +1,334 @@ +import { spawn, type ChildProcess } from 'child_process' +import { createServer } from 'net' + +interface TunnelInfo { + process: ChildProcess + port: number + remotePort: number + instanceName: string + ip: string +} + +class TunnelManager { + private tunnels: Map = new Map() + private basePort = 7681 + + /** + * Starts an SSH tunnel for the given instance. + * Returns the local port the tunnel is bound to. + */ + async startTunnel( + instanceName: string, + ip: string, + keyPath: string, + remotePort: number + ): Promise { + // If a tunnel already exists for this instance, return its port + if (this.tunnels.has(instanceName)) { + return this.tunnels.get(instanceName)!.port + } + + // Find an available port + const port = await this.findAvailablePort() + + console.log(`[Tunnel] Creating tunnel for ${instanceName}: localhost:${port} -> ${ip}:${remotePort}`) + + // Spawn SSH tunnel process + // -i: identity file (SSH key) + // -o StrictHostKeyChecking=no: don't prompt for host verification + // -o UserKnownHostsFile=/dev/null: don't save host to known_hosts + // -L: local port forward (localPort:remoteHost:remotePort) + // -N: don't execute remote command (just forward ports) + const sshProcess = spawn('ssh', [ + '-i', keyPath, + '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + '-o', 'LogLevel=ERROR', + '-L', `${port}:localhost:${remotePort}`, + '-N', + `root@${ip}` + ]) + + // Handle process errors + sshProcess.on('error', (error) => { + console.error(`[Tunnel] SSH tunnel error for ${instanceName}:`, error) + this.tunnels.delete(instanceName) + }) + + // Handle process exit + sshProcess.on('exit', (code) => { + console.log(`[Tunnel] SSH tunnel exited for ${instanceName} with code ${code}`) + this.tunnels.delete(instanceName) + }) + + // Log stderr for debugging (don't filter - we need all errors) + sshProcess.stderr?.on('data', (data) => { + const message = data.toString().trim() + if (message) { + // Log warnings as info, actual errors as errors + if (message.toLowerCase().includes('warning')) { + console.log(`[Tunnel] SSH stderr for ${instanceName}:`, message) + } else { + console.error(`[Tunnel] SSH stderr for ${instanceName}:`, message) + } + } + }) + + // Log stdout (usually empty for SSH -N, but useful for debugging) + sshProcess.stdout?.on('data', (data) => { + const message = data.toString().trim() + if (message) { + console.log(`[Tunnel] SSH stdout for ${instanceName}:`, message) + } + }) + + // Store tunnel info + this.tunnels.set(instanceName, { + process: sshProcess, + port, + remotePort, + instanceName, + ip, + }) + + // Wait for the tunnel to establish and verify it's listening + console.log(`[Tunnel] Waiting for SSH tunnel to establish...`) + await new Promise(resolve => setTimeout(resolve, 5000)) + + // Verify the tunnel port is actually listening + const isListening = await this.verifyTunnelListening(port) + if (!isListening) { + sshProcess.kill() + this.tunnels.delete(instanceName) + throw new Error(`SSH tunnel failed to establish on port ${port}`) + } + + console.log(`[Tunnel] SSH tunnel verified and ready on port ${port}`) + return port + } + + /** + * Starts an SSH tunnel on a fixed local port (no dynamic port finding). + * Returns the local port the tunnel is bound to. + */ + async startFixedPortTunnel( + tunnelKey: string, + ip: string, + keyPath: string, + localPort: number, + remotePort: number + ): Promise { + // If a tunnel already exists for this key, return its port + if (this.tunnels.has(tunnelKey)) { + return this.tunnels.get(tunnelKey)!.port + } + + // Check if the fixed port is available + const available = await this.isPortAvailable(localPort) + if (!available) { + throw new Error(`Port ${localPort} is already in use`) + } + + console.log(`[Tunnel] Creating fixed-port tunnel for ${tunnelKey}: localhost:${localPort} -> ${ip}:${remotePort}`) + + // Spawn SSH tunnel process + const sshProcess = spawn('ssh', [ + '-i', keyPath, + '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + '-o', 'LogLevel=ERROR', + '-L', `${localPort}:localhost:${remotePort}`, + '-N', + `root@${ip}` + ]) + + // Handle process errors + sshProcess.on('error', (error) => { + console.error(`[Tunnel] SSH tunnel error for ${tunnelKey}:`, error) + this.tunnels.delete(tunnelKey) + }) + + // Handle process exit + sshProcess.on('exit', (code) => { + console.log(`[Tunnel] SSH tunnel exited for ${tunnelKey} with code ${code}`) + this.tunnels.delete(tunnelKey) + }) + + // Log stderr for debugging + sshProcess.stderr?.on('data', (data) => { + const message = data.toString().trim() + if (message) { + if (message.toLowerCase().includes('warning')) { + console.log(`[Tunnel] SSH stderr for ${tunnelKey}:`, message) + } else { + console.error(`[Tunnel] SSH stderr for ${tunnelKey}:`, message) + } + } + }) + + // Log stdout + sshProcess.stdout?.on('data', (data) => { + const message = data.toString().trim() + if (message) { + console.log(`[Tunnel] SSH stdout for ${tunnelKey}:`, message) + } + }) + + // Store tunnel info + this.tunnels.set(tunnelKey, { + process: sshProcess, + port: localPort, + remotePort, + instanceName: tunnelKey, + ip, + }) + + // Wait for the tunnel to establish and verify it's listening + console.log(`[Tunnel] Waiting for SSH tunnel to establish...`) + await new Promise(resolve => setTimeout(resolve, 3000)) + + // Verify the tunnel port is actually listening + const isListening = await this.verifyTunnelListening(localPort) + if (!isListening) { + sshProcess.kill() + this.tunnels.delete(tunnelKey) + throw new Error(`SSH tunnel failed to establish on port ${localPort}`) + } + + console.log(`[Tunnel] SSH tunnel verified and ready on port ${localPort}`) + return localPort + } + + /** + * Stops the SSH tunnel for the given instance. + */ + stopTunnel(instanceName: string): void { + const tunnel = this.tunnels.get(instanceName) + if (tunnel) { + tunnel.process.kill('SIGTERM') + this.tunnels.delete(instanceName) + } + } + + /** + * Checks if a tunnel is active for the given instance. + */ + isActive(instanceName: string): boolean { + return this.tunnels.has(instanceName) + } + + /** + * Gets the local port for the given instance's tunnel. + * Returns null if no tunnel exists. + */ + getPort(instanceName: string): number | null { + const tunnel = this.tunnels.get(instanceName) + return tunnel ? tunnel.port : null + } + + /** + * Gets all active tunnel keys. + * Useful for finding tunnels by pattern (e.g., all :proxy tunnels). + */ + getActiveKeys(): string[] { + return Array.from(this.tunnels.keys()) + } + + /** + * Stops all active tunnels. + * Useful for cleanup on server shutdown. + */ + stopAll(): void { + for (const [instanceName] of this.tunnels) { + this.stopTunnel(instanceName) + } + } + + /** + * Finds an available port starting from basePort. + * Checks for availability by attempting to bind a temporary server. + */ + private async findAvailablePort(): Promise { + let port = this.basePort + + // Check up to 100 ports + for (let i = 0; i < 100; i++) { + const testPort = port + i + + // Check if already in use by our tunnels + const inUse = Array.from(this.tunnels.values()).some(t => t.port === testPort) + if (inUse) { + continue + } + + // Check if available on the system + const available = await this.isPortAvailable(testPort) + if (available) { + return testPort + } + } + + throw new Error('No available ports found for SSH tunnel') + } + + /** + * Checks if a port is available by attempting to create a temporary server. + */ + private isPortAvailable(port: number): Promise { + return new Promise((resolve) => { + const server = createServer() + + server.once('error', () => { + resolve(false) + }) + + server.once('listening', () => { + server.close() + resolve(true) + }) + + server.listen(port, '127.0.0.1') + }) + } + + /** + * Verifies that the SSH tunnel is listening on the specified port. + * Returns true if the port is in use (tunnel is listening), false otherwise. + */ + private async verifyTunnelListening(port: number): Promise { + return new Promise((resolve) => { + const server = createServer() + + server.once('error', (err: any) => { + // Port in use (EADDRINUSE) = tunnel is listening (good!) + resolve(err.code === 'EADDRINUSE') + }) + + server.once('listening', () => { + // Port available = tunnel NOT listening (bad!) + server.close() + resolve(false) + }) + + server.listen(port, '127.0.0.1') + }) + } +} + +// Singleton instance +const tunnelManager = new TunnelManager() + +// Cleanup on process exit +if (typeof process !== 'undefined') { + process.on('SIGINT', () => { + tunnelManager.stopAll() + process.exit(0) + }) + + process.on('SIGTERM', () => { + tunnelManager.stopAll() + process.exit(0) + }) +} + +export default tunnelManager diff --git a/website/lib/types.ts b/website/lib/types.ts new file mode 100644 index 0000000..ee64810 --- /dev/null +++ b/website/lib/types.ts @@ -0,0 +1,167 @@ +// Deployment configuration +export interface DeployConfig { + token: string + serverName?: string + serverType?: string + location?: string + image?: string +} + +// Deployment phases +export type DeployPhase = + | 'idle' + | 'keygen' + | 'ssh_key' + | 'provisioning' + | 'ssh_wait' + | 'install_packages' + | 'create_user' + | 'install_docker' + | 'configure_firewall' + | 'install_nodejs' + | 'install_roboclaw' + | 'verify' + | 'success' + | 'error' + +// Log entry levels +export type LogLevel = 'info' | 'success' | 'warning' | 'error' | 'command' + +// Single log entry +export interface LogEntry { + timestamp: string + level: LogLevel + message: string + phase?: DeployPhase +} + +// Phase progress update +export interface PhaseUpdate { + phase: DeployPhase + step: number + totalSteps: number + label: string +} + +// Progress update +export interface ProgressUpdate { + percent: number +} + +// Deployment success result +export interface DeployResult { + ip: string + serverName: string + sshPrivateKey: string + sshUser: string + nextSteps: string[] +} + +// Deployment error +export interface DeployError { + message: string + phase?: DeployPhase + serverId?: number + recoverable: boolean +} + +// SSE event types +export type SSEEvent = + | { type: 'log'; data: LogEntry } + | { type: 'phase'; data: PhaseUpdate } + | { type: 'progress'; data: ProgressUpdate } + | { type: 'success'; data: DeployResult } + | { type: 'error'; data: DeployError } + | { type: 'heartbeat'; data: Record } + +// Hetzner API types +export interface HetznerSSHKey { + id: number + name: string + fingerprint: string + public_key: string +} + +export interface HetznerServer { + id: number + name: string + status: string + public_net: { + ipv4: { + ip: string + } + } + server_type: { + name: string + } + datacenter: { + location: { + name: string + } + } + image: { + name: string + } +} + +export interface HetznerAPIError { + code: string + message: string + details?: unknown +} + +// SSH keypair +export interface SSHKeypair { + privateKey: string + publicKey: string +} + +// Deployment state (used in deployment manager) +export interface DeploymentState { + config: DeployConfig + sshKeypair?: SSHKeypair + sshKeyId?: number + serverId?: number + serverIp?: string +} + +// Instance representation (from instances/*.yml files) +export interface Instance { + name: string + ip: string + serverType: string + location: string + image: string + provisionedAt: string + installMode: string + status: 'active' | 'deleted' + onboardingCompleted: boolean + software: { + os: string + kernel: string + docker: string + nodejs: string + pnpm: string + roboclaw: string + gemini?: string + ttyd?: string + } + configuration: { + roboclawUser: string + roboclawHome: string + roboclawConfigDir: string + } + firewall?: { + ufwEnabled: boolean + allowedPorts: Array<{ + port: number + proto: string + description: string + }> + } + ssh: { + keyFile: string + publicKeyFile: string + } + gatewayToken?: string +} diff --git a/website/next.config.ts b/website/next.config.ts index 84db861..d1eb338 100644 --- a/website/next.config.ts +++ b/website/next.config.ts @@ -1,13 +1,7 @@ import type { NextConfig } from 'next' const nextConfig: NextConfig = { - output: 'export', - images: { - unoptimized: true, - }, - // Next.js 16 uses Turbopack by default - turbopack: {}, - // Exclude ssh2 from bundling (used in API routes which won't be in static export) + // Mark server-only packages to be excluded from client bundle serverExternalPackages: ['ssh2'], } diff --git a/website/tsconfig.json b/website/tsconfig.json index d81d4ee..9e9bbf7 100644 --- a/website/tsconfig.json +++ b/website/tsconfig.json @@ -14,7 +14,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, "plugins": [ { @@ -32,7 +32,8 @@ "next-env.d.ts", "**/*.ts", "**/*.tsx", - ".next/types/**/*.ts" + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" ], "exclude": [ "node_modules" From 5e670a53534d464cc359a37925f9acda1c8c30a3 Mon Sep 17 00:00:00 2001 From: Mo Balaa Date: Mon, 2 Feb 2026 18:47:57 -0600 Subject: [PATCH 2/8] Add deploy.yml back --- .github/workflows/deploy.yml | 75 ++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..2f52a4b --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,75 @@ +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 From 76e77e606a4a8ae8108dbc682bad95750c543982 Mon Sep 17 00:00:00 2001 From: Mo Balaa Date: Mon, 2 Feb 2026 18:49:58 -0600 Subject: [PATCH 3/8] Delete .github/workflows directory --- .github/workflows/deploy.yml | 75 ------------------------------------ 1 file changed, 75 deletions(-) delete mode 100644 .github/workflows/deploy.yml 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 From 39e870075ef530fb2a319ca1baabd5c65ed0284a Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 3 Feb 2026 14:02:31 -0600 Subject: [PATCH 4/8] Add CLI deployment scripts for deploying to existing servers Implements complete deployment workflow for deploying OpenClaw to existing servers without provisioning new Hetzner instances. Enables integration testing of Ansible playbooks and deployment to any server with SSH access. New scripts: - run-deploy.sh: Deploy OpenClaw using SSH key and inventory file - Auto-onboards by default (--skip-onboard to disable) - Creates instance artifacts automatically - Supports custom instance names via INSTANCE_NAME_OVERRIDE - connect-instance.sh: Connect to instances and run OpenClaw commands - Reads instance artifacts to get connection details - Supports interactive onboard/setup/shell access - Can use custom IP/key via --ip and --key flags - create-inventory.sh: Generate Ansible inventory from IP address - Simple wrapper to create inventory files - Validates IP format - cleanup-ssh-key.yml: Manage SSH keys in Hetzner Cloud - List all SSH keys - Delete specific keys by name Improvements: - run-hetzner.sh: Add prerequisite checks for Python 3.12, Ansible, dependencies - hetzner-finland-fast.yml: - Remove unsupported 'force' parameter from ssh_key module - Add debug task to show provisioning parameters Complete workflow: 1. Create inventory: ./create-inventory.sh inventory.ini 2. Deploy + onboard: ./run-deploy.sh -k -i inventory.ini 3. Or connect later: ./connect-instance.sh onboard Co-Authored-By: Claude Sonnet 4.5 --- cleanup-ssh-key.yml | 35 ++++++ connect-instance.sh | 131 +++++++++++++++++++ create-inventory.sh | 43 +++++++ hetzner-finland-fast.yml | 10 +- run-deploy.sh | 266 +++++++++++++++++++++++++++++++++++++++ run-hetzner.sh | 85 ++++++++++++- 6 files changed, 568 insertions(+), 2 deletions(-) create mode 100644 cleanup-ssh-key.yml create mode 100755 connect-instance.sh create mode 100755 create-inventory.sh create mode 100755 run-deploy.sh diff --git a/cleanup-ssh-key.yml b/cleanup-ssh-key.yml new file mode 100644 index 0000000..6c05da3 --- /dev/null +++ b/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/connect-instance.sh b/connect-instance.sh new file mode 100755 index 0000000..24dacf1 --- /dev/null +++ b/connect-instance.sh @@ -0,0 +1,131 @@ +#!/bin/bash +set -e + +# 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/create-inventory.sh b/create-inventory.sh new file mode 100755 index 0000000..d898939 --- /dev/null +++ b/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/hetzner-finland-fast.yml b/hetzner-finland-fast.yml index e6444a0..c4eb4ad 100644 --- a/hetzner-finland-fast.yml +++ b/hetzner-finland-fast.yml @@ -14,6 +14,15 @@ 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" @@ -30,7 +39,6 @@ name: "{{ ssh_key_name }}" public_key: "{{ ssh_public_key }}" state: present - force: true # Force update if key with same name exists but different public key register: ssh_key - name: Delete existing server if it exists (to force fresh deployment with new SSH key) diff --git a/run-deploy.sh b/run-deploy.sh new file mode 100755 index 0000000..25a5fb0 --- /dev/null +++ b/run-deploy.sh @@ -0,0 +1,266 @@ +#!/bin/bash +set -e + +# Deploy OpenClaw to existing servers using SSH key and Ansible inventory +# +# Usage: +# ./run-deploy.sh --ssh-key --inventory +# ./run-deploy.sh -k -i +# +# 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 -k ~/.ssh/id_ed25519 -i hosts.ini +# SSH_PRIVATE_KEY_PATH=./key INVENTORY_PATH=./hosts ./run-deploy.sh + +# 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 + fi + + echo "" + + if [ $errors -ne 0 ]; then + echo "Prerequisites not met. Please install missing dependencies." + echo "" + echo "Quick 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 + +# Parse arguments +SSH_KEY="${SSH_PRIVATE_KEY_PATH:-}" +INVENTORY="${INVENTORY_PATH:-}" +AUTO_SETUP="onboard" # Default: auto-onboard after deployment +EXTRA_ARGS=() + +while [[ $# -gt 0 ]]; do + case $1 in + -k|--ssh-key) + SSH_KEY="$2" + shift 2 + ;; + -i|--inventory) + INVENTORY="$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 " ./run-deploy.sh --ssh-key --inventory [options]" + echo " ./run-deploy.sh -k -i [options]" + echo "" + echo "Options:" + echo " -k, --ssh-key Path to SSH private key" + echo " -i, --inventory Path to Ansible inventory file" + echo " --skip-onboard Skip automatic onboarding (default: auto-onboard)" + 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 " ./run-deploy.sh -k ~/.ssh/id_ed25519 -i hosts.ini" + echo " ./run-deploy.sh -k ~/.ssh/id_ed25519 -i hosts.ini --skip-onboard" + echo " ./run-deploy.sh -k key -i inventory.ini --tags docker,nodejs" + echo " INSTANCE_NAME_OVERRIDE=my-server ./run-deploy.sh -k key -i hosts.ini" + 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 --ssh-key or set SSH_PRIVATE_KEY_PATH" + exit 1 +fi + +if [ ! -f "$SSH_KEY" ]; then + echo "Error: SSH key file not found: $SSH_KEY" + exit 1 +fi + +if [ -z "$INVENTORY" ]; then + echo "Error: Inventory not provided. Use --inventory or set INVENTORY_PATH" + exit 1 +fi + +if [ ! -f "$INVENTORY" ]; then + echo "Error: Inventory file not found: $INVENTORY" + 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" + 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[^ ]+') + INSTANCE_NAME=$(echo "$HOST" | tr '.' '-' | tr '_' '-') + else + # Host is the IP + IP="$HOST" + INSTANCE_NAME="instance-${IP//./-}" + fi + + # Allow overriding instance name via environment variable + if [ -n "$INSTANCE_NAME_OVERRIDE" ]; then + INSTANCE_NAME="$INSTANCE_NAME_OVERRIDE" + fi + + ARTIFACT_FILE="./instances/${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: ${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: ${INSTANCE_NAME}" + echo " → IP: ${IP}" + + done < <(grep -v "^$" "$INVENTORY" 2>/dev/null || true) + + 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 "${INSTANCE_NAME}" "$AUTO_SETUP" + else + echo "" + echo "To complete setup, run:" + echo " ./connect-instance.sh ${INSTANCE_NAME} onboard" + fi + +else + echo "" + echo "❌ Deployment failed with exit code: $ANSIBLE_EXIT_CODE" + exit $ANSIBLE_EXIT_CODE +fi diff --git a/run-hetzner.sh b/run-hetzner.sh index ef0b744..4f21168 100755 --- a/run-hetzner.sh +++ b/run-hetzner.sh @@ -1,7 +1,90 @@ #!/bin/bash set -e -# Activate virtualenv +# 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. Please install missing dependencies." + echo "" + echo "Quick 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 From f8564a240dc03f4202a054b3654b1d6bcbbb8320 Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 3 Feb 2026 14:04:58 -0600 Subject: [PATCH 5/8] Update README with CLI deployment documentation Add comprehensive documentation for the new CLI deployment workflow: - Quick Start section for deploying to existing servers - Detailed command documentation for run-deploy.sh, connect-instance.sh, create-inventory.sh - Complete workflow examples showing end-to-end deployment - Updated file structure with new scripts - Enhanced requirements section with Python 3.12+ setup - Updated TLDR with both deployment methods Key improvements: - Emphasizes CLI deployment as recommended approach for testing - Documents auto-onboard feature (default behavior) - Shows how to skip onboarding with --skip-onboard - Provides clear setup instructions for Python virtualenv - Includes prerequisite checking information Co-Authored-By: Claude Sonnet 4.5 --- README.md | 194 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 185 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index bbf4b1a..93603ba 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,35 @@ -# Hetzner VPS Provisioning with RoboClaw +# RoboClaw Deployment Automation -One-command provisioning of VPS instances in Finland with automated RoboClaw installation. +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. Setup Python environment +~/.pyenv/versions/3.12.0/bin/python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +ansible-galaxy collection install hetzner.hcloud + +# 2. Create inventory from your server IP +./create-inventory.sh inventory.ini + +# 3. Deploy OpenClaw and auto-onboard (one command!) +INSTANCE_NAME_OVERRIDE=my-server ./run-deploy.sh \ + -k ~/.ssh/your_key \ + -i inventory.ini + +# That's it! The script will: +# - Deploy OpenClaw + dependencies (~3-5 min) +# - Create instance artifact +# - Drop you into interactive onboarding wizard +``` + +## Quick Start - Hetzner Cloud Provisioning ```bash # 1. Get Hetzner API token from https://console.hetzner.cloud/ @@ -27,7 +54,65 @@ openclaw onboard --install-daemon ## Commands -### Provision New Server +### Deploy to Existing Server (CLI) + +#### Deploy with Auto-Onboard (Recommended) + +```bash +# Deploy to any server with SSH access +# By default, automatically launches 'openclaw onboard' after deployment +./run-deploy.sh -k -i + +# With custom instance name +INSTANCE_NAME_OVERRIDE=production ./run-deploy.sh \ + -k ~/.ssh/prod-key \ + -i prod-inventory.ini +``` + +**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 +./run-deploy.sh -k -i --skip-onboard + +# Connect and onboard later +./connect-instance.sh onboard +``` + +#### Create Inventory File + +```bash +# Generate Ansible inventory from IP address +./create-inventory.sh [output-file] + +# Example +./create-inventory.sh 1.2.3.4 production.ini +``` + +#### Connect to Instance + +```bash +# Connect and run onboarding wizard +./connect-instance.sh onboard + +# Connect to interactive shell +./connect-instance.sh + +# Connect with custom IP/key +./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) @@ -226,15 +311,23 @@ openclaw onboard --install-daemon ├── PROVISION.md # Detailed technical documentation ├── HETZNER_SETUP.md # Setup guide ├── ROBOCLAW_GUIDE.md # RoboClaw integration guide -├── run-hetzner.sh # Main script (provision/list/delete) +├── run-deploy.sh # Deploy to existing servers (CLI) +├── connect-instance.sh # Connect to instances and run OpenClaw +├── create-inventory.sh # Generate inventory files from IP +├── cleanup-ssh-key.yml # Manage SSH keys in Hetzner +├── run-hetzner.sh # Provision new Hetzner servers ├── validate-instance.sh # Validation script -├── hetzner-finland-fast.yml # Provision playbook +├── hetzner-finland-fast.yml # Hetzner provision playbook ├── hetzner-teardown.yml # Teardown playbook +├── reconfigure.yml # Software installation playbook ├── list-server-types.sh # List instance types ├── .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 ├── finland-instance-ip.txt # Server IP address ├── roboclaw/ # RoboClaw source code (submodule) └── instances/ # Instance artifacts (YAML) @@ -242,9 +335,32 @@ openclaw onboard --install-daemon ## Requirements +**For CLI deployment (run-deploy.sh):** +- Python 3.12+ (required for Ansible 13+) +- SSH access to target server(s) +- Virtual environment (setup instructions below) + +**For Hetzner provisioning (run-hetzner.sh):** - Python 3.12+ - Hetzner Cloud account with API token -- No Ansible installation needed (uses virtualenv) +- Virtual environment (setup instructions below) + +### Setup Python Environment + +```bash +# Using pyenv (recommended) +pyenv install 3.12.0 +~/.pyenv/versions/3.12.0/bin/python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +ansible-galaxy collection install hetzner.hcloud + +# Verify prerequisites +./run-deploy.sh --help +# Should show: ✓ Python 3.12.0, ✓ Ansible, etc. +``` + +The scripts automatically check prerequisites and provide helpful setup instructions if anything is missing. ## How It Works @@ -326,9 +442,54 @@ rm instances/*_deleted.yml ./validate-instance.sh ``` +## Complete Workflows + +### Deploy to Existing Server - Complete Example + +```bash +# 1. Setup prerequisites (one time) +~/.pyenv/versions/3.12.0/bin/python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +ansible-galaxy collection install hetzner.hcloud + +# 2. Create inventory from your server IP +./create-inventory.sh 192.168.1.100 production.ini + +# 3. Deploy and auto-onboard +INSTANCE_NAME_OVERRIDE=production ./run-deploy.sh \ + -k ~/.ssh/prod-key \ + -i production.ini + +# The script will: +# - Check prerequisites (Python 3.12+, Ansible, dependencies) +# - Deploy OpenClaw + all dependencies +# - Create artifact at ./instances/production.yml +# - Automatically launch 'openclaw onboard' wizard +# - Drop you into interactive configuration + +# 4. Later, reconnect if needed +./connect-instance.sh production onboard +``` + +### Deploy Multiple Servers + +```bash +# Server 1 +./create-inventory.sh 192.168.1.100 server1.ini +INSTANCE_NAME_OVERRIDE=server1 ./run-deploy.sh -k key -i server1.ini + +# Server 2 +./create-inventory.sh 192.168.1.101 server2.ini +INSTANCE_NAME_OVERRIDE=server2 ./run-deploy.sh -k key -i server2.ini + +# List all instances +ls -la ./instances/ +``` + ## Examples -### Provision Multiple Servers +### Provision Multiple Servers (Hetzner) ```bash # Edit server name in hetzner-finland-fast.yml @@ -418,7 +579,22 @@ For issues with: --- -**TLDR:** +## TLDR + +**Deploy to existing server (recommended):** +```bash +# Setup +~/.pyenv/versions/3.12.0/bin/python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt + +# Deploy with auto-onboard +./create-inventory.sh inventory.ini +INSTANCE_NAME_OVERRIDE=my-server ./run-deploy.sh -k ~/.ssh/key -i inventory.ini +# ↑ Automatically launches 'openclaw onboard' wizard +``` + +**Or provision new Hetzner server:** ```bash echo 'HCLOUD_TOKEN=your-token' > .env ./run-hetzner.sh # Provision (~2-3 min) From 7e0e7c957e367fbf1081a1dce8c534c0e96ab892 Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 3 Feb 2026 14:08:05 -0600 Subject: [PATCH 6/8] Add automatic setup script with Python 3.12+ detection Create setup.sh to automate environment setup: - Automatically detects Python 3.12+ (checks python3.12, python3, python, pyenv) - Creates virtual environment if needed - Installs all Python dependencies from requirements.txt - Installs Ansible Hetzner collection - Provides helpful error messages if Python 3.12+ not found - Shows installation instructions for pyenv, apt, and brew Update prerequisite checks: - run-deploy.sh: Suggest running ./setup.sh if prerequisites missing - run-hetzner.sh: Suggest running ./setup.sh if prerequisites missing Update README: - Replace manual setup steps with ./setup.sh one-liner - Document what setup.sh does and how it works - Update Quick Start, Requirements, Complete Workflows, and TLDR - Emphasize simplicity: just run ./setup.sh User experience improvements: - No need to know about venv, pip, or ansible-galaxy - No need to find the right Python binary - Just run ./setup.sh and everything is installed - Clear errors if Python 3.12+ not available Co-Authored-By: Claude Sonnet 4.5 --- README.md | 56 ++++++++++++------------- run-deploy.sh | 7 +++- run-hetzner.sh | 7 +++- setup.sh | 112 +++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 150 insertions(+), 32 deletions(-) create mode 100755 setup.sh diff --git a/README.md b/README.md index 93603ba..2f2fd46 100644 --- a/README.md +++ b/README.md @@ -9,16 +9,13 @@ Automated deployment system for provisioning VPS instances and installing OpenCl ## Quick Start - Deploy to Existing Server ```bash -# 1. Setup Python environment -~/.pyenv/versions/3.12.0/bin/python3 -m venv venv -source venv/bin/activate -pip install -r requirements.txt -ansible-galaxy collection install hetzner.hcloud +# 1. One-time setup (requires Python 3.12+) +./setup.sh # 2. Create inventory from your server IP ./create-inventory.sh inventory.ini -# 3. Deploy OpenClaw and auto-onboard (one command!) +# 3. Deploy OpenClaw and auto-onboard INSTANCE_NAME_OVERRIDE=my-server ./run-deploy.sh \ -k ~/.ssh/your_key \ -i inventory.ini @@ -29,6 +26,13 @@ INSTANCE_NAME_OVERRIDE=my-server ./run-deploy.sh \ # - Drop you into interactive onboarding wizard ``` +**What `./setup.sh` does:** +- Detects Python 3.12+ (checks python3.12, python3, python, pyenv) +- Creates virtual environment if needed +- Installs all Python dependencies +- Installs Ansible Hetzner collection +- Provides helpful errors if Python 3.12+ not found + ## Quick Start - Hetzner Cloud Provisioning ```bash @@ -338,29 +342,30 @@ openclaw onboard --install-daemon **For CLI deployment (run-deploy.sh):** - Python 3.12+ (required for Ansible 13+) - SSH access to target server(s) -- Virtual environment (setup instructions below) **For Hetzner provisioning (run-hetzner.sh):** - Python 3.12+ - Hetzner Cloud account with API token -- Virtual environment (setup instructions below) -### Setup Python Environment +### Automatic Setup ```bash -# Using pyenv (recommended) -pyenv install 3.12.0 -~/.pyenv/versions/3.12.0/bin/python3 -m venv venv -source venv/bin/activate -pip install -r requirements.txt -ansible-galaxy collection install hetzner.hcloud +# One command to install everything +./setup.sh -# Verify prerequisites -./run-deploy.sh --help -# Should show: ✓ Python 3.12.0, ✓ Ansible, etc. +# 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 ``` -The scripts automatically check prerequisites and provide helpful setup instructions if anything is missing. +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 `./setup.sh` if anything is missing. ## How It Works @@ -447,11 +452,8 @@ rm instances/*_deleted.yml ### Deploy to Existing Server - Complete Example ```bash -# 1. Setup prerequisites (one time) -~/.pyenv/versions/3.12.0/bin/python3 -m venv venv -source venv/bin/activate -pip install -r requirements.txt -ansible-galaxy collection install hetzner.hcloud +# 1. One-time setup (requires Python 3.12+) +./setup.sh # 2. Create inventory from your server IP ./create-inventory.sh 192.168.1.100 production.ini @@ -583,10 +585,8 @@ For issues with: **Deploy to existing server (recommended):** ```bash -# Setup -~/.pyenv/versions/3.12.0/bin/python3 -m venv venv -source venv/bin/activate -pip install -r requirements.txt +# One-time setup +./setup.sh # Deploy with auto-onboard ./create-inventory.sh inventory.ini diff --git a/run-deploy.sh b/run-deploy.sh index 25a5fb0..9dea74c 100755 --- a/run-deploy.sh +++ b/run-deploy.sh @@ -71,9 +71,12 @@ check_prerequisites() { echo "" if [ $errors -ne 0 ]; then - echo "Prerequisites not met. Please install missing dependencies." + echo "Prerequisites not met." echo "" - echo "Quick setup:" + 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" diff --git a/run-hetzner.sh b/run-hetzner.sh index 4f21168..26183fc 100755 --- a/run-hetzner.sh +++ b/run-hetzner.sh @@ -67,9 +67,12 @@ check_prerequisites() { echo "" if [ $errors -ne 0 ]; then - echo "Prerequisites not met. Please install missing dependencies." + echo "Prerequisites not met." echo "" - echo "Quick setup:" + 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" diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..69d22f3 --- /dev/null +++ b/setup.sh @@ -0,0 +1,112 @@ +#!/bin/bash +set -e + +# 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" From 3e235f0006a2457b90122221954bf825743bb4fb Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 3 Feb 2026 14:29:46 -0600 Subject: [PATCH 7/8] Implement one-command deployment with auto-setup and direct IP support Transform run-deploy.sh into a true one-command solution that: - Auto-installs dependencies (Python 3.12+, venv, Ansible, collections) - Accepts IP address directly as positional argument - Accepts instance name via -n/--name flag - Auto-generates temporary inventory files - Maintains full backward compatibility with inventory files New workflow: ./run-deploy.sh -k -n Old workflow still works: ./run-deploy.sh -k -i Co-Authored-By: Claude Sonnet 4.5 --- README.md | 68 +++++------- run-deploy.sh | 296 +++++++++++++++++++++++++++++++++++--------------- 2 files changed, 234 insertions(+), 130 deletions(-) diff --git a/README.md b/README.md index 2f2fd46..e9c1536 100644 --- a/README.md +++ b/README.md @@ -9,30 +9,29 @@ Automated deployment system for provisioning VPS instances and installing OpenCl ## Quick Start - Deploy to Existing Server ```bash -# 1. One-time setup (requires Python 3.12+) -./setup.sh - -# 2. Create inventory from your server IP -./create-inventory.sh inventory.ini +# One command to deploy OpenClaw and auto-onboard +./run-deploy.sh -k ~/.ssh/your_key -# 3. Deploy OpenClaw and auto-onboard -INSTANCE_NAME_OVERRIDE=my-server ./run-deploy.sh \ - -k ~/.ssh/your_key \ - -i inventory.ini +# With custom instance name +./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 `./setup.sh` does:** +**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 @@ -63,14 +62,14 @@ openclaw onboard --install-daemon #### Deploy with Auto-Onboard (Recommended) ```bash -# Deploy to any server with SSH access -# By default, automatically launches 'openclaw onboard' after deployment -./run-deploy.sh -k -i +# One-command deployment (recommended) +./run-deploy.sh -k # With custom instance name -INSTANCE_NAME_OVERRIDE=production ./run-deploy.sh \ - -k ~/.ssh/prod-key \ - -i prod-inventory.ini +./run-deploy.sh -k -n production + +# Legacy: Using inventory file (still supported) +./run-deploy.sh -k -i ``` **What gets installed:** @@ -87,16 +86,17 @@ INSTANCE_NAME_OVERRIDE=production ./run-deploy.sh \ ```bash # Deploy without launching onboarding wizard -./run-deploy.sh -k -i --skip-onboard +./run-deploy.sh -k --skip-onboard # Connect and onboard later ./connect-instance.sh onboard ``` -#### Create Inventory File +#### Create Inventory File (Advanced) ```bash -# Generate Ansible inventory from IP address +# For advanced use cases with multiple servers +# Most users should use direct IP deployment instead ./create-inventory.sh [output-file] # Example @@ -452,25 +452,17 @@ rm instances/*_deleted.yml ### Deploy to Existing Server - Complete Example ```bash -# 1. One-time setup (requires Python 3.12+) -./setup.sh - -# 2. Create inventory from your server IP -./create-inventory.sh 192.168.1.100 production.ini - -# 3. Deploy and auto-onboard -INSTANCE_NAME_OVERRIDE=production ./run-deploy.sh \ - -k ~/.ssh/prod-key \ - -i production.ini +# One command to deploy and auto-onboard +./run-deploy.sh 192.168.1.100 -k ~/.ssh/prod-key -n production # The script will: -# - Check prerequisites (Python 3.12+, Ansible, dependencies) +# - 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 -# 4. Later, reconnect if needed +# Later, reconnect if needed ./connect-instance.sh production onboard ``` @@ -478,12 +470,10 @@ INSTANCE_NAME_OVERRIDE=production ./run-deploy.sh \ ```bash # Server 1 -./create-inventory.sh 192.168.1.100 server1.ini -INSTANCE_NAME_OVERRIDE=server1 ./run-deploy.sh -k key -i server1.ini +./run-deploy.sh 192.168.1.100 -k ~/.ssh/key -n server1 # Server 2 -./create-inventory.sh 192.168.1.101 server2.ini -INSTANCE_NAME_OVERRIDE=server2 ./run-deploy.sh -k key -i server2.ini +./run-deploy.sh 192.168.1.101 -k ~/.ssh/key -n server2 # List all instances ls -la ./instances/ @@ -585,12 +575,8 @@ For issues with: **Deploy to existing server (recommended):** ```bash -# One-time setup -./setup.sh - -# Deploy with auto-onboard -./create-inventory.sh inventory.ini -INSTANCE_NAME_OVERRIDE=my-server ./run-deploy.sh -k ~/.ssh/key -i inventory.ini +# One command - auto-installs dependencies, deploys, and onboards +./run-deploy.sh -k ~/.ssh/key -n my-server # ↑ Automatically launches 'openclaw onboard' wizard ``` diff --git a/run-deploy.sh b/run-deploy.sh index 9dea74c..bf6591e 100755 --- a/run-deploy.sh +++ b/run-deploy.sh @@ -4,106 +4,171 @@ set -e # Deploy OpenClaw to existing servers using SSH key and Ansible inventory # # Usage: -# ./run-deploy.sh --ssh-key --inventory -# ./run-deploy.sh -k -i +# ./run-deploy.sh --ssh-key [options] +# ./run-deploy.sh -k [options] +# ./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 -k ~/.ssh/id_ed25519 -i hosts.ini -# SSH_PRIVATE_KEY_PATH=./key INVENTORY_PATH=./hosts ./run-deploy.sh +# ./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 prerequisites -check_prerequisites() { - local errors=0 +# Function to check Python version +check_python_version() { + local python_cmd="$1" - echo "Checking prerequisites..." + if ! command -v "$python_cmd" &> /dev/null; then + return 1 + fi - # 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" + # Try to get version, suppress errors + local version + if ! version=$($python_cmd --version 2>&1); then + return 1 + fi - # Activate venv to check contents - source venv/bin/activate + version=$(echo "$version" | awk '{print $2}') + local major=$(echo "$version" | cut -d. -f1) + local minor=$(echo "$version" | cut -d. -f2) - # 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) + # Check if we got valid version numbers + if ! [[ "$major" =~ ^[0-9]+$ ]] || ! [[ "$minor" =~ ^[0-9]+$ ]]; then + return 1 + fi - 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 + if [ "$major" -lt 3 ] || ([ "$major" -eq 3 ] && [ "$minor" -lt 12 ]); then + return 1 + 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 + return 0 +} - # 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" +# 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 "" - if [ $errors -ne 0 ]; then - echo "Prerequisites not met." + # 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 "Run automatic setup:" - echo " ./setup.sh" + 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 "" - 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" + PYTHON_CMD="$python_cmd" + PYTHON_VERSION=$($PYTHON_CMD --version 2>&1 | awk '{print $2}') + echo "✓ Found Python $PYTHON_VERSION" echo "" -} -# Run prerequisite checks -check_prerequisites + # 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 virtualenv (already activated in check, but re-activate to be safe) -if [ -d "venv" ]; then + # Activate venv source venv/bin/activate -fi + + # 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 @@ -112,6 +177,10 @@ while [[ $# -gt 0 ]]; do INVENTORY="$2" shift 2 ;; + -n|--name) + INSTANCE_NAME="$2" + shift 2 + ;; --skip-onboard|--no-onboard) AUTO_SETUP="" shift @@ -120,13 +189,16 @@ while [[ $# -gt 0 ]]; do echo "Deploy OpenClaw to existing servers using SSH key and Ansible inventory" echo "" echo "Usage:" - echo " ./run-deploy.sh --ssh-key --inventory [options]" - echo " ./run-deploy.sh -k -i [options]" + echo " ./run-deploy.sh -k [options] # Direct IP (recommended)" + echo " ./run-deploy.sh -k -n # With instance name" + echo " ./run-deploy.sh -k -i # Inventory file (advanced)" echo "" echo "Options:" - echo " -k, --ssh-key Path to SSH private key" - echo " -i, --inventory Path to Ansible inventory file" - echo " --skip-onboard Skip automatic onboarding (default: auto-onboard)" + 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 "" @@ -136,10 +208,10 @@ while [[ $# -gt 0 ]]; do echo " INSTANCE_NAME_OVERRIDE Override instance name in artifact" echo "" echo "Examples:" - echo " ./run-deploy.sh -k ~/.ssh/id_ed25519 -i hosts.ini" - echo " ./run-deploy.sh -k ~/.ssh/id_ed25519 -i hosts.ini --skip-onboard" - echo " ./run-deploy.sh -k key -i inventory.ini --tags docker,nodejs" - echo " INSTANCE_NAME_OVERRIDE=my-server ./run-deploy.sh -k key -i hosts.ini" + echo " ./run-deploy.sh 192.168.1.100 -k ~/.ssh/id_ed25519" + echo " ./run-deploy.sh 192.168.1.100 -k ~/.ssh/key -n production" + echo " ./run-deploy.sh 192.168.1.100 -k key -n prod --skip-onboard" + echo " ./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." @@ -154,7 +226,7 @@ done # Validate inputs if [ -z "$SSH_KEY" ]; then - echo "Error: SSH key not provided. Use --ssh-key or set SSH_PRIVATE_KEY_PATH" + echo "Error: SSH key not provided. Use -k/--ssh-key or set SSH_PRIVATE_KEY_PATH" exit 1 fi @@ -163,13 +235,44 @@ if [ ! -f "$SSH_KEY" ]; then exit 1 fi -if [ -z "$INVENTORY" ]; then - echo "Error: Inventory not provided. Use --inventory or set INVENTORY_PATH" - 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 -if [ ! -f "$INVENTORY" ]; then - echo "Error: Inventory file not found: $INVENTORY" + # 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 + # 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 " ./run-deploy.sh -k # Using IP" + echo " ./run-deploy.sh -k -i # Using inventory" + echo "" + echo "Run with --help for more options" exit 1 fi @@ -195,6 +298,7 @@ if [ $ANSIBLE_EXIT_CODE -eq 0 ]; then # 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 @@ -208,19 +312,21 @@ if [ $ANSIBLE_EXIT_CODE -eq 0 ]; then # Check if there's an ansible_host variable if echo "$line" | grep -q "ansible_host="; then IP=$(echo "$line" | grep -oP 'ansible_host=\K[^ ]+') - INSTANCE_NAME=$(echo "$HOST" | tr '.' '-' | tr '_' '-') + ARTIFACT_INSTANCE_NAME=$(echo "$HOST" | tr '.' '-' | tr '_' '-') else # Host is the IP IP="$HOST" - INSTANCE_NAME="instance-${IP//./-}" + ARTIFACT_INSTANCE_NAME="instance-${IP//./-}" fi - # Allow overriding instance name via environment variable - if [ -n "$INSTANCE_NAME_OVERRIDE" ]; then - INSTANCE_NAME="$INSTANCE_NAME_OVERRIDE" + # 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/${INSTANCE_NAME}.yml" + ARTIFACT_FILE="./instances/${ARTIFACT_INSTANCE_NAME}.yml" TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") # Get absolute path of SSH key @@ -230,7 +336,7 @@ if [ $ANSIBLE_EXIT_CODE -eq 0 ]; then cat > "$ARTIFACT_FILE" << EOF # Instance deployed via run-deploy.sh on ${TIMESTAMP} instances: - - name: ${INSTANCE_NAME} + - name: ${ARTIFACT_INSTANCE_NAME} ip: ${IP} deployed_at: ${TIMESTAMP} deployment_method: run-deploy.sh @@ -241,11 +347,18 @@ instances: EOF echo " ✓ Created artifact: ${ARTIFACT_FILE}" - echo " → Instance name: ${INSTANCE_NAME}" + 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!" @@ -255,14 +368,19 @@ EOF echo "🚀 Launching OpenClaw interactive wizard..." echo "" sleep 1 - ./connect-instance.sh "${INSTANCE_NAME}" "$AUTO_SETUP" + ./connect-instance.sh "${FINAL_INSTANCE_NAME}" "$AUTO_SETUP" else echo "" echo "To complete setup, run:" - echo " ./connect-instance.sh ${INSTANCE_NAME} onboard" + echo " ./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 From ee12c88a3d1baa0c00871ec4ee2d04ccd7763426 Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 3 Feb 2026 15:08:14 -0600 Subject: [PATCH 8/8] Organize CLI scripts into cli/ directory for better structure Move all CLI scripts and Ansible playbooks into a dedicated cli/ directory: - All shell scripts (.sh files) - All Ansible playbooks (.yml files) - ansible.cfg configuration Changes: - Created cli/ directory structure - Moved 16 files: scripts, playbooks, and config - Updated all scripts to cd to correct directory on startup - Added path resolution for user-provided arguments (SSH keys, inventory) - Updated README, PROVISION.md, HETZNER_SETUP.md with new paths - Updated File Structure section in README - All scripts reference ../instances/ and ../venv/ correctly User workflow remains simple: ./cli/run-deploy.sh -k -n Tested and verified: - Deployment works correctly with new structure - Path resolution handles relative and absolute paths - Instance artifacts created successfully Co-Authored-By: Claude Sonnet 4.5 --- HETZNER_SETUP.md | 2 +- PROVISION.md | 16 +-- README.md | 136 +++++++++--------- ansible.cfg => cli/ansible.cfg | 0 .../cleanup-ssh-key.yml | 0 .../connect-instance.sh | 7 +- .../create-inventory.sh | 0 .../hetzner-finland-fast.yml | 2 +- .../hetzner-requirements.yml | 0 .../hetzner-teardown.yml | 6 +- .../list-server-types.sh | 0 .../openclaw-service.yml | 2 +- quick-validate.sh => cli/quick-validate.sh | 0 reconfigure.yml => cli/reconfigure.yml | 0 run-deploy.sh => cli/run-deploy.sh | 61 +++++--- run-hetzner.sh => cli/run-hetzner.sh | 13 +- setup.sh => cli/setup.sh | 3 + .../validate-instance.sh | 11 +- .../validate-openclaw.yml | 0 instances/ROBOCLAW-INT-TEST.yml | 10 ++ instances/instance-77-42-73-229.yml | 10 ++ test-inventory.ini | 2 + 22 files changed, 170 insertions(+), 111 deletions(-) rename ansible.cfg => cli/ansible.cfg (100%) rename cleanup-ssh-key.yml => cli/cleanup-ssh-key.yml (100%) rename connect-instance.sh => cli/connect-instance.sh (94%) rename create-inventory.sh => cli/create-inventory.sh (100%) rename hetzner-finland-fast.yml => cli/hetzner-finland-fast.yml (99%) rename hetzner-requirements.yml => cli/hetzner-requirements.yml (100%) rename hetzner-teardown.yml => cli/hetzner-teardown.yml (95%) rename list-server-types.sh => cli/list-server-types.sh (100%) rename openclaw-service.yml => cli/openclaw-service.yml (98%) rename quick-validate.sh => cli/quick-validate.sh (100%) rename reconfigure.yml => cli/reconfigure.yml (100%) rename run-deploy.sh => cli/run-deploy.sh (84%) rename run-hetzner.sh => cli/run-hetzner.sh (96%) rename setup.sh => cli/setup.sh (96%) rename validate-instance.sh => cli/validate-instance.sh (96%) rename validate-openclaw.yml => cli/validate-openclaw.yml (100%) create mode 100644 instances/ROBOCLAW-INT-TEST.yml create mode 100644 instances/instance-77-42-73-229.yml create mode 100644 test-inventory.ini diff --git a/HETZNER_SETUP.md b/HETZNER_SETUP.md index 35040af..6765146 100644 --- a/HETZNER_SETUP.md +++ b/HETZNER_SETUP.md @@ -34,7 +34,7 @@ ansible-galaxy collection install -r hetzner-requirements.yml # (See .env.example for format) # 2. Run the playbook (credentials loaded from .env automatically) -./run-hetzner.sh +./cli/cli/run-hetzner.sh # 3. Connect to your server (IP saved to finland-instance-ip.txt) ssh root@$(cat finland-instance-ip.txt) diff --git a/PROVISION.md b/PROVISION.md index 223d5df..d1324d2 100644 --- a/PROVISION.md +++ b/PROVISION.md @@ -17,7 +17,7 @@ An automated infrastructure-as-code solution that: Local Machine Remote VPS (Helsinki) ├── hetzner-finland-fast.yml → ├── Ubuntu 24.04 ARM ├── roboclaw-ansible/ → ├── Docker CE -├── run-hetzner.sh → ├── Node.js 22 + pnpm +├── cli/run-hetzner.sh → ├── Node.js 22 + pnpm ├── .env (API token) → ├── UFW Firewall └── hetzner_key (SSH) → └── RoboClaw 2026.1.24-3 ``` @@ -59,7 +59,7 @@ The playbook runs **three sequential plays**: ├── ROBOCLAW_GUIDE.md # RoboClaw integration guide ├── hetzner-finland-fast.yml # Main playbook (3 plays) ├── hetzner-requirements.yml # Ansible Galaxy dependencies -├── run-hetzner.sh # Wrapper script (virtualenv + .env) +├── 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 @@ -123,7 +123,7 @@ HCLOUD_TOKEN=your-64-char-token-here EOF # 3. Run the playbook -./run-hetzner.sh +./cli/cli/run-hetzner.sh ``` ### Installation @@ -154,10 +154,10 @@ The playbook is **idempotent** - safe to run multiple times: ```bash # Update existing server -./run-hetzner.sh +./cli/cli/run-hetzner.sh # Provision a new server (change server_name in yml first) -./run-hetzner.sh +./cli/cli/run-hetzner.sh ``` ### List Available Instance Types @@ -318,7 +318,7 @@ vim hetzner-finland-fast.yml # server_name: "finland-instance-2" # Run playbook -./run-hetzner.sh +./cli/cli/run-hetzner.sh ``` ## Costs @@ -364,7 +364,7 @@ Re-run it. The playbook is idempotent and will resume from where it failed. rm finland-instance-ip.txt hetzner_key hetzner_key.pub # Re-run -./run-hetzner.sh +./cli/cli/run-hetzner.sh ``` ## Technical Notes @@ -499,4 +499,4 @@ We've built a production-ready, automated VPS provisioning system that: - ✅ Costs €3.29/month - ✅ Deployed in Helsinki, Finland -**One command to production**: `./run-hetzner.sh` +**One command to production**: `./cli/cli/run-hetzner.sh` diff --git a/README.md b/README.md index 44698d2..a5ba6a6 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,10 @@ Automated deployment system for provisioning VPS instances and installing OpenCl ```bash # One command to deploy OpenClaw and auto-onboard -./run-deploy.sh -k ~/.ssh/your_key +./cli/run-deploy.sh -k ~/.ssh/your_key # With custom instance name -./run-deploy.sh -k ~/.ssh/your_key -n production +./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) @@ -44,10 +44,10 @@ Automated deployment system for provisioning VPS instances and installing OpenCl echo 'HCLOUD_TOKEN=your-64-char-token-here' > .env # 3. Provision VPS (~2-3 minutes) -./run-hetzner.sh +./cli/run-hetzner.sh # 4. Validate installation (17 checks) -./validate-instance.sh +./cli/validate-instance.sh # 5. Connect ssh -i hetzner_key root@$(cat finland-instance-ip.txt) @@ -65,13 +65,13 @@ openclaw onboard --install-daemon ```bash # One-command deployment (recommended) -./run-deploy.sh -k +./cli/run-deploy.sh -k # With custom instance name -./run-deploy.sh -k -n production +./cli/run-deploy.sh -k -n production # Legacy: Using inventory file (still supported) -./run-deploy.sh -k -i +./cli/run-deploy.sh -k -i ``` **What gets installed:** @@ -88,10 +88,10 @@ openclaw onboard --install-daemon ```bash # Deploy without launching onboarding wizard -./run-deploy.sh -k --skip-onboard +./cli/run-deploy.sh -k --skip-onboard # Connect and onboard later -./connect-instance.sh onboard +./cli/connect-instance.sh onboard ``` #### Create Inventory File (Advanced) @@ -99,30 +99,30 @@ openclaw onboard --install-daemon ```bash # For advanced use cases with multiple servers # Most users should use direct IP deployment instead -./create-inventory.sh [output-file] +./cli/create-inventory.sh [output-file] # Example -./create-inventory.sh 1.2.3.4 production.ini +./cli/create-inventory.sh 1.2.3.4 production.ini ``` #### Connect to Instance ```bash # Connect and run onboarding wizard -./connect-instance.sh onboard +./cli/connect-instance.sh onboard # Connect to interactive shell -./connect-instance.sh +./cli/connect-instance.sh # Connect with custom IP/key -./connect-instance.sh --ip 1.2.3.4 --key ~/.ssh/key onboard +./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) -./run-hetzner.sh +./cli/run-hetzner.sh ``` **Install includes:** @@ -138,7 +138,7 @@ openclaw onboard --install-daemon ```bash # Validate provisioning was successful -./validate-instance.sh +./cli/validate-instance.sh ``` Runs 17 checks including: @@ -152,20 +152,20 @@ Runs 17 checks including: ```bash # Show all servers in your Hetzner account -./run-hetzner.sh list +./cli/run-hetzner.sh list ``` ### Delete Server ```bash # Delete default server (finland-instance) with confirmation prompt -./run-hetzner.sh delete +./cli/run-hetzner.sh delete # Delete specific server -./run-hetzner.sh delete -e server_name=my-server +./cli/run-hetzner.sh delete -e server_name=my-server # Delete server AND remove SSH key from Hetzner -./run-hetzner.sh delete -e delete_ssh_key=true +./cli/run-hetzner.sh delete -e delete_ssh_key=true ``` ### Clean Up Local Files @@ -195,7 +195,7 @@ vars: ```bash # List all available instance types and prices -./list-server-types.sh +./cli/list-server-types.sh cat available-server-types.txt ``` @@ -217,7 +217,7 @@ After successful provisioning, a YAML artifact is automatically created in `inst **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 `./run-hetzner.sh delete`, the artifact is: +**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 @@ -260,13 +260,13 @@ After provisioning, validate that everything was installed correctly: ```bash # Validate default instance (finland-instance) -./validate-instance.sh +./cli/validate-instance.sh # Validate specific instance -./validate-instance.sh my-server +./cli/validate-instance.sh my-server # Show help -./validate-instance.sh --help +./cli/validate-instance.sh --help ``` The validation script checks: @@ -317,16 +317,22 @@ openclaw onboard --install-daemon ├── PROVISION.md # Detailed technical documentation ├── HETZNER_SETUP.md # Setup guide ├── ROBOCLAW_GUIDE.md # RoboClaw integration guide -├── run-deploy.sh # Deploy to existing servers (CLI) -├── connect-instance.sh # Connect to instances and run OpenClaw -├── create-inventory.sh # Generate inventory files from IP -├── cleanup-ssh-key.yml # Manage SSH keys in Hetzner -├── run-hetzner.sh # Provision new Hetzner servers -├── validate-instance.sh # Validation script -├── hetzner-finland-fast.yml # Hetzner provision playbook -├── hetzner-teardown.yml # Teardown playbook -├── reconfigure.yml # Software installation playbook -├── list-server-types.sh # List instance types +├── 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 @@ -334,9 +340,9 @@ openclaw onboard --install-daemon ├── hetzner_key # SSH private key (auto-generated, gitignored) ├── hetzner_key.pub # SSH public key ├── ssh-keys/ # SSH keys for deployments -├── finland-instance-ip.txt # Server IP address -├── roboclaw/ # RoboClaw source code (submodule) -└── instances/ # Instance artifacts (YAML) +├── instances/ # Instance artifacts (YAML) +├── website/ # Landing page and documentation +└── roboclaw/ # RoboClaw source code (submodule) ``` ## Requirements @@ -353,7 +359,7 @@ openclaw onboard --install-daemon ```bash # One command to install everything -./setup.sh +./cli/setup.sh # That's it! The script will: # - Find Python 3.12+ (checks python3.12, python3, python, pyenv) @@ -367,7 +373,7 @@ If you don't have Python 3.12+, the script will show you how to install it: - **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 `./setup.sh` if anything is missing. +The deployment scripts automatically check prerequisites and suggest running `./cli/setup.sh` if anything is missing. ## How It Works @@ -395,7 +401,7 @@ Everything runs from your local machine. No manual SSH required. ### Check if provisioning was successful ```bash # Run validation to diagnose issues -./validate-instance.sh +./cli/validate-instance.sh # Shows exactly which checks pass/fail: # - SSH connectivity @@ -409,7 +415,7 @@ Everything runs from your local machine. No manual SSH required. Your API token is read-only. Create a new token with **Read & Write** permissions. ### "Server type unavailable" -Run `./list-server-types.sh` to see available types in Helsinki. +Run `./cli/list-server-types.sh` to see available types in Helsinki. ### Can't SSH to server ```bash @@ -418,7 +424,7 @@ Run `./list-server-types.sh` to see available types in Helsinki. ssh -i hetzner_key -v root@$(cat finland-instance-ip.txt) # Or use validation script -./validate-instance.sh +./cli/validate-instance.sh ``` ### Playbook fails mid-run @@ -426,14 +432,14 @@ Re-run it. The playbook is idempotent (safe to run multiple times). ```bash # After re-running, validate the instance -./run-hetzner.sh -./validate-instance.sh +./cli/run-hetzner.sh +./cli/validate-instance.sh ``` ### Want to start fresh ```bash # Delete server -./run-hetzner.sh delete +./cli/run-hetzner.sh delete # This renames the artifact to finland-instance_deleted.yml # Remove local files (optional) @@ -443,10 +449,10 @@ rm hetzner_key hetzner_key.pub finland-instance-ip.txt rm instances/*_deleted.yml # Provision again -./run-hetzner.sh +./cli/run-hetzner.sh # Validate -./validate-instance.sh +./cli/validate-instance.sh ``` ## Complete Workflows @@ -455,7 +461,7 @@ rm instances/*_deleted.yml ```bash # One command to deploy and auto-onboard -./run-deploy.sh 192.168.1.100 -k ~/.ssh/prod-key -n production +./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 @@ -465,17 +471,17 @@ rm instances/*_deleted.yml # - Drop you into interactive configuration # Later, reconnect if needed -./connect-instance.sh production onboard +./cli/connect-instance.sh production onboard ``` ### Deploy Multiple Servers ```bash # Server 1 -./run-deploy.sh 192.168.1.100 -k ~/.ssh/key -n server1 +./cli/run-deploy.sh 192.168.1.100 -k ~/.ssh/key -n server1 # Server 2 -./run-deploy.sh 192.168.1.101 -k ~/.ssh/key -n server2 +./cli/run-deploy.sh 192.168.1.101 -k ~/.ssh/key -n server2 # List all instances ls -la ./instances/ @@ -491,37 +497,37 @@ vim hetzner-finland-fast.yml # Change: server_name: "finland-instance-2" # Run provisioning -./run-hetzner.sh +./cli/run-hetzner.sh # Validate the new instance -./validate-instance.sh finland-instance-2 +./cli/validate-instance.sh finland-instance-2 # List all servers -./run-hetzner.sh list +./cli/run-hetzner.sh list ``` ### Use Different Instance Type ```bash # See available types -./list-server-types.sh +./cli/list-server-types.sh # Edit hetzner-finland-fast.yml vim hetzner-finland-fast.yml # Change: server_type: "cax21" # 4 vCPU, 8GB RAM # Provision -./run-hetzner.sh +./cli/run-hetzner.sh # Validate -./validate-instance.sh +./cli/validate-instance.sh ``` ### Validate Provisioning ```bash # Check if provisioning was successful -./validate-instance.sh +./cli/validate-instance.sh # Example successful output: # ✓ All validation checks passed! @@ -530,7 +536,7 @@ vim hetzner-finland-fast.yml # Checks Failed: 0 # Validate a specific instance -./validate-instance.sh my-server +./cli/validate-instance.sh my-server # If validation fails, it shows which checks failed # Then you can re-provision or fix specific issues @@ -540,10 +546,10 @@ vim hetzner-finland-fast.yml ```bash # List servers first -./run-hetzner.sh list +./cli/run-hetzner.sh list # Delete by name -./run-hetzner.sh delete -e server_name=finland-instance-2 +./cli/run-hetzner.sh delete -e server_name=finland-instance-2 ``` ## Documentation @@ -578,15 +584,15 @@ For issues with: **Deploy to existing server (recommended):** ```bash # One command - auto-installs dependencies, deploys, and onboards -./run-deploy.sh -k ~/.ssh/key -n my-server +./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 -./run-hetzner.sh # Provision (~2-3 min) -./validate-instance.sh # Validate (17 checks) +./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/ansible.cfg b/cli/ansible.cfg similarity index 100% rename from ansible.cfg rename to cli/ansible.cfg diff --git a/cleanup-ssh-key.yml b/cli/cleanup-ssh-key.yml similarity index 100% rename from cleanup-ssh-key.yml rename to cli/cleanup-ssh-key.yml diff --git a/connect-instance.sh b/cli/connect-instance.sh similarity index 94% rename from connect-instance.sh rename to cli/connect-instance.sh index 24dacf1..1570376 100755 --- a/connect-instance.sh +++ b/cli/connect-instance.sh @@ -1,6 +1,9 @@ #!/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: @@ -65,12 +68,12 @@ done # Determine connection details if [ -n "$INSTANCE_NAME" ]; then # Read from instance artifact - ARTIFACT="./instances/${INSTANCE_NAME}.yml" + 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/^/ - /' + ls -1 ../instances/*.yml 2>/dev/null | xargs -n1 basename | sed 's/.yml$//' | sed 's/^/ - /' exit 1 fi diff --git a/create-inventory.sh b/cli/create-inventory.sh similarity index 100% rename from create-inventory.sh rename to cli/create-inventory.sh diff --git a/hetzner-finland-fast.yml b/cli/hetzner-finland-fast.yml similarity index 99% rename from hetzner-finland-fast.yml rename to cli/hetzner-finland-fast.yml index c4eb4ad..9ca220f 100644 --- a/hetzner-finland-fast.yml +++ b/cli/hetzner-finland-fast.yml @@ -472,7 +472,7 @@ ssh: key_file: "{{ deployment_ssh_key_path }}" public_key_file: "{{ deployment_ssh_key_path }}.pub" - dest: "./instances/{{ deployment_server_name }}.yml" + dest: "../instances/{{ deployment_server_name }}.yml" mode: '0644' delegate_to: localhost become: false diff --git a/hetzner-requirements.yml b/cli/hetzner-requirements.yml similarity index 100% rename from hetzner-requirements.yml rename to cli/hetzner-requirements.yml diff --git a/hetzner-teardown.yml b/cli/hetzner-teardown.yml similarity index 95% rename from hetzner-teardown.yml rename to cli/hetzner-teardown.yml index fb36849..f4772a3 100644 --- a/hetzner-teardown.yml +++ b/cli/hetzner-teardown.yml @@ -101,7 +101,7 @@ - name: Update instance artifact with deletion timestamp ansible.builtin.lineinfile: - path: "./instances/{{ target_server_name }}.yml" + path: "../instances/{{ target_server_name }}.yml" insertafter: "^ provisioned_at:" line: " deleted_at: {{ ansible_date_time.iso8601 }}" state: present @@ -111,7 +111,7 @@ - name: Mark instance as deleted in artifact ansible.builtin.lineinfile: - path: "./instances/{{ target_server_name }}.yml" + path: "../instances/{{ target_server_name }}.yml" insertafter: "^ install_mode:" line: " status: deleted" state: present @@ -121,7 +121,7 @@ - name: Rename artifact file to indicate deletion ansible.builtin.command: - cmd: mv "./instances/{{ target_server_name }}.yml" "./instances/{{ target_server_name }}_deleted.yml" + cmd: mv "../instances/{{ target_server_name }}.yml" "../instances/{{ target_server_name }}_deleted.yml" when: server_deleted.changed ignore_errors: true tags: [delete] diff --git a/list-server-types.sh b/cli/list-server-types.sh similarity index 100% rename from list-server-types.sh rename to cli/list-server-types.sh diff --git a/openclaw-service.yml b/cli/openclaw-service.yml similarity index 98% rename from openclaw-service.yml rename to cli/openclaw-service.yml index 573fe01..2adffb9 100644 --- a/openclaw-service.yml +++ b/cli/openclaw-service.yml @@ -107,7 +107,7 @@ - name: Build instance YAML path ansible.builtin.set_fact: - instance_yaml_path: "./instances/{{ instance_name }}.yml" + instance_yaml_path: "../instances/{{ instance_name }}.yml" when: > openclaw_state == 'started' and gateway_token is defined and diff --git a/quick-validate.sh b/cli/quick-validate.sh similarity index 100% rename from quick-validate.sh rename to cli/quick-validate.sh diff --git a/reconfigure.yml b/cli/reconfigure.yml similarity index 100% rename from reconfigure.yml rename to cli/reconfigure.yml diff --git a/run-deploy.sh b/cli/run-deploy.sh similarity index 84% rename from run-deploy.sh rename to cli/run-deploy.sh index bf6591e..c9c7648 100755 --- a/run-deploy.sh +++ b/cli/run-deploy.sh @@ -1,12 +1,19 @@ #!/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: -# ./run-deploy.sh --ssh-key [options] -# ./run-deploy.sh -k [options] -# ./run-deploy.sh -k -i [options] (backward compatibility) +# ./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 @@ -103,15 +110,15 @@ auto_setup() { echo "" # Create venv if it doesn't exist - if [ ! -d "venv" ]; then + if [ ! -d "../venv" ]; then echo "Creating virtual environment..." - $PYTHON_CMD -m venv venv + $PYTHON_CMD -m venv ../venv echo "✓ Virtual environment created" echo "" fi # Activate venv - source venv/bin/activate + source ../venv/bin/activate # Check if dependencies are installed local need_install=0 @@ -125,7 +132,7 @@ auto_setup() { if [ $need_install -eq 1 ]; then echo "Installing dependencies..." pip install --upgrade pip -q - pip install -r requirements.txt + pip install -r ../requirements.txt echo "✓ Dependencies installed" echo "" fi @@ -146,7 +153,7 @@ auto_setup() { auto_setup # Activate virtualenv -source venv/bin/activate +source ../venv/bin/activate # Parse arguments SSH_KEY="${SSH_PRIVATE_KEY_PATH:-}" @@ -189,9 +196,9 @@ while [[ $# -gt 0 ]]; do echo "Deploy OpenClaw to existing servers using SSH key and Ansible inventory" echo "" echo "Usage:" - echo " ./run-deploy.sh -k [options] # Direct IP (recommended)" - echo " ./run-deploy.sh -k -n # With instance name" - echo " ./run-deploy.sh -k -i # Inventory file (advanced)" + 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)" @@ -208,10 +215,10 @@ while [[ $# -gt 0 ]]; do echo " INSTANCE_NAME_OVERRIDE Override instance name in artifact" echo "" echo "Examples:" - echo " ./run-deploy.sh 192.168.1.100 -k ~/.ssh/id_ed25519" - echo " ./run-deploy.sh 192.168.1.100 -k ~/.ssh/key -n production" - echo " ./run-deploy.sh 192.168.1.100 -k key -n prod --skip-onboard" - echo " ./run-deploy.sh -k key -i hosts.ini # Backward compatible" + 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." @@ -230,6 +237,12 @@ if [ -z "$SSH_KEY" ]; then 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 @@ -250,7 +263,7 @@ if [ -n "$IP_ADDRESS" ]; then # Create temporary inventory file in instances directory mkdir -p ./instances - TEMP_INVENTORY="./instances/.temp-inventory-${INSTANCE_NAME}.ini" + TEMP_INVENTORY="../instances/.temp-inventory-${INSTANCE_NAME}.ini" cat > "$TEMP_INVENTORY" << EOF [servers] $IP_ADDRESS ansible_user=root @@ -260,6 +273,12 @@ EOF 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" @@ -269,8 +288,8 @@ else echo "Error: Either IP address or inventory file required" echo "" echo "Usage:" - echo " ./run-deploy.sh -k # Using IP" - echo " ./run-deploy.sh -k -i # Using inventory" + 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 @@ -294,7 +313,7 @@ if [ $ANSIBLE_EXIT_CODE -eq 0 ]; then echo "📝 Creating instance artifacts..." # Create instances directory if it doesn't exist - mkdir -p ./instances + mkdir -p ../instances # Parse inventory file to extract hosts # This handles simple INI format: "host ansible_host=ip" or just "ip" @@ -326,7 +345,7 @@ if [ $ANSIBLE_EXIT_CODE -eq 0 ]; then ARTIFACT_INSTANCE_NAME="$INSTANCE_NAME_OVERRIDE" fi - ARTIFACT_FILE="./instances/${ARTIFACT_INSTANCE_NAME}.yml" + ARTIFACT_FILE="../instances/${ARTIFACT_INSTANCE_NAME}.yml" TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") # Get absolute path of SSH key @@ -372,7 +391,7 @@ EOF else echo "" echo "To complete setup, run:" - echo " ./connect-instance.sh ${FINAL_INSTANCE_NAME} onboard" + echo " ./cli/connect-instance.sh ${FINAL_INSTANCE_NAME} onboard" fi else diff --git a/run-hetzner.sh b/cli/run-hetzner.sh similarity index 96% rename from run-hetzner.sh rename to cli/run-hetzner.sh index 26183fc..4fa833e 100755 --- a/run-hetzner.sh +++ b/cli/run-hetzner.sh @@ -1,6 +1,9 @@ #!/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 @@ -8,7 +11,7 @@ check_prerequisites() { echo "Checking prerequisites..." # Check if venv exists - if [ ! -d "venv" ]; then + if [ ! -d "../venv" ]; then echo "❌ Virtual environment not found" echo " → Run: python3 -m venv venv" echo " → Ensure you have Python 3.12+ installed" @@ -88,8 +91,8 @@ check_prerequisites() { check_prerequisites # Activate virtualenv (already activated in check, but re-activate to be safe) -if [ -d "venv" ]; then - source venv/bin/activate +if [ -d "../venv" ]; then + source ../venv/bin/activate fi # Load environment variables from .env file @@ -159,7 +162,7 @@ case "${1:-provision}" in shift # Read IP and SSH key from instance artifact - ARTIFACT="./instances/${INSTANCE_NAME}.yml" + ARTIFACT="../instances/${INSTANCE_NAME}.yml" if [ ! -f "$ARTIFACT" ]; then echo "Error: Instance artifact not found: $ARTIFACT" exit 1 @@ -212,7 +215,7 @@ case "${1:-provision}" in done # Read IP and SSH key from instance artifact - ARTIFACT="./instances/${INSTANCE_NAME}.yml" + ARTIFACT="../instances/${INSTANCE_NAME}.yml" if [ ! -f "$ARTIFACT" ]; then echo "Error: Instance artifact not found: $ARTIFACT" exit 1 diff --git a/setup.sh b/cli/setup.sh similarity index 96% rename from setup.sh rename to cli/setup.sh index 69d22f3..ae0fe71 100755 --- a/setup.sh +++ b/cli/setup.sh @@ -1,6 +1,9 @@ #!/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 diff --git a/validate-instance.sh b/cli/validate-instance.sh similarity index 96% rename from validate-instance.sh rename to cli/validate-instance.sh index 42f7928..2878664 100755 --- a/validate-instance.sh +++ b/cli/validate-instance.sh @@ -1,6 +1,9 @@ #!/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' @@ -60,13 +63,13 @@ if [[ "$INSTANCE_NAME" == "-h" ]] || [[ "$INSTANCE_NAME" == "--help" ]]; then usage fi -ARTIFACT_FILE="instances/${INSTANCE_NAME}.yml" +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" + 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}" @@ -85,8 +88,8 @@ if [[ ! -f "$ARTIFACT_FILE" ]]; then 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 + 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 diff --git a/validate-openclaw.yml b/cli/validate-openclaw.yml similarity index 100% rename from validate-openclaw.yml rename to cli/validate-openclaw.yml 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/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