hcloud 0.1.2 → 1.0.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (45) hide show
  1. checksums.yaml +5 -5
  2. data/.github/workflows/ruby.yml +32 -0
  3. data/.gitignore +0 -1
  4. data/.rubocop.yml +28 -10
  5. data/.rubocop_todo.yml +18 -121
  6. data/CHANGELOG.md +60 -2
  7. data/Gemfile +3 -0
  8. data/Gemfile.lock +149 -0
  9. data/README.md +34 -1
  10. data/Rakefile +2 -0
  11. data/bin/console +1 -0
  12. data/hcloud.gemspec +14 -9
  13. data/lib/hcloud/abstract_resource.rb +165 -60
  14. data/lib/hcloud/action.rb +8 -10
  15. data/lib/hcloud/action_resource.rb +3 -29
  16. data/lib/hcloud/client.rb +70 -29
  17. data/lib/hcloud/datacenter.rb +7 -7
  18. data/lib/hcloud/datacenter_resource.rb +6 -31
  19. data/lib/hcloud/entry_loader.rb +186 -20
  20. data/lib/hcloud/errors.rb +2 -0
  21. data/lib/hcloud/floating_ip.rb +18 -29
  22. data/lib/hcloud/floating_ip_resource.rb +15 -30
  23. data/lib/hcloud/image.rb +12 -32
  24. data/lib/hcloud/image_resource.rb +7 -38
  25. data/lib/hcloud/iso.rb +4 -1
  26. data/lib/hcloud/iso_resource.rb +7 -28
  27. data/lib/hcloud/location.rb +3 -9
  28. data/lib/hcloud/location_resource.rb +6 -27
  29. data/lib/hcloud/network.rb +33 -0
  30. data/lib/hcloud/network_resource.rb +25 -0
  31. data/lib/hcloud/pagination.rb +2 -9
  32. data/lib/hcloud/server.rb +37 -70
  33. data/lib/hcloud/server_resource.rb +17 -38
  34. data/lib/hcloud/server_type.rb +3 -10
  35. data/lib/hcloud/server_type_resource.rb +6 -28
  36. data/lib/hcloud/ssh_key.rb +6 -17
  37. data/lib/hcloud/ssh_key_resource.rb +13 -32
  38. data/lib/hcloud/typhoeus_ext.rb +110 -0
  39. data/lib/hcloud/version.rb +3 -1
  40. data/lib/hcloud/volume.rb +32 -0
  41. data/lib/hcloud/volume_resource.rb +29 -0
  42. data/lib/hcloud.rb +31 -5
  43. metadata +56 -22
  44. data/.travis.yml +0 -9
  45. data/lib/hcloud/multi_reply.rb +0 -21
@@ -1,75 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/string'
4
+
1
5
  module Hcloud
2
6
  class AbstractResource
3
7
  include Enumerable
4
8
 
5
- attr_reader :client, :parent, :base_path
9
+ delegate :request, :prepare_request, to: :client
10
+
11
+ class << self
12
+ def bind_to(klass)
13
+ resource = self
14
+ %w[find find_by where all [] page limit per_page order
15
+ to_a count pagnation each].each do |method|
16
+ klass.define_singleton_method(method) do |*args, &block|
17
+ resource.new(client: Client.connection).public_send(method, *args, &block)
18
+ end
19
+ end
20
+ end
21
+
22
+ def filter_attributes(*keys)
23
+ return @filter_attributes if keys.to_a.empty?
24
+
25
+ @filter_attributes = keys
26
+ end
27
+
28
+ def resource_class
29
+ ancestors[ancestors.index(Hcloud::AbstractResource) - 1]
30
+ end
31
+
32
+ def resource_url(url = nil)
33
+ return (@resource_url = url) if url
34
+
35
+ @resource_url || resource_class.name.demodulize.gsub('Resource', '').tableize
36
+ end
37
+
38
+ def resource_path(path = nil)
39
+ return (@resource_path = path) if path
40
+
41
+ @resource_path || resource_url
42
+ end
43
+
44
+ def resource(res = nil)
45
+ return (@resource = res) if res
46
+ return @resource if @resource
47
+
48
+ auto_const = resource_class.name.demodulize.gsub('Resource', '').to_sym
49
+ return Hcloud.const_get(auto_const) if Hcloud.constants.include?(auto_const)
50
+
51
+ raise Error, "unable to lookup resource class for #{name}"
52
+ end
53
+ end
54
+
55
+ attr_reader :client
6
56
 
