From d53c1f4917b29920d93b29a9dbc6e029764103e5 Mon Sep 17 00:00:00 2001 From: Azat Date: Tue, 3 Feb 2026 00:46:23 +0100 Subject: [PATCH] Initial fleet-api skill --- SKILL.md | 51 +++++++++++++++++ lib/http.sh | 137 +++++++++++++++++++++++++++++++++++++++++++++ lib/machines.sh | 131 +++++++++++++++++++++++++++++++++++++++++++ scripts/autorun.sh | 22 ++++++++ scripts/run.sh | 17 ++++++ 5 files changed, 358 insertions(+) create mode 100644 SKILL.md create mode 100644 lib/http.sh create mode 100644 lib/machines.sh create mode 100644 scripts/autorun.sh create mode 100644 scripts/run.sh diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..a156c96 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,51 @@ +--- +name: fleet-api +description: Machine registry API for fleet management +metadata: + version: "1.0.0" + vibestack: + main: false + requires: [] +--- + +# Fleet API + +Minimal REST API for machine registration and discovery. + +## Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/machines/register` | Register/heartbeat a machine | +| GET | `/api/machines` | List all registered machines | +| DELETE | `/api/machines/:id` | Unregister a machine | + +## Registration Payload + +```json +{ + "id": "machine-uuid", + "name": "dev-machine-1", + "url": "http://192.168.1.10:3000", + "version": "1.0.0", + "skills": ["caddy", "ttyd", "dashboard"], + "status": "running" +} +``` + +## Dependencies + +Requires `socat` and `jq` to be installed: +```bash +apt-get install -y socat jq +``` + +## Configuration + +- `FLEET_API_PORT` - API server port (default: 3001) +- `FLEET_DATA_DIR` - Data directory (default: /data/fleet) + +## Storage + +Machines stored in `machines.json` with `lastSeen` timestamps. +Machines with `lastSeen` > 60 seconds are marked as `status: unknown`. diff --git a/lib/http.sh b/lib/http.sh new file mode 100644 index 0000000..4b6ac14 --- /dev/null +++ b/lib/http.sh @@ -0,0 +1,137 @@ +#!/bin/bash +# Minimal HTTP server using bash and socat/nc + +# Send HTTP response +send_response() { + local status="$1" + local content_type="${2:-application/json}" + local body="$3" + + local length=${#body} + + printf "HTTP/1.1 %s\r\n" "$status" + printf "Content-Type: %s\r\n" "$content_type" + printf "Content-Length: %d\r\n" "$length" + printf "Access-Control-Allow-Origin: *\r\n" + printf "Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS\r\n" + printf "Access-Control-Allow-Headers: Content-Type\r\n" + printf "Connection: close\r\n" + printf "\r\n" + printf "%s" "$body" +} + +send_json() { + send_response "$1" "application/json" "$2" +} + +send_error() { + local status="$1" + local message="$2" + send_json "$status" "{\"error\":\"$message\"}" +} + +# Handle a single HTTP request +handle_request() { + local request_line="" + local content_length=0 + local body="" + + # Read request line + read -r request_line + request_line=$(echo "$request_line" | tr -d '\r') + + # Parse method and path + local method=$(echo "$request_line" | cut -d' ' -f1) + local path=$(echo "$request_line" | cut -d' ' -f2) + + # Read headers + while read -r header; do + header=$(echo "$header" | tr -d '\r') + [ -z "$header" ] && break + + # Extract content-length + if echo "$header" | grep -qi "^content-length:"; then + content_length=$(echo "$header" | cut -d':' -f2 | tr -d ' ') + fi + done + + # Read body if present + if [ "$content_length" -gt 0 ] 2>/dev/null; then + body=$(head -c "$content_length") + fi + + # Route request + route_request "$method" "$path" "$body" +} + +# Route requests to handlers +route_request() { + local method="$1" + local path="$2" + local body="$3" + + # Handle CORS preflight + if [ "$method" = "OPTIONS" ]; then + send_response "204 No Content" "text/plain" "" + return + fi + + # API routes + case "$path" in + /api/machines/register) + if [ "$method" = "POST" ]; then + handle_register "$body" + else + send_error "405 Method Not Allowed" "Use POST" + fi + ;; + /api/machines) + if [ "$method" = "GET" ]; then + handle_list_machines + else + send_error "405 Method Not Allowed" "Use GET" + fi + ;; + /api/machines/*) + local machine_id="${path#/api/machines/}" + if [ "$method" = "DELETE" ]; then + handle_delete_machine "$machine_id" + elif [ "$method" = "GET" ]; then + handle_get_machine "$machine_id" + else + send_error "405 Method Not Allowed" "Use GET or DELETE" + fi + ;; + /health) + send_json "200 OK" '{"status":"healthy"}' + ;; + *) + send_error "404 Not Found" "Endpoint not found" + ;; + esac +} + +# Start HTTP server +http_server() { + local port="$1" + + # Check for socat + if command -v socat &>/dev/null; then + echo "Using socat..." + while true; do + socat TCP-LISTEN:"$port",reuseaddr,fork EXEC:"$0 --handle-request" + done + else + echo "Error: socat not found. Install with: apt-get install socat" + exit 1 + fi +} + +# Handle --handle-request flag (called by socat) +if [ "$1" = "--handle-request" ]; then + SKILL_DIR="$(dirname "$(dirname "$0")")" + source "$SKILL_DIR/lib/machines.sh" + DATA_DIR="${FLEET_DATA_DIR:-/data/fleet}" + export DATA_DIR + handle_request +fi diff --git a/lib/machines.sh b/lib/machines.sh new file mode 100644 index 0000000..3a68cf5 --- /dev/null +++ b/lib/machines.sh @@ -0,0 +1,131 @@ +#!/bin/bash +# Machine registry logic + +MACHINES_FILE="${DATA_DIR:-/data/fleet}/machines.json" +STALE_THRESHOLD=60 # seconds + +# Get current timestamp +now() { + date +%s +} + +# Read machines file with lock +read_machines() { + if [ -f "$MACHINES_FILE" ]; then + cat "$MACHINES_FILE" + else + echo '{}' + fi +} + +# Write machines file with lock +write_machines() { + local data="$1" + echo "$data" > "$MACHINES_FILE.tmp" + mv "$MACHINES_FILE.tmp" "$MACHINES_FILE" +} + +# Update machine status based on lastSeen +update_status() { + local machines="$1" + local current_time=$(now) + + echo "$machines" | jq --arg now "$current_time" --arg threshold "$STALE_THRESHOLD" ' + to_entries | map( + .value.status = ( + if (.value.lastSeen | tonumber) < (($now | tonumber) - ($threshold | tonumber)) + then "unknown" + else .value.status + end + ) + ) | from_entries + ' +} + +# Handle POST /api/machines/register +handle_register() { + local body="$1" + + # Validate JSON + if ! echo "$body" | jq -e . >/dev/null 2>&1; then + send_error "400 Bad Request" "Invalid JSON" + return + fi + + # Extract required fields + local id=$(echo "$body" | jq -r '.id // empty') + local name=$(echo "$body" | jq -r '.name // empty') + local url=$(echo "$body" | jq -r '.url // empty') + + if [ -z "$id" ] || [ -z "$name" ] || [ -z "$url" ]; then + send_error "400 Bad Request" "Missing required fields: id, name, url" + return + fi + + # Read current machines + local machines=$(read_machines) + local current_time=$(now) + + # Create/update machine entry + local machine=$(echo "$body" | jq --arg ts "$current_time" '. + {lastSeen: ($ts | tonumber)}') + + # Add to machines + machines=$(echo "$machines" | jq --arg id "$id" --argjson machine "$machine" '.[$id] = $machine') + + # Write back + write_machines "$machines" + + send_json "200 OK" "$machine" +} + +# Handle GET /api/machines +handle_list_machines() { + local machines=$(read_machines) + + # Update status based on lastSeen + machines=$(update_status "$machines") + + # Convert to array + local result=$(echo "$machines" | jq '[to_entries[] | .value]') + + send_json "200 OK" "$result" +} + +# Handle GET /api/machines/:id +handle_get_machine() { + local id="$1" + local machines=$(read_machines) + + # Update status + machines=$(update_status "$machines") + + # Get machine + local machine=$(echo "$machines" | jq --arg id "$id" '.[$id] // null') + + if [ "$machine" = "null" ]; then + send_error "404 Not Found" "Machine not found" + return + fi + + send_json "200 OK" "$machine" +} + +# Handle DELETE /api/machines/:id +handle_delete_machine() { + local id="$1" + local machines=$(read_machines) + + # Check if exists + local exists=$(echo "$machines" | jq --arg id "$id" 'has($id)') + + if [ "$exists" = "false" ]; then + send_error "404 Not Found" "Machine not found" + return + fi + + # Remove machine + machines=$(echo "$machines" | jq --arg id "$id" 'del(.[$id])') + write_machines "$machines" + + send_json "200 OK" '{"deleted":true}' +} diff --git a/scripts/autorun.sh b/scripts/autorun.sh new file mode 100644 index 0000000..af4822c --- /dev/null +++ b/scripts/autorun.sh @@ -0,0 +1,22 @@ +#!/bin/bash +set -e + +SKILL_DIR="$(dirname "$(dirname "$0")")" + +echo "=== Fleet API Setup ===" + +# Create data directory +DATA_DIR="${FLEET_DATA_DIR:-/data/fleet}" +mkdir -p "$DATA_DIR" + +# Initialize empty machines file if not exists +if [ ! -f "$DATA_DIR/machines.json" ]; then + echo '{}' > "$DATA_DIR/machines.json" + echo "Initialized machines.json" +fi + +# Ensure scripts are executable +chmod +x "$SKILL_DIR/scripts/"*.sh +chmod +x "$SKILL_DIR/lib/"*.sh + +echo "Fleet API setup complete" diff --git a/scripts/run.sh b/scripts/run.sh new file mode 100644 index 0000000..3af4e64 --- /dev/null +++ b/scripts/run.sh @@ -0,0 +1,17 @@ +#!/bin/bash +set -e + +SKILL_DIR="$(dirname "$(dirname "$0")")" +source "$SKILL_DIR/lib/http.sh" +source "$SKILL_DIR/lib/machines.sh" + +PORT="${FLEET_API_PORT:-3001}" +DATA_DIR="${FLEET_DATA_DIR:-/data/fleet}" + +export DATA_DIR + +echo "Starting Fleet API on port $PORT..." +echo "Data directory: $DATA_DIR" + +# Start HTTP server +http_server "$PORT"