smart_proxy_salt 3.1.0 → 5.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f3ac4ad731748d62d1ec922520aa60ce1a7db4b001b2aeaabfe9c2e0709e3f9e
4
- data.tar.gz: b345c0ae059918e924a6ec771664a38e687d3b8a288559cd48274e271f69c551
3
+ metadata.gz: b25650758e39a88cb8683f8ebe295d343c0752d0fff229fbe21f86ee585848c6
4
+ data.tar.gz: 9f26eb7258a77a426c1addf5e8ba949c432332f1b4b35897e193762cf1a60d3f
5
5
  SHA512:
6
- metadata.gz: 20b39c0f2ef2a6ea13cf94d996141ee1e28ff542fd8def32275560085b8f6c31e57d0d834e6179744c98a4eccfcb3efa4dc8ce74e178b0751103933253d1c465
7
- data.tar.gz: d1dd34e0c168829fe049c8db0bb751782b3548a39566c530d148a00485fcf0c96c2fd52b96a7bb71b7a0ac3b9e5ea74629ab39e7b4ef166b1f0ba1163aa6fbd7
6
+ metadata.gz: bd79c67ac4733c9b1f19a7e5f6a62a6019e39209d37abd21d7a67f48aea4e24da875bbdaecd3c8db574e566cb451038904fd9e21d045db61896df0a96ccab51e
7
+ data.tar.gz: 9c8640cc30f494fe2406b0a840643aacc2211c738fc25864c8c7e1024e4d2a6b6ad72e459eac947a2af9ac2b86ee14a87ba0cc84e27018742906d8a8ad50e18b
data/bin/foreman-node CHANGED
@@ -14,6 +14,7 @@ require 'net/http'
14
14
  require 'net/https'
15
15
  require 'etc'
16
16
  require 'timeout'
17
+ require 'msgpack' if SETTINGS[:filecache]
17
18
 
18
19
  begin
19
20
  require 'json'
@@ -57,19 +58,37 @@ rescue Exception => e
57
58
  exit 1
58
59
  end
59
60
 
61
+ def get_grains_from_filecache(minion)
62
+ # Use the grains from the salt master's filesystem based cache
63
+ # This requires the following settings in /etc/salt/foreman.yaml:
64
+ # :filecache: true
65
+ # :cachedir: "/path/to/master/cache" (default: "/var/cache/salt/master")
66
+ # Also, the msgpack rubygem needs to be present
67
+ cachedir = SETTINGS[:cachedir] || '/var/cache/salt/master'
68
+ content = File.read("#{cachedir}/minions/#{minion}/data.p")
69
+ data = MessagePack.unpack(content)
70
+ data['grains']
71
+ end
72
+
73
+ def get_grains_from_saltrun(minion)
74
+ result = IO.popen(['salt-run', '-l', 'quiet', '--output=json', 'cache.grains', minion], &:read)
75
+ data = JSON.parse(result)
76
+ data[minion]
77
+ end
78
+
60
79
  def plain_grains(minion)
61
80
  # We have to get the grains from the cache, because the client
62
81
  # is probably running 'state.highstate' right now.
63
82
 
64
- result = IO.popen(['salt-run', '--output=json', 'cache.grains', minion]) do |io|
65
- io.read
66
- end
67
-
68
- grains = JSON.parse(result)
83
+ grains = if SETTINGS[:filecache]
84
+ get_grains_from_filecache(minion)
85
+ else
86
+ get_grains_from_saltrun(minion)
87
+ end
69
88
 
70
89
  raise 'No grains received from Salt master' unless grains
71
90
 
72
- plainify(grains[minion]).flatten.inject(&:merge)
91
+ plainify(grains).flatten.inject(&:merge)
73
92
  end
74
93
 
75
94
  def plainify(hash, prefix = nil)
@@ -0,0 +1,16 @@
1
+ #!/bin/sh
2
+
3
+ set -u
4
+
5
+ for py in 'python3' 'python'; do
6
+ exe=$(type -p ${py})
7
+ if [ -n "${exe}" ]; then
8
+ if ${exe} -c 'import salt.config'; then
9
+ ${exe} "$@"
10
+ exit $?
11
+ fi
12
+ fi
13
+ done
14
+
15
+ echo "No usable python version found, check if python or python3 can import salt.config!" 1>&2
16
+ exit 1
@@ -0,0 +1,65 @@
1
+ # /etc/salt/master.d/foreman.config Example configuration
2
+ #
3
+ # This file summarizes configurations for the salt-master. Modify directories and
4
+ # parameters to fit your setup. When you're done, remove the .example from the
5
+ # filename so the salt-master will make use of it.
6
+ # Have a look at the [Foreman Salt Plugin Documentation](https://theforeman.org/plugins/foreman_salt/) for detailed explanations.
7
+ #
8
+ # After editing this file, run the following command to active the changes:
9
+ # $ systemctl restart salt-master
10
+
11
+
12
+ ##
13
+ # Autosign
14
+ autosign_grains_dir: /var/lib/foreman-proxy/salt/grains
15
+ autosign_file: /etc/salt/autosign.conf
16
+ # Uncomment the next line to make use of the autosign host name file (not recommended)
17
+ # permissive_pki_access: True
18
+
19
+
20
+ ##
21
+ # Node classifier
22
+ master_tops:
23
+ ext_nodes: /usr/bin/foreman-node
24
+
25
+
26
+ ##
27
+ # Pillar data access
28
+ ext_pillar:
29
+ - puppet: /usr/bin/foreman-node
30
+
31
+
32
+ ##
33
+ # Salt API access
34
+ external_auth:
35
+ pam:
36
+ saltuser: # Username of your salt user
37
+ - '@runner'
38
+
39
+ rest_cherrypy:
40
+ port: 9191
41
+ ssl_key: /etc/puppet/example.key # Add the path to your Puppet ssl key here
42
+ ssl_crt: /etc/puppet/example.crt # Add the path to your Puppet ssl certificate here
43
+
44
+
45
+ ##
46
+ # Remote execution provider
47
+ publisher_acl:
48
+ foreman-proxy:
49
+ - state.template_str
50
+
51
+
52
+ ##
53
+ # Salt environment (optional)
54
+ file_roots:
55
+ base:
56
+ - /srv/salt
57
+
58
+
59
+ ##
60
+ # Reactors
61
+ reactor:
62
+ - 'salt/auth': # Autosign reactor
63
+ - /var/lib/foreman-proxy/salt/reactors/foreman_minion_auth.sls
64
+ - 'salt/job/*/ret/*': # Report reactor
65
+ - /var/lib/foreman-proxy/salt/reactors/foreman_report_upload.sls
@@ -14,44 +14,43 @@ module Proxy
14
14
  Proxy::Salt::Plugin.settings.autosign_file
