smart_proxy_salt 3.0.0 → 4.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: 978fe04cd0b2ee65be99fc84f20599f295d51498b8aa1ba0541cc9569f56ee9a
4
- data.tar.gz: 732c3b575790b37ec8570816f76d10af534b10cf3fb7ea217b7378f645c951f9
3
+ metadata.gz: 63743a9b37c7860b60af93db9735f210608221d87675c7d11764eb9f08348caa
4
+ data.tar.gz: 38fa3a0d210534f63d1f925790d0a062b20a907499dad0ba2bae1125a0bb8190
5
5
  SHA512:
6
- metadata.gz: d291974d5e815bb0a8d84f46d8f4e8509d0c70dc8dc76fa9d35d0f836ff7e55f3fe7f67c3cd0d5fc1452d2747b528ccca05c0de7d881042468b652a30824dd5e
7
- data.tar.gz: e41c156691bb2c1c86381658104a4fb54fb4d6a22a8e1778b7dc6ce9f39d40183d649699c49b8c835815c16b073886d68d0988bf0a019c79d8fbb001cdc7133c
6
+ metadata.gz: '008b29492b0f227e04ba281423df738e9334e040492f2fcf62172e866cccd931a80de5a5eac14d8e5b4c81a17dd26697e11f89aa696ab28670471f55be585e26'
7
+ data.tar.gz: 05a4afde3202a35328d2b2daa4cc2266808a9c7af01b53dae3876a4a8b033ef898fc07e1d86bfc9a22c05b7ab3bcdb924884c34b1d5546e1a8c93a39ec256623
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,48 +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
- #
64
- # salt-run doesn't support directly outputting to json,
65
- # so we have resort to python to extract the grains.
66
- # Based on https://github.com/saltstack/salt/issues/9444
67
-
68
- script = <<-EOF
69
- #!/usr/bin/env python
70
- import json
71
- import os
72
- import sys
73
-
74
- import salt.config
75
- import salt.runner
76
-
77
- if __name__ == '__main__':
78
- __opts__ = salt.config.master_config(
79
- os.environ.get('SALT_MASTER_CONFIG', '/etc/salt/master'))
80
- runner = salt.runner.Runner(__opts__)
81
-
82
- stdout_bak = sys.stdout
83
- with open(os.devnull, 'wb') as f:
84
- sys.stdout = f
85
- ret = runner.cmd('cache.grains', ['#{minion}'])
86
- sys.stdout = stdout_bak
87
-
88
- print json.dumps(ret)
89
- EOF
90
-
91
- result = IO.popen('python 2>/dev/null', 'r+') do |python|
92
- python.write script
93
- python.close_write
94
- result = python.read
95
- end
96
82
 
97
- 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
98
88
 
99
89
  raise 'No grains received from Salt master' unless grains
100
90
 
101
- plainify(grains[minion]).flatten.inject(&:merge)
91
+ plainify(grains).flatten.inject(&:merge)
102
92
  end
103
93
 
104
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
@@ -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,57 @@ module Proxy
62
61
  end.map(&:chomp)
63
62
  end
64
63
 
64
+ def append_value_to_file(filepath, value)
65
+ result = false
66
+ begin
67
+ raise "No such file: #{filepath}" unless File.exist?(filepath)
68
+
69
+ file = open(filepath, File::RDWR)
70
+ found = false
71
+ file.each_line { |line| found = true if line.chomp == value }
72
+ file.puts value unless found
73
+ file.close
74
+
75
+ logger.info "Added an entry to '#{filepath}' successfully."
76
+ result = true
77
+ rescue SystemCallError => e
78
+ logger.info "Attempted to add an entry to '#{filepath}', but an exception occurred: #{e}"
79
+ end
80
+ result
81
+ end
82
+
83
+ def remove_value_from_file(filepath, value)
84
+ result = false
85
+ begin
86
+ raise "No such file: #{filepath}" unless File.exist?(filepath)
87
+
88
+ found = false
89
+ entries = open(filepath, File::RDONLY).readlines.collect do |l|
90
+ entry = l.chomp
91
+ if entry == value
92
+ found = true
93
+ nil
94
+ elsif entry == ""
95
+ nil
96
+ else
97
+ l
98
+ end
99
+ end.uniq.compact
100
+ if found
101
+ file = open(filepath, File::TRUNC | File::RDWR)
102
+ file.write entries.join()
103
+ file.close
104
+ logger.info "Removed an entry from '#{filepath}' successfully."
105
+ result = true
106
+ else
107
+ raise Proxy::Salt::NotFound.new("Attempt to remove non-existent entry.")
108
+ end
109
+ rescue SystemCallError => e
110
+ logger.info "Attempted to remove an entry from '#{filepath}', but an exception occurred: #{e}"
111
+ end
112
+ result
113
+ end
114
+
65
115
  def highstate(host)
