Project

General

Profile

« Previous | Next » 

Revision ab3d6fb4

Added by Daniel Lobato Garcia about 8 years ago

Fixes #11332 - DigitalOcean API v2 support

This should allow provisioning using API v2. It relies on Fog 1.36 at
least to work, so 1.10 and older versions of Foreman will not work with
this plugin unfortunately.

The plugin now:
  • Uses slug instead of id for image, region and flavor (size)
  • Uses .delete instead of .destroy to destroy the droplet on DO
  • Just requests the API key when creating the compute resource, not the
    client_id
  • Has a few simple tests for the compute resource & enhances the Jenkins
    task

View differences:

README.md
```foreman-digitalocean``` enables provisioning and managing of [DigitalOcean](http://digitalocean.com) droplets in [Foreman](http://github.com/theforeman/foreman), all of that under the GPL v3+ license.
## NOTICE - DigitalOcean APIv1 Deprecated
Digital Ocean has End of Life'd their APIv1 - https://developers.digitalocean.com/documentation/changelog/api-v1/api-v1-end-of-life/ -- as such, this module ceased working. There has been work in progress to update the "Fog" libraries to use APIv2, and just recently version 1.36 was release which should resolve the problems. I will update/remove this notice once it is resolved. Please see http://projects.theforeman.org/issues/11332 for the most up to date information.
* Website: [TheForeman.org](http://theforeman.org)
* ServerFault tag: [Foreman](http://serverfault.com/questions/tagged/foreman)
* Issues: [foreman-digitalocean Redmine](http://projects.theforeman.org/projects/digitalocean/issues)
app/helpers/digitalocean_images_helper.rb
module DigitaloceanImagesHelper
def digitalocean_image_field(f)
images = @compute_resource.available_images
images.each { |image| image.name = image.id if image.name.nil? }
select_f f, :uuid, images.to_a.sort_by(&:full_name),
:id, :full_name, {}, :label => _('Image')
end
def select_image(f, compute_resource)
images = possible_images(compute_resource, nil, nil)
select_f(f,
:image_id,
images,
:id,
:slug,
{ :include_blank => (images.empty? || images.size == 1) ? false : _('Please select an image') },
{ :label => ('Image'), :disabled => images.empty? } )
end
def select_region(f, compute_resource)
regions = compute_resource.regions
f.object.region = compute_resource.region
select_f(f,
:region,
regions,
:slug,
:slug,
{},
:label => ('Region'),
:disabled => compute_resource.images.empty?)
end
end
app/models/concerns/fog_extensions/digitalocean/image.rb
# Attempt guessing arch based on the name from digital ocean
def arch
requires :os_version
os_version.end_with?("x64") ? "x86_64" : ( os_version.end_with?("x32") ? "i386" : nil )
if os_version.end_with?("x64")
"x86_64"
elsif os_version.end_with?("x32")
"i386"
end
end
end
end
end
app/models/concerns/fog_extensions/digitalocean/server.rb
module Server
extend ActiveSupport::Concern
attr_accessor :image_id
def identity_to_s
identity.to_s
end
......
[public_ip_address, private_ip_address].flatten.select(&:present?)
end
def state
requires :status
@state ||= status
end
end
end
end
app/models/foreman_digitalocean/concerns/host_managed_extensions.rb
module ForemanDigitalocean
module Concerns
module HostManagedExtensions
extend ActiveSupport::Concern
included do
# Rails 4 does not provide dynamic finders for delegated methods and
# the SSH orchestrate compute method uses them.
def self.find_by_ip(ip)
nic = Nic::Base.find_by_ip(ip)
nic.host if nic.present?
end
end
end
end
end
app/models/foreman_digitalocean/digitalocean.rb
module ForemanDigitalocean
class Digitalocean < ComputeResource
alias_attribute :api_key, :password
alias_attribute :region, :url
has_one :key_pair, :foreign_key => :compute_resource_id, :dependent => :destroy
delegate :flavors, :to => :client
validates :user, :password, :presence => true
validates :api_key, :presence => true
before_create :test_connection
after_create :setup_key_pair
after_destroy :destroy_key_pair
# Not sure why it would need a url, but OK (copied from ec2)
alias_attribute :region, :url
attr_accessible :region, :api_key
def to_label
"#{name} (#{provider_friendly_name})"
end
def provided_attributes
super.merge({ :uuid => :identity_to_s, :ip => :public_ip_address })
super.merge(:uuid => :identity_to_s, :ip => :public_ip_address)
end
def self.model_name
......
raise(ActiveRecord::RecordNotFound)
end
def create_vm(args = { })
def create_vm(args = {})
args["ssh_keys"] = [ssh_key] if ssh_key
args['image'] = args['image_id']
super(args)
rescue Fog::Errors::Error => e
logger.error "Unhandled DigitalOcean error: #{e.class}:#{e.message}\n " + e.backtrace.join("\n ")
......
end
def regions
return [] if user.blank? || password.blank?
return [] if api_key.blank?
client.regions
end
def test_connection(options = {})
super
errors[:user].empty? and errors[:password].empty? and regions.count
errors[:password].empty? && regions.count
rescue Excon::Errors::Unauthorized => e
errors[:base] << e.response.body
rescue Fog::Errors::Error => e
......
def destroy_vm(uuid)
vm = find_vm_by_uuid(uuid)
vm.destroy if vm.present?
vm.delete if vm.present?
true
end
# not supporting update at the moment
def update_required?(old_attrs, new_attrs)
def update_required?(*)
false
end
......
end
def associated_host(vm)
Host.authorized(:view_hosts, Host).where(:ip => [vm.public_ip_address, vm.private_ip_address]).first
Host.authorized(:view_hosts, Host).
where(:ip => [vm.public_ip_address, vm.private_ip_address]).first
end
def user_data_supported?
......
end
def default_region_name
@default_region_name ||= client.regions.get(region.to_i).try(:name)
@default_region_name ||= client.regions[region.to_i].try(:name)
rescue Excon::Errors::Unauthorized => e
errors[:base] << e.response.body
end
......
def client
@client ||= Fog::Compute.new(
:provider => "DigitalOcean",
:digitalocean_client_id => user,
:digitalocean_api_key => password,
:version => 'V2',
:digitalocean_token => api_key
)
end
def vm_instance_defaults
super.merge(
:flavor_id => client.flavors.first.id
:size => client.flavors.first.slug
)
end
# this method creates a new key pair for each new DigitalOcean compute resource
# it should create the key and upload it to DigitalOcean
# Creates a new key pair for each new DigitalOcean compute resource
# After creating the key, it uploads it to DigitalOcean
def setup_key_pair
public_key, private_key = generate_key
key_name = "foreman-#{id}#{Foreman.uuid}"
key = client.create_ssh_key key_name, public_key
KeyPair.create! :name => key_name, :compute_resource_id => self.id, :secret => private_key
client.create_ssh_key key_name, public_key
KeyPair.create! :name => key_name, :compute_resource_id => id, :secret => private_key
rescue => e
logger.warn "failed to generate key pair"
logger.error e.message
......
def ssh_key
@ssh_key ||= begin
key = client.list_ssh_keys.data[:body]["ssh_keys"].select{|i| i["name"] == key_pair.name}.first
if key
#the vm creator expects objects which respond to id, OpenStruct is the shortest solution.
OpenStruct.new(key)
else
nil
end
key = client.list_ssh_keys.data[:body]["ssh_keys"].find { |i| i["name"] == key_pair.name }
key['id'] if key.present?
end
end
def generate_key
key = OpenSSL::PKey::RSA.new 2048
type = key.ssh_type
data = [ key.to_blob ].pack('m0')
data = [key.to_blob].pack('m0')
openssh_format_public_key = "#{type} #{data}"
[openssh_format_public_key, key.to_pem]
end
end
end
app/views/compute_resources/form/_digitalocean.html.erb
<%= text_f f, :user, :label => _("Client ID") %>
<%= password_f f, :password, :label => _("API Key") %>
<%= password_f f, :api_key, :label => _("API Key"), :unset => unset_password? %>
<% regions = f.object.regions rescue [] %>
<div id='region_selection'>
<%= select_f(f, :region, regions, :id, :name, {}, {:label => _('Default Region'), :disabled => regions.empty?,
:help_inline => link_to_function(regions.empty? ? _("Load Regions") : _("Test Connection"), "testConnection(this)",
:class => "btn + #{regions.empty? ? "btn-default" : "btn-success"}",
:'data-url' => test_connection_compute_resources_path) + image_tag('/assets/spinner.gif', :id => 'test_connection_indicator', :class => 'hide').html_safe }) %>
<%= select_f(
f, :region, regions, :slug, :name, {},
:label => _('Default Region'), :disabled => regions.empty?,
:help_inline => link_to_function(
regions.empty? ? _("Load Regions") : _("Test Connection"), "testConnection(this)",
:class => "btn + #{regions.empty? ? "btn-default" : "btn-success"}",
:'data-url' => test_connection_compute_resources_path) +
spinner('', :id => 'test_connection_indicator',
:class => 'hide').html_safe) %>
</div>
app/views/compute_resources_vms/form/digitalocean/_base.html.erb
<%= select_f f, :flavor_id, compute_resource.flavors, :id, :name, {}, {:label => _('Flavor')} %>
<%
arch ||= nil
os ||= nil
images = possible_images(compute_resource, arch, os)
images = compute_resource.available_images if images.empty?
regions = compute_resource.regions
f.object.region_id = compute_resource.region
%>
<div id='image_selection'><%= select_f f, :image_id, images, :uuid, :name, { :include_blank => (images.empty? || images.size == 1) ? false : _('Please Select an Image') }, { :label => ('Image'), :disabled => images.empty? } %></div>
<div id='region_selection'><%= select_f f, :region_id, regions, :id, :name, {}, { :label => ('Region'), :disabled => images.empty?} %></div>
<%= select_f f, :size, compute_resource.flavors, :slug, :slug, {}, {:label => _('Flavor')} %>
<div id='image_selection'>
<%= select_image(f, compute_resource) %>
</div>
<div id='region_selection'>
<%= select_region(f, compute_resource) %>
</div>
app/views/compute_resources_vms/index/_digitalocean.html.erb
<% @vms.each do |vm| %>
<tr>
<td><%= link_to_if_authorized vm.name, hash_for_compute_resource_vm_path(:compute_resource_id => @compute_resource, :id => vm.identity).merge(:auth_object => @compute_resource, :authorizer => authorizer) %></td>
<td><%= vm.image.name if vm.image.present? %></td>
<td><%= vm.flavor.name %></td>
<td><%= vm.region.name %></td>
<td><%= vm.image['slug'] if vm.image.present? %></td>
<td><%= vm.size['slug'] %></td>
<td><%= vm.region['slug'] %></td>
<td> <span <%= vm_power_class(vm.ready?) %>> <%= vm_state(vm) %></span> </td>
<td>
<%= action_buttons(
app/views/images/form/_digitalocean.html.erb
<%= text_f f, :username, :value => @image.username || "root", :help_inline => _("The user that is used to ssh into the instance, normally cloud-user, ec2-user, ubuntu, root etc") %>
<%= image_field(f) %>
<%= text_f f,
:username,
:value => @image.username || "root",
:help_inline => _("The user that is used to ssh into the instance, normally cloud-user, ec2-user, ubuntu, root etc") %>
<%= digitalocean_image_field(f) %>
<%= checkbox_f f, :user_data, :help_inline => _("Does this image support user data input (e.g. via cloud-init)?") %>
lib/foreman_digitalocean/engine.rb
require 'gettext_i18n_rails'
module ForemanDigitalocean
# Inherit from the Rails module of the parent app (Foreman), not the plugin.
# Thus, inherits from ::Rails::Engine and not from Rails::Engine
class Engine < ::Rails::Engine
engine_name 'foreman_digitalocean'
config.autoload_paths += Dir["#{config.root}/app/models/concerns"]
initializer 'foreman_digitalocean.register_gettext', :after => :load_config_initializers do
locale_dir = File.join(File.expand_path('../../..', __FILE__), 'locale')
locale_domain = 'foreman_digitalocean'
......
Foreman::Gettext::Support.add_text_domain locale_domain, locale_dir
end
initializer 'foreman_digitalocean.register_plugin', :after => :finisher_hook do
initializer 'foreman_digitalocean.register_plugin', :before => :finisher_hook do
Foreman::Plugin.register :foreman_digitalocean do
requires_foreman '>= 1.8'
compute_resource ForemanDigitalocean::Digitalocean
end
end
end
require 'fog/digitalocean'
require 'fog/digitalocean/models/compute/image'
require 'fog/digitalocean/models/compute/server'
require File.expand_path('../../../app/models/concerns/fog_extensions/digitalocean/server', __FILE__)
require File.expand_path('../../../app/models/concerns/fog_extensions/digitalocean/image', __FILE__)
Fog::Compute::DigitalOcean::Image.send(:include, FogExtensions::DigitalOcean::Image)
Fog::Compute::DigitalOcean::Server.send(:include, FogExtensions::DigitalOcean::Server)
rake_tasks do
load "#{ForemanDigitalocean::Engine.root}/lib/foreman_digitalocean/tasks/test.rake"
end
config.to_prepare do
require 'fog/digitalocean'
require 'fog/digitalocean/compute_v2'
require 'fog/digitalocean/models/compute_v2/image'
require 'fog/digitalocean/models/compute_v2/server'
require File.expand_path(
'../../../app/models/concerns/fog_extensions/digitalocean/server',
__FILE__)
require File.expand_path(
'../../../app/models/concerns/fog_extensions/digitalocean/image',
__FILE__)
Fog::Compute::DigitalOceanV2::Image.send :include,
FogExtensions::DigitalOcean::Image
Fog::Compute::DigitalOceanV2::Server.send :include,
FogExtensions::DigitalOcean::Server
::Host::Managed.send :include,
ForemanDigitalocean::Concerns::HostManagedExtensions
end
end
end
lib/foreman_digitalocean/tasks/test.rake
require File.expand_path("../engine", File.dirname(__FILE__))
namespace :test do
desc "Run the plugin unit test suite."
task :digitalocean => ['db:test:prepare'] do
test_task = Rake::TestTask.new('digitalocean_test_task') do |t|
t.libs << ["test", "#{ForemanDigitalocean::Engine.root}/test"]
t.test_files = [
"#{ForemanDigitalocean::Engine.root}/test/**/*_test.rb"
]
t.verbose = true
end
Rake::Task[test_task.name].invoke
end
end
namespace :digitalocean do
task :rubocop do
begin
require 'rubocop/rake_task'
RuboCop::RakeTask.new(:rubocop_digitalocean) do |task|
task.patterns = ["#{ForemanDigitalocean::Engine.root}/app/**/*.rb",
"#{ForemanDigitalocean::Engine.root}/lib/**/*.rb",
"#{ForemanDigitalocean::Engine.root}/test/**/*.rb"]
end
rescue
puts "Rubocop not loaded."
end
Rake::Task['rubocop_digitalocean'].invoke
end
end
Rake::Task[:test].enhance do
Rake::Task['test:digitalocean'].invoke
end
load 'tasks/jenkins.rake'
if Rake::Task.task_defined?(:'jenkins:unit')
Rake::Task["jenkins:unit"].enhance do
Rake::Task['test:digitalocean'].invoke
Rake::Task['digitalocean:rubocop'].invoke
end
end
test/factories/compute_resources.rb
FactoryGirl.define do
factory :container_resource, :class => ComputeResource do
sequence(:name) { |n| "compute_resource#{n}" }
trait :digitalocean do
provider 'Digitalocean'
api_key 'asampleapikey'
region 'everywhere'
end
factory :digitalocean_cr, :class => ForemanDigitalocean::Digitalocean, :traits => [:digitalocean]
end
end
test/test_plugin_helper.rb
# This calls the main test_helper in Foreman core
require 'test_helper'
# Add plugin to FactoryGirl's paths
FactoryGirl.definition_file_paths << File.join(File.dirname(__FILE__), 'factories')
FactoryGirl.reload
test/unit/foreman_digitalocean/digitalocean_test.rb
require 'test_plugin_helper'
class ForemanDigitalocean::DigitaloceanTest < ActiveSupport::TestCase
should validate_presence_of(:api_key)
should allow_mass_assignment_of(:region)
should allow_mass_assignment_of(:api_key)
should delegate_method(:flavors).to(:client)
should have_one(:key_pair)
setup { Fog.mock! }
teardown { Fog.unmock! }
test 'ssh key pair gets created after its saved' do
digitalocean = FactoryGirl.build(:digitalocean_cr)
digitalocean.expects(:setup_key_pair)
digitalocean.save
end
end

Also available in: Unified diff