Skill: guim4dev/caprover-management (version 1.0.0)

--- name: caprover description: "Manage CapRover PaaS instances via API: create/update apps, deploy from Docker image or custom Dockerfile (tar file), configure ports, volumes, env vars, and serviceUpdateOverride for Docker Swarm settings. Use when the user wants to deploy, configure, or diagnose an app on a CapRover server — including setting up TCP ports for non-HTTP servers (game servers, databases), mounting persistent volumes, building custom Docker images on the host, or reading build/runtime logs." --- # CapRover Management Skill CapRover is a self-hosted PaaS that wraps Docker Swarm. It exposes a REST API for full app lifecycle management. ## Quick Setup Always start by authenticating: ```python import urllib.request, json, ssl ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE # self-signed cert on CapRover is common BASE = "https://<captain-domain>" # e.g. https://captain.example.com def api(path, data=None, token=None, timeout=60): body = json.dumps(data).encode() if data else None headers = {"Content-Type": "application/json"} if [REDACTED_SECRET]"x-captain-auth"] = token req = urllib.request.Request(f"{BASE}{path}", data=body, headers=headers) resp = urllib.request.urlopen(req, context=ctx, timeout=timeout) return json.loads(resp.read()) token = api("/api/v2/login", {"password": "<password>"})["data"]["token"] ``` See `references/api.md` for all endpoints. See `scripts/caprover.py` for a ready-to-use helper class. ## Core Workflows ### 1. Create an App ```python api([REDACTED_SECRET], {"appName": "myapp", "hasPersistentData": False}, token) ``` Set `hasPersistentData: True` if the app needs persistent volumes. ### 2. Deploy from a Docker Image ```python api([REDACTED_SECRET], {"appName": "myapp", "imageName": "nginx:latest"}, token) api([REDACTED_SECRET], {"appName": "myapp", "gitHash": ""}, token) ``` ### 3. Deploy from a Custom Dockerfile (Build on Host) Pack a `captain-definition`, `Dockerfile`, and support files into a `.tar.gz`, then POST: ```python # captain-definition (required in tar root): # {"schemaVersion": 2, "dockerfilePath": "./Dockerfile"} with open("app.tar.gz", "rb") as f: tar_data = f.read() boundary = "----FormBoundaryCaprover" body = ( f"--{boundary}\r\n" f'Content-Disposition: form-data; name="sourceFile"; filename="app.tar.gz"\r\n' f"Content-Type: application/octet-stream\r\n\r\n" ).encode() + tar_data + f"\r\n--{boundary}--\r\n".encode() req = urllib.request.Request( f"{BASE}/api/v2/user/apps/appData/myapp", data=body, headers={ "Content-Type": f"multipart/form-data; boundary={boundary}", "x-captain-auth": token, }, ) resp = urllib.request.urlopen(req, context=ctx, timeout=180) ``` **This builds the image natively on the CapRover host** — critical for ARM64 hosts where pre-built amd64 images won't run. ### 4. Configure Ports, Env Vars, Volumes ```python api([REDACTED_SECRET], { "appName": "myapp", "envVars": [{"key": "MY_VAR", "value": "hello"}], "ports": [{"hostPort": 25565, "containerPort": 7777}], "volumes": [{"containerPath": "/data", "volumeName": "myapp-data"}], "instanceCount": 1, }, token) ``` > ⚠️ **Port update bug**: The `ports` field update sometimes returns HTTP 500 on CapRover (known issue). Workaround: set ports once at app creation time or use `serviceUpdateOverride`. ### 5. Advanced Docker Swarm Settings (serviceUpdateOverride) For settings not exposed in the standard API — volume mounts, custom DNS, resource limits: ```python override = json.dumps({ "TaskTemplate": { "ContainerSpec": { "Mounts": [{ "Type": "volume", "Source": "captain--myapp-data", # CapRover names: captain--<appname>-<name> "Target": "/data" }] } } }) api([REDACTED_SECRET], {"appName": "myapp", "serviceUpdateOverride": override}, token) ``` > ⚠️ Setting `serviceUpdateOverride` to `""` (empty string) **clears** it and removes all Docker Swarm overrides, including volume mounts. ### 6. Read Logs ```python # Build logs (after deploying) r = api("/api/v2/user/apps/appData/myapp", token=token) build_lines = r["data"]["logs"]["lines"] # Runtime logs (stdout of running container) r = api([REDACTED_SECRET], token=token) raw_logs = r["data"]["logs"] ``` ## ARM64 / Multi-Arch Gotchas If the CapRover host is ARM64 (`uname -m` returns `aarch64`): - **Do not use amd64-only pre-built images** — they will silently fail or crash with exec format errors - **Build from Dockerfile on the host** (workflow #3 above) to get native ARM64 images - For apps that need Mono (e.g. Windows .exe files on Linux ARM64): install `mono-runtime` in the Dockerfile and use `mono ./App.exe` as the entrypoint - Detect arch at runtime in scripts: `$(uname -m)` returns `aarch64` on ARM64 ## Common Issues | Symptom | Likely Cause | Fix | |---|---|---| | `HTTP 500` on port update | CapRover bug | Set ports at app creation, or use serviceUpdateOverride | | Container crashes, no logs | Wrong arch image (amd64 on arm64) | Build from Dockerfile on host | | Port open but server not responding | Server listening on `127.0.0.1` only | Check server bind address; use `0.0.0.0` | | World/data lost on restart | No volume mount | Add `serviceUpdateOverride` with `Mounts` | | Logs empty | App writes logs to file, not stdout | Override entrypoint to redirect to stdout | | `volumes: []` in API but data persists | serviceUpdateOverride holds the mount — API and Swarm state diverge | Check serviceUpdateOverride, not just app definition | ## Node / Cluster Info ```python r = api("/api/v2/user/system/info", token=token) nodes = r["data"]["nodes"] ``` ## References - Full API endpoint list + request/response shapes: `references/api.md` - Reusable Python helper class: `scripts/caprover.py`