66
116
  find_salt_binaries
67
117
  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.0.0'
6
+ VERSION = '4.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 and set the correct smart proxy address in the reactor file:
21
+
22
+ ```
23
+ /var/lib/foreman-proxy/salt/reactors/foreman_minion_auth.sls
24
+ ```
25
+
26
+ After changing the Salt master, 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,24 @@
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.query_cert:
7
+ - method: PUT
8
+ - host: example.proxy.com # set smart proxy address
9
+ - path: /salt/api/v2/salt_autosign_auth?name={{ data['id'] }}
10
+ - cert: /etc/pki/katello/puppet/puppet_client.crt # default cert location
11
+ - key: /etc/pki/katello/puppet/puppet_client.key # default key location
12
+ - port: 443
13
+ # Uncomment the following lines in case you want to use username + password authentication (not recommended)
14
+ # call_foreman_salt_custom_runner:
15
+ # runner.foreman_https.query_user:
16
+ # - method: PUT
17
+ # - host: example.proxy.com # set smart proxy address
18
+ # - path: /salt/api/v2/salt_autosign_auth?name={{ data['id'] }}
19
+ # - username: my_username # set username
20
+ # - password: my_password # set password
21
+ # - port: 443
22
+ {% endif %}
23
+ {% endif %}
24
+ {% 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,22 @@
1
+ #!/usr/bin/env python
2
+
3
+ import os
4
+ import time
5
+
6
+ SALT_KEY_PATH = "/etc/salt/pki/master/minions/"
7
+
8
+ def time_secs(path):
9
+ stat = os.stat(path)
10
+ return stat.st_mtime
11
+
12
+
13
+ def younger_than_secs(path, seconds):
14
+
15
+ now_time = time.time()
16
+ file_time = time_secs(path)
17
+ if now_time - file_time <= seconds:
18
+ return True
19
+ return False
20
+
21
+ def check_key(hostname, seconds):
22
+ return younger_than_secs(SALT_KEY_PATH + hostname, seconds)
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env python
2
+
3
+ from http.client import HTTPSConnection
4
+ import ssl
5
+ import base64
6
+ import json
7
+
8
+
9
+ def query_cert(host, path, port, method, cert, key,
10
+ payload=None, timeout=10):
11
+
12
+ headers = {"Accept": "application/json"}
13
+
14
+ if payload is not None or method.lower() in ['put', 'post']:
15
+ headers["Content-Type"] = "application/json"
16
+
17
+ ctx = ssl.create_default_context()
18
+ ctx.load_cert_chain(certfile=cert, keyfile=key)
19
+
20
+ connection = HTTPSConnection(host,
21
+ port=port,
22
+ context=ctx,
23
+ timeout=timeout)
24
+ if payload is None:
25
+ connection.request(method,
26
+ path,
27
+ headers=headers)
28
+ else:
29
+ payload_json = json.dumps(payload)
30
+ connection.request(method=method,
31
+ url=path,
32
+ body=payload_json,
33
+ headers=headers)
34
+ response = connection.getresponse()
35
+ response_str = response.read().decode('utf-8')
36
+ print(response_str)
37
+
38
+
39
+ def query_user(host, path, port, method, username, password,
40
+ payload=None, timeout=10):
41
+
42
+ auth = "{}:{}".format(username, password)
43
+ token = base64.b64encode(auth.encode('utf-8')).decode('ascii')
44
+ headers = {"Authorization": "Basic {}".format(token),
45
+ "Accept": "application/json"}
46
+ if payload is not None or method.lower() in ["put", "post"]:
47
+ headers["Content-Type"] = "application/json"
48
+
49
+ ctx = ssl._create_unverified_context()
50
+ connection = HTTPSConnection(host,
51
+ port=port,
52
+ context=ctx,
53
+ timeout=timeout)
54
+ if payload is None:
55
+ connection.request(method=method,
56
+ url=path,
57
+ headers=headers)
58
+ else:
59
+ payload_json = json.dumps(payload)
60
+ connection.request(method=method,
61
+ url=path,
62
+ body=payload_json,
63
+ headers=headers)
64
+ response = connection.getresponse()
65
+ response_str = response.read().decode('utf-8')
66
+ print(response_str)
@@ -0,0 +1,31 @@
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
+ In case '/srv/salt' is configured as 'file_roots' in your '/etc/salt/master' config, setup the necessary salt state file and Salt runner functions:
14
+
15
+ ```
16
+ /srv/salt/foreman_report_upload.sls
17
+ /srv/salt/_runners/foreman_report_upload.py
18
+ ```
19
+
20
+ After changing the salt master run:
21
+
22
+ ```
23
+ systemctl restart salt-master
24
+ ```
25
+
26
+ After adding the foreman_report_upload.sls and foreman_report_upload.py, run the following:
27
+
28
+ ```
29
+ salt-run saltutil.sync_all
30
+ ```
31
+
@@ -0,0 +1,3 @@
1
+ reactor:
2
+ - 'salt/job/*/ret/*':
3
+ - /srv/salt/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
+
@@ -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 %}
@@ -1,38 +1,55 @@
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
+ from __future__ import print_function
5
+
4
6
  LAST_UPLOADED = '/etc/salt/last_uploaded'
