cem_acpt 0.2.5 → 0.6.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/spec.yml +38 -0
  3. data/Gemfile +4 -3
  4. data/Gemfile.lock +85 -56
  5. data/README.md +144 -83
  6. data/cem_acpt.gemspec +8 -7
  7. data/exe/cem_acpt +41 -7
  8. data/lib/cem_acpt/config.rb +345 -0
  9. data/lib/cem_acpt/core_extensions.rb +17 -61
  10. data/lib/cem_acpt/goss/api/action_response.rb +175 -0
  11. data/lib/cem_acpt/goss/api.rb +83 -0
  12. data/lib/cem_acpt/goss.rb +8 -0
  13. data/lib/cem_acpt/image_name_builder.rb +0 -9
  14. data/lib/cem_acpt/logging/formatter.rb +97 -0
  15. data/lib/cem_acpt/logging.rb +168 -142
  16. data/lib/cem_acpt/platform/base.rb +26 -37
  17. data/lib/cem_acpt/platform/gcp.rb +48 -62
  18. data/lib/cem_acpt/platform.rb +30 -28
  19. data/lib/cem_acpt/provision/terraform/linux.rb +47 -0
  20. data/lib/cem_acpt/provision/terraform/os_data.rb +72 -0
  21. data/lib/cem_acpt/provision/terraform/windows.rb +22 -0
  22. data/lib/cem_acpt/provision/terraform.rb +193 -0
  23. data/lib/cem_acpt/provision.rb +20 -0
  24. data/lib/cem_acpt/puppet_helpers.rb +0 -1
  25. data/lib/cem_acpt/test_data.rb +23 -13
  26. data/lib/cem_acpt/test_runner/log_formatter/goss_action_response.rb +104 -0
  27. data/lib/cem_acpt/test_runner/log_formatter.rb +10 -0
  28. data/lib/cem_acpt/test_runner.rb +170 -3
  29. data/lib/cem_acpt/utils/puppet.rb +29 -0
  30. data/lib/cem_acpt/utils/ssh.rb +197 -0
  31. data/lib/cem_acpt/utils/terminal.rb +27 -0
  32. data/lib/cem_acpt/utils.rb +4 -138
  33. data/lib/cem_acpt/version.rb +1 -1
  34. data/lib/cem_acpt.rb +70 -20
  35. data/lib/terraform/gcp/linux/goss/puppet_idempotent.yaml +10 -0
  36. data/lib/terraform/gcp/linux/goss/puppet_noop.yaml +12 -0
  37. data/lib/terraform/gcp/linux/main.tf +191 -0
  38. data/lib/terraform/gcp/linux/systemd/goss-acpt.service +8 -0
  39. data/lib/terraform/gcp/linux/systemd/goss-idempotent.service +8 -0
  40. data/lib/terraform/gcp/linux/systemd/goss-noop.service +8 -0
  41. data/lib/terraform/gcp/windows/.keep +0 -0
  42. data/sample_config.yaml +22 -21
  43. metadata +88 -56
  44. data/lib/cem_acpt/bootstrap/bootstrapper.rb +0 -206
  45. data/lib/cem_acpt/bootstrap/operating_system/rhel_family.rb +0 -129
  46. data/lib/cem_acpt/bootstrap/operating_system.rb +0 -17
  47. data/lib/cem_acpt/bootstrap.rb +0 -12
  48. data/lib/cem_acpt/context.rb +0 -153
  49. data/lib/cem_acpt/platform/base/cmd.rb +0 -71
  50. data/lib/cem_acpt/platform/gcp/cmd.rb +0 -345
  51. data/lib/cem_acpt/platform/gcp/compute.rb +0 -332
  52. data/lib/cem_acpt/platform/vmpooler.rb +0 -24
  53. data/lib/cem_acpt/rspec_utils.rb +0 -242
  54. data/lib/cem_acpt/shared_objects.rb +0 -537
  55. data/lib/cem_acpt/spec_helper_acceptance.rb +0 -184
  56. data/lib/cem_acpt/test_runner/run_handler.rb +0 -187
  57. data/lib/cem_acpt/test_runner/runner.rb +0 -210
  58. data/lib/cem_acpt/test_runner/runner_result.rb +0 -103
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'pathname'
3
4
  require_relative 'core_extensions'