7
- def initialize(client:, parent: nil, base_path: '')
57
+ def initialize(client:, base_path: '')
8
58
  @client = client
9
- @parent = parent
10
59
  @page = 1
11
60
  @per_page = 25
12
61
  @order = []
13
62
  @base_path = base_path
14
63
  end
15
64
 
65
+ def all
66
+ where
67
+ end
68
+
69
+ def where(kwargs = {})
70
+ kwargs.each_key do |key|
71
+ keys = self.class.filter_attributes.map(&:to_s)
72
+ next if keys.include?(key.to_s)
73
+
74
+ raise ArgumentError, "unknown filter #{key}, allowed keys are #{keys}"
75
+ end
76
+
77
+ _dup :@query, @query.to_h.merge(kwargs)
78
+ end
79
+
80
+ def find(id)
81
+ prepare_request(
82
+ [self.class.resource_url, id].join('/'),
83
+ resource_path: resource_path.to_s.singularize,
84
+ resource_class: self.class.resource
85
+ )
86
+ end
87
+
88
+ def [](arg)
89
+ find_by(id: arg)
90
+ end
91
+
92
+ def find_by(**kwargs)
93
+ if id = kwargs.delete(:id)
94
+ return find(id)
95
+ end
96
+
97
+ per_page(1).where(**kwargs).first
98
+ rescue Error::NotFound
99
+ end
100
+
101
+ # def count
102
+ # per_page(1).first&.response&.pagination&.total_entries.to_i
103
+ # end
104
+
16
105
  def page(page)
17
- @page = page
18
- self
106
+ _dup :@page, page
19
107
  end
20
108
 
21
109
  def per_page(per_page)
22
- @per_page = per_page
23
- self
110
+ _dup :@per_page, per_page
24
111
  end
25
112
 
26
113
  def limit(limit)
27
- @limit = limit
28
- self
114
+ _dup :@limit, limit
29
115
  end
30
116
 
31
117
  def order(*sort)
32
- @order = sort.flat_map do |s|
33
- case s
34
- when Symbol, String then s.to_s
35
- when Hash then s.map { |k, v| "#{k}:#{v}" }
36
- else
37
- raise ArgumentError,
38
- "Unable to resolve type for given #{s.inspect} from #{sort}"
39
- end
40
- end
41
- self
118
+ _dup :@order,
119
+ begin
120
+ sort.flat_map do |s|
121
+ case s
122
+ when Symbol, String then s.to_s
123
+ when Hash then s.map { |k, v| "#{k}:#{v}" }
124
+ else
125
+ raise ArgumentError,
126
+ "Unable to resolve type for given #{s.inspect} from #{sort}"
127
+ end
128
+ end
129
+ end
42
130
  end
43
131
 
44
- def mj(path, **o, &block)
45
- if !client.nil? && client.auto_pagination
46
- requests = __entries__(path, **o)
47
- if requests.all? { |x| x.is_a? Hash }
48
- m = MultiReply.new(j: requests, pagination: :auto)
49
- m.cb = block
50
- return m
51
- end
52
- client.hydra.run
53
- j = requests.map do |x|
54
- Oj.load(x.response.body)
55
- end
56
- m = MultiReply.new(j: j, pagination: :auto)
57
- m.cb = block
58
- return m
59
- end
60
- m = MultiReply.new(j: [Oj.load(request(path, o.merge(ep: ep)).run.body)])
61
- m.cb = block
62
- m
132
+ def run
133
+ @run ||= multi_query(
134
+ resource_url,
135
+ q: @query,
136
+ resource_path: resource_path,
137
+ resource_class: self.class.resource
138
+ )
63
139
  end