5
7
  FOREMAN_CONFIG = '/etc/salt/foreman.yaml'
6
8
  LOCK_FILE = '/var/lock/salt-report-upload.lock'
7
9
 
8
- import httplib
10
+ try:
11
+ from http.client import HTTPConnection, HTTPSConnection
12
+ except ImportError:
13
+ from httplib import HTTPSConnection, HTTPSConnection
9
14
  import ssl
10
15
  import json
11
16
  import yaml
17
+ import io
12
18
  import os
13
19
  import sys
14
20
  import base64
15
-
16
21
  import traceback
17
-
18
22
  import salt.config
19
23
  import salt.runner
20
24
 
25
+ if sys.version_info.major == 3:
26
+ unicode = str
27
+
21
28
 
22
29
  def salt_config():
23
- with open(FOREMAN_CONFIG, 'r') as f:
30
+ with io.open(FOREMAN_CONFIG, 'r') as f:
24
31
  config = yaml.load(f.read())
25
32
  return config
26
33
 
27
34
 
28
35
  def get_job(job_id):
29
36
  result = run('jobs.lookup_jid', [job_id])
30
-
31
37
  # If any minion's results are strings, they're exceptions
32
38
  # and should be wrapped in a list like other errors
33
- for minion, value in result.iteritems():
34
- if type(value) == str:
35
- result[minion] = [value]
39
+
40
+ for minion, value in result.items():
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()
36
53
 
