summaryrefslogtreecommitdiff
path: root/lib/leap_cli/cloud
diff options
context:
space:
mode:
Diffstat (limited to 'lib/leap_cli/cloud')
-rw-r--r--lib/leap_cli/cloud/cloud.rb380
-rw-r--r--lib/leap_cli/cloud/dependencies.rb40
-rw-r--r--lib/leap_cli/cloud/image.rb31
3 files changed, 451 insertions, 0 deletions
diff --git a/lib/leap_cli/cloud/cloud.rb b/lib/leap_cli/cloud/cloud.rb
new file mode 100644
index 00000000..2c06e7ed
--- /dev/null
+++ b/lib/leap_cli/cloud/cloud.rb
@@ -0,0 +1,380 @@
+#
+# An abstractions in front of Fog, which is an abstraction in front of
+# the AWS api. Oh my!
+#
+# A Cloud object binds a particular node with particular Fog
+# authentication credentials.
+#
+# NOTE: Possible AWS options for creating instances:
+#
+# options = {
+# 'BlockDeviceMapping' => block_device_mapping,
+# 'NetworkInterfaces' => network_interfaces,
+# 'ClientToken' => client_token,
+# 'DisableApiTermination' => disable_api_termination,
+# 'EbsOptimized' => ebs_optimized,
+# 'IamInstanceProfile.Arn' => @iam_instance_profile_arn,
+# 'IamInstanceProfile.Name' => @iam_instance_profile_name,
+# 'InstanceInitiatedShutdownBehavior' => instance_initiated_shutdown_behavior,
+# 'InstanceType' => flavor_id,
+# 'KernelId' => kernel_id,
+# 'KeyName' => key_name,
+# 'Monitoring.Enabled' => monitoring,
+# 'Placement.AvailabilityZone' => availability_zone,
+# 'Placement.GroupName' => placement_group,
+# 'Placement.Tenancy' => tenancy,
+# 'PrivateIpAddress' => private_ip_address,
+# 'RamdiskId' => ramdisk_id,
+# 'SecurityGroup' => groups,
+# 'SecurityGroupId' => security_group_ids,
+# 'SubnetId' => subnet_id,
+# 'UserData' => user_data,
+# }
+#
+
+module LeapCli
+ class Cloud
+ DEFAULT_INSTANCE_OPTIONS = {
+ "InstanceType" => "t2.nano"
+ }
+ LEAP_SG_NAME = 'leap_default'
+ LEAP_SG_DESC = 'Default security group for LEAP nodes'
+
+ include LeapCli::LogCommand
+
+ attr_reader :compute # Fog::Compute object
+ attr_reader :node # Config::Node, if any
+ attr_reader :options # options for the VMs, if any
+ attr_reader :image # which vm image to use, if any
+ attr_reader :name # name of which entry in cloud.json to use
+
+ def initialize(name, conf, node=nil)
+ @node = node
+ @name = name
+ @conf = conf
+ @compute = nil
+ @options = DEFAULT_INSTANCE_OPTIONS
+ @image = nil
+
+ raise ArgumentError, 'name missing' unless @name
+ raise ArgumentError, 'config missing' unless @conf
+ raise ArgumentError, 'config auth missing' unless @conf["auth"]
+ raise ArgumentError, 'config auth missing' unless @conf["vendor"]
+
+ credentials = @conf["auth"].symbolize_keys
+ credentials[:provider] = @conf["vendor"]
+ @compute = Fog::Compute.new(credentials)
+
+ if @conf['default_options']
+ @options = @options.merge(@conf['default_options'])
+ end
+ @image = @conf['default_image'] || Cloud.aws_image(credentials[:region])
+ if @node
+ @options = @options.merge(node.vm.options) if node['vm.options']
+ @image = node.vm.image if node['vm.image']
+ end
+
+ unless @options['InstanceType']
+ raise ArgumentError, 'VM instance type is required. See https://leap.se/virtual-machines for more information.'
+ end
+ unless @image
+ raise ArgumentError, 'VM image is required. See https://leap.se/virtual-machines for more information.'
+ end
+ end
+
+ #
+ # fetches or creates a server for this cloud object.
+ #
+ def fetch_or_create_server(options)
+ fetch_server_for_node || create_new_vm_instance(choose_ssh_key: options[:choose_ssh_key])
+ end
+
+ #
+ # fetches the server for a particular node.
+ #
+ # return nil if this cloud object has no node, or there is no corresponding
+ # server.
+ #
+ def fetch_server_for_node(bail_on_failure=false)
+ server = nil
+ return nil unless @node
+
+ # does an instance exist that matches the node's vm.id?
+ if @node.vm_id?
+ instance_id = @node.vm.id
+ server = @compute.servers.get(instance_id)
+ end
+
+ # does an instance exist that is tagged with this node name?
+ if server.nil?
+ response = @compute.describe_instances({"tag:node_name" => @node.name})
+ # puts JSON.pretty_generate(response.body)
+ if !response.body["reservationSet"].empty?
+ instances = response.body["reservationSet"].first["instancesSet"]
+ if instances.size > 1
+ bail! "There are multiple VMs with the same node name tag! Manually remove one before continuing."
+ elsif instances.size == 1
+ instance_id = instances.first["instanceId"]
+ server = @compute.servers.get(instance_id)
+ end
+ end
+ end
+
+ if server.nil? && bail_on_failure
+ bail! :error, "A virtual machine could not be found for node `#{@node.name}`. Things to try:" do
+ log "check the output of `leap vm status`"
+ log "check the value of `vm.id` in #{@node.name}.json"
+ log "run `leap vm add #{@node.name}` to create a corresponding virtual machine"
+ end
+ end
+
+ return server
+ end
+
+ #
+ # associates a node with a vm
+ #
+ def bind_server_to_node(server)
+ unless @node
+ raise ArgumentError, 'no node'
+ end
+ if server.public_ip_address.nil?
+ bail! do
+ log 'The virtual machine `%s` must have an IP address in order to bind it to the configuration `%s`.' % [
+ server.id, Path.relative_path(Path.named_path([:node_config, @node.name]))]
+ log 'To fix, run `leap vm start %s`' % server.id
+ end
+ end
+
+ # assign tag
+ @compute.create_tags(server.id, {'node_name' => @node.name})
+ log :created, "association between node '%s' and vm '%s'" % [@node.name, server.id]
+
+ # update node json
+ @node.update_json({
+ "ip_address" => server.public_ip_address,
+ "vm"=> {"id"=>server.id}
+ })
+ end
+
+ #
+ # disassociates a node from a vm
+ #
+ def unbind_server_from_node(server)
+ raise ArgumentError, 'no node' unless @node
+
+ # assign tag
+ @compute.delete_tags(server.id, {'node_name' => @node.name})
+ log :removed, "association between node '%s' and vm '%s'" % [@node.name, server.id]
+
+ # update node json
+ @node.update_json({
+ "ip_address" => '0.0.0.0',
+ "vm"=> {"id" => ""}
+ })
+ end
+
+ #
+ # return an AWS KeyPair object, potentially uploading it to the server
+ # if necessary.
+ #
+ # this is used when initially creating the vm. After the first `node init`, then
+ # all sysadmins should have access to the server.
+ #
+ # NOTE: ssh and aws use different types of fingerprint
+ #
+ def find_or_create_key_pair(pick_ssh_key_method)
+ require 'leap_cli/ssh'
+ key_pair, local_key = match_ssh_key(:user_only => true)
+ if key_pair
+ log :using, "user SSH key #{local_key.filename}" do
+ log 'AWS MD5 fingerprint: ' + local_key.fingerprint(:digest => :md5, :type => :der, :encoding => :hex)
+ log 'SSH MD5 fingerprint: ' + local_key.fingerprint(:digest => :md5, :type => :ssh, :encoding => :hex)
+ log 'SSH SHA256 fingerprint: ' + local_key.fingerprint(:digest => :sha256, :type => :ssh, :encoding => :base64)
+ end
+ elsif key_pair.nil?
+ username, key = pick_ssh_key_method.call(self)
+ key_pair = upload_ssh_key(username, key)
+ end
+ return key_pair
+ end
+
+ #
+ # checks if there is a match between a local key and a registered key_pair
+ #
+ # options:
+ # :key_pair -- limit comparisons to this key_pair object.
+ # :user_only -- limit comparisons to the user's ~/.ssh directory only
+ #
+ # returns:
+ #
+ # key_pair -- an AWS KeyPair
+ # local_key -- a LeapCLi::SSH::Key
+ #
+ def match_ssh_key(options={})
+ key_pair = options[:key_pair]
+ local_keys_to_check = LeapCli::SSH::Key.my_public_keys
+ unless options[:user_only]
+ local_keys_to_check += LeapCli::SSH::Key.provider_public_keys
+ end
+ fingerprints = Hash[local_keys_to_check.map {|k|
+ [k.fingerprint(:digest => :md5, :type => :der, :encoding => :hex), k]
+ }]
+ key_pair ||= @compute.key_pairs.select {|key_pair|
+ fingerprints.include?(key_pair.fingerprint)
+ }.first
+ if key_pair
+ local_key = fingerprints[key_pair.fingerprint]
+ return key_pair, local_key
+ else
+ return nil, nil
+ end
+ end
+
+ def wait_for_ssh_host_key(server)
+ require 'leap_cli/ssh'
+ return nil if Fog.mock?
+ tries = 0
+ host_key = nil
+ cloud = self
+ server.wait_for {
+ if tries > 0
+ LeapCli.log :waiting, "for SSH host key..."
+ elsif tries > 20
+ return nil
+ end
+ tries += 1
+ ssh_host_keys = cloud.ssh_host_keys(server)
+ if ssh_host_keys.nil?
+ false
+ else
+ host_key = SSH::Key.pick_best_key(ssh_host_keys)
+ true
+ end
+ }
+ return host_key
+ end
+
+ #
+ # checks the console of the server for the ssh host keys
+ #
+ # returns nil if they cannot be found.
+ #
+ def ssh_host_keys(server)
+ require 'leap_cli/ssh'
+ return nil if Fog.mock?
+ response = @compute.get_console_output(server.id)
+ output = response.body["output"]
+ if output.nil?
+ return nil
+ end
+ keys = output.match(
+ /-----BEGIN SSH HOST KEY KEYS-----(.*)-----END SSH HOST KEY KEYS-----/m
+ )
+ if keys.nil?
+ return nil
+ else
+ ssh_key_list = keys[1].strip.split("\r\n").map {|key_str|
+ SSH::Key.load(key_str)
+ }
+ return ssh_key_list.compact
+ end
+ end
+
+ private
+
+ #
+ # Every AWS instance requires a security group, which is just a simple firewall.
+ # In the future, we could create a separate security group for each node,
+ # and set the rules to match what the rules should be for that node.
+ #
+ # However, for now, we just use a security group 'leap_default' that opens
+ # all the ports.
+ #
+ # The default behavior for AWS security groups is:
+ # all ingress traffic is blocked and all egress traffic is allowed.
+ #
+ def find_or_create_security_group
+ group = @compute.security_groups.get(LEAP_SG_NAME)
+ if group.nil?
+ group = @compute.security_groups.create(
+ :name => LEAP_SG_NAME,
+ :description => LEAP_SG_DESC
+ )
+ group.authorize_port_range(0..65535,
+ :ip_protocol => 'tcp',
+ :cidr_ip => '0.0.0.0/0',
+ )
+ group.authorize_port_range(0..65535,
+ :ip_protocol => 'udp',
+ :cidr_ip => '0.0.0.0/0',
+ )
+ end
+ return group
+ end
+
+ #
+ # key - a LeapCli::SSH::Key object
+ # returns -- AWS KeyPair
+ #
+ def upload_ssh_key(username, key)
+ key_name = 'leap_' + username
+ key_pair = @compute.key_pairs.create(
+ :name => key_name,
+ :public_key => key.public_key.to_s
+ )
+ log :registered, "public key" do
+ log 'cloud provider: ' + @name
+ log 'name: ' + key_name
+ log 'AWS MD5 fingerprint: ' + key.fingerprint(:digest => :md5, :type => :der, :encoding => :hex)
+ log 'SSH MD5 fingerprint: ' + key.fingerprint(:digest => :md5, :type => :ssh, :encoding => :hex)
+ log 'SSH SHA256 fingerprint: ' + key.fingerprint(:digest => :sha256, :type => :ssh, :encoding => :base64)
+ end
+ return key_pair
+ end
+
+ def create_new_vm_instance(choose_ssh_key: nil)
+ log :creating, "new vm instance..."
+ assert! @image, "No image found. Specify `default_image` in cloud.json or `vm.image` in node's config."
+ if Fog.mock?
+ options = @options
+ else
+ key_pair = find_or_create_key_pair(choose_ssh_key)
+ security_group = find_or_create_security_group
+ options = @options.merge({
+ 'KeyName' => key_pair.name,
+ 'SecurityGroup' => security_group.name
+ })
+ end
+ response = @compute.run_instances(
+ @image,
+ 1, # min count
+ 1, # max count
+ options
+ )
+ instance_id = response.body["instancesSet"].first["instanceId"]
+ log :created, "vm with instance id #{instance_id}."
+ server = @compute.servers.get(instance_id)
+ if server.nil?
+ bail! :error, "could not query instance '#{instance_id}'."
+ end
+ unless Fog.mock?
+ tries = 0
+ server.wait_for {
+ if tries > 0
+ LeapCli.log :waiting, "for IP address to be assigned..."
+ end
+ tries += 1
+ !public_ip_address.nil?
+ }
+ if options[:wait]
+ log :waiting, "for vm #{instance_id} to start..."
+ server.wait_for { ready? }
+ log :started, "#{instance_id} with #{server.public_ip_address}"
+ end
+ end
+ return server
+ end
+
+ end
+end \ No newline at end of file
diff --git a/lib/leap_cli/cloud/dependencies.rb b/lib/leap_cli/cloud/dependencies.rb
new file mode 100644
index 00000000..fd690e59
--- /dev/null
+++ b/lib/leap_cli/cloud/dependencies.rb
@@ -0,0 +1,40 @@
+#
+# I am not sure this is a good idea, but it might be. Tricky, so disabled for now
+#
+
+=begin
+module LeapCli
+ class Cloud
+
+ def self.check_required_gems
+ begin
+ require "fog"
+ rescue LoadError
+ bail! do
+ log :error, "The 'vm' command requires the gem 'fog-core'. Please run `gem install fog-core` and try again."
+ end
+ end
+
+ fog_gems = @cloud.required_gems
+ if !options[:mock] && fog_gems.empty?
+ bail! do
+ log :warning, "no vm providers are configured in cloud.json."
+ log "You must have credentials for one of: #{@cloud.possible_apis.join(', ')}."
+ end
+ end
+
+ fog_gems.each do |name, gem_name|
+ begin
+ require gem_name.sub('-','/')
+ rescue LoadError
+ bail! do
+ log :error, "The 'vm' command requires the gem '#{gem_name}' (because of what is configured in cloud.json)."
+ log "Please run `sudo gem install #{gem_name}` and try again."
+ end
+ end
+ end
+ end
+
+ end
+end
+=end \ No newline at end of file
diff --git a/lib/leap_cli/cloud/image.rb b/lib/leap_cli/cloud/image.rb
new file mode 100644
index 00000000..1f7f47b9
--- /dev/null
+++ b/lib/leap_cli/cloud/image.rb
@@ -0,0 +1,31 @@
+module LeapCli
+ class Cloud
+
+ #
+ # returns the latest official debian image for
+ # a particular AWS region
+ #
+ # https://wiki.debian.org/Cloud/AmazonEC2Image/Jessie
+ # current list based on Debian 8.4
+ #
+ # might return nil if no image is found.
+ #
+ def self.aws_image(region)
+ image_list = %q[
+ ap-northeast-1 ami-d7d4c5b9
+ ap-northeast-2 ami-9a03caf4
+ ap-southeast-1 ami-73974210
+ ap-southeast-2 ami-09daf96a
+ eu-central-1 ami-ccc021a3
+ eu-west-1 ami-e079f893
+ sa-east-1 ami-d3ae21bf
+ us-east-1 ami-c8bda8a2
+ us-west-1 ami-45374b25
+ us-west-2 ami-98e114f8
+ ]
+ region_to_image = Hash[image_list.strip.split("\n").map{|i| i.split(" ")}]
+ return region_to_image[region]
+ end
+
+ end
+end \ No newline at end of file