path: scripts/caprover.py
content: #!/usr/bin/env python3
"""
CapRover API helper.

Usage:
    from caprover import CapRover, build_tar

    cap = CapRover("https://captain.x.example.com", "mypassword")
    cap.create_app("myapp")
    cap.update_app("myapp", ports=[{"hostPort": 25565, "containerPort": 7777}])
    cap.deploy_tar("myapp", "build.tar.gz")
    print(cap.runtime_logs("myapp"))
"""

import urllib.request
import urllib.error
import json
import ssl
import re
import os
import tarfile
import tempfile
from typing import Optional


def _make_ctx():
    ctx = ssl.create_default_context()
    ctx.check_hostname = False
    ctx.verify_mode = ssl.CERT_NONE
    return ctx


class CapRover:
    def __init__(self, base_url: str, password: str):
        """
        Args:
            base_url: e.g. "https://captain.example.com"
            [REDACTED_SECRET] admin password
        """
        self.base = base_url.rstrip("/")
        self._ctx = _make_ctx()
        self.[REDACTED_SECRET])

    def _login(self, password: str) -> str:
        r = self._call("/api/v2/login", {"password": password})
        return r["data"]["token"]

    def _call(self, path: str, data=None, token: str = None, timeout: int = 120,
              raw_body: bytes = None, content_type: str = "application/json"):
        body = raw_body if raw_body else (json.dumps(data).encode() if data else None)
        headers = {"Content-Type": content_type}
        tok = token or getattr(self, "token", None)
        if tok:
            headers["x-captain-auth"] = tok
        req = urllib.request.Request(f"{self.base}{path}", data=body, headers=headers)
        try:
            resp = urllib.request.urlopen(req, context=self._ctx, timeout=timeout)
            return json.loads(resp.read())
        except urllib.error.HTTPError as e:
            body = e.read().decode()[:400]
            raise RuntimeError(f"HTTP {e.code} {path}: {body}")

    # ── App lifecycle ──────────────────────────────────────────────────────

    def create_app(self, app_name: str, has_persistent_data: bool = False) -> dict:
        return self._call([REDACTED_SECRET], {
            "appName": app_name,
            "hasPersistentData": has_persistent_data,
        })

    def delete_app(self, app_name: str, volumes: list = None) -> dict:
        return self._call([REDACTED_SECRET], {
            "appName": app_name,
            "volumes": volumes or [],
        })

    def get_apps(self) -> list:
        r = self._call([REDACTED_SECRET])
        return r.get("data", {}).get("appDefinitions", [])

    def get_app(self, app_name: str) -> Optional[dict]:
        apps = self.get_apps()
        return next((a for a in apps if a.get("appName") == app_name), None)

    def update_app(self, app_name: str, **kwargs) -> dict:
        """
        Update app definition. Keyword args map to API fields:
            image_name, instance_count, env_vars, ports, volumes,
            service_update_override, container_http_port, force_ssl,
            has_persistent_data, not_expose_as_sub_domain

        Example:
            cap.update_app("myapp",
                env_vars=[{"key": "FOO", "value": "bar"}],
                ports=[{"hostPort": 25565, "containerPort": 7777}])
        """
        field_map = {
            "image_name": "imageName",
            "instance_count": "instanceCount",
            "env_vars": "envVars",
            "ports": "ports",
            "volumes": "volumes",
            "service_update_override": "serviceUpdateOverride",
            "container_http_port": "containerHttpPort",
            "force_ssl": "forceSsl",
            "has_persistent_data": "hasPersistentData",
            "not_expose_as_sub_domain": "notExposeAsSubDomain",
            "description": "description",
        }
        payload = {"appName": app_name}
        for k, v in kwargs.items():
            [REDACTED_SECRET], k)
            payload[api_key] = v
        return self._call([REDACTED_SECRET], payload)

    def set_service_override(self, app_name: str, override: dict) -> dict:
        """Set Docker Swarm serviceUpdateOverride from a dict (auto-serializes to JSON string)."""
        return self.update_app(app_name, service_update_override=json.dumps(override))

    def clear_service_override(self, app_name: str) -> dict:
        """Clear serviceUpdateOverride (removes all Swarm overrides including mounts)."""
        return self.update_app(app_name, service_update_override="")

    # ── Deploy ─────────────────────────────────────────────────────────────

    def deploy_image(self, app_name: str, image_name: str) -> dict:
        """Deploy a pre-built Docker image."""
        self.update_app(app_name, image_name=image_name)
        return self._call(f"/api/v2/user/apps/appData/{app_name}/redeploy",
                          {"appName": app_name, "gitHash": ""})

    def deploy_tar(self, app_name: str, tar_path: str, timeout: int = 300) -> dict:
        """
        Build and deploy from a .tar.gz file containing captain-definition + Dockerfile.

        The tar must include:
            captain-definition  (JSON: {"schemaVersion": 2, "dockerfilePath": "./Dockerfile"})
            Dockerfile
            <any other files referenced by the Dockerfile>
        """
        with open(tar_path, "rb") as f:
            tar_data = f.read()

        boundary = "----CapRoverFormBoundary7MA4YW"
        body = (
            f"--{boundary}\r\n"
            f'Content-Disposition: form-data; name="sourceFile"; filename="{os.path.basename(tar_path)}"\r\n'
            f"Content-Type: application/octet-stream\r\n\r\n"
        ).encode() + tar_data + f"\r\n--{boundary}--\r\n".encode()

        return self._call(
            f"/api/v2/user/apps/appData/{app_name}",
            raw_body=body,
            content_type=f"multipart/form-data; boundary={boundary}",
            timeout=timeout,
        )

    def redeploy(self, app_name: str) -> dict:
        """Force redeploy current image."""
        return self._call(f"/api/v2/user/apps/appData/{app_name}/redeploy",
                          {"appName": app_name, "gitHash": ""})

    # ── Logs ───────────────────────────────────────────────────────────────

    def build_logs(self, app_name: str) -> list:
        """Return list of build log lines from the most recent deploy."""
        r = self._call(f"/api/v2/user/apps/appData/{app_name}")
        return r.get("data", {}).get("logs", {}).get("lines", [])

    def runtime_logs(self, app_name: str, clean: bool = True) -> str:
        """
        Return runtime stdout/stderr of the running container.

        Args:
            clean: Strip Docker binary log headers (recommended).
        """
        r = self._call(f"/api/v2/user/apps/appData/{app_name}/logs")
        raw = r.get("data", {}).get("logs", "")
        if clean:
            raw = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f]', '', raw)
        return raw

    def tail_logs(self, app_name: str, n: int = 30) -> list:
        """Return last n unique lines of runtime logs."""
        raw = self.runtime_logs(app_name)
        lines = [l.strip() for l in raw.split('\n') if l.strip() and len(l.strip()) > 2]
        seen, out = set(), []
        for l in lines:
            k = l[:120]
            if k not in seen:
                seen.add(k)
                out.append(l)
        return out[-n:]

    # ── System ─────────────────────────────────────────────────────────────

    def system_info(self) -> dict:
        return self._call("/api/v2/user/system/info").get("data", {})

    def nodes(self) -> list:
        return self.system_info().get("nodes", [])