4
5
  require_relative 'logging'
5
6
 
@@ -19,28 +20,38 @@ module CemAcpt
19
20
  include CemAcpt::Logging
20
21
  using CemAcpt::CoreExtensions::ExtendedHash
21
22
 
22
- attr_reader :module_dir, :acceptance_tests
23
+ attr_reader :acpt_test_dir, :acceptance_tests
23
24
 
24
25
  # Initializes a new Fetcher object.
25
26
  # @param config [CemAcpt::Config] the config object
26
27
  def initialize(config)
27
28
  @config = config
28
- @module_dir = config.get('module_dir')
29
- @acceptance_tests = find_acceptance_tests(config.get('module_dir'))
29
+ @acpt_test_dir = Pathname(File.join(@config.get('module_dir'), 'spec', 'acceptance'))
30
+ find_acceptance_tests!
30
31
  end
31
32
 
32
33
  # Extracts, formats, and returns a test data hash.
33
34
  # @return [Array<Hash>] an array of test data hashes
34
35
  def acceptance_test_data
35
36
  logger.info 'Gathering acceptance test data...'
36
- acceptance_tests.each_with_object([]) do |t, a|
37
- logger.debug("Processing #{t}...")
38
- test_name = File.basename(t, '_spec.rb')
37
+ raise "No 'tests' entry found in config" unless @config.has?('tests')
38
+
39
+ @config.get('tests').each_with_object([]) do |test_name, a|
40
+ test_dir = acceptance_tests.find { |f| File.basename(f) == test_name }
41
+ raise "Test directory not found for test #{test_name}" unless test_dir
42
+
43
+ goss_file = File.expand_path(File.join(test_dir, 'goss.yaml'))
44
+ puppet_manifest = File.expand_path(File.join(test_dir, 'manifest.pp'))
45
+ raise "Goss file not found for test #{test_name}" unless File.exist?(goss_file)
46
+ raise "Puppet manifest not found for test #{test_name}" unless File.exist?(puppet_manifest)
47
+
48
+ logger.debug("Complete test directory found for test #{test_name}: #{test_dir}")
39
49
  test_data = {
40
50
  test_name: test_name,
41
- test_file: File.expand_path(t),
51
+ test_dir: File.expand_path(test_dir),
52
+ goss_file: goss_file,
53
+ puppet_manifest: puppet_manifest,
42
54
  }
43
- next unless @config.has?('tests') && @config.get('tests').include?(test_name)
44
55
 
45
56
  process_for_each(test_data).each do |test_data_i|
46
57
  process_static_vars(test_data_i)
@@ -56,12 +67,11 @@ module CemAcpt
56
67
 
57
68
  # Locates acceptance tests in the module directory.
58
69
  # @return [Array<String>] the list of acceptance test paths
59
- def find_acceptance_tests(module_dir)
60
- tests = Dir.glob(File.join(module_dir, 'spec', 'acceptance', '*_spec.rb'))
61
- raise 'No acceptance tests found' if tests.empty?
70
+ def find_acceptance_tests!
71
+ @acceptance_tests = acpt_test_dir.children.select { |f| f.directory? && File.exist?(File.join(f, 'goss.yaml')) }.map(&:to_s)
72
+ raise 'No acceptance tests found' if @acceptance_tests.empty?
62
73
 
63
- logger.info "Found #{tests.size} acceptance tests"
64
- tests
74
+ logger.info "Found #{@acceptance_tests.size} acceptance tests"
65
75
  end
66
76
 
