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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +277 -0
- data/exe/sillydev +6 -0
- data/lib/sillydev/cli.rb +295 -0
- data/lib/sillydev/client.rb +199 -0
- data/lib/sillydev/console.rb +1131 -0
- data/lib/sillydev/envelope.rb +84 -0
- data/lib/sillydev/errors.rb +160 -0
- data/lib/sillydev/mcp_server.rb +193 -0
- data/lib/sillydev/monitoring/aggregator.rb +107 -0
- data/lib/sillydev/monitoring/client.rb +674 -0
- data/lib/sillydev/monitoring/discordrb.rb +131 -0
- data/lib/sillydev/monitoring/http.rb +31 -0
- data/lib/sillydev/monitoring/rack.rb +114 -0
- data/lib/sillydev/monitoring/rails.rb +41 -0
- data/lib/sillydev/monitoring/runtime.rb +96 -0
- data/lib/sillydev/monitoring/serialize.rb +168 -0
- data/lib/sillydev/monitoring.rb +227 -0
- data/lib/sillydev/resources.rb +844 -0
- data/lib/sillydev/transport.rb +71 -0
- data/lib/sillydev/version.rb +5 -0
- data/lib/sillydev/websocket.rb +248 -0
- data/lib/sillydev.rb +20 -0
- metadata +67 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module SillyDev
|
|
8
|
+
# Client for the Silly Development API.
|
|
9
|
+
#
|
|
10
|
+
# api_key defaults to the SILLYDEV_API_KEY environment variable and base_url to SILLYDEV_BASE_URL
|
|
11
|
+
# or https://sillydev.co.uk/panel/api/client. Requests answered 429 are retried up to max_retries
|
|
12
|
+
# times, and reads answered 502, 503 or 504 too, waiting as long as Retry-After asks.
|
|
13
|
+
#
|
|
14
|
+
# client = SillyDev::Client.new
|
|
15
|
+
# client.panel.servers.list.each { |server| puts "#{server["identifier"]} #{server["name"]}" }
|
|
16
|
+
class Client
|
|
17
|
+
DEFAULT_BASE_URL = "https://sillydev.co.uk/panel/api/client"
|
|
18
|
+
DEFAULT_TIMEOUT = 60
|
|
19
|
+
DEFAULT_MAX_RETRIES = 2
|
|
20
|
+
RETRY_STATUSES = [429, 502, 503, 504].freeze
|
|
21
|
+
# A POST answered 502, 503 or 504 may still have reached the server: the node can have run the
|
|
22
|
+
# command or the power action before the gateway gave up. Only 429, which is refused before
|
|
23
|
+
# anything runs, is safe to retry for those, unless the request carries an Idempotency-Key
|
|
24
|
+
# the panel uses to recognise a repeat.
|
|
25
|
+
UNSAFE_RETRY_STATUSES = [429].freeze
|
|
26
|
+
SAFE_METHODS = %w[GET HEAD OPTIONS].freeze
|
|
27
|
+
MAX_RETRY_AFTER = 60.0
|
|
28
|
+
|
|
29
|
+
attr_reader :api_key, :base_url, :max_retries, :panel
|
|
30
|
+
# How the client waits between retries and checks; replaceable in tests.
|
|
31
|
+
attr_accessor :sleeper
|
|
32
|
+
|
|
33
|
+
def initialize(api_key = nil, base_url: nil, timeout: DEFAULT_TIMEOUT, max_retries: DEFAULT_MAX_RETRIES,
|
|
34
|
+
default_headers: {}, transport: nil)
|
|
35
|
+
key = api_key || ENV.fetch("SILLYDEV_API_KEY", nil)
|
|
36
|
+
if key.nil? || key.empty?
|
|
37
|
+
raise Error, "No API key. Pass one to SillyDev::Client.new or set the SILLYDEV_API_KEY environment " \
|
|
38
|
+
"variable. Create a key in the panel under Account, API keys."
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
@api_key = key
|
|
42
|
+
base = base_url || ENV.fetch("SILLYDEV_BASE_URL", nil)
|
|
43
|
+
base = DEFAULT_BASE_URL if base.nil? || base.empty?
|
|
44
|
+
@base_url = base.chomp("/")
|
|
45
|
+
@max_retries = [max_retries.to_i, 0].max
|
|
46
|
+
@default_headers = default_headers.to_h
|
|
47
|
+
@transport = transport || NetHTTPTransport.new(timeout: timeout)
|
|
48
|
+
@sleeper = ->(seconds) { sleep(seconds) }
|
|
49
|
+
@panel = Panel.new(self)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def inspect
|
|
53
|
+
"#<#{self.class.name} base_url=#{@base_url.inspect}>"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Quotes one path parameter so that a slash or space in it cannot change the route.
|
|
57
|
+
def self.segment(value)
|
|
58
|
+
value.to_s.b.gsub(/[^A-Za-z0-9\-._~]/n) { |c| format("%%%02X", c.ord) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Sends one request and returns the unwrapped answer.
|
|
62
|
+
#
|
|
63
|
+
# json: is sent as a JSON body; content: as text (String) or binary (a String in
|
|
64
|
+
# Encoding::BINARY). raw: true skips unwrapping, text: true returns the body as a String.
|
|
65
|
+
def request(method, path, params: nil, json: :none, content: nil, headers: nil, raw: false, text: false)
|
|
66
|
+
method = method.to_s.upcase
|
|
67
|
+
attempt = 0
|
|
68
|
+
loop do
|
|
69
|
+
req = build(method, path, params, json, content, headers)
|
|
70
|
+
begin
|
|
71
|
+
response = @transport.call(req)
|
|
72
|
+
rescue ConnectFailed => e
|
|
73
|
+
if attempt < @max_retries
|
|
74
|
+
@sleeper.call(retry_delay(attempt, nil))
|
|
75
|
+
attempt += 1
|
|
76
|
+
next
|
|
77
|
+
end
|
|
78
|
+
raise APIConnectionError, "Could not reach #{@base_url}: #{e.message}"
|
|
79
|
+
rescue *Errors::TRANSPORT_ERRORS => e
|
|
80
|
+
raise APIConnectionError, "Could not reach #{@base_url}: #{e.message}"
|
|
81
|
+
end
|
|
82
|
+
if retry?(req, response, attempt)
|
|
83
|
+
@sleeper.call(retry_delay(attempt, response))
|
|
84
|
+
attempt += 1
|
|
85
|
+
next
|
|
86
|
+
end
|
|
87
|
+
return parse(response, raw: raw, text: text)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# One page of a paginated list.
|
|
92
|
+
def page(path, params = nil)
|
|
93
|
+
Page.from_response(request("GET", path, params: params, raw: true))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Every item of a paginated list, fetching each page when it is reached.
|
|
97
|
+
def paginate(path, params = nil)
|
|
98
|
+
params = (params || {}).dup
|
|
99
|
+
first = (params.delete(:page) || params.delete("page") || 1).to_i
|
|
100
|
+
Enumerator.new do |yielder|
|
|
101
|
+
number = first
|
|
102
|
+
loop do
|
|
103
|
+
result = page(path, params.merge(page: number))
|
|
104
|
+
result.each { |item| yielder << item }
|
|
105
|
+
break unless result.next_page?
|
|
106
|
+
|
|
107
|
+
number = result.current_page + 1
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def url(path)
|
|
115
|
+
return @base_url if path.nil? || path == "" || path == "/"
|
|
116
|
+
|
|
117
|
+
@base_url + (path.start_with?("/") ? path : "/#{path}")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def build(method, path, params, json, content, headers)
|
|
121
|
+
extra = (headers || {}).to_h { |k, v| [k.to_s, v.to_s] }
|
|
122
|
+
body = nil
|
|
123
|
+
if json != :none
|
|
124
|
+
body = JSON.generate(json)
|
|
125
|
+
extra["Content-Type"] ||= "application/json"
|
|
126
|
+
elsif !content.nil?
|
|
127
|
+
body = content
|
|
128
|
+
extra["Content-Type"] ||= content.encoding == Encoding::BINARY ? "application/octet-stream" : "text/plain"
|
|
129
|
+
end
|
|
130
|
+
full = url(path)
|
|
131
|
+
query = encode_params(params)
|
|
132
|
+
full += "?#{query}" unless query.empty?
|
|
133
|
+
Request.new(verb: method, url: full, headers: request_headers(extra), body: body)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def request_headers(extra)
|
|
137
|
+
{
|
|
138
|
+
"Authorization" => "Bearer #{@api_key}",
|
|
139
|
+
"Accept" => "application/json",
|
|
140
|
+
"User-Agent" => "sillydev-ruby/#{VERSION}",
|
|
141
|
+
}.merge(@default_headers.to_h { |k, v| [k.to_s, v.to_s] }).merge(extra)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def encode_params(params)
|
|
145
|
+
pairs = (params || {}).filter_map do |key, value|
|
|
146
|
+
next if value.nil?
|
|
147
|
+
|
|
148
|
+
value = case value
|
|
149
|
+
# Laravel's boolean rule accepts 1 and 0 but not the strings "true" and "false".
|
|
150
|
+
when true then "1"
|
|
151
|
+
when false then "0"
|
|
152
|
+
when Array then value.join(",")
|
|
153
|
+
else value.to_s
|
|
154
|
+
end
|
|
155
|
+
[key.to_s, value]
|
|
156
|
+
end
|
|
157
|
+
URI.encode_www_form(pairs)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def retry?(request, response, attempt)
|
|
161
|
+
return false if attempt >= @max_retries
|
|
162
|
+
|
|
163
|
+
safe = SAFE_METHODS.include?(request.verb) || !request.header("Idempotency-Key").nil?
|
|
164
|
+
(safe ? RETRY_STATUSES : UNSAFE_RETRY_STATUSES).include?(response.status)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def retry_delay(attempt, response)
|
|
168
|
+
if response
|
|
169
|
+
retry_after = Errors.parse_retry_after(response.header("retry-after"))
|
|
170
|
+
return [retry_after, MAX_RETRY_AFTER].min unless retry_after.nil?
|
|
171
|
+
end
|
|
172
|
+
[0.5 * (2**attempt), 8.0].min * rand(0.75..1.25)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def parse(response, raw:, text:)
|
|
176
|
+
raise Errors.for_response(response) unless response.success?
|
|
177
|
+
return response.body.to_s if text
|
|
178
|
+
return nil if response.status == 204 || response.body.nil? || response.body.empty?
|
|
179
|
+
|
|
180
|
+
begin
|
|
181
|
+
body = JSON.parse(response.body)
|
|
182
|
+
rescue JSON::ParserError
|
|
183
|
+
return response.body
|
|
184
|
+
end
|
|
185
|
+
# Endpoints that answer 202 with an empty JSON array (reinstall, run a schedule).
|
|
186
|
+
return nil if body == []
|
|
187
|
+
|
|
188
|
+
raw ? body : Envelope.unwrap(body)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
module Errors
|
|
193
|
+
# Failures after the connection was made: the request may have reached the panel, so they are
|
|
194
|
+
# not retried.
|
|
195
|
+
TRANSPORT_ERRORS = [
|
|
196
|
+
IOError, SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError, Net::ProtocolError, EOFError
|
|
197
|
+
].freeze
|
|
198
|
+
end
|
|
199
|
+
end
|