Revision ab3d6fb4
Added by Daniel Lobato Garcia about 8 years ago
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
Fixes #11332 - DigitalOcean API v2 support
This should allow provisioning using API v2. It relies on Fog 1.36 at
The plugin now:least to work, so 1.10 and older versions of Foreman will not work with
this plugin unfortunately.
client_id
task