67
77
  # Processes a for_each statement in the test data config.
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CemAcpt
4
+ module TestRunner
5
+ module LogFormatter
6
+ # Formats the results of a Goss action
7
+ class GossActionResponse
8
+ INDENT = ' '
9
+
10
+ def initialize(config, instance_names_ips)
11
+ @config = config
12
+ @instance_names_ips = instance_names_ips
13
+ end
14
+
15
+ def inspect
16
+ to_s
17
+ end
18
+
19
+ def to_s
20
+ "#<#{self.class.name}:0x#{object_id.to_s(16)}>"
21
+ end
22
+
23
+ def summary(response)
24
+ new_summary_message(response)
25
+ end
26
+
27
+ def results(response)
28
+ new_results_message(response.results)
29
+ end
30
+
31
+ def host_name(response)
32
+ name_from_ip(response.host)
33
+ end
34
+
35
+ def test_name(response)
36
+ test_from_ip(response.host)
37
+ end
38
+
39
+ private
40
+
41
+ def normalize_whitespace(str)
42
+ raise ArgumentError, 'str must be a String' unless str.is_a?(String)
43
+
44
+ str.gsub(%r{(\n|\r|\t)}, ' ').gsub(%r{\s{2,}}, ' ').strip
45
+ end
46
+
47
+ def success_str(success)
48
+ success ? 'passed' : 'failed'
49
+ end
50
+
51
+ def name_from_ip(ip)
52
+ @instance_names_ips.each do |name, val|
53
+ return name if val['ip'] == ip
54
+ end
55
+ ip
56
+ end
57
+
58
+ def test_from_ip(ip)
59
+ @instance_names_ips.each do |_name, val|
60
+ return val['test_name'] if val['ip'] == ip
61
+ end
62
+ '<unknown>'
63
+ end
64
+
65
+ def new_summary_message(response)
66
+ msg = [
67
+ "SUMMARY: #{success_str(response.success?).capitalize}: Test #{test_from_ip(response.host)}:",
68
+ "#{normalize_whitespace(response.summary.summary_line)}:",
69
+ "Action '#{response.action}' on host #{name_from_ip(response.host)}",
70
+ ].join(' ')
71
+ return msg unless @config.debug?
72
+
73
+ [
74
+ msg,
75
+ "HTTP Status: #{response.http_status}",
76
+ "Failed percentage: #{response.summary.failed_percentage}",
77
+ ].join("\n#{INDENT}")
78
+ end
79
+
80
+ def new_results_message(results)
81
+ results.map { |r| new_result_message(r) }
82
+ end
83
+
84
+ def new_result_message(result)
85
+ return "Error: #{result.error}" unless result.err.nil?
86
+
87
+ status = result.skipped? ? 'Skipped' : success_str(result.success?).capitalize
88
+ msg = [
89
+ "#{status}:",
90
+ normalize_whitespace(result.summary_line),
91
+ ]
92
+ return msg.join(' ') unless @config.debug?
93
+
94
+ [
95
+ msg.join(' '),
96
+ "Duration: #{result.duration.to_s}",
97
+ "Expected: #{result.expected}",
98
+ "Found: #{result.found}",
99
+ ].join("\n#{INDENT}")
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'log_formatter/goss_action_response'
4
+
5
+ module CemAcpt
6
+ module TestRunner
7
+ # Holds classes for formatting test runner results
8
+ module LogFormatter; end
9
+ end
10
+ end
@@ -1,10 +1,177 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'core_extensions'
4
+ require_relative 'goss'
5
+ require_relative 'logging'
6
+ require_relative 'platform'
7
+ require_relative 'provision'
8
+ require_relative 'test_data'
9
+ require_relative 'utils'
10
+ require_relative 'version'
11
+ require_relative 'test_runner/log_formatter'
12
+
3
13
  module CemAcpt
4
14
  # Namespace for all Runner-related classes and modules
5
15
  module TestRunner
