ubicloud 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/MIT-LICENSE +21 -0
- data/lib/ubicloud/adapter/net_http.rb +55 -0
- data/lib/ubicloud/adapter/rack.rb +41 -0
- data/lib/ubicloud/adapter.rb +62 -0
- data/lib/ubicloud/context.rb +53 -0
- data/lib/ubicloud/model/firewall.rb +60 -0
- data/lib/ubicloud/model/load_balancer.rb +53 -0
- data/lib/ubicloud/model/postgres.rb +73 -0
- data/lib/ubicloud/model/private_subnet.rb +29 -0
- data/lib/ubicloud/model/vm.rb +27 -0
- data/lib/ubicloud/model.rb +293 -0
- data/lib/ubicloud/model_adapter.rb +25 -0
- data/lib/ubicloud.rb +48 -0
- metadata +60 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 3f801ab2b4e1c51db702928710d8ad3620f24148f9a9ff010b56261f5b5d97a6
|
|
4
|
+
data.tar.gz: bab2944336ed68b238ea7e388c2a6b50615d48b94f9ea2c641bb776577f2df7f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 28a31228bd129ed12feaa7f318a60bf377c7798efc1419c1d7e898e5a13a2a723ed453c4b556d69d64687a08e35c56c512171b5b7db6656b407be9c9de6b43fc
|
|
7
|
+
data.tar.gz: 1a9a39683f664ae62b77505da081617ecc38a8a2864aade55c6617d1ae5812423521d272739d865a11ea4b23d1c5dd6ea5f0e4f2aa0d963e96ed45d0f340f3a2
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Ubicloud's Ruby SDK is released under the MIT license (the rest of Ubicloud
|
|
2
|
+
is released under the AGPL license).
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2025 Ubicloud, Inc.
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to
|
|
8
|
+
deal in the Software without restriction, including without limitation the
|
|
9
|
+
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
|
10
|
+
sell copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in
|
|
14
|
+
all copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
|
19
|
+
THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
20
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
21
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require "net/http"
|
|
5
|
+
|
|
6
|
+
module Ubicloud
|
|
7
|
+
# Ubicloud::Adapter::NetHttp is the recommended adapter for general use.
|
|
8
|
+
# It uses the net/http standard library to make requests to the Ubicloud API.
|
|
9
|
+
class Adapter::NetHttp < Adapter
|
|
10
|
+
# Set the token and project_id to use for requests. The base_uri argument
|
|
11
|
+
# can be used to access a self-hosted Ubicloud instance (or other Ubicloud
|
|
12
|
+
# instance not hosted by Ubicloud).
|
|
13
|
+
def initialize(token:, project_id:, base_uri: "https://api.ubicloud.com/")
|
|
14
|
+
@base_uri = URI.join(URI(base_uri), "project/#{project_id}/")
|
|
15
|
+
@headers = {
|
|
16
|
+
"authorization" => "Bearer: #{token}",
|
|
17
|
+
"content-type" => "application/json",
|
|
18
|
+
"accept" => "text/plain",
|
|
19
|
+
"connection" => "close"
|
|
20
|
+
}.freeze
|
|
21
|
+
@get_headers = @headers.dup
|
|
22
|
+
@get_headers.delete("content-type")
|
|
23
|
+
@get_headers.freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
METHOD_MAP = {
|
|
27
|
+
"GET" => :get,
|
|
28
|
+
"POST" => :post,
|
|
29
|
+
"DELETE" => :delete,
|
|
30
|
+
"PATCH" => :patch
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
private_constant :METHOD_MAP
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# Use Net::HTTP to submit a request to the Ubicloud API.
|
|
38
|
+
def call(method, path, params: nil, missing: :raise)
|
|
39
|
+
Net::HTTP.start(@base_uri.hostname, @base_uri.port, use_ssl: @base_uri.scheme == "https") do |http|
|
|
40
|
+
path = URI.join(@base_uri, path).path
|
|
41
|
+
|
|
42
|
+
response = case method
|
|
43
|
+
when "GET"
|
|
44
|
+
http.get(path, @get_headers)
|
|
45
|
+
when "DELETE"
|
|
46
|
+
http.delete(path, @headers)
|
|
47
|
+
else
|
|
48
|
+
http.send(METHOD_MAP.fetch(method), path, params&.to_json, @headers)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
handle_response(response.code.to_i, response.body, missing:)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ubicloud
|
|
4
|
+
# Ubicloud::Adapter::Rack is designed for internal use of the
|
|
5
|
+
# Ruby SDK by Ubicloud itself. It is used inside Ubicloud to
|
|
6
|
+
# issue internal requests when handling CLI commands. A new
|
|
7
|
+
# instance of Ubicloud::Adapter::Rack is created for each
|
|
8
|
+
# CLI command.
|
|
9
|
+
class Adapter::Rack < Adapter
|
|
10
|
+
# Accept the rack application (Clover), request env of the CLI request,
|
|
11
|
+
# and related project id.
|
|
12
|
+
def initialize(app:, env:, project_id:)
|
|
13
|
+
@app = app
|
|
14
|
+
@env = env
|
|
15
|
+
@project_id = project_id
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
# Create a new rack request enviroment hash for the internal
|
|
21
|
+
# request, and call the rack application with it.
|
|
22
|
+
def call(method, path, params: nil, missing: :raise)
|
|
23
|
+
env = @env.merge(
|
|
24
|
+
"REQUEST_METHOD" => method,
|
|
25
|
+
"PATH_INFO" => "/project/#{@project_id}/#{path}",
|
|
26
|
+
"rack.request.form_input" => nil,
|
|
27
|
+
"rack.request.form_hash" => nil
|
|
28
|
+
)
|
|
29
|
+
params &&= params.to_json.force_encoding(Encoding::BINARY)
|
|
30
|
+
env["rack.input"] = StringIO.new(params || "".b)
|
|
31
|
+
env.delete("roda.json_params")
|
|
32
|
+
|
|
33
|
+
status, _, rack_body = @app.call(env)
|
|
34
|
+
body = +""
|
|
35
|
+
rack_body.each { body << _1 }
|
|
36
|
+
rack_body.close if rack_body.respond_to?(:close)
|
|
37
|
+
|
|
38
|
+
handle_response(status, body, missing:)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ubicloud
|
|
6
|
+
# Ubicloud::Adapter is the base class for adapters used in Ubicloud's Ruby SDK.
|
|
7
|
+
# Ubicloud's Ruby SDK uses adapters to allow for separate ways to access
|
|
8
|
+
# Ubicloud's API. Currently, the :net_http adapter is the recommended adapter.
|
|
9
|
+
#
|
|
10
|
+
# Ubicloud::Adapter subclasses must implement +call+ to handle sending the
|
|
11
|
+
# request. They should call +handle_response+ to handle responses to the
|
|
12
|
+
# request.
|
|
13
|
+
class Adapter
|
|
14
|
+
ADAPTERS = {
|
|
15
|
+
net_http: :NetHttp,
|
|
16
|
+
rack: :Rack
|
|
17
|
+
}.freeze
|
|
18
|
+
private_constant :ADAPTERS
|
|
19
|
+
|
|
20
|
+
# Require the related adapter file, and return the related adapter.
|
|
21
|
+
def self.adapter_class(adapter_type)
|
|
22
|
+
require_relative "adapter/#{adapter_type}"
|
|
23
|
+
const_get(ADAPTERS.fetch(adapter_type))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Issue a GET request to the API for the given path.
|
|
27
|
+
def get(path, missing: :raise)
|
|
28
|
+
call("GET", path, missing:)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Issue a GET request to the API for the given path and parameters.
|
|
32
|
+
def post(path, params = nil)
|
|
33
|
+
call("POST", path, params:)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Issue a DELETE request to the API for the given path.
|
|
37
|
+
def delete(path)
|
|
38
|
+
call("DELETE", path)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Issue a PATCH request to the API for the given path and parameters.
|
|
42
|
+
def patch(path, params = nil)
|
|
43
|
+
call("PATCH", path, params:)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# Handle responses to the requests made the library. Non-200/204
|
|
49
|
+
# are treated as errors and result in an Ubicloud::Error being raised.
|
|
50
|
+
def handle_response(code, body, missing: :raise)
|
|
51
|
+
case code
|
|
52
|
+
when 204
|
|
53
|
+
nil
|
|
54
|
+
when 200
|
|
55
|
+
JSON.parse(body, symbolize_names: true)
|
|
56
|
+
else
|
|
57
|
+
return if code == 404 && missing.nil?
|
|
58
|
+
raise Error.new("unsuccessful response", code:, body:)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ubicloud
|
|
4
|
+
# Ubicloud::Context instances are the root object used in Ubicloud's Ruby
|
|
5
|
+
# SDK. They provide access to the models, using the configured adapter.
|
|
6
|
+
#
|
|
7
|
+
# The following instance methods are defined via metaprogramming. All
|
|
8
|
+
# return instances of Ubicloud::ModelAdapter, for the related model.
|
|
9
|
+
#
|
|
10
|
+
# +firewall+ :: Ubicloud::Firewall
|
|
11
|
+
# +load_balancer+ :: Ubicloud::LoadBalancer
|
|
12
|
+
# +postgres+ :: Ubicloud::Postgres
|
|
13
|
+
# +private_subnet+ :: Ubicloud::PrivateSubnet
|
|
14
|
+
# +vm+ :: Ubicloud::Vm
|
|
15
|
+
class Context
|
|
16
|
+
def initialize(adapter)
|
|
17
|
+
@adapter = adapter
|
|
18
|
+
@models = {}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
{
|
|
22
|
+
vm: Vm,
|
|
23
|
+
postgres: Postgres,
|
|
24
|
+
firewall: Firewall,
|
|
25
|
+
private_subnet: PrivateSubnet,
|
|
26
|
+
load_balancer: LoadBalancer
|
|
27
|
+
}.each do |meth, model|
|
|
28
|
+
define_method(meth) { @models[meth] ||= ModelAdapter.new(model, @adapter) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
MODEL_PREFIX_MAP = {
|
|
32
|
+
"vm" => Vm,
|
|
33
|
+
"pg" => Postgres,
|
|
34
|
+
"fw" => Firewall,
|
|
35
|
+
"ps" => PrivateSubnet,
|
|
36
|
+
"1b" => LoadBalancer
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
# Return a new model instance for the given id, assuming the id is properly
|
|
40
|
+
# formatted. Returns nil if the id is not properly formatted. Does not
|
|
41
|
+
# check with \Ubicloud to determine whether the object actually exists.
|
|
42
|
+
def new(id)
|
|
43
|
+
if id.is_a?(String) && (model = MODEL_PREFIX_MAP[id[0, 2]]) && model.id_regexp.match?(id)
|
|
44
|
+
model.new(@adapter, id)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The same as #new, but checks that the object exists and you have access to it.
|
|
49
|
+
def [](id)
|
|
50
|
+
new(id)&.check_exists
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ubicloud
|
|
4
|
+
class Firewall < Model
|
|
5
|
+
set_prefix "fw"
|
|
6
|
+
|
|
7
|
+
set_fragment "firewall"
|
|
8
|
+
|
|
9
|
+
set_columns :id, :name, :description, :location, :firewall_rules, :path, :private_subnets
|
|
10
|
+
|
|
11
|
+
set_associations do
|
|
12
|
+
{private_subnets: PrivateSubnet}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Allow the given cidr (ip address range) access to the given port range.
|
|
16
|
+
#
|
|
17
|
+
# * If +start_port+ and +end_port+ are both given, they specify the port range.
|
|
18
|
+
# * If only +start_port+ is given, only that single port is allowed.
|
|
19
|
+
# * If only +end_port+ is given, all ports up to that end port are allowed.
|
|
20
|
+
# * If neither +start_port+ and +end_port+ are given, all ports are allowed.
|
|
21
|
+
#
|
|
22
|
+
# Returns a hash for the firewall rule.
|
|
23
|
+
def add_rule(cidr, start_port: nil, end_port: nil)
|
|
24
|
+
rule = adapter.post(_path("/firewall-rule"), cidr:, port_range: "#{start_port || 0}..#{end_port || start_port || 65535}")
|
|
25
|
+
|
|
26
|
+
self[:firewall_rules]&.<<(rule)
|
|
27
|
+
|
|
28
|
+
rule
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Delete the firewall rule with the given id. Returns nil.
|
|
32
|
+
def delete_rule(rule_id)
|
|
33
|
+
check_no_slash(rule_id, "invalid rule id format")
|
|
34
|
+
adapter.delete(_path("/firewall-rule/#{rule_id}"))
|
|
35
|
+
|
|
36
|
+
self[:firewall_rules]&.delete_if { _1[:id] == rule_id }
|
|
37
|
+
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Attach the given private subnet to the firewall. Accepts either a PrivateSubnet instance
|
|
42
|
+
# or a private subnet id string. Returns a PrivateSubnet instance.
|
|
43
|
+
def attach_subnet(subnet)
|
|
44
|
+
subnet_action(subnet, "/attach-subnet")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Detach the given private subnet from the firewall. Accepts either a PrivateSubnet instance
|
|
48
|
+
# or a private subnet id string. Returns a PrivateSubnet instance.
|
|
49
|
+
def detach_subnet(subnet)
|
|
50
|
+
subnet_action(subnet, "/detach-subnet")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# Internals of attach_subnet/detach_subnet.
|
|
56
|
+
def subnet_action(subnet, action)
|
|
57
|
+
PrivateSubnet.new(adapter, adapter.post(_path(action), private_subnet_id: to_id(subnet)))
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ubicloud
|
|
4
|
+
class LoadBalancer < Model
|
|
5
|
+
set_prefix "1b"
|
|
6
|
+
|
|
7
|
+
set_fragment "load-balancer"
|
|
8
|
+
|
|
9
|
+
set_columns :id, :name, :location, :hostname, :algorithm, :stack, :health_check_endpoint, :health_check_protocol, :src_port, :dst_port, :subnet, :vms
|
|
10
|
+
|
|
11
|
+
set_associations do
|
|
12
|
+
{
|
|
13
|
+
subnet: PrivateSubnet,
|
|
14
|
+
vms: Vm
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
set_create_param_defaults do |params|
|
|
19
|
+
params[:algorithm] ||= "round_robin"
|
|
20
|
+
params[:stack] ||= "dual"
|
|
21
|
+
params[:health_check_protocol] ||= "http"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Update the receiver with new parameters. Returns self.
|
|
25
|
+
#
|
|
26
|
+
# The +vms+ argument should be an array of virtual machines attached to the load
|
|
27
|
+
# balancer. The method will attach and detach virtual machines to the load
|
|
28
|
+
# balancer as needed so that the list of attached virtual machines matches the
|
|
29
|
+
# array given.
|
|
30
|
+
def update(algorithm:, src_port:, dst_port:, health_check_endpoint:, vms:)
|
|
31
|
+
merge_into_values(adapter.patch(_path, algorithm:, src_port:, dst_port:, health_check_endpoint:, vms:))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Attach the given virtual machine to the firewall. Accepts either a Vm instance
|
|
35
|
+
# or a virtual machine id string. Returns a Vm instance.
|
|
36
|
+
def attach_vm(vm)
|
|
37
|
+
vm_action(vm, "/attach-vm")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Detach the given virtual machine from the firewall. Accepts either a Vm instance
|
|
41
|
+
# or a virtual machine id string. Returns a Vm instance.
|
|
42
|
+
def detach_vm(vm)
|
|
43
|
+
vm_action(vm, "/detach-vm")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# Internals of attach_vm/detach_vm
|
|
49
|
+
def vm_action(vm, action)
|
|
50
|
+
Vm.new(adapter, adapter.post(_path(action), vm_id: to_id(vm)))
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ubicloud
|
|
4
|
+
class Postgres < Model
|
|
5
|
+
set_prefix "pg"
|
|
6
|
+
|
|
7
|
+
set_fragment "postgres"
|
|
8
|
+
|
|
9
|
+
set_columns :id, :name, :state, :location, :vm_size, :storage_size_gib, :version, :ha_type, :flavor, :ca_certificates, :connection_string, :primary, :firewall_rules, :metric_destinations
|
|
10
|
+
|
|
11
|
+
set_create_param_defaults do |params|
|
|
12
|
+
params[:size] ||= "standard-2"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Schedule a restart of the PostgreSQL server. Returns self.
|
|
16
|
+
def restart
|
|
17
|
+
merge_into_values(adapter.post(_path("/restart")))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Allow the given cidr (ip address range) access to the PostgreSQL database port (5432)
|
|
21
|
+
# for this database. Returns a hash for the firewall rule.
|
|
22
|
+
def add_firewall_rule(cidr)
|
|
23
|
+
rule = adapter.post(_path("/firewall-rule"), cidr:)
|
|
24
|
+
|
|
25
|
+
self[:firewall_rules]&.<<(rule)
|
|
26
|
+
|
|
27
|
+
rule
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Delete the firewall rule with the given id. Returns nil.
|
|
31
|
+
def delete_firewall_rule(rule_id)
|
|
32
|
+
check_no_slash(rule_id, "invalid rule id format")
|
|
33
|
+
adapter.delete(_path("/firewall-rule/#{rule_id}"))
|
|
34
|
+
|
|
35
|
+
self[:firewall_rules]&.delete_if { _1[:id] == rule_id }
|
|
36
|
+
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Add a metric destination for this database with the given username, password,
|
|
41
|
+
# and URL. Returns a hash for the metric destination.
|
|
42
|
+
def add_metric_destination(username:, password:, url:)
|
|
43
|
+
md = adapter.post(_path("/metric-destination"), username:, password:, url:)
|
|
44
|
+
|
|
45
|
+
self[:metric_destinations]&.<<(md)
|
|
46
|
+
|
|
47
|
+
md
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Delete the metric destination with the given id. Returns nil.
|
|
51
|
+
def delete_metric_destination(md_id)
|
|
52
|
+
check_no_slash(md_id, "invalid metric destination id format")
|
|
53
|
+
adapter.delete(_path("/metric-destination/#{md_id}"))
|
|
54
|
+
|
|
55
|
+
self[:metric_destinations]&.delete_if { _1[:id] == md_id }
|
|
56
|
+
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Schedule a password reset for the database superuser (postgres) for the database.
|
|
61
|
+
# Returns self.
|
|
62
|
+
def reset_superuser_password(password)
|
|
63
|
+
merge_into_values(adapter.post(_path("/reset-superuser-password"), password:))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Schedule a restore of the database at the given restore_target time, to a new
|
|
67
|
+
# database with the given name. Returns a Postgres instance for the restored
|
|
68
|
+
# database.
|
|
69
|
+
def restore(name:, restore_target:)
|
|
70
|
+
Postgres.new(adapter, adapter.post(_path("/restore"), name:, restore_target:))
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ubicloud
|
|
4
|
+
class PrivateSubnet < Model
|
|
5
|
+
set_prefix "ps"
|
|
6
|
+
|
|
7
|
+
set_fragment "private-subnet"
|
|
8
|
+
|
|
9
|
+
set_columns :id, :name, :state, :location, :net4, :net6, :firewalls, :nics
|
|
10
|
+
|
|
11
|
+
set_associations do
|
|
12
|
+
{firewalls: Firewall}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Connect the given private subnet to the receiver. Accepts either a PrivateSubnet instance
|
|
16
|
+
# or a private subnet id string. Returns self.
|
|
17
|
+
def connect(subnet)
|
|
18
|
+
merge_into_values(adapter.post(_path("/connect"), "connected-subnet-ubid": to_id(subnet)))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Disconnect the given private subnet from the receiver. Accepts either a PrivateSubnet instance
|
|
22
|
+
# or a private subnet id string. Returns self.
|
|
23
|
+
def disconnect(subnet)
|
|
24
|
+
subnet = to_id(subnet)
|
|
25
|
+
check_no_slash(subnet, "invalid private subnet id format")
|
|
26
|
+
merge_into_values(adapter.post(_path("/disconnect/#{subnet}")))
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ubicloud
|
|
4
|
+
class Vm < Model
|
|
5
|
+
set_prefix "vm"
|
|
6
|
+
|
|
7
|
+
set_fragment "vm"
|
|
8
|
+
|
|
9
|
+
set_columns :id, :name, :state, :location, :size, :unix_user, :storage_size_gib, :ip6, :ip4_enabled, :ip4, :firewalls, :private_ipv4, :private_ipv6, :subnet
|
|
10
|
+
|
|
11
|
+
set_associations do
|
|
12
|
+
{
|
|
13
|
+
firewalls: Firewall,
|
|
14
|
+
subnet: PrivateSubnet
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
set_create_param_defaults do |params|
|
|
19
|
+
params[:public_key] = params[:public_key]&.gsub(/(?<!\r)\n/, "\r\n")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Schedule a restart of the virtual machine. Returns self.
|
|
23
|
+
def restart
|
|
24
|
+
merge_into_values(adapter.post(_path("/restart")))
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ubicloud
|
|
4
|
+
# Ubicloud::Model is the abstract base class for model classes. There is a
|
|
5
|
+
# separate model class for each primary object type in Ubicloud's API.
|
|
6
|
+
class Model
|
|
7
|
+
class << self
|
|
8
|
+
# A hash of associations for the model. This is used by instances
|
|
9
|
+
# to automatically wrap returned objects in model instances.
|
|
10
|
+
attr_reader :associations
|
|
11
|
+
|
|
12
|
+
# The path fragment for this model in the Ubicloud API.
|
|
13
|
+
attr_reader :fragment
|
|
14
|
+
|
|
15
|
+
# A regexp for valid id format for instances of this model.
|
|
16
|
+
attr_reader :id_regexp
|
|
17
|
+
|
|
18
|
+
# Return a new model instance for the given values, tied to the
|
|
19
|
+
# related adapter, if the model instance exists and is accessible.
|
|
20
|
+
# Return nil if the model instance does not exist or is not
|
|
21
|
+
# accessible. +values+ can be:
|
|
22
|
+
#
|
|
23
|
+
# * a string in a valid id format for the model
|
|
24
|
+
# * a string in location/name format
|
|
25
|
+
# * a hash of values (must contain either :id key or :location and :name keys)
|
|
26
|
+
def [](adapter, values)
|
|
27
|
+
new(adapter, values).check_exists
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Create a new model object in Ubicloud with the given location, name, and params.
|
|
31
|
+
def create(adapter, location:, name:, **params)
|
|
32
|
+
new(adapter, adapter.post("location/#{location}/#{fragment}/#{name}", _create_params(params)))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Return an array of all model instances you have access to in Ubicloud. If the
|
|
36
|
+
# +location+ keyword argument is given, only return model instances for that location.
|
|
37
|
+
def list(adapter, location: nil)
|
|
38
|
+
path = if location
|
|
39
|
+
raise Error, "invalid location: #{location.inspect}" if location.include?("/")
|
|
40
|
+
"location/#{location}/#{fragment}"
|
|
41
|
+
else
|
|
42
|
+
fragment
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
adapter.get(path)[:items].map { new(adapter, _1) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Resolve associations. This is called after all models have been loaded.
|
|
49
|
+
# This approach is taken to avoid the need for autoload or const_get.
|
|
50
|
+
def resolve_associations # :nodoc:
|
|
51
|
+
@associations = @association_block&.call || {}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Used by models to set defaults for parameters. Overrides the _create_params
|
|
57
|
+
# method using the given block. The block should mutate the parameter hash.
|
|
58
|
+
def set_create_param_defaults
|
|
59
|
+
singleton_class.send(:private, define_singleton_method(:_create_params) do |params|
|
|
60
|
+
params = params.dup || {}
|
|
61
|
+
yield params
|
|
62
|
+
params
|
|
63
|
+
end)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The parameters to use when creating an object. Uses only the given parameters
|
|
67
|
+
# by default.
|
|
68
|
+
def _create_params(params)
|
|
69
|
+
params
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Register the assocation block that will be used for resolving associations.
|
|
73
|
+
def set_associations(&block)
|
|
74
|
+
@association_block = block
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Create methods for each of the model's columns (unless the method is already defined).
|
|
78
|
+
# These methods will fully populate the object if the related key is not already present
|
|
79
|
+
# in the model.
|
|
80
|
+
def set_columns(*columns)
|
|
81
|
+
columns.each do |column|
|
|
82
|
+
next if method_defined?(column)
|
|
83
|
+
define_method(column) do
|
|
84
|
+
info unless @values.has_key?(column)
|
|
85
|
+
@values[column]
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Use the given regexp to set the valid id_regexp format for the model.
|
|
91
|
+
def set_prefix(prefix)
|
|
92
|
+
@id_regexp = %r{\A#{prefix}[a-tv-z0-9]{24}\z}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Set the path fragment that this model uses in the Ubicloud API.
|
|
96
|
+
def set_fragment(fragment)
|
|
97
|
+
@fragment = fragment
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Return the adapter used for this model instance. Each model instance is tied to a
|
|
102
|
+
# specific adapter, and requests to the Ubicloud API are made through the adapter.
|
|
103
|
+
attr_reader :adapter
|
|
104
|
+
|
|
105
|
+
# A hash of values for the model instance.
|
|
106
|
+
attr_reader :values
|
|
107
|
+
|
|
108
|
+
# Create a new model instance, which should represent an object that already exists
|
|
109
|
+
# in Ubicloud. +values+ can be:
|
|
110
|
+
#
|
|
111
|
+
# * a string in a valid id format for the model
|
|
112
|
+
# * a string in location/name format
|
|
113
|
+
# * a hash with symbol keys (must contain either :id key or :location and :name keys)
|
|
114
|
+
def initialize(adapter, values)
|
|
115
|
+
@adapter = adapter
|
|
116
|
+
|
|
117
|
+
case values
|
|
118
|
+
when String
|
|
119
|
+
@values = if self.class.id_regexp.match?(values)
|
|
120
|
+
{id: values}
|
|
121
|
+
else
|
|
122
|
+
location, name, extra = values.split("/", 3)
|
|
123
|
+
raise Error, "invalid #{self.class.fragment} location/name: #{values.inspect}" if extra || !name
|
|
124
|
+
{location:, name:}
|
|
125
|
+
end
|
|
126
|
+
when Hash
|
|
127
|
+
if !values[:id] && !(values[:location] && values[:name])
|
|
128
|
+
raise Error, "hash must have :id key or :location and :name keys"
|
|
129
|
+
end
|
|
130
|
+
@values = {}
|
|
131
|
+
merge_into_values(values)
|
|
132
|
+
else
|
|
133
|
+
raise Error, "unsupported value initializing #{self.class}: #{values.inspect}"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Return hash of data for this model instance.
|
|
138
|
+
def to_h
|
|
139
|
+
@values
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Return the value of a specific key for the model instance.
|
|
143
|
+
def [](key)
|
|
144
|
+
@values[key]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Destroy the given model instance in Ubicloud. It is not possible to restore
|
|
148
|
+
# objects that have been destroyed, so only use this if you are sure you want
|
|
149
|
+
# to destroy the object.
|
|
150
|
+
def destroy
|
|
151
|
+
adapter.delete(_path)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The model's id, which will be a 26 character string. This will load the
|
|
155
|
+
# id from Ubicloud if the model instance doesn't currently store the id
|
|
156
|
+
# (such as when it was initialized with a location and name).
|
|
157
|
+
def id
|
|
158
|
+
unless (id = @values[:id])
|
|
159
|
+
info
|
|
160
|
+
id = @values[:id]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
id
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# The model's location, as a string. This will load the location from Ubicloud
|
|
167
|
+
# if the model instance does not currently store it (such as when it was
|
|
168
|
+
# initialized with an id).
|
|
169
|
+
def location
|
|
170
|
+
unless (location = @values[:location])
|
|
171
|
+
load_object_info_from_id
|
|
172
|
+
location = @values[:location]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
location
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# The model's name. This will load the name from Ubicloud if the model instance
|
|
179
|
+
# does not currently store it (such as when it was initialized with an id).
|
|
180
|
+
def name
|
|
181
|
+
unless (name = @values[:name])
|
|
182
|
+
load_object_info_from_id
|
|
183
|
+
name = @values[:name]
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
name
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Fully populate the model instance by making a request to the Ubicloud API.
|
|
190
|
+
# This can also be used to refresh an already populated instance.
|
|
191
|
+
def info
|
|
192
|
+
_info
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Show the class name and values hash.
|
|
196
|
+
def inspect
|
|
197
|
+
"#<#{self.class.name} #{@values.inspect}>"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Check whether the current instance exists in Ubicloud. Returns nil if the
|
|
201
|
+
# object does not exist.
|
|
202
|
+
def check_exists
|
|
203
|
+
@values[:name] ? _info(missing: nil) : load_object_info_from_id(missing: nil)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
private
|
|
207
|
+
|
|
208
|
+
def _info(missing: :raise)
|
|
209
|
+
if (hash = adapter.get(_path, missing:))
|
|
210
|
+
merge_into_values(hash)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Raise an error if the given string contains a slash. This is used for strings
|
|
215
|
+
# used as path fragments when making requests, to raise an error before issuing
|
|
216
|
+
# an HTTP request in the case where you know the path would not be valid.
|
|
217
|
+
def check_no_slash(string, error_message)
|
|
218
|
+
raise Error, error_message if string.include?("/")
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# If the given id is not already a string, call id on it to get a string.
|
|
222
|
+
# This is used in methods that can accept either a model instance or an id.
|
|
223
|
+
def to_id(id)
|
|
224
|
+
(String === id) ? id : id.id
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# For each of the model's associations, convert the related entry in the values
|
|
228
|
+
# hash to an associated object.
|
|
229
|
+
def check_associations
|
|
230
|
+
values = @values
|
|
231
|
+
|
|
232
|
+
self.class.associations.each do |key, klass|
|
|
233
|
+
next unless (value = values[key])
|
|
234
|
+
|
|
235
|
+
values[key] = if value.is_a?(Array)
|
|
236
|
+
value.map do
|
|
237
|
+
convert_to_association(_1, klass)
|
|
238
|
+
end
|
|
239
|
+
else
|
|
240
|
+
convert_to_association(value, klass)
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Convert the given value to an instance of the given klass.
|
|
246
|
+
def convert_to_association(value, klass)
|
|
247
|
+
case value
|
|
248
|
+
when Hash, klass.id_regexp
|
|
249
|
+
klass.new(adapter, value)
|
|
250
|
+
when String
|
|
251
|
+
# The object is given by name and not by id, assume that
|
|
252
|
+
# it must be in the same location as the receiver.
|
|
253
|
+
klass.new(adapter, "#{location}/#{value}")
|
|
254
|
+
else
|
|
255
|
+
value
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Given only the object's id, find the location and name of the object
|
|
260
|
+
# and merge them into the values hash.
|
|
261
|
+
def load_object_info_from_id(missing: :raise)
|
|
262
|
+
if (hash = adapter.get("object-info/#{values[:id]}", missing:))
|
|
263
|
+
hash.delete("type")
|
|
264
|
+
merge_into_values(hash)
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Merge the given values into the values hash, and convert any entries in
|
|
269
|
+
# the values hash to associated objects.
|
|
270
|
+
def merge_into_values(values)
|
|
271
|
+
@values.merge!(values)
|
|
272
|
+
check_associations
|
|
273
|
+
self
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# The path to use for requests for the model instance. If +rest+ is given,
|
|
277
|
+
# it is appended to the path.
|
|
278
|
+
def _path(rest = "")
|
|
279
|
+
"location/#{location}/#{self.class.fragment}/#{name}#{rest}"
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Require each model subclass, and then resolve associations after
|
|
285
|
+
# all subclasses have been loaded.
|
|
286
|
+
Dir.open(File.join(__dir__, "model")) do |dir|
|
|
287
|
+
dir.each_child do |file|
|
|
288
|
+
if file.end_with?(".rb")
|
|
289
|
+
require_relative "model/#{file}"
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
Ubicloud::Model.subclasses.each(&:resolve_associations)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ubicloud
|
|
4
|
+
# Ubicloud::ModelAdapter instances represents a model class
|
|
5
|
+
# that is tied to a adapter. Methods called on instances
|
|
6
|
+
# of this class are forwarded to the related model, with
|
|
7
|
+
# the adapter as the first argument.
|
|
8
|
+
class ModelAdapter
|
|
9
|
+
def initialize(model, adapter)
|
|
10
|
+
@model = model
|
|
11
|
+
@adapter = adapter
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Forward methods to the model class, but include the
|
|
15
|
+
# adapter as the first argument.
|
|
16
|
+
def method_missing(meth, ...)
|
|
17
|
+
@model.public_send(meth, @adapter, ...)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Respond to the method if the model class responds to it.
|
|
21
|
+
def respond_to_missing?(...)
|
|
22
|
+
@model.respond_to?(...)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
data/lib/ubicloud.rb
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ubicloud/adapter"
|
|
4
|
+
require_relative "ubicloud/model"
|
|
5
|
+
require_relative "ubicloud/model_adapter"
|
|
6
|
+
require_relative "ubicloud/context"
|
|
7
|
+
|
|
8
|
+
# The Ubicloud module is the namespace for Ubicloud's Ruby SDK,
|
|
9
|
+
# and also the primary entry point. Even though it is a module,
|
|
10
|
+
# users are expected to call +Ubicloud.new+ to return an appropriate
|
|
11
|
+
# context (Ubicloud::Context) that is used to make requests to
|
|
12
|
+
# Ubicloud's API.
|
|
13
|
+
module Ubicloud
|
|
14
|
+
# Error class used for errors raised by Ubicloud's Ruby SDK.
|
|
15
|
+
class Error < StandardError
|
|
16
|
+
# The integer HTTP status code related to the error. Can be
|
|
17
|
+
# nil if the Error is not related to an HTTP request.
|
|
18
|
+
attr_reader :code
|
|
19
|
+
|
|
20
|
+
# Accept the code and body keyword arguments for metadata
|
|
21
|
+
# related to this error.
|
|
22
|
+
def initialize(message, code: nil, body: nil)
|
|
23
|
+
super(message)
|
|
24
|
+
@code = code
|
|
25
|
+
@body = body
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# A hash of parameters. This is the parsed JSON response body
|
|
29
|
+
# for the request that resulted in an error. If an invalid
|
|
30
|
+
# body is given, or the error is not related to an HTTP request,
|
|
31
|
+
# returns an empty hash.
|
|
32
|
+
def params
|
|
33
|
+
@body ? JSON.parse(@body) : {}
|
|
34
|
+
rescue
|
|
35
|
+
{}
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Create a new Ubicloud::Context for the given adapter type
|
|
40
|
+
# and parameters. This is the main entry point to the library.
|
|
41
|
+
# In general, users of the SDK will want to use the :net_http
|
|
42
|
+
# adapter type:
|
|
43
|
+
#
|
|
44
|
+
# Ubicloud.new(:net_http, token: "YOUR_API_TOKEN", project_id: "pj...")
|
|
45
|
+
def self.new(adapter_type, **params)
|
|
46
|
+
Context.new(Adapter.adapter_class(adapter_type).new(**params))
|
|
47
|
+
end
|
|
48
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ubicloud
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Ubicloud, Inc.
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2025-03-31 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description:
|
|
14
|
+
email:
|
|
15
|
+
- ruby-gem-owner@ubicloud.com
|
|
16
|
+
executables: []
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files:
|
|
19
|
+
- MIT-LICENSE
|
|
20
|
+
files:
|
|
21
|
+
- MIT-LICENSE
|
|
22
|
+
- lib/ubicloud.rb
|
|
23
|
+
- lib/ubicloud/adapter.rb
|
|
24
|
+
- lib/ubicloud/adapter/net_http.rb
|
|
25
|
+
- lib/ubicloud/adapter/rack.rb
|
|
26
|
+
- lib/ubicloud/context.rb
|
|
27
|
+
- lib/ubicloud/model.rb
|
|
28
|
+
- lib/ubicloud/model/firewall.rb
|
|
29
|
+
- lib/ubicloud/model/load_balancer.rb
|
|
30
|
+
- lib/ubicloud/model/postgres.rb
|
|
31
|
+
- lib/ubicloud/model/private_subnet.rb
|
|
32
|
+
- lib/ubicloud/model/vm.rb
|
|
33
|
+
- lib/ubicloud/model_adapter.rb
|
|
34
|
+
homepage: https://github.com/ubicloud/ubicloud/tree/main/sdk/ruby
|
|
35
|
+
licenses:
|
|
36
|
+
- MIT
|
|
37
|
+
metadata:
|
|
38
|
+
bug_tracker_uri: https://github.com/ubicloud/ubicloud/issues
|
|
39
|
+
mailing_list_uri: https://github.com/ubicloud/ubicloud/discussions/new?category=q-a
|
|
40
|
+
source_code_uri: https://github.com/ubicloud/ubicloud/tree/main/sdk/ruby
|
|
41
|
+
post_install_message:
|
|
42
|
+
rdoc_options: []
|
|
43
|
+
require_paths:
|
|
44
|
+
- lib
|
|
45
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
46
|
+
requirements:
|
|
47
|
+
- - ">="
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '3.1'
|
|
50
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - ">="
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '0'
|
|
55
|
+
requirements: []
|
|
56
|
+
rubygems_version: 3.4.19
|
|
57
|
+
signing_key:
|
|
58
|
+
specification_version: 4
|
|
59
|
+
summary: Ubicloud Ruby SDK
|
|
60
|
+
test_files: []
|