Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 0 additions & 77 deletions lib/smart_proxy_remote_execution_ssh.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,83 +6,6 @@
module Proxy::RemoteExecution
module Ssh
class << self
def validate!
unless private_key_file
raise "settings for `ssh_identity_key` not set"
end

unless File.exist?(private_key_file)
raise "Ssh public key file #{private_key_file} doesn't exist.\n"\
"You can generate one with `ssh-keygen -t rsa -b 4096 -f #{private_key_file} -N ''`"
end

unless File.exist?(public_key_file)
raise "Ssh public key file #{public_key_file} doesn't exist"
end

validate_mode!
validate_ssh_log_level!
validate_mqtt_settings!
end

def private_key_file
File.expand_path(Plugin.settings.ssh_identity_key_file)
end

def public_key_file
File.expand_path("#{private_key_file}.pub")
end

def validate_mode!
Plugin.settings.mode = Plugin.settings.mode.to_sym

unless Plugin::MODES.include? Plugin.settings.mode
raise "Mode has to be one of #{Plugin::MODES.join(', ')}, given #{Plugin.settings.mode}"
end

if Plugin.settings.async_ssh
Plugin.logger.warn('Option async_ssh is deprecated, use ssh-async mode instead.')

case Plugin.settings.mode
when :ssh
Plugin.logger.warn('Deprecated option async_ssh used together with ssh mode, switching mode to ssh-async.')
Plugin.settings.mode = :'ssh-async'
when :'async-ssh'
# This is a noop
else
Plugin.logger.warn('Deprecated option async_ssh used together with incompatible mode, ignoring.')
end
end
end

def validate_mqtt_settings!
return unless Plugin.settings.mode == :'pull-mqtt'

raise 'mqtt_broker has to be set when pull-mqtt mode is used' if Plugin.settings.mqtt_broker.nil?
raise 'mqtt_port has to be set when pull-mqtt mode is used' if Plugin.settings.mqtt_port.nil?
end

def validate_ssh_log_level!
wanted_level = Plugin.settings.ssh_log_level.to_s
levels = Plugin::SSH_LOG_LEVELS
unless levels.include? wanted_level
raise "Wrong value '#{Plugin.settings.ssh_log_level}' for ssh_log_level, must be one of #{levels.join(', ')}"
end

current = ::Proxy::SETTINGS.log_level.to_s.downcase

# regular log levels correspond to upcased ssh logger levels
ssh, regular = [wanted_level, current].map do |wanted|
levels.each_with_index.find { |value, _index| value == wanted }.last
end

if ssh < regular
raise 'ssh_log_level cannot be more verbose than regular log level'
end

Plugin.settings.ssh_log_level = Plugin.settings.ssh_log_level.to_sym
end

def job_storage
@job_storage ||= Proxy::RemoteExecution::Ssh::JobStorage.new
end
Expand Down
4 changes: 3 additions & 1 deletion lib/smart_proxy_remote_execution_ssh/api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ class Api < ::Sinatra::Base
include Proxy::Dynflow::Helpers

get "/pubkey" do
File.read(Ssh.public_key_file)
File.read(Proxy::RemoteExecution::Plugin.settings.ssh_identity_public_key_file)
rescue Errno::ENOENT
halt 404
end

post "/session" do
Expand Down
2 changes: 1 addition & 1 deletion lib/smart_proxy_remote_execution_ssh/cockpit.rb
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ def params
end

def key_file
@key_file ||= Proxy::RemoteExecution::Ssh.private_key_file
@key_file ||= Proxy::RemoteExecution::Plugin.settings.ssh_identity_key_file
end

def buf_socket
Expand Down
54 changes: 49 additions & 5 deletions lib/smart_proxy_remote_execution_ssh/plugin.rb
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
require_relative 'validators'

module Proxy::RemoteExecution::Ssh
class Plugin < Proxy::Plugin
SSH_LOG_LEVELS = %w[debug info error fatal].freeze
MODES = %i[ssh async-ssh pull pull-mqtt].freeze

http_rackup_path File.expand_path("http_config.ru", File.expand_path("../", __FILE__))
https_rackup_path File.expand_path("http_config.ru", File.expand_path("../", __FILE__))
rackup_path File.expand_path("http_config.ru", __dir__)

settings_file "remote_execution_ssh.yml"
default_settings :ssh_identity_key_file => '~/.ssh/id_rsa_foreman_proxy',
Expand All @@ -20,9 +21,51 @@ class Plugin < Proxy::Plugin
# :mqtt_port => nil,
:mode => :ssh

load_validators ssh_log_level: Proxy::RemoteExecution::Ssh::Validators::SshLogLevel,
rex_ssh_mode: Proxy::RemoteExecution::Ssh::Validators::RexSshMode

load_programmable_settings do |settings|
if settings[:ssh_identity_key_file]
settings[:ssh_identity_key_file] = File.expand_path(settings[:ssh_identity_key_file])
end

