calagoapi 1.0.dev1__py3-none-any.whl
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.
- calagoapi/__init__.py +190 -0
- calagoapi/account/__init__.py +14 -0
- calagoapi/account/account.py +292 -0
- calagoapi/account/auth.py +72 -0
- calagoapi/account/models.py +221 -0
- calagoapi/admin/__init__.py +48 -0
- calagoapi/admin/admin.py +341 -0
- calagoapi/admin/managers.py +490 -0
- calagoapi/admin/resources.py +970 -0
- calagoapi/aio/__init__.py +9 -0
- calagoapi/aio/console.py +289 -0
- calagoapi/aio/panel.py +176 -0
- calagoapi/api/__init__.py +63 -0
- calagoapi/api/account.py +195 -0
- calagoapi/api/admin.py +402 -0
- calagoapi/api/attachments.py +27 -0
- calagoapi/api/auth.py +52 -0
- calagoapi/api/backups.py +99 -0
- calagoapi/api/client.py +198 -0
- calagoapi/api/databases.py +217 -0
- calagoapi/api/files.py +210 -0
- calagoapi/api/network.py +84 -0
- calagoapi/api/public.py +13 -0
- calagoapi/api/schedules.py +79 -0
- calagoapi/api/servers.py +114 -0
- calagoapi/api/users.py +27 -0
- calagoapi/api/versions.py +29 -0
- calagoapi/backups/__init__.py +8 -0
- calagoapi/backups/backup.py +292 -0
- calagoapi/backups/manager.py +177 -0
- calagoapi/console/__init__.py +6 -0
- calagoapi/console/console.py +392 -0
- calagoapi/databases/__init__.py +12 -0
- calagoapi/databases/database.py +445 -0
- calagoapi/databases/explorer.py +159 -0
- calagoapi/databases/manager.py +122 -0
- calagoapi/files/__init__.py +9 -0
- calagoapi/files/file.py +196 -0
- calagoapi/files/manager.py +437 -0
- calagoapi/network/__init__.py +8 -0
- calagoapi/network/allocation.py +99 -0
- calagoapi/network/manager.py +132 -0
- calagoapi/nodes/__init__.py +6 -0
- calagoapi/nodes/node.py +477 -0
- calagoapi/panel.py +162 -0
- calagoapi/schedules/__init__.py +14 -0
- calagoapi/schedules/builders.py +461 -0
- calagoapi/schedules/manager.py +107 -0
- calagoapi/schedules/schedule.py +214 -0
- calagoapi/servers/__init__.py +13 -0
- calagoapi/servers/admin.py +430 -0
- calagoapi/servers/generic.py +785 -0
- calagoapi/servers/manager.py +167 -0
- calagoapi/servers/minecraft.py +207 -0
- calagoapi/tunnels/__init__.py +5 -0
- calagoapi/tunnels/tunnel.py +112 -0
- calagoapi/users/__init__.py +11 -0
- calagoapi/users/activity.py +45 -0
- calagoapi/users/subuser.py +127 -0
- calagoapi/users/user.py +267 -0
- calagoapi/utils/__init__.py +43 -0
- calagoapi/utils/convert.py +113 -0
- calagoapi/utils/exceptions.py +72 -0
- calagoapi/utils/model.py +142 -0
- calagoapi/utils/resource.py +100 -0
- calagoapi/utils/time.py +68 -0
- calagoapi-1.0.dev1.dist-info/METADATA +266 -0
- calagoapi-1.0.dev1.dist-info/RECORD +71 -0
- calagoapi-1.0.dev1.dist-info/WHEEL +5 -0
- calagoapi-1.0.dev1.dist-info/licenses/LICENSE +674 -0
- calagoapi-1.0.dev1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import ssl
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from websockets.sync.client import connect
|
|
8
|
+
from websockets.exceptions import ConnectionClosed, WebSocketException
|
|
9
|
+
|
|
10
|
+
from calagoapi.utils.exceptions import CalagoAPIError, ConsoleError, InvalidActionError, WaitTimeoutError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger('calagoapi.console')
|
|
14
|
+
|
|
15
|
+
POWER_ACTIONS = ('start', 'restart', 'stop', 'kill')
|
|
16
|
+
TOKEN_EVENTS = ('token expiring', 'token expired')
|
|
17
|
+
ERROR_EVENTS = ('jwt error', 'daemon error')
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConsoleEvent:
|
|
21
|
+
'''
|
|
22
|
+
One message from the server's live websocket.
|
|
23
|
+
|
|
24
|
+
Common events: 'console output', 'status', 'stats', 'install output',
|
|
25
|
+
'backup completed', 'daemon message', 'schedule completed'.
|
|
26
|
+
|
|
27
|
+
Example:
|
|
28
|
+
for event in console.events():
|
|
29
|
+
if event.getEvent() == 'status':
|
|
30
|
+
print('now', event.getArg())
|
|
31
|
+
'''
|
|
32
|
+
def __init__(self, event: str, args: list = None):
|
|
33
|
+
self.event = event
|
|
34
|
+
self.args = args or []
|
|
35
|
+
|
|
36
|
+
def __repr__(self):
|
|
37
|
+
return f'<ConsoleEvent {self.event!r} {self.args!r}>'[:200]
|
|
38
|
+
|
|
39
|
+
def getEvent(self) -> str:
|
|
40
|
+
'''Returns the event name, e.g. "console output".'''
|
|
41
|
+
return self.event
|
|
42
|
+
|
|
43
|
+
def getArgs(self) -> list:
|
|
44
|
+
'''Returns every argument (always strings).'''
|
|
45
|
+
return self.args
|
|
46
|
+
|
|
47
|
+
def getArg(self, index: int = 0, default=None) -> str:
|
|
48
|
+
'''Returns one argument, e.g. the console line or the new status.'''
|
|
49
|
+
return self.args[index] if index < len(self.args) else default
|
|
50
|
+
|
|
51
|
+
def getData(self, index: int = 0):
|
|
52
|
+
'''Returns an argument decoded from JSON, e.g. the resource usage of a "stats" event.'''
|
|
53
|
+
value = self.getArg(index)
|
|
54
|
+
try:
|
|
55
|
+
return json.loads(value)
|
|
56
|
+
except (TypeError, ValueError):
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# helpers shared with the async console
|
|
61
|
+
def socketUrl(url: str) -> str:
|
|
62
|
+
if url.startswith('https://'):
|
|
63
|
+
return 'wss://' + url[len('https://'):]
|
|
64
|
+
if url.startswith('http://'):
|
|
65
|
+
return 'ws://' + url[len('http://'):]
|
|
66
|
+
return url
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def sslContext(url: str, verify: bool):
|
|
70
|
+
if not url.startswith('wss://') or verify:
|
|
71
|
+
return None
|
|
72
|
+
context = ssl.create_default_context()
|
|
73
|
+
context.check_hostname = False
|
|
74
|
+
context.verify_mode = ssl.CERT_NONE
|
|
75
|
+
return context
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def encode(event: str, *args) -> str:
|
|
79
|
+
return json.dumps({'event': event, 'args': [str(a) for a in args]})
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def decode(raw) -> ConsoleEvent:
|
|
83
|
+
if isinstance(raw, bytes):
|
|
84
|
+
# binary (MessagePack) mode is never requested by CalagoAPI
|
|
85
|
+
return None
|
|
86
|
+
try:
|
|
87
|
+
message = json.loads(raw)
|
|
88
|
+
except ValueError:
|
|
89
|
+
return None
|
|
90
|
+
return ConsoleEvent(message.get('event'), [a for a in message.get('args') or [] if a is not None])
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Console:
|
|
94
|
+
'''
|
|
95
|
+
The live console of a server, available as `server.console`.
|
|
96
|
+
|
|
97
|
+
Connects straight to the node daemon over a websocket, so console output
|
|
98
|
+
arrives the moment it is printed. Expiring tokens are renewed automatically.
|
|
99
|
+
|
|
100
|
+
Three ways to use it:
|
|
101
|
+
|
|
102
|
+
1. Iterate lines:
|
|
103
|
+
|
|
104
|
+
with server.console as console:
|
|
105
|
+
console.sendCommand('list')
|
|
106
|
+
for line in console.lines():
|
|
107
|
+
print(line)
|
|
108
|
+
|
|
109
|
+
2. Wait for something:
|
|
110
|
+
|
|
111
|
+
with server.console as console:
|
|
112
|
+
console.sendCommand('save-all')
|
|
113
|
+
console.waitFor('Saved the game', timeout=30)
|
|
114
|
+
|
|
115
|
+
3. Callbacks in a background thread:
|
|
116
|
+
|
|
117
|
+
server.console.onConsoleLine(print)
|
|
118
|
+
server.console.onStatus(lambda state: print('status:', state))
|
|
119
|
+
server.console.listen()
|
|
120
|
+
...
|
|
121
|
+
server.console.stop()
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
server: The server whose console to open.
|
|
125
|
+
'''
|
|
126
|
+
def __init__(self, server):
|
|
127
|
+
self.server = server
|
|
128
|
+
self.socket = None
|
|
129
|
+
self.permissions = []
|
|
130
|
+
self.handlers = {}
|
|
131
|
+
self.running = False
|
|
132
|
+
self.thread = None
|
|
133
|
+
|
|
134
|
+
def __repr__(self):
|
|
135
|
+
state = 'connected' if self.isConnected() else 'closed'
|
|
136
|
+
return f'<Console {self.server.name!r} {state}>'
|
|
137
|
+
|
|
138
|
+
def __enter__(self):
|
|
139
|
+
return self.connect()
|
|
140
|
+
|
|
141
|
+
def __exit__(self, *exc):
|
|
142
|
+
self.stop()
|
|
143
|
+
|
|
144
|
+
# connection
|
|
145
|
+
def connect(self, timeout: int = 10) -> 'Console':
|
|
146
|
+
'''
|
|
147
|
+
Opens the websocket and authenticates. Does nothing if already connected.
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
ConsoleError: The daemon could not be reached or refused the token.
|
|
151
|
+
'''
|
|
152
|
+
if self.socket:
|
|
153
|
+
return self
|
|
154
|
+
credentials = self.server.getWebsocket()
|
|
155
|
+
url = socketUrl(credentials['url'])
|
|
156
|
+
try:
|
|
157
|
+
# entering the connection marks it as managed (websockets 17+ warns otherwise),
|
|
158
|
+
# close() exits it
|
|
159
|
+
self.socket = connect(
|
|
160
|
+
url,
|
|
161
|
+
origin=self.server.panel.url,
|
|
162
|
+
ssl=sslContext(url, self.server.panel.client.session.verify),
|
|
163
|
+
open_timeout=timeout,
|
|
164
|
+
user_agent_header='CalagoAPI',
|
|
165
|
+
).__enter__()
|
|
166
|
+
except (OSError, WebSocketException) as e:
|
|
167
|
+
raise ConsoleError(f'Could not connect to the console of {self.server.name}: {e}') from e
|
|
168
|
+
self.server.panel.consoles.add(self)
|
|
169
|
+
|
|
170
|
+
self._send('auth', credentials['token'])
|
|
171
|
+
deadline = time.monotonic() + timeout
|
|
172
|
+
while True:
|
|
173
|
+
event = self._receive(max(deadline - time.monotonic(), 0.01))
|
|
174
|
+
if event is None:
|
|
175
|
+
self.close()
|
|
176
|
+
raise ConsoleError(f'The console of {self.server.name} did not confirm authentication')
|
|
177
|
+
if event.getEvent() == 'auth success':
|
|
178
|
+
self.permissions = event.getArgs()
|
|
179
|
+
return self
|
|
180
|
+
if event.getEvent() in ERROR_EVENTS:
|
|
181
|
+
self.close()
|
|
182
|
+
raise ConsoleError(f'Console authentication failed: {event.getArg()}')
|
|
183
|
+
|
|
184
|
+
def close(self) -> None:
|
|
185
|
+
'''Closes the websocket. Use stop() to also end a running listen().'''
|
|
186
|
+
socket, self.socket = self.socket, None
|
|
187
|
+
self.server.panel.consoles.discard(self)
|
|
188
|
+
if socket:
|
|
189
|
+
socket.close()
|
|
190
|
+
|
|
191
|
+
def isConnected(self) -> bool:
|
|
192
|
+
'''Returns True while the websocket is open.'''
|
|
193
|
+
return self.socket is not None
|
|
194
|
+
|
|
195
|
+
def getPermissions(self) -> list:
|
|
196
|
+
'''Returns the console permissions the daemon granted, e.g. ["control.console", ...].'''
|
|
197
|
+
return self.permissions
|
|
198
|
+
|
|
199
|
+
# sending
|
|
200
|
+
def _send(self, event, *args):
|
|
201
|
+
if not self.socket:
|
|
202
|
+
raise ConsoleError('The console is not connected, call connect() first')
|
|
203
|
+
try:
|
|
204
|
+
self.socket.send(encode(event, *args))
|
|
205
|
+
except ConnectionClosed as e:
|
|
206
|
+
self.socket = None
|
|
207
|
+
raise ConsoleError(f'The console connection was closed: {e}') from e
|
|
208
|
+
|
|
209
|
+
def sendCommand(self, command: str) -> None:
|
|
210
|
+
'''
|
|
211
|
+
Sends a console command over the websocket.
|
|
212
|
+
|
|
213
|
+
Example:
|
|
214
|
+
console.sendCommand('say Hello')
|
|
215
|
+
'''
|
|
216
|
+
self._send('send command', command)
|
|
217
|
+
|
|
218
|
+
def setState(self, action: str) -> None:
|
|
219
|
+
'''Sends a power action: start, restart, stop or kill.'''
|
|
220
|
+
if action not in POWER_ACTIONS:
|
|
221
|
+
raise InvalidActionError(f'Invalid power action "{action}", expected one of: {", ".join(POWER_ACTIONS)}')
|
|
222
|
+
self._send('set state', action)
|
|
223
|
+
|
|
224
|
+
def requestLogs(self) -> None:
|
|
225
|
+
'''Asks the daemon to resend recent console lines (they arrive as "console output").'''
|
|
226
|
+
self._send('send logs')
|
|
227
|
+
|
|
228
|
+
def requestStats(self) -> None:
|
|
229
|
+
'''Asks the daemon for resource usage now (arrives as a "stats" event).'''
|
|
230
|
+
self._send('send stats')
|
|
231
|
+
|
|
232
|
+
def requestStatus(self) -> None:
|
|
233
|
+
'''Asks the daemon for the power state now (arrives as a "status" event).'''
|
|
234
|
+
self._send('send status')
|
|
235
|
+
|
|
236
|
+
# receiving
|
|
237
|
+
def _receive(self, timeout=None):
|
|
238
|
+
if not self.socket:
|
|
239
|
+
return None
|
|
240
|
+
try:
|
|
241
|
+
return decode(self.socket.recv(timeout=timeout)) or ConsoleEvent('unknown')
|
|
242
|
+
except TimeoutError:
|
|
243
|
+
return None
|
|
244
|
+
except ConnectionClosed:
|
|
245
|
+
self.close()
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
def _renewToken(self):
|
|
249
|
+
self._send('auth', self.server.getWebsocket()['token'])
|
|
250
|
+
|
|
251
|
+
def events(self, timeout: float = None):
|
|
252
|
+
'''
|
|
253
|
+
Yields every event from the daemon until the connection closes.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
timeout: Stop when no event arrives for this many seconds. None waits forever.
|
|
257
|
+
|
|
258
|
+
Example:
|
|
259
|
+
for event in console.events():
|
|
260
|
+
print(event.getEvent(), event.getArgs())
|
|
261
|
+
'''
|
|
262
|
+
while self.socket:
|
|
263
|
+
event = self._receive(timeout)
|
|
264
|
+
if event is None:
|
|
265
|
+
return
|
|
266
|
+
if event.getEvent() in TOKEN_EVENTS:
|
|
267
|
+
self._renewToken()
|
|
268
|
+
continue
|
|
269
|
+
yield event
|
|
270
|
+
|
|
271
|
+
def lines(self, history: bool = True, timeout: float = None):
|
|
272
|
+
'''
|
|
273
|
+
Yields console lines as they are printed.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
history: Start with the recent lines the daemon still has.
|
|
277
|
+
timeout: Stop when nothing arrives for this many seconds. None waits forever.
|
|
278
|
+
|
|
279
|
+
Example:
|
|
280
|
+
for line in console.lines(history=False):
|
|
281
|
+
if 'joined the game' in line:
|
|
282
|
+
print(line)
|
|
283
|
+
'''
|
|
284
|
+
self.connect()
|
|
285
|
+
if history:
|
|
286
|
+
self.requestLogs()
|
|
287
|
+
for event in self.events(timeout):
|
|
288
|
+
if event.getEvent() == 'console output':
|
|
289
|
+
yield event.getArg(0, '')
|
|
290
|
+
|
|
291
|
+
def waitFor(self, text: str, timeout: float = 60) -> str:
|
|
292
|
+
'''
|
|
293
|
+
Blocks until a console line contains `text` and returns that line.
|
|
294
|
+
|
|
295
|
+
Raises:
|
|
296
|
+
WaitTimeoutError: No matching line within `timeout` seconds.
|
|
297
|
+
|
|
298
|
+
Example:
|
|
299
|
+
console.sendCommand('save-all')
|
|
300
|
+
console.waitFor('Saved the game')
|
|
301
|
+
'''
|
|
302
|
+
self.connect()
|
|
303
|
+
deadline = time.monotonic() + timeout
|
|
304
|
+
while True:
|
|
305
|
+
remaining = deadline - time.monotonic()
|
|
306
|
+
event = self._receive(remaining) if remaining > 0 else None
|
|
307
|
+
if event is None:
|
|
308
|
+
raise WaitTimeoutError(f'No console line containing {text!r} within {timeout}s')
|
|
309
|
+
if event.getEvent() in TOKEN_EVENTS:
|
|
310
|
+
self._renewToken()
|
|
311
|
+
elif event.getEvent() == 'console output' and text in event.getArg(0, ''):
|
|
312
|
+
return event.getArg()
|
|
313
|
+
|
|
314
|
+
# callbacks
|
|
315
|
+
def on(self, event: str, callback) -> 'Console':
|
|
316
|
+
'''
|
|
317
|
+
Calls `callback(ConsoleEvent)` for an event while listen() runs. Use '*' for every event.
|
|
318
|
+
|
|
319
|
+
Example:
|
|
320
|
+
console.on('backup completed', lambda e: print('backup done', e.getArgs()))
|
|
321
|
+
'''
|
|
322
|
+
self.handlers.setdefault(event, []).append(callback)
|
|
323
|
+
return self
|
|
324
|
+
|
|
325
|
+
def onConsoleLine(self, callback) -> 'Console':
|
|
326
|
+
'''Calls `callback(line)` for every console line.'''
|
|
327
|
+
return self.on('console output', lambda event: callback(event.getArg(0, '')))
|
|
328
|
+
|
|
329
|
+
def onStatus(self, callback) -> 'Console':
|
|
330
|
+
'''Calls `callback(state)` when the power state changes ('running', 'offline', ...).'''
|
|
331
|
+
return self.on('status', lambda event: callback(event.getArg()))
|
|
332
|
+
|
|
333
|
+
def onStats(self, callback) -> 'Console':
|
|
334
|
+
'''Calls `callback(resources)` with a resource usage dict, sent every few seconds while running.'''
|
|
335
|
+
return self.on('stats', lambda event: callback(event.getData()))
|
|
336
|
+
|
|
337
|
+
def _dispatch(self, event):
|
|
338
|
+
for callback in self.handlers.get(event.getEvent(), []) + self.handlers.get('*', []):
|
|
339
|
+
try:
|
|
340
|
+
callback(event)
|
|
341
|
+
except Exception:
|
|
342
|
+
logger.exception('console callback for %r failed', event.getEvent())
|
|
343
|
+
|
|
344
|
+
def listen(self, background: bool = True, history: bool = False, reconnect: bool = True) -> 'Console':
|
|
345
|
+
'''
|
|
346
|
+
Delivers events to the registered callbacks.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
background: Run in a daemon thread and return immediately. False blocks until stop().
|
|
350
|
+
history: Replay recent console lines first.
|
|
351
|
+
reconnect: Reconnect automatically (with backoff) if the connection drops.
|
|
352
|
+
|
|
353
|
+
Raises:
|
|
354
|
+
ConsoleError: The first connection failed.
|
|
355
|
+
'''
|
|
356
|
+
if self.running:
|
|
357
|
+
return self
|
|
358
|
+
self.connect()
|
|
359
|
+
self.running = True
|
|
360
|
+
if background:
|
|
361
|
+
self.thread = threading.Thread(target=self._loop, args=(history, reconnect), name=f'calagoapi-console-{self.server.server_id}', daemon=True)
|
|
362
|
+
self.thread.start()
|
|
363
|
+
else:
|
|
364
|
+
self._loop(history, reconnect)
|
|
365
|
+
return self
|
|
366
|
+
|
|
367
|
+
def _loop(self, history, reconnect):
|
|
368
|
+
delay = 1
|
|
369
|
+
while self.running:
|
|
370
|
+
try:
|
|
371
|
+
self.connect()
|
|
372
|
+
delay = 1
|
|
373
|
+
if history:
|
|
374
|
+
self.requestLogs()
|
|
375
|
+
history = False
|
|
376
|
+
for event in self.events():
|
|
377
|
+
self._dispatch(event)
|
|
378
|
+
except CalagoAPIError as e:
|
|
379
|
+
logger.warning('console of %s disconnected: %s', self.server.name, e)
|
|
380
|
+
if not self.running or not reconnect:
|
|
381
|
+
break
|
|
382
|
+
time.sleep(delay)
|
|
383
|
+
delay = min(delay * 2, 30)
|
|
384
|
+
self.running = False
|
|
385
|
+
|
|
386
|
+
def stop(self) -> None:
|
|
387
|
+
'''Stops listen() and closes the connection.'''
|
|
388
|
+
self.running = False
|
|
389
|
+
self.close()
|
|
390
|
+
if self.thread and self.thread is not threading.current_thread():
|
|
391
|
+
self.thread.join(timeout=5)
|
|
392
|
+
self.thread = None
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from .database import Database, DatabaseInstance, InstanceDatabase, InstanceUser
|
|
2
|
+
from .explorer import DatabaseExplorer
|
|
3
|
+
from .manager import DatabaseManager
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
'Database',
|
|
7
|
+
'DatabaseInstance',
|
|
8
|
+
'InstanceDatabase',
|
|
9
|
+
'InstanceUser',
|
|
10
|
+
'DatabaseExplorer',
|
|
11
|
+
'DatabaseManager',
|
|
12
|
+
]
|