# ── Tar builder helper ────────────────────────────────────────────────────

def build_tar(output_path: str, files: dict, captain_definition: dict = None) -> str:
    """
    Build a deployable .tar.gz for CapRover.

    Args:
        output_path: Where to write the tar file.
        files: Dict of {filename: content_str_or_bytes}. Include "Dockerfile".
        captain_definition: Defaults to {"schemaVersion": 2, "dockerfilePath": "./Dockerfile"}.

    Returns:
        output_path

    Example:
        build_tar("deploy.tar.gz", {
            "Dockerfile": "FROM debian:slim\nCMD echo hello",
            "run.sh": "#!/bin/bash\necho running",
        })
    """
    if captain_definition is None:
        captain_definition = {"schemaVersion": 2, "dockerfilePath": "./Dockerfile"}

    all_files = {"captain-definition": json.dumps(captain_definition), **files}

    with tarfile.open(output_path, "w:gz") as tar:
        for name, content in all_files.items():
            if isinstance(content, str):
                content = content.encode()
            import io
            info = tarfile.TarInfo(name=name)
            info.size = len(content)
            tar.addfile(info, io.BytesIO(content))

    return output_path


# ── CLI usage ─────────────────────────────────────────────────────────────

if __name__ == "__main__":
    import sys

    if len(sys.argv) < 3:
        print("Usage: caprover.py <base_url> <password> [app_name]")
        sys.exit(1)

    cap = CapRover(sys.argv[1], sys.argv[2])
    print(f"Authenticated. [REDACTED_SECRET]")

    if len(sys.argv) >= 4:
        app = sys.argv[3]
        print(f"\n=== Runtime logs: {app} ===")
        for line in cap.tail_logs(app, 20):
            print(line)
    else:
        print("\n=== Apps ===")
        for a in cap.get_apps():
            print(f"  {a['appName']:30} image={a.get('imageName','<custom build>')}")


