sillydev 1.0.0

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.
@@ -0,0 +1,844 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module SillyDev
6
+ # The panel API's endpoints, grouped as in the API docs.
7
+ #
8
+ # Each method names the operationId it calls in https://sillydev.co.uk/docs/panel/openapi.json.
9
+ # Every method returns plain hashes and arrays (string keys), with the {"object", "attributes"}
10
+ # envelopes removed. Methods named list (and activity) on paginated endpoints return an
11
+ # Enumerator that fetches the following pages as it goes; their *_page twins return a single
12
+ # SillyDev::Page, which also carries the list's meta.
13
+ module Resources
14
+ # Shared plumbing for the resource classes.
15
+ class Base
16
+ def initialize(client)
17
+ @client = client
18
+ end
19
+
20
+ def inspect
21
+ "#<#{self.class.name}>"
22
+ end
23
+
24
+ private
25
+
26
+ def req(method, path, **kwargs)
27
+ @client.request(method, path, **kwargs)
28
+ end
29
+
30
+ def seg(value)
31
+ Client.segment(value)
32
+ end
33
+
34
+ def server_path(server)
35
+ "/servers/#{seg(server)}"
36
+ end
37
+
38
+ def project_path(project)
39
+ "/monitoring/projects/#{seg(project)}"
40
+ end
41
+
42
+ # A request body without the optional fields that were left as nil.
43
+ def body(**fields)
44
+ fields.compact.transform_keys(&:to_s)
45
+ end
46
+
47
+ # Turns [["a", "b"]] or [{"from" => "a", "to" => "b"}] into [{"from" => "a", "to" => "b"}].
48
+ def pairs(values, first, second)
49
+ values.map do |value|
50
+ if value.is_a?(Hash)
51
+ { first => value[first] || value[first.to_sym], second => value[second] || value[second.to_sym] }
52
+ else
53
+ a, b = value
54
+ { first => a, second => b }
55
+ end
56
+ end
57
+ end
58
+
59
+ # The body with its meta folded in: {key => body, ...meta}.
60
+ def with_meta(key, raw)
61
+ return { key => Envelope.unwrap(raw) } unless raw.is_a?(Hash)
62
+
63
+ out = { key => Envelope.unwrap(raw.except("meta")) }
64
+ out.merge!(Envelope.unwrap(raw["meta"])) if raw["meta"].is_a?(Hash)
65
+ out
66
+ end
67
+ end
68
+
69
+ # Servers, plus power and console (tags Servers and Power and console).
70
+ class Servers < Base
71
+ # Every server the key can reach, fetching page after page. operationId: listServers
72
+ def list(type: nil, search: nil, uuid: nil, name: nil, description: nil, external_id: nil, include: nil, per_page: nil)
73
+ @client.paginate("/", server_filters(type, search, uuid, name, description, external_id, include, per_page))
74
+ end
75
+
76
+ # One page of servers. page.meta["pending_orders"] lists paid servers not built yet. operationId: listServers
77
+ def list_page(page: 1, per_page: nil, type: nil, search: nil, uuid: nil, name: nil, description: nil,
78
+ external_id: nil, include: nil)
79
+ params = server_filters(type, search, uuid, name, description, external_id, include, per_page)
80
+ @client.page("/", params.merge(page: page))
81
+ end
82
+
83
+ # One server's details. operationId: getServer
84
+ def get(server, include: nil)
85
+ req("GET", server_path(server), params: { include: include })
86
+ end
87
+
88
+ # Delete a server you own. name must be the server's current name, as a confirmation. operationId: deleteServer
89
+ def delete(server, name:)
90
+ req("DELETE", server_path(server), json: { "name" => name })
91
+ end
92
+
93
+ # Live state and resource use of one server. operationId: getServerResources
94
+ def resources(server)
95
+ req("GET", "#{server_path(server)}/resources")
96
+ end
97
+
98
+ # Waits until the server is in state, checking its live state every interval seconds.
99
+ #
100
+ # Returns the server's resources from the check that found it in that state. Raises
101
+ # SillyDev::WaitTimeoutError if it isn't after timeout seconds. For example
102
+ # servers.start(id) then servers.wait_for_state(id). operationId: getServerResources
103
+ def wait_for_state(server, state = "running", timeout: 120.0, interval: 2.0)
104
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
105
+ loop do
106
+ current = resources(server)
107
+ return current if current.is_a?(Hash) && current["current_state"] == state
108
+
109
+ now_state = current.is_a?(Hash) ? current["current_state"] : nil
110
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
111
+ if remaining <= 0
112
+ raise WaitTimeoutError.new("server #{server} is #{now_state}, not #{state}, after #{timeout} seconds",
113
+ state: now_state)
114
+ end
115
+
116
+ @client.sleeper.call([interval, remaining].min)
117
+ end
118
+ end
119
+
120
+ # Live resource use of up to 100 servers, keyed by UUID (full UUIDs only). operationId: listServerResources
121
+ def resources_many(servers)
122
+ req("GET", "/resources", params: { servers: Array(servers).join(",") })
123
+ end
124
+
125
+ # The server's activity log, fetching page after page. operationId: listServerActivity
126
+ def activity(server, event: nil, sort: nil, include: nil, per_page: nil)
127
+ @client.paginate("#{server_path(server)}/activity",
128
+ { "filter[event]" => event, sort: sort, include: include, per_page: per_page })
129
+ end
130
+
131
+ # One page of the server's activity log. operationId: listServerActivity
132
+ def activity_page(server, page: 1, per_page: nil, event: nil, sort: nil, include: nil)
133
+ @client.page("#{server_path(server)}/activity",
134
+ { page: page, per_page: per_page, "filter[event]" => event, sort: sort, include: include })
135
+ end
136
+
137
+ # Every server permission there is, grouped, with descriptions. operationId: listPermissions
138
+ def permissions
139
+ req("GET", "/permissions")
140
+ end
141
+
142
+ # Send "start", "stop", "restart" or "kill". operationId: sendPowerSignal
143
+ def power(server, signal)
144
+ req("POST", "#{server_path(server)}/power", json: { "signal" => signal.to_s })
145
+ end
146
+
147
+ # Shortcut for power(server, "start"). operationId: sendPowerSignal
148
+ def start(server) = power(server, "start")
149
+
150
+ # Shortcut for power(server, "stop"). operationId: sendPowerSignal
151
+ def stop(server) = power(server, "stop")
152
+
153
+ # Shortcut for power(server, "restart"). operationId: sendPowerSignal
154
+ def restart(server) = power(server, "restart")
155
+
156
+ # Shortcut for power(server, "kill"). operationId: sendPowerSignal
157
+ def kill(server) = power(server, "kill")
158
+
159
+ # Send a command to the server's console. operationId: sendCommand
160
+ def command(server, command)
161
+ req("POST", "#{server_path(server)}/command", json: { "command" => command })
162
+ end
163
+
164
+ # Upload console output to a share link, with secrets hidden; returns the URL. operationId: shareLogs
165
+ def share_logs(server, content)
166
+ req("POST", "#{server_path(server)}/logs/share", json: { "content" => content })["url"].to_s
167
+ end
168
+
169
+ # A token and the addresses of the live console: {"token", "socket", "sessions"}. operationId: getWebsocketCredentials
170
+ def websocket(server)
171
+ req("GET", "#{server_path(server)}/websocket")["data"]
172
+ end
173
+
174
+ # Open a console session that the panel reads for you; returns the session ID. operationId: openConsoleRelay
175
+ def open_relay(server)
176
+ (req("POST", "#{server_path(server)}/websocket/relay")["data"] || {})["session"].to_s
177
+ end
178
+
179
+ # The relay session's messages after the one numbered after: {"events", "missed", "more"}. operationId: pollConsoleRelay
180
+ def poll_relay(server, session, after: nil)
181
+ req("GET", "#{server_path(server)}/websocket/relay/#{seg(session)}", params: { after: after })["data"]
182
+ end
183
+
184
+ # Send a websocket message ("send command", "set state"...) through a relay session. operationId: sendConsoleRelay
185
+ def send_relay(server, session, event, *args)
186
+ req("POST", "#{server_path(server)}/websocket/relay/#{seg(session)}/send",
187
+ json: { "event" => event, "args" => args.map(&:to_s) })
188
+ nil
189
+ end
190
+
191
+ # End a relay session. operationId: closeConsoleRelay
192
+ def close_relay(server, session)
193
+ req("DELETE", "#{server_path(server)}/websocket/relay/#{seg(session)}")
194
+ nil
195
+ end
196
+
197
+ private
198
+
199
+ def server_filters(type, search, uuid, name, description, external_id, include, per_page)
200
+ {
201
+ type: type,
202
+ "filter[*]" => search,
203
+ "filter[uuid]" => uuid,
204
+ "filter[name]" => name,
205
+ "filter[description]" => description,
206
+ "filter[external_id]" => external_id,
207
+ include: include,
208
+ per_page: per_page,
209
+ }
210
+ end
211
+ end
212
+
213
+ # Rename, reinstall, change the Docker image and the status badge (tag Settings).
214
+ class Settings < Base
215
+ # Rename a server and optionally change its description. operationId: renameServer
216
+ def rename(server, name, description: nil)
217
+ req("POST", "#{server_path(server)}/settings/rename", json: body(name: name, description: description))
218
+ end
219
+
220
+ # Run the server's install script again. operationId: reinstallServer
221
+ def reinstall(server)
222
+ req("POST", "#{server_path(server)}/settings/reinstall")
223
+ end
224
+
225
+ # Switch to another of the egg's Docker images. operationId: setDockerImage
226
+ def set_docker_image(server, docker_image)
227
+ req("PUT", "#{server_path(server)}/settings/docker-image", json: { "docker_image" => docker_image })
228
+ end
229
+
230
+ # The public status badge: {"enabled", "show_name", "url", "link", "markdown"}. operationId: getStatusBadge
231
+ def badge(server)
232
+ req("GET", "#{server_path(server)}/settings/badge")
233
+ end
234
+
235
+ # Turn the status badge on, or change whether it shows the server's name. operationId: updateStatusBadge
236
+ def enable_badge(server, show_name: nil)
237
+ req("PUT", "#{server_path(server)}/settings/badge", json: body(show_name: show_name))
238
+ end
239
+
240
+ # Give the status badge a new address; the old one stops working. operationId: regenerateStatusBadge
241
+ def regenerate_badge(server)
242
+ req("POST", "#{server_path(server)}/settings/badge/regenerate")
243
+ end
244
+
245
+ # Turn the status badge off and forget its address. operationId: deleteStatusBadge
246
+ def disable_badge(server)
247
+ req("DELETE", "#{server_path(server)}/settings/badge")
248
+ end
249
+ end
250
+
251
+ # The startup command and variables (tag Startup).
252
+ class Startup < Base
253
+ # {"variables" => [...], "startup_command", "raw_startup_command", "docker_images", ...}. operationId: getStartup
254
+ def get(server)
255
+ with_meta("variables", req("GET", "#{server_path(server)}/startup", raw: true))
256
+ end
257
+
258
+ # Set one startup variable by its env_variable; returns {"variable" => {...}, "startup_command", ...}.
259
+ # operationId: updateStartupVariable
260
+ def set_variable(server, key, value)
261
+ with_meta("variable",
262
+ req("PUT", "#{server_path(server)}/startup/variable", json: { "key" => key, "value" => value }, raw: true))
263
+ end
264
+ end
265
+
266
+ # Browse and change a server's files (tag Files).
267
+ class Files < Base
268
+ # The files and folders in one directory (not paginated). operationId: listFiles
269
+ def list(server, directory = "/")
270
+ req("GET", "#{server_path(server)}/files/list", params: { directory: directory })
271
+ end
272
+
273
+ # A file's contents as text (files up to 4 MiB). operationId: getFileContents
274
+ def read(server, file)
275
+ req("GET", "#{server_path(server)}/files/contents", params: { file: file }, text: true)
276
+ end
277
+
278
+ # A single-use download link for a file, valid for 15 minutes. operationId: getFileDownloadUrl
279
+ def download_url(server, file)
280
+ req("GET", "#{server_path(server)}/files/download", params: { file: file })["url"].to_s
281
+ end
282
+
283
+ # Create or overwrite a file. A String in Encoding::BINARY is sent as binary, any other as text. operationId: writeFile
284
+ def write(server, file, content)
285
+ req("POST", "#{server_path(server)}/files/write", params: { file: file }, content: content.to_s)
286
+ end
287
+
288
+ # Rename or move files: files is [["old", "new"]] or [{"from" => ..., "to" => ...}]. operationId: renameFiles
289
+ def rename(server, files, root: nil)
290
+ req("PUT", "#{server_path(server)}/files/rename", json: { "root" => root, "files" => pairs(files, "from", "to") })
291
+ end
292
+
293
+ # Copy a file next to itself (main.rb becomes "main copy.rb"). operationId: copyFile
294
+ def copy(server, location)
295
+ req("POST", "#{server_path(server)}/files/copy", json: { "location" => location })
296
+ end
297
+
298
+ # Pack files into an archive in root; returns the archive's file object. operationId: compressFiles
299
+ def compress(server, files, root: nil)
300
+ req("POST", "#{server_path(server)}/files/compress", json: body(root: root, files: Array(files)))
301
+ end
302
+
303
+ # Unpack an archive into root. operationId: decompressFile
304
+ def decompress(server, file, root: nil)
305
+ req("POST", "#{server_path(server)}/files/decompress", json: body(root: root, file: file))
306
+ end
307
+
308
+ # Delete files and folders. operationId: deleteFiles
309
+ def delete(server, files, root: nil)
310
+ req("POST", "#{server_path(server)}/files/delete", json: { "root" => root, "files" => Array(files) })
311
+ end
312
+
313
+ # Create a folder in root. operationId: createFolder
314
+ def create_folder(server, name, root: nil)
315
+ req("POST", "#{server_path(server)}/files/create-folder", json: body(root: root, name: name))
316
+ end
317
+
318
+ # Change permissions: files is [["main.rb", "644"]] or [{"file" => ..., "mode" => ...}]. operationId: chmodFiles
319
+ def chmod(server, files, root: nil)
320
+ req("POST", "#{server_path(server)}/files/chmod", json: { "root" => root, "files" => pairs(files, "file", "mode") })
321
+ end
322
+
323
+ # Have the node download a file from a URL into the server. operationId: pullFile
324
+ def pull(server, url, directory: nil, filename: nil, use_header: nil, foreground: nil)
325
+ req("POST", "#{server_path(server)}/files/pull",
326
+ json: body(url: url, directory: directory, filename: filename, use_header: use_header, foreground: foreground))
327
+ end
328
+
329
+ # A single-use upload link on the node, valid for 15 minutes. operationId: getFileUploadUrl
330
+ def upload_url(server)
331
+ req("GET", "#{server_path(server)}/files/upload")["url"].to_s
332
+ end
333
+
334
+ # Whether the files are managed by a linked Git repository. operationId: getFileGitNotice
335
+ def git_notice(server)
336
+ req("GET", "#{server_path(server)}/files/git")
337
+ end
338
+ end
339
+
340
+ # Backups of a server's files (tag Backups).
341
+ class Backups < Base
342
+ # Every backup, fetching page after page. operationId: listBackups
343
+ def list(server, per_page: nil)
344
+ @client.paginate("#{server_path(server)}/backups", { per_page: per_page })
345
+ end
346
+
347
+ # One page of backups; page.meta["backup_count"] counts those towards the limit. operationId: listBackups
348
+ def list_page(server, page: 1, per_page: nil)
349
+ @client.page("#{server_path(server)}/backups", { page: page, per_page: per_page })
350
+ end
351
+
352
+ # Start a backup. ignored is paths to leave out, one per line or as an array. operationId: createBackup
353
+ def create(server, name: nil, is_locked: nil, ignored: nil)
354
+ ignored = ignored.join("\n") if ignored.is_a?(Array)
355
+ req("POST", "#{server_path(server)}/backups", json: body(name: name, is_locked: is_locked, ignored: ignored))
356
+ end
357
+
358
+ # One backup. operationId: getBackup
359
+ def get(server, backup)
360
+ req("GET", "#{server_path(server)}/backups/#{seg(backup)}")
361
+ end
362
+
363
+ # Delete an unlocked backup. operationId: deleteBackup
364
+ def delete(server, backup)
365
+ req("DELETE", "#{server_path(server)}/backups/#{seg(backup)}")
366
+ end
367
+
368
+ # A single-use download link for a backup, valid for 15 minutes. operationId: getBackupDownloadUrl
369
+ def download_url(server, backup)
370
+ req("GET", "#{server_path(server)}/backups/#{seg(backup)}/download")["url"].to_s
371
+ end
372
+
373
+ # Lock an unlocked backup or unlock a locked one. operationId: toggleBackupLock
374
+ def toggle_lock(server, backup)
375
+ req("POST", "#{server_path(server)}/backups/#{seg(backup)}/lock")
376
+ end
377
+
378
+ # Restore a backup; truncate: true deletes every file first. operationId: restoreBackup
379
+ def restore(server, backup, truncate: false)
380
+ req("POST", "#{server_path(server)}/backups/#{seg(backup)}/restore", json: { "truncate" => truncate })
381
+ end
382
+ end
383
+
384
+ # Schedules and their tasks (tag Schedules).
385
+ class Schedules < Base
386
+ # Every schedule on the server, with its tasks. operationId: listSchedules
387
+ def list(server)
388
+ req("GET", "#{server_path(server)}/schedules")
389
+ end
390
+
391
+ # Create a schedule from a cron timetable. operationId: createSchedule
392
+ def create(server, name:, minute:, hour:, day_of_month:, month:, day_of_week:, is_active: nil, only_when_online: nil)
393
+ req("POST", "#{server_path(server)}/schedules",
394
+ json: body(name: name, minute: minute, hour: hour, day_of_month: day_of_month, month: month,
395
+ day_of_week: day_of_week, is_active: is_active, only_when_online: only_when_online))
396
+ end
397
+
398
+ # One schedule with its tasks. operationId: getSchedule
399
+ def get(server, schedule)
400
+ req("GET", "#{server_path(server)}/schedules/#{seg(schedule)}")
401
+ end
402
+
403
+ # Replace a schedule's name and timetable. operationId: updateSchedule
404
+ def update(server, schedule, name:, minute:, hour:, day_of_month:, month:, day_of_week:, is_active: nil,
405
+ only_when_online: nil)
406
+ req("POST", "#{server_path(server)}/schedules/#{seg(schedule)}",
407
+ json: body(name: name, minute: minute, hour: hour, day_of_month: day_of_month, month: month,
408
+ day_of_week: day_of_week, is_active: is_active, only_when_online: only_when_online))
409
+ end
410
+
411
+ # Delete a schedule and its tasks. operationId: deleteSchedule
412
+ def delete(server, schedule)
413
+ req("DELETE", "#{server_path(server)}/schedules/#{seg(schedule)}")
414
+ end
415
+
416
+ # Run a schedule now. operationId: runSchedule
417
+ def run(server, schedule)
418
+ req("POST", "#{server_path(server)}/schedules/#{seg(schedule)}/execute")
419
+ end
420
+
421
+ # Add a task ("command", "power" or "backup") to a schedule. operationId: createScheduleTask
422
+ def create_task(server, schedule, action:, time_offset:, payload: nil, sequence_id: nil, continue_on_failure: nil)
423
+ req("POST", "#{server_path(server)}/schedules/#{seg(schedule)}/tasks",
424
+ json: body(action: action, payload: payload, time_offset: time_offset, sequence_id: sequence_id,
425
+ continue_on_failure: continue_on_failure))
426
+ end
427
+
428
+ # Change a task. operationId: updateScheduleTask
429
+ def update_task(server, schedule, task, action:, time_offset:, payload: nil, sequence_id: nil,
430
+ continue_on_failure: nil)
431
+ req("POST", "#{server_path(server)}/schedules/#{seg(schedule)}/tasks/#{seg(task)}",
432
+ json: body(action: action, payload: payload, time_offset: time_offset, sequence_id: sequence_id,
433
+ continue_on_failure: continue_on_failure))
434
+ end
435
+
436
+ # Remove a task from a schedule. operationId: deleteScheduleTask
437
+ def delete_task(server, schedule, task)
438
+ req("DELETE", "#{server_path(server)}/schedules/#{seg(schedule)}/tasks/#{seg(task)}")
439
+ end
440
+ end
441
+
442
+ # The server's allocations, its IP and port pairs (tag Network).
443
+ class Network < Base
444
+ # Every allocation on the server. operationId: listAllocations
445
+ def list(server)
446
+ req("GET", "#{server_path(server)}/network/allocations")
447
+ end
448
+
449
+ # Add a port, where an administrator has allowed it. operationId: createAllocation
450
+ def create(server)
451
+ req("POST", "#{server_path(server)}/network/allocations")
452
+ end
453
+
454
+ # Set or clear (nil) an allocation's notes. operationId: updateAllocationNotes
455
+ def set_notes(server, allocation, notes)
456
+ req("POST", "#{server_path(server)}/network/allocations/#{seg(allocation)}", json: { "notes" => notes })
457
+ end
458
+
459
+ # Remove an allocation that is not the primary one. operationId: deleteAllocation
460
+ def delete(server, allocation)
461
+ req("DELETE", "#{server_path(server)}/network/allocations/#{seg(allocation)}")
462
+ end
463
+
464
+ # Make an allocation the primary one. operationId: setPrimaryAllocation
465
+ def set_primary(server, allocation)
466
+ req("POST", "#{server_path(server)}/network/allocations/#{seg(allocation)}/primary")
467
+ end
468
+ end
469
+
470
+ # Other accounts with access to the server (tag Subusers).
471
+ class Subusers < Base
472
+ # Every subuser of the server. operationId: listSubusers
473
+ def list(server)
474
+ req("GET", "#{server_path(server)}/users")
475
+ end
476
+
477
+ # Invite an account by email with a list of permissions. operationId: createSubuser
478
+ def create(server, email, permissions)
479
+ req("POST", "#{server_path(server)}/users", json: { "email" => email, "permissions" => Array(permissions) })
480
+ end
481
+
482
+ # One subuser, by their UUID. operationId: getSubuser
483
+ def get(server, user)
484
+ req("GET", "#{server_path(server)}/users/#{seg(user)}")
485
+ end
486
+
487
+ # Replace a subuser's permissions. operationId: updateSubuser
488
+ def update(server, user, permissions)
489
+ req("POST", "#{server_path(server)}/users/#{seg(user)}", json: { "permissions" => Array(permissions) })
490
+ end
491
+
492
+ # Remove a subuser. operationId: deleteSubuser
493
+ def delete(server, user)
494
+ req("DELETE", "#{server_path(server)}/users/#{seg(user)}")
495
+ end
496
+ end
497
+
498
+ # Deploy a Git repository into the server's files (tag Git deploys).
499
+ class Git < Base
500
+ # The linked repository, its settings and recent deployments. operationId: getGitDeploys
501
+ def get(server)
502
+ req("GET", "#{server_path(server)}/git")
503
+ end
504
+
505
+ # Link a repository ("github", "gitlab" or "url" source), or change the link's settings. operationId: linkGitRepository
506
+ def link(server, source:, repository:, branch:, installation: nil, directory: nil, auto_deploy: nil,
507
+ restart_on_deploy: nil, start_if_offline: nil, apply_config: nil, sync_startup: nil, write_config: nil)
508
+ req("PUT", "#{server_path(server)}/git",
509
+ json: body(source: source, installation: installation, repository: repository, branch: branch,
510
+ directory: directory, auto_deploy: auto_deploy, restart_on_deploy: restart_on_deploy,
511
+ start_if_offline: start_if_offline, apply_config: apply_config, sync_startup: sync_startup,
512
+ write_config: write_config))
513
+ end
514
+
515
+ # Unlink the repository; the files stay. operationId: unlinkGitRepository
516
+ def unlink(server, delete_config: nil)
517
+ req("DELETE", "#{server_path(server)}/git", params: { delete_config: delete_config })
518
+ end
519
+
520
+ # Repositories the connected GitHub or GitLab account can deploy: {"data", "errors"}. operationId: listGitRepositories
521
+ def repositories(server, provider: nil)
522
+ req("GET", "#{server_path(server)}/git/repositories", params: { provider: provider })
523
+ end
524
+
525
+ # Deploy the linked branch now; returns the queued deployment. operationId: deployGitRepository
526
+ def deploy(server)
527
+ req("POST", "#{server_path(server)}/git/deploy")
528
+ end
529
+
530
+ # Cancel a queued or running deployment. operationId: cancelGitDeployment
531
+ def cancel_deployment(server, deployment)
532
+ req("POST", "#{server_path(server)}/git/deployments/#{seg(deployment)}/cancel")
533
+ end
534
+
535
+ # A sillydev.conf written from the server's current settings. operationId: getGitConfigTemplate
536
+ def config_template(server)
537
+ req("GET", "#{server_path(server)}/git/config")
538
+ end
539
+
540
+ # Commit the server's settings to sillydev.conf in the repository. operationId: syncGitConfig
541
+ def sync_config(server)
542
+ req("POST", "#{server_path(server)}/git/config")
543
+ end
544
+
545
+ # The repository's sillydev.conf as the panel's editor shows it. operationId: getGitConfigEditor
546
+ def config_editor(server)
547
+ req("GET", "#{server_path(server)}/git/config/editor")
548
+ end
549
+
550
+ # Commit a new sillydev.conf (mode is "form" or "raw"). operationId: saveGitConfigEditor
551
+ def save_config_editor(server, mode:, sha: nil, raw: nil, variables: nil, image: nil)
552
+ req("PUT", "#{server_path(server)}/git/config/editor",
553
+ json: body(mode: mode, sha: sha, raw: raw, variables: variables, image: image))
554
+ end
555
+ end
556
+
557
+ # The account the key belongs to (tag Account).
558
+ class Account < Base
559
+ # The account's details. operationId: getAccount
560
+ def get
561
+ req("GET", "/account")
562
+ end
563
+
564
+ # The account's activity log, fetching page after page. operationId: listAccountActivity
565
+ def activity(event: nil, sort: nil, include: nil, per_page: nil)
566
+ @client.paginate("/account/activity",
567
+ { "filter[event]" => event, sort: sort, include: include, per_page: per_page })
568
+ end
569
+
570
+ # One page of the account's activity log. operationId: listAccountActivity
571
+ def activity_page(page: 1, per_page: nil, event: nil, sort: nil, include: nil)
572
+ @client.page("/account/activity",
573
+ { page: page, per_page: per_page, "filter[event]" => event, sort: sort, include: include })
574
+ end
575
+ end
576
+
577
+ # Create free servers (tag Server creation).
578
+ class ServerCreation < Base
579
+ # How many free servers the account may create, and the runtimes on offer. operationId: getServerCreationOptions
580
+ def options
581
+ req("GET", "/server-creation")
582
+ end
583
+
584
+ # Create a free server ("nodejs", "python" or "bun" runtime). operationId: createServer
585
+ def create(name:, runtime:, description: nil)
586
+ req("POST", "/server-creation", json: body(name: name, description: description, runtime: runtime))
587
+ end
588
+ end
589
+
590
+ # The account's free MariaDB and Postgres databases (tag Databases).
591
+ class Databases < Base
592
+ # {"limit", "owned", "can_create", "quota_bytes", "versions", "databases"}. operationId: listDatabases
593
+ def list
594
+ req("GET", "/databases")
595
+ end
596
+
597
+ # Create a database; the result holds its password. operationId: createDatabase
598
+ def create(label:, version_id:)
599
+ req("POST", "/databases", json: { "label" => label, "version_id" => version_id })
600
+ end
601
+
602
+ # Give the database a new password. operationId: rotateDatabasePassword
603
+ def rotate_password(database)
604
+ req("POST", "/databases/#{seg(database)}/rotate-password")
605
+ end
606
+
607
+ # Move the database to another version. operationId: moveDatabase
608
+ def move(database, version_id:)
609
+ req("POST", "/databases/#{seg(database)}/move", json: { "version_id" => version_id })
610
+ end
611
+
612
+ # Go back to the version the database was moved from. operationId: undoDatabaseMove
613
+ def undo_move(database)
614
+ req("POST", "/databases/#{seg(database)}/undo-move")
615
+ end
616
+
617
+ # Keep a move and drop the old copy. operationId: keepDatabaseMove
618
+ def keep_move(database)
619
+ req("POST", "/databases/#{seg(database)}/keep-move")
620
+ end
621
+
622
+ # Delete a database and everything in it. operationId: deleteDatabase
623
+ def delete(database)
624
+ req("DELETE", "/databases/#{seg(database)}")
625
+ end
626
+ end
627
+
628
+ # Paid servers and databases (tag Billing). Prices are in pence.
629
+ class Billing < Base
630
+ # The plan, the account's subscriptions and its saved card. operationId: getBilling
631
+ def get
632
+ req("GET", "/billing")
633
+ end
634
+
635
+ # Order a new paid server (name: and runtime:) or upgrade a free one (server_uuid:).
636
+ #
637
+ # An Idempotency-Key is always sent, so a retried request never buys twice: pass your own
638
+ # idempotency_key: to make retries across runs safe too. operationId: createCheckout
639
+ def checkout(name: nil, runtime: nil, description: nil, server_uuid: nil, use_saved_card: nil,
640
+ promotion_code: nil, plan: nil, idempotency_key: nil)
641
+ req("POST", "/billing/checkout",
642
+ json: body(server_uuid: server_uuid, name: name, description: description, runtime: runtime,
643
+ use_saved_card: use_saved_card, promotion_code: promotion_code, plan: plan),
644
+ headers: { "Idempotency-Key" => idempotency_key || SecureRandom.uuid })
645
+ end
646
+
647
+ # Order a paid database (plan:, label: and version_id:) or put a free one on a paid plan
648
+ # (plan: and database_id:). An Idempotency-Key is always sent. operationId: createDatabaseCheckout
649
+ def database_checkout(plan:, database_id: nil, label: nil, version_id: nil, use_saved_card: nil,
650
+ promotion_code: nil, idempotency_key: nil)
651
+ req("POST", "/billing/databases/checkout",
652
+ json: body(plan: plan, database_id: database_id, label: label, version_id: version_id,
653
+ use_saved_card: use_saved_card, promotion_code: promotion_code),
654
+ headers: { "Idempotency-Key" => idempotency_key || SecureRandom.uuid })
655
+ end
656
+
657
+ # Change a paid database's plan, or pass "free" to end it. operationId: changeDatabasePlan
658
+ def change_database_plan(database, plan)
659
+ req("POST", "/billing/databases/#{seg(database)}/plan", json: { "plan" => plan })
660
+ end
661
+
662
+ # Undo a waiting move to a smaller plan, or to free. operationId: keepDatabasePlan
663
+ def keep_database_plan(database)
664
+ req("POST", "/billing/databases/#{seg(database)}/plan/keep")
665
+ end
666
+
667
+ # The credit balance and one page (25 entries, newest first) of its activity. view: is
668
+ # "history" (the default) or "pending". operationId: getAccountCredit
669
+ def credit(view: nil, page: nil)
670
+ req("GET", "/billing/credit", params: { view: view, page: page })
671
+ end
672
+
673
+ # Every entry of the credit activity (or pending rewards), fetching each page as it is reached.
674
+ def credit_entries(view = "history")
675
+ Enumerator.new do |yielder|
676
+ number = 1
677
+ loop do
678
+ result = credit(view: view, page: number)
679
+ entries = result["data"] || []
680
+ entries.each { |entry| yielder << entry }
681
+ pages = ((result["pagination"] || {})["total_pages"] || 1).to_i
682
+ break if entries.empty? || number >= pages
683
+
684
+ number += 1
685
+ end
686
+ end
687
+ end
688
+
689
+ # Move a paid server to another paid plan. A smaller one waits for the next bill unless
690
+ # when: "now" (refund_to: "card" or "credit"). operationId: changeServerPlan
691
+ def change_server_plan(server_uuid, plan, when: nil, refund_to: nil)
692
+ req("POST", "/billing/subscriptions/#{seg(server_uuid)}/plan",
693
+ json: body(plan: plan, when: binding.local_variable_get(:when), refund_to: refund_to))
694
+ end
695
+
696
+ # Undo a waiting move to a smaller paid plan. operationId: keepServerPlan
697
+ def keep_server_plan(server_uuid)
698
+ req("POST", "/billing/subscriptions/#{seg(server_uuid)}/plan/keep")
699
+ end
700
+
701
+ # Cancel an order that has not been paid. operationId: cancelOrder
702
+ def cancel_order(order_uuid)
703
+ req("DELETE", "/billing/orders/#{seg(order_uuid)}")
704
+ end
705
+
706
+ # Cancel a server's plan at the end of the period. operationId: cancelSubscription
707
+ def cancel_subscription(server_uuid)
708
+ req("POST", "/billing/subscriptions/#{seg(server_uuid)}/cancel")
709
+ end
710
+
711
+ # Undo a cancellation before the period ends. operationId: resumeSubscription
712
+ def resume_subscription(server_uuid)
713
+ req("POST", "/billing/subscriptions/#{seg(server_uuid)}/resume")
714
+ end
715
+
716
+ # Move the server back to the free tier now. operationId: cancelSubscriptionImmediately
717
+ def cancel_subscription_immediately(server_uuid)
718
+ req("POST", "/billing/subscriptions/#{seg(server_uuid)}/cancel-immediately")
719
+ end
720
+ end
721
+
722
+ # Application monitoring projects, keys, issues and events (tag Monitoring). This manages
723
+ # projects; it never sends telemetry.
724
+ class Monitoring < Base
725
+ # The account's monitoring projects. operationId: listMonitoringProjects
726
+ def list_projects
727
+ req("GET", "/monitoring/projects")
728
+ end
729
+
730
+ # Create a monitoring project. operationId: createMonitoringProject
731
+ def create_project(name:)
732
+ req("POST", "/monitoring/projects", json: { "name" => name })
733
+ end
734
+
735
+ # One project, by its UUID. operationId: getMonitoringProject
736
+ def get_project(project)
737
+ req("GET", project_path(project))
738
+ end
739
+
740
+ # Rename a project, set the origins its browser keys work on, or change its alerts. operationId: updateMonitoringProject
741
+ def update_project(project, name: nil, allowed_origins: nil, alerts: nil)
742
+ req("PATCH", project_path(project),
743
+ json: body(name: name, allowed_origins: allowed_origins&.to_a, alerts: alerts&.to_h))
744
+ end
745
+
746
+ # Delete a project and all of its data. operationId: deleteMonitoringProject
747
+ def delete_project(project)
748
+ req("DELETE", project_path(project))
749
+ end
750
+
751
+ # The project's keys (never their secrets). operationId: listMonitoringKeys
752
+ def list_keys(project)
753
+ req("GET", "#{project_path(project)}/keys")
754
+ end
755
+
756
+ # Create a "server" (sdevm_) or "browser" (sdevb_) key. The result's "secret_key" is the key
757
+ # itself, which is never shown again. operationId: createMonitoringKey
758
+ def create_key(project, name:, kind: "server")
759
+ raw = req("POST", "#{project_path(project)}/keys", json: { "name" => name, "kind" => kind }, raw: true)
760
+ return Envelope.unwrap(raw) unless raw.is_a?(Hash)
761
+
762
+ out = Envelope.unwrap(raw.except("meta"))
763
+ out["secret_key"] = raw["meta"]["secret_key"] if raw["meta"].is_a?(Hash) && raw["meta"].key?("secret_key")
764
+ out
765
+ end
766
+
767
+ # Revoke a key at once. operationId: deleteMonitoringKey
768
+ def delete_key(project, key)
769
+ req("DELETE", "#{project_path(project)}/keys/#{seg(key)}")
770
+ end
771
+
772
+ # Grouped errors: {"data" => [...], "pagination" => {...}}. operationId: listMonitoringIssues
773
+ def list_issues(project, status: nil, environment: nil, release: nil, q: nil, sort: nil, page: nil, per_page: nil)
774
+ req("GET", "#{project_path(project)}/issues",
775
+ params: { status: status, environment: environment, release: release, q: q, sort: sort, page: page,
776
+ per_page: per_page })
777
+ end
778
+
779
+ # One issue with its hourly history and latest occurrence. operationId: getMonitoringIssue
780
+ def get_issue(project, fingerprint)
781
+ req("GET", "#{project_path(project)}/issues/#{seg(fingerprint)}")
782
+ end
783
+
784
+ # Mark an issue "resolved", "ignored" or "unresolved". operationId: updateMonitoringIssue
785
+ def update_issue(project, fingerprint, status:)
786
+ req("PATCH", "#{project_path(project)}/issues/#{seg(fingerprint)}", json: { "status" => status })
787
+ end
788
+
789
+ # Stored occurrences of an issue, newest first. operationId: listMonitoringIssueOccurrences
790
+ def list_issue_occurrences(project, fingerprint, page: nil, per_page: nil)
791
+ req("GET", "#{project_path(project)}/issues/#{seg(fingerprint)}/occurrences",
792
+ params: { page: page, per_page: per_page })
793
+ end
794
+
795
+ # Events, newest first; pass the answer's "next" as before: for older ones. operationId: listMonitoringEvents
796
+ def list_events(project, range: nil, from: nil, to: nil, name: nil, before: nil, limit: nil)
797
+ req("GET", "#{project_path(project)}/events",
798
+ params: { range: range, from: from, to: to, name: name, before: before, limit: limit })
799
+ end
800
+
801
+ # Series, daily quotas and what was dropped today. operationId: getMonitoringUsage
802
+ def usage(project)
803
+ req("GET", "#{project_path(project)}/usage")
804
+ end
805
+ end
806
+ end
807
+
808
+ # The panel API: client.panel.servers, client.panel.files and the rest.
809
+ class Panel
810
+ attr_reader :servers, :settings, :startup, :files, :backups, :schedules, :network, :subusers, :git,
811
+ :account, :server_creation, :databases, :billing, :monitoring
812
+
813
+ def initialize(client)
814
+ @client = client
815
+ @servers = Resources::Servers.new(client)
816
+ @settings = Resources::Settings.new(client)
817
+ @startup = Resources::Startup.new(client)
818
+ @files = Resources::Files.new(client)
819
+ @backups = Resources::Backups.new(client)
820
+ @schedules = Resources::Schedules.new(client)
821
+ @network = Resources::Network.new(client)
822
+ @subusers = Resources::Subusers.new(client)
823
+ @git = Resources::Git.new(client)
824
+ @account = Resources::Account.new(client)
825
+ @server_creation = Resources::ServerCreation.new(client)
826
+ @databases = Resources::Databases.new(client)
827
+ @billing = Resources::Billing.new(client)
828
+ @monitoring = Resources::Monitoring.new(client)
829
+ end
830
+
831
+ # The live console: websocket, streamed, polled or relayed through the panel. Loaded on first
832
+ # use, so code that never opens a console does not load it.
833
+ def console
834
+ @console ||= begin
835
+ require_relative "console"
836
+ ConsoleApi.new(@client)
837
+ end
838
+ end
839
+
840
+ def inspect
841
+ "#<#{self.class.name}>"
842
+ end
843
+ end
844
+ end