fly_io 0.1.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/CHANGELOG.md +15 -0
- data/CONTRIBUTING.md +14 -0
- data/LICENSE +21 -0
- data/README.md +239 -0
- data/Rakefile +30 -0
- data/contracts/additional_rest/network_policies.json +94 -0
- data/contracts/additional_rest/prometheus.json +103 -0
- data/contracts/graphql/fly_go_operations.json +948 -0
- data/contracts/graphql/flyctl_named_operations.graphql +316 -0
- data/contracts/graphql/flyctl_operations.json +336 -0
- data/contracts/graphql/official_examples.json +75 -0
- data/contracts/graphql/schema.graphql +10995 -0
- data/contracts/graphql/source.json +29 -0
- data/contracts/machines/openapi.headers +9 -0
- data/contracts/machines/openapi.json +1 -0
- data/contracts/machines/source.json +20 -0
- data/contracts/public_surface_inventory.json +164 -0
- data/contracts/research_metadata.json +17 -0
- data/contracts/sources/metrics.html.md +474 -0
- data/contracts/sources/network-policies.html.markerb +142 -0
- data/docs/API.md +149 -0
- data/docs/SURFACES.md +60 -0
- data/lib/fly_io/client.rb +52 -0
- data/lib/fly_io/configuration.rb +115 -0
- data/lib/fly_io/errors.rb +45 -0
- data/lib/fly_io/generated/additional_operations.json +123 -0
- data/lib/fly_io/generated/graphql_operations.json +1658 -0
- data/lib/fly_io/generated/operations.json +6130 -0
- data/lib/fly_io/generated/prometheus_operations.json +503 -0
- data/lib/fly_io/generated/schemas.json +4237 -0
- data/lib/fly_io/graphql_client.rb +103 -0
- data/lib/fly_io/model.rb +65 -0
- data/lib/fly_io/operation_registry.rb +79 -0
- data/lib/fly_io/redactor.rb +34 -0
- data/lib/fly_io/resources/base.rb +68 -0
- data/lib/fly_io/resources.rb +30 -0
- data/lib/fly_io/response.rb +45 -0
- data/lib/fly_io/schema_registry.rb +89 -0
- data/lib/fly_io/schema_validator.rb +56 -0
- data/lib/fly_io/transport.rb +282 -0
- data/lib/fly_io/version.rb +5 -0
- data/lib/fly_io.rb +23 -0
- data/script/check_api_coverage +60 -0
- data/script/fetch_openapi +18 -0
- data/script/generate_api +302 -0
- metadata +197 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module FlyIO
|
|
9
|
+
class Transport
|
|
10
|
+
METHODS = %i[get head options post put patch delete].freeze
|
|
11
|
+
SAFE_METHODS = %i[get head options].freeze
|
|
12
|
+
RETRYABLE_STATUSES = [408, 429, 500, 502, 503, 504].freeze
|
|
13
|
+
ERROR_CLASSES = {
|
|
14
|
+
400 => ValidationError,
|
|
15
|
+
401 => AuthenticationError,
|
|
16
|
+
403 => AuthorizationError,
|
|
17
|
+
404 => NotFoundError,
|
|
18
|
+
408 => RequestTimeoutError,
|
|
19
|
+
409 => ValidationError,
|
|
20
|
+
410 => NotFoundError,
|
|
21
|
+
412 => ValidationError,
|
|
22
|
+
422 => ValidationError,
|
|
23
|
+
429 => RateLimitError
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
attr_reader :configuration
|
|
27
|
+
|
|
28
|
+
def initialize(configuration)
|
|
29
|
+
@configuration = configuration
|
|
30
|
+
@connection = build_connection unless configuration.adapter.respond_to?(:call)
|
|
31
|
+
freeze
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def request(method:, path:, path_params: {}, query: {}, headers: {}, body: FlyIO::UNSET,
|
|
35
|
+
content_type: "application/json", accept: "application/json", retry_unsafe: nil, operation: nil,
|
|
36
|
+
timeout: nil)
|
|
37
|
+
verb = validate_method(method)
|
|
38
|
+
expanded_path = build_path(path, path_params)
|
|
39
|
+
url = absolute_url(build_url(expanded_path, query))
|
|
40
|
+
request_headers = default_headers(accept).merge(stringify_keys(headers))
|
|
41
|
+
encoded_body = encode_body(body, content_type, request_headers)
|
|
42
|
+
metadata = safe_request_metadata(verb, expanded_path, query, request_headers, body, operation)
|
|
43
|
+
attempts = 0
|
|
44
|
+
|
|
45
|
+
loop do
|
|
46
|
+
begin
|
|
47
|
+
raw_response = perform(verb, url, request_headers, encoded_body, timeout)
|
|
48
|
+
rescue Faraday::TimeoutError
|
|
49
|
+
if attempts < configuration.max_retries && retry_allowed?(verb, retry_unsafe)
|
|
50
|
+
configuration.sleeper.call(retry_delay(attempts, {}))
|
|
51
|
+
attempts += 1
|
|
52
|
+
next
|
|
53
|
+
end
|
|
54
|
+
raise RequestTimeoutError.new("Fly.io API request timed out", request: metadata)
|
|
55
|
+
rescue Faraday::ConnectionFailed, Faraday::SSLError => e
|
|
56
|
+
if attempts < configuration.max_retries && retry_allowed?(verb, retry_unsafe)
|
|
57
|
+
configuration.sleeper.call(retry_delay(attempts, {}))
|
|
58
|
+
attempts += 1
|
|
59
|
+
next
|
|
60
|
+
end
|
|
61
|
+
raise TransportError.new("Fly.io API transport failed: #{e.class}", request: metadata)
|
|
62
|
+
end
|
|
63
|
+
if retry_status?(raw_response.status, verb, retry_unsafe) && attempts < configuration.max_retries
|
|
64
|
+
delay = retry_delay(attempts, raw_response.headers)
|
|
65
|
+
attempts += 1
|
|
66
|
+
configuration.sleeper.call(delay)
|
|
67
|
+
next
|
|
68
|
+
end
|
|
69
|
+
response = build_response(raw_response, metadata, operation)
|
|
70
|
+
raise_api_error(response) unless response.success?
|
|
71
|
+
|
|
72
|
+
log(:debug, "Fly.io API #{verb.to_s.upcase} #{expanded_path} -> #{response.status}", metadata)
|
|
73
|
+
return response
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def build_path(template, path_params = {})
|
|
78
|
+
value = String(template)
|
|
79
|
+
raise FlyIO::ArgumentError, "path must start with /" unless value.start_with?("/")
|
|
80
|
+
if value.match?(%r{\A//|[?#\\\x00]})
|
|
81
|
+
raise FlyIO::ArgumentError,
|
|
82
|
+
"path must not contain a URL, query, fragment, backslash, or NUL"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
provided = stringify_keys(path_params)
|
|
86
|
+
result = value.gsub(/\{([^}]+)\}/) do
|
|
87
|
+
name = Regexp.last_match(1)
|
|
88
|
+
raise FlyIO::ArgumentError, "missing required path parameter: #{name}" unless provided.key?(name)
|
|
89
|
+
|
|
90
|
+
escape_path_component(provided.fetch(name), name)
|
|
91
|
+
end
|
|
92
|
+
unused = provided.keys - value.scan(/\{([^}]+)\}/).flatten
|
|
93
|
+
raise FlyIO::ArgumentError, "unknown path parameters: #{unused.join(', ')}" unless unused.empty?
|
|
94
|
+
if result.split("/").any? { |segment| [".", ".."].include?(URI.decode_www_form_component(segment)) }
|
|
95
|
+
raise FlyIO::ArgumentError, "path traversal segments are not allowed"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
result
|
|
99
|
+
rescue URI::InvalidURIError, ::ArgumentError
|
|
100
|
+
raise FlyIO::ArgumentError, "path contains invalid encoding"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def build_connection
|
|
106
|
+
Faraday.new(url: configuration.base_url, proxy: configuration.proxy) do |faraday|
|
|
107
|
+
faraday.options.open_timeout = configuration.open_timeout
|
|
108
|
+
faraday.options.timeout = configuration.request_timeout || configuration.read_timeout
|
|
109
|
+
faraday.options.read_timeout = configuration.read_timeout if faraday.options.respond_to?(:read_timeout=)
|
|
110
|
+
faraday.options.write_timeout = configuration.write_timeout if faraday.options.respond_to?(:write_timeout=)
|
|
111
|
+
faraday.adapter(configuration.adapter || Faraday.default_adapter)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def validate_method(method)
|
|
116
|
+
value = method.to_s.downcase.to_sym
|
|
117
|
+
raise FlyIO::ArgumentError, "unsupported HTTP method: #{method}" unless METHODS.include?(value)
|
|
118
|
+
|
|
119
|
+
value
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def escape_path_component(value, name)
|
|
123
|
+
string = String(value)
|
|
124
|
+
raise FlyIO::ArgumentError, "path parameter #{name} must not be empty" if string.empty?
|
|
125
|
+
raise FlyIO::ArgumentError, "path parameter #{name} contains NUL" if string.include?("\0")
|
|
126
|
+
|
|
127
|
+
URI.encode_www_form_component(string).gsub("+", "%20")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def build_url(path, query)
|
|
131
|
+
pairs = query_pairs(query)
|
|
132
|
+
pairs.empty? ? path : "#{path}?#{URI.encode_www_form(pairs)}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def absolute_url(relative_url)
|
|
136
|
+
base = URI.parse(configuration.base_url)
|
|
137
|
+
relative = URI.parse(relative_url)
|
|
138
|
+
base_path = base.path.to_s.sub(%r{/+\z}, "")
|
|
139
|
+
base.path = "#{base_path}/#{relative.path.delete_prefix('/')}"
|
|
140
|
+
base.query = relative.query
|
|
141
|
+
base.to_s
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def query_pairs(query)
|
|
145
|
+
raise FlyIO::ArgumentError, "query must be a Hash" unless query.is_a?(Hash)
|
|
146
|
+
|
|
147
|
+
query.each_with_object([]) do |(key, value), pairs|
|
|
148
|
+
next if value.nil?
|
|
149
|
+
|
|
150
|
+
values = value.is_a?(Array) ? value : [value]
|
|
151
|
+
values.each { |item| pairs << [key.to_s, query_value(item)] unless item.nil? }
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def query_value(value)
|
|
156
|
+
case value
|
|
157
|
+
when true then "true"
|
|
158
|
+
when false then "false"
|
|
159
|
+
else String(value)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def default_headers(accept)
|
|
164
|
+
{
|
|
165
|
+
"Accept" => accept,
|
|
166
|
+
"Authorization" => configuration.authorization_value,
|
|
167
|
+
"User-Agent" => configuration.user_agent
|
|
168
|
+
}
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def encode_body(body, content_type, headers)
|
|
172
|
+
return nil if body.equal?(FlyIO::UNSET)
|
|
173
|
+
|
|
174
|
+
headers["Content-Type"] ||= content_type
|
|
175
|
+
serializable = serialize(body)
|
|
176
|
+
content_type.to_s.downcase.include?("json") ? JSON.generate(serializable) : serializable
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def serialize(value)
|
|
180
|
+
case value
|
|
181
|
+
when Model then value.to_h
|
|
182
|
+
when Array then value.map { |item| serialize(item) }
|
|
183
|
+
when Hash then value.transform_keys(&:to_s).transform_values { |item| serialize(item) }
|
|
184
|
+
else value
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def perform(method, url, headers, body, timeout)
|
|
189
|
+
if configuration.adapter.respond_to?(:call)
|
|
190
|
+
return configuration.adapter.call(method: method, url: url,
|
|
191
|
+
headers: headers, body: body, timeout: timeout)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
@connection.run_request(method, url, body, headers) do |request|
|
|
195
|
+
request.options.timeout = timeout if timeout
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def build_response(raw_response, request, operation)
|
|
200
|
+
status = Integer(raw_response.status)
|
|
201
|
+
headers = stringify_keys(raw_response.headers).transform_keys(&:downcase)
|
|
202
|
+
raw_body = raw_response.body
|
|
203
|
+
content_type = headers.fetch("content-type", "").downcase
|
|
204
|
+
parsed = parse_body(raw_body, content_type, status)
|
|
205
|
+
schema = operation&.response_schema(status, content_type)
|
|
206
|
+
body = schema ? SchemaRegistry.coerce(schema, parsed) : parsed
|
|
207
|
+
Response.new(status: status, headers: headers, body: body, raw_body: raw_body, request: request)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def parse_body(body, content_type, status)
|
|
211
|
+
return nil if status == 204 || body.nil? || body == ""
|
|
212
|
+
return body unless content_type.include?("json")
|
|
213
|
+
|
|
214
|
+
JSON.parse(body)
|
|
215
|
+
rescue JSON::ParserError
|
|
216
|
+
body
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def raise_api_error(response)
|
|
220
|
+
klass = ERROR_CLASSES[response.status] || (response.status >= 500 ? ServerError : APIError)
|
|
221
|
+
details = Redactor.redact_object(response.body)
|
|
222
|
+
message = error_message(response.status, details, response.request_id)
|
|
223
|
+
raise klass.new(message, status: response.status, headers: safe_headers(response.headers), details: details,
|
|
224
|
+
request_id: response.request_id, request: response.request)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def error_message(status, details, request_id)
|
|
228
|
+
server_message = details.is_a?(Hash) && (details["error"] || details["message"])
|
|
229
|
+
["Fly.io API request failed (HTTP #{status})", server_message,
|
|
230
|
+
("request_id=#{request_id}" if request_id)].compact.join(": ")
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def retry_status?(status, method, retry_unsafe)
|
|
234
|
+
RETRYABLE_STATUSES.include?(Integer(status)) && retry_allowed?(method, retry_unsafe)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def retry_allowed?(method, retry_unsafe)
|
|
238
|
+
SAFE_METHODS.include?(method) || retry_unsafe == true || (retry_unsafe.nil? && configuration.retry_unsafe)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def retry_delay(attempt, headers)
|
|
242
|
+
retry_after = stringify_keys(headers).find { |key, _| key.downcase == "retry-after" }&.last
|
|
243
|
+
parsed = parse_retry_after(retry_after)
|
|
244
|
+
return [parsed, configuration.max_retry_interval].min if parsed
|
|
245
|
+
|
|
246
|
+
base = [configuration.base_retry_interval * (2**attempt), configuration.max_retry_interval].min
|
|
247
|
+
[base + (base * configuration.retry_jitter * configuration.random.rand), configuration.max_retry_interval].min
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def parse_retry_after(value)
|
|
251
|
+
return if value.nil?
|
|
252
|
+
return [Float(value), 0].max if value.to_s.match?(/\A\d+(?:\.\d+)?\z/)
|
|
253
|
+
|
|
254
|
+
[Time.httpdate(value.to_s) - Time.now, 0].max
|
|
255
|
+
rescue ::ArgumentError
|
|
256
|
+
nil
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def safe_request_metadata(method, path, query, headers, body, operation)
|
|
260
|
+
{
|
|
261
|
+
method: method,
|
|
262
|
+
path: path,
|
|
263
|
+
query: Redactor.redact_object(query),
|
|
264
|
+
headers: safe_headers(headers),
|
|
265
|
+
body: Redactor.redact_object(body.equal?(FlyIO::UNSET) ? nil : serialize(body)),
|
|
266
|
+
operation_id: operation&.operation_id
|
|
267
|
+
}.freeze
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def safe_headers(headers)
|
|
271
|
+
Redactor.redact_object(stringify_keys(headers))
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def stringify_keys(hash)
|
|
275
|
+
hash.to_h.transform_keys(&:to_s)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def log(level, message, metadata)
|
|
279
|
+
configuration.logger&.public_send(level, "#{message} #{metadata.inspect}")
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
data/lib/fly_io.rb
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "fly_io/version"
|
|
4
|
+
require_relative "fly_io/errors"
|
|
5
|
+
require_relative "fly_io/redactor"
|
|
6
|
+
require_relative "fly_io/configuration"
|
|
7
|
+
require_relative "fly_io/model"
|
|
8
|
+
require_relative "fly_io/schema_registry"
|
|
9
|
+
require_relative "fly_io/schema_validator"
|
|
10
|
+
require_relative "fly_io/response"
|
|
11
|
+
require_relative "fly_io/operation_registry"
|
|
12
|
+
require_relative "fly_io/transport"
|
|
13
|
+
require_relative "fly_io/resources/base"
|
|
14
|
+
require_relative "fly_io/resources"
|
|
15
|
+
require_relative "fly_io/client"
|
|
16
|
+
require_relative "fly_io/graphql_client"
|
|
17
|
+
|
|
18
|
+
module FlyIO
|
|
19
|
+
UNSET = Object.new.freeze
|
|
20
|
+
|
|
21
|
+
SchemaRegistry.install_model_constants!
|
|
22
|
+
Resources.install!
|
|
23
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require "digest"
|
|
6
|
+
|
|
7
|
+
ROOT = File.expand_path("..", __dir__)
|
|
8
|
+
abort unless system(RbConfig.ruby, File.join(ROOT, "script/generate_api"), "--check")
|
|
9
|
+
|
|
10
|
+
additional = JSON.parse(File.read(File.join(ROOT, "lib/fly_io/generated/additional_operations.json")))
|
|
11
|
+
abort "Network Policies coverage drift" unless additional.fetch("operations").length == 3
|
|
12
|
+
puts "Network Policies REST coverage OK: 3 documentation-derived operations (response contracts undocumented upstream)"
|
|
13
|
+
network_source_hash = Digest::SHA256.file(File.join(ROOT, "contracts/sources/network-policies.html.markerb")).hexdigest
|
|
14
|
+
abort "Network Policies source drift" unless network_source_hash == additional.fetch("generated_from").fetch("source_sha256")
|
|
15
|
+
|
|
16
|
+
prometheus = JSON.parse(File.read(File.join(ROOT, "lib/fly_io/generated/prometheus_operations.json")))
|
|
17
|
+
abort "Prometheus endpoint-family coverage drift" unless prometheus.fetch("operations").length == 7
|
|
18
|
+
puts "Prometheus API coverage OK: 7 documented endpoint families (standard contract delegated upstream)"
|
|
19
|
+
metrics_source_hash = Digest::SHA256.file(File.join(ROOT, "contracts/sources/metrics.html.md")).hexdigest
|
|
20
|
+
abort "Prometheus source drift" unless metrics_source_hash == prometheus.fetch("generated_from").fetch("source_sha256")
|
|
21
|
+
|
|
22
|
+
graphql_path = File.join(ROOT, "lib/fly_io/generated/graphql_operations.json")
|
|
23
|
+
graphql = JSON.parse(File.read(graphql_path))
|
|
24
|
+
operations = graphql.fetch("operations")
|
|
25
|
+
names = operations.map { |operation| operation.fetch("name") }
|
|
26
|
+
abort "duplicate GraphQL operation names" unless names.uniq.length == names.length
|
|
27
|
+
operations.each do |operation|
|
|
28
|
+
abort "GraphQL operation #{operation['name']} lacks a document" if operation.fetch("document").strip.empty?
|
|
29
|
+
abort "GraphQL operation #{operation['name']} is not experimental" unless operation.fetch("stability") == "experimental"
|
|
30
|
+
end
|
|
31
|
+
counts = graphql.fetch("counts")
|
|
32
|
+
abort "GraphQL inventory count drift" unless counts == {
|
|
33
|
+
"flyctl_named" => 27, "flyctl_ad_hoc" => 2, "fly_go" => 85, "official_examples" => 3, "total" => 117
|
|
34
|
+
}
|
|
35
|
+
schema_path = File.join(ROOT, "contracts/graphql/schema.graphql")
|
|
36
|
+
schema_hash = Digest::SHA256.file(schema_path).hexdigest
|
|
37
|
+
abort "GraphQL evidence schema drift" unless schema_hash == graphql.fetch("schema").fetch("sha256")
|
|
38
|
+
schema_document = File.read(schema_path)
|
|
39
|
+
root_fields = {"query" => "Queries", "mutation" => "Mutations"}.to_h do |kind, type_name|
|
|
40
|
+
block = schema_document.match(/^type #{type_name} \{$(.*?)^\}/m)&.captures&.first
|
|
41
|
+
abort "GraphQL evidence schema lacks #{type_name}" unless block
|
|
42
|
+
[kind, block.scan(/^ ([A-Za-z_]\w*)\s*(?:\(|:)/).flatten]
|
|
43
|
+
end
|
|
44
|
+
abort "GraphQL Query root count drift" unless root_fields.fetch("query").length == 39
|
|
45
|
+
abort "GraphQL Mutation root count drift" unless root_fields.fetch("mutation").length == 141
|
|
46
|
+
operations.each do |operation|
|
|
47
|
+
missing = operation.fetch("root_fields") - root_fields.fetch(operation.fetch("operation_type"))
|
|
48
|
+
abort "GraphQL operation #{operation['name']} has unknown roots: #{missing.join(', ')}" unless missing.empty?
|
|
49
|
+
end
|
|
50
|
+
graphql_source = JSON.parse(File.read(File.join(ROOT, "contracts/graphql/source.json")))
|
|
51
|
+
{
|
|
52
|
+
"flyctl_operations.json" => graphql_source.dig("flyctl", "inventory_sha256"),
|
|
53
|
+
"fly_go_operations.json" => graphql_source.dig("fly_go", "inventory_sha256"),
|
|
54
|
+
"official_examples.json" => graphql_source.dig("official_examples", "inventory_sha256"),
|
|
55
|
+
"flyctl_named_operations.graphql" => graphql_source.dig("flyctl", "named_documents_sha256")
|
|
56
|
+
}.each do |name, expected|
|
|
57
|
+
actual = Digest::SHA256.file(File.join(ROOT, "contracts/graphql", name)).hexdigest
|
|
58
|
+
abort "GraphQL source drift: #{name}" unless actual == expected
|
|
59
|
+
end
|
|
60
|
+
puts "GraphQL audit OK: 29 flyctl + 85 fly-go + 3 official experimental operations; raw documents supported"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "digest"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
url = URI("https://docs.machines.dev/openapi.json")
|
|
9
|
+
response = Net::HTTP.get_response(url)
|
|
10
|
+
abort "OpenAPI fetch failed: HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
11
|
+
|
|
12
|
+
snapshot = File.binread(File.expand_path("../contracts/machines/openapi.json", __dir__))
|
|
13
|
+
expected = Digest::SHA256.hexdigest(snapshot)
|
|
14
|
+
actual = Digest::SHA256.hexdigest(response.body)
|
|
15
|
+
abort "upstream OpenAPI drift: committed=#{expected} upstream=#{actual}" unless expected == actual
|
|
16
|
+
|
|
17
|
+
puts "OpenAPI upstream matches committed snapshot: sha256=#{actual} " \
|
|
18
|
+
"etag=#{response['etag'].inspect} last-modified=#{response['last-modified'].inspect}"
|