cem_acpt 0.2.5 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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