path: references/api.md
content: # CapRover API Reference

Base URL: `https://<captain-domain>`
Auth header: `x-captain-auth: <token>`
All bodies: `Content-Type: application/json`
SSL: often self-signed → disable verification in HTTP clients

## Table of Contents
1. [Authentication](#authentication)
2. [App Management](#app-management)
3. [Deploy](#deploy)
4. [Logs](#logs)
5. [System / Cluster](#system--cluster)
6. [Docker Registry](#docker-registry)

---

## Authentication

### POST `/api/v2/login`
```json
Request:  { "password": "str" }
Response: { "status": 100, "data": { "token": "str" } }
```
Token is a JWT valid for the session. Always re-authenticate at script start.

---

## App Management

### GET [REDACTED_SECRET]
Returns all apps and their current definitions.

```json
Response:
{
  "data": {
    "appDefinitions": [
      {
        "appName": "str",
        "imageName": "str",
        "instanceCount": 1,
        "ports": [{ "hostPort": 25565, "containerPort": 7777 }],
        "volumes": [{ "containerPath": "/data", "volumeName": "myapp-data" }],
        "envVars": [{ "key": "str", "value": "str" }],
        "serviceUpdateOverride": "json-string-or-empty",
        "hasPersistentData": false,
        "notExposeAsSubDomain": false,
        "customDomain": [],
        "deployedVersion": 3
      }
    ]
  }
}
```

### POST [REDACTED_SECRET]
Create a new app.
```json
Request:  { "appName": "str", "hasPersistentData": false }
Response: { "status": 100, "description": "App created", "data": {} }
```

### POST [REDACTED_SECRET]
Update app settings. All fields are optional — only provided fields are updated.

```json
Request:
{
  "appName": "str",                          // required
  "imageName": "str",                        // optional — pre-built image
  "instanceCount": 1,                        // optional
  "envVars": [{ "key": "str", "value": "str" }],   // optional — replaces all envvars
  "ports": [{ "hostPort": 25565, "containerPort": 7777 }],  // optional — ⚠️ may 500
  "volumes": [{ "containerPath": "/data", "volumeName": "myapp-data" }],  // optional
  "serviceUpdateOverride": "json-str",       // optional — raw Docker Swarm ServiceSpec fragment
  "notExposeAsSubDomain": false,             // optional
  "hasPersistentData": false,                // optional
  "forceSsl": false,                         // optional
  "websocketSupport": false,                 // optional
  "containerHttpPort": 80,                   // optional — for HTTP apps
  "description": "str"                       // optional
}
```

> ⚠️ Sending `"serviceUpdateOverride": ""` clears it completely, removing any Docker Swarm overrides (including volume mounts).

### POST [REDACTED_SECRET]
```json
Request:  { "appName": "str", "volumes": [] }
```
Pass volume names in `volumes` to also delete associated volumes.

---

## Deploy

### POST `/api/v2/user/apps/appData/<appName>` — Deploy from Dockerfile tar

Multipart form upload of a `.tar.gz` containing `captain-definition` + `Dockerfile` + files.

```
Content-Type: multipart/form-data; boundary=<boundary>
x-captain-auth: <token>

--<boundary>
Content-Disposition: form-data; name="sourceFile"; filename="app.tar.gz"
Content-Type: application/octet-stream

<binary tar data>
--<boundary>--
```

`captain-definition` (JSON file at tar root):
```json
{ "schemaVersion": 2, "dockerfilePath": "./Dockerfile" }
```

Response:
```json
{ "status": 100, "description": "Deploy is done", "data": {} }
```

Build logs appear immediately after in the build logs endpoint.

### POST `/api/v2/user/apps/appData/<appName>/redeploy`
Force redeploy of current image (no tar needed):
```json
Request:  { "appName": "str", "gitHash": "" }
```

---

## Logs

### GET `/api/v2/user/apps/appData/<appName>`
Build logs (from most recent deploy/build):
```json
Response:
{
  "data": {
    "logs": {
      "lines": ["Step 1/5 : FROM ...", "Successfully built ..."],
      "firstLineNumber": 0
    }
  }
}
```

### GET `/api/v2/user/apps/appData/<appName>/logs`
Runtime logs (Docker container stdout/stderr):
```json
Response: { "data": { "logs": "<raw-text-with-timestamps>" } }
```

Raw format: each line prefixed with a Docker log header byte + timestamp.
Strip control bytes: `re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f]', '', raw)`

---

## System / Cluster

### GET `/api/v2/user/system/info`
Cluster info, node list:
```json
{
  "data": {
    "nodes": [
      {
        "id": "n5ru6s7t5hv8q8cjx9vytw6lk",
        "hostname": "rodney",
        "role": "manager",
        "status": "Ready",
        "resources": {
          "nanoCpu": 4000000000,
          "memoryBytes": 24069836800
        }
      }
    ]
  }
}
```

### GET `/api/v2/user/system/info` also returns CapRover version, Docker version, etc.

---

## Docker Registry

### GET `/api/v2/user/registries`
List configured Docker registries.

### POST `/api/v2/user/registries`
Add a registry (Docker Hub, ECR, GCR, etc.):
```json
{
  "serverAddress": "registry.hub.docker.com",
  "username": "str",
  "password": "str",
  "registryDomain": "str"
}
```

---

## serviceUpdateOverride — Docker Swarm Service Spec

The `serviceUpdateOverride` field accepts a **JSON string** (not object) with a fragment of the Docker Service Spec. It is merged into the service spec at deploy time.

Common use cases:

### Named volume mounts
```json
{
  "TaskTemplate": {
    "ContainerSpec": {
      "Mounts": [
        {
          "Type": "volume",
          "Source": "captain--myapp-data",
          "Target": "/data"
        }
      ]
    }
  }
}
```

CapRover names volumes: `captain--<appname>-<volumename>` when created via the volumes API.
For manually created volumes, use the exact Docker volume name.

### Override container args / command
```json
{
  "TaskTemplate": {
    "ContainerSpec": {
      "Args": ["-worldname", "Terraria", "-autocreate", "3"]
    }
  }
}
```
> ⚠️ `Args` overrides CMD in the Dockerfile. `Command` overrides ENTRYPOINT.

### Publish ports (TCP/UDP — for non-HTTP services)
```json
{
  "EndpointSpec": {
    "Ports": [
      { "Protocol": "tcp", "PublishedPort": 25565, "TargetPort": 7777 }
    ]
  }
}
```
> ⚠️ This may return HTTP 500 from CapRover API (known bug). Prefer setting ports via the `ports` field at app creation time.

### Resource limits
```json
{
  "TaskTemplate": {
    "Resources": {
      "Limits": { "NanoCPUs": 2000000000, "MemoryBytes": 2147483648 }
    }
  }
}
```


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)