37
54
  return {'job':
38
55
  {
@@ -47,7 +64,7 @@ def read_last_uploaded():
47
64
  if not os.path.isfile(LAST_UPLOADED):
48
65
  return 0
49
66
  else:
50
- with open(LAST_UPLOADED, 'r') as f:
67
+ with io.open(LAST_UPLOADED, 'r') as f:
51
68
  result = f.read().strip()
52
69
  if len(result) == 20:
53
70
  try:
@@ -59,8 +76,8 @@ def read_last_uploaded():
59
76
 
60
77
 
61
78
  def write_last_uploaded(last_uploaded):
62
- with open(LAST_UPLOADED, 'w+') as f:
63
- f.write(last_uploaded)
79
+ with io.open(LAST_UPLOADED, 'w+') as f:
80
+ f.write(unicode(last_uploaded))
64
81
 
65
82
 
66
83
  def run(*args, **kwargs):
@@ -68,7 +85,7 @@ def run(*args, **kwargs):
68
85
  os.environ.get('SALT_MASTER_CONFIG', '/etc/salt/master'))
69
86
 
70
87
  runner = salt.runner.Runner(__opts__)
71
- with open(os.devnull, 'wb') as f:
88
+ with io.open(os.devnull, 'w') as f:
72
89
  stdout_bak, sys.stdout = sys.stdout, f
73
90
  try:
74
91
  ret = runner.cmd(*args, **kwargs)
@@ -79,12 +96,11 @@ def run(*args, **kwargs):
79
96
 
80
97
  def jobs_to_upload():
81
98
  jobs = run('jobs.list_jobs', kwarg={
82
- "search_function": "state.highstate",
99
+ "search_function": ["state.highstate","state.template_str"],
83
100
  })
84
101
  last_uploaded = read_last_uploaded()
85
102
 
86
- job_ids = [jid for (jid, value) in jobs.iteritems()
87
- if int(jid) > last_uploaded]
103
+ job_ids = [jid for jid in jobs.keys() if int(jid) > last_uploaded]
88
104
 
89
105
  for job_id in sorted(job_ids):
90
106
  yield job_id, get_job(job_id)
@@ -99,15 +115,17 @@ def upload(jobs):
99
115
  ctx = ssl.create_default_context()
100
116
  ctx.load_cert_chain(certfile=config[':ssl_cert'], keyfile=config[':ssl_key'])
101
117
  if config[':ssl_ca']:
102
- ctx.load_verify_locations(cafile=config[':ssl_ca'])
103
- connection = httplib.HTTPSConnection(config[':host'],
104
- port=config[':port'], context=ctx)
118
+ ctx.load_verify_locations(cafile=config[':ssl_ca'])
119
+ connection = HTTPSConnection(config[':host'],
120
+ port=config[':port'], context=ctx)
105
121
  else:
106
- connection = httplib.HTTPConnection(config[':host'],
107
- port=config[':port'])
122
+ connection = HTTPConnection(config[':host'],
123
+ port=config[':port'])
108
124
  if ':username' in config and ':password' in config:
109
- token = base64.b64encode('{}:{}'.format(config[':username'],
110
- 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)
111
129
  headers['Authorization'] = 'Basic {}'.format(token)
112
130
 
113
131
  for job_id, job in jobs:
@@ -121,23 +139,24 @@ def upload(jobs):
121
139
 
122
140
  if response.status == 200:
123
141
  write_last_uploaded(job_id)
124
- print "Success %s: %s" % (job_id, response.read())
142
+ print("Success %s: %s" % (job_id, response.read()))
125
143
  else:
126
- print "Unable to upload job - aborting report upload"
127
- print response.read()
144
+ print("Unable to upload job - aborting report upload")
145
+ print(response.read())
128
146
 
129
147
 
130
148
  def get_lock():
131
149
  if os.path.isfile(LOCK_FILE):
132
150
  raise Exception("Unable to obtain lock.")
133
151
  else:
134
- open(LOCK_FILE, 'w+').close()
152
+ io.open(LOCK_FILE, 'w+').close()
135
153
 
136
154
 
137
155
  def release_lock():
138
156
  if os.path.isfile(LOCK_FILE):
139
157
  os.remove(LOCK_FILE)
140
158
 
159
+
141
160
  if __name__ == '__main__':
142
161
  try:
143
162
  get_lock()
@@ -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.0.0
4
+ version: 4.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-08-02 00:00:00.000000000 Z
12
+ date: 2021-08-02 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,6 +122,7 @@ 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
168
128
  - etc/foreman.yaml.example
@@ -173,11 +133,18 @@ files:
173
133
  - lib/smart_proxy_salt/salt.rb
174
134
  - lib/smart_proxy_salt/salt_api.rb
175
135
  - lib/smart_proxy_salt/salt_http_config.ru
136
+ - lib/smart_proxy_salt/salt_runner.rb
137
+ - lib/smart_proxy_salt/salt_task_launcher.rb
176
138
  - 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
139
+ - salt/minion_auth/README.md
140
+ - salt/minion_auth/foreman_minion_auth.sls
141
+ - salt/minion_auth/master.snippet
142
+ - salt/minion_auth/srv/salt/_runners/foreman_file.py
143
+ - salt/minion_auth/srv/salt/_runners/foreman_https.py
144
+ - salt/report_upload/README.md
145
+ - salt/report_upload/master.snippet
146
+ - salt/report_upload/srv/salt/_runners/foreman_report_upload.py
147
+ - salt/report_upload/srv/salt/foreman_report_upload.sls
181
148
  - sbin/upload-salt-reports
182
149
  - settings.d/salt.saltfile.example
183
150
  - settings.d/salt.yml.example
@@ -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
@@ -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.1'.freeze
3
- end