├── .github └── workflows │ └── ruby.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .rubocop_todo.yml ├── CHANGELOG.md ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── bin ├── console └── setup ├── hcloud.gemspec ├── lib ├── hcloud.rb └── hcloud │ ├── abstract_resource.rb │ ├── action.rb │ ├── action_resource.rb │ ├── certificate.rb │ ├── certificate_resource.rb │ ├── client.rb │ ├── datacenter.rb │ ├── datacenter_resource.rb │ ├── entry_loader.rb │ ├── errors.rb │ ├── firewall.rb │ ├── firewall_resource.rb │ ├── floating_ip.rb │ ├── floating_ip_resource.rb │ ├── future.rb │ ├── image.rb │ ├── image_resource.rb │ ├── iso.rb │ ├── iso_resource.rb │ ├── load_balancer.rb │ ├── load_balancer_resource.rb │ ├── load_balancer_type.rb │ ├── load_balancer_type_resource.rb │ ├── location.rb │ ├── location_resource.rb │ ├── network.rb │ ├── network_resource.rb │ ├── pagination.rb │ ├── placement_group.rb │ ├── placement_group_resource.rb │ ├── primary_ip.rb │ ├── primary_ip_resource.rb │ ├── resource_loader.rb │ ├── server.rb │ ├── server_resource.rb │ ├── server_type.rb │ ├── server_type_resource.rb │ ├── ssh_key.rb │ ├── ssh_key_resource.rb │ ├── typhoeus_ext.rb │ ├── version.rb │ ├── volume.rb │ └── volume_resource.rb └── spec ├── doubles ├── action_tests.rb ├── actions.rb ├── base.rb ├── certificates.rb ├── datacenters.rb ├── firewalls.rb ├── floating_ips.rb ├── images.rb ├── isos.rb ├── load_balancer_types.rb ├── load_balancers.rb ├── locations.rb ├── networks.rb ├── placement_groups.rb ├── primary_ips.rb ├── server_types.rb ├── servers.rb ├── ssh_keys.rb └── volumes.rb ├── fake_service ├── action.rb ├── base.rb ├── datacenter.rb ├── firewall.rb ├── floating_ip.rb ├── image.rb ├── iso.rb ├── location.rb ├── network.rb ├── server.rb ├── server_type.rb └── ssh_key.rb ├── hcloud ├── action_spec.rb ├── base_spec.rb ├── certificate_actions_spec.rb ├── certificate_spec.rb ├── datacenter_spec.rb ├── firewall_actions_spec.rb ├── firewall_spec.rb ├── floating_ip_actions_spec.rb ├── floating_ip_spec.rb ├── image_actions_spec.rb ├── image_spec.rb ├── iso_spec.rb ├── load_balancer_actions_spec.rb ├── load_balancer_spec.rb ├── load_balancer_type_spec.rb ├── location_spec.rb ├── network_actions_spec.rb ├── network_spec.rb ├── placement_group_spec.rb ├── primary_ip_actions_spec.rb ├── primary_ip_spec.rb ├── resource_loader_spec.rb ├── server_actions_spec.rb ├── server_spec.rb ├── server_type_spec.rb ├── ssh_key_spec.rb ├── volume_actions_spec.rb └── volume_spec.rb ├── integration ├── datacenter_spec.rb ├── firewall_spec.rb ├── iso_spec.rb ├── location_spec.rb ├── network_spec.rb ├── server_spec.rb ├── server_type_spec.rb ├── ssh_key_spec.rb ├── z_floating_ip_spec.rb └── zz_image_spec.rb ├── spec_helper.rb └── support ├── it_supports_action_fetch.rb ├── it_supports_destroy.rb ├── it_supports_fetch.rb ├── it_supports_find_by_id_and_name.rb ├── it_supports_labels_on_update.rb ├── it_supports_metrics.rb ├── it_supports_search.rb ├── it_supports_update.rb ├── matchers.rb └── typhoeus_ext.rb /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: hcloud-ruby ci 3 | 4 | on: 5 | pull_request: {} 6 | push: 7 | branches: 8 | - master 9 | 10 | jobs: 11 | lint: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Setup Ruby 16 | uses: ruby/setup-ruby@v1 17 | with: 18 | ruby-version: 2.7 19 | bundler-cache: true 20 | - name: Run rubocop 21 | run: bundle exec rubocop --parallel 22 | 23 | test: 24 | runs-on: ubuntu-latest 25 | strategy: 26 | fail-fast: false 27 | matrix: 28 | ruby-version: [ '2.7', '3.0', '3.1' ] 29 | steps: 30 | - uses: actions/checkout@v2 31 | - name: Setup Ruby 32 | uses: ruby/setup-ruby@v1 33 | with: 34 | ruby-version: ${{ matrix.ruby-version }} 35 | bundler-cache: true 36 | - name: Run double tests 37 | run: bundle exec rspec -t doubles --order rand 38 | - name: Run legacy tests 39 | run: LEGACY_TESTS=y bundle exec rspec -t ~doubles 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /vendor 3 | config.ru 4 | /.yardoc 5 | /_yardoc/ 6 | /coverage/ 7 | /doc/ 8 | /pkg/ 9 | /spec/reports/ 10 | /tmp/ 11 | *.swp 12 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format=documentation 2 | --color 3 | --tty 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: .rubocop_todo.yml 2 | 3 | AllCops: 4 | TargetRubyVersion: 2.7 5 | 6 | Style/GlobalVars: 7 | Exclude: 8 | - 'spec/**/*.rb' 9 | 10 | Style/Documentation: 11 | Enabled: false 12 | 13 | Naming/ConstantName: 14 | Enabled: false 15 | 16 | Lint/SuppressedException: 17 | Enabled: false 18 | 19 | Lint/AssignmentInCondition: 20 | Enabled: false 21 | 22 | Metrics/BlockLength: 23 | Exclude: 24 | - 'spec/**/*.rb' 25 | 26 | Metrics/ParameterLists: 27 | CountKeywordArgs: false 28 | 29 | Metrics/MethodLength: 30 | Max: 20 31 | Exclude: 32 | - 'spec/doubles/*.rb' 33 | 34 | Metrics/ClassLength: 35 | Max: 200 36 | Exclude: 37 | - 'spec/**/*.rb' 38 | 39 | Metrics/LineLength: 40 | Max: 100 41 | Exclude: 42 | - 'spec/**/*.rb' 43 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config` 3 | # on 2019-10-12 23:08:05 +0200 using RuboCop version 0.75.0. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | 9 | # Offense count: 3 10 | Lint/ShadowingOuterLocalVariable: 11 | Exclude: 12 | - 'spec/fake_service/datacenter.rb' 13 | - 'spec/fake_service/location.rb' 14 | - 'spec/fake_service/server_type.rb' 15 | 16 | # Offense count: 1 17 | # Configuration parameters: AllowKeywordBlockArguments. 18 | Lint/UnderscorePrefixedVariableName: 19 | Exclude: 20 | - 'lib/hcloud/typhoeus_ext.rb' 21 | 22 | # Offense count: 7 23 | Metrics/AbcSize: 24 | Max: 31 25 | 26 | # Offense count: 4 27 | Metrics/CyclomaticComplexity: 28 | Max: 9 29 | 30 | # Offense count: 1 31 | # Configuration parameters: CountComments, ExcludedMethods. 32 | Metrics/MethodLength: 33 | Max: 35 34 | 35 | # Offense count: 1 36 | Metrics/PerceivedComplexity: 37 | Max: 9 38 | 39 | # Offense count: 3 40 | # Configuration parameters: MinNameLength, AllowNamesEndingInNumbers, AllowedNames, ForbiddenNames. 41 | # AllowedNames: io, id, to, by, on, in, at, ip, db 42 | Naming/MethodParameterName: 43 | Exclude: 44 | - 'lib/hcloud/abstract_resource.rb' 45 | - 'spec/fake_service/action.rb' 46 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } 6 | 7 | gem 'codecov', require: false, group: :test 8 | gem 'faker' 9 | gem 'pry' 10 | gem 'rubocop' 11 | 12 | # Specify your gem's dependencies in hcloud.gemspec 13 | gemspec 14 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | hcloud (1.3.0) 5 | activemodel 6 | activesupport 7 | oj 8 | typhoeus 9 | 10 | GEM 11 | remote: https://rubygems.org/ 12 | specs: 13 | activemodel (7.1.1) 14 | activesupport (= 7.1.1) 15 | activesupport (7.1.1) 16 | base64 17 | bigdecimal 18 | concurrent-ruby (~> 1.0, >= 1.0.2) 19 | connection_pool (>= 2.2.5) 20 | drb 21 | i18n (>= 1.6, < 2) 22 | minitest (>= 5.1) 23 | mutex_m 24 | tzinfo (~> 2.0) 25 | addressable (2.8.1) 26 | public_suffix (>= 2.0.2, < 6.0) 27 | ast (2.4.2) 28 | base64 (0.1.1) 29 | bigdecimal (3.1.4) 30 | builder (3.2.4) 31 | codecov (0.6.0) 32 | simplecov (>= 0.15, < 0.22) 33 | coderay (1.1.3) 34 | concurrent-ruby (1.1.10) 35 | connection_pool (2.4.1) 36 | crack (0.4.5) 37 | rexml 38 | diff-lcs (1.5.0) 39 | docile (1.4.0) 40 | drb (2.1.1) 41 | ruby2_keywords 42 | dry-core (1.0.0) 43 | concurrent-ruby (~> 1.0) 44 | zeitwerk (~> 2.6) 45 | dry-inflector (1.0.0) 46 | dry-logic (1.5.0) 47 | concurrent-ruby (~> 1.0) 48 | dry-core (~> 1.0, < 2) 49 | zeitwerk (~> 2.6) 50 | dry-types (1.7.0) 51 | concurrent-ruby (~> 1.0) 52 | dry-core (~> 1.0, < 2) 53 | dry-inflector (~> 1.0, < 2) 54 | dry-logic (>= 1.4, < 2) 55 | zeitwerk (~> 2.6) 56 | ethon (0.16.0) 57 | ffi (>= 1.15.0) 58 | faker (3.0.0) 59 | i18n (>= 1.8.11, < 2) 60 | ffi (1.16.3) 61 | grape (1.8.0) 62 | activesupport (>= 5) 63 | builder 64 | dry-types (>= 1.1) 65 | mustermann-grape (~> 1.0.0) 66 | rack (>= 1.3.0) 67 | rack-accept 68 | hashdiff (1.0.1) 69 | i18n (1.12.0) 70 | concurrent-ruby (~> 1.0) 71 | json (2.6.2) 72 | method_source (1.0.0) 73 | minitest (5.16.3) 74 | mustermann (3.0.0) 75 | ruby2_keywords (~> 0.0.1) 76 | mustermann-grape (1.0.2) 77 | mustermann (>= 1.0.0) 78 | mutex_m (0.1.2) 79 | oj (3.16.3) 80 | bigdecimal (>= 3.0) 81 | parallel (1.22.1) 82 | parser (3.1.3.0) 83 | ast (~> 2.4.1) 84 | pry (0.14.1) 85 | coderay (~> 1.1) 86 | method_source (~> 1.0) 87 | public_suffix (5.0.0) 88 | rack (3.0.6.1) 89 | rack-accept (0.4.5) 90 | rack (>= 0.4) 91 | rainbow (3.1.1) 92 | regexp_parser (2.6.1) 93 | rexml (3.2.5) 94 | rspec (3.12.0) 95 | rspec-core (~> 3.12.0) 96 | rspec-expectations (~> 3.12.0) 97 | rspec-mocks (~> 3.12.0) 98 | rspec-core (3.12.0) 99 | rspec-support (~> 3.12.0) 100 | rspec-expectations (3.12.0) 101 | diff-lcs (>= 1.2.0, < 2.0) 102 | rspec-support (~> 3.12.0) 103 | rspec-mocks (3.12.0) 104 | diff-lcs (>= 1.2.0, < 2.0) 105 | rspec-support (~> 3.12.0) 106 | rspec-support (3.12.0) 107 | rubocop (1.39.0) 108 | json (~> 2.3) 109 | parallel (~> 1.10) 110 | parser (>= 3.1.2.1) 111 | rainbow (>= 2.2.2, < 4.0) 112 | regexp_parser (>= 1.8, < 3.0) 113 | rexml (>= 3.2.5, < 4.0) 114 | rubocop-ast (>= 1.23.0, < 2.0) 115 | ruby-progressbar (~> 1.7) 116 | unicode-display_width (>= 1.4.0, < 3.0) 117 | rubocop-ast (1.23.0) 118 | parser (>= 3.1.1.0) 119 | ruby-progressbar (1.11.0) 120 | ruby2_keywords (0.0.5) 121 | simplecov (0.21.2) 122 | docile (~> 1.1) 123 | simplecov-html (~> 0.11) 124 | simplecov_json_formatter (~> 0.1) 125 | simplecov-html (0.12.3) 126 | simplecov_json_formatter (0.1.4) 127 | typhoeus (1.4.1) 128 | ethon (>= 0.9.0) 129 | tzinfo (2.0.5) 130 | concurrent-ruby (~> 1.0) 131 | unicode-display_width (2.3.0) 132 | webmock (3.18.1) 133 | addressable (>= 2.8.0) 134 | crack (>= 0.3.2) 135 | hashdiff (>= 0.4.0, < 2.0.0) 136 | zeitwerk (2.6.6) 137 | 138 | PLATFORMS 139 | ruby 140 | x86_64-linux 141 | 142 | DEPENDENCIES 143 | bundler 144 | codecov 145 | faker 146 | grape 147 | hcloud! 148 | pry 149 | rspec 150 | rubocop 151 | webmock 152 | 153 | BUNDLED WITH 154 | 2.3.25 155 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Tim Foerster 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hcloud 2 | 3 | [![Build Status](https://github.com/tonobo/hcloud-ruby/actions/workflows/ruby.yml/badge.svg)](https://github.com/tonobo/hcloud-ruby/actions/workflows/ruby.yml) 4 | [![codecov](https://codecov.io/gh/tonobo/hcloud-ruby/branch/master/graph/badge.svg)](https://codecov.io/gh/tonobo/hcloud-ruby) 5 | [![Gem Version](https://badge.fury.io/rb/hcloud.svg)](https://badge.fury.io/rb/hcloud) 6 | [![Maintainability](https://api.codeclimate.com/v1/badges/aa67f9d590d86845822f/maintainability)](https://codeclimate.com/github/tonobo/hcloud-ruby/maintainability) 7 | 8 | This is an unoffical ruby client for HetznerCloud Api service. 9 | 10 | **Its currently in development and lacking a lot of feature. 11 | The bindings are also not considered stable.** 12 | 13 | ## Installation 14 | 15 | Add this line to your application's Gemfile: 16 | 17 | ```ruby 18 | gem 'hcloud' 19 | ``` 20 | 21 | And then execute: 22 | 23 | $ bundle 24 | 25 | Or install it yourself as: 26 | 27 | $ gem install hcloud 28 | 29 | ## Usage 30 | 31 | ### Client 32 | 33 | * Create a client instance. 34 | 35 | ```ruby 36 | c = Hcloud::Client.new(token: "") 37 | ``` 38 | 39 | * Create a client instance which fully handles concurrent pagination 40 | 41 | ```ruby 42 | c = Hcloud::Client.new( 43 | token: "", 44 | auto_pagination: true, 45 | concurrency: 50 # default 20 46 | ) 47 | ``` 48 | 49 | * Expose client connection to class level 50 | 51 | ```ruby 52 | Hcloud::Client.connection = Hcloud::Client.new(...) 53 | ``` 54 | 55 | ### Client concurrency 56 | 57 | Each action could be handled concurrently. The actual downsides are located 58 | at the exception handling. Means one request could break the whole bunch of requests, 59 | you currently have to deal with that. 60 | 61 | ```ruby 62 | servers = [] 63 | client.concurrent do 64 | 10.times do 65 | servers << client.servers.create(...) 66 | end 67 | end 68 | 69 | servers.each do |(action, server, root_password)| 70 | # do something with your servers ... 71 | end 72 | ``` 73 | 74 | ### Server Resource 75 | 76 | * List servers (basic client) 77 | 78 | ```ruby 79 | # default page(1) 80 | # default per_page(50) 81 | c.servers.page(2).per_page(40).each do |server| 82 | server.datacenter.location.id #=> 1 83 | end 84 | ``` 85 | 86 | * List servers (auto pagination client) 87 | 88 | ```ruby 89 | # default nolimit 90 | c.servers.limit(80).each do |server| 91 | server.datacenter.location.id #=> 1 92 | end 93 | ``` 94 | 95 | * List with registered class level client 96 | 97 | ```ruby 98 | Server.limit(10).each do |server| 99 | # do something with the server 100 | end 101 | ``` 102 | 103 | * Create a server 104 | 105 | Nonblocking: 106 | 107 | ```ruby 108 | c.servers.create(name: "moo5", server_type: "cx11", image: "ubuntu-16.04") 109 | #=> [#, <#Hcloud::Server>, "root_password"] 110 | ``` 111 | 112 | Wating for finish: 113 | 114 | ```ruby 115 | action,server = c.servers.create(name: "moo5", server_type: "cx11", image: "ubuntu-16.04") 116 | 117 | while action.status == "running" 118 | puts "Waiting for Action #{action.id} to complete ..." 119 | action = c.actions.find(action.id) 120 | server = c.servers.find(server.id) 121 | puts "Action Status: #{action.status}" 122 | puts "Server Status: #{server.status}" 123 | puts "Server IP Config: #{server.public_net["ipv4"]}" 124 | sleep 5 125 | end 126 | ``` 127 | 128 | * Update servers' name 129 | 130 | ```ruby 131 | c.servers.count 132 | #=> 2 133 | c.servers.first.update(name: "moo") 134 | #=> # 135 | c.servers.each{|x| x.update(name: "moo") } 136 | Hcloud::Error::UniquenessError: server name is already used 137 | ``` 138 | 139 | * Delete a server 140 | 141 | ```ruby 142 | c.servers.first.destroy 143 | #=> # 144 | ``` 145 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'hcloud' 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | require 'pry' 12 | Pry.start 13 | 14 | # require 'irb' 15 | # IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /hcloud.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'hcloud/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = 'hcloud' 9 | spec.version = Hcloud::VERSION 10 | spec.authors = ['Tim Foerster', 'Raphael Pour'] 11 | spec.email = ['github@moo.gl', 'rubygems@evilcookie.de'] 12 | 13 | spec.summary = 'HetznerCloud native Ruby client' 14 | spec.homepage = 'https://github.com/tonobo/hcloud-ruby' 15 | spec.license = 'MIT' 16 | 17 | spec.required_ruby_version = '>= 2.7.0' 18 | 19 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 20 | f.match(%r{^(test|spec|features)/}) 21 | end 22 | spec.bindir = 'exe' 23 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 24 | spec.require_paths = ['lib'] 25 | 26 | spec.add_development_dependency 'bundler' 27 | spec.add_development_dependency 'grape' 28 | spec.add_development_dependency 'rspec' 29 | spec.add_development_dependency 'webmock' 30 | spec.add_runtime_dependency 'activemodel' 31 | spec.add_runtime_dependency 'activesupport' 32 | spec.add_runtime_dependency 'oj' 33 | spec.add_runtime_dependency 'typhoeus' 34 | end 35 | -------------------------------------------------------------------------------- /lib/hcloud.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'hcloud/version' 4 | require 'active_support' 5 | require 'active_support/core_ext/object/to_query' 6 | require 'active_support/core_ext/hash/indifferent_access' 7 | require 'active_support/core_ext/object/blank' 8 | 9 | module Hcloud 10 | autoload :Error, 'hcloud/errors' 11 | autoload :Client, 'hcloud/client' 12 | autoload :Future, 'hcloud/future' 13 | autoload :TyphoeusExt, 'hcloud/typhoeus_ext' 14 | autoload :AbstractResource, 'hcloud/abstract_resource' 15 | autoload :EntryLoader, 'hcloud/entry_loader' 16 | autoload :ResourceLoader, 'hcloud/resource_loader' 17 | 18 | autoload :Server, 'hcloud/server' 19 | autoload :ServerResource, 'hcloud/server_resource' 20 | 21 | autoload :ServerType, 'hcloud/server_type' 22 | autoload :ServerTypeResource, 'hcloud/server_type_resource' 23 | 24 | autoload :FloatingIP, 'hcloud/floating_ip' 25 | autoload :FloatingIPResource, 'hcloud/floating_ip_resource' 26 | 27 | autoload :PrimaryIP, 'hcloud/primary_ip' 28 | autoload :PrimaryIPResource, 'hcloud/primary_ip_resource' 29 | 30 | autoload :SSHKey, 'hcloud/ssh_key' 31 | autoload :SSHKeyResource, 'hcloud/ssh_key_resource' 32 | 33 | autoload :Certificate, 'hcloud/certificate' 34 | autoload :CertificateResource, 'hcloud/certificate_resource' 35 | 36 | autoload :Datacenter, 'hcloud/datacenter' 37 | autoload :DatacenterResource, 'hcloud/datacenter_resource' 38 | 39 | autoload :Location, 'hcloud/location' 40 | autoload :LocationResource, 'hcloud/location_resource' 41 | 42 | autoload :Image, 'hcloud/image' 43 | autoload :ImageResource, 'hcloud/image_resource' 44 | 45 | autoload :Network, 'hcloud/network' 46 | autoload :NetworkResource, 'hcloud/network_resource' 47 | 48 | autoload :Firewall, 'hcloud/firewall' 49 | autoload :FirewallResource, 'hcloud/firewall_resource' 50 | 51 | autoload :Volume, 'hcloud/volume' 52 | autoload :VolumeResource, 'hcloud/volume_resource' 53 | 54 | autoload :Action, 'hcloud/action' 55 | autoload :ActionResource, 'hcloud/action_resource' 56 | 57 | autoload :Iso, 'hcloud/iso' 58 | autoload :IsoResource, 'hcloud/iso_resource' 59 | 60 | autoload :Pagination, 'hcloud/pagination' 61 | 62 | autoload :PlacementGroup, 'hcloud/placement_group' 63 | autoload :PlacementGroupResource, 'hcloud/placement_group_resource' 64 | 65 | autoload :LoadBalancerType, 'hcloud/load_balancer_type' 66 | autoload :LoadBalancerTypeResource, 'hcloud/load_balancer_type_resource' 67 | 68 | autoload :LoadBalancer, 'hcloud/load_balancer' 69 | autoload :LoadBalancerResource, 'hcloud/load_balancer_resource' 70 | 71 | COLLECT_ARGS = proc do |method_name, bind| 72 | query = bind.receiver.method(method_name).parameters.inject({}) do |hash, (_type, name)| 73 | hash.merge(name => bind.local_variable_get(name)) 74 | end 75 | query.delete_if { |_, v| v.nil? } 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/hcloud/action.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | class Action 5 | require 'hcloud/action_resource' 6 | 7 | include EntryLoader 8 | 9 | schema( 10 | started: :time, 11 | finished: :time 12 | ) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/hcloud/action_resource.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | class ActionResource < AbstractResource 5 | filter_attributes :status 6 | 7 | bind_to Action 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/hcloud/certificate.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | class Certificate 5 | require 'hcloud/certificate_resource' 6 | 7 | include EntryLoader 8 | 9 | schema( 10 | created: :time 11 | ) 12 | 13 | updatable :name 14 | destructible 15 | 16 | has_actions 17 | 18 | def retry 19 | prepare_request('actions/retry', j: COLLECT_ARGS.call(__method__, binding)) 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/hcloud/certificate_resource.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | class CertificateResource < AbstractResource 5 | filter_attributes :name, :label_selector, :type 6 | 7 | def [](arg) 8 | case arg 9 | when Integer then find_by(id: arg) 10 | when String then find_by(name: arg) 11 | end 12 | end 13 | 14 | def create( 15 | name:, 16 | type: :uploaded, 17 | certificate: nil, 18 | private_key: nil, 19 | domain_names: nil, 20 | labels: {} 21 | ) 22 | raise Hcloud::Error::InvalidInput, 'no name given' if name.blank? 23 | 24 | case type 25 | when :uploaded 26 | raise Hcloud::Error::InvalidInput, 'no certificate given' if certificate.blank? 27 | raise Hcloud::Error::InvalidInput, 'no private_key given' if private_key.blank? 28 | when :managed 29 | raise Hcloud::Error::InvalidInput, 'no domain_names given' if domain_names.to_a.empty? 30 | end 31 | 32 | prepare_request( 33 | 'certificates', j: COLLECT_ARGS.call(__method__, binding), 34 | expected_code: 201 35 | ) do |response| 36 | [ 37 | Action.new(client, response.parsed_json[:action]), 38 | Certificate.new(client, response.parsed_json[:certificate]) 39 | ] 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/hcloud/client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | autoload :Typhoeus, 'typhoeus' 4 | autoload :Oj, 'oj' 5 | 6 | require 'delegate' 7 | 8 | module Hcloud 9 | class Client 10 | MAX_ENTRIES_PER_PAGE = 50 11 | 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) 25 | @token = token 26 | @user_agent = user_agent || "hcloud-ruby v#{VERSION}" 27 | @auto_pagination = auto_pagination 28 | @concurrency = concurrency 29 | @hydra = Typhoeus::Hydra.new(max_concurrency: concurrency) 30 | end 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 | 50 | def authorized? 51 | request('server_types').run 52 | true 53 | rescue Error::Unauthorized 54 | false 55 | end 56 | 57 | def servers 58 | ServerResource.new(client: self) 59 | end 60 | 61 | def actions 62 | ActionResource.new(client: self) 63 | end 64 | 65 | def isos 66 | IsoResource.new(client: self) 67 | end 68 | 69 | def images 70 | ImageResource.new(client: self) 71 | end 72 | 73 | def datacenters 74 | DatacenterResource.new(client: self) 75 | end 76 | 77 | def locations 78 | LocationResource.new(client: self) 79 | end 80 | 81 | def server_types 82 | ServerTypeResource.new(client: self) 83 | end 84 | 85 | def ssh_keys 86 | SSHKeyResource.new(client: self) 87 | end 88 | 89 | def certificates 90 | CertificateResource.new(client: self) 91 | end 92 | 93 | def floating_ips 94 | FloatingIPResource.new(client: self) 95 | end 96 | 97 | def primary_ips 98 | PrimaryIPResource.new(client: self) 99 | end 100 | 101 | def networks 102 | NetworkResource.new(client: self) 103 | end 104 | 105 | def firewalls 106 | FirewallResource.new(client: self) 107 | end 108 | 109 | def volumes 110 | VolumeResource.new(client: self) 111 | end 112 | 113 | def placement_groups 114 | PlacementGroupResource.new(client: self) 115 | end 116 | 117 | def load_balancer_types 118 | LoadBalancerTypeResource.new(client: self) 119 | end 120 | 121 | def load_balancers 122 | LoadBalancerResource.new(client: self) 123 | end 124 | 125 | class ResourceFuture < Delegator 126 | def initialize(request) # rubocop:disable Lint/MissingSuper 127 | @request = request 128 | end 129 | 130 | def __getobj__ 131 | @__getobj__ ||= @request&.response&.resource 132 | end 133 | end 134 | 135 | def prepare_request(url, args = {}, &block) 136 | req = request(url, **args.merge(block: block)) 137 | return req.run.resource unless concurrent? 138 | 139 | hydra.queue req 140 | ResourceFuture.new(req) 141 | end 142 | 143 | def request(path, options = {}) # rubocop:disable Metrics/MethodLength 144 | hcloud_attributes = TyphoeusExt.collect_attributes(options) 145 | if x = options.delete(:j) 146 | options[:body] = Oj.dump(x, mode: :compat) 147 | options[:method] ||= :post 148 | end 149 | q = [] 150 | q << options.delete(:ep).to_s 151 | if x = options.delete(:q) 152 | q << x.to_param 153 | end 154 | path = path.dup 155 | path << "?#{q.join('&')}" 156 | r = Typhoeus::Request.new( 157 | "https://api.hetzner.cloud/v1/#{path}", 158 | { 159 | headers: { 160 | 'Authorization' => "Bearer #{token}", 161 | 'User-Agent' => user_agent, 162 | 'Content-Type' => 'application/json' 163 | } 164 | }.merge(options) 165 | ) 166 | r.on_complete do |response| 167 | response.extend(TyphoeusExt) 168 | response.attributes = hcloud_attributes 169 | response.context.client = self 170 | response.check_for_error unless response.request.hydra 171 | end 172 | r 173 | end 174 | end 175 | end 176 | -------------------------------------------------------------------------------- /lib/hcloud/datacenter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | class Datacenter 5 | require 'hcloud/datacenter_resource' 6 | 7 | include EntryLoader 8 | 9 | schema( 10 | location: Location 11 | ) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/hcloud/datacenter_resource.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | class DatacenterResource < AbstractResource 5 | filter_attributes :name 6 | 7 | bind_to Datacenter 8 | 9 | def recommended 10 | all.first 11 | end 12 | 13 | def [](arg) 14 | case arg 15 | when Integer then find_by(id: arg) 16 | when String then find_by(name: arg) 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/hcloud/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | class Error < StandardError 5 | class Unauthorized < Error; end 6 | class ServerError < Error; end 7 | class Forbidden < Error; end 8 | class InvalidInput < Error; end 9 | class Locked < Error; end 10 | class NotFound < Error; end 11 | class RateLimitExceeded < Error; end 12 | class ResourceUnavailable < Error; end 13 | class ServiceError < Error; end 14 | class UniquenessError < Error; end 15 | class UnknownError < Error; end 16 | class UnexpectedError < Error; end 17 | class ResourcePathError < Error; end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/hcloud/firewall.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | class Firewall 5 | require 'hcloud/firewall_resource' 6 | 7 | include EntryLoader 8 | 9 | schema( 10 | created: :time 11 | ) 12 | 13 | updatable :name 14 | destructible 15 | 16 | has_actions 17 | 18 | def set_rules(rules:) 19 | # Set rules to empty when nil is passed 20 | rules = rules.to_a 21 | 22 | prepare_request('actions/set_rules', j: COLLECT_ARGS.call(__method__, binding)) 23 | end 24 | 25 | def apply_to_resources(apply_to:) 26 | raise Hcloud::Error::InvalidInput, 'no apply_to resources given' if apply_to.nil? 27 | 28 | prepare_request('actions/apply_to_resources', j: COLLECT_ARGS.call(__method__, binding)) 29 | end 30 | 31 | def remove_from_resources(remove_from:) 32 | raise Hcloud::Error::InvalidInput, 'no remove_from resources given' if remove_from.nil? 33 | 34 | prepare_request('actions/remove_from_resources', j: COLLECT_ARGS.call(__method__, binding)) 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/hcloud/firewall_resource.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | class FirewallResource < AbstractResource 5 | filter_attributes :name, :label_selector 6 | 7 | def [](arg) 8 | case arg 9 | when Integer then find_by(id: arg) 10 | when String then find_by(name: arg) 11 | end 12 | end 13 | 14 | def create(name:, rules: [], apply_to: [], labels: {}) 15 | raise Hcloud::Error::InvalidInput, 'no name given' if name.blank? 16 | 17 | prepare_request( 18 | 'firewalls', j: COLLECT_ARGS.call(__method__, binding), 19 | expected_code: 201 20 | ) do |response| 21 | [ 22 | response.parsed_json[:actions].map do |action| 23 | Action.new(client, action) 24 | end, 25 | Firewall.new(client, response.parsed_json[:firewall]) 26 | ] 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/hcloud/floating_ip.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | class FloatingIP 5 | require 'hcloud/floating_ip_resource' 6 | 7 | include EntryLoader 8 | 9 | schema( 10 | home_location: Location, 11 | created: :time 12 | ) 13 | 14 | protectable :delete 15 | updatable :name, :description 16 | destructible 17 | 18 | has_actions 19 | 20 | def assign(server:) 21 | raise Hcloud::Error::InvalidInput, 'no server given' if server.nil? 22 | 23 | prepare_request('actions/assign', j: COLLECT_ARGS.call(__method__, binding)) 24 | end 25 | 26 | def unassign 27 | prepare_request('actions/unassign', method: :post) 28 | end 29 | 30 | def change_dns_ptr(ip:, dns_ptr:) 31 | raise Hcloud::Error::InvalidInput, 'no IP given' if ip.blank? 32 | 33 | prepare_request('actions/change_dns_ptr', j: COLLECT_ARGS.call(__method__, binding)) 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/hcloud/floating_ip_resource.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | class FloatingIPResource < AbstractResource 5 | filter_attributes :name, :label_selector 6 | 7 | bind_to FloatingIP 8 | 9 | def [](arg) 10 | case arg 11 | when Integer then find_by(id: arg) 12 | when String then find_by(name: arg) 13 | end 14 | end 15 | 16 | def create(type:, name: nil, server: nil, home_location: nil, description: nil, labels: {}) 17 | raise Hcloud::Error::InvalidInput, 'no type given' if type.blank? 18 | if server.nil? && home_location.nil? 19 | raise Hcloud::Error::InvalidInput, 'either server or home_location must be given' 20 | end 21 | 22 | prepare_request( 23 | 'floating_ips', j: COLLECT_ARGS.call(__method__, binding), 24 | expected_code: 201 25 | ) do |response| 26 | action = Action.new(client, response[:action]) if response[:action] 27 | [action, FloatingIP.new(client, response[:floating_ip])] 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/hcloud/future.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/core_ext/string/inflections' 4 | 5 | module Hcloud 6 | class Future < Delegator 7 | attr_reader :raw_data 8 | 9 | # rubocop: disable Lint/MissingSuper 10 | def initialize(client, target_class, id, raw_data: nil) 11 | @target_class = target_class 12 | @id = id 13 | @raw_data = raw_data 14 | @__client = client 15 | end 16 | # rubocop: enable Lint/MissingSuper 17 | 18 | def __getobj__ 19 | # pluralize class name and convert it to symbol 20 | @__getobj__ ||= @__client.public_send( 21 | @target_class.name # full name of the class including namespaces 22 | .demodulize # last module name only 23 | .tableize # convert to table name, split words by _ + downcase 24 | .pluralize 25 | .to_sym 26 | ).find(@id) 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/hcloud/image.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | class Image 5 | require 'hcloud/image_resource' 6 | 7 | include EntryLoader 8 | 9 | schema( 10 | created: :time, 11 | deprecated: :time 12 | ) 13 | 14 | protectable :delete 15 | updatable :description, :type 16 | destructible 17 | 18 | has_actions 19 | 20 | def to_snapshot 21 | update(type: 'snapshot') 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/hcloud/image_resource.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | class ImageResource < AbstractResource 5 | filter_attributes :type, :bound_to, :name, :label_selector, :status, :include_deprecated 6 | 7 | bind_to Image 8 | 9 | def [](arg) 10 | case arg 11 | when Integer then find_by(id: arg) 12 | when String then find_by(name: arg) 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/hcloud/iso.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | class Iso 5 | Attributes = { 6 | id: nil, 7 | name: nil, 8 | description: nil, 9 | type: nil, 10 | deprecated: :time 11 | }.freeze 12 | 13 | include EntryLoader 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/hcloud/iso_resource.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | class IsoResource < AbstractResource 5 | filter_attributes :name 6 | 7 | bind_to Iso 8 | 9 | def [](arg) 10 | case arg 11 | when Integer then find_by(id: arg) 12 | when String then find_by(name: arg) 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/hcloud/load_balancer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/core_ext/hash/keys' 4 | 5 | module Hcloud 6 | class LoadBalancer 7 | require 'hcloud/load_balancer_resource' 8 | 9 | include EntryLoader 10 | 11 | schema( 12 | location: Location, 13 | load_balancer_type: LoadBalancerType, 14 | created: :time 15 | ) 16 | 17 | protectable :delete 18 | updatable :name 19 | destructible 20 | 21 | has_metrics 22 | has_actions 23 | 24 | %w[enable_public_interface disable_public_interface].each do |action| 25 | define_method(action) do 26 | prepare_request("actions/#{action}", method: :post) 27 | end 28 | end 29 | 30 | def attach_to_network(network:, ip: nil) 31 | raise Hcloud::Error::InvalidInput, 'no network given' if network.nil? 32 | 33 | prepare_request('actions/attach_to_network', j: COLLECT_ARGS.call(__method__, binding)) 34 | end 35 | 36 | def detach_from_network(network:) 37 | raise Hcloud::Error::InvalidInput, 'no network given' if network.nil? 38 | 39 | prepare_request('actions/detach_from_network', j: COLLECT_ARGS.call(__method__, binding)) 40 | end 41 | 42 | def change_dns_ptr(ip:, dns_ptr:) 43 | raise Hcloud::Error::InvalidInput, 'no IP given' if ip.blank? 44 | raise Hcloud::Error::InvalidInput, 'no dns_ptr given' if dns_ptr.blank? 45 | 46 | prepare_request('actions/change_dns_ptr', j: COLLECT_ARGS.call(__method__, binding)) 47 | end 48 | 49 | def change_type(load_balancer_type:) 50 | raise Hcloud::Error::InvalidInput, 'no type given' if load_balancer_type.blank? 51 | 52 | prepare_request('actions/change_type', j: COLLECT_ARGS.call(__method__, binding)) 53 | end 54 | 55 | def change_algorithm(type:) 56 | raise Hcloud::Error::InvalidInput, 'no type given' if type.blank? 57 | 58 | prepare_request('actions/change_algorithm', j: COLLECT_ARGS.call(__method__, binding)) 59 | end 60 | 61 | def add_service( 62 | protocol:, listen_port:, destination_port:, health_check:, proxyprotocol:, http: nil 63 | ) 64 | validate_service_input( 65 | protocol: protocol, 66 | listen_port: listen_port, 67 | destination_port: destination_port, 68 | health_check: health_check, 69 | proxyprotocol: proxyprotocol 70 | ) 71 | 72 | prepare_request('actions/add_service', j: COLLECT_ARGS.call(__method__, binding)) 73 | end 74 | 75 | def update_service( 76 | protocol:, listen_port:, destination_port:, health_check:, proxyprotocol:, http: nil 77 | ) 78 | validate_service_input( 79 | protocol: protocol, 80 | listen_port: listen_port, 81 | destination_port: destination_port, 82 | health_check: health_check, 83 | proxyprotocol: proxyprotocol 84 | ) 85 | 86 | prepare_request('actions/update_service', j: COLLECT_ARGS.call(__method__, binding)) 87 | end 88 | 89 | def delete_service(listen_port:) 90 | raise Hcloud::Error::InvalidInput, 'no listen_port given' if listen_port.nil? 91 | 92 | prepare_request('actions/delete_service', j: COLLECT_ARGS.call(__method__, binding)) 93 | end 94 | 95 | def add_target(type:, server: nil, label_selector: nil, ip: nil, use_private_ip: false) 96 | validate_target_input( 97 | type: type, server: server, label_selector: label_selector, ip: ip 98 | ) 99 | 100 | prepare_request('actions/add_target', j: COLLECT_ARGS.call(__method__, binding)) 101 | end 102 | 103 | def remove_target(type:, server: nil, label_selector: nil, ip: nil) 104 | validate_target_input( 105 | type: type, server: server, label_selector: label_selector, ip: ip 106 | ) 107 | 108 | prepare_request('actions/remove_target', j: COLLECT_ARGS.call(__method__, binding)) 109 | end 110 | 111 | private 112 | 113 | def validate_service_input( 114 | protocol:, listen_port:, destination_port:, health_check:, proxyprotocol: 115 | ) 116 | raise Hcloud::Error::InvalidInput, 'no protocol given' if protocol.blank? 117 | raise Hcloud::Error::InvalidInput, 'no listen_port given' if listen_port.nil? 118 | raise Hcloud::Error::InvalidInput, 'no destination_port given' if destination_port.nil? 119 | raise Hcloud::Error::InvalidInput, 'no health_check given' if health_check.nil? 120 | raise Hcloud::Error::InvalidInput, 'no proxyprotocol given' if proxyprotocol.nil? 121 | end 122 | 123 | def validate_target_input(type:, server: nil, label_selector: nil, ip: nil) 124 | raise Hcloud::Error::InvalidInput, 'no type given' if type.nil? 125 | 126 | case type.to_sym 127 | when :server 128 | raise Hcloud::Error::InvalidInput, 'invalid server given' unless server.to_h.key?(:id) 129 | when :ip 130 | raise Hcloud::Error::InvalidInput, 'no IP given' if ip.blank? 131 | when :label_selector 132 | unless label_selector.to_h.key?(:selector) 133 | raise Hcloud::Error::InvalidInput, 'invalid label_selector given' 134 | end 135 | else 136 | raise Hcloud::Error::InvalidInput, 'invalid type given' 137 | end 138 | end 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /lib/hcloud/load_balancer_resource.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | class LoadBalancerResource < AbstractResource 5 | filter_attributes :name, :label_selector 6 | 7 | bind_to LoadBalancer 8 | 9 | def [](arg) 10 | case arg 11 | when Integer then find_by(id: arg) 12 | when String then find_by(name: arg) 13 | end 14 | end 15 | 16 | def create( 17 | name:, load_balancer_type:, algorithm:, location: nil, network_zone: nil, 18 | network: nil, public_interface: nil, services: nil, targets: nil, 19 | labels: {} 20 | ) 21 | raise Hcloud::Error::InvalidInput, 'no name given' if name.blank? 22 | raise Hcloud::Error::InvalidInput, 'no type given' if load_balancer_type.blank? 23 | if !algorithm.to_h.key?(:type) || algorithm[:type].blank? 24 | raise Hcloud::Error::InvalidInput, 'invalid algorithm given' 25 | end 26 | if location.blank? && network_zone.blank? 27 | raise Hcloud::Error::InvalidInput, 'either location or network_zone must be given' 28 | end 29 | 30 | prepare_request( 31 | 'load_balancers', j: COLLECT_ARGS.call(__method__, binding), 32 | expected_code: 201 33 | ) do |response| 34 | action = Action.new(client, response[:action]) if response[:action] 35 | [action, LoadBalancer.new(client, response[:load_balancer])] 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/hcloud/load_balancer_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | class LoadBalancerType 5 | require 'hcloud/load_balancer_type_resource' 6 | 7 | include EntryLoader 8 | 9 | schema( 10 | deprecated: :time 11 | ) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/hcloud/load_balancer_type_resource.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | class LoadBalancerTypeResource < AbstractResource 5 | filter_attributes :name 6 | 7 | bind_to LoadBalancerType 8 | 9 | def [](arg) 10 | case arg 11 | when Integer then find_by(id: arg) 12 | when String then find_by(name: arg) 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/hcloud/location.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | class Location 5 | require 'hcloud/location_resource' 6 | 7 | include EntryLoader 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/hcloud/location_resource.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | class LocationResource < AbstractResource 5 | filter_attributes :name 6 | 7 | bind_to Location 8 | 9 | def [](arg) 10 | case arg 11 | when Integer then find_by(id: arg) 12 | when String then find_by(name: arg) 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/hcloud/network.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | class Network 5 | include EntryLoader 6 | 7 | schema( 8 | created: :time 9 | ) 10 | 11 | protectable :delete 12 | updatable :name 13 | destructible 14 | 15 | has_actions 16 | 17 | def add_subnet(type:, network_zone:, ip_range: nil) 18 | raise Hcloud::Error::InvalidInput, 'no type given' if type.blank? 19 | raise Hcloud::Error::InvalidInput, 'no network_zone given' if network_zone.blank? 20 | 21 | prepare_request('actions/add_subnet', j: COLLECT_ARGS.call(__method__, binding)) 22 | end 23 | 24 | def del_subnet(ip_range:) 25 | raise Hcloud::Error::InvalidInput, 'no ip_range given' if ip_range.blank? 26 | 27 | prepare_request('actions/delete_subnet', j: COLLECT_ARGS.call(__method__, binding)) 28 | end 29 | 30 | def add_route(destination:, gateway:) 31 | raise Hcloud::Error::InvalidInput, 'no destination given' if destination.blank? 32 | raise Hcloud::Error::InvalidInput, 'no gateway given' if gateway.blank? 33 | 34 | prepare_request('actions/add_route', j: COLLECT_ARGS.call(__method__, binding)) 35 | end 36 | 37 | def del_route(destination:, gateway:) 38 | raise Hcloud::Error::InvalidInput, 'no destination given' if destination.blank? 39 | raise Hcloud::Error::InvalidInput, 'no gateway given' if gateway.blank? 40 | 41 | prepare_request('actions/delete_route', j: COLLECT_ARGS.call(__method__, binding)) 42 | end 43 | 44 | def change_ip_range(ip_range:) 45 | raise Hcloud::Error::InvalidInput, 'no ip_range given' if ip_range.blank? 46 | 47 | prepare_request('actions/change_ip_range', j: COLLECT_ARGS.call(__method__, binding)) 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/hcloud/network_resource.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | class NetworkResource < AbstractResource 5 | filter_attributes :name, :label_selector 6 | 7 | bind_to Network 8 | 9 | def [](arg) 10 | case arg 11 | when Integer then find_by(id: arg) 12 | when String then find_by(name: arg) 13 | end 14 | end 15 | 16 | def create(name:, ip_range:, subnets: nil, routes: nil, labels: {}) 17 | raise Hcloud::Error::InvalidInput, 'no name given' if name.blank? 18 | raise Hcloud::Error::InvalidInput, 'no IP range given' if ip_range.blank? 19 | 20 | prepare_request( 21 | 'networks', j: COLLECT_ARGS.call(__method__, binding), 22 | expected_code: 201 23 | ) do |response| 24 | Network.new(client, response.parsed_json[:network]) 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/hcloud/pagination.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | class Pagination 5 | include EntryLoader 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/hcloud/placement_group.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | class PlacementGroup 5 | require 'hcloud/placement_group_resource' 6 | 7 | include EntryLoader 8 | 9 | schema( 10 | created: :time 11 | ) 12 | 13 | updatable :name 14 | destructible 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/hcloud/placement_group_resource.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | class PlacementGroupResource < AbstractResource 5 | filter_attributes :type, :name, :label_selector 6 | 7 | bind_to PlacementGroup 8 | 9 | def [](arg) 10 | return find_by(name: arg) if arg.is_a?(String) 11 | 12 | super 13 | end 14 | 15 | # currently only spread is available 16 | def create(name:, type: 'spread', labels: {}) 17 | if type.to_s != 'spread' 18 | raise Hcloud::Error::InvalidInput, "invalid type #{type.inspect}, only 'spread' is allowed" 19 | end 20 | raise Hcloud::Error::InvalidInput, 'no name given' if name.blank? 21 | 22 | prepare_request( 23 | 'placement_groups', j: COLLECT_ARGS.call(__method__, binding), 24 | expected_code: 201 25 | ) do |response| 26 | PlacementGroup.new(client, response.parsed_json[:placement_group]) 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/hcloud/primary_ip.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | class PrimaryIP 5 | require 'hcloud/primary_ip_resource' 6 | 7 | include EntryLoader 8 | 9 | schema( 10 | datacenter: Datacenter, 11 | created: :time 12 | ) 13 | 14 | protectable :delete 15 | updatable :name, :auto_delete 16 | destructible 17 | 18 | def assign(assignee_id:, assignee_type: 'server') 19 | raise Hcloud::Error::InvalidInput, 'no assignee_id given' if assignee_id.nil? 20 | raise Hcloud::Error::InvalidInput, 'no assignee_type given' if assignee_type.nil? 21 | 22 | prepare_request('actions/assign', j: COLLECT_ARGS.call(__method__, binding)) 23 | end 24 | 25 | def unassign 26 | prepare_request('actions/unassign', method: :post) 27 | end 28 | 29 | def change_dns_ptr(ip:, dns_ptr:) 30 | raise Hcloud::Error::InvalidInput, 'no IP given' if ip.blank? 31 | 32 | prepare_request('actions/change_dns_ptr', j: { ip: ip, dns_ptr: dns_ptr }) 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/hcloud/primary_ip_resource.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | class PrimaryIPResource < AbstractResource 5 | filter_attributes :name, :label_selector, :ip 6 | 7 | bind_to PrimaryIP 8 | 9 | def [](arg) 10 | case arg 11 | when Integer then find_by(id: arg) 12 | when String then find_by(name: arg) 13 | end 14 | end 15 | 16 | def create( 17 | name:, 18 | type:, 19 | assignee_id: nil, 20 | assignee_type: 'server', 21 | datacenter: nil, 22 | auto_delete: nil, 23 | labels: {} 24 | ) 25 | raise Hcloud::Error::InvalidInput, 'no name given' if name.blank? 26 | 27 | unless %w[ipv4 ipv6].include?(type.to_s) 28 | raise Hcloud::Error::InvalidInput, 'invalid type given' 29 | end 30 | 31 | raise Hcloud::Error::InvalidInput, 'no assignee_type given' if assignee_type.blank? 32 | 33 | if assignee_id.nil? && datacenter.nil? 34 | raise Hcloud::Error::InvalidInput, 'either assignee_id or datacenter must be given' 35 | end 36 | 37 | prepare_request( 38 | 'primary_ips', j: COLLECT_ARGS.call(__method__, binding), 39 | expected_code: 201 40 | ) do |response| 41 | action = Action.new(client, response[:action]) if response[:action] 42 | [action, PrimaryIP.new(client, response[:primary_ip])] 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/hcloud/resource_loader.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'time' 4 | 5 | module Hcloud 6 | class ResourceLoader 7 | def initialize(schema, client:) 8 | @schema = schema 9 | @client = client 10 | end 11 | 12 | def load(data) 13 | load_with_schema(@schema, data) 14 | end 15 | 16 | private 17 | 18 | def load_with_schema(schema, data) 19 | if schema.respond_to?(:call) 20 | schema.call(data, @client) 21 | elsif schema.is_a?(Array) && schema.count.positive? && data.is_a?(Array) 22 | load_array(schema, data) 23 | elsif schema.is_a?(Hash) && data.is_a?(Hash) 24 | load_hash(schema, data) 25 | else 26 | load_single_item(schema, data) 27 | end 28 | end 29 | 30 | def load_array(schema, data) 31 | data.map do |item| 32 | load_with_schema(schema[0], item) 33 | end 34 | end 35 | 36 | def load_hash(schema, data) 37 | data.map do |key, value| 38 | [key, load_with_schema(schema[key], value)] 39 | end.to_h 40 | end 41 | 42 | def load_single_item(definition, value) 43 | if definition == :time 44 | return value ? Time.parse(value) : nil 45 | end 46 | 47 | if definition.is_a?(Class) && definition.include?(EntryLoader) 48 | return if value.nil? 49 | 50 | # If value is an integer, this is the id of an object which's class can be 51 | # retreived from definition. Load a future object that can on access retreive the 52 | # data from the api and convert it to a proper object. 53 | return Future.new(@client, definition, value) if value.is_a?(Integer) 54 | 55 | # Otherwise the value *is* the content of the object 56 | return definition.new(@client, value) 57 | end 58 | 59 | value 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/hcloud/server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | class Server 5 | require 'hcloud/server_resource' 6 | 7 | include EntryLoader 8 | 9 | schema( 10 | created: :time, 11 | server_type: ServerType, 12 | datacenter: Datacenter, 13 | image: Image, 14 | iso: Iso, 15 | load_balancers: [LoadBalancer], 16 | placement_group: PlacementGroup, 17 | private_net: [Network], 18 | public_net: { 19 | ipv4: lambda do |data, client| 20 | Future.new(client, PrimaryIP, data[:id]) if data.to_h[:id].is_a?(Integer) 21 | end, 22 | ipv6: lambda do |data, client| 23 | Future.new(client, PrimaryIP, data[:id]) if data.to_h[:id].is_a?(Integer) 24 | end, 25 | floating_ips: [FloatingIP], 26 | firewalls: [ 27 | lambda do |data, client| 28 | Future.new(client, Firewall, data[:id], raw_data: data) if data.to_h[:id].is_a?(Integer) 29 | end 30 | ] 31 | # firewalls: [{ id: Firewall }] 32 | }, 33 | volumes: [Volume] 34 | ) 35 | 36 | protectable :delete, :rebuild 37 | updatable :name 38 | destructible 39 | 40 | has_actions 41 | has_metrics 42 | 43 | def enable_rescue(type: 'linux64', ssh_keys: []) 44 | query = COLLECT_ARGS.call(__method__, binding) 45 | prepare_request('actions/enable_rescue', j: query) { |j| j[:root_password] } 46 | end 47 | 48 | def reset_password 49 | prepare_request('actions/reset_password', method: :post) { |j| j[:root_password] } 50 | end 51 | 52 | def create_image(description: nil, type: nil) 53 | query = COLLECT_ARGS.call(__method__, binding) 54 | prepare_request('actions/create_image', j: query) { |j| Image.new(client, j[:image]) } 55 | end 56 | 57 | def rebuild(image:) 58 | raise Hcloud::Error::InvalidInput, 'no image given' if image.blank? 59 | 60 | prepare_request('actions/rebuild', j: { image: image }) { |j| j[:root_password] } 61 | end 62 | 63 | def change_type(server_type:, upgrade_disk:) 64 | raise Hcloud::Error::InvalidInput, 'no server_type given' if server_type.blank? 65 | raise Hcloud::Error::InvalidInput, 'no upgrade_disk given' if upgrade_disk.nil? 66 | 67 | prepare_request('actions/change_type', j: COLLECT_ARGS.call(__method__, binding)) 68 | end 69 | 70 | # Specifying a backup window is not supported anymore. We keep this method 71 | # to ensure backwards compatibility, but ignore the argument if provided. 72 | def enable_backup(**_kwargs) 73 | prepare_request('actions/enable_backup', method: :post) 74 | end 75 | 76 | def attach_iso(iso:) 77 | raise Hcloud::Error::InvalidInput, 'no iso given' if iso.blank? 78 | 79 | prepare_request('actions/attach_iso', j: { iso: iso }) 80 | end 81 | 82 | def attach_to_network(network:, ip: nil, alias_ips: nil) 83 | raise Hcloud::Error::InvalidInput, 'no network given' if network.nil? 84 | 85 | prepare_request('actions/attach_to_network', j: COLLECT_ARGS.call(__method__, binding)) 86 | end 87 | 88 | def detach_from_network(network:) 89 | raise Hcloud::Error::InvalidInput, 'no network given' if network.nil? 90 | 91 | prepare_request('actions/detach_from_network', j: { network: network }) 92 | end 93 | 94 | def add_to_placement_group(placement_group:) 95 | raise Hcloud::Error::InvalidInput, 'no placement_group given' if placement_group.nil? 96 | 97 | prepare_request('actions/add_to_placement_group', j: COLLECT_ARGS.call(__method__, binding)) 98 | end 99 | 100 | def change_alias_ips(alias_ips:, network:) 101 | raise Hcloud::Error::InvalidInput, 'no alias_ips given' if alias_ips.to_a.count.zero? 102 | raise Hcloud::Error::InvalidInput, 'no network given' if network.nil? 103 | 104 | prepare_request('actions/change_alias_ips', j: COLLECT_ARGS.call(__method__, binding)) 105 | end 106 | 107 | def change_dns_ptr(ip:, dns_ptr:) 108 | raise Hcloud::Error::InvalidInput, 'no IP given' if ip.blank? 109 | raise Hcloud::Error::InvalidInput, 'no dns_ptr given' if dns_ptr.blank? 110 | 111 | prepare_request('actions/change_dns_ptr', j: COLLECT_ARGS.call(__method__, binding)) 112 | end 113 | 114 | %w[ 115 | poweron poweroff shutdown reboot reset 116 | disable_rescue disable_backup detach_iso 117 | request_console remove_from_placement_group 118 | ].each do |action| 119 | define_method(action) do 120 | prepare_request("actions/#{action}", method: :post) 121 | end 122 | end 123 | 124 | def request_console 125 | prepare_request('actions/request_console', method: :post) { |j| [j[:wss_url], j[:password]] } 126 | end 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /lib/hcloud/server_resource.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | class ServerResource < AbstractResource 5 | filter_attributes :status, :name, :label_selector 6 | 7 | bind_to Server 8 | 9 | def create(name:, 10 | server_type:, 11 | image:, datacenter: nil, 12 | location: nil, 13 | start_after_create: nil, 14 | ssh_keys: [], 15 | public_net: nil, 16 | firewalls: nil, 17 | networks: [], 18 | placement_group: nil, 19 | user_data: nil, 20 | volumes: nil, 21 | automount: nil, 22 | labels: {}) 23 | prepare_request('servers', j: COLLECT_ARGS.call(__method__, binding), 24 | expected_code: 201) do |response| 25 | [ 26 | Action.new(client, response.parsed_json[:action]), 27 | Server.new(client, response.parsed_json[:server]), 28 | response.parsed_json[:root_password], 29 | response.parsed_json[:next_actions].to_a.map do |action| 30 | Action.new(client, action) 31 | end 32 | ] 33 | end 34 | end 35 | 36 | def [](arg) 37 | case arg 38 | when Integer then find_by(id: arg) 39 | when String then find_by(name: arg) 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/hcloud/server_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | class ServerType 5 | require 'hcloud/server_type_resource' 6 | 7 | include EntryLoader 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/hcloud/server_type_resource.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | class ServerTypeResource < AbstractResource 5 | filter_attributes :name 6 | 7 | bind_to ServerType 8 | 9 | def [](arg) 10 | case arg 11 | when Integer then find_by(id: arg) 12 | when String then find_by(name: arg) 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/hcloud/ssh_key.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | class SSHKey 5 | require 'hcloud/ssh_key_resource' 6 | 7 | include EntryLoader 8 | 9 | schema( 10 | created: :time 11 | ) 12 | 13 | updatable :name 14 | destructible 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/hcloud/ssh_key_resource.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | class SSHKeyResource < AbstractResource 5 | filter_attributes :name, :label_selector, :fingerprint 6 | 7 | def [](arg) 8 | case arg 9 | when Integer then find_by(id: arg) 10 | when String then find_by(name: arg) 11 | end 12 | end 13 | 14 | def create(name:, public_key:, labels: {}) 15 | raise Hcloud::Error::InvalidInput, 'no name given' if name.blank? 16 | 17 | unless public_key.to_s.starts_with?('ssh') 18 | raise Hcloud::Error::InvalidInput, 'no valid SSH key given' 19 | end 20 | 21 | prepare_request( 22 | 'ssh_keys', j: COLLECT_ARGS.call(__method__, binding), 23 | expected_code: 201 24 | ) do |response| 25 | SSHKey.new(client, response.parsed_json[:ssh_key]) 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/hcloud/typhoeus_ext.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/core_ext/hash/indifferent_access' 4 | 5 | module Hcloud 6 | module TyphoeusExt 7 | Context = Struct.new(:client) 8 | 9 | ATTRIBUTES = %i[ 10 | block autoload_action resource_path 11 | resource_class expected_code 12 | ].freeze 13 | 14 | attr_accessor(*ATTRIBUTES) 15 | 16 | def self.collect_attributes(kwargs) 17 | hash = {} 18 | ATTRIBUTES.each do |key| 19 | hash[key] = kwargs.delete(key) if kwargs.key?(key) 20 | end 21 | hash 22 | end 23 | 24 | def attributes=(kwargs) 25 | kwargs.each do |key, value| 26 | public_send("#{key}=", value) 27 | end 28 | end 29 | 30 | def parsed_json 31 | return {} if code == 204 32 | 33 | @parsed_json ||= Oj.load(body, symbol_keys: true, mode: :compat).tap do |json| 34 | next unless request.hydra 35 | 36 | check_for_error( 37 | e_code: json.to_h.dig(:error, :code), 38 | e_message: json.to_h.dig(:error, :message) 39 | ) 40 | end 41 | rescue StandardError 42 | raise Error::UnexpectedError, "unable to load body: #{body}" 43 | end 44 | 45 | def context 46 | @context ||= Context.new 47 | end 48 | 49 | def check_for_error(e_code: nil, e_message: nil) 50 | case code 51 | when 401 then raise(Error::Unauthorized) 52 | when 0 then raise(Error::ServerError, "Connection error: #{return_code}") 53 | when 400...600 54 | raise _error_class(e_code || error_code), e_message || error_message 55 | end 56 | 57 | raise Error::UnexpectedError, body if expected_code && expected_code != code 58 | end 59 | 60 | def pagination 61 | @pagination ||= Pagination.new(nil, parsed_json[:meta].to_h[:pagination]) 62 | end 63 | 64 | def resource_attributes 65 | _resource = [@resource_path].flatten.compact.map(&:to_s).map(&:to_sym) 66 | return parsed_json if _resource.empty? 67 | 68 | parsed_json.dig(*_resource) 69 | end 70 | 71 | def [](arg) 72 | parsed_json[arg] 73 | end 74 | 75 | def resource 76 | action = parsed_json[:action] if autoload_action 77 | return [Action.new(self, action), block.call(self)].flatten(1) if block && action 78 | return block.call(self) if block 79 | 80 | @resource_class.from_response(self, autoload_action: autoload_action) 81 | end 82 | 83 | def error_code 84 | error[:code] 85 | end 86 | 87 | def error_message 88 | error[:message] 89 | end 90 | 91 | def error 92 | parsed_json[:error].to_h 93 | end 94 | 95 | def _error_class(code) 96 | case code 97 | when 'invalid_input' then Error::InvalidInput 98 | when 'forbidden' then Error::Forbidden 99 | when 'locked' then Error::Locked 100 | when 'not_found' then Error::NotFound 101 | when 'rate_limit_exceeded' then Error::RateLimitExceeded 102 | when 'resource_unavailable' then Error::ResourceUnavailable 103 | when 'service_error' then Error::ServiceError 104 | when 'uniqueness_error' then Error::UniquenessError 105 | else 106 | Error::ServerError 107 | end 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/hcloud/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | VERSION = '1.3.0' 5 | end 6 | -------------------------------------------------------------------------------- /lib/hcloud/volume.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | class Volume 5 | require 'hcloud/volume_resource' 6 | 7 | include EntryLoader 8 | 9 | schema( 10 | created: :time, 11 | location: Location 12 | ) 13 | 14 | protectable :delete 15 | updatable :name 16 | destructible 17 | 18 | has_actions 19 | 20 | def attach(server:, automount: nil) 21 | raise Hcloud::Error::InvalidInput, 'no server given' if server.nil? 22 | 23 | prepare_request('actions/attach', j: COLLECT_ARGS.call(__method__, binding)) 24 | end 25 | 26 | def detach 27 | prepare_request('actions/detach', method: :post) 28 | end 29 | 30 | def resize(size:) 31 | raise Hcloud::Error::InvalidInput, 'invalid size given' unless size.to_i > self.size 32 | 33 | prepare_request('actions/resize', j: COLLECT_ARGS.call(__method__, binding)) 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/hcloud/volume_resource.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | class VolumeResource < AbstractResource 5 | filter_attributes :name, :label_selector, :status 6 | 7 | def [](arg) 8 | case arg 9 | when Integer then find_by(id: arg) 10 | when String then find_by(name: arg) 11 | end 12 | end 13 | 14 | def create(size:, name:, automount: nil, format: nil, location: nil, server: nil, labels: {}) 15 | raise Hcloud::Error::InvalidInput, 'no name given' if name.blank? 16 | raise Hcloud::Error::InvalidInput, 'invalid size given' unless size.to_i >= 10 17 | if location.blank? && server.nil? 18 | raise Hcloud::Error::InvalidInput, 'location or server must be given' 19 | end 20 | 21 | prepare_request( 22 | 'volumes', j: COLLECT_ARGS.call(__method__, binding), 23 | expected_code: 201 24 | ) do |response| 25 | [ 26 | Action.new(client, response.parsed_json[:action]), 27 | Volume.new(client, response.parsed_json[:volume]), 28 | response.parsed_json[:next_actions].map do |action| 29 | Action.new(client, action) 30 | end 31 | ] 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/doubles/action_tests.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_context 'action tests' do 4 | def resources_name 5 | described_class.resource_class.name.demodulize.tableize 6 | end 7 | 8 | def resource_name 9 | resources_name.gsub(/s$/, '') 10 | end 11 | 12 | def generate_resources(other_resources) 13 | # An action has a field "resources" that gets populated with the types 14 | # and IDs of all affected resources. Often this is only the main resource 15 | # on which the action is executed, but sometimes multiple resources can 16 | # be affected, e.g. when a firewall (primary resource) gets attached to 17 | # a server (other resource). 18 | resources = other_resources.to_a.map do |resource_name| 19 | { id: 42, type: resource_name.to_s } 20 | end 21 | resources << { id: send(resource_name)[:id], type: resource_name } 22 | 23 | resources 24 | end 25 | 26 | def test_action(action, command = nil, params: nil, additional_resources: nil) 27 | command ||= action 28 | 29 | stub = stub_action(resources_name.to_sym, send(resource_name)[:id], action) do |req, _info| 30 | unless params.nil? 31 | expect(req).to have_body_params(a_hash_including(params.deep_stringify_keys)) 32 | end 33 | 34 | { 35 | action: build_action_resp( 36 | command, 37 | :running, 38 | resources: generate_resources(additional_resources) 39 | ) 40 | } 41 | end 42 | 43 | action = send("#{resource_name}_obj").send(action, **params.to_h) 44 | 45 | expect(stub.times_called).to eq(1) 46 | expect(action).to be_a(Hcloud::Action) 47 | expect(action.command).to eq(command.to_s) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/doubles/actions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_context 'actions doubles' do 4 | def action_status(status) 5 | case status 6 | when :running 7 | { 8 | status: :running, 9 | progress: Faker::Number.within(range: 0...100) 10 | } 11 | when :success 12 | { 13 | status: 'success', 14 | progress: 100 15 | } 16 | when :error 17 | { 18 | status: 'error', 19 | progress: Faker::Number.within(range: 0..100), 20 | error: { 21 | code: 'action_failed', 22 | message: 'action is failed' 23 | } 24 | } 25 | else 26 | raise "invalid action status: #{status.inspect}" 27 | end 28 | end 29 | 30 | def new_action(kind = nil, **kwargs) 31 | { 32 | id: Faker::Number.number, 33 | command: 'start_server', 34 | started: Faker::Time.backward, 35 | finished: random_choice(Faker::Time.backward, Faker::Time.forward), 36 | resources: [] 37 | } 38 | .merge(action_status(kind || random_choice(:error, :running, :success))) 39 | .deep_merge(kwargs) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/doubles/certificates.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_context 'certificates doubles' do 4 | def new_certificate(kwargs = {}) 5 | { 6 | id: Faker::Number.number, 7 | name: Faker::Internet.slug, 8 | created: Faker::Time.backward, 9 | type: random_choice(:uploaded, :managed), 10 | certificate: random_choice(nil, '-----BEGIN CERTIFICATE-----'), 11 | fingerprint: Faker::Crypto.md5.chars.each_slice(2).map(&:join).join(':'), 12 | domain_names: Array.new(Faker::Number.within(range: 0..5)).map do 13 | Faker::Internet.domain_name 14 | end, 15 | not_valid_after: random_choice(Faker::Time.forward, Faker::Time.backward), 16 | not_valid_before: random_choice(Faker::Time.forward, Faker::Time.backward), 17 | status: new_certificate_status, 18 | used_by: Array.new(Faker::Number.within(range: 0..3)).map do 19 | { id: Faker::Number.number, type: 'load_balancer' } 20 | end 21 | }.deep_merge(kwargs) 22 | end 23 | 24 | def new_certificate_status 25 | random_choice( 26 | nil, 27 | { 28 | issuance: random_choice(:pending, :completed, :failed), 29 | renewal: random_choice(:scheduled, :pending, :failed, :unavailable) 30 | } 31 | ) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/doubles/datacenters.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_context 'datacenters doubles' do 4 | def new_datacenter(kwargs = {}) 5 | { 6 | id: Faker::Number.number, 7 | name: Faker::Internet.slug, 8 | description: Faker::Lorem.sentence, 9 | location: new_location, 10 | server_types: { 11 | available: random_server_types, 12 | available_for_migration: random_server_types, 13 | supported: random_server_types 14 | } 15 | }.deep_merge(kwargs) 16 | end 17 | 18 | private 19 | 20 | def random_server_types 21 | server_types = 1..Faker::Number.within(range: 20..150) 22 | server_types.to_a.sample(Faker::Number.within(range: 0..20)) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/doubles/firewalls.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_context 'firewalls doubles' do 4 | def new_firewall(kwargs = {}) 5 | { 6 | id: Faker::Number.number, 7 | name: Faker::Internet.slug, 8 | created: Faker::Time.backward, 9 | applied_to: Array.new(Faker::Number.within(range: 0..10)).map { new_applied_to }, 10 | rules: Array.new(Faker::Number.within(range: 0..5)).map { new_rule } 11 | }.deep_merge(kwargs) 12 | end 13 | 14 | private 15 | 16 | def new_applied_to 17 | label_selector = { 18 | type: 'label_selector', 19 | label_selector: { selector: Faker::Lorem.words.join(' ') }, 20 | applied_to_resources: Array.new(Faker::Number.within(range: 0..5)).map do 21 | { type: 'server', server: { id: Faker::Number.number } } 22 | end 23 | } 24 | server = { 25 | type: 'server', 26 | server: { id: Faker::Number.number } 27 | } 28 | random_choice(label_selector, server) 29 | end 30 | 31 | def new_rule 32 | lower_port = Faker::Number.within(range: 1..65_535) 33 | upper_port = Faker::Number.within(range: lower_port..65_535) 34 | 35 | { 36 | description: Faker::Lorem.sentence, 37 | direction: random_choice('in', 'out'), 38 | source_ips: Array.new(Faker::Number.within(range: 0..10)).map do 39 | random_choice(Faker::Internet.ip_v4_cidr, Faker::Internet.ip_v6_cidr) 40 | end, 41 | destination_ips: Array.new(Faker::Number.within(range: 0..10)).map do 42 | random_choice(Faker::Internet.ip_v4_cidr, Faker::Internet.ip_v6_cidr) 43 | end, 44 | # port can be a single port or a port range 45 | port: random_choice(lower_port, "#{lower_port}-#{upper_port}"), 46 | protocol: random_choice('tcp', 'udp', 'icmp', 'esp', 'gre') 47 | } 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/doubles/floating_ips.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_context 'floating_ips doubles' do 4 | def new_floating_ip(kwargs = {}) 5 | ip_type = random_choice(:ipv4, :ipv6) 6 | 7 | { 8 | id: Faker::Number.number, 9 | ip: ip_type == :ipv4 ? Faker::Internet.ip_v4_address : Faker::Internet.ip_v6_address, 10 | type: ip_type, 11 | name: Faker::Internet.slug, 12 | description: Faker::Lorem.sentence, 13 | created: Faker::Time.backward, 14 | blocked: random_choice(true, false), 15 | dns_ptr: { 16 | dns_ptr: Faker::Internet.domain_name, 17 | ip: random_choice(Faker::Internet.ip_v4_address, Faker::Internet.ip_v6_address) 18 | }, 19 | home_location: new_location, 20 | server: random_choice(nil, Faker::Number.number), 21 | protection: { delete: random_choice(true, false) } 22 | }.deep_merge(kwargs) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/doubles/images.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_context 'images doubles' do 4 | def new_image(kwargs = {}) 5 | { 6 | id: Faker::Number.number, 7 | name: Faker::Internet.slug, 8 | description: Faker::Lorem.sentence, 9 | disk_size: Faker::Number.within(range: 10..100), 10 | image_size: Faker::Number.within(range: 0.1..10.0), 11 | # actually, Faker::Computer.os includes both flavor and version; so for our 12 | # fakes, we're using the platform (Linux, BSD, ...) for the OS flavor 13 | os_flavor: Faker::Computer.platform, 14 | os_version: Faker::Computer.os, 15 | protection: { delete: random_choice(true, false) }, 16 | rapid_deploy: random_choice(true, false), 17 | status: random_choice('available', 'unavailable', 'creating'), 18 | type: random_choice('snapshot', 'system', 'app', 'backup', 'temporary'), 19 | created: Faker::Time.backward, 20 | deprecated: Faker::Time.backward, 21 | deleted: random_choice(nil, Faker::Time.backward), 22 | bound_to: random_choice(nil, Faker::Number.number) 23 | }.deep_merge(kwargs) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/doubles/isos.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_context 'isos doubles' do 4 | def new_iso(kwargs = {}) 5 | { 6 | id: Faker::Number.number, 7 | name: Faker::Internet.slug, 8 | description: Faker::Lorem.sentence, 9 | type: random_choice(:public, :private), 10 | deprecated: Faker::Time.backward 11 | }.deep_merge(kwargs) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/doubles/load_balancer_types.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_context 'load_balancer_types doubles' do 4 | def new_load_balancer_type(kwargs = {}) 5 | { 6 | id: Faker::Number.number, 7 | name: Faker::Internet.slug, 8 | description: Faker::Lorem.sentence, 9 | max_assigned_certificates: Faker::Number.within(range: 10..50), 10 | max_connections: Faker::Number.number, 11 | max_services: Faker::Number.within(range: 5..30), 12 | max_targets: Faker::Number.within(range: 25..150), 13 | deprecated: random_choice(nil, Faker::Time.backward), 14 | prices: Array.new(Faker::Number.within(range: 1..3)).map { new_load_balancer_price } 15 | }.deep_merge(kwargs) 16 | end 17 | 18 | private 19 | 20 | def new_load_balancer_price 21 | { 22 | location: random_choice('fsn1', 'hel1', 'nbg1', 'hil', 'ash'), 23 | price_hourly: { 24 | gross: Faker::Number.decimal(l_digits: 2, r_digits: 6).to_s, 25 | net: Faker::Number.decimal(l_digits: 2, r_digits: 6).to_s 26 | }, 27 | price_monthly: { 28 | gross: Faker::Number.decimal(l_digits: 2, r_digits: 6).to_s, 29 | net: Faker::Number.decimal(l_digits: 2, r_digits: 6).to_s 30 | } 31 | } 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/doubles/load_balancers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_context 'load_balancers doubles' do 4 | def new_load_balancer(kwargs = {}) 5 | { 6 | id: Faker::Number.number, 7 | name: Faker::Internet.slug, 8 | algorithm: { type: random_choice('round_robin', 'least_connections') }, 9 | created: Faker::Time.backward, 10 | included_traffic: Faker::Number.number, 11 | ingoing_traffic: Faker::Number.number, 12 | outgoing_traffic: Faker::Number.number, 13 | private_net: Array.new(Faker::Number.within(range: 1..5)).map { new_load_balancer_priv_net }, 14 | public_net: { 15 | enabled: random_choice(true, false), 16 | ipv4: { 17 | dns_ptr: random_choice(nil, Faker::Internet.domain_name), 18 | ip: random_choice(nil, Faker::Internet.public_ip_v4_address) 19 | }, 20 | ipv6: { 21 | dns_ptr: random_choice(nil, Faker::Internet.domain_name), 22 | ip: random_choice(nil, Faker::Internet.ip_v6_address) 23 | } 24 | }, 25 | protection: { delete: random_choice(true, false) }, 26 | services: Array.new(Faker::Number.within(range: 0..5)).map { new_load_balancer_service }, 27 | targets: Array.new(Faker::Number.within(range: 0..20)).map { new_load_balancer_target } 28 | }.deep_merge(kwargs) 29 | end 30 | 31 | def new_load_balancer_service 32 | # choose a few random status codes from a list of examples 33 | http_codes = [ 34 | '2??', '3??', '4??', '5??', 35 | '2*', '3*', '4*', '5*', 36 | '200', '300', '400', '500' 37 | ] 38 | status_codes = http_codes.sample(Faker::Number.within(range: 1..4)).join(',') 39 | 40 | { 41 | listen_port: Faker::Number.within(range: 1..65_535), 42 | destination_port: Faker::Number.within(range: 1..65_535), 43 | protocol: random_choice('tcp', 'http', 'https'), 44 | proxyprotocol: random_choice(true, false), 45 | http: { 46 | certificates: Array.new(Faker::Number.within(range: 0..5)).map { Faker::Number.number }, 47 | cookie_lifetime: Faker::Number.within(range: 60..600), 48 | cookie_name: Faker::Internet.slug, 49 | redirect_http: random_choice(true, false), 50 | sticky_sessions: random_choice(true, false) 51 | }, 52 | health_check: { 53 | protocol: random_choice('tcp', 'http'), 54 | port: Faker::Number.within(range: 1..65_535), 55 | interval: Faker::Number.within(range: 10..60), 56 | retries: Faker::Number.within(range: 1..10), 57 | timeout: Faker::Number.within(range: 10..60), 58 | http: { 59 | domain: random_choice(nil, Faker::Internet.domain_name), 60 | path: random_choice('/', "/#{Faker::Internet.slug}"), 61 | response: random_choice(nil, Faker::Lorem.sentence), 62 | status_codes: status_codes, 63 | tls: random_choice(true, false) 64 | } 65 | } 66 | } 67 | end 68 | 69 | def new_load_balancer_target(allowed_types = %i[server ip label_selector]) 70 | chosen_type = allowed_types.sample 71 | 72 | case chosen_type 73 | when :server 74 | { 75 | server: { id: Faker::Number.number }, 76 | health_status: { 77 | listen_port: Faker::Number.within(range: 1..65_535), 78 | status: random_choice('healthy', 'unhealthy', 'unknown') 79 | }, 80 | use_private_ip: random_choice(true, false), 81 | type: chosen_type.to_s 82 | } 83 | when :ip 84 | { 85 | ip: { ip: random_choice(Faker::Internet.ip_v4_address, Faker::Internet.ip_v6_address) }, 86 | health_status: { 87 | listen_port: Faker::Number.within(range: 1..65_535), 88 | status: random_choice('healthy', 'unhealthy', 'unknown') 89 | }, 90 | type: chosen_type.to_s 91 | } 92 | when :label_selector 93 | { 94 | label_selector: { selector: Faker::Lorem.words(number: 4).join(' ') }, 95 | targets: Array.new(Faker::Number.within(range: 0..5)).map do 96 | new_load_balancer_target([:server]) 97 | end 98 | } 99 | end 100 | end 101 | 102 | private 103 | 104 | def new_load_balancer_priv_net 105 | { 106 | ip: Faker::Internet.public_ip_v4_address, 107 | network: Faker::Number.number 108 | } 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /spec/doubles/locations.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_context 'locations doubles' do 4 | def new_location(kwargs = {}) 5 | { 6 | id: Faker::Number.number, 7 | name: Faker::Internet.slug, 8 | description: Faker::Lorem.sentence, 9 | city: Faker::Address.city, 10 | country: Faker::Address.country, 11 | latitude: Faker::Address.latitude, 12 | longitude: Faker::Address.longitude, 13 | network_zone: random_choice('eu-central', 'us-west', 'us-east') 14 | }.deep_merge(kwargs) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/doubles/networks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_context 'networks doubles' do 4 | def new_network(kwargs = {}) 5 | { 6 | id: Faker::Number.number, 7 | name: Faker::Internet.slug, 8 | ip_range: Faker::Internet.ip_v4_cidr, 9 | created: Faker::Time.backward, 10 | load_balancers: Array.new(Faker::Number.within(range: 0..10)).map { Faker::Number.number }, 11 | servers: Array.new(Faker::Number.within(range: 0..10)).map { Faker::Number.number }, 12 | protection: { delete: random_choice(true, false) }, 13 | routes: Array.new(Faker::Number.within(range: 0..10)).map { new_route }, 14 | subnets: Array.new(Faker::Number.within(range: 0..10)).map { new_subnet } 15 | }.deep_merge(kwargs) 16 | end 17 | 18 | private 19 | 20 | def new_route 21 | { 22 | destination: Faker::Internet.ip_v4_cidr, 23 | gateway: Faker::Internet.private_ip_v4_address 24 | } 25 | end 26 | 27 | def new_subnet 28 | { 29 | gateway: Faker::Internet.private_ip_v4_address, 30 | ip_range: Faker::Internet.ip_v4_cidr, 31 | network_zone: random_choice('eu-central', 'us-west', 'eu-west'), 32 | type: 'cloud' 33 | } 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/doubles/placement_groups.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_context 'placement_groups doubles' do 4 | def new_placement_group(kwargs = {}) 5 | { 6 | id: Faker::Number.number, 7 | name: Faker::Internet.slug, 8 | servers: [], 9 | created: Faker::Time.backward, 10 | type: 'spread' 11 | }.deep_merge(kwargs) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/doubles/primary_ips.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_context 'primary_ips doubles' do 4 | def new_primary_ip(kwargs = {}) 5 | ip_type = random_choice(:ipv4, :ipv6) 6 | 7 | { 8 | id: Faker::Number.number, 9 | ip: ip_type == :ipv4 ? Faker::Internet.ip_v4_address : Faker::Internet.ip_v6_cidr, 10 | type: ip_type, 11 | name: Faker::Internet.slug, 12 | auto_delete: random_choice(true, false), 13 | created: Faker::Time.backward, 14 | blocked: random_choice(true, false), 15 | assignee_id: random_choice(nil, Faker::Number.number), 16 | assignee_type: 'server', 17 | dns_ptr: { 18 | dns_ptr: Faker::Internet.domain_name, 19 | ip: random_choice(Faker::Internet.ip_v4_address, Faker::Internet.ip_v6_address) 20 | }, 21 | datacenter: new_datacenter, 22 | protection: { delete: random_choice(true, false) } 23 | }.deep_merge(kwargs) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/doubles/server_types.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_context 'server_types doubles' do 4 | def new_server_type(kwargs = {}) 5 | { 6 | id: Faker::Number.number, 7 | name: Faker::Internet.slug, 8 | description: Faker::Lorem.sentence, 9 | cores: Faker::Number.within(range: 1..32), 10 | cpu_type: random_choice('shared', 'dedicated'), 11 | deprecated: random_choice(true, false), 12 | disk: Faker::Number.within(range: 1..500), 13 | memory: Faker::Number.within(range: 1..128), 14 | prices: { 15 | location: random_choice('fsn1', 'nbg1', 'hel1', 'ash', 'hil'), 16 | price_hourly: { 17 | net: Faker::Number.decimal(l_digits: 2, r_digits: 2), 18 | gross: Faker::Number.decimal(l_digits: 2, r_digits: 2) 19 | }, 20 | price_monthly: { 21 | net: Faker::Number.decimal(l_digits: 2, r_digits: 2), 22 | gross: Faker::Number.decimal(l_digits: 2, r_digits: 2) 23 | } 24 | }, 25 | storage_type: random_choice('local', 'network') 26 | }.deep_merge(kwargs) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/doubles/servers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_context 'servers doubles' do 4 | def image 5 | { 6 | id: Faker::Number.number, 7 | "type": random_choice(:system, :snapshot, :backup), 8 | "status": random_choice(:available, :creating), 9 | "name": 'ubuntu-16.04', 10 | "description": 'Ubuntu 16.04 Standard 64 bit', 11 | "image_size": Faker::Number.decimal(l_digits: 1, r_digits: 3), 12 | "disk_size": Faker::Number.within(range: 25..1000), 13 | "created": Faker::Time.backward, 14 | "created_from": { 15 | "id": 1, 16 | "name": 'Server' 17 | }, 18 | "bound_to": nil, 19 | "os_flavor": 'ubuntu', 20 | "os_version": '16.04', 21 | "rapid_deploy": random_choice(true, false), 22 | "protection": { 23 | "delete": random_choice(true, false) 24 | }, 25 | "deprecated": random_choice(nil, Faker::Time.backward), 26 | "labels": {} 27 | } 28 | end 29 | 30 | def iso 31 | { 32 | id: Faker::Number.number, 33 | name: 'FreeBSD-11.0-RELEASE-amd64-dvd1', 34 | description: 'FreeBSD 11.0 x64', 35 | type: random_choice(:private, :public), 36 | deprecated: random_choice(nil, Faker::Time.backward) 37 | } 38 | end 39 | 40 | def datacenter(**kwargs) 41 | { 42 | id: Faker::Number.number, 43 | name: "#{random_choice('fsn1', 'ngb1', 'hel1')}-dc#{Faker::Number.within(range: 1..50)}", 44 | "description": 'Falkenstein 1 DC 8', 45 | "location": { 46 | "id": 1, 47 | "name": 'fsn1', 48 | "description": 'Falkenstein DC Park 1', 49 | "country": 'DE', 50 | "city": 'Falkenstein', 51 | "latitude": 50.47612, 52 | "longitude": 12.370071, 53 | "network_zone": 'eu-central' 54 | }, 55 | "server_types": { 56 | "supported": [ 57 | 1, 58 | 2, 59 | 3 60 | ], 61 | "available": [ 62 | 1, 63 | 2, 64 | 3 65 | ], 66 | "available_for_migration": [ 67 | 1, 68 | 2, 69 | 3 70 | ] 71 | } 72 | }.deep_merge(kwargs) 73 | end 74 | 75 | def server_type 76 | { 77 | id: Faker::Number.number, 78 | name: "#{random_choice('cx', 'ccx')}#{Faker::Number.within(range: 1..90)}", 79 | cores: Faker::Number.within(range: 1..30), 80 | memory: Faker::Number.within(range: 1..200), 81 | disk: Faker::Number.within(range: 25..1000), 82 | deprecated: random_choice(true, false), 83 | prices: [ 84 | { 85 | location: random_choice('fsn1', 'hel1', 'nbg1'), 86 | price_hourly: { 87 | net: Faker::Number.decimal(l_digits: 2) 88 | }, 89 | price_monthly: { 90 | net: Faker::Number.decimal(l_digits: 2) 91 | } 92 | } 93 | ], 94 | storage_type: random_choice(:local, :network), 95 | cpu_type: random_choice(:shared, :dedicated) 96 | }.tap do |hash| 97 | hash[:description] = hash[:name].to_s.upcase 98 | %i[price_hourly price_monthly].each do |kind| 99 | price = hash[:prices][0][kind] 100 | price[:gross] = price[:net] * 1.19 101 | end 102 | end 103 | end 104 | 105 | def new_server(**kwargs) 106 | { 107 | id: Faker::Number.number, 108 | name: Faker::Internet.slug(glue: '-'), 109 | status: random_choice( 110 | :running, :initializing, :starting, :stopping, :off, :deleting, :migrating, :rebuilding, :unknown 111 | ), 112 | created: Faker::Time.backward, 113 | public_net: new_server_public_net, 114 | private_net: [{ 115 | alias_ips: [], 116 | ip: '10.0.0.2', 117 | mac_address: '86:00:ff:2a:7d:e1', 118 | network: 4711 119 | }], 120 | server_type: server_type, 121 | datacenter: datacenter, 122 | image: random_choice(nil, image), 123 | iso: random_choice(nil, iso), 124 | rescue_enabled: random_choice(true, false), 125 | locked: random_choice(true, false), 126 | backup_window: '22-02', 127 | outgoing_traffic: Faker::Number.number, 128 | ingoing_traffic: Faker::Number.number, 129 | included_traffic: Faker::Number.number, 130 | protection: { delete: random_choice(true, false), rebuild: random_choice(true, false) }, 131 | placement_group: random_choice(nil, new_placement_group), 132 | load_balancers: Array.new(Faker::Number.within(range: 0..3)).map { Faker::Number.number }, 133 | labels: {}, 134 | volumes: Array.new(Faker::Number.within(range: 0..2)).map { new_volume } 135 | }.deep_merge(kwargs) 136 | end 137 | 138 | private 139 | 140 | def new_server_public_net 141 | { 142 | ipv4: { 143 | id: Faker::Number.number, 144 | ip: Faker::Internet.ip_v4_address, 145 | blocked: random_choice(true, false), 146 | dns_ptr: Faker::Internet.domain_name 147 | }, 148 | ipv6: { 149 | id: Faker::Number.number, 150 | ip: Faker::Internet.ip_v6_cidr, 151 | blocked: random_choice(true, false), 152 | dns_ptr: random_choice( 153 | [], 154 | [{ ip: Faker::Internet.ip_v6_address, dns_ptr: Faker::Internet.domain_name }] 155 | ) 156 | }, 157 | firewalls: Array.new(Faker::Number.within(range: 0..5)).map do 158 | { id: Faker::Number.number, status: 'applied' } 159 | end, 160 | floating_ips: Array.new(Faker::Number.within(range: 0..3)).map { Faker::Number.number } 161 | } 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /spec/doubles/ssh_keys.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_context 'ssh_keys doubles' do 4 | def new_ssh_key(kwargs = {}) 5 | { 6 | id: Faker::Number.number, 7 | name: Faker::Internet.slug, 8 | created: Faker::Time.backward, 9 | fingerprint: Faker::Crypto.md5.chars.each_slice(2).map(&:join).join(':'), 10 | # not really a SSH key, but should be enough for tests 11 | public_key: "#{random_choice('ssh-rsa', 'ssh-ed25519')} " \ 12 | "#{Faker::Lorem.characters} #{Faker::Internet.slug}" 13 | }.deep_merge(kwargs) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/doubles/volumes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_context 'volumes doubles' do 4 | def new_volume(kwargs = {}) 5 | { 6 | id: Faker::Number.number, 7 | name: Faker::Internet.slug(glue: '-'), 8 | created: Faker::Time.backward, 9 | format: random_choice(nil, 'ext4', 'xfs'), 10 | linux_device: '/dev/disk/by-id/scsi-0HC_Volume_1234', 11 | location: new_location, 12 | protection: { delete: random_choice(true, false), rebuild: random_choice(true, false) }, 13 | server: nil, 14 | size: Faker::Number.within(range: 25..1000), 15 | status: 'available' 16 | }.deep_merge(kwargs) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/fake_service/action.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'time' 4 | 5 | module Hcloud 6 | module FakeService 7 | $ACTION_ID = 0 8 | $ACTIONS = { 9 | 'actions' => [], 10 | 'meta' => { 11 | 'pagination' => { 12 | 'page' => 1, 13 | 'per_page' => 25, 14 | 'previous_page' => nil, 15 | 'next_page' => nil, 16 | 'last_page' => 1, 17 | 'total_entries' => 2 18 | } 19 | } 20 | } 21 | 22 | class Action < Grape::API 23 | class << self 24 | def add(h = {}) 25 | a = { 26 | id: $ACTION_ID += 1, 27 | progress: 0, 28 | started: Time.now.iso8601, 29 | finished: nil, 30 | error: nil 31 | }.merge(h).deep_stringify_keys 32 | $ACTIONS['actions'] << a 33 | a 34 | end 35 | 36 | def reset 37 | $ACTION_ID = 0 38 | $ACTIONS['actions'].clear 39 | end 40 | 41 | def resource_actions(resource_type, resource_id) 42 | actions = $ACTIONS.deep_dup 43 | actions['actions'].select! do |action| 44 | action['resources'].to_a.any? do |res| 45 | (res.to_h['type'] == resource_type) && (res.to_h['id'].to_s == resource_id.to_s) 46 | end 47 | end 48 | actions 49 | end 50 | 51 | def resource_action(resource_type, resource_id, action_id) 52 | actions = resource_actions(resource_type, resource_id) 53 | 54 | actions['actions'].find do |action| 55 | action['id'].to_s == action_id.to_s 56 | end 57 | end 58 | end 59 | group :actions do 60 | params do 61 | requires :id, type: Integer 62 | end 63 | route_param :id do 64 | before_validation do 65 | @x = $ACTIONS['actions'].find { |x| x['id'].to_s == params[:id].to_s } 66 | error!({ error: { code: :not_found } }, 404) if @x.nil? 67 | end 68 | get do 69 | { action: @x } 70 | end 71 | end 72 | 73 | params do 74 | optional :status, type: String 75 | optional :per_page, type: Integer 76 | optional :page, type: Integer 77 | optional :sort, type: String 78 | end 79 | get do 80 | dc = $ACTIONS.deep_dup 81 | unless params[:status].nil? 82 | dc['actions'].select! { |x| x['status'].to_s == params[:status].to_s } 83 | end 84 | dc['actions'].shuffle! 85 | unless params[:sort].nil? 86 | dc['actions'].sort_by! { |x| x[params[:sort].split(':')[0]] } 87 | dc['actions'].reverse! if params[:sort].end_with?(':desc') 88 | end 89 | FakeService.pagination_wrapper(dc, 'actions', params[:per_page], params[:page]) 90 | end 91 | end 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /spec/fake_service/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'grape' 4 | 5 | module Hcloud 6 | module FakeService 7 | # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity 8 | def self.pagination_wrapper(object, key, per_page, page) 9 | o = object.deep_dup 10 | per_page ||= 25 11 | page ||= 1 12 | per_page = 50 if per_page > 50 13 | per_page = 25 if per_page < 1 14 | page = 1 if page < 1 15 | low = per_page * (page - 1) 16 | high = per_page * page 17 | last_page = (o[key].size / per_page) + ((o[key].size % per_page).zero? ? 0 : 1) 18 | o['meta'] ||= {} 19 | o['meta']['pagination'] = { 20 | 'page' => page, 21 | 'per_page' => per_page, 22 | 'previous_page' => page > 1 ? page - 1 : nil, 23 | 'next_page' => page < last_page ? page + 1 : nil, 24 | 'last_page' => last_page, 25 | 'total_entries' => o[key].size 26 | } 27 | o[key] = o[key][low...high].to_a 28 | o 29 | end 30 | # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity 31 | 32 | def self.label_selector_matches(label_selector, resource_labels) 33 | resource_labels ||= {} 34 | 35 | # only implements a subset of the label selector query language 36 | # to support selectors like "key=value,key2" 37 | selectors = label_selector.split(',') 38 | selectors.all? do |selector| 39 | key, value = selector.split('=', 2) 40 | value = '' if value.nil? # API uses "" to denote labels without values 41 | 42 | resource_labels.key?(key) && resource_labels[key] == value 43 | end 44 | end 45 | 46 | class Base < Grape::API 47 | version 'v1', using: :path 48 | 49 | format :json 50 | 51 | before do 52 | next if headers['Authorization'] == 'Bearer secure' 53 | 54 | error!('Unauthorized', 401) 55 | end 56 | 57 | require_relative './action' 58 | require_relative './server' 59 | require_relative './image' 60 | require_relative './iso' 61 | require_relative './server_type' 62 | require_relative './floating_ip' 63 | require_relative './ssh_key' 64 | require_relative './location' 65 | require_relative './datacenter' 66 | require_relative './firewall' 67 | require_relative './network' 68 | 69 | mount Action 70 | mount Server 71 | mount Image 72 | mount ISO 73 | mount ServerType 74 | mount FloatingIP 75 | mount Datacenter 76 | mount Location 77 | mount SSHKey 78 | mount Firewall 79 | mount Network 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /spec/fake_service/datacenter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | module FakeService 5 | $DATACENTERS = { 6 | 'datacenters' => [ 7 | { 8 | 'id' => 1, 9 | 'name' => 'fsn1-dc8', 10 | 'description' => 'Falkenstein 1 DC 8', 11 | 'location' => { 12 | 'id' => 1, 13 | 'name' => 'fsn1', 14 | 'description' => 'Falkenstein DC Park 1', 15 | 'country' => 'DE', 16 | 'city' => 'Falkenstein', 17 | 'latitude' => 50.47612, 18 | 'longitude' => 12.370071 19 | }, 20 | 'server_types' => { 21 | 'supported' => [2, 4, 6, 8, 10, 9, 7, 5, 3, 1], 22 | 'available' => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] 23 | } 24 | }, { 25 | 'id' => 2, 26 | 'name' => 'nbg1-dc3', 27 | 'description' => 'Nuremberg 1 DC 3', 28 | 'location' => { 29 | 'id' => 2, 30 | 'name' => 'nbg1', 31 | 'description' => 'Nuremberg DC Park 1', 32 | 'country' => 'DE', 33 | 'city' => 'Nuremberg', 34 | 'latitude' => 49.452102, 35 | 'longitude' => 11.076665 36 | }, 37 | 'server_types' => { 38 | 'supported' => [2, 4, 6, 8, 10, 9, 7, 5, 3, 1], 39 | 'available' => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] 40 | } 41 | } 42 | ], 43 | 'recommendation' => 2, 44 | 'meta' => { 45 | 'pagination' => { 46 | 'page' => 1, 47 | 'per_page' => 25, 48 | 'previous_page' => nil, 49 | 'next_page' => nil, 50 | 'last_page' => 1, 51 | 'total_entries' => 2 52 | } 53 | } 54 | } 55 | 56 | class Datacenter < Grape::API 57 | group :datacenters do 58 | params do 59 | requires :id, type: Integer 60 | end 61 | route_param :id do 62 | get do 63 | x = $DATACENTERS['datacenters'].find { |x| x['id'] == params[:id] } 64 | error!({ error: { code: :not_found } }, 404) if x.nil? 65 | { datacenter: x } 66 | end 67 | end 68 | 69 | params do 70 | optional :name, type: String 71 | end 72 | get do 73 | if params.key?(:name) 74 | if params[:name].to_s.include?('-') 75 | dc = $DATACENTERS.deep_dup 76 | dc['datacenters'].select! { |x| x['name'] == params[:name] } 77 | dc 78 | else 79 | error!({ error: { code: :invalid_input } }, 400) 80 | end 81 | else 82 | $DATACENTERS 83 | end 84 | end 85 | end 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /spec/fake_service/image.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | module FakeService 5 | $IMAGES = { 6 | 'images' => [ 7 | { 8 | 'id' => 1, 9 | 'type' => 'system', 10 | 'status' => 'available', 11 | 'name' => 'ubuntu-16.04', 12 | 'description' => 'Ubuntu 16.04', 13 | 'image_size' => nil, 14 | 'disk_size' => 5, 15 | 'created' => '2018-01-15T11:34:45+00:00', 16 | 'created_from' => nil, 17 | 'bound_to' => nil, 18 | 'os_flavor' => 'ubuntu', 19 | 'os_version' => '16.04', 20 | 'rapid_deploy' => true, 21 | 'deprecated' => '2018-02-28T00:00:00+00:00', 22 | 'protection' => { 23 | 'delete' => false 24 | } 25 | }, 26 | { 27 | 'id' => 3454, 28 | 'type' => 'snapshot', 29 | 'status' => 'available', 30 | 'name' => nil, 31 | 'description' => 'snapshot image created at 2018-02-02 10:28:21', 32 | 'image_size' => 0.64352086328125, 33 | 'disk_size' => 20, 34 | 'created' => '2018-02-02T10:28:21+00:00', 35 | 'created_from' => { 'id' => 497_533, 'name' => 'moo5' }, 36 | 'bound_to' => nil, 37 | 'os_flavor' => 'ubuntu', 38 | 'os_version' => nil, 39 | 'rapid_deploy' => false, 40 | 'deprecated' => nil, 41 | 'protection' => { 42 | 'delete' => false 43 | } 44 | } 45 | ], 46 | 'meta' => { 47 | 'pagination' => { 48 | 'page' => 1, 49 | 'per_page' => 25, 50 | 'previous_page' => nil, 51 | 'next_page' => nil, 52 | 'last_page' => 1, 53 | 'total_entries' => 2 54 | } 55 | } 56 | } 57 | 58 | class Image < Grape::API 59 | group :images do 60 | params do 61 | requires :id, type: Integer 62 | end 63 | route_param :id do 64 | before_validation do 65 | @x = $IMAGES['images'].find { |x| x['id'].to_s == params[:id].to_s } 66 | error!({ error: { code: :not_found } }, 404) if @x.nil? 67 | end 68 | get do 69 | { image: @x } 70 | end 71 | 72 | params do 73 | optional :description, type: String 74 | optional :type, type: String 75 | end 76 | put do 77 | if !params[:description].nil? && @x['id'] != 3454 && @x.nil? 78 | error!({ error: { code: :not_found } }, 404) 79 | end 80 | if !params[:type].nil? && @x['id'] != 3454 81 | if %w[backup system snapshot].include?(params[:type]) 82 | error!({ error: { code: :not_found } }, 400) 83 | else 84 | error!({ error: { code: :invalid_input } }, 400) 85 | end 86 | end 87 | case params[:type] 88 | when 'system' 89 | error!({ error: { code: :service_error } }, 400) 90 | when 'backup' 91 | error!({ error: { code: :service_error } }, 400) 92 | end 93 | @x['description'] = params[:description] unless params[:description].nil? 94 | @x['type'] = params[:type] unless params[:type].nil? 95 | @x['labels'] = params[:labels] unless params[:labels].nil? 96 | { image: @x } 97 | end 98 | 99 | group :actions do 100 | params do 101 | optional :status, type: String 102 | optional :sort, type: String 103 | end 104 | get do 105 | dc = $ACTIONS.deep_dup 106 | dc['actions'].select! do |x| 107 | x['resources'].to_a.any? do |y| 108 | (y.to_h['type'] == 'image') && (y.to_h['id'].to_s == @x['id'].to_s) 109 | end 110 | end 111 | unless params[:status].nil? 112 | dc['actions'].select! do |x| 113 | x['status'].to_s == params[:status].to_s 114 | end 115 | end 116 | dc 117 | end 118 | 119 | params do 120 | optional :delete, type: Boolean 121 | end 122 | post :change_protection do 123 | a = { 'action' => Action.add(command: 'change_protection', status: 'success', 124 | resources: [{ id: @x['id'].to_i, type: 'image' }]) } 125 | @x['protection']['delete'] = params[:delete] unless params[:delete].nil? 126 | a 127 | end 128 | end 129 | 130 | delete do 131 | $IMAGES['images'].delete(@x) 132 | '' 133 | end 134 | end 135 | 136 | params do 137 | optional :name, type: String 138 | optional :type, type: String 139 | optional :bound_to, type: String 140 | end 141 | get do 142 | dc = $IMAGES.deep_dup 143 | dc['images'].select! { |x| x['name'] == params[:name] } if params[:name]&.size&.positive? 144 | dc['images'].select! { |x| x['type'] == params[:type] } if params[:type]&.size&.positive? 145 | unless params[:bound_to].nil? 146 | dc['images'].select! { |x| x['bound_to'].to_s == params[:bound_to].to_s } 147 | end 148 | unless params[:label_selector].nil? 149 | dc['images'].select! do |x| 150 | FakeService.label_selector_matches(params[:label_selector], x['labels']) 151 | end 152 | end 153 | dc 154 | end 155 | end 156 | end 157 | end 158 | end 159 | -------------------------------------------------------------------------------- /spec/fake_service/iso.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | module FakeService 5 | $ISOS = { 6 | 'isos' => [ 7 | { 8 | 'id' => 26, 9 | 'name' => 'virtio-win-0.1.141.iso', 10 | 'description' => 'virtio 0.1.141-1', 11 | 'type' => 'public', 12 | 'deprecated' => nil 13 | } 14 | ], 15 | 'meta' => { 16 | 'pagination' => { 17 | 'page' => 1, 18 | 'per_page' => 25, 19 | 'previous_page' => nil, 20 | 'next_page' => nil, 21 | 'last_page' => 1, 22 | 'total_entries' => 1 23 | } 24 | } 25 | } 26 | 27 | class ISO < Grape::API 28 | group :isos do 29 | params do 30 | requires :id, type: Integer 31 | end 32 | route_param :id do 33 | before_validation do 34 | @x = $ISOS['isos'].find { |x| x['id'].to_s == params[:id].to_s } 35 | error!({ error: { code: :not_found } }, 404) if @x.nil? 36 | end 37 | get do 38 | { iso: @x } 39 | end 40 | end 41 | 42 | params do 43 | optional :name, type: String 44 | end 45 | get do 46 | dc = $ISOS.deep_dup 47 | dc['isos'].select! { |x| x['name'] == params[:name] } unless params[:name].nil? 48 | dc 49 | end 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /spec/fake_service/location.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | module FakeService 5 | $LOCATIONS = { 6 | 'locations' => [ 7 | { 8 | 'id' => 1, 9 | 'name' => 'fsn1', 10 | 'description' => 'Falkenstein DC Park 1', 11 | 'country' => 'DE', 12 | 'city' => 'Falkenstein', 13 | 'latitude' => 50.47612, 14 | 'longitude' => 12.370071 15 | }, { 16 | 'id' => 2, 17 | 'name' => 'nbg1', 18 | 'description' => 'Nuremberg DC Park 1', 19 | 'country' => 'DE', 20 | 'city' => 'Nuremberg', 21 | 'latitude' => 49.452102, 22 | 'longitude' => 11.076665 23 | } 24 | ], 25 | 'meta' => { 26 | 'pagination' => { 27 | 'page' => 1, 28 | 'per_page' => 25, 29 | 'previous_page' => nil, 30 | 'next_page' => nil, 31 | 'last_page' => 1, 32 | 'total_entries' => 2 33 | } 34 | } 35 | } 36 | 37 | class Location < Grape::API 38 | group :locations do 39 | params do 40 | requires :id, type: Integer 41 | end 42 | route_param :id do 43 | get do 44 | x = $LOCATIONS['locations'].find { |x| x['id'] == params[:id] } 45 | error!({ error: { code: :not_found } }, 404) if x.nil? 46 | { location: x } 47 | end 48 | end 49 | 50 | params do 51 | optional :name, type: String 52 | end 53 | get do 54 | if params.key?(:name) 55 | dc = $LOCATIONS.deep_dup 56 | dc['locations'].select! { |x| x['name'] == params[:name] } 57 | dc 58 | else 59 | $LOCATIONS 60 | end 61 | end 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /spec/fake_service/server_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | module FakeService 5 | $SERVER_TYPES = { 6 | 'server_types' => [ 7 | { 8 | 'id' => 1, 9 | 'name' => 'cx11', 10 | 'description' => 'CX11', 11 | 'cores' => 1, 12 | 'memory' => 2.0, 13 | 'disk' => 20, 14 | 'prices' => [ 15 | { 16 | 'location' => 'fsn1', 17 | 'price_hourly' => { 18 | 'net' => '0.0040000000', 19 | 'gross' => '0.0047600000000000' 20 | }, 21 | 'price_monthly' => { 22 | 'net' => '2.4900000000', 23 | 'gross' => '2.9631000000000000' 24 | } 25 | }, 26 | { 27 | 'location' => 'nbg1', 28 | 'price_hourly' => { 29 | 'net' => '0.0040000000', 30 | 'gross' => '0.0047600000000000' 31 | }, 32 | 'price_monthly' => { 33 | 'net' => '2.4900000000', 34 | 'gross' => '2.9631000000000000' 35 | } 36 | } 37 | ], 38 | 'storage_type' => 'local' 39 | } 40 | ], 41 | 'meta' => { 42 | 'pagination' => { 43 | 'page' => 1, 44 | 'per_page' => 25, 45 | 'previous_page' => nil, 46 | 'next_page' => nil, 47 | 'last_page' => 1, 48 | 'total_entries' => 2 49 | } 50 | } 51 | } 52 | 53 | class ServerType < Grape::API 54 | group :server_types do 55 | params do 56 | requires :id, type: Integer 57 | end 58 | route_param :id do 59 | get do 60 | x = $SERVER_TYPES['server_types'].find { |x| x['id'] == params[:id] } 61 | error!({ error: { code: :not_found } }, 404) if x.nil? 62 | { server_type: x } 63 | end 64 | end 65 | 66 | params do 67 | optional :name, type: String 68 | end 69 | get do 70 | if params.key?(:name) 71 | dc = $SERVER_TYPES.deep_dup 72 | dc['server_types'].select! { |x| x['name'] == params[:name] } 73 | dc 74 | else 75 | $SERVER_TYPES 76 | end 77 | end 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /spec/fake_service/ssh_key.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hcloud 4 | module FakeService 5 | $SSH_KEY_ID = 0 6 | $SSH_KEYS = { 7 | 'ssh_keys' => [], 8 | 'meta' => { 9 | 'pagination' => { 10 | 'page' => 1, 11 | 'per_page' => 25, 12 | 'previous_page' => nil, 13 | 'next_page' => nil, 14 | 'last_page' => 1, 15 | 'total_entries' => 2 16 | } 17 | } 18 | } 19 | 20 | class SSHKey < Grape::API 21 | group :ssh_keys do 22 | params do 23 | requires :id, type: Integer 24 | end 25 | route_param :id do 26 | before_validation do 27 | @x = $SSH_KEYS['ssh_keys'].find { |x| x['id'].to_s == params[:id].to_s } 28 | error!({ error: { code: :not_found } }, 404) if @x.nil? 29 | end 30 | get do 31 | { ssh_key: @x } 32 | end 33 | 34 | params do 35 | optional :name, type: String 36 | end 37 | put do 38 | @x['name'] = params[:name] unless params[:name].nil? 39 | @x['labels'] = params[:labels] unless params[:labels].nil? 40 | { ssh_key: @x } 41 | end 42 | 43 | delete do 44 | $SSH_KEYS['ssh_keys'].delete(@x) 45 | @body = nil 46 | status 204 47 | end 48 | end 49 | 50 | params do 51 | optional :name, type: String 52 | optional :public_key, type: String 53 | end 54 | post do 55 | error!({ error: { code: :invalid_input } }, 400) if params[:name].nil? 56 | unless params[:public_key].to_s.start_with?('ssh-') 57 | error!({ error: { code: :invalid_input } }, 400) 58 | end 59 | if $SSH_KEYS['ssh_keys'].any? { |x| params[:public_key] == x['public_key'] } 60 | error!({ error: { code: :uniqueness_error } }, 400) 61 | end 62 | if $SSH_KEYS['ssh_keys'].any? { |x| params[:name] == x['name'] } 63 | error!({ error: { code: :uniqueness_error } }, 400) 64 | end 65 | key = { 66 | 'id' => $SSH_KEY_ID += 1, 67 | 'name' => params[:name], 68 | 'fingerprint' => 0.upto(15).map { rand(0..255) }.map { |num| num.to_s(16) }.join(':'), 69 | 'public_key' => params[:public_key], 70 | 'labels' => params[:labels] 71 | } 72 | $SSH_KEYS['ssh_keys'] << key 73 | { ssh_key: key } 74 | end 75 | 76 | params do 77 | optional :name, type: String 78 | end 79 | get do 80 | ssh_keys = $SSH_KEYS.deep_dup 81 | 82 | ssh_keys['ssh_keys'].select! { |x| x['name'] == params[:name] } unless params[:name].nil? 83 | unless params[:label_selector].nil? 84 | ssh_keys['ssh_keys'].select! do |x| 85 | FakeService.label_selector_matches(params[:label_selector], x['labels']) 86 | end 87 | end 88 | 89 | ssh_keys 90 | end 91 | end 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /spec/hcloud/action_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Hcloud::Action, doubles: :action do 6 | let :actions do 7 | Array.new(Faker::Number.within(range: 20..150)).map { new_action } 8 | end 9 | 10 | let(:action) { actions.first } 11 | 12 | context 'client - pagination' do 13 | def run_query 14 | client = Hcloud::Client.connection 15 | 16 | random_choice(true, false) ? client.actions.to_a : Hcloud::Action.all.to_a 17 | end 18 | 19 | it 'checks auto pagination' do 20 | actions = Array.new(Faker::Number.within(range: 101..150)).map { new_action } 21 | pages = [] 22 | page_count = [] 23 | stub_collection(:actions, actions) do |request, _page_info| 24 | page_count << request.url[/per_page=(\d+)/, 1] 25 | pages << request.url[/[^_]page=(\d+)/, 1] 26 | end 27 | run_query 28 | expect(pages).to eq(%w[1 1 2 3]) 29 | expect(page_count).to eq(%w[1 50 50 50]) 30 | end 31 | 32 | it 'stops on single action' do 33 | pages = [] 34 | page_count = [] 35 | stub_collection(:actions, [new_action]) do |request, _page_info| 36 | page_count << request.url[/per_page=(\d+)/, 1] 37 | pages << request.url[/[^_]page=(\d+)/, 1] 38 | end 39 | run_query 40 | expect(pages).to eq(%w[1]) 41 | expect(page_count).to eq(%w[1]) 42 | end 43 | 44 | it "won't break on empty result set" do 45 | pages = [] 46 | page_count = [] 47 | stub_collection(:actions, []) do |request, _page_info| 48 | page_count << request.url[/per_page=(\d+)/, 1] 49 | pages << request.url[/[^_]page=(\d+)/, 1] 50 | end 51 | run_query 52 | expect(pages).to eq(%w[1]) 53 | expect(page_count).to eq(%w[1]) 54 | end 55 | 56 | it 'queries actions manually and concurrently' do 57 | actions = Array.new(Faker::Number.within(range: 100..150)).map { new_action } 58 | pages = [] 59 | page_count = [] 60 | client = Hcloud::Client.new(token: 'moo') 61 | stub_collection(:actions, actions) do |request, _page_info| 62 | expect(request.hydra).to eq(client.hydra) 63 | page_count << request.url[/per_page=(\d+)/, 1] 64 | pages << request.url[/[^_]page=(\d+)/, 1] 65 | end 66 | fetched_actions = client.concurrent do 67 | [ 68 | client.actions.page(1).per_page(50), 69 | client.actions.page(2).per_page(50), 70 | client.actions.page(5).per_page(25), 71 | client.actions.page(6).per_page(25) 72 | ] 73 | end 74 | expect(fetched_actions.flat_map(&:to_a).map(&:id)).to eq(actions.map { |x| x[:id] }) 75 | expect(pages).to eq(%w[1 2 5 6]) 76 | expect(page_count).to eq(%w[50 50 25 25]) 77 | end 78 | end 79 | 80 | it 'GET /actions' do 81 | stub_collection :actions, actions 82 | expect(Hcloud::Action.all.to_a.size).to eq(actions.size) 83 | expect(actions.map { |action| action[:id] }).to eq(Hcloud::Action.all.map(&:id)) 84 | end 85 | 86 | it 'GET /actions?status=running' do 87 | stub_collection :actions, actions do |request, _page_info| 88 | expect(request.url).to include('status=running') 89 | end 90 | expect(Hcloud::Action.where(status: :running).map(&:id)).to( 91 | eq(actions.select { |x| x[:status] == :running }.map { |x| x[:id] }.compact) 92 | ) 93 | end 94 | 95 | it 'GET /actions/:id' do 96 | stub "actions/#{action[:id]}" do |_request, _page_info| 97 | { 98 | body: { action: action }, 99 | code: 200 100 | } 101 | end 102 | 103 | expect(Hcloud::Action.find(action[:id])).to be_a Hcloud::Action 104 | action.each do |key, value| 105 | expect(Hcloud::Action.find(action[:id]).public_send(key)).to eq(non_sym(value)) 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /spec/hcloud/base_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe 'Generic' do 6 | let :client do 7 | Hcloud::Client.new(token: 'secure') 8 | end 9 | 10 | let :uclient do 11 | Hcloud::Client.new(token: 'invalid') 12 | end 13 | 14 | it 'preload all constants' do 15 | Hcloud.constants.each do |klass| 16 | Hcloud.send(:const_get, klass) 17 | end 18 | end 19 | 20 | it 'check authorized' do 21 | expect(client.authorized?).to be(true) 22 | end 23 | 24 | it 'check unauthorized' do 25 | expect(uclient.authorized?).to be(false) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/hcloud/certificate_actions_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/all' 4 | require 'spec_helper' 5 | 6 | describe Hcloud::Certificate, doubles: :certificate do 7 | include_context 'test doubles' 8 | include_context 'action tests' 9 | 10 | let :certificates do 11 | Array.new(Faker::Number.within(range: 10..50)).map { new_certificate } 12 | end 13 | 14 | let(:certificate) { certificates.sample } 15 | 16 | let :certificate_obj do 17 | stub_item(:certificates, certificate) 18 | client.certificates[certificate[:id]] 19 | end 20 | 21 | let :client do 22 | Hcloud::Client.new(token: 'secure') 23 | end 24 | 25 | context '#retry' do 26 | it 'works' do 27 | test_action(:retry, :issue_certificate) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/hcloud/certificate_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/all' 4 | require 'spec_helper' 5 | require 'support/it_supports_fetch' 6 | require 'support/it_supports_search' 7 | require 'support/it_supports_find_by_id_and_name' 8 | require 'support/it_supports_update' 9 | require 'support/it_supports_destroy' 10 | require 'support/it_supports_labels_on_update' 11 | require 'support/it_supports_action_fetch' 12 | 13 | describe Hcloud::Certificate, doubles: :certificate do 14 | let :certificates do 15 | Array.new(Faker::Number.within(range: 20..150)).map { new_certificate } 16 | end 17 | 18 | let(:certificate) { certificates.sample } 19 | 20 | let :client do 21 | Hcloud::Client.new(token: 'secure') 22 | end 23 | 24 | include_examples 'it_supports_fetch', described_class 25 | include_examples 'it_supports_search', described_class, %i[name label_selector type] 26 | include_examples 'it_supports_find_by_id_and_name', described_class 27 | include_examples 'it_supports_update', described_class, { name: 'new_name' } 28 | include_examples 'it_supports_destroy', described_class 29 | include_examples 'it_supports_labels_on_update', described_class 30 | include_examples 'it_supports_action_fetch', described_class 31 | 32 | context '#create' do 33 | it 'handle missing name' do 34 | params = { 35 | name: nil, 36 | type: :managed, 37 | domain_names: ['example.com'] 38 | } 39 | expect { client.certificates.create(**params) }.to( 40 | raise_error(Hcloud::Error::InvalidInput) 41 | ) 42 | end 43 | 44 | it 'handles missing domains for type "managed"' do 45 | params = { 46 | name: 'moo', 47 | type: :managed, 48 | domain_names: [] 49 | } 50 | expect { client.certificates.create(**params) }.to( 51 | raise_error(Hcloud::Error::InvalidInput) 52 | ) 53 | end 54 | 55 | it 'handles missing certificate for type "uploaded"' do 56 | params = { 57 | name: 'moo', 58 | type: :uploaded, 59 | certificate: nil, 60 | private_key: 'secret' 61 | } 62 | expect { client.certificates.create(**params) }.to( 63 | raise_error(Hcloud::Error::InvalidInput) 64 | ) 65 | end 66 | 67 | it 'handles missing private_key for type "uploaded"' do 68 | params = { 69 | name: 'moo', 70 | type: :uploaded, 71 | certificate: 'cert', 72 | private_key: nil 73 | } 74 | expect { client.certificates.create(**params) }.to( 75 | raise_error(Hcloud::Error::InvalidInput) 76 | ) 77 | end 78 | 79 | context 'works' do 80 | it 'with managed certificate' do 81 | params = { name: 'moo', type: 'managed', domain_names: ['example.com'] } 82 | 83 | expectation = stub_create( 84 | :certificate, 85 | params, 86 | action: new_action(:running, command: 'create_certificate') 87 | ) 88 | 89 | action, certificate = client.certificates.create(**params) 90 | expect(expectation.times_called).to eq(1) 91 | 92 | expect(action).to be_a Hcloud::Action 93 | expect(certificate).to be_a described_class 94 | expect(certificate).to have_attributes(id: a_kind_of(Integer), name: 'moo') 95 | end 96 | 97 | it 'with uploaded certificate' do 98 | params = { 99 | name: 'moo', 100 | type: 'uploaded', 101 | certificate: 'cert', 102 | private_key: 'secret' 103 | } 104 | # response does not include the private key 105 | response_params = { 106 | name: params[:name], 107 | certificate: params[:certificate] 108 | } 109 | expectation = stub_create( 110 | :certificate, 111 | params, 112 | response_params: response_params, 113 | action: new_action(:running, command: 'create_certificate') 114 | ) 115 | 116 | action, certificate = client.certificates.create(**params) 117 | expect(expectation.times_called).to eq(1) 118 | expect(action).to be_a Hcloud::Action 119 | expect(certificate).to be_a described_class 120 | expect(certificate).to have_attributes( 121 | id: a_kind_of(Integer), 122 | name: 'moo', 123 | certificate: 'cert' 124 | ) 125 | end 126 | end 127 | 128 | it 'validates uniq name' do 129 | params = { 130 | name: 'moo', 131 | type: :managed, 132 | domain_names: ['example.com'] 133 | } 134 | stub_error(:certificates, :post, 'uniqueness_error', 409) 135 | 136 | expect { client.certificates.create(**params) }.to( 137 | raise_error(Hcloud::Error::UniquenessError) 138 | ) 139 | end 140 | end 141 | end 142 | -------------------------------------------------------------------------------- /spec/hcloud/datacenter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'support/it_supports_fetch' 5 | require 'support/it_supports_search' 6 | require 'support/it_supports_find_by_id_and_name' 7 | 8 | describe Hcloud::Datacenter, doubles: :datacenter do 9 | let :datacenters do 10 | Array.new(Faker::Number.within(range: 20..150)).map { new_datacenter } 11 | end 12 | 13 | let(:datacenter) { datacenters.sample } 14 | 15 | let :client do 16 | Hcloud::Client.new(token: 'secure') 17 | end 18 | 19 | include_examples 'it_supports_fetch', described_class 20 | include_examples 'it_supports_search', described_class, %i[name] 21 | include_examples 'it_supports_find_by_id_and_name', described_class 22 | 23 | it '#recommended' do 24 | # using skip instead of pending, because due to randomness sometimes 25 | # this test will work successfully 26 | skip 'currently does not take into account the recommendation from the API' 27 | 28 | stub(:datacenters) do |_req, _info| 29 | { 30 | body: { 31 | datacenters: datacenters, 32 | recommendation: datacenter[:id] 33 | }, 34 | code: 200 35 | } 36 | end 37 | 38 | expect(client.datacenters.recommended).to be_a Hcloud::Datacenter 39 | expect(client.datacenters.recommended.id).to eq(datacenter[:id]) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/hcloud/firewall_actions_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/all' 4 | require 'spec_helper' 5 | 6 | describe Hcloud::Firewall, doubles: :firewall do 7 | include_context 'test doubles' 8 | include_context 'action tests' 9 | 10 | let :firewalls do 11 | Array.new(Faker::Number.within(range: 20..150)).map { new_firewall } 12 | end 13 | 14 | let(:firewall) { firewalls.sample } 15 | 16 | let :firewall_obj do 17 | stub_item(:firewalls, firewall) 18 | client.firewalls[firewall[:id]] 19 | end 20 | 21 | let :client do 22 | Hcloud::Client.new(token: 'secure') 23 | end 24 | 25 | def actions_resource_ids(actions) 26 | actions.map { |action| action.resources.map { |res| res['id'] } }.flatten 27 | end 28 | 29 | context '#apply_to_resources' do 30 | it 'handles missing apply_to' do 31 | expect { firewall_obj.apply_to_resources(apply_to: nil) }.to( 32 | raise_error(Hcloud::Error::InvalidInput) 33 | ) 34 | end 35 | 36 | it 'handles firewall_already_applied error' do 37 | stub_error( 38 | "firewalls/#{firewall[:id]}/actions/apply_to_resources", 39 | :post, 40 | :firewall_already_applied, 41 | 422 42 | ) 43 | 44 | apply_to = [{ 'server' => { 'id' => 42 }, 'type' => 'server' }] 45 | expect do 46 | firewall_obj.apply_to_resources(apply_to: apply_to) 47 | end.to raise_error Hcloud::Error::ServerError 48 | end 49 | 50 | it 'works' do 51 | apply_to = [ 52 | { 'server' => { 'id' => 42 }, 'type' => 'server' }, 53 | { 'server' => { 'id' => 1 }, 'type' => 'server' } 54 | ] 55 | test_action( 56 | :apply_to_resources, 57 | :apply_firewall, 58 | params: { apply_to: apply_to }, 59 | additional_resources: %i[server] 60 | ) 61 | end 62 | end 63 | 64 | context '#remove_from_resources' do 65 | it 'handles missing remove_from' do 66 | expect { firewall_obj.remove_from_resources(remove_from: nil) }.to( 67 | raise_error(Hcloud::Error::InvalidInput) 68 | ) 69 | end 70 | 71 | it 'handles firewall_already_removed error' do 72 | stub_error( 73 | "firewalls/#{firewall[:id]}/actions/remove_from_resources", 74 | :post, 75 | :firewall_already_removed, 76 | 422 77 | ) 78 | 79 | expect do 80 | firewall_obj.remove_from_resources( 81 | remove_from: [{ 'server' => { 'id' => 42 }, 'type' => 'server' }] 82 | ) 83 | end.to raise_error Hcloud::Error::ServerError 84 | end 85 | 86 | it 'works' do 87 | remove_from = [ 88 | { 'server' => { 'id' => 42 }, 'type' => 'server' }, 89 | { 'server' => { 'id' => 1 }, 'type' => 'server' } 90 | ] 91 | test_action( 92 | :remove_from_resources, 93 | :remove_firewall, 94 | params: { remove_from: remove_from }, 95 | additional_resources: %i[server] 96 | ) 97 | end 98 | end 99 | 100 | context '#set_rules' do 101 | it 'accepts nil to remove rules' do 102 | expectation = stub("firewalls/#{firewall[:id]}/actions/set_rules", :post) do |req, _info| 103 | expect(req).to have_body_params(a_hash_including({ 'rules' => [] })) 104 | 105 | { 106 | body: { 107 | actions: [ 108 | build_action_resp( 109 | :set_firewall_rules, :running, 110 | resources: [{ id: firewall[:id], type: 'firewall' }] 111 | ) 112 | ] 113 | }, 114 | code: 201 115 | } 116 | end 117 | 118 | firewall_obj.set_rules(rules: nil) 119 | expect(expectation.times_called).to eq(1) 120 | end 121 | 122 | it 'works' do 123 | rules = [ 124 | { protocol: 'tcp', port: 80, direction: 'in', source_ips: '0.0.0.0/0' } 125 | ] 126 | test_action( 127 | :set_rules, 128 | :set_firewall_rules, 129 | params: { rules: rules }, 130 | additional_resources: %i[server] 131 | ) 132 | end 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /spec/hcloud/firewall_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/all' 4 | require 'spec_helper' 5 | require 'support/it_supports_fetch' 6 | require 'support/it_supports_search' 7 | require 'support/it_supports_find_by_id_and_name' 8 | require 'support/it_supports_update' 9 | require 'support/it_supports_destroy' 10 | require 'support/it_supports_labels_on_update' 11 | require 'support/it_supports_action_fetch' 12 | 13 | describe Hcloud::Firewall, doubles: :firewall do 14 | let :firewalls do 15 | Array.new(Faker::Number.within(range: 20..150)).map { new_firewall } 16 | end 17 | 18 | let(:firewall) { firewalls.sample } 19 | 20 | let :client do 21 | Hcloud::Client.new(token: 'secure') 22 | end 23 | 24 | include_examples 'it_supports_fetch', described_class 25 | include_examples 'it_supports_search', described_class, %i[name label_selector] 26 | include_examples 'it_supports_find_by_id_and_name', described_class 27 | include_examples 'it_supports_update', described_class, { name: 'new_name' } 28 | include_examples 'it_supports_destroy', described_class 29 | include_examples 'it_supports_labels_on_update', described_class 30 | include_examples 'it_supports_action_fetch', described_class 31 | 32 | context '#create' do 33 | it 'handle missing name' do 34 | expect { client.firewalls.create(name: nil) }.to( 35 | raise_error(Hcloud::Error::InvalidInput) 36 | ) 37 | end 38 | 39 | context 'works' do 40 | it 'with required parameters' do 41 | params = { name: 'moo' } 42 | expectation = stub_create(:firewall, params, actions: []) 43 | 44 | actions, firewall = client.firewalls.create(**params) 45 | expect(expectation.times_called).to eq(1) 46 | 47 | expect(actions).to all be_a Hcloud::Action 48 | 49 | expect(firewall).to be_a described_class 50 | expect(firewall.id).to be_a Integer 51 | expect(firewall.created).to be_a Time 52 | expect(firewall.name).to eq('moo') 53 | end 54 | 55 | it 'with all parameters' do 56 | params = { 57 | name: 'moo', 58 | apply_to: [{ 59 | server: { id: 42 }, 60 | type: 'server' 61 | }], 62 | rules: [{ 63 | protocol: 'tcp', 64 | port: 80, 65 | direction: 'in', 66 | source_ips: ['192.0.2.0/24', '2001:db8::/32'] 67 | }], 68 | labels: { 'key' => 'value' } 69 | } 70 | response_params = { 71 | name: params[:name], 72 | applied_to: params[:apply_to], 73 | rules: params[:rules], 74 | labels: params[:labels] 75 | } 76 | stub_create( 77 | :firewall, 78 | params, 79 | response_params: response_params, 80 | actions: 81 | [ 82 | new_action(:running, command: 'apply_firewall'), 83 | new_action(:running, command: 'set_firewall_rules') 84 | ] 85 | ) 86 | 87 | actions, firewall = client.firewalls.create(**params) 88 | expect(actions).to all be_a Hcloud::Action 89 | 90 | expect(firewall).to be_a described_class 91 | expect(firewall.id).to be_a Integer 92 | expect(firewall.created).to be_a Time 93 | expect(firewall.name).to eq('moo') 94 | expect(firewall.rules).to eq(params[:rules].map(&:deep_stringify_keys)) 95 | expect(firewall.applied_to).to eq(params[:apply_to].map(&:deep_stringify_keys)) 96 | expect(firewall.labels).to eq(params[:labels]) 97 | end 98 | 99 | it 'with IPv6 ::/0' do 100 | # IPv6 global address ::/0 can cause some problems, because if parsed from JSON with 101 | # the wrong parser settings it gets interpreted as a Ruby symbol. This results 102 | # in the deletion of the first : character. 103 | params = { 104 | name: 'moo', 105 | rules: [{ 106 | protocol: 'tcp', 107 | port: 443, 108 | direction: 'in', 109 | source_ips: ['::/0'] 110 | }] 111 | } 112 | stub_create( 113 | :firewall, 114 | params, 115 | actions: [new_action(:running, command: 'set_firewall_rules')] 116 | ) 117 | 118 | _actions, firewall = client.firewalls.create(**params) 119 | expect(firewall.rules[0][:source_ips]).to eq(['::/0']) 120 | end 121 | end 122 | 123 | it 'validates uniq name' do 124 | stub_error(:firewalls, :post, 'uniqueness_error', 409) 125 | 126 | expect { client.firewalls.create(name: 'moo') }.to( 127 | raise_error(Hcloud::Error::UniquenessError) 128 | ) 129 | end 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /spec/hcloud/floating_ip_actions_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/all' 4 | require 'spec_helper' 5 | 6 | describe Hcloud::FloatingIP, doubles: :floating_ip do 7 | include_context 'test doubles' 8 | include_context 'action tests' 9 | 10 | let :floating_ips do 11 | Array.new(Faker::Number.within(range: 20..150)).map { new_floating_ip } 12 | end 13 | 14 | let(:floating_ip) { floating_ips.sample } 15 | 16 | let :client do 17 | Hcloud::Client.new(token: 'secure') 18 | end 19 | 20 | let :floating_ip_obj do 21 | stub_item(:floating_ips, floating_ip) 22 | client.floating_ips[floating_ip[:id]] 23 | end 24 | 25 | context '#assign' do 26 | it 'handles missing server ID' do 27 | expect do 28 | floating_ip_obj.assign(server: nil) 29 | end.to raise_error Hcloud::Error::InvalidInput 30 | end 31 | 32 | it 'works' do 33 | test_action( 34 | :assign, 35 | :assign_floating_ip, 36 | params: { server: 42 }, 37 | additional_resources: %i[server] 38 | ) 39 | end 40 | end 41 | 42 | context '#unassign' do 43 | it 'works' do 44 | test_action( 45 | :unassign, 46 | :unassign_floating_ip, 47 | additional_resources: %i[server] 48 | ) 49 | end 50 | end 51 | 52 | context '#change_dns_ptr' do 53 | it 'handles missing ip' do 54 | expect { floating_ip_obj.change_dns_ptr(ip: nil, dns_ptr: 'example.com') }.to( 55 | raise_error(Hcloud::Error::InvalidInput) 56 | ) 57 | end 58 | 59 | it 'works' do 60 | test_action(:change_dns_ptr, params: { ip: '2001:db8::1', dns_ptr: 'example.com' }) 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/hcloud/floating_ip_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/all' 4 | require 'spec_helper' 5 | require 'support/it_supports_fetch' 6 | require 'support/it_supports_search' 7 | require 'support/it_supports_find_by_id_and_name' 8 | require 'support/it_supports_update' 9 | require 'support/it_supports_destroy' 10 | require 'support/it_supports_labels_on_update' 11 | require 'support/it_supports_action_fetch' 12 | 13 | describe Hcloud::FloatingIP, doubles: :floating_ip do 14 | let :floating_ips do 15 | Array.new(Faker::Number.within(range: 20..150)).map { new_floating_ip } 16 | end 17 | 18 | let(:floating_ip) { floating_ips.sample } 19 | 20 | let :client do 21 | Hcloud::Client.new(token: 'secure') 22 | end 23 | 24 | include_examples 'it_supports_fetch', described_class 25 | include_examples 'it_supports_search', described_class, %i[name label_selector] 26 | include_examples 'it_supports_find_by_id_and_name', described_class 27 | include_examples 'it_supports_update', \ 28 | described_class, \ 29 | { name: 'new_name', description: 'new_desc' } 30 | include_examples 'it_supports_destroy', described_class 31 | include_examples 'it_supports_labels_on_update', described_class 32 | include_examples 'it_supports_action_fetch', described_class 33 | 34 | context '#create' do 35 | it 'handle missing type' do 36 | expect { client.floating_ips.create(type: nil, home_location: 'fsn1') }.to( 37 | raise_error(Hcloud::Error::InvalidInput) 38 | ) 39 | end 40 | 41 | it 'handle missing home_location and server' do 42 | expect do 43 | client.floating_ips.create(type: 'ipv4', home_location: nil, server: nil) 44 | end.to raise_error(Hcloud::Error::InvalidInput) 45 | end 46 | 47 | it 'works' do 48 | params = { 49 | type: 'ipv4', 50 | name: 'moo', 51 | home_location: 'fsn1', 52 | labels: { 'key' => 'value', 'novalue' => '' } 53 | } 54 | response_params = { 55 | type: params[:type], 56 | name: params[:name], 57 | home_location: floating_ip[:home_location], 58 | labels: params[:labels] 59 | } 60 | expectation = stub_create(:floating_ip, params, response_params: response_params) 61 | 62 | _action, key = client.floating_ips.create(**params) 63 | expect(expectation.times_called).to eq(1) 64 | 65 | expect(key).to be_a described_class 66 | expect(key.id).to be_a Integer 67 | expect(key.name).to be_a String 68 | expect(key.type).to eq('ipv4') 69 | expect(key.name).to eq('moo') 70 | expect(key.home_location).to be_a Hcloud::Location 71 | expect(key.created).to be_a Time 72 | expect(key.labels).to eq(params[:labels]) 73 | end 74 | 75 | it 'validates uniq name' do 76 | stub_error(:floating_ips, :post, 'uniqueness_error', 409) 77 | 78 | expect do 79 | client.floating_ips.create(name: 'moo', type: 'ipv4', home_location: 'fsn1') 80 | end.to raise_error(Hcloud::Error::UniquenessError) 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /spec/hcloud/image_actions_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/all' 4 | require 'spec_helper' 5 | 6 | describe Hcloud::Image, doubles: :image do 7 | include_context 'test doubles' 8 | include_context 'action tests' 9 | 10 | let :images do 11 | Array.new(Faker::Number.within(range: 20..150)).map { new_image } 12 | end 13 | 14 | let(:image) { images.sample } 15 | 16 | let :client do 17 | Hcloud::Client.new(token: 'secure') 18 | end 19 | 20 | let :image_obj do 21 | stub_item(:images, image) 22 | client.images[image[:id]] 23 | end 24 | 25 | context '#change_protection' do 26 | it 'works' do 27 | test_action(:change_protection, params: { delete: true }) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/hcloud/image_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'support/it_supports_fetch' 5 | require 'support/it_supports_search' 6 | require 'support/it_supports_find_by_id_and_name' 7 | require 'support/it_supports_update' 8 | require 'support/it_supports_destroy' 9 | require 'support/it_supports_labels_on_update' 10 | require 'support/it_supports_action_fetch' 11 | 12 | describe Hcloud::Image, doubles: :image do 13 | let :images do 14 | Array.new(Faker::Number.within(range: 20..150)).map { new_image } 15 | end 16 | 17 | let(:image) { images.sample } 18 | 19 | let :client do 20 | Hcloud::Client.new(token: 'secure') 21 | end 22 | 23 | include_examples 'it_supports_fetch', described_class 24 | include_examples 'it_supports_search', described_class, \ 25 | %i[type status bound_to include_deprecated name label_selector] 26 | include_examples 'it_supports_find_by_id_and_name', described_class 27 | include_examples 'it_supports_update', described_class, { description: 'new description' } 28 | include_examples 'it_supports_destroy', described_class 29 | include_examples 'it_supports_labels_on_update', described_class 30 | include_examples 'it_supports_action_fetch', described_class 31 | 32 | it '#to_snapshot' do 33 | expectation = stub_update(:image, image, { type: 'snapshot' }) 34 | stub_item(:images, image) 35 | 36 | client.images[image[:id]].to_snapshot 37 | 38 | expect(expectation.times_called).to eq(1) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/hcloud/iso_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'support/it_supports_fetch' 5 | require 'support/it_supports_search' 6 | require 'support/it_supports_find_by_id_and_name' 7 | 8 | describe Hcloud::Iso, doubles: :iso do 9 | let :isos do 10 | Array.new(Faker::Number.within(range: 20..150)).map { new_iso } 11 | end 12 | 13 | let(:iso) { isos.sample } 14 | 15 | let :client do 16 | Hcloud::Client.new(token: 'secure') 17 | end 18 | 19 | include_examples 'it_supports_fetch', described_class 20 | include_examples 'it_supports_search', described_class, %i[name] 21 | include_examples 'it_supports_find_by_id_and_name', described_class 22 | end 23 | -------------------------------------------------------------------------------- /spec/hcloud/load_balancer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/all' 4 | require 'spec_helper' 5 | require 'support/it_supports_fetch' 6 | require 'support/it_supports_search' 7 | require 'support/it_supports_find_by_id_and_name' 8 | require 'support/it_supports_update' 9 | require 'support/it_supports_destroy' 10 | require 'support/it_supports_labels_on_update' 11 | require 'support/it_supports_metrics' 12 | 13 | describe Hcloud::LoadBalancer, doubles: :load_balancer do 14 | let :load_balancers do 15 | Array.new(Faker::Number.within(range: 10..50)).map { new_load_balancer } 16 | end 17 | 18 | let(:load_balancer) { load_balancers.sample } 19 | 20 | let :client do 21 | Hcloud::Client.new(token: 'secure') 22 | end 23 | 24 | include_examples 'it_supports_fetch', described_class 25 | include_examples 'it_supports_search', described_class, %i[name label_selector] 26 | include_examples 'it_supports_find_by_id_and_name', described_class 27 | include_examples 'it_supports_update', described_class, { name: 'new_name' } 28 | include_examples 'it_supports_destroy', described_class 29 | include_examples 'it_supports_labels_on_update', described_class 30 | include_examples 'it_supports_metrics', described_class, \ 31 | %i[open_connections connections_per_second requests_per_second 32 | bandwidth] 33 | 34 | context '#create' do 35 | let :required_params do 36 | { 37 | name: 'test-lb', 38 | load_balancer_type: 'lb11', 39 | algorithm: { type: 'round_robin' }, 40 | location: 'fsn1' 41 | } 42 | end 43 | 44 | it 'handles missing name' do 45 | required_params[:name] = nil 46 | expect { client.load_balancers.create(**required_params) }.to( 47 | raise_error(Hcloud::Error::InvalidInput) 48 | ) 49 | end 50 | 51 | it 'handles missing type' do 52 | required_params[:load_balancer_type] = nil 53 | expect { client.load_balancers.create(**required_params) }.to( 54 | raise_error(Hcloud::Error::InvalidInput) 55 | ) 56 | end 57 | 58 | it 'handles missing algorithm' do 59 | required_params[:algorithm] = nil 60 | expect do 61 | client.load_balancers.create(**required_params) 62 | end.to raise_error(Hcloud::Error::InvalidInput) 63 | end 64 | 65 | it 'handles blank algoritm' do 66 | required_params[:algorithm] = { type: '' } 67 | expect do 68 | client.load_balancers.create(**required_params) 69 | end.to raise_error(Hcloud::Error::InvalidInput) 70 | end 71 | 72 | it 'handles missing network_zone and location' do 73 | required_params[:location] = nil 74 | required_params[:network_zone] = nil 75 | expect do 76 | client.load_balancers.create(**required_params) 77 | end.to raise_error(Hcloud::Error::InvalidInput) 78 | end 79 | 80 | context 'works' do 81 | it 'with required parameters' do 82 | response_params = { 83 | name: required_params[:name], 84 | load_balancer_type: new_load_balancer_type, 85 | algorithm: required_params[:algorithm], 86 | location: new_location 87 | } 88 | expectation = stub_create( 89 | :load_balancer, required_params, response_params: response_params 90 | ) 91 | 92 | _action, lb = client.load_balancers.create(**required_params) 93 | expect(expectation.times_called).to eq(1) 94 | 95 | expect(lb).to be_a Hcloud::LoadBalancer 96 | expect(lb.id).to be_a Integer 97 | expect(lb.created).to be_a Time 98 | expect(lb.name).to eq('test-lb') 99 | expect(lb.load_balancer_type).to be_a Hcloud::LoadBalancerType 100 | expect(lb.algorithm).to eq({ 'type' => 'round_robin' }) 101 | expect(lb.location).to be_a Hcloud::Location 102 | end 103 | 104 | it 'with all parameters' do 105 | params = required_params.dup 106 | params[:network] = 42 107 | params[:public_interface] = true 108 | params[:services] = { 109 | listen_port: 80, 110 | destination_port: 80, 111 | protocol: 'tcp', 112 | proxyprotocol: true, 113 | health_check: { 114 | interval: 60, 115 | port: 80, 116 | protocol: 'tcp', 117 | retries: 3, 118 | timeout: 10 119 | } 120 | } 121 | params[:targets] = { 122 | type: 'server', 123 | server: { id: 42 }, 124 | health_status: { 125 | listen_port: 80, 126 | status: 'healthy' 127 | } 128 | } 129 | params[:labels] = { 'key' => 'value', 'novalue' => '' } 130 | 131 | response_params = { 132 | name: params[:name], 133 | load_balancer_type: new_load_balancer_type, 134 | algorithm: params[:algorithm], 135 | location: new_location, 136 | services: params[:services], 137 | targets: params[:targets], 138 | labels: params[:labels] 139 | } 140 | 141 | expectation = stub_create(:load_balancer, params, response_params: response_params) 142 | 143 | _action, lb = client.load_balancers.create(**params) 144 | expect(expectation.times_called).to eq(1) 145 | 146 | expect(lb).to be_a described_class 147 | expect(lb.id).to be_a Integer 148 | expect(lb.name).to eq('test-lb') 149 | expect(lb.load_balancer_type).to be_a Hcloud::LoadBalancerType 150 | expect(lb.algorithm).to eq({ 'type' => 'round_robin' }) 151 | expect(lb.location).to be_a Hcloud::Location 152 | expect(lb.created).to be_a Time 153 | expect(lb.services).to eq(params[:services].deep_stringify_keys) 154 | expect(lb.labels).to eq(params[:labels].deep_stringify_keys) 155 | end 156 | end 157 | 158 | it 'validates uniq name' do 159 | stub_error(:load_balancers, :post, 'uniqueness_error', 409) 160 | 161 | expect do 162 | client.load_balancers.create(**required_params) 163 | end.to raise_error(Hcloud::Error::UniquenessError) 164 | end 165 | end 166 | end 167 | -------------------------------------------------------------------------------- /spec/hcloud/load_balancer_type_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'support/it_supports_fetch' 5 | require 'support/it_supports_search' 6 | require 'support/it_supports_find_by_id_and_name' 7 | 8 | describe Hcloud::LoadBalancerType, doubles: :load_balancer_type do 9 | let :load_balancer_types do 10 | Array.new(Faker::Number.within(range: 5..20)).map { new_load_balancer_type } 11 | end 12 | 13 | let(:load_balancer_type) { load_balancer_types.sample } 14 | 15 | let :client do 16 | Hcloud::Client.new(token: 'secure') 17 | end 18 | 19 | include_examples 'it_supports_fetch', described_class 20 | include_examples 'it_supports_search', described_class, %i[name] 21 | include_examples 'it_supports_find_by_id_and_name', described_class 22 | end 23 | -------------------------------------------------------------------------------- /spec/hcloud/location_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'support/it_supports_fetch' 5 | require 'support/it_supports_search' 6 | require 'support/it_supports_find_by_id_and_name' 7 | 8 | describe Hcloud::Location, doubles: :location do 9 | let :locations do 10 | Array.new(Faker::Number.within(range: 20..150)).map { new_location } 11 | end 12 | 13 | let(:location) { locations.sample } 14 | 15 | let :client do 16 | Hcloud::Client.new(token: 'secure') 17 | end 18 | 19 | include_examples 'it_supports_fetch', described_class 20 | include_examples 'it_supports_search', described_class, %i[name] 21 | include_examples 'it_supports_find_by_id_and_name', described_class 22 | end 23 | -------------------------------------------------------------------------------- /spec/hcloud/network_actions_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/all' 4 | require 'spec_helper' 5 | 6 | describe Hcloud::Network, doubles: :network do 7 | include_context 'test doubles' 8 | include_context 'action tests' 9 | 10 | let :networks do 11 | Array.new(Faker::Number.within(range: 20..150)).map { new_network } 12 | end 13 | 14 | let(:network) { networks.sample } 15 | 16 | let :client do 17 | Hcloud::Client.new(token: 'secure') 18 | end 19 | 20 | let :network_obj do 21 | stub_item(:networks, network) 22 | client.networks[network[:id]] 23 | end 24 | 25 | context '#add_route' do 26 | it 'handles missing destination' do 27 | expect do 28 | network_obj.add_route(destination: nil, gateway: '192.168.2.2') 29 | end.to raise_error Hcloud::Error::InvalidInput 30 | end 31 | 32 | it 'handles missing gateway' do 33 | expect do 34 | network_obj.add_route(destination: '192.168.0.0/24', gateway: nil) 35 | end.to raise_error Hcloud::Error::InvalidInput 36 | end 37 | 38 | it 'works' do 39 | test_action(:add_route, params: { destination: '192.168.0.0/24', gateway: '192.168.2.2' }) 40 | end 41 | end 42 | 43 | context '#delete_route' do 44 | it 'handles missing destination' do 45 | expect do 46 | network_obj.del_route(destination: nil, gateway: '192.168.2.2') 47 | end.to raise_error Hcloud::Error::InvalidInput 48 | end 49 | 50 | it 'handles missing gateway' do 51 | expect do 52 | network_obj.del_route(destination: '192.168.0.0/24', gateway: nil) 53 | end.to raise_error Hcloud::Error::InvalidInput 54 | end 55 | 56 | it 'works' do 57 | expectation = stub_action(:networks, network[:id], :delete_route) do |req, _info| 58 | expect(req).to have_body_params( 59 | a_hash_including( 60 | { 'destination' => '192.168.0.0/24', 'gateway' => '192.168.2.2' } 61 | ) 62 | ) 63 | 64 | { 65 | action: build_action_resp( 66 | :delete_route, :running, 67 | resources: [{ id: network[:id], type: 'network' }] 68 | ) 69 | } 70 | end 71 | 72 | action = network_obj.del_route(destination: '192.168.0.0/24', gateway: '192.168.2.2') 73 | expect(expectation.times_called).to eq(1) 74 | expect(action).to be_a(Hcloud::Action) 75 | expect(action.resources[0]['id']).to eq(network[:id]) 76 | end 77 | end 78 | 79 | context '#add_subnet' do 80 | it 'handles missing type' do 81 | expect do 82 | network_obj.add_subnet( 83 | ip_range: '10.0.0.0/24', network_zone: 'eu-central', type: nil 84 | ) 85 | end.to raise_error Hcloud::Error::InvalidInput 86 | end 87 | 88 | it 'handles missing network_zone' do 89 | expect do 90 | network_obj.add_subnet( 91 | ip_range: '10.0.0.0/24', network_zone: nil, type: 'cloud' 92 | ) 93 | end.to raise_error Hcloud::Error::InvalidInput 94 | end 95 | 96 | it 'works' do 97 | test_action( 98 | :add_subnet, 99 | params: { ip_range: '10.0.0.0/24', network_zone: 'eu-central', type: 'cloud' } 100 | ) 101 | end 102 | end 103 | 104 | context '#delete_subnet' do 105 | it 'handles missing ip_range' do 106 | expect do 107 | network_obj.del_subnet(ip_range: nil) 108 | end.to raise_error Hcloud::Error::InvalidInput 109 | end 110 | 111 | it 'works' do 112 | expectation = stub_action(:networks, network[:id], :delete_subnet) do |req, _info| 113 | expect(req).to have_body_params(a_hash_including({ 'ip_range' => '10.0.0.0/24' })) 114 | 115 | { 116 | action: build_action_resp( 117 | :delete_subnet, :running, 118 | resources: [{ id: network[:id], type: 'network' }] 119 | ) 120 | } 121 | end 122 | 123 | action = network_obj.del_subnet(ip_range: '10.0.0.0/24') 124 | expect(expectation.times_called).to eq(1) 125 | expect(action).to be_a(Hcloud::Action) 126 | expect(action.resources[0]['id']).to eq(network[:id]) 127 | end 128 | end 129 | 130 | context '#change_ip_range' do 131 | it 'handles missing ip_range' do 132 | expect do 133 | network_obj.change_ip_range(ip_range: nil) 134 | end.to raise_error Hcloud::Error::InvalidInput 135 | end 136 | 137 | it 'works' do 138 | test_action(:change_ip_range, params: { ip_range: '10.0.0.0/24' }) 139 | end 140 | end 141 | 142 | context '#change_protection' do 143 | it 'works' do 144 | test_action(:change_protection, params: { delete: true }) 145 | end 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /spec/hcloud/network_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/all' 4 | require 'spec_helper' 5 | require 'support/it_supports_fetch' 6 | require 'support/it_supports_search' 7 | require 'support/it_supports_find_by_id_and_name' 8 | require 'support/it_supports_update' 9 | require 'support/it_supports_destroy' 10 | require 'support/it_supports_labels_on_update' 11 | require 'support/it_supports_action_fetch' 12 | 13 | describe Hcloud::Network, doubles: :network do 14 | let :networks do 15 | Array.new(Faker::Number.within(range: 20..150)).map { new_network } 16 | end 17 | 18 | let(:network) { networks.sample } 19 | 20 | let :client do 21 | Hcloud::Client.new(token: 'secure') 22 | end 23 | 24 | include_examples 'it_supports_fetch', described_class 25 | include_examples 'it_supports_search', described_class, %i[name label_selector] 26 | include_examples 'it_supports_find_by_id_and_name', described_class 27 | include_examples 'it_supports_update', described_class, { name: 'new_name' } 28 | include_examples 'it_supports_destroy', described_class 29 | include_examples 'it_supports_labels_on_update', described_class 30 | include_examples 'it_supports_action_fetch', described_class 31 | 32 | context '#create' do 33 | it 'handle missing name' do 34 | expect { client.networks.create(name: nil, ip_range: '10.0.0.0/16') }.to( 35 | raise_error(Hcloud::Error::InvalidInput) 36 | ) 37 | end 38 | 39 | it 'handle missing ip_range' do 40 | expect { client.networks.create(name: 'moo', ip_range: nil) }.to( 41 | raise_error(Hcloud::Error::InvalidInput) 42 | ) 43 | end 44 | 45 | context 'works' do 46 | it 'with minimum required parameters' do 47 | params = { name: 'moo', ip_range: '10.0.0.0/16' } 48 | stub_create(:network, params) 49 | 50 | key = client.networks.create(**params) 51 | expect(key).to be_a described_class 52 | expect(key.id).to be_a Integer 53 | expect(key.name).to eq('moo') 54 | expect(key.ip_range).to eq('10.0.0.0/16') 55 | expect(key.created).to be_a Time 56 | expect(key.protection[:delete]).to be_in([true, false]) 57 | end 58 | 59 | it 'with all parameters' do 60 | params = { 61 | name: 'moo', 62 | ip_range: '10.0.0.0/16', 63 | routes: [{ 64 | destination: '10.100.1.0/24', 65 | gateway: '10.0.1.1' 66 | }], 67 | subnets: [{ 68 | ip_range: '10.0.1.0/24', 69 | network_zone: 'eu-central', 70 | type: 'cloud' 71 | }], 72 | labels: { 'key' => 'value' } 73 | } 74 | expectation = stub_create(:network, params) 75 | 76 | key = client.networks.create(**params) 77 | expect(expectation.times_called).to eq(1) 78 | 79 | expect(key).to be_a described_class 80 | expect(key.id).to be_a Integer 81 | expect(key.name).to eq('moo') 82 | expect(key.ip_range).to eq('10.0.0.0/16') 83 | expect(key.created).to be_a Time 84 | expect(key.protection[:delete]).to be_in([true, false]) 85 | expect(key.routes).to eq(params[:routes].map(&:deep_stringify_keys)) 86 | expect(key.subnets).to eq(params[:subnets].map(&:deep_stringify_keys)) 87 | expect(key.labels).to eq(params[:labels]) 88 | end 89 | end 90 | 91 | it 'validates uniq name' do 92 | stub_error(:networks, :post, 'uniqueness_error', 409) 93 | 94 | expect { client.networks.create(name: 'moo', ip_range: '10.0.0.0/16') }.to( 95 | raise_error(Hcloud::Error::UniquenessError) 96 | ) 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /spec/hcloud/placement_group_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'support/it_supports_fetch' 5 | require 'support/it_supports_search' 6 | require 'support/it_supports_find_by_id_and_name' 7 | require 'support/it_supports_update' 8 | require 'support/it_supports_destroy' 9 | require 'support/it_supports_labels_on_update' 10 | 11 | describe Hcloud::PlacementGroup, doubles: :placement_group do 12 | let :placement_groups do 13 | Array.new(Faker::Number.within(range: 20..150)).map { new_placement_group } 14 | end 15 | 16 | let(:placement_group) { placement_groups.sample } 17 | 18 | let :client do 19 | Hcloud::Client.new(token: 'secure') 20 | end 21 | 22 | include_examples 'it_supports_fetch', described_class 23 | include_examples 'it_supports_search', described_class, %i[name label_selector type] 24 | include_examples 'it_supports_find_by_id_and_name', described_class 25 | include_examples 'it_supports_update', described_class, { name: 'new_name' } 26 | include_examples 'it_supports_destroy', described_class 27 | include_examples 'it_supports_labels_on_update', described_class 28 | 29 | context '#create' do 30 | it 'handle missing name' do 31 | expect { client.placement_groups.create(name: nil, type: 'spread') }.to( 32 | raise_error(Hcloud::Error::InvalidInput) 33 | ) 34 | end 35 | 36 | it 'handle missing type' do 37 | expect { client.placement_groups.create(name: 'moo', type: nil) }.to( 38 | raise_error(Hcloud::Error::InvalidInput) 39 | ) 40 | end 41 | 42 | it 'handle invalid type' do 43 | expect { client.placement_groups.create(name: 'moo', type: 'not-spread') }.to( 44 | raise_error(Hcloud::Error::InvalidInput) 45 | ) 46 | end 47 | 48 | it 'works' do 49 | params = { 50 | name: 'moo', 51 | type: 'spread', 52 | labels: { 'key' => 'value' } 53 | } 54 | expectation = stub_create(:placement_group, params) 55 | 56 | key = client.placement_groups.create(**params) 57 | expect(expectation.times_called).to eq(1) 58 | 59 | expect(key).to be_a described_class 60 | expect(key.id).to be_a Integer 61 | expect(key.name).to eq('moo') 62 | expect(key.type).to eq('spread') 63 | expect(key.servers).to eq([]) 64 | expect(key.created).to be_a Time 65 | expect(key.labels).to eq(params[:labels]) 66 | end 67 | 68 | it 'validates uniq name' do 69 | stub_error(:placement_groups, :post, 'uniqueness_error', 409) 70 | 71 | expect { client.placement_groups.create(name: 'moo', type: 'spread') }.to( 72 | raise_error(Hcloud::Error::UniquenessError) 73 | ) 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /spec/hcloud/primary_ip_actions_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/all' 4 | require 'spec_helper' 5 | 6 | describe Hcloud::PrimaryIP, doubles: :primary_ip do 7 | include_context 'action tests' 8 | 9 | let :primary_ips do 10 | Array.new(Faker::Number.within(range: 20..150)).map { new_primary_ip } 11 | end 12 | 13 | let(:primary_ip) { primary_ips.sample } 14 | 15 | let :client do 16 | Hcloud::Client.new(token: 'secure') 17 | end 18 | 19 | let :primary_ip_obj do 20 | stub_item(:primary_ips, primary_ip) 21 | client.primary_ips[primary_ip[:id]] 22 | end 23 | 24 | context '#assign' do 25 | it 'handles missing assigne_id' do 26 | expect do 27 | primary_ip_obj.assign(assignee_id: nil, assignee_type: 'server') 28 | end.to raise_error Hcloud::Error::InvalidInput 29 | end 30 | 31 | it 'defaults to assignee_type "server"' do 32 | stub = stub_action(:primary_ips, primary_ip[:id], :assign) do |req, _info| 33 | expect(req).to have_body_params(a_hash_including({ 'assignee_type' => 'server' })) 34 | 35 | { 36 | action: build_action_resp( 37 | :assign_primary_ip, :success, 38 | resources: [ 39 | { id: 42, type: 'server' }, 40 | { id: primary_ip[:id], type: 'primary_ip' } 41 | ] 42 | ) 43 | } 44 | end 45 | 46 | primary_ip_obj.assign(assignee_id: 42) 47 | expect(stub.times_called).to eq(1) 48 | end 49 | 50 | it 'works' do 51 | test_action( 52 | :assign, 53 | :assign_primary_ip, 54 | params: { assignee_id: 42 }, 55 | additional_resources: %i[server] 56 | ) 57 | end 58 | end 59 | 60 | context '#unassign' do 61 | it 'works' do 62 | test_action(:unassign, :unassign_primary_ip, additional_resources: %i[server]) 63 | end 64 | end 65 | 66 | context '#change_dns_ptr' do 67 | it 'handles missing ip' do 68 | expect { primary_ip_obj.change_dns_ptr(ip: nil, dns_ptr: 'example.com') }.to( 69 | raise_error(Hcloud::Error::InvalidInput) 70 | ) 71 | end 72 | 73 | it 'allows dns_ptr nil' do 74 | test_action(:change_dns_ptr, params: { ip: '2001:db8::10', dns_ptr: nil }) 75 | end 76 | 77 | it 'works with IPv4' do 78 | test_action(:change_dns_ptr, params: { ip: '192.0.2.0', dns_ptr: 'example.com' }) 79 | end 80 | 81 | it 'works with IPv6' do 82 | test_action(:change_dns_ptr, params: { ip: '2001:db8::10', dns_ptr: 'example.com' }) 83 | end 84 | end 85 | 86 | context '#change_protection' do 87 | it 'works' do 88 | test_action(:change_protection, params: { delete: true }, additional_resources: %i[server]) 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /spec/hcloud/primary_ip_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/all' 4 | require 'spec_helper' 5 | require 'support/it_supports_fetch' 6 | require 'support/it_supports_search' 7 | require 'support/it_supports_find_by_id_and_name' 8 | require 'support/it_supports_update' 9 | require 'support/it_supports_destroy' 10 | require 'support/it_supports_labels_on_update' 11 | require 'support/it_supports_action_fetch' 12 | 13 | describe Hcloud::PrimaryIP, doubles: :primary_ip do 14 | let :primary_ips do 15 | Array.new(Faker::Number.within(range: 20..150)).map { new_primary_ip } 16 | end 17 | 18 | let(:primary_ip) { primary_ips.sample } 19 | 20 | let :client do 21 | Hcloud::Client.new(token: 'secure') 22 | end 23 | 24 | include_examples 'it_supports_fetch', described_class 25 | include_examples 'it_supports_search', described_class, %i[name label_selector ip] 26 | include_examples 'it_supports_find_by_id_and_name', described_class 27 | include_examples 'it_supports_update', \ 28 | described_class, \ 29 | { name: 'new_name', auto_delete: true } 30 | include_examples 'it_supports_destroy', described_class 31 | include_examples 'it_supports_labels_on_update', described_class 32 | 33 | context '#create' do 34 | context 'with missing parameter' do 35 | let :params do 36 | { 37 | name: 'moo', 38 | type: 'ipv4', 39 | assignee_type: 'server', 40 | assignee_id: 42 41 | } 42 | end 43 | 44 | it 'handles missing name' do 45 | params[:name] = nil 46 | expect do 47 | client.primary_ips.create(**params) 48 | end.to raise_error(Hcloud::Error::InvalidInput) 49 | end 50 | 51 | it 'handles missing type' do 52 | params[:type] = nil 53 | expect do 54 | client.primary_ips.create(**params) 55 | end.to raise_error(Hcloud::Error::InvalidInput) 56 | end 57 | 58 | it 'handles missing assignee_type' do 59 | params[:assignee_type] = nil 60 | expect do 61 | client.primary_ips.create(**params) 62 | end.to raise_error(Hcloud::Error::InvalidInput) 63 | end 64 | 65 | it 'handles missing assignee_id and datacenter' do 66 | params[:assignee_id] = nil 67 | params[:datacenter] = nil 68 | 69 | expect do 70 | client.primary_ips.create(**params) 71 | end.to raise_error(Hcloud::Error::InvalidInput) 72 | end 73 | end 74 | 75 | context 'without assignee_type specified' do 76 | it 'defaults to "server"' do 77 | params = { name: 'moo', type: 'ipv4', assignee_id: 42 } 78 | stub = stub_create(:primary_ip, params) 79 | 80 | _action, primary_ip = client.primary_ips.create(**params) 81 | expect(stub.times_called).to eq(1) 82 | expect(primary_ip).to have_attributes(assignee_type: 'server') 83 | end 84 | end 85 | 86 | context 'works' do 87 | it 'with required parameters' do 88 | params = { 89 | name: 'moo', 90 | type: 'ipv4', 91 | assignee_id: 42 92 | } 93 | stub = stub_create( 94 | :primary_ip, 95 | params, 96 | action: new_action(:running, command: 'create_primary_ip') 97 | ) 98 | 99 | action, ip = client.primary_ips.create(**params) 100 | expect(stub.times_called).to eq(1) 101 | 102 | expect(action).to be_a(Hcloud::Action) 103 | 104 | expect(ip).to be_a described_class 105 | expect(ip).to have_attributes( 106 | id: a_kind_of(Integer), 107 | name: 'moo', 108 | type: 'ipv4', 109 | assignee_id: 42 110 | ) 111 | end 112 | 113 | it 'with datacenter' do 114 | params = { 115 | name: 'moo', 116 | type: 'ipv4', 117 | datacenter: 'fsn1-dc14' 118 | } 119 | response_params = params.deep_dup 120 | response_params[:datacenter] = new_datacenter(name: params[:datacenter]) 121 | stub = stub_create( 122 | :primary_ip, 123 | params, 124 | response_params: response_params, 125 | action: new_action(:running, command: 'create_primary_ip') 126 | ) 127 | 128 | action, ip = client.primary_ips.create(**params) 129 | expect(stub.times_called).to eq(1) 130 | 131 | expect(action).to be_a Hcloud::Action 132 | 133 | expect(ip).to be_a described_class 134 | expect(ip).to have_attributes(datacenter: a_kind_of(Hcloud::Datacenter)) 135 | end 136 | 137 | it 'with all parameters' do 138 | params = { 139 | name: 'moo', 140 | type: 'ipv4', 141 | assignee_type: 'server', 142 | assignee_id: 42, 143 | auto_delete: true, 144 | labels: { 'key' => 'value' } 145 | } 146 | stub = stub_create( 147 | :primary_ip, 148 | params, 149 | action: new_action(:running, command: 'create_primary_ip') 150 | ) 151 | 152 | action, ip = client.primary_ips.create(**params) 153 | expect(stub.times_called).to eq(1) 154 | 155 | expect(action).to be_a Hcloud::Action 156 | 157 | expect(ip).to be_a described_class 158 | expect(ip).to have_attributes(**params.merge(id: a_kind_of(Integer))) 159 | end 160 | end 161 | 162 | it 'validates uniq name' do 163 | stub_error(:primary_ips, :post, 'uniqueness_error', 409) 164 | 165 | expect do 166 | client.primary_ips.create(name: 'moo', type: 'ipv4', assignee_id: 42) 167 | end.to raise_error(Hcloud::Error::UniquenessError) 168 | end 169 | end 170 | end 171 | -------------------------------------------------------------------------------- /spec/hcloud/resource_loader_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Hcloud::ResourceLoader, doubles: :helper do 6 | include_context 'test doubles' 7 | 8 | let :sample_location do 9 | new_location(name: 'fsn1') 10 | end 11 | 12 | it 'can handle time' do 13 | loader = Hcloud::ResourceLoader.new({ field: :time }, client: client) 14 | result = loader.load({ field: '2022-03-09T10:11:12Z' }) 15 | expect(result[:field]).to be_a Time 16 | expect(result[:field]).to eq(Time.new(2022, 3, 9, 10, 11, 12, 'Z')) 17 | end 18 | 19 | context 'can handle EntryLoader classes' do 20 | it 'with eagerly loaded objects' do 21 | loader = Hcloud::ResourceLoader.new({ field: Hcloud::Location }, client: client) 22 | result = loader.load({ field: sample_location }) 23 | expect(result[:field]).to be_a Hcloud::Location 24 | end 25 | 26 | it 'with lazily loaded objects' do 27 | stub_item(:locations, sample_location) 28 | 29 | loader = Hcloud::ResourceLoader.new({ field: Hcloud::Location }, client: client) 30 | result = loader.load({ field: sample_location[:id] }) 31 | expect(result[:field]).to be_a Hcloud::Future 32 | expect(result[:field].name).to eq('fsn1') 33 | end 34 | end 35 | 36 | context 'can handle arrays' do 37 | it 'of time' do 38 | loader = Hcloud::ResourceLoader.new({ field: [:time] }, client: client) 39 | result = loader.load({ field: ['2022-03-09T10:11:12Z', '2022-03-12T17:13:16Z'] }) 40 | expect(result[:field]).to all be_a Time 41 | expect(result[:field]).to eq( 42 | [ 43 | Time.new(2022, 3, 9, 10, 11, 12, 'Z'), 44 | Time.new(2022, 3, 12, 17, 13, 16, 'Z') 45 | ] 46 | ) 47 | end 48 | 49 | it 'of class' do 50 | loader = Hcloud::ResourceLoader.new({ field: [Hcloud::Location] }, client: client) 51 | result = loader.load({ field: [sample_location, sample_location] }) 52 | expect(result[:field]).to all be_a Hcloud::Location 53 | expect(result[:field].count).to eq(2) 54 | end 55 | 56 | it 'by keeping value when data is not an array' do 57 | loader = Hcloud::ResourceLoader.new({ field: [Hcloud::Location] }, client: client) 58 | result = loader.load({ field: sample_location }) 59 | expect(result[:field]).to be_a Hash 60 | expect(result.dig(:field, :name)).to eq('fsn1') 61 | end 62 | end 63 | 64 | it 'can handle nested hashes' do 65 | schema = { 66 | field1: Hcloud::Location, 67 | field2: { 68 | sub_field: [Hcloud::Location] 69 | } 70 | } 71 | data = { 72 | field1: sample_location, 73 | field2: { 74 | sub_field: [sample_location] 75 | }, 76 | field3: 'data-without-schema' 77 | } 78 | 79 | loader = Hcloud::ResourceLoader.new(schema, client: client) 80 | result = loader.load(data) 81 | 82 | expect(result[:field1]).to be_a Hcloud::Location 83 | expect(result.dig(:field2, :sub_field)).to all be_a Hcloud::Location 84 | expect(result.dig(:field2, :sub_field).count).to eq(1) 85 | expect(result[:field3]).to eq('data-without-schema') 86 | end 87 | 88 | it 'can handle lists with nested structures' do 89 | schema = { field: [{ field: Hcloud::Location }] } 90 | data = { 91 | field: [ 92 | { field: sample_location, other: 'data-without-schema' }, 93 | { field: sample_location, other: 'more-data-without-schema' } 94 | ] 95 | } 96 | 97 | loader = Hcloud::ResourceLoader.new(schema, client: client) 98 | result = loader.load(data) 99 | 100 | expect(result[:field].map { |item| item[:field] }).to all be_a Hcloud::Location 101 | expect(result[:field].map { |item| item[:field] }.map(&:name)).to all eq('fsn1') 102 | expect(result[:field][0][:other]).to eq('data-without-schema') 103 | expect(result[:field][1][:other]).to eq('more-data-without-schema') 104 | end 105 | 106 | it 'can use an extractor' do 107 | stub_item(:locations, sample_location) 108 | 109 | schema = { 110 | field: lambda do |data, client| 111 | Hcloud::Future.new(client, Hcloud::Location, data[:id]) 112 | end 113 | } 114 | data = { 115 | field: { 116 | id: sample_location[:id] 117 | } 118 | } 119 | 120 | loader = Hcloud::ResourceLoader.new(schema, client: client) 121 | result = loader.load(data) 122 | 123 | expect(result[:field]).to be_a(Hcloud::Future).and have_attributes(name: 'fsn1') 124 | end 125 | end 126 | -------------------------------------------------------------------------------- /spec/hcloud/server_type_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'support/it_supports_fetch' 5 | require 'support/it_supports_search' 6 | require 'support/it_supports_find_by_id_and_name' 7 | 8 | describe Hcloud::ServerType, doubles: :server_type do 9 | let :server_types do 10 | Array.new(Faker::Number.within(range: 20..150)).map { new_server_type } 11 | end 12 | 13 | let(:server_type) { server_types.sample } 14 | 15 | let :client do 16 | Hcloud::Client.new(token: 'secure') 17 | end 18 | 19 | include_examples 'it_supports_fetch', described_class 20 | include_examples 'it_supports_search', described_class, %i[name] 21 | include_examples 'it_supports_find_by_id_and_name', described_class 22 | end 23 | -------------------------------------------------------------------------------- /spec/hcloud/ssh_key_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'support/it_supports_fetch' 5 | require 'support/it_supports_search' 6 | require 'support/it_supports_find_by_id_and_name' 7 | require 'support/it_supports_update' 8 | require 'support/it_supports_destroy' 9 | require 'support/it_supports_labels_on_update' 10 | 11 | SSH_KEY = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILh8GHJkJRgf3wuuUUQYG3UfqtVK56+FEXAOFaNZ659C m@x.com' 12 | 13 | describe Hcloud::SSHKey, doubles: :ssh_key do 14 | let :ssh_keys do 15 | Array.new(Faker::Number.within(range: 20..150)).map { new_ssh_key } 16 | end 17 | 18 | let(:ssh_key) { ssh_keys.sample } 19 | 20 | let(:ssh_pub_key) { SSH_KEY } 21 | 22 | let :client do 23 | Hcloud::Client.new(token: 'secure') 24 | end 25 | 26 | include_examples 'it_supports_fetch', described_class 27 | include_examples 'it_supports_search', described_class, %i[name fingerprint label_selector] 28 | include_examples 'it_supports_find_by_id_and_name', described_class 29 | include_examples 'it_supports_update', described_class, { name: 'new_name' } 30 | include_examples 'it_supports_destroy', described_class 31 | include_examples 'it_supports_labels_on_update', described_class 32 | 33 | context '#create' do 34 | it 'handle missing name' do 35 | expect { client.ssh_keys.create(name: nil, public_key: 'ssh-rsa') }.to( 36 | raise_error(Hcloud::Error::InvalidInput) 37 | ) 38 | end 39 | 40 | it 'handle missing public_key' do 41 | expect { client.ssh_keys.create(name: 'moo', public_key: nil) }.to( 42 | raise_error(Hcloud::Error::InvalidInput) 43 | ) 44 | end 45 | 46 | it 'handle invalid public_key' do 47 | expect { client.ssh_keys.create(name: 'moo', public_key: 'not-ssh') }.to( 48 | raise_error(Hcloud::Error::InvalidInput) 49 | ) 50 | end 51 | 52 | it 'works' do 53 | params = { 54 | name: 'moo', 55 | public_key: ssh_pub_key, 56 | labels: { 'key' => 'value' } 57 | } 58 | expectation = stub_create(:ssh_key, params) 59 | 60 | key = client.ssh_keys.create(**params) 61 | expect(expectation.times_called).to eq(1) 62 | 63 | expect(key).to be_a described_class 64 | expect(key.id).to be_a Integer 65 | expect(key.name).to eq('moo') 66 | expect(key.public_key).to eq(ssh_pub_key) 67 | expect(key.fingerprint).to be_a String 68 | expect(key.created).to be_a Time 69 | expect(key.labels).to eq(params[:labels]) 70 | end 71 | 72 | it 'validates uniq name' do 73 | stub_error(:ssh_keys, :post, 'uniqueness_error', 409) 74 | 75 | expect { client.ssh_keys.create(name: 'moo', public_key: ssh_pub_key) }.to( 76 | raise_error(Hcloud::Error::UniquenessError) 77 | ) 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /spec/hcloud/volume_actions_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/all' 4 | require 'spec_helper' 5 | 6 | describe Hcloud::Volume, doubles: :volume do 7 | include_context 'test doubles' 8 | include_context 'action tests' 9 | 10 | let :volumes do 11 | Array.new(Faker::Number.within(range: 20..150)).map { new_volume } 12 | end 13 | 14 | let(:volume) { volumes.sample } 15 | 16 | let :client do 17 | Hcloud::Client.new(token: 'secure') 18 | end 19 | 20 | let :volume_obj do 21 | stub_item(:volumes, volume) 22 | client.volumes[volume[:id]] 23 | end 24 | 25 | context '#change_protection' do 26 | it 'works' do 27 | test_action(:change_protection, params: { delete: true }) 28 | end 29 | end 30 | 31 | context '#attach' do 32 | it 'handles missing server' do 33 | expect do 34 | volume_obj.attach(server: nil) 35 | end.to raise_error(Hcloud::Error::InvalidInput) 36 | end 37 | 38 | it 'works' do 39 | test_action( 40 | :attach, 41 | :attach_volume, 42 | params: { server: 42, automount: true }, 43 | additional_resources: %i[server] 44 | ) 45 | end 46 | end 47 | 48 | context '#detach' do 49 | it 'works' do 50 | test_action( 51 | :detach, 52 | :detach_volume, 53 | additional_resources: %i[server] 54 | ) 55 | end 56 | end 57 | 58 | context '#resize' do 59 | it 'handles missing size' do 60 | expect do 61 | volume_obj.resize(size: nil) 62 | end.to raise_error(Hcloud::Error::InvalidInput) 63 | end 64 | 65 | it 'does not allow downsize' do 66 | expect do 67 | volume_obj.resize(size: volume_obj.size - 1) 68 | end.to raise_error(Hcloud::Error::InvalidInput) 69 | end 70 | 71 | it 'works' do 72 | # make sure the new size is larger than old size 73 | new_size = volume[:size] + 10 74 | 75 | test_action(:resize, :resize_volume, params: { size: new_size }) 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /spec/hcloud/volume_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/all' 4 | require 'spec_helper' 5 | require 'support/it_supports_fetch' 6 | require 'support/it_supports_search' 7 | require 'support/it_supports_find_by_id_and_name' 8 | require 'support/it_supports_update' 9 | require 'support/it_supports_destroy' 10 | require 'support/it_supports_labels_on_update' 11 | require 'support/it_supports_action_fetch' 12 | 13 | describe Hcloud::Volume, doubles: :volume do 14 | let :volumes do 15 | Array.new(Faker::Number.within(range: 20..150)).map { new_volume } 16 | end 17 | 18 | let(:volume) { volumes.sample } 19 | 20 | let :client do 21 | Hcloud::Client.new(token: 'secure') 22 | end 23 | 24 | include_examples 'it_supports_fetch', described_class 25 | include_examples 'it_supports_search', described_class, %i[status name label_selector] 26 | include_examples 'it_supports_find_by_id_and_name', described_class 27 | include_examples 'it_supports_update', described_class, { name: 'new_name' } 28 | include_examples 'it_supports_destroy', described_class 29 | include_examples 'it_supports_labels_on_update', described_class 30 | include_examples 'it_supports_action_fetch', described_class 31 | 32 | context '#create' do 33 | it 'handle missing name' do 34 | expect { client.volumes.create(name: nil, size: 10, location: 'fsn1') }.to( 35 | raise_error(Hcloud::Error::InvalidInput) 36 | ) 37 | end 38 | 39 | it 'handle missing size' do 40 | expect { client.volumes.create(name: 'moo', size: nil, location: 'fsn1') }.to( 41 | raise_error(Hcloud::Error::InvalidInput) 42 | ) 43 | end 44 | 45 | it 'handle too small size' do 46 | expect { client.volumes.create(name: 'moo', size: 5, location: 'fsn1') }.to( 47 | raise_error(Hcloud::Error::InvalidInput) 48 | ) 49 | end 50 | 51 | it 'handle missing location and server' do 52 | expect { client.volumes.create(name: 'moo', size: 10, location: nil, server: nil) }.to( 53 | raise_error(Hcloud::Error::InvalidInput) 54 | ) 55 | end 56 | 57 | context 'works' do 58 | it 'with required parameters' do 59 | params = { name: 'moo', size: 10, location: 'fsn1' } 60 | response_params = { 61 | name: params[:name], 62 | size: params[:size], 63 | location: new_location 64 | } 65 | expectation = stub_create( 66 | :volume, 67 | params, 68 | response_params: response_params, 69 | action: new_action(:running, command: 'create_volume'), 70 | next_actions: [] 71 | ) 72 | 73 | _action, volume, _next_actions = client.volumes.create(**params) 74 | expect(expectation.times_called).to eq(1) 75 | 76 | expect(volume).to be_a described_class 77 | expect(volume.id).to be_a Integer 78 | expect(volume.created).to be_a Time 79 | expect(volume.name).to eq('moo') 80 | expect(volume.size).to eq(10) 81 | end 82 | 83 | it 'with all parameters' do 84 | params = { 85 | name: 'moo', 86 | size: 10, 87 | location: 'fsn1', 88 | format: 'ext4', 89 | automount: false, 90 | labels: { 'key' => 'value' } 91 | } 92 | response_params = { 93 | name: params[:name], 94 | size: params[:size], 95 | location: new_location, 96 | linux_device: '/foo/bar', 97 | labels: params[:labels] 98 | } 99 | 100 | expectation = stub_create( 101 | :volume, 102 | params, 103 | response_params: response_params, 104 | action: new_action(:running, command: 'create_volume'), 105 | next_actions: [] 106 | ) 107 | 108 | _action, volume, _next_actions = client.volumes.create(**params) 109 | expect(expectation.times_called).to eq(1) 110 | 111 | expect(volume).to be_a described_class 112 | expect(volume.id).to be_a Integer 113 | expect(volume.created).to be_a Time 114 | expect(volume.name).to eq('moo') 115 | expect(volume.size).to eq(10) 116 | expect(volume.linux_device).to eq('/foo/bar') 117 | expect(volume.labels).to eq(params[:labels]) 118 | end 119 | end 120 | 121 | it 'validates uniq name' do 122 | stub_error(:volumes, :post, 'uniqueness_error', 409) 123 | 124 | expect { client.volumes.create(name: 'moo', size: 10, location: 'fsn1') }.to( 125 | raise_error(Hcloud::Error::UniquenessError) 126 | ) 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /spec/integration/datacenter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe 'Datacenter' do 6 | let :client do 7 | Hcloud::Client.new(token: 'secure') 8 | end 9 | it 'fetchs datacenters' do 10 | expect(client.datacenters.count).to eq(2) 11 | end 12 | 13 | it '#[] -> find by id' do 14 | expect(client.datacenters[1].id).to eq(1) 15 | end 16 | 17 | it '#[] -> find by id, handle nonexistent' do 18 | expect(client.datacenters[3]).to be nil 19 | end 20 | 21 | it '#find -> find by id' do 22 | expect(client.datacenters.find(1).id).to eq(1) 23 | end 24 | 25 | it '#find -> find by id, handle nonexistent' do 26 | expect { client.datacenters.find(3).id }.to raise_error(Hcloud::Error::NotFound) 27 | end 28 | 29 | it '#[] -> filter by name' do 30 | expect(client.datacenters['fsn1-dc8'].name).to eq('fsn1-dc8') 31 | end 32 | 33 | it '#[] -> filter by name, handle nonexistent' do 34 | expect(client.datacenters['fsn1-dc3']).to be nil 35 | end 36 | 37 | it '#[] -> filter by name, handle invalid format' do 38 | expect { client.datacenters['fsn1dc3'] }.to( 39 | raise_error(Hcloud::Error::InvalidInput) 40 | ) 41 | end 42 | 43 | it '#find_by -> filter by name, handle invalid format' do 44 | expect { client.datacenters.find_by(name: 'fsn1dc3') }.to( 45 | raise_error(Hcloud::Error::InvalidInput) 46 | ) 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/integration/iso_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe 'ISO' do 6 | let :client do 7 | Hcloud::Client.new(token: 'secure') 8 | end 9 | 10 | it 'fetchs isos' do 11 | expect(client.isos.count).to eq(1) 12 | end 13 | 14 | it '#[] -> find by id' do 15 | expect(client.isos.first).to be_a Hcloud::Iso 16 | id = client.isos.first.id 17 | expect(id).to be_a Integer 18 | expect(client.isos[id]).to be_a Hcloud::Iso 19 | expect(client.isos[id].id).to eq(id) 20 | end 21 | 22 | it '#[] -> find by id, handle nonexistent' do 23 | expect(client.isos[3]).to be nil 24 | end 25 | 26 | it '#find -> find by id' do 27 | expect(client.isos.first).to be_a Hcloud::Iso 28 | id = client.isos.first.id 29 | expect(id).to be_a Integer 30 | expect(client.isos.find(id)).to be_a Hcloud::Iso 31 | expect(client.isos.find(id).id).to eq(id) 32 | end 33 | 34 | it '#find -> find by id, handle nonexistent' do 35 | expect { client.isos.find(3).id }.to raise_error(Hcloud::Error::NotFound) 36 | end 37 | 38 | it '#[] -> filter by name' do 39 | expect(client.isos.first).to be_a Hcloud::Iso 40 | name = client.isos.first.name 41 | expect(name).to be_a String 42 | expect(client.isos[name]).to be_a Hcloud::Iso 43 | expect(client.isos[name].name).to eq(name) 44 | end 45 | 46 | it '#[] -> filter by name, handle nonexistent' do 47 | expect(client.isos['mooo']).to be nil 48 | end 49 | 50 | it '#find_by -> filter by name' do 51 | expect(client.isos.first).to be_a Hcloud::Iso 52 | name = client.isos.first.name 53 | expect(name).to be_a String 54 | expect(client.isos.find_by(name: name)).to be_a Hcloud::Iso 55 | expect(client.isos.find_by(name: name).name).to eq(name) 56 | end 57 | 58 | it '#find_by -> filter by name, handle nonexistent' do 59 | expect(client.isos.find_by(name: 'moo')).to be nil 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /spec/integration/location_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe 'Location' do 6 | before(:each) do 7 | Hcloud::Client.connection = Hcloud::Client.new(token: 'secure') 8 | end 9 | after(:each) do 10 | Hcloud::Client.connection = nil 11 | end 12 | let :client do 13 | end 14 | it 'fetchs locations' do 15 | expect(Hcloud::Location.count).to eq(2) 16 | end 17 | 18 | it '#[] -> find by id' do 19 | expect(Hcloud::Location[1].id).to eq(1) 20 | end 21 | 22 | it '#[] -> find by id, handle nonexistent' do 23 | expect(Hcloud::Location[3]).to be nil 24 | end 25 | 26 | it '#find -> find by id' do 27 | expect(Hcloud::Location.find(1).id).to eq(1) 28 | end 29 | 30 | it '#find -> find by id, handle nonexistent' do 31 | expect { Hcloud::Location.find(3).id }.to raise_error(Hcloud::Error::NotFound) 32 | end 33 | 34 | it '#[] -> filter by name' do 35 | expect(Hcloud::Location['fsn1'].name).to eq('fsn1') 36 | end 37 | 38 | it '#[] -> filter by name, handle nonexistent' do 39 | expect(Hcloud::Location['mooo']).to be nil 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/integration/network_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe 'Network' do 6 | let :client do 7 | Hcloud::Client.new(token: 'secure') 8 | end 9 | 10 | it 'fetch networks' do 11 | expect(client.networks.count).to eq(0) 12 | end 13 | 14 | it 'create new network, handle missing name' do 15 | expect { client.networks.create(name: nil, ip_range: '10.0.0.0/16') }.to( 16 | raise_error(Hcloud::Error::InvalidInput) 17 | ) 18 | end 19 | 20 | it 'create new network, handle missing ip_range' do 21 | expect { client.networks.create(name: 'testnet', ip_range: nil) }.to( 22 | raise_error(Hcloud::Error::InvalidInput) 23 | ) 24 | end 25 | 26 | it 'create new network' do 27 | network = client.networks.create( 28 | name: 'testnet', 29 | ip_range: '192.168.0.0/16', 30 | routes: [{ 31 | destination: '10.0.0.0/24', 32 | gateway: '192.168.0.10' 33 | }], 34 | subnets: [{ 35 | ip_range: '192.168.0.0/24', 36 | network_zone: 'eu-central', 37 | type: 'cloud' 38 | }], 39 | labels: { 'source' => 'create' } 40 | ) 41 | expect(network).to be_a Hcloud::Network 42 | expect(network.id).to be_a Integer 43 | expect(network.name).to eq('testnet') 44 | expect(network.routes[0][:destination]).to eq('10.0.0.0/24') 45 | expect(network.routes[0][:gateway]).to eq('192.168.0.10') 46 | expect(network.subnets[0][:ip_range]).to eq('192.168.0.0/24') 47 | expect(network.subnets[0][:network_zone]).to eq('eu-central') 48 | expect(network.subnets[0][:type]).to eq('cloud') 49 | expect(network.labels).to eq({ 'source' => 'create' }) 50 | end 51 | 52 | it 'create new network, uniq name' do 53 | expect { client.networks.create(name: 'testnet', ip_range: '10.0.0.0/24') }.to( 54 | raise_error(Hcloud::Error::UniquenessError) 55 | ) 56 | end 57 | 58 | it 'fetch networks' do 59 | expect(client.networks.count).to eq(1) 60 | end 61 | 62 | it '#[] -> find by id' do 63 | expect(client.networks.first).to be_a Hcloud::Network 64 | id = client.networks.first.id 65 | expect(id).to be_a Integer 66 | expect(client.networks[id]).to be_a Hcloud::Network 67 | expect(client.networks[id].name).to eq('testnet') 68 | end 69 | 70 | it '#[] -> find by id, handle nonexistent' do 71 | expect(client.networks[0]).to be nil 72 | end 73 | 74 | it '#find -> find by id' do 75 | expect(client.networks.first).to be_a Hcloud::Network 76 | id = client.networks.first.id 77 | expect(id).to be_a Integer 78 | expect(client.networks.find(id)).to be_a Hcloud::Network 79 | end 80 | 81 | it '#find -> find by id, handle nonexistent' do 82 | expect { client.networks.find(0).id }.to raise_error(Hcloud::Error::NotFound) 83 | end 84 | 85 | it '#[] -> filter by name' do 86 | expect(client.networks['testnet']).to be_a Hcloud::Network 87 | expect(client.networks['testnet'].name).to eq('testnet') 88 | expect(client.networks['testnet'].routes.length).to eq(1) 89 | expect(client.networks['testnet'].subnets.length).to eq(1) 90 | end 91 | 92 | it '#[] -> filter by name, handle nonexistent' do 93 | expect(client.networks['network-missing']).to be nil 94 | end 95 | 96 | it '#add_subnet' do 97 | network = client.networks['testnet'] 98 | expect(network).to be_a Hcloud::Network 99 | 100 | network.add_subnet( 101 | type: 'cloud', 102 | network_zone: 'eu-central', 103 | ip_range: '192.168.1.0/24' 104 | ) 105 | 106 | expect(client.networks['testnet'].subnets.length).to eq(2) 107 | 108 | expect(client.actions.count).to eq(1) 109 | expect(client.networks['testnet'].actions.count).to eq(1) 110 | end 111 | 112 | it '#del_subnet' do 113 | network = client.networks['testnet'] 114 | expect(network).to be_a Hcloud::Network 115 | 116 | network.del_subnet(ip_range: '192.168.1.0/24') 117 | expect(client.networks['testnet'].subnets.length).to eq(1) 118 | 119 | expect(client.actions.count).to eq(2) 120 | expect(client.networks['testnet'].actions.count).to eq(2) 121 | end 122 | 123 | it '#add_route' do 124 | network = client.networks['testnet'] 125 | expect(network).to be_a Hcloud::Network 126 | 127 | network.add_route(destination: '10.0.1.0/24', gateway: '192.168.0.10') 128 | 129 | expect(client.networks['testnet'].routes.length).to eq(2) 130 | 131 | expect(client.actions.count).to eq(3) 132 | expect(client.networks['testnet'].actions.count).to eq(3) 133 | end 134 | 135 | it '#del_route' do 136 | network = client.networks['testnet'] 137 | expect(network).to be_a Hcloud::Network 138 | 139 | network.del_route(destination: '10.0.1.0/24', gateway: '192.168.0.10') 140 | 141 | expect(client.networks['testnet'].routes.length).to eq(1) 142 | 143 | expect(client.actions.count).to eq(4) 144 | expect(client.networks['testnet'].actions.count).to eq(4) 145 | end 146 | 147 | it '#update(name:)' do 148 | id = client.networks['testnet'].id 149 | expect(id).to be_a Integer 150 | expect(client.networks.find(id).name).to eq('testnet') 151 | expect(client.networks.find(id).update(name: 'testing').name).to eq('testing') 152 | expect(client.networks.find(id).name).to eq('testing') 153 | end 154 | 155 | it '#update(labels:)' do 156 | id = client.networks.first.id 157 | network = client.networks[id] 158 | updated = network.update(labels: { 'source' => 'update' }) 159 | expect(updated.labels).to eq({ 'source' => 'update' }) 160 | expect(client.networks[id].labels).to eq({ 'source' => 'update' }) 161 | end 162 | 163 | it '#where -> find by label_selector' do 164 | networks = client.networks.where(label_selector: 'source=update').to_a 165 | expect(networks.length).to eq(1) 166 | expect(networks.first.labels).to include('source' => 'update') 167 | end 168 | 169 | it '#destroy' do 170 | expect(client.networks.first).to be_a Hcloud::Network 171 | id = client.networks.first.id 172 | expect(id).to be_a Integer 173 | expect(client.networks.find(id).destroy).to be_a Hcloud::Network 174 | expect(client.networks[id]).to be nil 175 | end 176 | end 177 | -------------------------------------------------------------------------------- /spec/integration/server_type_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe 'ServerType' do 6 | let :client do 7 | Hcloud::Client.new(token: 'secure') 8 | end 9 | 10 | it 'fetch server_types' do 11 | expect(client.server_types.count).to eq(1) 12 | end 13 | 14 | it '#[] -> find by id' do 15 | expect(client.server_types.first).to be_a Hcloud::ServerType 16 | id = client.server_types.first.id 17 | expect(id).to be_a Integer 18 | expect(client.server_types[id]).to be_a Hcloud::ServerType 19 | expect(client.server_types[id].name).to be_a String 20 | expect(client.server_types[id].description).to be_a String 21 | expect(client.server_types[id].cores).to be_a Integer 22 | expect(client.server_types[id].memory).to be_a Float 23 | expect(client.server_types[id].prices).to be_a Array 24 | expect(client.server_types[id].storage_type).to be_a String 25 | end 26 | 27 | it '#[] -> find by id, handle nonexistent' do 28 | expect(client.ssh_keys[0]).to be nil 29 | end 30 | 31 | it '#find -> find by id' do 32 | expect(client.server_types.first).to be_a Hcloud::ServerType 33 | id = client.server_types.first.id 34 | expect(id).to be_a Integer 35 | expect(client.server_types.find(id)).to be_a Hcloud::ServerType 36 | expect(client.server_types.find(id).name).to be_a String 37 | expect(client.server_types.find(id).description).to be_a String 38 | expect(client.server_types.find(id).cores).to be_a Integer 39 | expect(client.server_types.find(id).memory).to be_a Float 40 | expect(client.server_types.find(id).prices).to be_a Array 41 | expect(client.server_types.find(id).storage_type).to be_a String 42 | end 43 | 44 | it '#find -> find by id, handle nonexistent' do 45 | expect { client.ssh_keys.find(0).id }.to raise_error(Hcloud::Error::NotFound) 46 | end 47 | 48 | it '#[] -> filter by name' do 49 | expect(client.server_types['cx11']).to be_a Hcloud::ServerType 50 | expect(client.server_types['cx11'].name).to be_a String 51 | expect(client.server_types['cx11'].description).to be_a String 52 | expect(client.server_types['cx11'].cores).to be_a Integer 53 | expect(client.server_types['cx11'].memory).to be_a Float 54 | expect(client.server_types['cx11'].prices).to be_a Array 55 | expect(client.server_types['cx11'].storage_type).to be_a String 56 | end 57 | 58 | it '#[] -> filter by name, handle nonexistent' do 59 | expect(client.ssh_keys['mooo']).to be nil 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /spec/integration/ssh_key_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | REAL_KEY = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILh8GH'\ 6 | 'JkJRgf3wuuUUQYG3UfqtVK56+FEXAOFaNZ659C m@x.com' 7 | 8 | describe 'SSHKey' do 9 | let :client do 10 | Hcloud::Client.new(token: 'secure') 11 | end 12 | it 'fetch ssh_keys' do 13 | expect(client.ssh_keys.count).to eq(0) 14 | end 15 | 16 | it 'create new ssh_key, handle missing name' do 17 | expect { client.ssh_keys.create(name: nil, public_key: 'ssh-rsa') }.to( 18 | raise_error(Hcloud::Error::InvalidInput) 19 | ) 20 | end 21 | 22 | it 'create new ssh_key, handle missing public_key' do 23 | expect { client.ssh_keys.create(name: 'moo', public_key: nil) }.to( 24 | raise_error(Hcloud::Error::InvalidInput) 25 | ) 26 | end 27 | 28 | it 'create new ssh_key, handle invalid public_key' do 29 | expect { client.ssh_keys.create(name: 'moo', public_key: 'not-ssh') }.to( 30 | raise_error(Hcloud::Error::InvalidInput) 31 | ) 32 | end 33 | 34 | it 'create new ssh_key' do 35 | key = client.ssh_keys.create(name: 'moo', public_key: REAL_KEY, labels: { 'source' => 'test' }) 36 | expect(key).to be_a Hcloud::SSHKey 37 | expect(key.id).to be_a Integer 38 | expect(key.name).to eq('moo') 39 | expect(key.fingerprint.split(':').count).to eq(16) 40 | expect(key.public_key).to eq(REAL_KEY) 41 | expect(key.labels).to eq({ 'source' => 'test' }) 42 | end 43 | 44 | it 'create new ssh_key, uniq name' do 45 | expect { client.ssh_keys.create(name: 'moo', public_key: 'ssh-rsa') }.to( 46 | raise_error(Hcloud::Error::UniquenessError) 47 | ) 48 | end 49 | 50 | it 'create new ssh_key, uniq public key' do 51 | expect { client.ssh_keys.create(name: 'foo', public_key: REAL_KEY) }.to( 52 | raise_error(Hcloud::Error::UniquenessError) 53 | ) 54 | end 55 | 56 | it 'fetch ssh_keys' do 57 | expect(client.ssh_keys.count).to eq(1) 58 | end 59 | 60 | it '#[] -> find by id' do 61 | expect(client.ssh_keys.first).to be_a Hcloud::SSHKey 62 | id = client.ssh_keys.first.id 63 | expect(id).to be_a Integer 64 | expect(client.ssh_keys[id]).to be_a Hcloud::SSHKey 65 | expect(client.ssh_keys[id].name).to eq('moo') 66 | expect(client.ssh_keys[id].public_key).to eq(REAL_KEY) 67 | expect(client.ssh_keys[id].fingerprint.split(':').count).to eq(16) 68 | end 69 | 70 | it '#[] -> find by id, handle nonexistent' do 71 | expect(client.ssh_keys[0]).to be nil 72 | end 73 | 74 | it '#find -> find by id' do 75 | expect(client.ssh_keys.first).to be_a Hcloud::SSHKey 76 | id = client.ssh_keys.first.id 77 | expect(id).to be_a Integer 78 | expect(client.ssh_keys.find(id)).to be_a Hcloud::SSHKey 79 | expect(client.ssh_keys.find(id).name).to eq('moo') 80 | expect(client.ssh_keys.find(id).public_key).to eq(REAL_KEY) 81 | expect(client.ssh_keys.find(id).fingerprint.split(':').count).to eq(16) 82 | end 83 | 84 | it '#find -> find by id, handle nonexistent' do 85 | expect { client.ssh_keys.find(0).id }.to raise_error(Hcloud::Error::NotFound) 86 | end 87 | 88 | it '#[] -> filter by name' do 89 | expect(client.ssh_keys['moo']).to be_a Hcloud::SSHKey 90 | expect(client.ssh_keys['moo'].name).to eq('moo') 91 | expect(client.ssh_keys['moo'].public_key).to eq(REAL_KEY) 92 | expect(client.ssh_keys['moo'].fingerprint.split(':').count).to eq(16) 93 | end 94 | 95 | it '#[] -> filter by name, handle nonexistent' do 96 | expect(client.ssh_keys['mooo']).to be nil 97 | end 98 | 99 | it '#update' do 100 | expect(client.ssh_keys.first).to be_a Hcloud::SSHKey 101 | id = client.ssh_keys.first.id 102 | expect(id).to be_a Integer 103 | expect(client.ssh_keys.find(id).name).to eq('moo') 104 | updated = client.ssh_keys.find(id).update( 105 | name: 'hui', 106 | labels: { 'source' => 'unittest' } 107 | ) 108 | expect(updated.name).to eq('hui') 109 | expect(updated.labels['source']).to eq('unittest') 110 | 111 | expect(client.ssh_keys.find(id).name).to eq('hui') 112 | expect(client.ssh_keys.find(id).labels['source']).to eq('unittest') 113 | end 114 | 115 | it '#where -> find by label_selector' do 116 | ssh_keys = client.ssh_keys.where(label_selector: 'source=unittest').to_a 117 | expect(ssh_keys.length).to eq(1) 118 | expect(ssh_keys.first.labels).to include('source' => 'unittest') 119 | end 120 | 121 | it '#destroy' do 122 | expect(client.ssh_keys.first).to be_a Hcloud::SSHKey 123 | id = client.ssh_keys.first.id 124 | expect(id).to be_a Integer 125 | expect(client.ssh_keys.find(id).destroy).to be_a Hcloud::SSHKey 126 | expect(client.ssh_keys[id]).to be nil 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'grape' 4 | require 'active_support/all' 5 | require 'pry' 6 | 7 | require 'simplecov' 8 | SimpleCov.start 9 | 10 | require 'codecov' 11 | SimpleCov.formatter = SimpleCov::Formatter::Codecov 12 | 13 | require_relative './fake_service/base' 14 | require_relative './doubles/base' 15 | require_relative './doubles/action_tests' 16 | require_relative './support/typhoeus_ext' 17 | require_relative './support/matchers' 18 | 19 | require 'rspec' 20 | require 'hcloud' 21 | 22 | def deep_load(scope) 23 | return unless scope.respond_to?(:constants) 24 | 25 | scope.constants.each do |const| 26 | next unless scope.autoload?(const) 27 | 28 | deep_load(scope.const_get(const)) 29 | end 30 | end 31 | 32 | deep_load Hcloud 33 | 34 | RSpec.configure do |c| 35 | Faker::Config.random = Random.new(c.seed) 36 | 37 | c.include_context 'test doubles', :doubles 38 | 39 | if ENV['LEGACY_TESTS'] 40 | require 'webmock/rspec' 41 | c.before(:each) do 42 | stub_request(:any, /api.hetzner.cloud/).to_rack(Hcloud::FakeService::Base) 43 | end 44 | 45 | c.after(:all) do 46 | # Action holds the record of all executed actions (which can be queried 47 | # on the API). We need to delete this record after each example. 48 | Hcloud::FakeService::Action.reset 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/support/it_supports_action_fetch.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_examples 'it_supports_action_fetch' do |resource| 4 | source_url = resource.resource_class.name.demodulize.tableize 5 | sample_resource = source_url.gsub(/s$/, '') 6 | 7 | context 'Action' do 8 | let :actions do 9 | Array.new(Faker::Number.within(range: 5..10)).map { new_action } 10 | end 11 | 12 | let(:action) { actions.sample } 13 | 14 | let(:sample) { send(sample_resource) } 15 | 16 | it 'get all actions' do 17 | stub_item(source_url, sample) 18 | stub_collection("#{source_url}/#{sample[:id]}/actions", actions, resource_name: :actions) 19 | 20 | sample_obj = client.send(source_url)[sample[:id]] 21 | expect(sample_obj.actions.count).to be_positive 22 | expect(sample_obj.actions).to all be_a Hcloud::Action 23 | # check whether the action content is actually read correctly 24 | expect(sample_obj.actions.map(&:id)).to all be_positive 25 | end 26 | 27 | it 'get a single action' do 28 | stub_item(source_url, sample) 29 | stub_item( 30 | "#{source_url}/#{sample[:id]}/actions/#{action[:id]}", 31 | action, 32 | resource_name: :actions 33 | ) 34 | 35 | sample_obj = client.send(source_url)[sample[:id]] 36 | got_action = sample_obj.actions[action[:id]] 37 | expect(got_action).to be_a Hcloud::Action 38 | expect(got_action.id).to eq(action[:id]) 39 | expect(got_action.command).to eq(action[:command]) 40 | expect(got_action.status).to eq(action[:status].to_s) 41 | expect(got_action.started).to eq(action[:started]) 42 | expect(got_action.finished).to eq(action[:finished]) 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/support/it_supports_destroy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_examples 'it_supports_destroy' do |resource| 4 | source_url = resource.resource_class.name.demodulize.tableize 5 | sample_resource = source_url.gsub(/s$/, '') 6 | 7 | it '#destroy' do 8 | resource = send(sample_resource) 9 | expectation = stub_delete(sample_resource, resource) 10 | stub_collection(source_url, send(source_url)) 11 | 12 | expect(client.send(source_url).find(resource[:id]).destroy).to be_a described_class 13 | 14 | expect(expectation.times_called).to eq(1) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/support/it_supports_fetch.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_examples 'it_supports_fetch' do |resource| 4 | source_url = resource.resource_class.name.demodulize.tableize 5 | 6 | it 'fetch items' do 7 | stub_collection(source_url, send(source_url)) 8 | expect(client.send(source_url).count).to be_positive 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/support/it_supports_find_by_id_and_name.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'hcloud/errors' 4 | 5 | RSpec.shared_examples 'it_supports_find_by_id_and_name' do |resource| 6 | source_url = resource.resource_class.name.demodulize.tableize 7 | sample_resource = source_url.gsub(/s$/, '') 8 | 9 | context 'find' do 10 | before :each do 11 | stub_collection source_url, send(source_url) 12 | end 13 | 14 | context 'works' do 15 | it '#[] -> find by id' do 16 | ref = send(sample_resource) 17 | pg = client.send(source_url)[ref[:id]] 18 | 19 | expect(pg).to be_a resource 20 | expect(pg.name).to eq(ref[:name]) 21 | end 22 | 23 | it '#[] -> find by name' do 24 | ref = send(sample_resource) 25 | pg = client.send(source_url)[ref[:name]] 26 | 27 | expect(pg).to be_a resource 28 | expect(pg.name).to eq(ref[:name]) 29 | end 30 | 31 | it '#find_by -> find by id' do 32 | ref = send(sample_resource) 33 | pg = client.send(source_url).find_by(id: ref[:id]) 34 | 35 | expect(pg).to be_a resource 36 | expect(pg.name).to eq(ref[:name]) 37 | end 38 | 39 | it '#find_by -> find by name' do 40 | ref = send(sample_resource) 41 | pg = client.send(source_url).find_by(name: ref[:name]) 42 | 43 | expect(pg).to be_a resource 44 | expect(pg.name).to eq(ref[:name]) 45 | end 46 | 47 | it '#find_by -> find by name' do 48 | ref = send(sample_resource) 49 | pg = client.send(source_url).find(ref[:id]) 50 | 51 | expect(pg).to be_a resource 52 | expect(pg.name).to eq(ref[:name]) 53 | end 54 | end 55 | 56 | context 'handle non existent' do 57 | it '#[] -> find by id' do 58 | expect do 59 | client.send(source_url).find(0) 60 | end.to raise_error Hcloud::Error::NotFound 61 | end 62 | 63 | it '#[] -> find by name' do 64 | expect(client.send(source_url)[0]).to be_nil 65 | end 66 | 67 | it '#find_by -> find by id' do 68 | expect(client.send(source_url).find_by(id: 0)).to be_nil 69 | end 70 | 71 | it '#find_by -> find by name' do 72 | expect(client.send(source_url).find_by(name: 'a')).to be_nil 73 | end 74 | 75 | it '#find -> find by id' do 76 | expect do 77 | client.send(source_url).find(0) 78 | end.to raise_error Hcloud::Error::NotFound 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /spec/support/it_supports_labels_on_update.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_examples 'it_supports_labels_on_update' do |resource_type| 4 | source_url = resource_type.resource_class.name.demodulize.tableize 5 | sample_resource = source_url.gsub(/s$/, '') 6 | 7 | context 'label support' do 8 | it '#update labels' do 9 | new_labels = { 'key' => 'value', 'novalue' => '' } 10 | sample = send(sample_resource) 11 | expectation = stub_update(sample_resource, sample, { labels: new_labels }) 12 | stub_collection(source_url, send(source_url)) 13 | 14 | updated_item = client.send(source_url).find(sample[:id]).update(labels: new_labels) 15 | expect(updated_item).to be_a resource_type 16 | expect(updated_item.labels).to eq(new_labels) 17 | expect(expectation.times_called).to eq(1) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/support/it_supports_metrics.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'faker' 4 | 5 | RSpec.shared_examples 'it_supports_metrics' do |resource, metrics| 6 | source_url = resource.resource_class.name.demodulize.tableize 7 | sample_resource = source_url.gsub(/s$/, '') 8 | 9 | context '#metrics' do 10 | let :sample do 11 | send(sample_resource) 12 | end 13 | 14 | let :sample_obj do 15 | stub_item(source_url, sample) 16 | client.send(source_url)[sample[:id]] 17 | end 18 | 19 | def build_metrics(params) 20 | step = params[:step] || Faker::Number.within(range: 1..60) 21 | start = DateTime.iso8601(params[:start]).strftime('%s').to_i 22 | 23 | { 24 | start: params[:start], 25 | end: params[:end], 26 | step: step, 27 | time_series: { 28 | # In reality the time series name does not necessarily have to be the same 29 | # as the queried type. E.g. server metrics for the type "network" returns multiple time 30 | # series called "network.0.pps.in", "network.0.pps.out" and so on. But for our unit 31 | # tests returning a single time series is enough at the moment. 32 | params[:type].to_sym => { 33 | # generate a random length list of a few random values 34 | values: Array.new(Faker::Number.within(range: 0..100)).map.with_index do |_, idx| 35 | [start + idx * step, Faker::Number.decimal.to_s] 36 | end 37 | } 38 | } 39 | } 40 | end 41 | 42 | it 'handles missing type' do 43 | expect do 44 | sample_obj.metrics(type: nil, start: Time.now, end: Time.now + 60) 45 | end.to raise_error Hcloud::Error::InvalidInput 46 | end 47 | 48 | it 'handles missing start' do 49 | expect do 50 | sample_obj.metrics(type: :open_connections, start: nil, end: Time.now + 60) 51 | end.to raise_error Hcloud::Error::InvalidInput 52 | end 53 | 54 | it 'handles missing end' do 55 | expect do 56 | sample_obj.metrics(type: :open_connections, start: Time.now, end: nil) 57 | end.to raise_error Hcloud::Error::InvalidInput 58 | end 59 | 60 | it 'handles end before start' do 61 | expect do 62 | sample_obj.metrics(type: :open_connections, start: Time.now, end: Time.now - 60) 63 | end.to raise_error Hcloud::Error::InvalidInput 64 | end 65 | 66 | metrics.each do |metric| 67 | it "can fetch metric \"#{metric}\"" do 68 | expectation = stub("#{source_url}/#{sample[:id]}/metrics") do |req, _info| 69 | # required parameters for calls to metrics endpoint 70 | expect(req.options[:params]).to have_key(:type) 71 | expect(req.options[:params]).to have_key(:start) 72 | expect(req.options[:params]).to have_key(:end) 73 | 74 | # start and end must be in ISO-8601 format 75 | expect { DateTime.iso8601(req.options[:params][:start]) }.not_to raise_error 76 | expect { DateTime.iso8601(req.options[:params][:end]) }.not_to raise_error 77 | 78 | { 79 | body: { 80 | metrics: build_metrics(req.options[:params]) 81 | }, 82 | code: 200 83 | } 84 | end 85 | 86 | expect(sample_obj).to be_a resource 87 | result = sample_obj.metrics( 88 | type: metric, 89 | start: Time.now - 7 * 24 * 60 * 60, 90 | end: Time.now, 91 | step: 5 92 | ) 93 | 94 | expect(expectation.times_called).to eq(1) 95 | expect(result[:time_series]).to have_key(metric) 96 | 97 | # access is possible both with symbol and string 98 | expect(result[:time_series][metric.to_sym][:values]).to be_a Array 99 | expect(result['time_series'][metric.to_s]['values']).to be_a Array 100 | 101 | expect(result[:time_series][metric][:values].all? do |contents| 102 | # time + value must be exactly two values 103 | contents.count == 2 104 | end).to be true 105 | end 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /spec/support/it_supports_search.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_examples 'it_supports_search' do |resource, filter_attributes| 4 | source_url = resource.resource_class.name.demodulize.tableize 5 | 6 | context '#where' do 7 | filter_attributes.to_a.each do |filter| 8 | context "with filter for \"#{filter}\"" do 9 | it 'works' do 10 | search_term = 'search_term' 11 | expectation = stub(source_url) do |req, _info| 12 | expect(req).to have_query_params( 13 | a_hash_including({ filter => search_term }.deep_stringify_keys) 14 | ) 15 | 16 | items = send(source_url) 17 | { 18 | body: { 19 | source_url.to_sym => items 20 | }.merge(pagination(items)), 21 | code: 200 22 | } 23 | end 24 | 25 | items = client.send(source_url).where({ filter => search_term }) 26 | expect(items.count).to be_positive 27 | expect(items).to all be_a resource 28 | 29 | # might be called multiple times due to pagination 30 | expect(expectation.times_called).to be > 0 31 | end 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/support/it_supports_update.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_examples 'it_supports_update' do |resource_type, update_params| 4 | source_url = resource_type.resource_class.name.demodulize.tableize 5 | sample_resource = source_url.gsub(/s$/, '') 6 | 7 | context '#update' do 8 | # Test update of all attributes at once 9 | it 'update all attributes' do 10 | sample = send(sample_resource) 11 | expectation = stub_update(sample_resource, sample, update_params) 12 | stub_collection(source_url, send(source_url)) 13 | 14 | updated_item = client.send(source_url).find(sample[:id]).update(**update_params) 15 | expect(expectation.times_called).to eq(1) 16 | 17 | expect(updated_item).to be_a resource_type 18 | update_params.each do |key, value| 19 | expect(updated_item.send(key)).to eq(value) 20 | end 21 | end 22 | 23 | # Test update of each attribute individually 24 | update_params.each do |key, value| 25 | it "update attribute \"#{key}\"" do 26 | sample = send(sample_resource) 27 | expectation = stub_update(sample_resource, sample, { key => value }) 28 | stub_collection(source_url, send(source_url)) 29 | 30 | updated_item = client.send(source_url).find(sample[:id]).update(**{ key => value }) 31 | expect(updated_item).to be_a resource_type 32 | expect(updated_item.send(key)).to eq(value) 33 | expect(expectation.times_called).to eq(1) 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/support/matchers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec::Matchers.define :have_body_params do |params| 4 | match do |request| 5 | body = body_params(request) 6 | 7 | values_match?(params, body) 8 | end 9 | failure_message do |request| 10 | super() + ", but had #{body_params(request)}" 11 | end 12 | failure_message_when_negated do |request| 13 | super() + ", but had #{body_params(request)}" 14 | end 15 | 16 | private 17 | 18 | def body_params(request) 19 | Oj.load(request.encoded_body, mode: :compat) 20 | end 21 | end 22 | 23 | RSpec::Matchers.define :have_query_params do |params| 24 | match do |request| 25 | body = fetch_uri_params(request) 26 | 27 | values_match?(params, body) 28 | end 29 | failure_message do |request| 30 | super() + ", but had #{fetch_uri_params(request)}" 31 | end 32 | failure_message_when_negated do |request| 33 | super() + ", but had #{fetch_uri_params(request)}" 34 | end 35 | 36 | private 37 | 38 | # TODO: This method is also used in spec/doubles/base.rb. Where can we put it 39 | # to safely re-use it in both locations? Imo matchers.rb and spec/doubles/base.rb 40 | # on their own do not have a relationship. matchers.rb can be used outside of doubles 41 | # tests and doubles tests do not peek into the private methods of custom matchers. 42 | # So, it probably has to be defined somewhere globally within rspec? 43 | def fetch_uri_params(request) 44 | URI.parse(request.url).query.split('&').map { |pair| pair.split('=') }.to_h 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/support/typhoeus_ext.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'typhoeus' 4 | 5 | module Typhoeus 6 | module ExpectationExt 7 | def times_called 8 | @response_counter 9 | end 10 | end 11 | end 12 | 13 | Typhoeus::Expectation.include(Typhoeus::ExpectationExt) 14 | --------------------------------------------------------------------------------