ciderstack 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ciderstack-0.1.0/PKG-INFO +30 -0
- ciderstack-0.1.0/README.md +211 -0
- ciderstack-0.1.0/ciderstack/__init__.py +32 -0
- ciderstack-0.1.0/ciderstack/client.py +650 -0
- ciderstack-0.1.0/ciderstack/types.py +286 -0
- ciderstack-0.1.0/ciderstack.egg-info/PKG-INFO +30 -0
- ciderstack-0.1.0/ciderstack.egg-info/SOURCES.txt +10 -0
- ciderstack-0.1.0/ciderstack.egg-info/dependency_links.txt +1 -0
- ciderstack-0.1.0/ciderstack.egg-info/requires.txt +4 -0
- ciderstack-0.1.0/ciderstack.egg-info/top_level.txt +1 -0
- ciderstack-0.1.0/setup.cfg +4 -0
- ciderstack-0.1.0/setup.py +31 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ciderstack
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CiderStack Fleet SDK for Python
|
|
5
|
+
Home-page: https://ciderstack.io
|
|
6
|
+
Author: CiderStack
|
|
7
|
+
Author-email: support@ciderstack.io
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.8
|
|
20
|
+
Requires-Dist: requests>=2.28.0
|
|
21
|
+
Provides-Extra: pairing
|
|
22
|
+
Requires-Dist: cryptography>=41.0.0; extra == "pairing"
|
|
23
|
+
Dynamic: author
|
|
24
|
+
Dynamic: author-email
|
|
25
|
+
Dynamic: classifier
|
|
26
|
+
Dynamic: home-page
|
|
27
|
+
Dynamic: provides-extra
|
|
28
|
+
Dynamic: requires-dist
|
|
29
|
+
Dynamic: requires-python
|
|
30
|
+
Dynamic: summary
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# CiderStack Fleet SDK for Python
|
|
2
|
+
|
|
3
|
+
A Python client for managing macOS VMs across CiderStack Fleet nodes.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install ciderstack
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or install from source:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
cd sdk/python
|
|
15
|
+
pip install -e .
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
For pairing support (optional — not needed if using API tokens):
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install -e ".[pairing]"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Authentication
|
|
25
|
+
|
|
26
|
+
All API calls require authentication. Two methods are available:
|
|
27
|
+
|
|
28
|
+
### API Tokens (recommended)
|
|
29
|
+
|
|
30
|
+
The simplest way to authenticate. Generate a token from the CiderStack CLI or app, then pass it to the client:
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from ciderstack import FleetClient
|
|
34
|
+
|
|
35
|
+
client = FleetClient("192.168.1.100", api_token="csk_abc123...")
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Tokens can be **read-only** (can only list/get data) or **full-access** (can also start/stop/delete VMs, etc.).
|
|
39
|
+
|
|
40
|
+
### Node ID (via pairing)
|
|
41
|
+
|
|
42
|
+
For advanced use cases, you can pair the SDK as a fleet node using a 6-digit code from the CiderStack UI:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from ciderstack import FleetClient
|
|
46
|
+
|
|
47
|
+
# One-time pairing (requires `cryptography` package)
|
|
48
|
+
creds = FleetClient.pair("192.168.1.100", "123456", "my-ci-script")
|
|
49
|
+
print(f"Save this node ID: {creds['node_id']}")
|
|
50
|
+
|
|
51
|
+
# Use the node ID for all future connections
|
|
52
|
+
client = FleetClient("192.168.1.100", node_id=creds["node_id"])
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Quick Start
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from ciderstack import FleetClient
|
|
59
|
+
|
|
60
|
+
client = FleetClient("192.168.1.100", api_token="csk_abc123...")
|
|
61
|
+
|
|
62
|
+
# List VMs
|
|
63
|
+
vms = client.list_vms()
|
|
64
|
+
for vm in vms:
|
|
65
|
+
print(f"{vm.name}: {vm.state}")
|
|
66
|
+
|
|
67
|
+
# Start a VM
|
|
68
|
+
client.start_vm("vm-uuid")
|
|
69
|
+
|
|
70
|
+
# Execute a command
|
|
71
|
+
result = client.exec_command(
|
|
72
|
+
vm_id="vm-uuid",
|
|
73
|
+
command="uname -a",
|
|
74
|
+
ssh_user="admin",
|
|
75
|
+
ssh_password="password"
|
|
76
|
+
)
|
|
77
|
+
print(result.stdout)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Examples
|
|
81
|
+
|
|
82
|
+
### VM Management
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
# Clone a VM
|
|
86
|
+
new_vm = client.clone_vm("vm-uuid", "my-clone")
|
|
87
|
+
|
|
88
|
+
# Update VM settings
|
|
89
|
+
client.update_vm_settings(
|
|
90
|
+
vm_id="vm-uuid",
|
|
91
|
+
cpu_count=8,
|
|
92
|
+
memory_size=16384 # 16 GB
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Delete a VM
|
|
96
|
+
client.delete_vm("vm-uuid")
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Snapshots
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
# Create a snapshot
|
|
103
|
+
snapshot = client.create_snapshot(
|
|
104
|
+
vm_id="vm-uuid",
|
|
105
|
+
name="pre-update",
|
|
106
|
+
description="Before system update"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# List snapshots
|
|
110
|
+
snapshots = client.list_snapshots("vm-uuid")
|
|
111
|
+
|
|
112
|
+
# Restore snapshot
|
|
113
|
+
client.restore_snapshot("vm-uuid", snapshot.id)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Fleet Overview
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
# Get cluster-wide status
|
|
120
|
+
raw = client.get_fleet_overview()
|
|
121
|
+
stats = raw["overview"]["stats"]
|
|
122
|
+
print(f"Total nodes: {stats['totalNodes']}")
|
|
123
|
+
print(f"Running VMs: {stats['runningVMs']}")
|
|
124
|
+
|
|
125
|
+
# Get node stats
|
|
126
|
+
stats = client.get_node_stats()
|
|
127
|
+
print(f"CPU: {stats.cpu_usage_percent}%")
|
|
128
|
+
print(f"Memory: {stats.memory_used_gb}/{stats.memory_total_gb} GB")
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Image Management
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
# Pull an OCI image
|
|
135
|
+
client.pull_oci_image("ghcr.io/myorg/macos-base:latest")
|
|
136
|
+
|
|
137
|
+
# Create a VM from image
|
|
138
|
+
vm_id = client.create_vm(
|
|
139
|
+
name="ci-runner",
|
|
140
|
+
cpu_count=4,
|
|
141
|
+
memory_mb=8192,
|
|
142
|
+
disk_gb=64,
|
|
143
|
+
oci_image="ghcr.io/myorg/macos-base:latest"
|
|
144
|
+
)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## API Reference
|
|
148
|
+
|
|
149
|
+
### FleetClient
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
FleetClient(host, node_id=None, api_token=None, port=9473, timeout=30)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
| Parameter | Type | Default | Description |
|
|
156
|
+
|-------------|-------|---------|------------------------------------------|
|
|
157
|
+
| `host` | `str` | — | IP address or hostname |
|
|
158
|
+
| `node_id` | `str` | `None` | Trusted node ID (from pairing) |
|
|
159
|
+
| `api_token` | `str` | `None` | API token (from CiderStack CLI/UI) |
|
|
160
|
+
| `port` | `int` | `9473` | Port number |
|
|
161
|
+
| `timeout` | `int` | `30` | Request timeout in seconds |
|
|
162
|
+
|
|
163
|
+
Provide exactly one of `node_id` or `api_token`.
|
|
164
|
+
|
|
165
|
+
**Node Info**
|
|
166
|
+
- `get_node_info()` -> NodeInfo
|
|
167
|
+
- `get_node_stats()` -> NodeStats
|
|
168
|
+
|
|
169
|
+
**VM Management**
|
|
170
|
+
- `list_vms()` -> List[VM]
|
|
171
|
+
- `get_vm(vm_id)` -> VM | None
|
|
172
|
+
- `start_vm(vm_id)` -> bool
|
|
173
|
+
- `stop_vm(vm_id)` -> bool
|
|
174
|
+
- `start_vm_recovery(vm_id)` -> bool
|
|
175
|
+
- `clone_vm(vm_id, new_name)` -> VM
|
|
176
|
+
- `rename_vm(vm_id, new_name)` -> bool
|
|
177
|
+
- `delete_vm(vm_id)` -> bool
|
|
178
|
+
- `get_vm_settings(vm_id)` -> Dict
|
|
179
|
+
- `update_vm_settings(vm_id, ...)` -> bool
|
|
180
|
+
|
|
181
|
+
**Snapshots**
|
|
182
|
+
- `list_snapshots(vm_id)` -> List[Snapshot]
|
|
183
|
+
- `create_snapshot(vm_id, name, description)` -> Snapshot
|
|
184
|
+
- `restore_snapshot(vm_id, snapshot_id)` -> bool
|
|
185
|
+
- `delete_snapshot(vm_id, snapshot_id)` -> bool
|
|
186
|
+
|
|
187
|
+
**Command Execution**
|
|
188
|
+
- `exec_command(vm_id, command, ssh_user, ssh_password, timeout=300)` -> ExecResult (timeout in seconds; default 300 for long-running steps like artifact download)
|
|
189
|
+
|
|
190
|
+
**Tasks**
|
|
191
|
+
- `get_tasks(include_completed)` -> List[Task]
|
|
192
|
+
|
|
193
|
+
**Images**
|
|
194
|
+
- `push_image(vm_id, image_name, insecure)` -> str
|
|
195
|
+
- `list_ipsws()` -> List[Dict]
|
|
196
|
+
- `list_oci_images()` -> List[Dict]
|
|
197
|
+
- `download_ipsw(url, name, version)` -> bool
|
|
198
|
+
- `pull_oci_image(image_reference, username, password)` -> bool
|
|
199
|
+
- `create_vm(name, cpu_count, memory_mb, disk_gb, ipsw_path, oci_image)` -> str
|
|
200
|
+
|
|
201
|
+
**Fleet**
|
|
202
|
+
- `get_fleet_overview()` -> Dict
|
|
203
|
+
- `get_fleet_events(limit, event_type)` -> List[Dict]
|
|
204
|
+
- `get_remote_resources()` -> Dict
|
|
205
|
+
|
|
206
|
+
**Pairing** (static method, requires `cryptography`)
|
|
207
|
+
- `FleetClient.pair(host, code, name, port)` -> Dict
|
|
208
|
+
|
|
209
|
+
## License
|
|
210
|
+
|
|
211
|
+
MIT
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CiderStack Fleet SDK for Python
|
|
3
|
+
|
|
4
|
+
A Python client for managing macOS VMs across CiderStack Fleet nodes.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
from ciderstack import FleetClient
|
|
8
|
+
|
|
9
|
+
# Using an API token (recommended)
|
|
10
|
+
client = FleetClient("192.168.1.100", api_token="csk_...")
|
|
11
|
+
|
|
12
|
+
# Or using a trusted node ID (from pairing)
|
|
13
|
+
client = FleetClient("192.168.1.100", node_id="your-trusted-node-id")
|
|
14
|
+
|
|
15
|
+
# List VMs
|
|
16
|
+
vms = client.list_vms()
|
|
17
|
+
for vm in vms:
|
|
18
|
+
print(f"{vm.name}: {vm.state}")
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from .client import FleetClient, FleetError
|
|
22
|
+
from .types import (
|
|
23
|
+
VMState, TaskStatus, NodeRole,
|
|
24
|
+
Template, APIToken, APITokenSummary, APITokenPermissions,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__version__ = "0.1.0"
|
|
28
|
+
__all__ = [
|
|
29
|
+
"FleetClient", "FleetError",
|
|
30
|
+
"VMState", "TaskStatus", "NodeRole",
|
|
31
|
+
"Template", "APIToken", "APITokenSummary", "APITokenPermissions",
|
|
32
|
+
]
|
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CiderStack Fleet Client
|
|
3
|
+
|
|
4
|
+
HTTP client for the CiderStack Fleet JSON-RPC API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import uuid
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
from typing import List, Optional, Dict, Any
|
|
11
|
+
from .types import (
|
|
12
|
+
NodeInfo, NodeStats, VM, Snapshot, Task, ExecResult,
|
|
13
|
+
VMState, TaskStatus, Template, APIToken, APITokenSummary
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FleetError(Exception):
|
|
18
|
+
"""Exception raised when a Fleet API call fails."""
|
|
19
|
+
def __init__(self, message: str, response: Optional[Dict] = None):
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.response = response
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FleetClient:
|
|
25
|
+
"""
|
|
26
|
+
Client for the CiderStack Fleet API.
|
|
27
|
+
|
|
28
|
+
Authenticate with either an API token (recommended) or a trusted node ID.
|
|
29
|
+
|
|
30
|
+
Examples:
|
|
31
|
+
# Using an API token (recommended)
|
|
32
|
+
client = FleetClient("192.168.1.100", api_token="csk_...")
|
|
33
|
+
|
|
34
|
+
# Using a trusted node ID (from pairing)
|
|
35
|
+
client = FleetClient("192.168.1.100", node_id="your-trusted-node-id")
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
DEFAULT_PORT = 9473
|
|
39
|
+
DEFAULT_TIMEOUT = 30
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
host: str,
|
|
44
|
+
node_id: Optional[str] = None,
|
|
45
|
+
api_token: Optional[str] = None,
|
|
46
|
+
port: int = DEFAULT_PORT,
|
|
47
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
48
|
+
):
|
|
49
|
+
"""
|
|
50
|
+
Initialize the Fleet client.
|
|
51
|
+
|
|
52
|
+
Provide exactly one of node_id or api_token for authentication.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
host: IP address or hostname of the Fleet node
|
|
56
|
+
node_id: Trusted node ID (obtained via pairing)
|
|
57
|
+
api_token: API token string (generated via CiderStack CLI/UI)
|
|
58
|
+
port: Port number (default: 9473)
|
|
59
|
+
timeout: Request timeout in seconds (default: 30)
|
|
60
|
+
"""
|
|
61
|
+
if not node_id and not api_token:
|
|
62
|
+
raise FleetError("Either node_id or api_token is required for authentication")
|
|
63
|
+
if node_id and api_token:
|
|
64
|
+
raise FleetError("Provide either node_id or api_token, not both")
|
|
65
|
+
self.host = host
|
|
66
|
+
self.port = port
|
|
67
|
+
self.timeout = timeout
|
|
68
|
+
self.node_id = node_id
|
|
69
|
+
self.api_token = api_token
|
|
70
|
+
self.base_url = f"http://{host}:{port}"
|
|
71
|
+
|
|
72
|
+
def _rpc(
|
|
73
|
+
self,
|
|
74
|
+
method: str,
|
|
75
|
+
payload: Optional[Dict] = None,
|
|
76
|
+
request_timeout: Optional[int] = None,
|
|
77
|
+
) -> Dict:
|
|
78
|
+
"""Make an RPC call to the Fleet server."""
|
|
79
|
+
url = f"{self.base_url}/rpc/{method}"
|
|
80
|
+
timeout_sec = request_timeout if request_timeout is not None else self.timeout
|
|
81
|
+
|
|
82
|
+
# Inject authentication into every request
|
|
83
|
+
if self.api_token:
|
|
84
|
+
authenticated_payload: Dict[str, Any] = {"apiToken": self.api_token}
|
|
85
|
+
else:
|
|
86
|
+
authenticated_payload = {"callerNodeID": self.node_id}
|
|
87
|
+
if payload:
|
|
88
|
+
authenticated_payload.update(payload)
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
response = requests.post(
|
|
92
|
+
url,
|
|
93
|
+
json=authenticated_payload,
|
|
94
|
+
timeout=timeout_sec,
|
|
95
|
+
headers={"Content-Type": "application/json"}
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if response.status_code == 403:
|
|
99
|
+
try:
|
|
100
|
+
body = response.json()
|
|
101
|
+
error_msg = body.get("error", "Authentication failed")
|
|
102
|
+
except Exception:
|
|
103
|
+
error_msg = "Authentication failed"
|
|
104
|
+
raise FleetError(error_msg)
|
|
105
|
+
|
|
106
|
+
response.raise_for_status()
|
|
107
|
+
return response.json()
|
|
108
|
+
except FleetError:
|
|
109
|
+
raise
|
|
110
|
+
except requests.exceptions.RequestException as e:
|
|
111
|
+
raise FleetError(f"Request failed: {e}")
|
|
112
|
+
|
|
113
|
+
def _check_response(self, response: Dict, operation: str) -> Dict:
|
|
114
|
+
"""Check if response indicates success."""
|
|
115
|
+
if not response.get("success", True):
|
|
116
|
+
error = response.get("error") or response.get("message") or "Unknown error"
|
|
117
|
+
raise FleetError(f"{operation} failed: {error}", response)
|
|
118
|
+
return response
|
|
119
|
+
|
|
120
|
+
# =========================================================================
|
|
121
|
+
# Node Information
|
|
122
|
+
# =========================================================================
|
|
123
|
+
|
|
124
|
+
def get_node_info(self) -> NodeInfo:
|
|
125
|
+
"""Get information about the connected node."""
|
|
126
|
+
data = self._rpc("GetNodeInfo")
|
|
127
|
+
return NodeInfo.from_dict(data)
|
|
128
|
+
|
|
129
|
+
def get_node_stats(self) -> NodeStats:
|
|
130
|
+
"""Get real-time statistics for the node."""
|
|
131
|
+
data = self._rpc("GetNodeStats")
|
|
132
|
+
return NodeStats.from_dict(data)
|
|
133
|
+
|
|
134
|
+
# =========================================================================
|
|
135
|
+
# VM Management
|
|
136
|
+
# =========================================================================
|
|
137
|
+
|
|
138
|
+
def list_vms(self) -> List[VM]:
|
|
139
|
+
"""List all VMs on the node."""
|
|
140
|
+
data = self._rpc("ListVMs")
|
|
141
|
+
vms = data.get("vms", [])
|
|
142
|
+
return [VM.from_dict(vm) for vm in vms]
|
|
143
|
+
|
|
144
|
+
def get_vm(self, vm_id: str) -> Optional[VM]:
|
|
145
|
+
"""Get a specific VM by ID."""
|
|
146
|
+
vms = self.list_vms()
|
|
147
|
+
for vm in vms:
|
|
148
|
+
if vm.id == vm_id:
|
|
149
|
+
return vm
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
def start_vm(self, vm_id: str) -> bool:
|
|
153
|
+
"""Start a VM."""
|
|
154
|
+
response = self._rpc("StartVM", {"vmID": vm_id})
|
|
155
|
+
self._check_response(response, "Start VM")
|
|
156
|
+
return True
|
|
157
|
+
|
|
158
|
+
def stop_vm(self, vm_id: str) -> bool:
|
|
159
|
+
"""Stop a VM."""
|
|
160
|
+
response = self._rpc("StopVM", {"vmID": vm_id})
|
|
161
|
+
self._check_response(response, "Stop VM")
|
|
162
|
+
return True
|
|
163
|
+
|
|
164
|
+
def suspend_vm(self, vm_id: str) -> bool:
|
|
165
|
+
"""Suspend (pause) a running VM."""
|
|
166
|
+
response = self._rpc("SuspendVM", {"vmID": vm_id})
|
|
167
|
+
self._check_response(response, "Suspend VM")
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
def start_vm_recovery(self, vm_id: str) -> bool:
|
|
171
|
+
"""Start a VM in recovery mode."""
|
|
172
|
+
response = self._rpc("StartVMRecovery", {"vmID": vm_id})
|
|
173
|
+
self._check_response(response, "Start VM recovery")
|
|
174
|
+
return True
|
|
175
|
+
|
|
176
|
+
def clone_vm(self, vm_id: str, new_name: str) -> VM:
|
|
177
|
+
"""Clone a VM."""
|
|
178
|
+
response = self._rpc("CloneVM", {"vmID": vm_id, "newName": new_name})
|
|
179
|
+
self._check_response(response, "Clone VM")
|
|
180
|
+
return VM.from_dict(response.get("newVM", {}))
|
|
181
|
+
|
|
182
|
+
def rename_vm(self, vm_id: str, new_name: str) -> bool:
|
|
183
|
+
"""Rename a VM."""
|
|
184
|
+
response = self._rpc("RenameVM", {"vmID": vm_id, "newName": new_name})
|
|
185
|
+
self._check_response(response, "Rename VM")
|
|
186
|
+
return True
|
|
187
|
+
|
|
188
|
+
def delete_vm(self, vm_id: str) -> bool:
|
|
189
|
+
"""Delete a VM."""
|
|
190
|
+
response = self._rpc("DeleteVM", {"vmID": vm_id})
|
|
191
|
+
self._check_response(response, "Delete VM")
|
|
192
|
+
return True
|
|
193
|
+
|
|
194
|
+
def get_vm_settings(self, vm_id: str) -> Dict:
|
|
195
|
+
"""Get VM settings/configuration."""
|
|
196
|
+
response = self._rpc("GetVMSettings", {"vmID": vm_id})
|
|
197
|
+
self._check_response(response, "Get VM settings")
|
|
198
|
+
return response.get("configuration", {})
|
|
199
|
+
|
|
200
|
+
def update_vm_settings(
|
|
201
|
+
self,
|
|
202
|
+
vm_id: str,
|
|
203
|
+
cpu_count: Optional[int] = None,
|
|
204
|
+
memory_size: Optional[int] = None,
|
|
205
|
+
display_width: Optional[int] = None,
|
|
206
|
+
display_height: Optional[int] = None,
|
|
207
|
+
intent: Optional[str] = None,
|
|
208
|
+
ttl_seconds: Optional[int] = None,
|
|
209
|
+
) -> bool:
|
|
210
|
+
"""Update VM settings.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
vm_id: VM identifier.
|
|
214
|
+
cpu_count: Number of virtual CPUs.
|
|
215
|
+
memory_size: Memory in MB.
|
|
216
|
+
display_width: Display width in pixels.
|
|
217
|
+
display_height: Display height in pixels.
|
|
218
|
+
intent: VM intent ("disposable", "persistent", "interactive", "ci").
|
|
219
|
+
ttl_seconds: TTL in seconds from now. Pass -1 to clear the TTL.
|
|
220
|
+
"""
|
|
221
|
+
payload: Dict[str, Any] = {"vmID": vm_id}
|
|
222
|
+
if cpu_count is not None:
|
|
223
|
+
payload["cpuCount"] = cpu_count
|
|
224
|
+
if memory_size is not None:
|
|
225
|
+
payload["memorySize"] = memory_size
|
|
226
|
+
if display_width is not None:
|
|
227
|
+
payload["displayWidth"] = display_width
|
|
228
|
+
if display_height is not None:
|
|
229
|
+
payload["displayHeight"] = display_height
|
|
230
|
+
if intent is not None:
|
|
231
|
+
payload["intent"] = intent
|
|
232
|
+
if ttl_seconds is not None:
|
|
233
|
+
payload["ttlSeconds"] = ttl_seconds
|
|
234
|
+
|
|
235
|
+
response = self._rpc("UpdateVMSettings", payload)
|
|
236
|
+
self._check_response(response, "Update VM settings")
|
|
237
|
+
return True
|
|
238
|
+
|
|
239
|
+
# =========================================================================
|
|
240
|
+
# Snapshot Management
|
|
241
|
+
# =========================================================================
|
|
242
|
+
|
|
243
|
+
def list_snapshots(self, vm_id: str) -> List[Snapshot]:
|
|
244
|
+
"""List snapshots for a VM."""
|
|
245
|
+
data = self._rpc("ListSnapshots", {"vmID": vm_id})
|
|
246
|
+
snapshots = data.get("snapshots", [])
|
|
247
|
+
return [Snapshot.from_dict(s) for s in snapshots]
|
|
248
|
+
|
|
249
|
+
def create_snapshot(
|
|
250
|
+
self,
|
|
251
|
+
vm_id: str,
|
|
252
|
+
name: str,
|
|
253
|
+
description: Optional[str] = None
|
|
254
|
+
) -> Snapshot:
|
|
255
|
+
"""Create a snapshot."""
|
|
256
|
+
payload = {"vmID": vm_id, "name": name}
|
|
257
|
+
if description:
|
|
258
|
+
payload["snapshotDescription"] = description
|
|
259
|
+
|
|
260
|
+
response = self._rpc("CreateSnapshot", payload)
|
|
261
|
+
self._check_response(response, "Create snapshot")
|
|
262
|
+
return Snapshot.from_dict(response.get("snapshot", {}))
|
|
263
|
+
|
|
264
|
+
def restore_snapshot(self, vm_id: str, snapshot_id: str) -> bool:
|
|
265
|
+
"""Restore a VM to a snapshot."""
|
|
266
|
+
response = self._rpc("RestoreSnapshot", {"vmID": vm_id, "snapshotID": snapshot_id})
|
|
267
|
+
self._check_response(response, "Restore snapshot")
|
|
268
|
+
return True
|
|
269
|
+
|
|
270
|
+
def delete_snapshot(self, vm_id: str, snapshot_id: str) -> bool:
|
|
271
|
+
"""Delete a snapshot."""
|
|
272
|
+
response = self._rpc("DeleteSnapshot", {"vmID": vm_id, "snapshotID": snapshot_id})
|
|
273
|
+
self._check_response(response, "Delete snapshot")
|
|
274
|
+
return True
|
|
275
|
+
|
|
276
|
+
# =========================================================================
|
|
277
|
+
# Command Execution
|
|
278
|
+
# =========================================================================
|
|
279
|
+
|
|
280
|
+
DEFAULT_EXEC_TIMEOUT = 300 # seconds (5 min); increase for large downloads
|
|
281
|
+
|
|
282
|
+
def exec_command(
|
|
283
|
+
self,
|
|
284
|
+
vm_id: str,
|
|
285
|
+
command: str,
|
|
286
|
+
ssh_user: Optional[str] = None,
|
|
287
|
+
ssh_password: Optional[str] = None,
|
|
288
|
+
timeout: int = DEFAULT_EXEC_TIMEOUT,
|
|
289
|
+
) -> ExecResult:
|
|
290
|
+
"""Execute a command on a VM via SSH. Use a longer timeout for long-running steps (e.g. curl + unzip)."""
|
|
291
|
+
payload = {
|
|
292
|
+
"vmID": vm_id,
|
|
293
|
+
"command": command,
|
|
294
|
+
"timeout": timeout,
|
|
295
|
+
}
|
|
296
|
+
if ssh_user:
|
|
297
|
+
payload["sshUser"] = ssh_user
|
|
298
|
+
if ssh_password:
|
|
299
|
+
payload["sshPassword"] = ssh_password
|
|
300
|
+
|
|
301
|
+
# HTTP request must stay open at least as long as the command (+ buffer) to avoid broken pipe
|
|
302
|
+
request_timeout = max(self.timeout, timeout + 60)
|
|
303
|
+
response = self._rpc("ExecCommand", payload, request_timeout=request_timeout)
|
|
304
|
+
return ExecResult.from_dict(response)
|
|
305
|
+
|
|
306
|
+
# =========================================================================
|
|
307
|
+
# Task Management
|
|
308
|
+
# =========================================================================
|
|
309
|
+
|
|
310
|
+
def get_tasks(self, include_completed: bool = False) -> List[Task]:
|
|
311
|
+
"""Get tasks running on the node."""
|
|
312
|
+
data = self._rpc("GetTasks", {"includeCompleted": include_completed})
|
|
313
|
+
tasks = data.get("tasks", [])
|
|
314
|
+
return [Task.from_dict(t) for t in tasks]
|
|
315
|
+
|
|
316
|
+
# =========================================================================
|
|
317
|
+
# Image Management
|
|
318
|
+
# =========================================================================
|
|
319
|
+
|
|
320
|
+
def push_image(self, vm_id: str, image_name: str, insecure: bool = False) -> str:
|
|
321
|
+
"""Push a VM as an OCI image."""
|
|
322
|
+
response = self._rpc("PushImage", {
|
|
323
|
+
"vmID": vm_id,
|
|
324
|
+
"imageName": image_name,
|
|
325
|
+
"insecure": insecure,
|
|
326
|
+
})
|
|
327
|
+
self._check_response(response, "Push image")
|
|
328
|
+
return response.get("taskID", "")
|
|
329
|
+
|
|
330
|
+
def list_ipsws(self) -> List[Dict]:
|
|
331
|
+
"""List available IPSW files on the node."""
|
|
332
|
+
data = self._rpc("ListRemoteIPSWs")
|
|
333
|
+
return data.get("ipsws", [])
|
|
334
|
+
|
|
335
|
+
def list_oci_images(self) -> List[Dict]:
|
|
336
|
+
"""List OCI images on the node."""
|
|
337
|
+
data = self._rpc("ListRemoteOCIImages")
|
|
338
|
+
return data.get("images", [])
|
|
339
|
+
|
|
340
|
+
def download_ipsw(self, url: str, name: str, version: str) -> bool:
|
|
341
|
+
"""Download an IPSW on the remote node."""
|
|
342
|
+
response = self._rpc("DownloadIPSWOnNode", {
|
|
343
|
+
"requestID": str(uuid.uuid4()),
|
|
344
|
+
"downloadURL": url,
|
|
345
|
+
"expectedName": name,
|
|
346
|
+
"expectedVersion": version,
|
|
347
|
+
})
|
|
348
|
+
self._check_response(response, "Download IPSW")
|
|
349
|
+
return True
|
|
350
|
+
|
|
351
|
+
def pull_oci_image(
|
|
352
|
+
self,
|
|
353
|
+
image_reference: str,
|
|
354
|
+
username: Optional[str] = None,
|
|
355
|
+
password: Optional[str] = None,
|
|
356
|
+
) -> bool:
|
|
357
|
+
"""Pull an OCI image on the remote node."""
|
|
358
|
+
payload = {
|
|
359
|
+
"requestID": str(uuid.uuid4()),
|
|
360
|
+
"imageReference": image_reference,
|
|
361
|
+
}
|
|
362
|
+
if username and password:
|
|
363
|
+
payload["credentials"] = {"username": username, "password": password}
|
|
364
|
+
|
|
365
|
+
response = self._rpc("PullOCIImageOnNode", payload)
|
|
366
|
+
self._check_response(response, "Pull OCI image")
|
|
367
|
+
return True
|
|
368
|
+
|
|
369
|
+
def create_vm(
|
|
370
|
+
self,
|
|
371
|
+
name: str,
|
|
372
|
+
cpu_count: int = 4,
|
|
373
|
+
memory_mb: int = 8192,
|
|
374
|
+
disk_gb: int = 64,
|
|
375
|
+
ipsw_path: Optional[str] = None,
|
|
376
|
+
oci_image: Optional[str] = None,
|
|
377
|
+
) -> str:
|
|
378
|
+
"""Create a VM on the remote node."""
|
|
379
|
+
payload = {
|
|
380
|
+
"requestID": str(uuid.uuid4()),
|
|
381
|
+
"name": name,
|
|
382
|
+
"cpuCount": cpu_count,
|
|
383
|
+
"memorySizeMB": memory_mb,
|
|
384
|
+
"diskSizeGB": disk_gb,
|
|
385
|
+
}
|
|
386
|
+
if ipsw_path:
|
|
387
|
+
payload["ipswPath"] = ipsw_path
|
|
388
|
+
if oci_image:
|
|
389
|
+
payload["ociImageReference"] = oci_image
|
|
390
|
+
|
|
391
|
+
response = self._rpc("CreateVMOnNode", payload)
|
|
392
|
+
self._check_response(response, "Create VM")
|
|
393
|
+
return response.get("vmID", "")
|
|
394
|
+
|
|
395
|
+
# =========================================================================
|
|
396
|
+
# Fleet Overview
|
|
397
|
+
# =========================================================================
|
|
398
|
+
|
|
399
|
+
def get_fleet_overview(self) -> Dict:
|
|
400
|
+
"""Get aggregated fleet status."""
|
|
401
|
+
return self._rpc("GetFleetOverview")
|
|
402
|
+
|
|
403
|
+
def get_fleet_events(self, limit: int = 100, event_type: Optional[str] = None) -> List[Dict]:
|
|
404
|
+
"""Get fleet activity events."""
|
|
405
|
+
payload = {"limit": limit}
|
|
406
|
+
if event_type:
|
|
407
|
+
payload["eventType"] = event_type
|
|
408
|
+
|
|
409
|
+
data = self._rpc("GetFleetEvents", payload)
|
|
410
|
+
return data.get("events", [])
|
|
411
|
+
|
|
412
|
+
def get_remote_resources(self) -> Dict:
|
|
413
|
+
"""Get available resources for VM creation."""
|
|
414
|
+
return self._rpc("GetRemoteResources")
|
|
415
|
+
|
|
416
|
+
# =========================================================================
|
|
417
|
+
# Pairing
|
|
418
|
+
# =========================================================================
|
|
419
|
+
|
|
420
|
+
@staticmethod
|
|
421
|
+
def pair(
|
|
422
|
+
host: str,
|
|
423
|
+
code: str,
|
|
424
|
+
name: Optional[str] = None,
|
|
425
|
+
port: int = 9473,
|
|
426
|
+
) -> Dict:
|
|
427
|
+
"""
|
|
428
|
+
Pair this SDK client with a Fleet node using a 6-digit pairing code.
|
|
429
|
+
|
|
430
|
+
The pairing code is displayed in the CiderStack app UI.
|
|
431
|
+
On success, returns credentials that can be used to create a FleetClient.
|
|
432
|
+
|
|
433
|
+
Args:
|
|
434
|
+
host: IP address or hostname of the Fleet node
|
|
435
|
+
code: 6-digit pairing code from the CiderStack UI
|
|
436
|
+
name: Display name for this SDK client (auto-generated if omitted)
|
|
437
|
+
port: Port number (default: 9473)
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
Dict with keys: node_id, private_key_hex, public_key_hex,
|
|
441
|
+
responder_node_id, responder_name
|
|
442
|
+
|
|
443
|
+
Example:
|
|
444
|
+
creds = FleetClient.pair("192.168.1.100", "123456", "my-ci-script")
|
|
445
|
+
# Store creds["node_id"] securely for future use
|
|
446
|
+
client = FleetClient("192.168.1.100", node_id=creds["node_id"])
|
|
447
|
+
"""
|
|
448
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
449
|
+
from cryptography.hazmat.primitives import serialization
|
|
450
|
+
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
|
451
|
+
import base64
|
|
452
|
+
|
|
453
|
+
# Generate a new node identity
|
|
454
|
+
node_id = str(uuid.uuid4()).upper()
|
|
455
|
+
client_name = name or f"sdk-{node_id[:8].lower()}"
|
|
456
|
+
|
|
457
|
+
# Generate P-256 keypair
|
|
458
|
+
private_key = ec.generate_private_key(ec.SECP256R1())
|
|
459
|
+
public_key_bytes = private_key.public_key().public_bytes(
|
|
460
|
+
Encoding.X962, PublicFormat.UncompressedPoint
|
|
461
|
+
)
|
|
462
|
+
private_key_bytes = private_key.private_numbers().private_value.to_bytes(32, "big")
|
|
463
|
+
|
|
464
|
+
public_key_hex = public_key_bytes.hex()
|
|
465
|
+
private_key_hex = private_key_bytes.hex()
|
|
466
|
+
public_key_b64 = base64.b64encode(public_key_bytes).decode()
|
|
467
|
+
|
|
468
|
+
# Send pairing request — ensure code is always a string
|
|
469
|
+
url = f"http://{host}:{port}/rpc/RequestPairing"
|
|
470
|
+
payload = {
|
|
471
|
+
"requestID": str(uuid.uuid4()).upper(),
|
|
472
|
+
"requesterNodeID": {"value": node_id},
|
|
473
|
+
"requesterName": client_name,
|
|
474
|
+
"requesterIP": "0.0.0.0",
|
|
475
|
+
"publicKey": public_key_b64,
|
|
476
|
+
"code": str(code),
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
try:
|
|
480
|
+
response = requests.post(
|
|
481
|
+
url,
|
|
482
|
+
json=payload,
|
|
483
|
+
timeout=30,
|
|
484
|
+
headers={"Content-Type": "application/json"},
|
|
485
|
+
)
|
|
486
|
+
response.raise_for_status()
|
|
487
|
+
result = response.json()
|
|
488
|
+
except requests.exceptions.RequestException as e:
|
|
489
|
+
raise FleetError(f"Pairing request failed: {e}")
|
|
490
|
+
|
|
491
|
+
if not result.get("accepted"):
|
|
492
|
+
error_msg = result.get("errorMessage", "Pairing rejected")
|
|
493
|
+
raise FleetError(f"Pairing failed: {error_msg}")
|
|
494
|
+
|
|
495
|
+
responder_node_id = result.get("responderNodeID", {})
|
|
496
|
+
|
|
497
|
+
return {
|
|
498
|
+
"node_id": node_id,
|
|
499
|
+
"private_key_hex": private_key_hex,
|
|
500
|
+
"public_key_hex": public_key_hex,
|
|
501
|
+
"responder_node_id": responder_node_id.get("value", ""),
|
|
502
|
+
"responder_name": result.get("responderName", ""),
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
# =========================================================================
|
|
506
|
+
# Template Management
|
|
507
|
+
# =========================================================================
|
|
508
|
+
|
|
509
|
+
def list_templates(self) -> List[Template]:
|
|
510
|
+
"""List all VM templates."""
|
|
511
|
+
data = self._rpc("ListTemplates")
|
|
512
|
+
templates = data.get("templates", [])
|
|
513
|
+
return [Template.from_dict(t) for t in templates]
|
|
514
|
+
|
|
515
|
+
def get_template(self, template_id: str) -> Optional[Template]:
|
|
516
|
+
"""Get a template by ID."""
|
|
517
|
+
response = self._rpc("GetTemplate", {"templateID": template_id})
|
|
518
|
+
if not response.get("success") or not response.get("template"):
|
|
519
|
+
return None
|
|
520
|
+
return Template.from_dict(response["template"])
|
|
521
|
+
|
|
522
|
+
def get_template_by_name(self, name: str) -> Optional[Template]:
|
|
523
|
+
"""Get a template by name."""
|
|
524
|
+
response = self._rpc("GetTemplate", {"templateName": name})
|
|
525
|
+
if not response.get("success") or not response.get("template"):
|
|
526
|
+
return None
|
|
527
|
+
return Template.from_dict(response["template"])
|
|
528
|
+
|
|
529
|
+
def create_template_from_vm(
|
|
530
|
+
self,
|
|
531
|
+
vm_id: str,
|
|
532
|
+
name: str,
|
|
533
|
+
description: Optional[str] = None,
|
|
534
|
+
category: Optional[str] = None,
|
|
535
|
+
keep_original_vm: bool = True,
|
|
536
|
+
) -> Template:
|
|
537
|
+
"""Create a template from an existing VM."""
|
|
538
|
+
payload: Dict[str, Any] = {"vmID": vm_id, "name": name}
|
|
539
|
+
if description is not None:
|
|
540
|
+
payload["description"] = description
|
|
541
|
+
if category is not None:
|
|
542
|
+
payload["category"] = category
|
|
543
|
+
payload["keepOriginalVM"] = keep_original_vm
|
|
544
|
+
|
|
545
|
+
response = self._rpc("CreateTemplateFromVM", payload)
|
|
546
|
+
self._check_response(response, "Create template from VM")
|
|
547
|
+
return Template.from_dict(response.get("template", {}))
|
|
548
|
+
|
|
549
|
+
def delete_template(self, template_id: str) -> bool:
|
|
550
|
+
"""Delete a template."""
|
|
551
|
+
response = self._rpc("DeleteTemplate", {"templateID": template_id})
|
|
552
|
+
self._check_response(response, "Delete template")
|
|
553
|
+
return True
|
|
554
|
+
|
|
555
|
+
# =========================================================================
|
|
556
|
+
# API Token Management
|
|
557
|
+
# =========================================================================
|
|
558
|
+
|
|
559
|
+
def generate_api_token(
|
|
560
|
+
self,
|
|
561
|
+
name: str,
|
|
562
|
+
permissions: str = "fullAccess",
|
|
563
|
+
) -> APIToken:
|
|
564
|
+
"""Generate a new API token for SDK/script access."""
|
|
565
|
+
response = self._rpc("GenerateAPIToken", {
|
|
566
|
+
"name": name,
|
|
567
|
+
"permissions": permissions,
|
|
568
|
+
})
|
|
569
|
+
if not response.get("success"):
|
|
570
|
+
raise FleetError(response.get("error", "Failed to generate API token"), response)
|
|
571
|
+
return APIToken.from_dict(response.get("token", {}))
|
|
572
|
+
|
|
573
|
+
def revoke_api_token(self, token_id: str) -> bool:
|
|
574
|
+
"""Revoke an API token by its ID."""
|
|
575
|
+
response = self._rpc("RevokeAPIToken", {"tokenID": token_id})
|
|
576
|
+
return response.get("revoked", False)
|
|
577
|
+
|
|
578
|
+
def list_api_tokens(self) -> List[APITokenSummary]:
|
|
579
|
+
"""List all API tokens (token strings are redacted)."""
|
|
580
|
+
response = self._rpc("ListAPITokens")
|
|
581
|
+
tokens = response.get("tokens", [])
|
|
582
|
+
return [APITokenSummary.from_dict(t) for t in tokens]
|
|
583
|
+
|
|
584
|
+
# =========================================================================
|
|
585
|
+
# Shared Folder Management
|
|
586
|
+
# =========================================================================
|
|
587
|
+
|
|
588
|
+
def list_shared_folders(self, vm_id: str) -> List[Dict]:
|
|
589
|
+
"""List shared folders for a VM."""
|
|
590
|
+
response = self._rpc("ListSharedFolders", {"vmID": vm_id})
|
|
591
|
+
self._check_response(response, "List shared folders")
|
|
592
|
+
return response.get("folders", [])
|
|
593
|
+
|
|
594
|
+
def add_shared_folder(
|
|
595
|
+
self,
|
|
596
|
+
vm_id: str,
|
|
597
|
+
name: str,
|
|
598
|
+
host_path: str,
|
|
599
|
+
read_only: bool = False,
|
|
600
|
+
mount_tag: Optional[str] = None,
|
|
601
|
+
) -> bool:
|
|
602
|
+
"""Add a shared folder to a VM."""
|
|
603
|
+
payload: Dict[str, Any] = {
|
|
604
|
+
"vmID": vm_id,
|
|
605
|
+
"name": name,
|
|
606
|
+
"hostPath": host_path,
|
|
607
|
+
"readOnly": read_only,
|
|
608
|
+
}
|
|
609
|
+
if mount_tag is not None:
|
|
610
|
+
payload["mountTag"] = mount_tag
|
|
611
|
+
|
|
612
|
+
response = self._rpc("AddSharedFolder", payload)
|
|
613
|
+
self._check_response(response, "Add shared folder")
|
|
614
|
+
return True
|
|
615
|
+
|
|
616
|
+
def remove_shared_folder(self, vm_id: str, name: str) -> bool:
|
|
617
|
+
"""Remove a shared folder from a VM."""
|
|
618
|
+
response = self._rpc("RemoveSharedFolder", {"vmID": vm_id, "name": name})
|
|
619
|
+
self._check_response(response, "Remove shared folder")
|
|
620
|
+
return True
|
|
621
|
+
|
|
622
|
+
def set_shared_folder_enabled(self, vm_id: str, name: str, enabled: bool) -> bool:
|
|
623
|
+
"""Enable or disable a shared folder."""
|
|
624
|
+
response = self._rpc("SetSharedFolderEnabled", {
|
|
625
|
+
"vmID": vm_id,
|
|
626
|
+
"name": name,
|
|
627
|
+
"enabled": enabled,
|
|
628
|
+
})
|
|
629
|
+
self._check_response(response, "Set shared folder enabled")
|
|
630
|
+
return True
|
|
631
|
+
|
|
632
|
+
# =========================================================================
|
|
633
|
+
# Fleet Utilities
|
|
634
|
+
# =========================================================================
|
|
635
|
+
|
|
636
|
+
def unpair(self, node_id: str) -> bool:
|
|
637
|
+
"""Unpair a node from the fleet."""
|
|
638
|
+
response = self._rpc("Unpair", {"nodeID": node_id})
|
|
639
|
+
self._check_response(response, "Unpair")
|
|
640
|
+
return True
|
|
641
|
+
|
|
642
|
+
def cleanup_vms(self, force: bool = False) -> int:
|
|
643
|
+
"""Clean up retired VMs whose TTL has passed."""
|
|
644
|
+
response = self._rpc("CleanupVMs", {"force": force})
|
|
645
|
+
self._check_response(response, "Cleanup VMs")
|
|
646
|
+
return response.get("cleanedCount", 0)
|
|
647
|
+
|
|
648
|
+
def get_ipsw_info(self, path: str) -> Dict:
|
|
649
|
+
"""Get metadata about an IPSW file on the node."""
|
|
650
|
+
return self._rpc("GetIPSWInfo", {"path": path})
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CiderStack Fleet SDK Types
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Optional, List
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class VMState(str, Enum):
|
|
12
|
+
"""VM state enumeration."""
|
|
13
|
+
STOPPED = "stopped"
|
|
14
|
+
STARTING = "starting"
|
|
15
|
+
RUNNING = "running"
|
|
16
|
+
PAUSED = "paused"
|
|
17
|
+
STOPPING = "stopping"
|
|
18
|
+
ERROR = "error"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TaskStatus(str, Enum):
|
|
22
|
+
"""Task status enumeration."""
|
|
23
|
+
PENDING = "pending"
|
|
24
|
+
RUNNING = "running"
|
|
25
|
+
COMPLETED = "completed"
|
|
26
|
+
FAILED = "failed"
|
|
27
|
+
CANCELLED = "cancelled"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class NodeRole(str, Enum):
|
|
31
|
+
"""Node role enumeration."""
|
|
32
|
+
WORKER = "worker"
|
|
33
|
+
MANAGER = "manager"
|
|
34
|
+
OPERATOR = "operator"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class NodeInfo:
|
|
39
|
+
"""Information about a Fleet node."""
|
|
40
|
+
node_id: str
|
|
41
|
+
name: str
|
|
42
|
+
hostname: str
|
|
43
|
+
ip_address: str
|
|
44
|
+
port: int
|
|
45
|
+
machine_model: str
|
|
46
|
+
os_version: str
|
|
47
|
+
cpu_cores: int
|
|
48
|
+
total_memory_gb: int
|
|
49
|
+
fleet_version: str
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_dict(cls, data: dict) -> "NodeInfo":
|
|
53
|
+
return cls(
|
|
54
|
+
node_id=data.get("nodeID", ""),
|
|
55
|
+
name=data.get("name", ""),
|
|
56
|
+
hostname=data.get("hostname", ""),
|
|
57
|
+
ip_address=data.get("ipAddress", ""),
|
|
58
|
+
port=data.get("port", 9473),
|
|
59
|
+
machine_model=data.get("machineModel", ""),
|
|
60
|
+
os_version=data.get("osVersion", ""),
|
|
61
|
+
cpu_cores=data.get("cpuCores", 0),
|
|
62
|
+
total_memory_gb=data.get("totalMemoryGB", 0),
|
|
63
|
+
fleet_version=data.get("fleetVersion", ""),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class NodeStats:
|
|
69
|
+
"""Real-time statistics for a node."""
|
|
70
|
+
node_id: str
|
|
71
|
+
timestamp: datetime
|
|
72
|
+
cpu_usage_percent: float
|
|
73
|
+
memory_used_gb: float
|
|
74
|
+
memory_total_gb: float
|
|
75
|
+
disk_used_gb: float
|
|
76
|
+
disk_total_gb: float
|
|
77
|
+
running_vm_count: int
|
|
78
|
+
total_vm_count: int
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def from_dict(cls, data: dict) -> "NodeStats":
|
|
82
|
+
return cls(
|
|
83
|
+
node_id=data.get("nodeID", ""),
|
|
84
|
+
timestamp=datetime.fromisoformat(data.get("timestamp", "").replace("Z", "+00:00")) if data.get("timestamp") else datetime.now(),
|
|
85
|
+
cpu_usage_percent=data.get("cpuUsagePercent", 0.0),
|
|
86
|
+
memory_used_gb=data.get("memoryUsedGB", 0.0),
|
|
87
|
+
memory_total_gb=data.get("memoryTotalGB", 0.0),
|
|
88
|
+
disk_used_gb=data.get("diskUsedGB", 0.0),
|
|
89
|
+
disk_total_gb=data.get("diskTotalGB", 0.0),
|
|
90
|
+
running_vm_count=data.get("runningVMCount", 0),
|
|
91
|
+
total_vm_count=data.get("totalVMCount", 0),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class VM:
|
|
97
|
+
"""A virtual machine."""
|
|
98
|
+
id: str
|
|
99
|
+
name: str
|
|
100
|
+
state: VMState
|
|
101
|
+
cpu_count: int
|
|
102
|
+
memory_mb: int
|
|
103
|
+
disk_size_gb: Optional[int]
|
|
104
|
+
os_version: Optional[str]
|
|
105
|
+
ip_address: Optional[str]
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def from_dict(cls, data: dict) -> "VM":
|
|
109
|
+
return cls(
|
|
110
|
+
id=data.get("id", ""),
|
|
111
|
+
name=data.get("name", ""),
|
|
112
|
+
state=VMState(data.get("state", "stopped")),
|
|
113
|
+
cpu_count=data.get("cpuCount", 0),
|
|
114
|
+
memory_mb=data.get("memoryMB", 0),
|
|
115
|
+
disk_size_gb=data.get("diskSizeGB"),
|
|
116
|
+
os_version=data.get("osVersion"),
|
|
117
|
+
ip_address=data.get("ipAddress"),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass
|
|
122
|
+
class Snapshot:
|
|
123
|
+
"""A VM snapshot."""
|
|
124
|
+
id: str
|
|
125
|
+
name: str
|
|
126
|
+
description: Optional[str]
|
|
127
|
+
created_at: datetime
|
|
128
|
+
size_bytes: int
|
|
129
|
+
|
|
130
|
+
@classmethod
|
|
131
|
+
def from_dict(cls, data: dict) -> "Snapshot":
|
|
132
|
+
return cls(
|
|
133
|
+
id=data.get("id", ""),
|
|
134
|
+
name=data.get("name", ""),
|
|
135
|
+
description=data.get("description"),
|
|
136
|
+
created_at=datetime.fromisoformat(data.get("createdAt", "").replace("Z", "+00:00")) if data.get("createdAt") else datetime.now(),
|
|
137
|
+
size_bytes=data.get("sizeBytes", 0),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass
|
|
142
|
+
class Task:
|
|
143
|
+
"""A background task."""
|
|
144
|
+
id: str
|
|
145
|
+
type: str
|
|
146
|
+
title: str
|
|
147
|
+
status: TaskStatus
|
|
148
|
+
progress: float
|
|
149
|
+
progress_text: Optional[str]
|
|
150
|
+
started_at: datetime
|
|
151
|
+
completed_at: Optional[datetime]
|
|
152
|
+
vm_id: Optional[str]
|
|
153
|
+
vm_name: Optional[str]
|
|
154
|
+
error_message: Optional[str]
|
|
155
|
+
|
|
156
|
+
@classmethod
|
|
157
|
+
def from_dict(cls, data: dict) -> "Task":
|
|
158
|
+
completed_at = None
|
|
159
|
+
if data.get("completedAt"):
|
|
160
|
+
completed_at = datetime.fromisoformat(data["completedAt"].replace("Z", "+00:00"))
|
|
161
|
+
|
|
162
|
+
return cls(
|
|
163
|
+
id=data.get("id", ""),
|
|
164
|
+
type=data.get("type", ""),
|
|
165
|
+
title=data.get("title", ""),
|
|
166
|
+
status=TaskStatus(data.get("status", "pending")),
|
|
167
|
+
progress=data.get("progress", 0.0),
|
|
168
|
+
progress_text=data.get("progressText"),
|
|
169
|
+
started_at=datetime.fromisoformat(data.get("startedAt", "").replace("Z", "+00:00")) if data.get("startedAt") else datetime.now(),
|
|
170
|
+
completed_at=completed_at,
|
|
171
|
+
vm_id=data.get("vmID"),
|
|
172
|
+
vm_name=data.get("vmName"),
|
|
173
|
+
error_message=data.get("errorMessage"),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass
|
|
178
|
+
class Template:
|
|
179
|
+
"""A VM template."""
|
|
180
|
+
id: str
|
|
181
|
+
name: str
|
|
182
|
+
description: Optional[str]
|
|
183
|
+
macos_version: Optional[str]
|
|
184
|
+
icon_name: str
|
|
185
|
+
category: str
|
|
186
|
+
source_type: str
|
|
187
|
+
created_at: datetime
|
|
188
|
+
modified_at: datetime
|
|
189
|
+
disk_size_bytes: Optional[int]
|
|
190
|
+
default_cpu: int
|
|
191
|
+
default_memory_mb: int
|
|
192
|
+
default_disk_gb: int
|
|
193
|
+
|
|
194
|
+
@classmethod
|
|
195
|
+
def from_dict(cls, data: dict) -> "Template":
|
|
196
|
+
return cls(
|
|
197
|
+
id=data.get("id", ""),
|
|
198
|
+
name=data.get("name", ""),
|
|
199
|
+
description=data.get("description"),
|
|
200
|
+
macos_version=data.get("macOSVersion"),
|
|
201
|
+
icon_name=data.get("iconName", ""),
|
|
202
|
+
category=data.get("category", ""),
|
|
203
|
+
source_type=data.get("sourceType", ""),
|
|
204
|
+
created_at=datetime.fromisoformat(data.get("createdAt", "").replace("Z", "+00:00")) if data.get("createdAt") else datetime.now(),
|
|
205
|
+
modified_at=datetime.fromisoformat(data.get("modifiedAt", "").replace("Z", "+00:00")) if data.get("modifiedAt") else datetime.now(),
|
|
206
|
+
disk_size_bytes=data.get("diskSizeBytes"),
|
|
207
|
+
default_cpu=data.get("defaultCPU", 0),
|
|
208
|
+
default_memory_mb=data.get("defaultMemoryMB", 0),
|
|
209
|
+
default_disk_gb=data.get("defaultDiskGB", 0),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class APITokenPermissions(str, Enum):
|
|
214
|
+
"""API token permission level."""
|
|
215
|
+
READ_ONLY = "readOnly"
|
|
216
|
+
FULL_ACCESS = "fullAccess"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@dataclass
|
|
220
|
+
class APIToken:
|
|
221
|
+
"""An API token for SDK/script access."""
|
|
222
|
+
id: str
|
|
223
|
+
name: str
|
|
224
|
+
token: str
|
|
225
|
+
permissions: APITokenPermissions
|
|
226
|
+
created_at: datetime
|
|
227
|
+
last_used_at: Optional[datetime]
|
|
228
|
+
|
|
229
|
+
@classmethod
|
|
230
|
+
def from_dict(cls, data: dict) -> "APIToken":
|
|
231
|
+
last_used = None
|
|
232
|
+
if data.get("lastUsedAt"):
|
|
233
|
+
last_used = datetime.fromisoformat(str(data["lastUsedAt"]).replace("Z", "+00:00"))
|
|
234
|
+
return cls(
|
|
235
|
+
id=data.get("id", ""),
|
|
236
|
+
name=data.get("name", ""),
|
|
237
|
+
token=data.get("token", ""),
|
|
238
|
+
permissions=APITokenPermissions(data.get("permissions", "fullAccess")),
|
|
239
|
+
created_at=datetime.fromisoformat(str(data.get("createdAt", "")).replace("Z", "+00:00")) if data.get("createdAt") else datetime.now(),
|
|
240
|
+
last_used_at=last_used,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@dataclass
|
|
245
|
+
class APITokenSummary:
|
|
246
|
+
"""Summary of an API token (token string redacted)."""
|
|
247
|
+
id: str
|
|
248
|
+
name: str
|
|
249
|
+
token_prefix: str
|
|
250
|
+
permissions: APITokenPermissions
|
|
251
|
+
created_at: datetime
|
|
252
|
+
last_used_at: Optional[datetime]
|
|
253
|
+
|
|
254
|
+
@classmethod
|
|
255
|
+
def from_dict(cls, data: dict) -> "APITokenSummary":
|
|
256
|
+
last_used = None
|
|
257
|
+
if data.get("lastUsedAt"):
|
|
258
|
+
last_used = datetime.fromisoformat(str(data["lastUsedAt"]).replace("Z", "+00:00"))
|
|
259
|
+
return cls(
|
|
260
|
+
id=data.get("id", ""),
|
|
261
|
+
name=data.get("name", ""),
|
|
262
|
+
token_prefix=data.get("tokenPrefix", ""),
|
|
263
|
+
permissions=APITokenPermissions(data.get("permissions", "fullAccess")),
|
|
264
|
+
created_at=datetime.fromisoformat(str(data.get("createdAt", "")).replace("Z", "+00:00")) if data.get("createdAt") else datetime.now(),
|
|
265
|
+
last_used_at=last_used,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@dataclass
|
|
270
|
+
class ExecResult:
|
|
271
|
+
"""Result of executing a command on a VM."""
|
|
272
|
+
success: bool
|
|
273
|
+
exit_code: int
|
|
274
|
+
stdout: str
|
|
275
|
+
stderr: str
|
|
276
|
+
duration: float
|
|
277
|
+
|
|
278
|
+
@classmethod
|
|
279
|
+
def from_dict(cls, data: dict) -> "ExecResult":
|
|
280
|
+
return cls(
|
|
281
|
+
success=data.get("success", False),
|
|
282
|
+
exit_code=data.get("exitCode", -1),
|
|
283
|
+
stdout=data.get("stdout", ""),
|
|
284
|
+
stderr=data.get("stderr", ""),
|
|
285
|
+
duration=data.get("duration", 0.0),
|
|
286
|
+
)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ciderstack
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CiderStack Fleet SDK for Python
|
|
5
|
+
Home-page: https://ciderstack.io
|
|
6
|
+
Author: CiderStack
|
|
7
|
+
Author-email: support@ciderstack.io
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.8
|
|
20
|
+
Requires-Dist: requests>=2.28.0
|
|
21
|
+
Provides-Extra: pairing
|
|
22
|
+
Requires-Dist: cryptography>=41.0.0; extra == "pairing"
|
|
23
|
+
Dynamic: author
|
|
24
|
+
Dynamic: author-email
|
|
25
|
+
Dynamic: classifier
|
|
26
|
+
Dynamic: home-page
|
|
27
|
+
Dynamic: provides-extra
|
|
28
|
+
Dynamic: requires-dist
|
|
29
|
+
Dynamic: requires-python
|
|
30
|
+
Dynamic: summary
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
setup.py
|
|
3
|
+
ciderstack/__init__.py
|
|
4
|
+
ciderstack/client.py
|
|
5
|
+
ciderstack/types.py
|
|
6
|
+
ciderstack.egg-info/PKG-INFO
|
|
7
|
+
ciderstack.egg-info/SOURCES.txt
|
|
8
|
+
ciderstack.egg-info/dependency_links.txt
|
|
9
|
+
ciderstack.egg-info/requires.txt
|
|
10
|
+
ciderstack.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ciderstack
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from setuptools import setup, find_packages
|
|
2
|
+
|
|
3
|
+
setup(
|
|
4
|
+
name="ciderstack",
|
|
5
|
+
version="0.1.0",
|
|
6
|
+
description="CiderStack Fleet SDK for Python",
|
|
7
|
+
author="CiderStack",
|
|
8
|
+
author_email="support@ciderstack.io",
|
|
9
|
+
url="https://ciderstack.io",
|
|
10
|
+
packages=find_packages(),
|
|
11
|
+
install_requires=[
|
|
12
|
+
"requests>=2.28.0",
|
|
13
|
+
],
|
|
14
|
+
extras_require={
|
|
15
|
+
"pairing": ["cryptography>=41.0.0"],
|
|
16
|
+
},
|
|
17
|
+
python_requires=">=3.8",
|
|
18
|
+
classifiers=[
|
|
19
|
+
"Development Status :: 4 - Beta",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"License :: OSI Approved :: MIT License",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3.8",
|
|
25
|
+
"Programming Language :: Python :: 3.9",
|
|
26
|
+
"Programming Language :: Python :: 3.10",
|
|
27
|
+
"Programming Language :: Python :: 3.11",
|
|
28
|
+
"Programming Language :: Python :: 3.12",
|
|
29
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
30
|
+
],
|
|
31
|
+
)
|