From 205b61dfe721e6d88fc06b050a0497eeb35f4e02 Mon Sep 17 00:00:00 2001 From: elijah Date: Thu, 21 Jul 2016 00:55:12 -0700 Subject: added 'leap vm' command --- lib/leap_cli/cloud.rb | 4 + lib/leap_cli/cloud/cloud.rb | 310 ++++++++++++++++++++++++++++ lib/leap_cli/cloud/dependencies.rb | 40 ++++ lib/leap_cli/cloud/image.rb | 31 +++ lib/leap_cli/commands/node.rb | 42 +++- lib/leap_cli/commands/node_init.rb | 7 + lib/leap_cli/commands/vm.rb | 399 +++++++++++++++++++++++++++++++++++++ lib/leap_cli/config/cloud.rb | 64 ++++++ lib/leap_cli/config/environment.rb | 10 +- lib/leap_cli/config/filter.rb | 7 +- lib/leap_cli/config/manager.rb | 23 ++- lib/leap_cli/config/node.rb | 13 +- lib/leap_cli/load_libraries.rb | 4 +- lib/leap_cli/log_filter.rb | 9 +- lib/leap_cli/ssh/backend.rb | 6 + lib/leap_cli/ssh/key.rb | 123 +++++++++++- lib/leap_cli/ssh/options.rb | 3 + lib/leap_cli/ssh/remote_command.rb | 6 + lib/leap_cli/ssh/scripts.rb | 15 ++ lib/leap_cli/util/console_table.rb | 13 +- 20 files changed, 1090 insertions(+), 39 deletions(-) create mode 100644 lib/leap_cli/cloud.rb create mode 100644 lib/leap_cli/cloud/cloud.rb create mode 100644 lib/leap_cli/cloud/dependencies.rb create mode 100644 lib/leap_cli/cloud/image.rb create mode 100644 lib/leap_cli/commands/vm.rb create mode 100644 lib/leap_cli/config/cloud.rb (limited to 'lib') diff --git a/lib/leap_cli/cloud.rb b/lib/leap_cli/cloud.rb new file mode 100644 index 00000000..481dd6e7 --- /dev/null +++ b/lib/leap_cli/cloud.rb @@ -0,0 +1,4 @@ + +require 'fog' +require_relative 'cloud/cloud.rb' +require_relative 'cloud/image.rb' diff --git a/lib/leap_cli/cloud/cloud.rb b/lib/leap_cli/cloud/cloud.rb new file mode 100644 index 00000000..f0bb45b8 --- /dev/null +++ b/lib/leap_cli/cloud/cloud.rb @@ -0,0 +1,310 @@ +# +# 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 + 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 = nil + @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) + + @options = @conf['default_options'] || {} + @image = @conf['default_image'] || aws_image(credentials[:region]) + if @node + @options = node.vm.options if node['vm.options'] + @image = node.vm.image if node['vm.image'] + 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 = cloud.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) + raise ArgumentError, 'no node' unless @node + + # 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} + }) + log "done", :color => :green, :style => :bold + 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, "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 + + 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 diff --git a/lib/leap_cli/commands/node.rb b/lib/leap_cli/commands/node.rb index 15575d4f..60540de9 100644 --- a/lib/leap_cli/commands/node.rb +++ b/lib/leap_cli/commands/node.rb @@ -19,9 +19,14 @@ module LeapCli; module Commands "Separate multiple values for a single property with a comma, like so: `leap node add mynode services:webapp,dns`"].join("\n\n") node.arg_name 'NAME [SEED]' # , :optional => false, :multiple => false node.command :add do |add| - add.switch :local, :desc => 'Make a local testing node (by automatically assigning the next available local IP address). Local nodes are run as virtual machines on your computer.', :negatable => false + add.switch :local, :desc => 'Make a local testing node (by assigning the next available local IP address). Local nodes are run as virtual machines on your computer.', :negatable => false + add.switch :vm, :desc => 'Make a remote virtual machine for this node. Requires a valid cloud.json configuration.', :negatable => false add.action do |global_options,options,args| - add_node(global_options, options, args) + if options[:vm] + do_vm_add(global_options, options, args) + else + do_node_add(global_options, options, args) + end end end @@ -29,7 +34,7 @@ module LeapCli; module Commands node.arg_name 'OLD_NAME NEW_NAME' node.command :mv do |mv| mv.action do |global_options,options,args| - move_node(global_options, options, args) + do_node_move(global_options, options, args) end end @@ -37,7 +42,7 @@ module LeapCli; module Commands node.arg_name 'NAME' #:optional => false #, :multiple => false node.command :rm do |rm| rm.action do |global_options,options,args| - rm_node(global_options, options, args) + do_node_rm(global_options, options, args) end end end @@ -58,7 +63,10 @@ module LeapCli; module Commands protected - def add_node(global, options, args) + # + # additionally called by `leap vm add` + # + def do_node_add(global, options, args) name = args.first unless global[:force] assert_files_missing! [:node_config, name] @@ -81,7 +89,7 @@ module LeapCli; module Commands private - def move_node(global, options, args) + def do_node_move(global, options, args) node = get_node_from_args(args, include_disabled: true) new_name = args.last Config::Node.validate_name!(new_name, node.vagrant?) @@ -91,14 +99,30 @@ module LeapCli; module Commands end remove_directory! [:node_files_dir, node.name] rename_node_facts(node.name, new_name) + if node.vm_id? + node['name'] = new_name + bind_server_to_node(node.vm.id, node, options) + end end - def rm_node(global, options, args) + def do_node_rm(global, options, args) node = get_node_from_args(args, include_disabled: true) - node.remove_files - if node.vagrant? + if node.vm? + if !node.vm_id? + log :warning, "The node #{node.name} is missing a 'vm.id' property. "+ + "You may have a virtual machine instance that is left "+ + "running. Check `leap vm status`" + else + msg = "The node #{node.name} appears to be associated with a virtual machine. " + + "Do you want to also destroy this virtual machine? " + if global[:yes] || agree(msg) + do_vm_rm(global, options, args) + end + end + elsif node.vagrant? vagrant_command("destroy --force", [node.name]) end + node.remove_files remove_node_facts(node.name) end diff --git a/lib/leap_cli/commands/node_init.rb b/lib/leap_cli/commands/node_init.rb index 62a57496..59661295 100644 --- a/lib/leap_cli/commands/node_init.rb +++ b/lib/leap_cli/commands/node_init.rb @@ -37,6 +37,13 @@ module LeapCli; module Commands update_compiled_ssh_configs # allow password auth for new nodes: options[:auth_methods] = ["publickey", "password"] + if node.vm? + # new AWS virtual machines will only allow login as 'admin' + # before we continue, we must enable root access. + SSH.remote_command(node, options.merge(:user => 'admin')) do |ssh, host| + ssh.scripts.allow_root_ssh + end + end SSH.remote_command(node, options) do |ssh, host| if node.vagrant? ssh.scripts.install_insecure_vagrant_key diff --git a/lib/leap_cli/commands/vm.rb b/lib/leap_cli/commands/vm.rb new file mode 100644 index 00000000..ec2b6993 --- /dev/null +++ b/lib/leap_cli/commands/vm.rb @@ -0,0 +1,399 @@ +module LeapCli; module Commands + + desc "Manage virtual machines." + long_desc "This command provides a convenient way to manage virtual machines. " + + "FILTER may be a node filter or the ID of a virtual machine." + + command [:vm] do |vm| + vm.switch :mock, :desc => "Run as simulation, without actually connecting to a cloud provider. If set, --auth is ignored." + vm.switch :wait, :desc => "Wait for servers to start/stop before continuing." + vm.flag :auth, :arg_name => 'AUTH', + :desc => "Choose which authentication credentials to use from the file cloud.json. "+ + "If omitted, will default to the node's `vm.auth` property, or the first credentials in cloud.json" + + vm.desc "Allocates a new virtual machine and/or associates it with node NAME. "+ + "If node configuration file does not yet exist, "+ + "it is created with the optional SEED values. "+ + "You can run this command when the virtual machine already exists "+ + "in order to update the node's `vm.id` property." + vm.arg_name 'NODE_NAME [SEED]' + vm.command :add do |cmd| + #cmd.flag(:image, + # :desc => "The image to use to create this virtual machine.", + # :arg_name => 'IMAGE' + #) + cmd.action do |global, options, args| + do_vm_add(global, options, args) + end + end + + vm.desc 'Starts the virtual machine(s)' + vm.arg_name 'FILTER', :optional => true + vm.command :start do |start| + start.action do |global, options, args| + do_vm_start(global, options, args) + end + end + + vm.desc 'Shuts down the virtual machine(s), but keeps the storage allocated (to save resources, run `leap vm rm` instead).' + vm.arg_name 'FILTER', :optional => true + vm.command :stop do |stop| + stop.action do |global, options, args| + do_vm_stop(global, options, args) + end + end + + vm.desc 'Destroys the virtual machine(s)' + vm.arg_name 'FILTER', :optional => true + vm.command :rm do |rm| + rm.action do |global, options, args| + do_vm_rm(global, options, args) + end + end + + vm.desc 'Print the status of virtual machine(s)' + vm.arg_name 'FILTER', :optional => true + vm.command :status do |status| + status.action do |global, options, args| + do_vm_status(global, options, args) + end + end + + vm.desc "Binds a running virtual machine instance to a node configuration. "+ + "Afterwards, the VM will be assigned a label matching the node name, "+ + "and the node config will be updated with the instance ID." + vm.arg_name 'NODE_NAME INSTANCE_ID' + vm.command 'bind' do |cmd| + cmd.action do |global, options, args| + do_vm_bind(global, options, args) + end + end + + vm.desc "Registers a SSH public key for use when creating new virtual machines. "+ + "Note that only people who are creating new VM instances need to "+ + "have their key registered." + vm.command 'key-register' do |cmd| + cmd.action do |global, options, args| + do_vm_key_register(global, options, args) + end + end + + vm.desc "Lists the registered SSH public keys for a particular virtual "+ + "machine provider." + vm.command 'key-list' do |cmd| + cmd.action do |global, options, args| + do_vm_key_list(global, options, args) + end + end + + #vm.desc 'Saves the current state of the virtual machine as a new snapshot.' + #vm.arg_name 'FILTER', :optional => true + #vm.command :save do |save| + # save.action do |global, options, args| + # do_vm_save(global, options, args) + # end + #end + + #vm.desc 'Resets virtual machine(s) to the last saved snapshot' + #vm.arg_name 'FILTER', :optional => true + #vm.command :reset do |reset| + # reset.action do |global, options, args| + # do_vm_reset(global, options, args) + # end + #end + + #vm.desc 'Lists the available images.' + #vm.command 'image-list' do |cmd| + # cmd.action do |global, options, args| + # do_vm_image_list(global, options, args) + # end + #end + end + + ## + ## SHARED UTILITY METHODS + ## + + protected + + # + # a callback used if we need to upload a new ssh key + # + def choose_ssh_key_for_upload(cloud) + puts + bail! unless agree("The cloud provider `#{cloud.name}` does not have "+ + "your public key. Do you want to upload one? ") + key = pick_ssh_key + username = ask("username? ", :default => `whoami`.strip) + assert!(username && !username.empty? && username =~ /[0-9a-z_-]+/, "Username must consist of one or more letters or numbers") + puts + return username, key + end + + def bind_server_to_node(vm_id, node, options={}) + cloud = new_cloud_handle(node, options) + server = cloud.compute.servers.get(vm_id) + assert! server, "Could not find a VM instance with ID '#{vm_id}'" + cloud.bind_server_to_node(server) + end + + ## + ## COMMANDS + ## + + protected + + # + # entirely removes the vm, not just stopping it. + # + # This might be additionally called by the 'leap node rm' command. + # + def do_vm_rm(global, options, args) + servers_from_args(global, options, args) do |cloud, server| + cloud.unbind_server_from_node(server) if cloud.node + destroy_server(server, options[:wait]) + end + end + + private + + def do_vm_status(global, options, args) + cloud = new_cloud_handle(nil, options) + servers = cloud.compute.servers + t = LeapCli::Util::ConsoleTable.new + t.table do + t.row(color: :cyan) do + t.column "ID" + t.column "NODE" + t.column "STATE" + t.column "FLAVOR" + t.column "IP" + t.column "ZONE" + end + servers.each do |server| + t.row do + t.column server.id + t.column server.tags["node_name"] + t.column server.state, :color => state_color(server.state) + t.column server.flavor_id + t.column server.public_ip_address + t.column server.availability_zone + end + end + end + puts + t.draw_table + end + + def do_vm_add(global, options, args) + name = args.first + if manager.nodes[name].nil? + do_node_add(global, {:ip_address => '0.0.0.0'}.merge(options), args) + end + node = manager.nodes[name] + cloud = new_cloud_handle(node, options) + server = cloud.fetch_or_create_server(:choose_ssh_key => method(:choose_ssh_key_for_upload)) + + if server + cloud.bind_server_to_node(server) + end + end + + def do_vm_start(global, options, args) + servers_from_args(global, options, args) do |cloud, server| + start_server(server, options[:wait]) + end + end + + def do_vm_stop(global, options, args) + servers_from_args(global, options, args) do |cloud, server| + stop_server(server, options[:wait]) + end + end + + def do_vm_key_register(global, options, args) + cloud = new_cloud_handle(nil, options) + cloud.find_or_create_key_pair(method(:choose_ssh_key_for_upload)) + end + + def do_vm_key_list(global, options, args) + require 'leap_cli/ssh' + cloud = new_cloud_handle(nil, options) + cloud.compute.key_pairs.each do |key_pair| + log key_pair.name, :color => :cyan do + log "AWS fingerprint: " + key_pair.fingerprint + key_pair, local_key = cloud.match_ssh_key(:key_pair => key_pair) + if local_key + log "matches local key: " + local_key.filename + 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 + end + end + end + + # + # update association between node and virtual machine. + # + # This might additionally be called by the 'leap node mv' command. + # + def do_vm_bind(global, options, args) + node_name = args.first + vm_id = args.last + assert! node_name, "NODE_NAME is missing" + assert! vm_id, "INSTANCE_ID is missing" + node = manager.nodes[node_name] + assert! node, "No node with name '#{node_name}'" + bind_server_to_node(vm_id, node, options) + end + + #def do_vm_image_list(global, options, args) + # compute = fog_setup(nil, options) + # p compute.images.all + #end + + ## + ## PRIVATE UTILITY METHODS + ## + + def stop_server(server, wait=false) + if server.state == 'stopped' + log :skipping, "virtual machine `#{server.id}` (already stopped)." + elsif ['shutting-down', 'terminated'].include?(server.state) + log :skipping, "virtual machine `#{server.id}` (being destroyed)." + else + log :stopping, "virtual machine `#{server.id}` (#{server.flavor_id}, #{server.availability_zone})" + server.stop + if wait + log 'please wait...', :indent => 1 + server.wait_for { state == 'stopped' } + log 'done', :color => :green, :indent => 1 + end + end + end + + def start_server(server, wait=false) + if server.state == 'running' + log :skipping, "virtual machine `#{server.id}` (already running)." + elsif ['shutting-down', 'terminated'].include?(server.state) + log :skipping, "virtual machine `#{server.id}` (being destroyed)." + else + log :starting, "virtual machine `#{server.id}` (#{server.flavor_id}, #{server.availability_zone})" + server.start + if wait + log 'please wait...', :indent => 1 + server.wait_for { ready? } + log 'done', :color => :green, :indent => 1 + end + end + end + + def destroy_server(server, wait=false) + if ['shutting-down', 'terminated'].include?(server.state) + log :skipping, "virtual machine `#{server.id}` (already being removed)." + else + log :terminated, "virtual machine `#{server.id}` (#{server.flavor_id}, #{server.availability_zone})" + server.destroy + if wait + log 'please wait...', :indent => 1 + server.wait_for { state == 'terminated' } + log 'done', :color => :green, :indent => 1 + end + end + end + + # + # for each server it finds, yields cloud, server + # + def servers_from_args(global, options, args) + nodes = filter_vm_nodes(args) + if nodes.any? + nodes.each_node do |node| + cloud = new_cloud_handle(node, options) + server = cloud.fetch_server_for_node(true) + yield cloud, server + end + else + instance_id = args.first + cloud = new_cloud_handle(nil, options) + server = cloud.compute.servers.get(instance_id) + if server.nil? + bail! :error, "There is no virtual machine with ID `#{instance_id}`." + end + yield cloud, server + end + end + + # + # returns either: + # + # * the set of nodes specified by the filter, for this environment + # even if the result includes nodes that are not previously tagged with 'vm' + # * the list of all vm nodes for this environment, if filter is empty + # + def filter_vm_nodes(filter) + if filter.nil? || filter.empty? + return manager.filter(['vm'], :warning => false) + elsif filter.is_a? Array + return manager.filter(filter + ['+vm'], :warning => false) + else + raise ArgumentError, 'could not understand filter' + end + end + + def new_cloud_handle(node, options) + require 'leap_cli/cloud' + + config = manager.env.cloud + name = nil + if options[:mock] + Fog.mock! + name = 'mock_aws' + config['mock_aws'] = { + "api" => "aws", + "vendor" => "aws", + "auth" => { + "aws_access_key_id" => "dummy", + "aws_secret_access_key" => "dummy", + "region" => "us-west-2" + }, + "instance_options" => { + "image" => "dummy" + } + } + elsif options[:auth] + name = options[:auth] + assert! config[name], "The value for --auth does not correspond to any value in cloud.json." + elsif node && node['vm.auth'] + name = node.vm.auth + assert! config[name], "The node '#{node.name}' has a value for property 'vm.auth' that does not correspond to any value in cloud.json." + elsif config.keys.length == 1 + name = config.keys.first + log :using, "cloud vendor credentials `#{name}`." + else + bail! "You must specify --mock, --auth, or a node filter." + end + + entry = config[name] # entry in cloud.json + assert! entry, "cloud.json: could not find cloud resource `#{name}`." + assert! entry['vendor'], "cloud.json: property `vendor` is missing from `#{name}` entry." + assert! entry['api'], "cloud.json: property `api` is missing from `#{name}` entry. It must be one of #{config.possible_apis.join(', ')}." + assert! entry['auth'], "cloud.json: property `auth` is missing from `#{name}` entry." + assert! entry['auth']['region'], "cloud.json: property `auth.region` is missing from `#{name}` entry." + assert! entry['api'] == 'aws', "cloud.json: currently, only 'aws' is supported for `api`." + assert! entry['vendor'] == 'aws', "cloud.json: currently, only 'aws' is supported for `vendor`." + + return LeapCli::Cloud.new(name, entry, node) + end + + def state_color(state) + case state + when 'running'; :green + when 'terminated'; :red + when 'stopped'; :magenta + when 'shutting-down'; :yellow + else; :white + end + end + +end; end diff --git a/lib/leap_cli/config/cloud.rb b/lib/leap_cli/config/cloud.rb new file mode 100644 index 00000000..e3e5c1f1 --- /dev/null +++ b/lib/leap_cli/config/cloud.rb @@ -0,0 +1,64 @@ +# encoding: utf-8 +# +# A class for the cloud.json file +# +# Example format: +# +# { +# "my_aws": { +# "api": "aws", +# "vendor": "aws", +# "auth": { +# "region": "us-west-2", +# "aws_access_key_id": "xxxxxxxxxxxxxxx", +# "aws_secret_access_key": "xxxxxxxxxxxxxxxxxxxxxxxxxx" +# }, +# "default_image": "ami-98e114f8", +# "default_options": { +# "InstanceType": "t2.nano" +# } +# } +# } +# + +module LeapCli; module Config + + # http://fog.io/about/supported_services.html + VM_APIS = { + 'aws' => 'fog-aws', + 'google' => 'fog-google', + 'libvirt' => 'fog-libvirt', + 'openstack' => 'fog-openstack', + 'rackspace' => 'fog-rackspace' + } + + class Cloud < Hash + def initialize(env=nil) + end + + # + # returns hash, each key is the name of an API that is + # needed and the value is the name of the gem. + # + # only provider APIs that are required because they are present + # in cloud.json are included. + # + def required_gems + required = {} + self.each do |name, conf| + api = conf["api"] + required_gems[api] = VM_APIS[api] + end + return required + end + + # + # returns an array of all possible providers + # + def possible_apis + VM_APIS.keys + end + + end + +end; end diff --git a/lib/leap_cli/config/environment.rb b/lib/leap_cli/config/environment.rb index 398fd023..dadd9eaf 100644 --- a/lib/leap_cli/config/environment.rb +++ b/lib/leap_cli/config/environment.rb @@ -30,10 +30,12 @@ module LeapCli; module Config # shared, non-inheritable def nodes; @@nodes; end def secrets; @@secrets; end + def cloud; @@cloud; end def initialize(manager, name, search_dir, parent, options={}) @@nodes ||= nil @@secrets ||= nil + @@cloud ||= nil @manager = manager @name = name @@ -64,6 +66,7 @@ module LeapCli; module Config @partials = Config::ObjectList.new @provider = Config::Provider.new @common = Config::Object.new + @cloud = Config::Cloud.new return end @@ -89,7 +92,9 @@ module LeapCli; module Config @partials.values.each {|partial| partial.delete('name'); } # - # shared: currently non-inheritable + # shared + # + # shared configs are also non-inheritable # load the first ones we find, and only those. # if @@nodes.nil? || @@nodes.empty? @@ -98,6 +103,9 @@ module LeapCli; module Config if @@secrets.nil? || @@secrets.empty? @@secrets = load_json(Path.named_path(:secrets_config, search_dir), Config::Secrets, options) end + if @@cloud.nil? || @@cloud.empty? + @@cloud = load_json(Path.named_path(:cloud_config, search_dir), Config::Cloud) + end end # diff --git a/lib/leap_cli/config/filter.rb b/lib/leap_cli/config/filter.rb index 27502577..07424894 100644 --- a/lib/leap_cli/config/filter.rb +++ b/lib/leap_cli/config/filter.rb @@ -29,6 +29,7 @@ module LeapCli # options -- hash, possible keys include # :nopin -- disregard environment pinning # :local -- if false, disallow local nodes + # :warning -- if false, don't print a warning when no nodes are found. # # A nil value in the filters array indicates # the default environment. This is in order to support @@ -139,9 +140,11 @@ module LeapCli return @manager.env('_all_').services[name].node_list elsif @manager.tags[name] return @manager.env('_all_').tags[name].node_list - else + elsif @options[:warning] != false LeapCli.log :warning, "filter '#{name}' does not match any node names, tags, services, or environments." return Config::ObjectList.new + else + return Config::ObjectList.new end else node_list = Config::ObjectList.new @@ -153,7 +156,7 @@ module LeapCli @environments.each do |env| node_list.merge!(@manager.env(env).tags[name].node_list) end - else + elsif @options[:warning] != false LeapCli.log :warning, "filter '#{name}' does not match any node names, tags, services, or environments." end return node_list diff --git a/lib/leap_cli/config/manager.rb b/lib/leap_cli/config/manager.rb index 62eaa894..b42f1ae0 100644 --- a/lib/leap_cli/config/manager.rb +++ b/lib/leap_cli/config/manager.rb @@ -315,19 +315,20 @@ module LeapCli end # inherit from tags + node['tags'] = (node['tags'] || []).to_a if node.vagrant? - node['tags'] = (node['tags'] || []).to_a + ['local'] + node['tags'] << 'local' + elsif node['vm'] + node['tags'] << 'vm' end - if node['tags'] - node['tags'].to_a.each do |node_tag| - tag = node_env.tags[node_tag] - if tag.nil? - msg = 'in node "%s": the tag "%s" does not exist.' % [node['name'], node_tag] - log 0, :error, msg - raise LeapCli::ConfigError.new(node, "error " + msg) if throw_exceptions - else - new_node.deep_merge!(tag) - end + node['tags'].each do |node_tag| + tag = node_env.tags[node_tag] + if tag.nil? + msg = 'in node "%s": the tag "%s" does not exist.' % [node['name'], node_tag] + log 0, :error, msg + raise LeapCli::ConfigError.new(node, "error " + msg) if throw_exceptions + else + new_node.deep_merge!(tag) end end diff --git a/lib/leap_cli/config/node.rb b/lib/leap_cli/config/node.rb index 2d76b814..23abdee3 100644 --- a/lib/leap_cli/config/node.rb +++ b/lib/leap_cli/config/node.rb @@ -9,11 +9,8 @@ module LeapCli; module Config class Node < Object attr_accessor :file_paths - def initialize(environment=nil) #, name=nil) + def initialize(environment=nil) super(environment) - #if name - # self['name'] = name - #end @node = self @file_paths = [] end @@ -38,6 +35,14 @@ module LeapCli; module Config return vagrant_range.include?(ip_addr) end + def vm? + self['vm'] + end + + def vm_id? + self['vm.id'] && !self['vm.id'].empty? + end + # # Return a hash table representation of ourselves, with the key equal to the @node.name, # and the value equal to the fields specified in *keys. diff --git a/lib/leap_cli/load_libraries.rb b/lib/leap_cli/load_libraries.rb index cec3812d..01384f78 100644 --- a/lib/leap_cli/load_libraries.rb +++ b/lib/leap_cli/load_libraries.rb @@ -8,14 +8,16 @@ require 'leap_cli/log_filter' require 'leap_cli/config/object' require 'leap_cli/config/node' +require 'leap_cli/config/node_cert' require 'leap_cli/config/tag' require 'leap_cli/config/provider' require 'leap_cli/config/secrets' +require 'leap_cli/config/cloud' require 'leap_cli/config/object_list' require 'leap_cli/config/filter' require 'leap_cli/config/environment' require 'leap_cli/config/manager' require 'leap_cli/util/secret' -require 'leap_cli/util/x509' require 'leap_cli/util/vagrant' +require 'leap_cli/util/console_table' diff --git a/lib/leap_cli/log_filter.rb b/lib/leap_cli/log_filter.rb index 0d745cc2..28504e1a 100644 --- a/lib/leap_cli/log_filter.rb +++ b/lib/leap_cli/log_filter.rb @@ -99,11 +99,15 @@ module LeapCli # TITLE_FORMATTERS = [ # red - { :match => /error/, :color => :red, :style => :bold }, { :match => /fatal_error/, :replace => 'fatal error:', :color => :red, :style => :bold }, + { :match => /error/, :color => :red, :style => :bold }, { :match => /removed/, :color => :red, :style => :bold }, + { :match => /removing/, :color => :red, :style => :bold }, + { :match => /destroyed/, :color => :red, :style => :bold }, + { :match => /destroying/, :color => :red, :style => :bold }, + { :match => /terminated/, :color => :red, :style => :bold }, { :match => /failed/, :replace => 'FAILED', :color => :red, :style => :bold }, - { :match => /bail/, :replace => 'bailing out', :color => :red, :style => :bold }, + { :match => /bailing/, :replace => 'bailing', :color => :red, :style => :bold }, { :match => /invalid/, :color => :red, :style => :bold }, # yellow @@ -115,6 +119,7 @@ module LeapCli { :match => /created/, :color => :green, :style => :bold }, { :match => /completed/, :color => :green, :style => :bold }, { :match => /ran/, :color => :green, :style => :bold }, + { :match => /registered/, :color => :green, :style => :bold }, # cyan { :match => /note/, :replace => 'NOTE:', :color => :cyan, :style => :bold }, diff --git a/lib/leap_cli/ssh/backend.rb b/lib/leap_cli/ssh/backend.rb index 80203b61..42e58c15 100644 --- a/lib/leap_cli/ssh/backend.rb +++ b/lib/leap_cli/ssh/backend.rb @@ -46,6 +46,12 @@ module LeapCli Thread.current["sshkit_backend"] = nil end + # if set, all the commands will begin with: + # sudo -u #{@user} -- sh -c '' + def set_user(user='root') + @user = user + end + # # like default capture, but gracefully logs failures for us # last argument can be an options hash. diff --git a/lib/leap_cli/ssh/key.rb b/lib/leap_cli/ssh/key.rb index ad1ecf15..76223b7e 100644 --- a/lib/leap_cli/ssh/key.rb +++ b/lib/leap_cli/ssh/key.rb @@ -2,12 +2,34 @@ # A wrapper around OpenSSL::PKey::RSA instances to provide a better api for # dealing with SSH keys. # -# NOTE: cipher 'ssh-ed25519' not supported yet because we are waiting +# NOTES: +# +# cipher 'ssh-ed25519' not supported yet because we are waiting # for support in Net::SSH # +# there are many ways to represent an SSH key, since SSH keys can be of +# a variety of types. +# +# To confuse matters more, there are multiple binary representations. +# So, for example, an RSA key has a native SSH representation +# (two bignums, e followed by n), and a DER representation. +# +# AWS uses fingerprints of the DER representation, but SSH typically reports +# fingerprints of the SSH representation. +# +# Also, SSH public key files are base64 encoded, but with whitespace removed +# so it all goes on one line. +# +# Some useful links: +# +# https://stackoverflow.com/questions/3162155/convert-rsa-public-key-to-rsa-der +# https://net-ssh.github.io/ssh/v2/api/classes/Net/SSH/Buffer.html +# https://serverfault.com/questions/603982/why-does-my-openssh-key-fingerprint-not-match-the-aws-ec2-console-keypair-finger +# require 'net/ssh' require 'forwardable' +require 'base64' module LeapCli module SSH @@ -72,6 +94,14 @@ module LeapCli public_key || private_key end + def self.my_public_keys + load_keys_from_paths File.join(ENV['HOME'], '.ssh', '*.pub') + end + + def self.provider_public_keys + load_keys_from_paths Path.named_path([:user_ssh, '*']) + end + # # Picks one key out of an array of keys that we think is the "best", # based on the order of preference in SUPPORTED_TYPES @@ -127,19 +157,29 @@ module LeapCli end end + private + + def self.load_keys_from_paths(key_glob) + keys = [] + Dir.glob(key_glob).each do |file| + key = Key.load(file) + if key && key.public? + keys << key + end + end + return keys + end + ## ## INSTANCE METHODS ## public - def initialize(rsa_key) - @key = rsa_key + def initialize(p_key) + @key = p_key end - def_delegator :@key, :fingerprint, :fingerprint - def_delegator :@key, :public?, :public? - def_delegator :@key, :private?, :private? def_delegator :@key, :ssh_type, :type def_delegator :@key, :public_encrypt, :public_encrypt def_delegator :@key, :public_decrypt, :public_decrypt @@ -156,6 +196,70 @@ module LeapCli Key.new(@key.private_key) end + def private? + @key.respond_to?(:private?) ? @key.private? : @key.private_key? + end + + def public? + @key.respond_to?(:public?) ? @key.public? : @key.public_key? + end + + # + # three arguments: + # + # - digest: one of md5, sha1, sha256, etc. (default sha256) + # - encoding: either :hex (default) or :base64 + # - type: fingerprint type, either :ssh (default) or :der + # + # NOTE: + # + # * I am not sure how to make a fingerprint for OpenSSL::PKey::EC::Point + # + # * AWS reports fingerprints using MD5 digest for uploaded ssh keys, + # but SHA1 for keys it created itself. + # + # * Also, AWS fingerprints are digests on the DER encoding of the key. + # But standard SSH fingerprints are digests of SSH encoding of the key. + # + # * Other tools will sometimes display fingerprints in hex and sometimes + # in base64. Arrrgh. + # + def fingerprint(type: :ssh, digest: :sha256, encoding: :hex) + require 'digest' + + digest = digest.to_s.upcase + digester = case digest + when "MD5" then Digest::MD5.new + when "SHA1" then Digest::SHA1.new + when "SHA256" then Digest::SHA256.new + when "SHA384" then Digest::SHA384.new + when "SHA512" then Digest::SHA512.new + else raise ArgumentError, "digest #{digest} is unknown" + end + + keymatter = nil + if type == :der && @key.respond_to?(:to_der) + keymatter = @key.to_der + else + keymatter = self.raw_key.to_s + end + + fp = nil + if encoding == :hex + fp = digester.hexdigest(keymatter) + elsif encoding == :base64 + fp = Base64.encode64(digester.digest(keymatter)).sub(/=$/, '') + else + raise ArgumentError, "encoding #{encoding} not understood" + end + + if digest == "MD5" && encoding == :hex + return fp.scan(/../).join(':') + else + return fp + end + end + # # not sure if this will always work, but is seems to for now. # @@ -175,10 +279,17 @@ module LeapCli self.type + " " + self.key end + # + # base64 encoding of the key, with spaces removed. + # def key [Net::SSH::Buffer.from(:key, @key).to_s].pack("m*").gsub(/\s/, "") end + def raw_key + Net::SSH::Buffer.from(:key, @key) + end + def ==(other_key) return false if other_key.nil? return false if self.class != other_key.class diff --git a/lib/leap_cli/ssh/options.rb b/lib/leap_cli/ssh/options.rb index b8266d11..7bc06564 100644 --- a/lib/leap_cli/ssh/options.rb +++ b/lib/leap_cli/ssh/options.rb @@ -46,6 +46,9 @@ module LeapCli if args[:auth_methods] ssh_options[:auth_methods] = args[:auth_methods] end + if args[:user] + ssh_options[:user] = args[:user] + end return ssh_options end diff --git a/lib/leap_cli/ssh/remote_command.rb b/lib/leap_cli/ssh/remote_command.rb index 7195405e..0e9f2d55 100644 --- a/lib/leap_cli/ssh/remote_command.rb +++ b/lib/leap_cli/ssh/remote_command.rb @@ -38,6 +38,7 @@ module LeapCli # :port -- ssh port # :ip -- ssh ip # :auth_methods -- e.g. ["pubkey", "password"] + # :user -- default 'root' # def self.remote_command(nodes, options={}, &block) CustomCoordinator.new( @@ -47,6 +48,11 @@ module LeapCli ) ).each do |ssh, host| LeapCli.log 2, "ssh options for #{host.hostname}: #{host.ssh_options.inspect}" + if host.user != 'root' + # if the ssh user is not root, we want to make the ssh commands + # switch to root before they are run: + ssh.set_user('root') + end yield ssh, host end end diff --git a/lib/leap_cli/ssh/scripts.rb b/lib/leap_cli/ssh/scripts.rb index a15a9edd..9fef6240 100644 --- a/lib/leap_cli/ssh/scripts.rb +++ b/lib/leap_cli/ssh/scripts.rb @@ -119,6 +119,21 @@ module LeapCli end end + # + # AWS debian images only allow you to login as admin. This is done with a + # custom command in /root/.ssh/authorized_keys, instead of by modifying + # /etc/ssh/sshd_config. + # + # We need to be able to ssh as root for scp and rsync to work. + # + # This command is run as 'admin', with a sudo wrapper. In order for the + # sudo to work, the command must be specified as separate arguments with + # no spaces (that is how ssh-kit works). + # + def allow_root_ssh + ssh.execute 'cp', '/home/admin/.ssh/authorized_keys', '/root/.ssh/authorized_keys' + end + private def flagize(hsh) diff --git a/lib/leap_cli/util/console_table.rb b/lib/leap_cli/util/console_table.rb index 53c5e18a..ccdcc2ab 100644 --- a/lib/leap_cli/util/console_table.rb +++ b/lib/leap_cli/util/console_table.rb @@ -3,9 +3,12 @@ module LeapCli; module Util class ConsoleTable def table @rows = [] + @cell_options = [] + @row_options = [] @column_widths = [] @column_options = [] + @current_row = 0 @current_column = 0 yield @@ -13,6 +16,8 @@ module LeapCli; module Util def row(options=nil) @current_column = 0 + @rows[@current_row] = [] + @cell_options[@current_row] = [] @row_options[@current_row] ||= options yield @current_row += 1 @@ -20,8 +25,8 @@ module LeapCli; module Util def column(str, options={}) str ||= "" - @rows[@current_row] ||= [] @rows[@current_row][@current_column] = str + @cell_options[@current_row][@current_column] = options @column_widths[@current_column] = [str.length, options[:min_width]||0, @column_widths[@current_column]||0].max @column_options[@current_column] ||= options @current_column += 1 @@ -33,8 +38,10 @@ module LeapCli; module Util row.each_with_index do |column, j| align = (@column_options[j]||{})[:align] || "left" width = @column_widths[j] - if color - str = LeapCli.logger.colorize(column, color) + cell_color = @cell_options[i][j] && @cell_options[i][j][:color] + cell_color ||= color + if cell_color + str = LeapCli.logger.colorize(column, cell_color) extra_width = str.length - column.length else str = column -- cgit v1.2.3