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 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: []