15
15
  end
16
16
 
17
- def autosign_create(host)
18
- FileUtils.touch(autosign_file) unless File.exist?(autosign_file)
19
-
20
- autosign = open(autosign_file, File::RDWR)
17
+ def autosign_key_file
18
+ Proxy::Salt::Plugin.settings.autosign_key_file
19
+ end
21
20
 
22
- found = false
23
- autosign.each_line { |line| found = true if line.chomp == host }
24
- autosign.puts host unless found
25
- autosign.close
21
+ def autosign_create_hostname(hostname)
22
+ if append_value_to_file(autosign_file, hostname)
23
+ { message: 'Added hostname successfully.' }
24
+ else
25
+ { message: 'Failed to add hostname.' \
26
+ ' See smart proxy error log for more information.' }
27
+ end
28
+ end
26
29
 
27
- result = { :message => "Added #{host} to autosign" }
28
- logger.info result[:message]
29
- result
30
+ def autosign_remove_hostname(hostname)
31
+ if remove_value_from_file(autosign_file, hostname)
32
+ { message: 'Removed hostname successfully.' }
33
+ else
34
+ { message: 'Failed to remove hostname.' \
35
+ ' See smart proxy error log for more information.' }
36
+ end
30
37
  end
31
38
 
32
- def autosign_remove(host)
33
- raise "No such file #{autosign_file}" unless File.exist?(autosign_file)
39
+ def autosign_create_key(key)
40
+ if append_value_to_file(autosign_key_file, key)
41
+ { message: 'Added key successfully.' }
42
+ else
43
+ { message: 'Failed to add key.' \
44
+ ' See smart proxy error log for more information.' }
45
+ end
46
+ end
34
47
 
35
- found = false
36
- entries = open(autosign_file, File::RDONLY).readlines.collect do |l|
37
- if l.chomp != host
38
- l
39
- else
40
- found = true
41
- nil
42
- end
43
- end.uniq.compact
44
- if found
45
- autosign = open(autosign_file, File::TRUNC | File::RDWR)
46
- autosign.write entries.join("\n")
47
- autosign.write "\n"
48
- autosign.close
49
- result = { :message => "Removed #{host} from autosign" }
50
- logger.info result[:message]
51
- result
48
+ def autosign_remove_key(key)
49
+ if remove_value_from_file(autosign_key_file, key)
50
+ { message: 'Removed key successfully.' }
52
51
  else
53
- logger.info "Attempt to remove nonexistant client autosign for #{host}"
54
- raise Proxy::Salt::NotFound.new("Attempt to remove nonexistant client autosign for #{host}")
52
+ { message: 'Failed to remove key.' \
53
+ ' See smart proxy error log for more information.' }
55
54
  end
56
55
  end
57
56
 
@@ -62,6 +61,45 @@ module Proxy
62
61
  end.map(&:chomp)
63
62
  end
64
63
 
64
+ def append_value_to_file(filepath, value)
65
+ File.open(filepath, File::CREAT|File::RDWR) do |file|
66
+ unless file.any? { |line| line.chomp == value}
67
+ file.puts value
68
+ end
69
+ end
70
+ logger.info "Added an entry to '#{filepath}' successfully."
71
+ true
72
+ rescue IOError => e
73
+ logger.info "Attempted to add an entry to '#{filepath}', but an exception occurred: #{e}"
74
+ false
75
+ end
76
+
77
+ def remove_value_from_file(filepath, value)
78
+
79
+ return true unless File.exist?(filepath)
80
+
81
+ found = false
82
+ entries = File.readlines(filepath).collect do |line|
83
+ entry = line.chomp
84
+ if entry == value
85
+ found = true
86
+ nil
87
+ elsif entry == ""
88
+ nil
89
+ else
90
+ line
91
+ end
92
+ end.uniq.compact
93
+ if found
94
+ File.write(filepath, entries.join())
95
+ logger.info "Removed an entry from '#{filepath}' successfully."
96
+ end
97
+ true
98
+ rescue IOError => e
99
+ logger.info "Attempted to remove an entry from '#{filepath}', but an exception occurred: #{e}"
100
+ false
101
+ end
102
+
65
103
  def highstate(host)
66
104
  find_salt_binaries
67
105
  cmd = [@sudo, '-u', Proxy::Salt::Plugin.settings.salt_command_user, @salt, '--async', escape_for_shell(host), 'state.highstate']
@@ -13,11 +13,24 @@ module Proxy
13
13
  plugin 'salt', Proxy::Salt::VERSION
14
14
 
15
15
  default_settings :autosign_file => '/etc/salt/autosign.conf',