64
140
 
65
- def each
66
- all.each do |member|
67
- yield(member)
68
- end
141
+ def each(&block)
142
+ run.each(&block)
143
+ end
144
+
145
+ # this is just to keep the actual bevahior
146
+ def pagination
147
+ return :auto if client.auto_pagination
148
+
149
+ run.response.pagination
69
150
  end
70
151
 
71
152
  protected
72
153
 
154
+ def _dup(var, value)
155
+ dup.tap do |res|
156
+ res.instance_variable_set(var, value)
157
+ res.instance_variable_set(:@run, nil)
158
+ end
159
+ end
160
+
161
+ def resource_path
162
+ self.class.resource_path || self.class.resource_url
163
+ end
164
+
165
+ def resource_url
166
+ [@base_path.to_s, self.class.resource_url.to_s].reject(&:empty?).join('/')
167
+ end
168
+
169
+ def multi_query(path, **o)
170
+ return prepare_request(path, o.merge(ep: ep)) unless client&.auto_pagination
171
+
172
+ raise Error, 'unable to run auto paginate within concurrent excecution' if @concurrent
173
+
174
+ requests = __entries__(path, **o)
175
+ return requests.flat_map(&:resource) if requests.all? { |req| req.respond_to? :resource }
176
+
177
+ client.hydra.run
178
+ requests.flat_map { |req| req.response.resource }
179
+ end
180
+
73
181
  def page_params(per_page: nil, page: nil)
74
182
  { per_page: per_page || @per_page, page: page || @page }.to_param
75
183
  end
@@ -85,28 +193,25 @@ module Hcloud
85
193
  r.compact.join('&')
86
194
  end
87
195
 
88
- def request(*args)
89
- client.request(*args)
90
- end
91
-
196
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
92
197
  def __entries__(path, **o)
93
- ret = Oj.load(request(path, o.merge(ep: ep(per_page: 1, page: 1))).run.body)
94
- a = ret.dig('meta', 'pagination', 'total_entries').to_i
95
- return [ret] if a <= 1
96
- unless @limit.nil?
97
- a = @limit if a > @limit
98
- end
99
- r = a / Client::MAX_ENTRIES_PER_PAGE
100
- r += 1 if a % Client::MAX_ENTRIES_PER_PAGE > 0
101
- requests = r.times.map do |i|
198
+ first_page = request(path, o.merge(ep: ep(per_page: 1, page: 1))).run
199
+ total_entries = first_page.pagination.total_entries
200
+ return [first_page] if total_entries <= 1 || @limit == 1
201
+
202
+ total_entries = @limit if !@limit.nil? && (total_entries > @limit)
203
+ pages = total_entries / Client::MAX_ENTRIES_PER_PAGE
204
+ pages += 1 if (total_entries % Client::MAX_ENTRIES_PER_PAGE).positive?
205
+ pages.times.map do |page|
102
206
  per_page = Client::MAX_ENTRIES_PER_PAGE
103
- if !@limit.nil? && (r == (i + 1)) && (a % per_page != 0)
104
- per_page = a % per_page
207
+ if !@limit.nil? && (pages == (page + 1)) && (total_entries % per_page != 0)
208
+ per_page = total_entries % per_page
209
+ end
210
+ request(path, o.merge(ep: ep(per_page: per_page, page: page + 1))).tap do |req|
211
+ client.hydra.queue req
105
212
  end
106
- req = request(path, o.merge(ep: ep(per_page: per_page, page: i + 1)))
107
- client.hydra.queue req
108
- req
109
213
  end
110
214
  end
215
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
111
216
  end
112
217
  end
data/lib/hcloud/action.rb CHANGED
@@ -1,16 +1,14 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Hcloud
2
4
  class Action
3
- Attributes = {
4
- id: nil,
5
- command: nil,
6
- status: nil,
7
- progress: nil,
8
- started: :time,
9
- finished: :time,
10
- resources: nil,
11
- error: nil
12
- }.freeze
5
+ require 'hcloud/action_resource'
13
6
 
14
7
  include EntryLoader
