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.
Files changed (71) hide show
  1. calagoapi/__init__.py +190 -0
  2. calagoapi/account/__init__.py +14 -0
  3. calagoapi/account/account.py +292 -0
  4. calagoapi/account/auth.py +72 -0
  5. calagoapi/account/models.py +221 -0
  6. calagoapi/admin/__init__.py +48 -0
  7. calagoapi/admin/admin.py +341 -0
  8. calagoapi/admin/managers.py +490 -0
  9. calagoapi/admin/resources.py +970 -0
  10. calagoapi/aio/__init__.py +9 -0
  11. calagoapi/aio/console.py +289 -0
  12. calagoapi/aio/panel.py +176 -0
  13. calagoapi/api/__init__.py +63 -0
  14. calagoapi/api/account.py +195 -0
  15. calagoapi/api/admin.py +402 -0
  16. calagoapi/api/attachments.py +27 -0
  17. calagoapi/api/auth.py +52 -0
  18. calagoapi/api/backups.py +99 -0
  19. calagoapi/api/client.py +198 -0
  20. calagoapi/api/databases.py +217 -0
  21. calagoapi/api/files.py +210 -0
  22. calagoapi/api/network.py +84 -0
  23. calagoapi/api/public.py +13 -0
  24. calagoapi/api/schedules.py +79 -0
  25. calagoapi/api/servers.py +114 -0
  26. calagoapi/api/users.py +27 -0
  27. calagoapi/api/versions.py +29 -0
  28. calagoapi/backups/__init__.py +8 -0
  29. calagoapi/backups/backup.py +292 -0
  30. calagoapi/backups/manager.py +177 -0
  31. calagoapi/console/__init__.py +6 -0
  32. calagoapi/console/console.py +392 -0
  33. calagoapi/databases/__init__.py +12 -0
  34. calagoapi/databases/database.py +445 -0
  35. calagoapi/databases/explorer.py +159 -0
  36. calagoapi/databases/manager.py +122 -0
  37. calagoapi/files/__init__.py +9 -0
  38. calagoapi/files/file.py +196 -0
  39. calagoapi/files/manager.py +437 -0
  40. calagoapi/network/__init__.py +8 -0
  41. calagoapi/network/allocation.py +99 -0
  42. calagoapi/network/manager.py +132 -0
  43. calagoapi/nodes/__init__.py +6 -0
  44. calagoapi/nodes/node.py +477 -0
  45. calagoapi/panel.py +162 -0
  46. calagoapi/schedules/__init__.py +14 -0
  47. calagoapi/schedules/builders.py +461 -0
  48. calagoapi/schedules/manager.py +107 -0
  49. calagoapi/schedules/schedule.py +214 -0
  50. calagoapi/servers/__init__.py +13 -0
  51. calagoapi/servers/admin.py +430 -0
  52. calagoapi/servers/generic.py +785 -0
  53. calagoapi/servers/manager.py +167 -0
  54. calagoapi/servers/minecraft.py +207 -0
  55. calagoapi/tunnels/__init__.py +5 -0
  56. calagoapi/tunnels/tunnel.py +112 -0
  57. calagoapi/users/__init__.py +11 -0
  58. calagoapi/users/activity.py +45 -0
  59. calagoapi/users/subuser.py +127 -0
  60. calagoapi/users/user.py +267 -0
  61. calagoapi/utils/__init__.py +43 -0
  62. calagoapi/utils/convert.py +113 -0
  63. calagoapi/utils/exceptions.py +72 -0
  64. calagoapi/utils/model.py +142 -0
  65. calagoapi/utils/resource.py +100 -0
  66. calagoapi/utils/time.py +68 -0
  67. calagoapi-1.0.dev1.dist-info/METADATA +266 -0
  68. calagoapi-1.0.dev1.dist-info/RECORD +71 -0
  69. calagoapi-1.0.dev1.dist-info/WHEEL +5 -0
  70. calagoapi-1.0.dev1.dist-info/licenses/LICENSE +674 -0
  71. 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
+ ]