6
- require_relative 'test_runner/run_handler'
7
- require_relative 'test_runner/runner'
8
- require_relative 'test_runner/runner_result'
16
+ # Holds all the Runner related code
17
+ class Runner
18
+ include CemAcpt::Logging
19
+
20
+ attr_reader :duration, :exit_code
21
+
22
+ def initialize(config)
23
+ @config = config
24
+ @run_data = {}
25
+ @duration = 0
26
+ @exit_code = 0
27
+ @results = nil
28
+ @http_statuses = []
29
+ end
30
+
31
+ def inspect
32
+ to_s
33
+ end
34
+
35
+ def to_s
36
+ "#<#{self.class.name}:0x#{object_id.to_s(16)}>"
37
+ end
38
+
39
+ def run
40
+ @run_data = {}
41
+ @start_time = Time.now
42
+ logger.info('CemAcpt') { "Starting CemAcpt v#{CemAcpt::VERSION}..." }
43
+ logger.info('CemAcpt') { "Test suite started at #{@start_time}..." }
44
+ logger.info('CemAcpt') { "Using module directory: #{config.get('module_dir')}..." }
45
+ Dir.chdir(config.get('module_dir')) do
46
+ keep_terminal_alive
47
+ @run_data[:priv_key], @run_data[:pub_key], @run_data[:known_hosts] = new_ephemeral_ssh_keys
48
+ logger.info('CemAcpt') { 'Created ephemeral SSH key pair...' }
49
+ @run_data[:module_package_path] = build_module_package
50
+ logger.info('CemAcpt') { "Created module package: #{@run_data[:module_package_path]}..." }
51
+ @run_data[:test_data] = new_test_data
52
+ logger.info('CemAcpt') { 'Created test data...' }
53
+ logger.verbose('CemAcpt') { "Test data: #{@run_data[:test_data]}" }
54
+ @run_data[:nodes] = new_node_data
55
+ logger.info('CemAcpt') { 'Created node data...' }
56
+ logger.verbose('CemAcpt') { "Node data: #{@run_data[:nodes]}" }
57
+ @instance_names_ips = provision_test_nodes
58
+ logger.info('CemAcpt') { 'Provisioned test nodes...' }
59
+ logger.debug('CemAcpt') { "Instance names and IPs: #{@instance_names_ips}" }
60
+ @results = run_tests(@instance_names_ips.map { |_, v| v['ip'] },
61
+ config.get('actions.only'),
62
+ config.get('actions.except'))
63
+ end
64
+ ensure
65
+ clean_up
66
+ process_test_results
67
+ end
68
+
69
+ def clean_up(trap_context = false)
70
+ unless trap_context
71
+ kill_keep_terminal_alive
72
+ end
73
+ clean_ephemeral_ssh_keys
74
+ destroy_test_nodes
75
+ end
76
+
77
+ private
78
+
79
+ attr_reader :config
80
+
81
+ # @return [Thread] The thread that keeps the terminal alive
82
+ def keep_terminal_alive
83
+ return unless config.ci?
84
+
85
+ @keep_terminal_alive ||= CemAcpt::Utils::Terminal.keep_terminal_alive
86
+ end
87
+
88
+ def kill_keep_terminal_alive
89
+ return if @trap_context
90
+
91
+ keep_terminal_alive&.kill
92
+ end
93
+
94
+ # @return [String] The path to the module package
95
+ def build_module_package
96
+ CemAcpt::Utils::Puppet.build_module_package(config.get('module_dir'))
97
+ end
98
+
99
+ # @return [Array<String>] The paths to the ssh private key, public key, and known hosts file
100
+ def new_ephemeral_ssh_keys
101
+ return [nil, nil, nil] if config.get('no_ephemeral_ssh_key')
102
+
103
+ CemAcpt::Utils::SSH::Ephemeral.create
104
+ end
105
+
106
+ def clean_ephemeral_ssh_keys
107
+ return if config.get('no_ephemeral_ssh_key')
108
+
109
+ CemAcpt::Utils::SSH::Ephemeral.clean
110
+ end
111
+
112
+ def new_test_data
113
+ CemAcpt::TestData.acceptance_test_data(config)
114
+ end
115
+
116
+ def new_node_data
117
+ CemAcpt::Platform.use(config.get('platform.name'), config, @run_data[:test_data])
118
+ end
119
+
120
+ def provision_test_nodes
121
+ @provisioner = CemAcpt::Provision.new_provisioner(config, @run_data)
122
+ @provisioner.provision
123
+ end
124
+
125
+ def destroy_test_nodes
126
+ @provisioner&.destroy
127
+ end
128
+
129
+ def run_tests(hosts, only_actions, except_actions)
130
+ only_actions = [] if only_actions.nil?
131
+ except_actions = [] if except_actions.nil?
132
+ CemAcpt::Goss::Api.run_actions_async(hosts, only: only_actions, except: except_actions)
133
+ end
134
+
135
+ def result_log_formatter
136
+ @result_log_formatter ||= LogFormatter::GossActionResponse.new(config, @instance_names_ips)
137
+ end
138
+
139
+ def process_test_results
140
+ if @results.nil?
141
+ logger.error('CemAcpt') { 'No test results to process' }
142
+ @exit_code = 1
143
+ else
144
+ until @results.empty?
145
+ result = @results.pop
146
+ @http_statuses << result.http_status
147
+ log_test_result(result)
148
+ end
149
+ if @http_statuses.empty?
150
+ logger.error('CemAcpt') { 'No test results to process' }
151
+ @exit_code = 1
152
+ else
153
+ @exit_code = @http_statuses.any? { |s| s.to_i != 200 } ? 1 : 0
154
+ end
155
+ end
156
+ @duration = Time.now - @start_time
157
+ logger.info('CemAcpt') { "Test suite finished after ~#{duration.round} seconds." }
158
+ end
159
+
160
+ def log_test_result(result)
161
+ logger.with_ci_group("Test results for #{result_log_formatter.test_name(result)}") do
162
+ logger.info(result_log_formatter.summary(result))
163
+ formatted_results = result_log_formatter.results(result)
164
+ formatted_results.each do |r|
165
+ if r.start_with?('Passed:')
166
+ logger.verbose { r }
167
+ elsif r.start_with?('Skipped:')
168
+ logger.info { r }
169
+ else
170
+ logger.error { r }
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
9
176
  end