16
+ :autosign_key_file => '/var/lib/foreman-proxy/salt/grains/autosign_key',
16
17
  :salt_command_user => 'root',
17
- :use_api => false
18
+ :use_api => false,
19
+ :saltfile => '/etc/foreman-proxy/settings.d/salt.saltfile'
18
20
 
19
- http_rackup_path File.expand_path('salt_http_config.ru', File.expand_path('../', __FILE__))
20
- https_rackup_path File.expand_path('salt_http_config.ru', File.expand_path('../', __FILE__))
21
+ requires :dynflow, '>= 0.5.0'
22
+
23
+ rackup_path File.expand_path('salt_http_config.ru', __dir__)
24
+
25
+ load_classes do
26
+ require 'smart_proxy_dynflow'
27
+ require 'smart_proxy_salt/salt_runner'
28
+ require 'smart_proxy_salt/salt_task_launcher'
29
+ end
30
+
31
+ load_dependency_injection_wirings do |_container_instance, _settings|
32
+ Proxy::Dynflow::TaskLauncherRegistry.register('salt', SaltTaskLauncher)
33
+ end
21
34
  end
22
35
 
23
36
  class << self
@@ -33,6 +46,10 @@ module Proxy
33
46
  super
34
47
  end
35
48
  end
49
+
50
+ def respond_to_missing?(method, include_private = false)
51
+ Proxy::Salt::Rest.respond_to?(method) || Proxy::Salt::CLI.respond_to?(method) || super
52
+ end
36
53
  end
37
54
  end
38
55
  end
@@ -9,12 +9,32 @@ module Proxy
9
9
  class Api < ::Sinatra::Base
10
10
  include ::Proxy::Log
11
11
  helpers ::Proxy::Helpers
12
- authorize_with_ssl_client
12
+ authorize_with_trusted_hosts
13
+
14
+ post '/autosign_key/:key' do
15
+ content_type :json
16
+ begin
17
+ Proxy::Salt.autosign_create_key(params[:key]).to_json
18
+ rescue Exception => e
19
+ log_halt 406, "Failed to create autosign key #{params[:key]}: #{e}"
20
+ end
21
+ end
22
+
23
+ delete '/autosign_key/:key' do
24
+ content_type :json
25
+ begin
26
+ Proxy::Salt.autosign_remove_key(params[:key]).to_json
27
+ rescue Proxy::Salt::NotFound => e
28
+ log_halt 404, e.to_s
29
+ rescue Exception => e
30
+ log_halt 406, "Failed to remove autosign key #{params[:key]}: #{e}"
31
+ end
32
+ end
13
33
 
14
34
  post '/autosign/:host' do
15
35
  content_type :json
16
36
  begin
17
- Proxy::Salt.autosign_create(params[:host]).to_json
37
+ Proxy::Salt.autosign_create_hostname(params[:host]).to_json
18
38
  rescue Exception => e
19
39
  log_halt 406, "Failed to create autosign for #{params[:host]}: #{e}"
20
40
  end
@@ -23,7 +43,7 @@ module Proxy
23
43
  delete '/autosign/:host' do
24
44
  content_type :json
25
45
  begin
26
- Proxy::Salt.autosign_remove(params[:host]).to_json
46
+ Proxy::Salt.autosign_remove_hostname(params[:host]).to_json
27
47
  rescue Proxy::Salt::NotFound => e
28
48
  log_halt 404, e.to_s
29
49
  rescue Exception => e
@@ -0,0 +1,55 @@
1
+ require 'smart_proxy_dynflow/runner/base'
2
+ require 'smart_proxy_dynflow/runner/command_runner'
3
+
4
+ module Proxy
5
+ module Salt
6
+ # Implements the SaltRunner to be used by foreman_remote_execution
7
+ class SaltRunner < Proxy::Dynflow::Runner::CommandRunner
8
+ DEFAULT_REFRESH_INTERVAL = 1
9
+
10
+ attr_reader :jid
11
+
12
+ def initialize(options, suspended_action)
13
+ super(options, :suspended_action => suspended_action)
14
+ @options = options
15
+ end
16
+
17
+ def start
18
+ command = generate_command
19
+ logger.debug("Running command '#{command.join(' ')}'")
20
+ initialize_command(*command)
21
+ end
22
+
23
+ def kill
24
+ publish_data('== TASK ABORTED BY USER ==', 'stdout')
25
+ publish_exit_status(1)
26
+ ::Process.kill('SIGTERM', @command_pid)
27
+ end
28
+
29
+ def publish_data(data, type)
30
+ if @jid.nil? && (match = data.match(/jid: ([0-9]+)/))
31
+ @jid = match[1]
32
+ end
33
+ super
34
+ end
35
+
36
+ def publish_exit_status(status)
37
+ # If there was no salt job associated with this run, mark the job as failed
38
+ status = 1 if @jid.nil?
39
+ super status
40
+ end
41
+
42
+ private
43
+
44
+ def generate_command
45
+ saltfile_path = ::Proxy::Salt::Plugin.settings[:saltfile]
46
+ command = %w[salt --show-jid]
47
+ command << "--saltfile=#{saltfile_path}" if File.file?(saltfile_path)
48
+ command << @options['name']
49
+ command << 'state.template_str'
50
+ command << @options['script']
51
+ command
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,27 @@
1
+ require 'smart_proxy_dynflow/task_launcher'
2
+
3
+ module Proxy
4
+ module Salt
5
+ # Implements the TaskLauncher::Batch for Salt
6
+ class SaltTaskLauncher < ::Proxy::Dynflow::TaskLauncher::Batch
7
+ # Implements the Runner::Action for Salt
8
+ class SaltRunnerAction < ::Proxy::Dynflow::Action::Runner
9
+ def initiate_runner
10
+ additional_options = {
11
+ :step_id => run_step_id,
12
+ :uuid => execution_plan_id
13
+ }
14
+ ::Proxy::Salt::SaltRunner.new(
15
+ input.merge(additional_options),
16
+ suspended_action
17
+ )
18
+ end
19
+ end
20
+
21
+ def child_launcher(parent)
22
+ ::Proxy::Dynflow::TaskLauncher::Single.new(world, callback, :parent => parent,
23
+ :action_class_override => SaltRunnerAction)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -3,6 +3,6 @@
3
3
  module Proxy