if settings[:ssh_identity_public_key_file]
settings[:ssh_identity_public_key_file] = File.expand_path(settings[:ssh_identity_public_key_file])
elsif settings[:ssh_identity_key_file]
settings[:ssh_identity_public_key_file] = "#{settings[:ssh_identity_key_file]}.pub"
end

if settings[:async_ssh]
Plugin.logger.warn('Option async_ssh is deprecated, use ssh-async mode instead.')

case setting_value
when :ssh
Plugin.logger.warn('Deprecated option async_ssh used together with ssh mode, switching mode to ssh-async.')
settings[:mode] = :'ssh-async'
when :'async-ssh'
# This is a noop
else
Plugin.logger.warn('Deprecated option async_ssh used together with incompatible mode, ignoring.')
end
end

settings[:mode] = settings[:mode].to_sym
settings[:ssh_log_level] = settings[:ssh_log_level].to_sym
end

validate_readable :ssh_identity_key_file, :ssh_identity_public_key_file
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Am I reading it right that this doesn't expand the path?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, it doesn't. Perhaps it needs to be in load_programmable_settings instead of lib/smart_proxy_remote_execution_ssh.rb?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, does this plugin still use the SSH keys if MQTT is used? I think so due to cockpit, but I'm starting to wonder if this shouldn't be named smart_proxy_remote_execution instead of ..._ssh.

Does it make sense to expose the mode as a setting so you can externally read that out? Or as a capability?

validate :ssh_log_level, ssh_log_level: SSH_LOG_LEVELS
validate :mode, rex_ssh_mode: MODES
validate_presence :mqtt_broker, :mqtt_port, if: ->(settings) { settings[:mode] == :'pull-mqtt' }

plugin :ssh, Proxy::RemoteExecution::Ssh::VERSION
after_activation do

requires :dynflow, '~> 0.5'

load_classes do
require 'smart_proxy_dynflow'
require 'smart_proxy_dynflow/task_launcher'
require 'smart_proxy_dynflow/runner'
require 'smart_proxy_remote_execution_ssh/version'
require 'smart_proxy_remote_execution_ssh/cockpit'
require 'smart_proxy_remote_execution_ssh/api'
Expand All @@ -32,9 +75,10 @@ class Plugin < Proxy::Plugin
require 'smart_proxy_remote_execution_ssh/runners'
require 'smart_proxy_remote_execution_ssh/utils'
require 'smart_proxy_remote_execution_ssh/job_storage'
end

Proxy::RemoteExecution::Ssh.validate!

# Not really Smart Proxy dependency injection, but similar enough
load_dependency_injection_wirings do |container_instance, settings|
Proxy::Dynflow::TaskLauncherRegistry.register('ssh', Proxy::Dynflow::TaskLauncher::Batch)
end

Expand Down
42 changes: 42 additions & 0 deletions lib/smart_proxy_remote_execution_ssh/validators.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
module Proxy
module RemoteExecution
module Ssh
module Validators
class SshLogLevel < ::Proxy::PluginValidators::Base
def validate!(settings)
setting_value = settings[@setting_name].to_s

unless @params.include?(setting_value)
raise ::Proxy::Error::ConfigurationError, "Parameter '#{@setting_name}' must be one of #{@params.join(', ')}"
end

current = ::Proxy::SETTINGS.log_level.to_s.downcase

# regular log levels correspond to upcased ssh logger levels
ssh, regular = [setting_value, current].map do |wanted|
@params.each_with_index.find { |value, _index| value == wanted }.last
end

if ssh < regular
raise ::Proxy::Error::ConfigurationError, "Parameter '#{@setting_name}' cannot be more verbose than regular log level (#{current})"
end

true
end
end

class RexSshMode < ::Proxy::PluginValidators::Base
def validate!(settings)
setting_value = settings[@setting_name]

unless @params.include?(setting_value)
raise ::Proxy::Error::ConfigurationError, "Parameter '#{@setting_name}' must be one of #{@params.join(', ')}"
end

true
end
end
end
end
end
end
6 changes: 6 additions & 0 deletions test/api_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,17 @@ module Proxy::RemoteExecution::Ssh
class ApiTest < MiniTest::Spec
include Rack::Test::Methods

def setup
super
Proxy::RemoteExecution::Ssh::Plugin.load_test_settings(ssh_identity_key_file: FAKE_PRIVATE_KEY_FILE, ssh_identity_public_key_file: FAKE_PUBLIC_KEY_FILE)
end

let(:app) { Proxy::RemoteExecution::Ssh::Api.new }

describe '/pubkey' do
it 'returns the content of the public key' do
get '/pubkey'
_(last_response.status).must_equal 200, last_response.body
_(last_response.body).must_equal '===public-key==='
end
end
Expand Down
104 changes: 104 additions & 0 deletions test/integration_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
require 'test_helper'
require 'json'
require 'root/root'
require 'root/root_v2_api'
require 'smart_proxy_remote_execution_ssh/plugin'

class SmartProxyRemoteExecutionSshApiFeaturesTest < MiniTest::Test
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is currently no test for the legacy async-ssh mode. Would probably be useful.

include Rack::Test::Methods

