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.
- checksums.yaml +4 -4
- data/.github/workflows/spec.yml +38 -0
- data/Gemfile +4 -3
- data/Gemfile.lock +85 -56
- data/README.md +144 -83
- data/cem_acpt.gemspec +8 -7
- data/exe/cem_acpt +41 -7
- data/lib/cem_acpt/config.rb +345 -0
- data/lib/cem_acpt/core_extensions.rb +17 -61
- data/lib/cem_acpt/goss/api/action_response.rb +175 -0
- data/lib/cem_acpt/goss/api.rb +83 -0
- data/lib/cem_acpt/goss.rb +8 -0
- data/lib/cem_acpt/image_name_builder.rb +0 -9
- data/lib/cem_acpt/logging/formatter.rb +97 -0
- data/lib/cem_acpt/logging.rb +168 -142
- data/lib/cem_acpt/platform/base.rb +26 -37
- data/lib/cem_acpt/platform/gcp.rb +48 -62
- data/lib/cem_acpt/platform.rb +30 -28
- data/lib/cem_acpt/provision/terraform/linux.rb +47 -0
- data/lib/cem_acpt/provision/terraform/os_data.rb +72 -0
- data/lib/cem_acpt/provision/terraform/windows.rb +22 -0
- data/lib/cem_acpt/provision/terraform.rb +193 -0
- data/lib/cem_acpt/provision.rb +20 -0
- data/lib/cem_acpt/puppet_helpers.rb +0 -1
- data/lib/cem_acpt/test_data.rb +23 -13
- data/lib/cem_acpt/test_runner/log_formatter/goss_action_response.rb +104 -0
- data/lib/cem_acpt/test_runner/log_formatter.rb +10 -0
- data/lib/cem_acpt/test_runner.rb +170 -3
- data/lib/cem_acpt/utils/puppet.rb +29 -0
- data/lib/cem_acpt/utils/ssh.rb +197 -0
- data/lib/cem_acpt/utils/terminal.rb +27 -0
- data/lib/cem_acpt/utils.rb +4 -138
- data/lib/cem_acpt/version.rb +1 -1
- data/lib/cem_acpt.rb +70 -20
- data/lib/terraform/gcp/linux/goss/puppet_idempotent.yaml +10 -0
- data/lib/terraform/gcp/linux/goss/puppet_noop.yaml +12 -0
- data/lib/terraform/gcp/linux/main.tf +191 -0
- data/lib/terraform/gcp/linux/systemd/goss-acpt.service +8 -0
- data/lib/terraform/gcp/linux/systemd/goss-idempotent.service +8 -0
- data/lib/terraform/gcp/linux/systemd/goss-noop.service +8 -0
- data/lib/terraform/gcp/windows/.keep +0 -0
- data/sample_config.yaml +22 -21
- metadata +88 -56
- data/lib/cem_acpt/bootstrap/bootstrapper.rb +0 -206
- data/lib/cem_acpt/bootstrap/operating_system/rhel_family.rb +0 -129
- data/lib/cem_acpt/bootstrap/operating_system.rb +0 -17
- data/lib/cem_acpt/bootstrap.rb +0 -12
- data/lib/cem_acpt/context.rb +0 -153
- data/lib/cem_acpt/platform/base/cmd.rb +0 -71
- data/lib/cem_acpt/platform/gcp/cmd.rb +0 -345
- data/lib/cem_acpt/platform/gcp/compute.rb +0 -332
- data/lib/cem_acpt/platform/vmpooler.rb +0 -24
- data/lib/cem_acpt/rspec_utils.rb +0 -242
- data/lib/cem_acpt/shared_objects.rb +0 -537
- data/lib/cem_acpt/spec_helper_acceptance.rb +0 -184
- data/lib/cem_acpt/test_runner/run_handler.rb +0 -187
- data/lib/cem_acpt/test_runner/runner.rb +0 -210
- data/lib/cem_acpt/test_runner/runner_result.rb +0 -103
data/lib/cem_acpt/test_data.rb
CHANGED
@@ -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 :
|
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
|
-
@
|
29
|
-
|
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
|
-
|
37
|
-
|
38
|
-
|
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
|
-
|
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
|
60
|
-
|
61
|
-
raise 'No acceptance tests found' if
|
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 #{
|
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
|
data/lib/cem_acpt/test_runner.rb
CHANGED
@@ -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
|
-
|
7
|
-
|
8
|
-
|
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
|