4
4
  # Salt module
5
5
  module Salt
6
- VERSION = '3.1.0'
6
+ VERSION = '5.0.0'.freeze
7
7
  end
8
8
  end
@@ -0,0 +1,48 @@
1
+ # Foreman Salt Minion Authentication
2
+
3
+ Currently, there are two possibilites to authenticate a newly deployed minion automatically:
4
+ 1. Use the _/etc/salt/autosign.conf_ file which stores the hostnames of acceptable hosts.
5
+ 2. Use _Salt Autosign Grains_ for a more secure way which relies on a shared secret key.
6
+
7
+ This README, handles the second option and how to configure it
8
+
9
+ ## Setup
10
+ Add the content of 'master.snippet' to '/etc/salt/master' which configures the grains key file on the master and a reactor. The grains file holds the acceptable keys and will be written by the Smart Proxy when a new minion is deployed. The reactor initiates an interaction with Foreman Salt if a new minion was authenticated successfully.
11
+ In case there is already a reactor configured, you need to adapt it using the options mentioned in 'master.snippet'. The directories given in 'master.snippet' are the default ones. In case you want your files in a different place, you have to change the paths accordingly.
12
+
13
+ If '/srv/salt' is configured as 'file_roots' in your '/etc/salt/master' config, setup the necessary Salt runners:
14
+
15
+ ```
16
+ /srv/salt/_runners/foreman_file.py
17
+ /srv/salt/_runners/foreman_https.py
18
+ ```
19
+
20
+ Check if the reactor ('foreman_minion_auth.sls') is at the appropriated location:
21
+
22
+ ```
23
+ /var/lib/foreman-proxy/salt/reactors/foreman_minion_auth.sls
24
+ ```
25
+
26
+ Restart the salt-master service:
27
+
28
+ ```
29
+ systemctl restart salt-master
30
+ ```
31
+
32
+ After checking the reactor and runners, run the following command to make them available in the Salt environment:
33
+
34
+ ```
35
+ salt-run saltutil.sync_all
36
+ ```
37
+
38
+ ## Procedure
39
+
40
+ 1. A new host, configured as Salt minion, is deployed with Foreman Salt.
41
+ 2. Foreman Salt generates a unique key for that minion and distributes it via the Provisioning Template to the host and via an API call to the Smart Proxy.
42
+ 3. The Smart Proxy makes the key available for the Salt Autosign Grains procedure by adding it to the previously defined file (by default: /var/lib/foreman-proxy/salt/grains/autosign_key).
43
+ 4. The Salt minion is started and uses the configured Salt autosign grain for authentication to the Salt master.
44
+ 5. The Salt master accepts the minion depending on the key and the corresponding auth reactor is triggered on the Salt master.
45
+ 6. The Salt master initiates an API call to Foreman Salt which marks the corresponding host status as _authenticated_.
46
+ 7. Foreman Salt triggers an API call to Smart Proxy Salt which deletes the key from the acceptable keys list of the Salt master (since the minion was authenticated already and shall not be reused).
47
+
48
+ The minion was authenticated successfully.
@@ -0,0 +1,10 @@
1
+ {% if 'act' in data and 'id' in data %}
2
+ {% if data['act'] == 'accept' %}
3
+ {% if salt['saltutil.runner']('foreman_file.check_key', (data['id'], 100)) == True %}
4
+ {%- do salt.log.info('Minion authenticated successfully, starting HTTPS request to delete autosign key.') -%}
5
+ remove_autosign_key_custom_runner:
6
+ runner.foreman_https.remove_key:
7
+ - minion: {{ data['id'] }}
8
+ {% endif %}
9
+ {% endif %}
10
+ {% endif %}
@@ -0,0 +1,5 @@
1
+ autosign_grains_dir: /var/lib/foreman-proxy/salt/grains
2
+
3
+ reactor:
4
+ - 'salt/auth':
5
+ - /var/lib/foreman-proxy/salt/reactors/foreman_minion_auth.sls
@@ -0,0 +1,26 @@
1
+ """
2
+ Salt runner to check the age of a minion key file.
3
+ """
4
+
5
+ import os
6
+ import time
7
+
8
+ SALT_KEY_PATH = "/etc/salt/pki/master/minions/"
9
+
10
+
11
+ def time_secs(path):
12
+ stat = os.stat(path)
13
+ return stat.st_mtime
14
+
15
+
16
+ def younger_than_secs(path, seconds):
17
+
18
+ now_time = time.time()
19
+ file_time = time_secs(path)
20
+ if now_time - file_time <= seconds:
21
+ return True
22
+ return False
23
+
24
+
25
+ def check_key(hostname, seconds):
26
+ return younger_than_secs(SALT_KEY_PATH + hostname, seconds)
@@ -0,0 +1,121 @@
1
+ """
2
+ Salt runner to make generic https requests or perform directly
3
+ an autosign key removal.
4
+ """
5
+
6
+
7
+ from http.client import HTTPSConnection
8
+ import ssl
9
+ import base64
10
+ import json
11
+ import logging
12
+ import yaml
13
+
14
+ FOREMAN_CONFIG = '/etc/salt/foreman.yaml'
15
+ log = logging.getLogger(__name__)
16
+
17
+
18
+ def salt_config():
19
+ """
20
+ Read the foreman configuratoin from FOREMAN_CONFIG
21
+ """
22
+ with open(FOREMAN_CONFIG, 'r') as config_file:
23
+ config = yaml.load(config_file.read())
24
+ return config
25
+
26
+
27
+ def remove_key(minion):
28
+ """
29
+ Perform an HTTPS request to the configured foreman host and trigger
30
+ the autosign key removal process.
31
+ """
32
+ config = salt_config()
33
+ host_name = config[':host']
34
+ port = config[':port']
35
+ timeout = config[':timeout']
36
+ method = 'PUT'
37
+ path = '/salt/api/v2/salt_autosign_auth?name=%s' % minion
38
+
39
+ # Differentiate between cert and user authentication
40
+ if config[':proto'] == 'https':
41
+ query_cert(host=host_name,
42
+ path=path,
43
+ port=port,
44
+ method=method,
45
+ cert=config[':ssl_cert'],
46
+ key=config[':ssl_key'],
47
+ timeout=timeout)
48
+ else:
49
+ query_user(host=host_name,
50
+ path=path,
51
+ port=port,
52
+ method=method,
53
+ username=config[':username'],
54
+ password=config[':password'],
55
+ timeout=timeout)
56
+
57
+
58
+ def query_cert(host, path, port, method, cert, key,
59
+ payload=None, timeout=10):
60
+ """
61
+ Perform an HTTPS query with certificate credentials.
62
+ """
63
+
64
+ headers = {"Accept": "application/json"}
65
+
66
+ if payload is not None or method.lower() in ['put', 'post']:
67
+ headers["Content-Type"] = "application/json"
68
+
69
+ ctx = ssl.create_default_context()
70
+ ctx.load_cert_chain(certfile=cert, keyfile=key)
71
+
72
+ connection = HTTPSConnection(host,
73
+ port=port,
74
+ context=ctx,
75
+ timeout=timeout)
76
+ if payload is None:
77
+ connection.request(method,
78
+ path,
79
+ headers=headers)
80
+ else:
81
+ payload_json = json.dumps(payload)
82
+ connection.request(method=method,
83
+ url=path,
84
+ body=payload_json,
85
+ headers=headers)
86
+ response = connection.getresponse()
87
+ response_str = response.read().decode('utf-8')
88
+ print(response_str)
89
+
90
+
91
+ def query_user(host, path, port, method, username, password,
92
+ payload=None, timeout=10):
93
+ """
94
+ Perform an HTTPS query with user credentials.
95
+ """
96
+
97
+ auth = "{}:{}".format(username, password)
98
+ token = base64.b64encode(auth.encode('utf-8')).decode('ascii')
99
+ headers = {"Authorization": "Basic {}".format(token),
100
+ "Accept": "application/json"}
101
+ if payload is not None or method.lower() in ["put", "post"]:
102
+ headers["Content-Type"] = "application/json"
103
+
104
+ ctx = ssl._create_unverified_context()
105
+ connection = HTTPSConnection(host,
106
+ port=port,
107
+ context=ctx,
108
+ timeout=timeout)
109
+ if payload is None:
110
+ connection.request(method=method,
111
+ url=path,
112
+ headers=headers)
113
+ else:
114
+ payload_json = json.dumps(payload)
115
+ connection.request(method=method,
116
+ url=path,
117
+ body=payload_json,
118
+ headers=headers)
119
+ response = connection.getresponse()
120
+ response_str = response.read().decode('utf-8')
121
+ print(response_str)
@@ -0,0 +1,36 @@
1
+ # Foreman Salt Report Upload
2
+
3
+ Currently, there are two possibilites to upload the salt report to Foreman:
4
+ 1. Use /usr/sbin/upload-salt-reports which is called by a cron job every 10 minutes by default
5
+ 2. Upload the report immediately by using a Salt Reactor.
6
+
7
+ This README, handles the second option and how to configure it
8
+
9
+ ## Setup
10
+ Add the content of 'master.snippet' to '/etc/salt/master' which configures a reactor.
11
+ In case there is already a reactor configured, you need to adapt it using the options mentioned in 'master.snippet'.
12
+
13
+ Check the reactor file to be in the following folder (or a different one depending on your master configuration):
14
+
15
+ ```
16
+ /var/lib/foreman-proxy/salt/reactors/foreman_report_upload.sls
17
+ ```
18
+
19
+ In case '/srv/salt' is configured as 'file_roots' in your '/etc/salt/master' config, setup the necessary Salt runner:
20
+
21
+ ```
22
+ /srv/salt/_runners/foreman_report_upload.py
23
+ ```
24
+
25
+ After changing the salt master run:
26
+
27
+ ```
28
+ systemctl restart salt-master
29
+ ```
30
+
31
+ After adding the foreman_report_upload.sls and foreman_report_upload.py, run the following:
32
+
33
+ ```
34
+ salt-run saltutil.sync_all
35
+ ```
36
+
@@ -0,0 +1,10 @@
1
+ {% if 'cmd' in data and data['cmd'] == '_return' and 'fun' in data and (
2
+ data['fun'] == 'state.highstate' or (data['fun'] == 'state.template_str' and
3
+ 'fun_args' in data and
4
+ data['fun_args'][0].startswith('state.highstate:')
5
+ )) %}
6
+ foreman_report_upload:
7
+ runner.foreman_report_upload.now:
8
+ - args:
9
+ - highstate: '{{ data|json|base64_encode }}'
10
+ {% endif %}
@@ -0,0 +1,3 @@
1
+ reactor:
2
+ - 'salt/job/*/ret/*':
3
+ - /var/lib/foreman-proxy/salt/reactors/foreman_report_upload.sls
@@ -0,0 +1,106 @@
1
+ # -*- coding: utf-8 -*-
2
+ '''
3
+ Uploads reports from the Salt job cache to Foreman
4
+ '''
5
+ from __future__ import absolute_import, print_function, unicode_literals
6
+
7
+ FOREMAN_CONFIG = '/etc/salt/foreman.yaml'
8
+
9
+ try:
10
+ from http.client import HTTPConnection, HTTPSConnection
11
+ except ImportError:
12
+ from httplib import HTTPSConnection, HTTPSConnection
13
+
14
+ import ssl
15
+ import json
16
+ import yaml
17
+ import os
18
+ import sys
19
+ import base64
20
+
21
+ # Import python libs
22
+ import logging
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+
27
+ def salt_config():
28
+ with open(FOREMAN_CONFIG, 'r') as f:
29
+ config = yaml.load(f.read())
30
+ return config
31
+
32
+
33
+ def upload(report):
34
+ config = salt_config()
35
+ headers = {'Accept': 'application/json',
36
+ 'Content-Type': 'application/json'}
37
+
38
+ if config[':proto'] == 'https':
39
+ ctx = ssl.create_default_context()
40
+ ctx.load_cert_chain(certfile=config[':ssl_cert'], keyfile=config[':ssl_key'])
41
+ if config[':ssl_ca']:
42
+ ctx.load_verify_locations(cafile=config[':ssl_ca'])
43
+ connection = HTTPSConnection(config[':host'],
44
+ port=config[':port'], context=ctx)
45
+ else:
46
+ connection = HTTPConnection(config[':host'],
47
+ port=config[':port'])
48
+ if ':username' in config and ':password' in config:
49
+ token = base64.b64encode('{}:{}'.format(config[':username'],
50
+ config[':password']))
51
+ headers['Authorization'] = 'Basic {}'.format(token)
52
+
53
+ connection.request('POST', '/salt/api/v2/jobs/upload',
54
+ json.dumps(report), headers)
55
+ response = connection.getresponse()
56
+
57
+ if response.status == 200:
58
+ info_msg = 'Success {0}: {1}'.format(report['job']['job_id'], response.read())
59
+ log.info(info_msg)
60
+ else:
61
+ log.error("Unable to upload job - aborting report upload")
62
+ log.error(response.read())
63
+
64
+
65
+ def create_report(json_str):
66
+ msg = json.loads(json_str)
67
+
68
+ if msg['fun'] == 'state.highstate':
69
+ return {'job':
70
+ {
71
+ 'result': {
72
+ msg['id']: msg['return'],
73
+ },
74
+ 'function': 'state.highstate',
75
+ 'job_id': msg['jid']
76
+ }
77
+ }
78
+ elif msg['fun'] == 'state.template_str':
79
+ for key, entry in msg['return'].items():
80
+ if key.startswith('module_') and entry['__id__'] == 'state.highstate':
81
+ return {'job':
82
+ {
83
+ 'result': {
84
+ msg['id']: entry['changes']['ret'],
85
+ },
86
+ 'function': 'state.highstate',
87
+ 'job_id': msg['jid']
88
+ }
89
+ }
90
+ raise Exception('No state.highstate found')
91
+
92
+
93
+ def now(highstate):
94
+ '''
95
+ Upload a highstate to Foreman
96
+ highstate :
97
+ dictionary containing a highstate (generated by a Salt reactor)
98
+ '''
99
+ log.debug('Upload highstate to Foreman')
100
+
101
+ try:
102
+ report = create_report(base64.b64decode(highstate))
103
+ upload(report)
104
+ except Exception as exc:
105
+ log.error('Exception encountered: %s', exc)
106
+
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env python
1
+ #!/usr/bin/salt_python_wrapper
2
2
  # Uploads reports from the Salt job cache to Foreman
