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
@@ -1,332 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module CemAcpt::Platform::Gcp
|
4
|
-
require_relative 'cmd'
|
5
|
-
require_relative File.join(__dir__, '..', '..', 'logging.rb')
|
6
|
-
|
7
|
-
module Helper
|
8
|
-
def add_cmd(cmd_obj)
|
9
|
-
@cmd = cmd_obj
|
10
|
-
end
|
11
|
-
|
12
|
-
def cmd
|
13
|
-
@cmd ||= CemAcpt::Platform::Gcp::Cmd.new(out_format: 'json')
|
14
|
-
end
|
15
|
-
|
16
|
-
def cmd_flag_vars?(*vars)
|
17
|
-
vars.none? { |v| v.nil? || (v.empty? if v.respond_to?(:empty?)) }
|
18
|
-
end
|
19
|
-
end
|
20
|
-
|
21
|
-
# This class represents a GCP project
|
22
|
-
class Project
|
23
|
-
include Helper
|
24
|
-
|
25
|
-
def initialize(name: nil, zone: nil)
|
26
|
-
@name = name
|
27
|
-
@zone = zone
|
28
|
-
end
|
29
|
-
|
30
|
-
def name
|
31
|
-
@name ||= cmd.project_from_config
|
32
|
-
end
|
33
|
-
|
34
|
-
def zone
|
35
|
-
@zone ||= cmd.zone_from_config
|
36
|
-
end
|
37
|
-
|
38
|
-
def cmd_flag
|
39
|
-
"--project=#{name}"
|
40
|
-
end
|
41
|
-
end
|
42
|
-
|
43
|
-
# This class represents a GCP VM service account
|
44
|
-
class ServiceAccount
|
45
|
-
include Helper
|
46
|
-
|
47
|
-
attr_reader :name, :scopes
|
48
|
-
|
49
|
-
def initialize(name: nil, scopes: ['devstorage.read_only', 'logging.write', 'monitoring.write', 'servicecontrol', 'service.management.readonly', 'trace.append' ])
|
50
|
-
@name = name
|
51
|
-
@scopes = scopes.map { |s| "https://www.googleapis.com/auth/#{s}" }
|
52
|
-
end
|
53
|
-
|
54
|
-
def cmd_flag
|
55
|
-
"--service-account=#{name} --scopes=#{scopes.join(',')}" if cmd_flag_vars?(name, scopes)
|
56
|
-
end
|
57
|
-
end
|
58
|
-
|
59
|
-
# This class represents a GCP VM disk
|
60
|
-
class Disk
|
61
|
-
include Helper
|
62
|
-
|
63
|
-
attr_reader :project, :name, :size, :image_name
|
64
|
-
|
65
|
-
def initialize(project: nil, name: 'disk-1', image_name: '', size: 20, type: 'pd-standard')
|
66
|
-
@project = project
|
67
|
-
@name = name
|
68
|
-
@image_name = image_name
|
69
|
-
@size = size
|
70
|
-
@type = type
|
71
|
-
end
|
72
|
-
|
73
|
-
def cmd_flag
|
74
|
-
return unless cmd_flag_vars?(name, image, size, type)
|
75
|
-
|
76
|
-
'--create-disk=auto-delete=yes,boot=yes,' \
|
77
|
-
"device-name=#{name},image=#{image}," \
|
78
|
-
"mode=rw,size=#{size},type=#{type}"
|
79
|
-
end
|
80
|
-
|
81
|
-
def type
|
82
|
-
type_path(@type)
|
83
|
-
end
|
84
|
-
|
85
|
-
def image
|
86
|
-
@image ||= image_path(@image_name)
|
87
|
-
end
|
88
|
-
|
89
|
-
private
|
90
|
-
|
91
|
-
def image_path(image_str)
|
92
|
-
raise ArgumentError, 'image must be a non-empty string' unless image_str.is_a?(String) && !image_str.empty?
|
93
|
-
|
94
|
-
data = cmd.local_exec('compute images list', out_filter: "name ~ '#{image_str}$'").first
|
95
|
-
if data.nil? || data.empty?
|
96
|
-
data = cmd.local_exec('compute images list', out_filter: "family ~ '#{image_str}$'").first
|
97
|
-
end
|
98
|
-
raise "Image #{image_str} not found" unless data
|
99
|
-
raise "Image status invalid for image #{data['selfLink']}: #{data[status]}" unless data['status'] == 'READY'
|
100
|
-
|
101
|
-
data['selfLink'].split('v1/')[-1]
|
102
|
-
end
|
103
|
-
|
104
|
-
def type_path(type)
|
105
|
-
raise ArgumentError, 'type must be a non-empty string' unless type.is_a?(String) && !type.empty?
|
106
|
-
|
107
|
-
"projects/#{project.name}/zones/#{project.zone}/diskTypes/#{type}"
|
108
|
-
end
|
109
|
-
end
|
110
|
-
|
111
|
-
# This class represents a GCP VM network interface
|
112
|
-
class NetworkInterface
|
113
|
-
include Helper
|
114
|
-
|
115
|
-
attr_reader :tier, :subnetwork
|
116
|
-
|
117
|
-
def initialize(tier: 'STANDARD', subnetwork: 'default')
|
118
|
-
@tier = tier.upcase
|
119
|
-
@subnetwork = subnetwork
|
120
|
-
end
|
121
|
-
|
122
|
-
def cmd_flag
|
123
|
-
"--network-interface=network-tier=#{tier},subnet=#{subnetwork}" if cmd_flag_vars?(tier, subnetwork)
|
124
|
-
end
|
125
|
-
end
|
126
|
-
|
127
|
-
# This class represents GCP VM metadata
|
128
|
-
class Metadata
|
129
|
-
include Helper
|
130
|
-
|
131
|
-
def initialize(**data)
|
132
|
-
@data = data.transform_keys(&:to_s)
|
133
|
-
end
|
134
|
-
|
135
|
-
def data
|
136
|
-
format_data(@data)
|
137
|
-
end
|
138
|
-
|
139
|
-
def cmd_flag
|
140
|
-
return unless cmd_flag_vars?(data)
|
141
|
-
|
142
|
-
data_from_file = data.select { |k, _v| k == 'ssh-keys' }.map { |k, v| "#{k}=#{v}" }.join(',')
|
143
|
-
normalized_data = data.reject { |k, _v| k == 'ssh-keys' }.map { |k, v| "#{k}=#{v}" }.join(',')
|
144
|
-
cmd = []
|
145
|
-
cmd << "--metadata-from-file=#{data_from_file}" unless data_from_file.empty?
|
146
|
-
cmd << "--metadata=#{normalized_data}" unless normalized_data.empty?
|
147
|
-
cmd.join(' ')
|
148
|
-
end
|
149
|
-
|
150
|
-
private
|
151
|
-
|
152
|
-
def format_data(data)
|
153
|
-
default_data = { 'enable-oslogin' => 'TRUE' }
|
154
|
-
if data['ephemeral_ssh_key']
|
155
|
-
args = {
|
156
|
-
username: (data.dig('ephemeral_ssh_key', 'username') || cmd.user_name),
|
157
|
-
}
|
158
|
-
args[:lifetime] = data.dig('ephemeral_ssh_key', 'lifetime') if data.dig('ephemeral_ssh_key', 'lifetime')
|
159
|
-
args[:keydir] = data.dig('ephemeral_ssh_key', 'keydir') if data.dig('ephemeral_ssh_key', 'keydir')
|
160
|
-
priv_key_path, ssh_keys = ephemeral_ssh_key(**args)
|
161
|
-
default_data['priv_key_local_path'] = priv_key_path
|
162
|
-
default_data['ssh-keys'] = ssh_keys
|
163
|
-
data.delete('ephemeral_ssh_key')
|
164
|
-
end
|
165
|
-
default_data.merge(data)
|
166
|
-
end
|
167
|
-
|
168
|
-
def ephemeral_ssh_key(username: nil, lifetime: 2, keydir: '/tmp/acpt_ssh_keys')
|
169
|
-
raise ArgumentError, 'username must be a non-empty string' unless username.is_a?(String) && !username.empty?
|
170
|
-
|
171
|
-
keypath = File.join(keydir, SecureRandom.hex(10))
|
172
|
-
Dir.mkdir(keydir) unless Dir.exist?(keydir)
|
173
|
-
lifetime_in_seconds = (lifetime.to_i * 60 * 60)
|
174
|
-
iso8601_expiration = (Time.now.utc + lifetime_in_seconds).strftime('%Y-%m-%dT%H:%M:%S%z')
|
175
|
-
keygen_cmd = [
|
176
|
-
'ssh-keygen',
|
177
|
-
'-t ecdsa',
|
178
|
-
'-b 521',
|
179
|
-
"-C 'google-ssh {\"userName\":\"#{username}\",\"expireOn\":\"#{iso8601_expiration}\"}'",
|
180
|
-
"-f #{keypath}",
|
181
|
-
'-N ""',
|
182
|
-
'-q',
|
183
|
-
].join(' ')
|
184
|
-
_, stderr, status = Open3.capture3(keygen_cmd)
|
185
|
-
raise stderr unless status.success?
|
186
|
-
|
187
|
-
File.write("#{keypath}.pub", "#{username}:#{File.read("#{keypath}.pub")}")
|
188
|
-
[keypath, "#{keypath}.pub"]
|
189
|
-
rescue StandardError => e
|
190
|
-
raise "Failed to generate ephemeral SSH key: #{e} #{e.backtrace}"
|
191
|
-
end
|
192
|
-
end
|
193
|
-
|
194
|
-
# This class represents a GCP VM. It is composed of various component classes.
|
195
|
-
class VM
|
196
|
-
include Helper
|
197
|
-
include CemAcpt::Logging
|
198
|
-
|
199
|
-
attr_accessor :name, :project, :service_account, :disk, :network_interface, :machine_type, :metadata
|
200
|
-
attr_reader :cmd
|
201
|
-
|
202
|
-
def initialize(name, components: {})
|
203
|
-
@name = name
|
204
|
-
@components = components
|
205
|
-
@machine_type = components[:machine_type]
|
206
|
-
@cmd = CemAcpt::Platform::Gcp::Cmd.new(
|
207
|
-
project: components[:project][:name],
|
208
|
-
zone: components[:project][:zone],
|
209
|
-
out_format: 'json',
|
210
|
-
local_port: components[:local_port],
|
211
|
-
)
|
212
|
-
@configured = false
|
213
|
-
end
|
214
|
-
|
215
|
-
def configure!
|
216
|
-
return @configured if @configured
|
217
|
-
|
218
|
-
@project = Project.new(**@components[:project])
|
219
|
-
@project.add_cmd(@cmd)
|
220
|
-
@service_account = @components.key?(:service_account) ? ServiceAccount.new(**@components[:service_account]) : nil
|
221
|
-
@disk = Disk.new(project: @project, **@components[:disk])
|
222
|
-
@disk.add_cmd(@cmd)
|
223
|
-
@network_interface = NetworkInterface.new(**@components[:network_interface])
|
224
|
-
@machine_type = @components[:machine_type]
|
225
|
-
@metadata = Metadata.new(**@components[:metadata])
|
226
|
-
@metadata.add_cmd(@cmd)
|
227
|
-
@configured = true
|
228
|
-
end
|
229
|
-
|
230
|
-
def info
|
231
|
-
describe_cmd = "compute instances describe #{name}"
|
232
|
-
logger.debug("Gathering info for VM #{name} with gcloud command: #{describe_cmd}")
|
233
|
-
data = @cmd.local_exec(describe_cmd, out_format: 'json')
|
234
|
-
opts = @cmd.ssh_opts(instance_name: name)
|
235
|
-
{
|
236
|
-
node_data: data,
|
237
|
-
transport: :ssh,
|
238
|
-
ssh_opts: opts,
|
239
|
-
}
|
240
|
-
end
|
241
|
-
|
242
|
-
def create
|
243
|
-
# Add the test ssh key to os-login
|
244
|
-
logger.debug("Adding test SSH key to os-login for #{name}")
|
245
|
-
@cmd.local_exec("compute os-login ssh-keys add --key-file #{@cmd.ssh_key}.pub --project #{project.name} --ttl 4h")
|
246
|
-
@cmd.local_exec(create_cmd)
|
247
|
-
rescue StandardError => e
|
248
|
-
raise "Failed to create VM #{name} with command #{create_cmd}: #{e}"
|
249
|
-
end
|
250
|
-
|
251
|
-
def ready?
|
252
|
-
logger.debug("Checking if VM #{name} is ready")
|
253
|
-
instance_status = @cmd.local_exec('compute instances list', out_filter: "NAME = #{name}").first['status']
|
254
|
-
logger.debug("Instance #{name} status: #{instance_status}")
|
255
|
-
return false unless instance_status == 'RUNNING'
|
256
|
-
|
257
|
-
logger.debug("Checking instance #{name} SSH connectivity")
|
258
|
-
@cmd.ssh_ready?(name)
|
259
|
-
rescue StandardError, Exception
|
260
|
-
false
|
261
|
-
end
|
262
|
-
|
263
|
-
def destroy
|
264
|
-
@cmd.delete_instance(name)
|
265
|
-
end
|
266
|
-
|
267
|
-
def install_puppet_module_package(module_pkg_path, remote_path, puppet_path)
|
268
|
-
@cmd.scp_upload(@name, module_pkg_path, remote_path)
|
269
|
-
logger.info("Uploaded module package #{module_pkg_path} to #{remote_path} on #{@name}")
|
270
|
-
@cmd.ssh(@name, "sudo #{puppet_path} module install #{remote_path}")
|
271
|
-
logger.info("Installed module package #{remote_path} on #{@name}")
|
272
|
-
end
|
273
|
-
|
274
|
-
private
|
275
|
-
|
276
|
-
# Determines whether the given instance name is a fully qualified
|
277
|
-
# hostname or not.
|
278
|
-
def hostname?(instance_name)
|
279
|
-
instance_name.include?('.')
|
280
|
-
end
|
281
|
-
|
282
|
-
# Validates a GCP instance name.
|
283
|
-
def validate_instance_name(instance_name)
|
284
|
-
if instance_name.nil? || !instance_name.is_a?(String) || instance_name.empty?
|
285
|
-
raise ArgumentError, 'Instance name must be a non-empty string'
|
286
|
-
end
|
287
|
-
raise ArgumentError, 'Instance name must be less than 253 characters' if instance_name.length > 253
|
288
|
-
|
289
|
-
return true unless hostname?(instance_name)
|
290
|
-
|
291
|
-
labels = instance_name.split('.')
|
292
|
-
labels.each do |label|
|
293
|
-
unless label.match?(%r{^[a-z]([-a-z0-9]*[a-z0-9])?$})
|
294
|
-
raise ArgumentError, "Instance name portion #{label} contains invalid characters"
|
295
|
-
end
|
296
|
-
raise ArgumentError, "Instance name portion #{label} is too long" if label.length > 63
|
297
|
-
end
|
298
|
-
true
|
299
|
-
end
|
300
|
-
|
301
|
-
def create_cmd
|
302
|
-
validate_instance_name(name)
|
303
|
-
if hostname?(name)
|
304
|
-
instance_name = name.split('.')[0]
|
305
|
-
hostname = name
|
306
|
-
else
|
307
|
-
instance_name = name
|
308
|
-
hostname = nil
|
309
|
-
end
|
310
|
-
|
311
|
-
cmd = [
|
312
|
-
'compute',
|
313
|
-
'instances',
|
314
|
-
'create',
|
315
|
-
instance_name,
|
316
|
-
'--async',
|
317
|
-
"--machine-type=#{machine_type}",
|
318
|
-
'--maintenance-policy=MIGRATE',
|
319
|
-
'--no-shielded-secure-boot',
|
320
|
-
'--shielded-vtpm',
|
321
|
-
'--shielded-integrity-monitoring',
|
322
|
-
'--reservation-affinity=any',
|
323
|
-
'--tags=acpt-test-node',
|
324
|
-
]
|
325
|
-
[disk&.cmd_flag, network_interface&.cmd_flag, metadata&.cmd_flag, service_account&.cmd_flag].each do |flag|
|
326
|
-
cmd << flag unless flag.nil?
|
327
|
-
end
|
328
|
-
cmd << "--hostname=#{hostname}" unless hostname.nil?
|
329
|
-
cmd.join(' ')
|
330
|
-
end
|
331
|
-
end
|
332
|
-
end
|
@@ -1,24 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
# Currently a placeholder for future functionality
|
4
|
-
module Platform
|
5
|
-
def node
|
6
|
-
logger.debug 'VMPooler node'
|
7
|
-
end
|
8
|
-
|
9
|
-
def provision
|
10
|
-
logger.debug 'VMPooler provision'
|
11
|
-
end
|
12
|
-
|
13
|
-
def destroy
|
14
|
-
logger.debug 'VMPooler destroy'
|
15
|
-
end
|
16
|
-
|
17
|
-
def ready?
|
18
|
-
logger.debug 'VMPooler ready?'
|
19
|
-
end
|
20
|
-
|
21
|
-
def install_puppet_module_package
|
22
|
-
logger.debug 'VMPooler install_puppet_module_package'
|
23
|
-
end
|
24
|
-
end
|
data/lib/cem_acpt/rspec_utils.rb
DELETED
@@ -1,242 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require 'English'
|
4
|
-
require 'open3'
|
5
|
-
require 'pty'
|
6
|
-
require 'shellwords'
|
7
|
-
require_relative 'logging'
|
8
|
-
|
9
|
-
module CemAcpt
|
10
|
-
# Module that provides methods and objects for running and managing RSpec
|
11
|
-
module RSpecUtils
|
12
|
-
class BundlerNotFoundError < StandardError; end
|
13
|
-
class RSpecNotFoundError < StandardError; end
|
14
|
-
|
15
|
-
# Holds and formats a RSpec command
|
16
|
-
class Command
|
17
|
-
include CemAcpt::LoggingAsync
|
18
|
-
attr_reader :debug, :format, :test_path, :use_bundler, :pty_pid
|
19
|
-
|
20
|
-
# @param opts [Hash] options hash for the RSpec command
|
21
|
-
# @option opts [String] :test_path The path (or glob path) to the test file(s) to run. If blank, runs all.
|
22
|
-
# @option opts [Hash] :format Format options for rspec where the key is the format (documentation, json, etc)
|
23
|
-
# and the value is the out-file path. If you do not want to save the results of a format to a file, the
|
24
|
-
# value should be `nil`.
|
25
|
-
# @option opts [Boolean] :debug True if RSpec should run in debug mode, false if not. Mutually exclusive with
|
26
|
-
# `:quiet`. Default is `false`.
|
27
|
-
# @option opts [Boolean] :quiet True if no output should be logged from RSpec command, false if output should
|
28
|
-
# be logged. Mutually exclusive with `:debug`. Default is `false`.
|
29
|
-
# @option opts [Boolean] :use_bundler Whether or not `bundle exec` should be used to run the RSpec command.
|
30
|
-
# Default is `true`.
|
31
|
-
# @option opts [Boolean] :bundle_install Whether or not to run `bundle install` before the RSpec command
|
32
|
-
# if `use_bundler` is `true`.
|
33
|
-
# @option opts [Boolean] :use_shell Whether or not to add `$SHELL` as a prefix to the command
|
34
|
-
# @option opts [Hash] :env Environment variables to prepend to the command
|
35
|
-
def initialize(opts = {})
|
36
|
-
@test_path = opts[:test_path]&.shellescape
|
37
|
-
@format = opts.fetch(:format, {})
|
38
|
-
@debug = opts.fetch(:debug, false)
|
39
|
-
@quiet = @debug ? false : opts.fetch(:quiet, false)
|
40
|
-
@use_bundler = opts.fetch(:use_bundler, false)
|
41
|
-
@bundle_install = opts.fetch(:bundle_install, false)
|
42
|
-
@env = opts.fetch(:env, {})
|
43
|
-
@pty_pid = nil
|
44
|
-
validate_and_set_bin_paths(opts)
|
45
|
-
end
|
46
|
-
|
47
|
-
# Sets debug mode to `true`
|
48
|
-
def set_debug
|
49
|
-
@debug = true
|
50
|
-
if @quiet
|
51
|
-
async_debug('Setting :quiet to false because :debug is now true.')
|
52
|
-
@quiet = false
|
53
|
-
end
|
54
|
-
end
|
55
|
-
|
56
|
-
# Sets debug mode to `false`
|
57
|
-
def unset_debug
|
58
|
-
@debug = false
|
59
|
-
end
|
60
|
-
|
61
|
-
def quiet
|
62
|
-
@quiet && !debug
|
63
|
-
end
|
64
|
-
|
65
|
-
# Adds a new format to the RSpec command
|
66
|
-
# @param fmt [String] The name of the format (i.e. "documentation", "json", etc.)
|
67
|
-
# @param out [String] If specified, saves the specified format to a file at this path
|
68
|
-
def with_format(fmt, out: nil)
|
69
|
-
@format[fmt.to_sym] = out
|
70
|
-
end
|
71
|
-
|
72
|
-
# Environment variables that will be used for the RSpec command
|
73
|
-
# @return [Hash] A Hash of environment variables with each key pair being: <var name> => <var value>
|
74
|
-
def env
|
75
|
-
@debug ? @env.merge({ 'RSPEC_DEBUG' => 'true' }) : @env
|
76
|
-
end
|
77
|
-
|
78
|
-
# Returns an array representation of the RSpec command
|
79
|
-
def to_a
|
80
|
-
cmd = cmd_base.dup
|
81
|
-
cmd << test_path if test_path
|
82
|
-
format.each do |fmt, out|
|
83
|
-
cmd += ['--format', fmt.to_s.shellescape]
|
84
|
-
cmd += ['--out', out.to_s.shellescape] if out
|
85
|
-
end
|
86
|
-
cmd.compact
|
87
|
-
end
|
88
|
-
|
89
|
-
# Returns a string representation of the RSpec command
|
90
|
-
def to_s
|
91
|
-
to_a.join(' ')
|
92
|
-
end
|
93
|
-
|
94
|
-
# Executes the RSpec command on the current machine
|
95
|
-
# @param pty [Boolean] If true, execute command in a PTY. If false, execute command directly.
|
96
|
-
# @param log_prefix [String] A prefix to add to log messages generated while the command is running.
|
97
|
-
def execute(pty: true, log_prefix: 'RSPEC')
|
98
|
-
if pty
|
99
|
-
execute_pty(log_prefix: log_prefix)
|
100
|
-
else
|
101
|
-
execute_no_pty(log_prefix: log_prefix)
|
102
|
-
end
|
103
|
-
end
|
104
|
-
|
105
|
-
# Executes the RSpec command in a psuedo-terminal (PTY). First, it spawns a process
|
106
|
-
# for $SHELL, sets environment variables `export_envs`, then calls the current RSpec
|
107
|
-
# command in the shell and exits with the last exit code `$?`. Output is read from the
|
108
|
-
# RSpec command in near real-time in a blocking manner unless the `:quiet` option has
|
109
|
-
# been specified.
|
110
|
-
# @param log_prefix [String] A prefix to add to the log messages that are output from
|
111
|
-
# the RSpec command.
|
112
|
-
# @return [Integer] The exit code of the RSpec command
|
113
|
-
def execute_pty(log_prefix: 'RSPEC')
|
114
|
-
async_debug("Executing RSpec command '#{self}' in PTY...", log_prefix)
|
115
|
-
PTY.spawn(env, ENV['SHELL']) do |r, w, pid|
|
116
|
-
@pty_pid = pid
|
117
|
-
async_debug("Spawned RSpec PTY with PID #{@pty_pid}", log_prefix)
|
118
|
-
export_envs(w)
|
119
|
-
w.puts "#{self}; exit $?"
|
120
|
-
quiet ? wait_io(r) : read_io(r, log_prefix: log_prefix)
|
121
|
-
end
|
122
|
-
$CHILD_STATUS
|
123
|
-
end
|
124
|
-
|
125
|
-
# Executes the RSpec command using Open3.popen2e(). The output stream, which is both
|
126
|
-
# stderr and stdout, is read in real-time in a non-blocking manner.
|
127
|
-
# @param log_prefix [String] A prefix to add to the log messages that are output from
|
128
|
-
# the RSpec command.
|
129
|
-
# @return [Integer] The exit code of the RSpec command
|
130
|
-
def execute_no_pty(log_prefix: 'RSPEC')
|
131
|
-
async_info("Executing RSpec command '#{self}' with Open3.popen2e()...", log_prefix)
|
132
|
-
exit_status = nil
|
133
|
-
Open3.popen2e(env, to_s) do |stdin, std_out_err, wait_thr|
|
134
|
-
stdin.close
|
135
|
-
quiet ? wait_io(std_out_err) : read_io(std_out_err, log_prefix: log_prefix)
|
136
|
-
exit_status = wait_thr.value
|
137
|
-
end
|
138
|
-
exit_status
|
139
|
-
end
|
140
|
-
|
141
|
-
# Kills the PTY process with `SIGKILL` if the process exists
|
142
|
-
def kill_pty
|
143
|
-
Process.kill('KILL', @pty_pid) unless @pty_pid.nil?
|
144
|
-
rescue Errno::ESRCH
|
145
|
-
true
|
146
|
-
end
|
147
|
-
|
148
|
-
private
|
149
|
-
|
150
|
-
# Detects if the current Ruby context is JRuby
|
151
|
-
def jruby?
|
152
|
-
File.basename(RbConfig.ruby) == 'jruby'
|
153
|
-
end
|
154
|
-
|
155
|
-
# The base RSpec command
|
156
|
-
def cmd_base
|
157
|
-
use_bundler ? cmd_base_bundler : cmd_base_rspec
|
158
|
-
end
|
159
|
-
|
160
|
-
# The base RSpec command if `:use_bundler` is `true`.
|
161
|
-
def cmd_base_bundler
|
162
|
-
base = [@bundle, 'exec', 'rspec']
|
163
|
-
base.unshift("#{@bundle} install;") if @bundle_install
|
164
|
-
base
|
165
|
-
end
|
166
|
-
|
167
|
-
# The base RSpec command if `:use_bundler` is `false`
|
168
|
-
def cmd_base_rspec
|
169
|
-
[@rspec]
|
170
|
-
end
|
171
|
-
|
172
|
-
# Puts export statements for each key-value pair in `env` to the given writer.
|
173
|
-
# Writer is the write pipe of a PTY session, or a similar IO object that can
|
174
|
-
# pass the statements to a shell.
|
175
|
-
# @param writer [IO] An IO object that supprts `puts` and can send statements to a shell
|
176
|
-
def export_envs(writer)
|
177
|
-
env.each do |key, val|
|
178
|
-
writer.puts "export #{key}=#{val}"
|
179
|
-
end
|
180
|
-
end
|
181
|
-
|
182
|
-
# Finds and sets the paths to the `bundle` and `rspec` binaries. The paths can
|
183
|
-
# be either passed in as options in the `opts` Hash or interrogated from the
|
184
|
-
# system.
|
185
|
-
# @param opts [Hash] The options hash
|
186
|
-
# @option opts [String] :bundle An absolute path on the system to the `bundle` binary.
|
187
|
-
# @option opts [String] :rspec An absolute path on the system to the `rspec` binary.
|
188
|
-
# @raise [CemAcpt::RSpecUtils::BundlerNotFoundError] if `@use_bundler` is true and
|
189
|
-
# `bundle` binary is not found.
|
190
|
-
# @raise [CemAcpt::RSpecUtils::RSpecNotFoundError] if `rspec` binary is not found.
|
191
|
-
def validate_and_set_bin_paths(opts = {})
|
192
|
-
%i[bundle rspec].each do |bin|
|
193
|
-
bin_path = opts[bin] || `command -v #{bin}`.strip
|
194
|
-
bin_not_found(bin, bin_path) unless bin_path && File.exist?(bin_path)
|
195
|
-
instance_variable_set("@#{bin}", bin_path)
|
196
|
-
end
|
197
|
-
end
|
198
|
-
|
199
|
-
# Handles binary paths which are not found
|
200
|
-
# @param bin [Symbol] The binary that was not found, either :bundle or :rspec.
|
201
|
-
# @param bin_path [String] The path to the binary that was checked.
|
202
|
-
# @raise [CemAcpt::RSpecUtils::BundlerNotFoundError] if `@use_bundler` is true and
|
203
|
-
# `bundle` binary is not found.
|
204
|
-
# @raise [CemAcpt::RSpecUtils::RSpecNotFoundError] if `rspec` binary is not found.
|
205
|
-
# @raise [RuntimeError] if `bin` is not :bundle or :rspec.
|
206
|
-
def bin_not_found(bin, bin_path)
|
207
|
-
msg_base = "#{bin} not found."
|
208
|
-
msg = bin_path.nil? ? "#{msg_base} Path is nil." : "#{msg_base} Path: #{bin_path}"
|
209
|
-
case bin
|
210
|
-
when :bundle
|
211
|
-
raise BundlerNotFoundError, msg if @use_bundler
|
212
|
-
when :rspec
|
213
|
-
raise RSpecNotFoundError, msg
|
214
|
-
else
|
215
|
-
raise "bin #{bin} not recognized!"
|
216
|
-
end
|
217
|
-
end
|
218
|
-
|
219
|
-
# Blocking wait on an IO stream. Wait stops once the IO stream has reached
|
220
|
-
# end of file.
|
221
|
-
# @param stdout [IO] An IO stream with output that can be read from.
|
222
|
-
def wait_io(stdout)
|
223
|
-
sleep(0.01) until stdout.eof?
|
224
|
-
end
|
225
|
-
|
226
|
-
# Reads and logs data from `stdout` in a near real-time, blocking manner.
|
227
|
-
# @param stdout [IO] An IO stream with output that can be read from.
|
228
|
-
# @param log_prefix [String] A string prefix that is added to log messages.
|
229
|
-
def read_io(stdout, log_prefix: 'RSPEC')
|
230
|
-
loop do
|
231
|
-
chunk = stdout.read_nonblock(4096).strip
|
232
|
-
async_info(chunk, log_prefix) unless chunk.nil? || chunk.empty?
|
233
|
-
rescue IO::WaitReadable
|
234
|
-
IO.select([stdout])
|
235
|
-
retry
|
236
|
-
rescue EOFError
|
237
|
-
break
|
238
|
-
end
|
239
|
-
end
|
240
|
-
end
|
241
|
-
end
|
242
|
-
end
|