8
+
9
+ schema(
10
+ started: :time,
11
+ finished: :time
12
+ )
15
13
  end
16
14
  end
@@ -1,35 +1,9 @@
1
+ # frozen_string_literal: true
1
2
 
2
3
  module Hcloud
3
4
  class ActionResource < AbstractResource
4
- def all
5
- mj(base_path('actions')) do |j|
6
- j.flat_map { |x| x['actions'].map { |x| Action.new(x, self, client) } }
7
- end
8
- end
5
+ filter_attributes :status
9
6
 
10
- def find(id)
11
- Action.new(
12
- Oj.load(request(base_path("actions/#{id.to_i}")).run.body)['action'],
13
- self,
14
- client
15
- )
16
- end
17
-
18
- def [](arg)
19
- find(arg)
20
- rescue Error::NotFound
21
- end
22
-
23
- def where(status: nil)
24
- mj(base_path('actions'), q: { status: status }) do |j|
25
- j.flat_map { |x| x['actions'].map { |x| Action.new(x, self, client) } }
26
- end
27
- end
28
-
29
- private
30
-
31
- def base_path(ext)
32
- [@base_path, ext].reject(&:empty?).join('/')
33
- end
7
+ bind_to Action
34
8
  end
35
9
  end
data/lib/hcloud/client.rb CHANGED
@@ -1,18 +1,52 @@
1
+ # frozen_string_literal: true
2
+
1
3
  autoload :Typhoeus, 'typhoeus'
2
4
  autoload :Oj, 'oj'
3
5
 
6
+ require 'delegate'
7
+
4
8
  module Hcloud
5
9
  class Client
6
10
  MAX_ENTRIES_PER_PAGE = 50
7
11
 
8
- attr_reader :token, :auto_pagination, :hydra
9
- def initialize(token:, auto_pagination: false, concurrency: 20)
12
+ class << self
13
+ attr_writer :connection
14
+
15
+ def connection
16
+ return @connection if @connection.is_a? Hcloud::Client
17
+
18
+ raise ArgumentError, "client not correctly initialized, actually #{@client.inspect}"
19
+ end
20
+ end
21
+
22
+ attr_reader :token, :auto_pagination, :hydra, :user_agent
23
+
24
+ def initialize(token:, auto_pagination: false, concurrency: 20, user_agent: nil)
10
25
  @token = token
26
+ @user_agent = user_agent || "hcloud-ruby v#{VERSION}"
11
27
  @auto_pagination = auto_pagination
12
28
  @concurrency = concurrency
13
29
  @hydra = Typhoeus::Hydra.new(max_concurrency: concurrency)
14
30
  end
15
31
 
32
+ def concurrent
33
+ @concurrent = true
34
+ ret = yield
35
+ ret.each do |element|
36
+ next unless element.is_a?(AbstractResource)
37
+
38
+ element.run
39
+ end
40
+ hydra.run
41
+ ret
42
+ ensure
43
+ @concurrent = nil
44
+ end
45
+
46
+ def concurrent?
47
+ !@concurrent.nil?
48
+ end
49
+
16
50
  def authorized?
17
51
  request('server_types').run
18
52
  true
@@ -56,8 +90,34 @@ module Hcloud
56
90
  FloatingIPResource.new(client: self)
57
91
  end
58
92
 
59
- def request(path, **options)
60
- code = options.delete(:code)
93
+ def networks
94
+ NetworkResource.new(client: self)
95
+ end
96
+
97
+ def volumes
98
+ VolumeResource.new(client: self)
99
+ end
100
+
101
+ class ResourceFuture < Delegator
102
+ def initialize(request) # rubocop:disable Lint/MissingSuper
103
+ @request = request
104
+ end
105
+
106
+ def __getobj__
107
+ @__getobj__ ||= @request&.response&.resource
108
+ end
109
+ end
110
+
111
+ def prepare_request(url, args = {}, &block)
112
+ req = request(url, **args.merge(block: block))
113
+ return req.run.resource unless concurrent?
114
+
115
+ hydra.queue req
116
+ ResourceFuture.new(req)
117
+ end
118
+
119
+ def request(path, options = {}) # rubocop:disable Metrics/MethodLength
120
+ hcloud_attributes = TyphoeusExt.collect_attributes(options)
61
121
  if x = options.delete(:j)