3
3
 
4
4
  from __future__ import print_function
@@ -18,12 +18,13 @@ import io
18
18
  import os
19
19
  import sys
20
20
  import base64
21
-
22
21
  import traceback
23
-
24
22
  import salt.config
25
23
  import salt.runner
26
24
 
25
+ if sys.version_info.major == 3:
26
+ unicode = str
27
+
27
28
 
28
29
  def salt_config():
29
30
  with io.open(FOREMAN_CONFIG, 'r') as f:
@@ -33,12 +34,22 @@ def salt_config():
33
34
 
34
35
  def get_job(job_id):
35
36
  result = run('jobs.lookup_jid', [job_id])
36
-
37
37
  # If any minion's results are strings, they're exceptions
38
38
  # and should be wrapped in a list like other errors
39
+
39
40
  for minion, value in result.items():
40
- if type(value) == str:
41
- result[minion] = [value]
41
+ try:
42
+ if isinstance(value,str):
43
+ result[minion] = [value]
44
+ elif isinstance(value,list):
45
+ result[minion] = value
46
+ else:
47
+ for key, entry in value.items():
48
+ if key.startswith('module_') and '__id__' in entry and entry['__id__'] == 'state.highstate':
49
+ result[minion] = entry['changes']['ret']
50
+ break
51
+ except KeyError:
52
+ traceback.print_exc()
42
53
 