def app
Proxy::PluginInitializer.new(Proxy::Plugins.instance).initialize_plugins
Proxy::RootV2Api.new
end

def test_features_for_default_mode_without_dynflow
Proxy::LegacyModuleLoader.any_instance.expects(:load_configuration_file).with('dynflow.yml').returns(enabled: false)
Proxy::DefaultModuleLoader.any_instance.expects(:load_configuration_file).with('remote_execution_ssh.yml').returns(
enabled: true,
ssh_identity_key_file: FAKE_PRIVATE_KEY_FILE,
mqtt_broker: 'broker.example.com',
mqtt_port: 1883,
)

get '/features'

response = JSON.parse(last_response.body)

mod = response['ssh']
refute_nil(mod)
assert_equal('failed', mod['state'], Proxy::LogBuffer::Buffer.instance.info[:failed_modules][:ssh])
assert_equal("Disabling all modules in the group ['ssh'] due to a failure in one of them: 'dynflow' required by 'ssh' could not be found.",
Proxy::LogBuffer::Buffer.instance.info[:failed_modules][:ssh])
end

def test_features_for_default_mode_with_dynflow
Proxy::LegacyModuleLoader.any_instance.expects(:load_configuration_file).with('dynflow.yml').returns(enabled: true)
Proxy::DefaultModuleLoader.any_instance.expects(:load_configuration_file).with('remote_execution_ssh.yml').returns(
enabled: true,
ssh_identity_key_file: FAKE_PRIVATE_KEY_FILE,
)

get '/features'

response = JSON.parse(last_response.body)

mod = response['dynflow']
refute_nil(mod)
assert_equal('running', mod['state'], Proxy::LogBuffer::Buffer.instance.info[:failed_modules][:dynflow])

mod = response['ssh']
refute_nil(mod)
assert_equal('running', mod['state'], Proxy::LogBuffer::Buffer.instance.info[:failed_modules][:ssh])
assert_equal([], mod['capabilities'], 'Has no capabilities')
assert_equal({}, mod['settings'], 'Has no settings')
end

def test_features_for_pull_mqtt_mode_without_required_options
Proxy::LegacyModuleLoader.any_instance.expects(:load_configuration_file).with('dynflow.yml').returns(enabled: true)
Proxy::DefaultModuleLoader.any_instance.expects(:load_configuration_file).with('remote_execution_ssh.yml').returns(
enabled: true,
ssh_identity_key_file: FAKE_PRIVATE_KEY_FILE,
mode: 'pull-mqtt',
)

get '/features'

response = JSON.parse(last_response.body)

mod = response['dynflow']
refute_nil(mod)
assert_equal('running', mod['state'], Proxy::LogBuffer::Buffer.instance.info[:failed_modules][:dynflow])

mod = response['ssh']
refute_nil(mod)
assert_equal('failed', mod['state'], Proxy::LogBuffer::Buffer.instance.info[:failed_modules][:ssh])
assert_equal("Disabling all modules in the group ['ssh'] due to a failure in one of them: Parameter 'mqtt_broker' is expected to have a non-empty value",
Proxy::LogBuffer::Buffer.instance.info[:failed_modules][:ssh])
end

def test_features_with_dynflow_and_required_options
Proxy::LegacyModuleLoader.any_instance.expects(:load_configuration_file).with('dynflow.yml').returns(enabled: true)
Proxy::DefaultModuleLoader.any_instance.expects(:load_configuration_file).with('remote_execution_ssh.yml').returns(
enabled: true,
ssh_identity_key_file: FAKE_PRIVATE_KEY_FILE,
mode: 'pull-mqtt',
mqtt_broker: 'broker.example.com',
mqtt_port: 1883,
)

get '/features'

response = JSON.parse(last_response.body)

mod = response['dynflow']
refute_nil(mod)
assert_equal('running', mod['state'], Proxy::LogBuffer::Buffer.instance.info[:failed_modules][:dynflow])

mod = response['ssh']
refute_nil(mod)
assert_equal('running', mod['state'], Proxy::LogBuffer::Buffer.instance.info[:failed_modules][:ssh])
assert_equal([], mod['capabilities'], 'Has no capabilities')
assert_equal({}, mod['settings'], 'Has no settings')
end
end
3 changes: 0 additions & 3 deletions test/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,6 @@
FileUtils.mkdir_p(logdir) unless File.exist?(logdir)

def prepare_fake_keys
Proxy::RemoteExecution::Ssh::Plugin.settings.ssh_identity_key_file = FAKE_PRIVATE_KEY_FILE
# Workaround for Proxy::RemoteExecution::Ssh::Plugin.settings.ssh_identity_key_file returning nil
Proxy::RemoteExecution::Ssh::Plugin.settings.stubs(:ssh_identity_key_file).returns(FAKE_PRIVATE_KEY_FILE)
FileUtils.mkdir_p(DATA_DIR) unless File.exist?(DATA_DIR)
File.write(FAKE_PRIVATE_KEY_FILE, '===private-key===')
File.write(FAKE_PUBLIC_KEY_FILE, '===public-key===')
Expand Down