62
122
  options[:body] = Oj.dump(x, mode: :compat)
63
123
  options[:method] ||= :post
@@ -68,41 +128,22 @@ module Hcloud
68
128
  q << x.to_param
69
129
  end
70
130
  path = path.dup
71
- path << '?' + q.join('&')
131
+ path << "?#{q.join('&')}"
72
132
  r = Typhoeus::Request.new(
73
133
  "https://api.hetzner.cloud/v1/#{path}",
74
134
  {
75
135
  headers: {
76
136
  'Authorization' => "Bearer #{token}",
137
+ 'User-Agent' => user_agent,
77
138
  'Content-Type' => 'application/json'
78
139
  }
79
140
  }.merge(options)
80
141
  )
81
142
  r.on_complete do |response|
82
- case response.code
83
- when code
84
- raise Error::UnexpectedError, response.body
85
- when 401
86
- raise Error::Unauthorized
87
- when 0
88
- raise Error::ServerError, "Connection error: #{response.return_code}"
89
- when 400...600
90
- j = Oj.load(response.body)
91
- code = j.dig('error', 'code')
92
- error_class = case code
93
- when 'invalid_input' then Error::InvalidInput
94
- when 'forbidden' then Error::Forbidden
95
- when 'locked' then Error::Locked
96
- when 'not_found' then Error::NotFound
97
- when 'rate_limit_exceeded' then Error::RateLimitExceeded
98
- when 'resource_unavailable' then Error::ResourceUnavilable
99
- when 'service_error' then Error::ServiceError
100
- when 'uniqueness_error' then Error::UniquenessError
101
- else
102
- Error::ServerError
103
- end
104
- raise error_class, j.dig('error', 'message')
105
- end
143
+ response.extend(TyphoeusExt)
144
+ response.attributes = hcloud_attributes
145
+ response.context.client = self
146
+ response.check_for_error unless response.request.hydra
106
147
  end
107
148
  r
108
149
  end
@@ -1,13 +1,13 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Hcloud
2
4
  class Datacenter
3
- Attributes = {
4
- id: nil,
5
- name: nil,
6
- description: nil,
7
- location: Location,
8
- server_types: nil
9
- }.freeze
5
+ require 'hcloud/datacenter_resource'
10
6
 
11
7
  include EntryLoader
8
+
9
+ schema(
10
+ location: Location
11
+ )
12
12
  end
13
13
  end
@@ -1,44 +1,19 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Hcloud
2
4
  class DatacenterResource < AbstractResource
3
- include Enumerable
5
+ filter_attributes :name
4
6
 
5
- def all
6
- j = Oj.load(request('datacenters').run.body)
7
- dc = j['datacenters'].map { |x| Datacenter.new(x, self, client) }
8
- dc.reject { |x| x.id == j['recommendation'] }.unshift(
9
- dc.find { |x| x.id == j['recommendation'] }
10
- )
11
- end
7
+ bind_to Datacenter
12
8
 
13
9
  def recommended
14
10
  all.first
15
11
  end
16
12
 
17
- def find(id)
18
- Datacenter.new(
19
- Oj.load(request("datacenters/#{id}").run.body)['datacenter'],
20
- self,
21
- client
22
- )
23
- end
24
-
25
- def find_by(name:)
26
- x = Oj.load(request('datacenters', q: { name: name }).run.body)['datacenters']
27
- return nil if x.none?
28
- x.each do |s|
29
- return Datacenter.new(s, self, client)
30
- end
31
- end
32
-
33
13
  def [](arg)
34
14
  case arg
35
- when Integer
36
- begin
37
- find(arg)
38
- rescue Error::NotFound
39
- end
40
- when String
41
- find_by(name: arg)
15
+ when Integer then find_by(id: arg)
16
+ when String then find_by(name: arg)
42
17
  end
43
18
  end
44
19
  end