43
54
  return {'job':
44
55
  {
@@ -66,7 +77,7 @@ def read_last_uploaded():
66
77
 
67
78
  def write_last_uploaded(last_uploaded):
68
79
  with io.open(LAST_UPLOADED, 'w+') as f:
69
- f.write(last_uploaded)
80
+ f.write(unicode(last_uploaded))
70
81
 
71
82
 
72
83
  def run(*args, **kwargs):
@@ -85,12 +96,11 @@ def run(*args, **kwargs):
85
96
 
86
97
  def jobs_to_upload():
87
98
  jobs = run('jobs.list_jobs', kwarg={
88
- "search_function": "state.highstate",
99
+ "search_function": ["state.highstate","state.template_str"],
89
100
  })
90
101
  last_uploaded = read_last_uploaded()
91
102
 
92
- job_ids = [jid for (jid, value) in jobs.items()
93
- if int(jid) > last_uploaded]
103
+ job_ids = [jid for jid in jobs.keys() if int(jid) > last_uploaded]
94
104
 
95
105
  for job_id in sorted(job_ids):
96
106
  yield job_id, get_job(job_id)
@@ -112,8 +122,10 @@ def upload(jobs):
112
122
  connection = HTTPConnection(config[':host'],
113
123
  port=config[':port'])
114
124
  if ':username' in config and ':password' in config:
115
- token = base64.b64encode('{}:{}'.format(config[':username'],
116
- config[':password']))
125
+ auth = '{}:{}'.format(config[':username'], config[':password'])
126
+ if not isinstance(auth, bytes):
127
+ auth = auth.encode('UTF-8')
128
+ token = base64.b64encode(auth)
117
129
  headers['Authorization'] = 'Basic {}'.format(token)
118
130
 
119
131
  for job_id, job in jobs:
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  :enabled: true
3
3
  :autosign_file: /etc/salt/autosign.conf
4
+ :autosign_key_file: /var/lib/foreman-proxy/salt/grains/autosign_key
4
5
  :salt_command_user: root
5
6
  # Some features require using the Salt API - such as listing
6
7
  # environments and retrieving state info
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smart_proxy_salt
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.1.0
4
+ version: 5.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michael Moll
@@ -9,64 +9,8 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2019-10-31 00:00:00.000000000 Z
12
+ date: 2022-02-14 00:00:00.000000000 Z
13
13
  dependencies:
14
- - !ruby/object:Gem::Dependency
15
- name: json
16
- requirement: !ruby/object:Gem::Requirement
17
- requirements:
18
- - - ">="
19
- - !ruby/object:Gem::Version
20
- version: '0'
21
- type: :runtime
22
- prerelease: false
23
- version_requirements: !ruby/object:Gem::Requirement
24
- requirements:
25
- - - ">="
26
- - !ruby/object:Gem::Version
27
- version: '0'
28
- - !ruby/object:Gem::Dependency
29
- name: rack
30
- requirement: !ruby/object:Gem::Requirement
31
- requirements:
32
- - - ">="
33
- - !ruby/object:Gem::Version
34
- version: '1.1'
35
- type: :runtime
36
- prerelease: false
37
- version_requirements: !ruby/object:Gem::Requirement
38
- requirements:
39
- - - ">="
40
- - !ruby/object:Gem::Version
41
- version: '1.1'
42
- - !ruby/object:Gem::Dependency
43
- name: sinatra
44
- requirement: !ruby/object:Gem::Requirement
45
- requirements:
46
- - - ">="
47
- - !ruby/object:Gem::Version
48
- version: '0'
49
- type: :runtime
50
- prerelease: false
51
- version_requirements: !ruby/object:Gem::Requirement
52
- requirements:
53
- - - ">="
54
- - !ruby/object:Gem::Version
55
- version: '0'
56
- - !ruby/object:Gem::Dependency
57
- name: logging
58
- requirement: !ruby/object:Gem::Requirement
59
- requirements:
60
- - - ">="
61
- - !ruby/object:Gem::Version
62
- version: '0'
63
- type: :runtime
64
- prerelease: false
65
- version_requirements: !ruby/object:Gem::Requirement
66
- requirements:
67
- - - ">="
68
- - !ruby/object:Gem::Version
69
- version: '0'
70
14
  - !ruby/object:Gem::Dependency
71
15
  name: test-unit
72
16
  requirement: !ruby/object:Gem::Requirement
@@ -115,28 +59,28 @@ dependencies:
115
59
  requirements:
116
60
  - - "~>"
117
61
  - !ruby/object:Gem::Version
118
- version: '10'
62
+ version: '13'
119
63
  type: :development
120
64
  prerelease: false
121
65
  version_requirements: !ruby/object:Gem::Requirement
122
66
  requirements:
123
67
  - - "~>"
124
68
  - !ruby/object:Gem::Version
125
- version: '10'
69
+ version: '13'
126
70
  - !ruby/object:Gem::Dependency
127
71
  name: rubocop
128
72
  requirement: !ruby/object:Gem::Requirement
129
73
  requirements:
130
74
  - - '='
131
75
  - !ruby/object:Gem::Version
132
- version: 0.32.1
76
+ version: 0.50.0
133
77
  type: :development
134
78
  prerelease: false
135
79
  version_requirements: !ruby/object:Gem::Requirement
136
80
  requirements:
137
81
  - - '='
138
82
  - !ruby/object:Gem::Version
139
- version: 0.32.1
83
+ version: 0.50.0
140
84
  - !ruby/object:Gem::Dependency
141
85
  name: rack-test
142
86
  requirement: !ruby/object:Gem::Requirement
@@ -151,10 +95,25 @@ dependencies:
151
95
  - - "~>"
152
96
  - !ruby/object:Gem::Version
153
97
  version: '0'
98
+ - !ruby/object:Gem::Dependency
99
+ name: smart_proxy_dynflow
100
+ requirement: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: 0.5.0
105
+ type: :runtime
106
+ prerelease: false
107
+ version_requirements: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: 0.5.0
154
112
  description: SaltStack Plug-In for Foreman's Smart Proxy
155
113
  email: foreman-dev@googlegroups.com
156
114
  executables:
157
115
  - foreman-node
116
+ - salt_python_wrapper
158
117
  extensions: []
159
118
  extra_rdoc_files:
160
119
  - README.md
@@ -163,8 +122,10 @@ files:
163
122
  - LICENSE
164
123
  - README.md
165
124
  - bin/foreman-node
125
+ - bin/salt_python_wrapper
166
126
  - bundler.d/salt.rb
167
127
  - cron/smart_proxy_salt
128
+ - etc/foreman.conf.example
168
129
  - etc/foreman.yaml.example
169
130
  - lib/smart_proxy_salt.rb
170
131
  - lib/smart_proxy_salt/api_request.rb
@@ -173,11 +134,18 @@ files:
173
134
  - lib/smart_proxy_salt/salt.rb
174
135
  - lib/smart_proxy_salt/salt_api.rb
175
136
  - lib/smart_proxy_salt/salt_http_config.ru
137
+ - lib/smart_proxy_salt/salt_runner.rb
138
+ - lib/smart_proxy_salt/salt_task_launcher.rb
176
139
  - lib/smart_proxy_salt/version.rb
177
- - lib/smart_proxy_salt_core.rb
178
- - lib/smart_proxy_salt_core/salt_runner.rb
179
- - lib/smart_proxy_salt_core/salt_task_launcher.rb
180
- - lib/smart_proxy_salt_core/version.rb
140
+ - salt/minion_auth/README.md
141
+ - salt/minion_auth/foreman_minion_auth.sls
142
+ - salt/minion_auth/master.snippet
143
+ - salt/minion_auth/srv/salt/_runners/foreman_file.py
144
+ - salt/minion_auth/srv/salt/_runners/foreman_https.py
145
+ - salt/report_upload/README.md
146
+ - salt/report_upload/foreman_report_upload.sls
147
+ - salt/report_upload/master.snippet
148
+ - salt/report_upload/srv/salt/_runners/foreman_report_upload.py
181
149
  - sbin/upload-salt-reports
182
150
  - settings.d/salt.saltfile.example
183
151
  - settings.d/salt.yml.example
@@ -1,51 +0,0 @@
1
- require 'foreman_tasks_core/runner/command_runner'
2
-
3
- module SmartProxySaltCore
4
- class SaltRunner < ForemanTasksCore::Runner::CommandRunner
5
- DEFAULT_REFRESH_INTERVAL = 1
6
-
7
- attr_reader :jid
8
-
9
- def initialize(options, suspended_action:)
10
- super(options, :suspended_action => suspended_action)
11
- @options = options
12
- end
13
-
14
- def start
15
- command = generate_command
16
- logger.debug("Running command '#{command.join(' ')}'")
17
- initialize_command(*command)
18
- end
19
-
20
- def kill
21
- publish_data('== TASK ABORTED BY USER ==', 'stdout')
22
- publish_exit_status(1)
23
- ::Process.kill('SIGTERM', @command_pid)
24
- end
25
-
26
- def publish_data(data, type)
27
- if @jid.nil? && (match = data.match(/jid: ([0-9]+)/))
28
- @jid = match[1]
29
- end
30
- super
31
- end
32
-
33
- def publish_exit_status(status)
34
- # If there was no salt job associated with this run, mark the job as failed
35
- status = 1 if @jid.nil?
36
- super status
37
- end
38
-
39
- private
40
-
41
- def generate_command
42
- saltfile_path = SmartProxySaltCore.settings[:saltfile]
43
- command = %w(salt --show-jid)
44
- command << "--saltfile=#{saltfile_path}" if File.file?(saltfile_path)
45
- command << @options['name']
46
- command << 'state.template_str'
47
- command << @options['script']
48
- command
49
- end
50
- end
51
- end
@@ -1,21 +0,0 @@
1
- module SmartProxySaltCore
2
- class SaltTaskLauncher < ForemanTasksCore::TaskLauncher::Batch
3
- class SaltRunnerAction < ForemanTasksCore::Runner::Action
4
- def initiate_runner
5
- additional_options = {
6
- :step_id => run_step_id,
7
- :uuid => execution_plan_id
8
- }
9
- ::SmartProxySaltCore::SaltRunner.new(
10
- input.merge(additional_options),
11
- :suspended_action => suspended_action
12
- )
13
- end
14
- end
15
-
16
- def child_launcher(parent)
17
- ForemanTasksCore::TaskLauncher::Single.new(world, callback, :parent => parent,
18
- :action_class_override => SaltRunnerAction)
19
- end
20
- end
21
- end
@@ -1,3 +0,0 @@
1
- module SmartProxySaltCore
2
- VERSION = '0.0.2'.freeze
3
- end
@@ -1,17 +0,0 @@
1
- require 'foreman_tasks_core'
2
- require 'foreman_remote_execution_core'
3
-
4
- module SmartProxySaltCore
5
- extend ForemanTasksCore::SettingsLoader
6
- register_settings(:salt,
7
- :saltfile => '/etc/foreman-proxy/settings.d/salt.saltfile')
8
-
9
- if ForemanTasksCore.dynflow_present?
10
- require 'smart_proxy_salt_core/salt_runner'
11
- require 'smart_proxy_salt_core/salt_task_launcher'
12
-
13
- if defined?(SmartProxyDynflowCore)
14
- SmartProxyDynflowCore::TaskLauncherRegistry.register('salt', SaltTaskLauncher)
15
- end
16
- end
17
- end