10
177
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'puppet/modulebuilder'
4
+ require 'fileutils'
5
+
6
+ module CemAcpt
7
+ module Utils
8
+ # Puppet-related utilities
9
+ module Puppet
10
+ # Builds a Puppet module package.
11
+ # @param module_dir [String] Path to the module directory. If target_dir
12
+ # is specified as a relative path, it will be relative to the module dir.
13
+ # @param target_dir [String] Path to the target directory where the package
14
+ # will be built. This defaults to the relative path 'pkg/'.
15
+ # @param should_log [Boolean] Whether or not to log the build process.
16
+ # @return [String] Path to the built package.
17
+ def self.build_module_package(module_dir, target_dir = nil, should_log: false)
18
+ builder_logger = should_log ? logger : nil
19
+ builder = ::Puppet::Modulebuilder::Builder.new(::File.expand_path(module_dir), target_dir, builder_logger)
20
+
21
+ # Validates module metadata by raising exception if invalid
22
+ _metadata = builder.metadata
23
+
24
+ # Builds the module package
25
+ builder.build
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require_relative '../logging'
5
+
6
+ module CemAcpt
7
+ module Utils
8
+ # SSH-related utilities
9
+ module SSH
10
+ # Class for generating SSH keys
11
+ class Keygen
12
+ include CemAcpt::Logging
13
+
14
+ DEFAULT_TYPE = 'ed25519'
15
+ DEFAULT_PASSPHRASE = ''
16
+ DEFAULT_ROUNDS = 100
17
+ DEFAULT_BITS = 4096
18
+
19
+ def initialize(key_dir: ::File.join(ENV['HOME'], '.ssh'))
20
+ @key_dir = key_dir
21
+ @bin_path = find_bin_path
22
+ end
23
+
24
+ def exist?(key_name)
25
+ key_paths(key_name, chmod: false)
26
+ true
27
+ rescue StandardError
28
+ false
29
+ end
30
+
31
+ def create(key_name, **options)
32
+ delete(key_name) # Delete existing keys with same name
33
+ cmd = new_keygen_cmd(key_name, **options)
34
+ logger.debug("Creating SSH key with command: #{cmd}")
35
+ _stdout, stderr, status = Open3.capture3(cmd)
36
+ raise "Failed to create SSH key! #{stderr}" unless status.success?
37
+
38
+ key_paths(key_name)
39
+ end
40
+
41
+ def delete(key_name)
42
+ priv_key = key_path(key_name)
43
+ pub_key = key_path(key_name, public_key: true)
44
+ if ::File.file?(priv_key)
45
+ logger.debug("Deleting private key: #{priv_key}")
46
+ ::File.delete(priv_key)
47
+ end
48
+ if ::File.file?(pub_key)
49
+ logger.debug("Deleting public key: #{pub_key}")
50
+ ::File.delete(pub_key)
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ FIND_BIN_PATH_COMMANDS = [
57
+ "#{ENV['SHELL']} -c 'command -v ssh-keygen'",
58
+ "#{ENV['SHELL']} -c 'which ssh-keygen'",
59
+ ].freeze
60
+
61
+ def find_bin_path(find_cmd = FIND_BIN_PATH_COMMANDS.first)
62
+ bin_path, stderr, status = Open3.capture3(find_cmd)
63
+ raise "Cannot find ssh-keygen with command #{find_cmd}: #{stderr}" unless status.success?
64
+
65
+ bin_path.chomp
66
+ rescue StandardError => e
67
+ return find_bin_path(FIND_BIN_PATH_COMMANDS.last) unless FIND_BIN_PATH_COMMANDS.last == find_cmd
68
+
69
+ raise e
70
+ end
71
+
72
+ def new_keygen_cmd(key_name, **options)
73
+ [
74
+ @bin_path,
75
+ '-o',
76
+ "-t #{options[:type] || DEFAULT_TYPE}",
77
+ "-C '#{options[:comment] || "cem_acpt_#{key_name}"}'",
78
+ "-f #{::File.join(@key_dir, key_name)}",
79
+ "-N '#{options[:passphrase] || DEFAULT_PASSPHRASE}'",
80
+ "-a #{options[:rounds] || DEFAULT_ROUNDS}",
81
+ "-b #{options[:bits] || DEFAULT_BITS}",
82
+ ].join(' ')
83
+ end
84
+
85
+ def key_path(file_name, public_key: false)
86
+ key = ::File.join(@key_dir, file_name)
87
+ public_key ? "#{key}.pub" : key
88
+ end
89
+
90
+ def key_paths(file_name, chmod: true)
91
+ priv_key = key_path(file_name)
92
+ pub_key = key_path(file_name, public_key: true)
93
+ raise "Private key file #{priv_key} does not exist" unless ::File.file?(priv_key)
94
+ raise "Public key file #{pub_key} does not exist" unless ::File.file?(pub_key)
95
+
96
+ ::File.chmod(0o600, priv_key) if chmod
97
+ ::File.chmod(0o600, pub_key) if chmod
98
+ [priv_key, pub_key]
99
+ end
100
+ end
101
+
102
+ def self.ssh_keygen
103
+ bin_path = `#{ENV['SHELL']} -c 'command -v ssh-keygen'`.chomp
104
+ raise 'Cannot find ssh-keygen! Install it and verify PATH' unless bin_path
105
+
106
+ bin_path
107
+ rescue StandardError => e
108
+ raise "Cannot find ssh-keygen! Install it and verify PATH. Orignal error: #{e}"
109
+ end
110
+
111
+ def self.default_keydir
112
+ ssh_dir = ::File.join(ENV['HOME'], '.ssh')
113
+ raise "SSH directory at #{ssh_dir} does not exist" unless ::File.directory?(ssh_dir)
114
+
115
+ ssh_dir
116
+ end
117
+
118
+ def self.file_path(file_name, keydir: default_keydir)
119
+ ::File.join(keydir, file_name)
120
+ end
121
+
122
+ # Takes a file name (not path) and optional SSH key directory and returns the paths
123
+ # to the private key and public key based on the file name given.
124
+ # @param file_name [String] The base name for the keys
125
+ # @param keydir [String] An optional SSH key directory
126
+ def self.key_paths(file_name, keydir: default_keydir)
127
+ [file_path(file_name, keydir: keydir), file_path("#{file_name}.pub", keydir: keydir)]
128
+ end
129
+
130
+ def self.create(key_name, **options)
131
+ keygen = Keygen.new
132
+ keys = keygen.create(key_name, **options)
133
+ keys + ['/dev/null']
134
+ end
135
+
136
+ def self.create_known_hosts(known_hosts, overwrite: true, keydir: default_keydir)
137
+ return nil unless known_hosts
138
+
139
+ kh_file = file_path(known_hosts, keydir: keydir)
140
+ ::File.open(kh_file, 'w') { |f| f.write("\n") } unless ::File.exist?(kh_file) && !overwrite
141
+ kh_file
142
+ end
143
+
144
+ def self.set_ssh_file_permissions(*files)
145
+ files.uniq.compact.map { |p| ::File.chmod(0o600, p) }
146
+ end
147
+
148
+ def self.ephemeral_ssh_key(keydir: default_keydir)
149
+ CemAcpt::Utils::SSH::Ephemeral.create(keydir: keydir)
150
+ end
151
+
152
+ def self.clean_ephemeral_keys
153
+ CemAcpt::Utils::SSH::Ephemeral.clean
154
+ end
155
+
156
+ # Ephemeral SSH key generation and cleanup
157
+ module Ephemeral
158
+ PRIV_KEY = 'acpt_test_key'
159
+ CREATE_OPTS = {
160
+ type: 'ed25519',
161
+ bits: '4096',
162
+ rounds: '100',
163
+ comment: 'Ephemeral for cem_acpt',
164
+ password: '',
165
+ known_hosts: 'acpt_known_hosts',
166
+ overwrite_known_hosts: true,
167
+ }.freeze
168
+
169
+ class << self
170
+ attr_accessor :ephemeral_keydir
171
+ end
172
+
173
+ def self.create(keydir: CemAcpt::Utils::SSH.default_keydir)
174
+ return [false, false, false] if ENV['CEM_ACPT_SSH_PRI_KEY'] # Don't create ephemeral keys if this is set
175
+
176
+ self.ephemeral_keydir = keydir
177
+ @priv_key, @pub_key, @known_hosts = CemAcpt::Utils::SSH.create(PRIV_KEY, keydir: ephemeral_keydir, **CREATE_OPTS)
178
+ [@priv_key, @pub_key, @known_hosts]
179
+ end
180
+
181
+ def self.clean
182
+ return if ENV['CEM_ACPT_SSH_PRI_KEY']
183
+
184
+ [@priv_key, @pub_key, @known_hosts].each_with_object([]) do |f, arr|
185
+ next unless f
186
+
187
+ path = CemAcpt::Utils::SSH.file_path(f, keydir: ephemeral_keydir)
188
+ if ::File.exist?(path)
189
+ ::File.delete(path)
190
+ arr << path
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent-ruby'
4
+
5
+ module CemAcpt
6
+ module Utils
7
+ # Terminal-related utilities
8
+ module Terminal
9
+ def self.keep_terminal_alive
10
+ executor = Concurrent::SingleThreadExecutor.new
11
+ executor.post do
12
+ loop do
13
+ $stdout.print(".\r")
14
+ sleep(1)
15
+ $stdout.print("..\r")
16
+ sleep(1)
17
+ $stdout.print("...\r")
18
+ sleep(1)
19
+ $stdout.print(" \r")
20
+ sleep(1)
21
+ end
22
+ end
23
+ executor
24
+ end
25
+ end
26
+ end
27
+ end