cem_acpt 0.11.0 → 0.11.2

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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +8 -0
  3. data/.worktreeinclude +1 -0
  4. data/CLAUDE.md +64 -25
  5. data/Gemfile.lock +1 -1
  6. data/README.md +20 -7
  7. data/docs/ARCHITECTURE.md +1042 -0
  8. data/docs/rfcs/0000-template.md +54 -0
  9. data/docs/rfcs/0001-fix-bolt-missing-skip-path.md +105 -0
  10. data/docs/rfcs/0002-fix-default-character-substitutions.md +119 -0
  11. data/docs/rfcs/0003-windows-image-builder-template.md +110 -0
  12. data/docs/rfcs/0004-image-name-truncation-off-by-one.md +108 -0
  13. data/docs/rfcs/0005-os-dispatch-replace-windows-heuristic.md +117 -0
  14. data/docs/rfcs/0006-configurable-windows-bucket.md +96 -0
  15. data/docs/rfcs/0007-logging-quiet-and-typos.md +121 -0
  16. data/docs/rfcs/0008-namespace-platform-classes.md +110 -0
  17. data/docs/rfcs/0009-bolt-log-formatter-cleanup.md +111 -0
  18. data/docs/rfcs/0010-dead-code-cleanup.md +83 -0
  19. data/docs/rfcs/0011-provisioner-factory-consistency.md +89 -0
  20. data/docs/rfcs/README.md +34 -0
  21. data/lib/cem_acpt/cli.rb +10 -1
  22. data/lib/cem_acpt/config/cem_acpt.rb +4 -1
  23. data/lib/cem_acpt/image_builder/errors.rb +24 -0
  24. data/lib/cem_acpt/image_builder/provision_commands.rb +15 -3
  25. data/lib/cem_acpt/image_builder.rb +29 -2
  26. data/lib/cem_acpt/image_name_builder.rb +8 -1
  27. data/lib/cem_acpt/platform/gcp.rb +112 -106
  28. data/lib/cem_acpt/platform.rb +21 -19
  29. data/lib/cem_acpt/provision/terraform/linux.rb +1 -1
  30. data/lib/cem_acpt/provision/terraform/os_data.rb +23 -0
  31. data/lib/cem_acpt/provision/terraform/windows.rb +7 -1
  32. data/lib/cem_acpt/provision/terraform.rb +20 -16
  33. data/lib/cem_acpt/test_runner/log_formatter/bolt_summary_results_formatter.rb +2 -1
  34. data/lib/cem_acpt/test_runner/log_formatter.rb +0 -1
  35. data/lib/cem_acpt/test_runner.rb +21 -8
  36. data/lib/cem_acpt/utils/winrm_runner.rb +4 -3
  37. data/lib/cem_acpt/utils.rb +0 -12
  38. data/lib/cem_acpt/version.rb +1 -1
  39. data/lib/cem_acpt.rb +19 -7
  40. data/lib/terraform/gcp/linux/main.tf +6 -1
  41. data/lib/terraform/image/gcp/linux/main.tf +8 -1
  42. data/specifications/CEM-6713.md +165 -0
  43. data/specifications/CEM-6714.md +271 -0
  44. data/specifications/CEM-6715.md +133 -0
  45. data/specifications/CEM-6716.md +160 -0
  46. data/specifications/CEM-6717.md +239 -0
  47. data/specifications/CEM-6718.md +120 -0
  48. data/specifications/CEM-6719.md +173 -0
  49. metadata +26 -11
  50. data/.claude/settings.local.json +0 -7
  51. data/lib/cem_acpt/action_result.rb +0 -91
  52. data/lib/cem_acpt/puppet_helpers.rb +0 -38
  53. data/lib/cem_acpt/test_runner/log_formatter/bolt_error_formatter.rb +0 -65
  54. data/lib/cem_acpt/test_runner/log_formatter/bolt_output_formatter.rb +0 -54
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CemAcpt
4
+ module ImageBuilder
5
+ # Raised when an OS-specific image-builder Terraform template
6
+ # (e.g. `lib/terraform/image/<platform>/windows/main.tf`) is missing
7
+ # but the user has configured at least one image for that OS. The
8
+ # builder catches this before invoking `terraform init` so users get
9
+ # an actionable message instead of Terraform's "no configuration
10
+ # files" output.
11
+ class MissingTemplateError < StandardError
12
+ attr_reader :os_str, :template_path
13
+
14
+ def initialize(os_str, template_path)
15
+ @os_str = os_str
16
+ @template_path = template_path
17
+ super(
18
+ "No #{os_str} image-builder Terraform template ships with this version of cem_acpt " \
19
+ "(expected #{template_path}). Either upgrade cem_acpt or pass --no-#{os_str}."
20
+ )
21
+ end
22
+ end
23
+ end
24
+ end
@@ -19,7 +19,11 @@ module CemAcpt
19
19
 
20
20
  def provision_commands
21
21
  commands_from_config = @config.get("images.#{@image_name}.provision_commands") || []
22
- (default_provision_commands + commands_from_config).compact
22
+ if @config.get("images.#{@image_name}.skip_default_provision_commands")
23
+ commands_from_config.compact
24
+ else
25
+ (default_provision_commands + commands_from_config).compact
26
+ end
23
27
  end
24
28
  alias to_a provision_commands
25
29
 
@@ -83,7 +87,11 @@ module CemAcpt
83
87
 
84
88
  def provision_commands
85
89
  commands_from_config = @config.get("images.#{@image_name}.provision_commands") || []
86
- (default_provision_commands + commands_from_config).compact
90
+ if @config.get("images.#{@image_name}.skip_default_provision_commands")
91
+ commands_from_config.compact
92
+ else
93
+ (default_provision_commands + commands_from_config).compact
94
+ end
87
95
  end
88
96
  alias to_a provision_commands
89
97
  end
@@ -103,7 +111,11 @@ module CemAcpt
103
111
 
104
112
  def provision_commands
105
113
  commands_from_config = @config.get("images.#{@image_name}.provision_commands") || []
106
- (default_provision_commands + commands_from_config).compact
114
+ if @config.get("images.#{@image_name}.skip_default_provision_commands")
115
+ commands_from_config.compact
116
+ else
117
+ (default_provision_commands + commands_from_config).compact
118
+ end
107
119
  end
108
120
  alias to_a provision_commands
109
121
 
@@ -7,6 +7,7 @@ require_relative 'platform'
7
7
  require_relative 'utils'
8
8
  require_relative 'version'
9
9
  require_relative 'provision/terraform/terraform_cmd'
10
+ require_relative 'image_builder/errors'
10
11
  require_relative 'image_builder/exec'
11
12
  require_relative 'image_builder/provision_commands'
12
13
 
@@ -35,6 +36,10 @@ module CemAcpt
35
36
  class TerraformBuilder
36
37
  DEFAULT_PLAN_NAME = 'testplan.tfplan'
37
38
  DEFAULT_VARS_FILE = 'imagevars.json'
39
+ # GCE image names must match RFC 1035 label rules: 1-63 chars, lowercase
40
+ # alphanumeric and dashes, no trailing dash. See
41
+ # https://cloud.google.com/compute/docs/reference/rest/v1/images.
42
+ GCE_IMAGE_NAME_MAX = 63
38
43
  include CemAcpt::Logging
39
44
 
40
45
  attr_reader :exit_code, :duration
@@ -64,6 +69,7 @@ module CemAcpt
64
69
  if image_types.empty?
65
70
  raise 'No images to build. Ensure the images config is populated and that --filter (if used) matches at least one image.'
66
71
  end
72
+ image_types.each { |_, os_str| assert_template_present!(os_str) }
67
73
  return dry_run(image_types) if @config.get('dry_run')
68
74
 
69
75
  @working_dir = new_working_dir
@@ -80,7 +86,7 @@ module CemAcpt
80
86
  output.each do |instance_name, data|
81
87
  unless @config.get('no_destroy_nodes')
82
88
  logger.info('CemAcpt::ImageBuilder') { "Stopping instance #{instance_name}..." }
83
- @exec.run('compute', 'instances', 'stop', instance_name)
89
+ @exec.run('compute', 'instances', 'stop', instance_name, '--zone', @config.get('platform.zone'))
84
90
  end
85
91
  unless @config.get('no_build_images')
86
92
  deprecate_old_images_in_family(data['image_family'])
@@ -109,8 +115,18 @@ module CemAcpt
109
115
 
110
116
  attr_reader :environment
111
117
 
118
+ # Builds a GCE-safe image name from an image family. The returned name
119
+ # is at most GCE_IMAGE_NAME_MAX characters long, preserves the
120
+ # `-v<unix_timestamp>` suffix verbatim (so consecutive builds in the
121
+ # same family cannot collide on the truncated name), and never ends in
122
+ # a dash.
112
123
  def image_name_from_image_family(image_family)
113
- "#{image_family}-v#{@start_time.to_i}"[0..64]
124
+ suffix = "-v#{@start_time.to_i}"
125
+ full = "#{image_family}#{suffix}"
126
+ return full if full.length <= GCE_IMAGE_NAME_MAX
127
+
128
+ prefix = image_family[0, GCE_IMAGE_NAME_MAX - suffix.length]
129
+ prefix.sub(/-+\z/, '') + suffix
114
130
  end
115
131
 
116
132
  def deprecate_old_images_in_family(image_family)
@@ -131,6 +147,17 @@ module CemAcpt
131
147
  logger.info('CemAcpt::ImageBuilder') { "Image #{image_name} created for family #{image_family}"}
132
148
  end
133
149
 
150
+ # Raises MissingTemplateError if the OS bucket has images configured but
151
+ # no `main.tf` ships under `lib/terraform/image/<platform>/<os_str>/`.
152
+ # Without this guard, `terraform init` later fails with an unhelpful
153
+ # "no configuration files" message.
154
+ def assert_template_present!(os_str)
155
+ template_path = File.join(@image_terraform_dir, os_str, 'main.tf')
156
+ return if File.exist?(template_path)
157
+
158
+ raise MissingTemplateError.new(os_str, template_path)
159
+ end
160
+
134
161
  def no_windows?
135
162
  @windows_tfvars[:node_data].empty? || @config.get('cem_acpt_image.no_windows')
136
163
  end
@@ -85,8 +85,15 @@ module CemAcpt
85
85
  def character_substitutions(name)
86
86
  return name unless @config[:character_substitutions]
87
87
 
88
+ subs = @config[:character_substitutions]
89
+ unless subs.is_a?(Array) && subs.all? { |s| s.is_a?(Array) && s.size == 2 }
90
+ raise ArgumentError,
91
+ 'image_name_builder.character_substitutions must be an array of 2-item arrays, ' \
92
+ "e.g. [['_', '-']]; got: #{subs.inspect}"
93
+ end
94
+
88
95
  subbed_name = name
89
- @config[:character_substitutions].each do |char_sub|
96
+ subs.each do |char_sub|
90
97
  subbed_name.gsub!(char_sub[0], char_sub[1])
91
98
  end
92
99
  subbed_name
@@ -4,112 +4,118 @@ require 'json'
4
4
  require 'open3'
5
5
 
6
6
  # GCP platform implementation
7
- module Platform
8
- def platform_data
9
- {
10
- username: gcp_username,
11
- credentials_file: gcp_credentials_file,
12
- project: gcp_project,
13
- region: gcp_region,
14
- zone: gcp_zone,
15
- subnetwork: gcp_subnetwork,
16
- private_key: gcp_private_key,
17
- public_key: gcp_public_key,
18
- }
19
- end
20
-
21
- def node_data
22
- nd = {}
23
- nd[:machine_type] = gcp_machine_type
24
- nd[:disk_size] = gcp_disk_size
25
- nd[:max_run_duration] = gcp_max_run_duration
26
- nd[:image] = image_name if image_name
27
- nd[:test_name] = @test_data[:test_name] if @test_data&.key?(:test_name)
28
- nd
29
- end
30
-
31
- def gcp_username
32
- return @gcp_username if @gcp_username
33
-
34
- @gcp_username = @config.get('platform.username')
35
- return @gcp_username if @gcp_username
36
-
37
- stdout, stderr, status = Open3.capture3('gcloud compute os-login describe-profile --format json')
38
- raise "gcloud os-login describe-profile failed (exit #{status.exitstatus}): #{stderr.chomp}" unless status.success?
39
-
40
- parsed = JSON.parse(stdout)
41
- accounts = parsed['posixAccounts']
42
- raise "gcloud os-login profile returned no posixAccounts. stderr: #{stderr.chomp}" if accounts.nil? || accounts.empty?
43
-
44
- username = accounts.first['username']
45
- raise "gcloud os-login posixAccounts entry has no username. stdout: #{stdout.chomp}" unless username
46
-
47
- @gcp_username = username
48
- rescue JSON::ParserError => e
49
- raise "Failed to parse gcloud os-login output: #{e.message}. stdout: #{stdout.chomp}"
50
- end
51
-
52
- def gcp_credentials_file
53
- @gcp_credentials_file ||= (@config.get('platform.credentials_file') || File.join(Dir.home, '.config', 'gcloud', 'application_default_credentials.json'))
54
- end
55
-
56
- def gcp_project
57
- return @gcp_project if @gcp_project
58
-
59
- configured = @config.get('platform.project')
60
- return @gcp_project = configured if configured
61
-
62
- stdout, stderr, status = Open3.capture3('gcloud config get-value project')
63
- raise "gcloud config get-value project failed (exit #{status.exitstatus}): #{stderr.chomp}" unless status.success?
64
-
65
- @gcp_project = stdout.chomp
66
- end
67
-
68
- def gcp_region
69
- return @gcp_region if @gcp_region
70
-
71
- configured = @config.get('platform.region')
72
- return @gcp_region = configured if configured
73
-
74
- stdout, stderr, status = Open3.capture3('gcloud config get-value compute/region')
75
- raise "gcloud config get-value compute/region failed (exit #{status.exitstatus}): #{stderr.chomp}" unless status.success?
76
-
77
- @gcp_region = stdout.chomp
78
- end
79
-
80
- def gcp_zone
81
- return @gcp_zone if @gcp_zone
82
-
83
- configured = @config.get('platform.zone')
84
- return @gcp_zone = configured if configured
85
-
86
- stdout, stderr, status = Open3.capture3('gcloud config get-value compute/zone')
87
- raise "gcloud config get-value compute/zone failed (exit #{status.exitstatus}): #{stderr.chomp}" unless status.success?
88
-
89
- @gcp_zone = stdout.chomp
90
- end
91
-
92
- def gcp_subnetwork
93
- @gcp_subnetwork ||= (@config.get('platform.subnetwork') || 'default')
94
- end
95
-
96
- def gcp_private_key
97
- @gcp_private_key ||= (@run_data[:private_key] || File.join(Dir.home, '.ssh', 'google_compute_engine'))
98
- end
99
-
100
- def gcp_public_key
101
- @gcp_public_key ||= (@run_data[:public_key] || File.join(Dir.home, '.ssh', 'google_compute_engine.pub'))
102
- end
103
-
104
- def gcp_machine_type
105
- @gcp_machine_type ||= (@config.get('node_data.machine_type') || 'e2-medium')
106
- end
107
-
108
- def gcp_disk_size
109
- @gcp_disk_size ||= (@config.get('node_data.disk_size') || 40)
110
- end
7
+ module CemAcpt
8
+ module Platform
9
+ module Mixin
10
+ module Gcp
11
+ def platform_data
12
+ {
13
+ username: gcp_username,
14
+ credentials_file: gcp_credentials_file,
15
+ project: gcp_project,
16
+ region: gcp_region,
17
+ zone: gcp_zone,
18
+ subnetwork: gcp_subnetwork,
19
+ private_key: gcp_private_key,
20
+ public_key: gcp_public_key,
21
+ }
22
+ end
23
+
24
+ def node_data
25
+ nd = {}
26
+ nd[:machine_type] = gcp_machine_type
27
+ nd[:disk_size] = gcp_disk_size
28
+ nd[:max_run_duration] = gcp_max_run_duration
29
+ nd[:image] = image_name if image_name
30
+ nd[:test_name] = @test_data[:test_name] if @test_data&.key?(:test_name)
31
+ nd
32
+ end
33
+
34
+ def gcp_username
35
+ return @gcp_username if @gcp_username
36
+
37
+ @gcp_username = @config.get('platform.username')
38
+ return @gcp_username if @gcp_username
39
+
40
+ stdout, stderr, status = Open3.capture3('gcloud compute os-login describe-profile --format json')
41
+ raise "gcloud os-login describe-profile failed (exit #{status.exitstatus}): #{stderr.chomp}" unless status.success?
42
+
43
+ parsed = JSON.parse(stdout)
44
+ accounts = parsed['posixAccounts']
45
+ raise "gcloud os-login profile returned no posixAccounts. stderr: #{stderr.chomp}" if accounts.nil? || accounts.empty?
46
+
47
+ username = accounts.first['username']
48
+ raise "gcloud os-login posixAccounts entry has no username. stdout: #{stdout.chomp}" unless username
49
+
50
+ @gcp_username = username
51
+ rescue JSON::ParserError => e
52
+ raise "Failed to parse gcloud os-login output: #{e.message}. stdout: #{stdout.chomp}"
53
+ end
54
+
55
+ def gcp_credentials_file
56
+ @gcp_credentials_file ||= (@config.get('platform.credentials_file') || File.join(Dir.home, '.config', 'gcloud', 'application_default_credentials.json'))
57
+ end
58
+
59
+ def gcp_project
60
+ return @gcp_project if @gcp_project
61
+
62
+ configured = @config.get('platform.project')
63
+ return @gcp_project = configured if configured
64
+
65
+ stdout, stderr, status = Open3.capture3('gcloud config get-value project')
66
+ raise "gcloud config get-value project failed (exit #{status.exitstatus}): #{stderr.chomp}" unless status.success?
67
+
68
+ @gcp_project = stdout.chomp
69
+ end
70
+
71
+ def gcp_region
72
+ return @gcp_region if @gcp_region
73
+
74
+ configured = @config.get('platform.region')
75
+ return @gcp_region = configured if configured
76
+
77
+ stdout, stderr, status = Open3.capture3('gcloud config get-value compute/region')
78
+ raise "gcloud config get-value compute/region failed (exit #{status.exitstatus}): #{stderr.chomp}" unless status.success?
79
+
80
+ @gcp_region = stdout.chomp
81
+ end
82
+
83
+ def gcp_zone
84
+ return @gcp_zone if @gcp_zone
85
+
86
+ configured = @config.get('platform.zone')
87
+ return @gcp_zone = configured if configured
88
+
89
+ stdout, stderr, status = Open3.capture3('gcloud config get-value compute/zone')
90
+ raise "gcloud config get-value compute/zone failed (exit #{status.exitstatus}): #{stderr.chomp}" unless status.success?
91
+
92
+ @gcp_zone = stdout.chomp
93
+ end
94
+
95
+ def gcp_subnetwork
96
+ @gcp_subnetwork ||= (@config.get('platform.subnetwork') || 'default')
97
+ end
98
+
99
+ def gcp_private_key
100
+ @gcp_private_key ||= (@run_data[:private_key] || File.join(Dir.home, '.ssh', 'google_compute_engine'))
101
+ end
102
+
103
+ def gcp_public_key
104
+ @gcp_public_key ||= (@run_data[:public_key] || File.join(Dir.home, '.ssh', 'google_compute_engine.pub'))
105
+ end
106
+
107
+ def gcp_machine_type
108
+ @gcp_machine_type ||= (@config.get('node_data.machine_type') || 'e2-medium')
109
+ end
110
+
111
+ def gcp_disk_size
112
+ @gcp_disk_size ||= (@config.get('node_data.disk_size') || 40)
113
+ end
111
114
 
112
- def gcp_max_run_duration
113
- @gcp_max_run_duration ||= (@config.get('node_data.max_run_duration') || 3600)
115
+ def gcp_max_run_duration
116
+ @gcp_max_run_duration ||= (@config.get('node_data.max_run_duration') || 3600)
117
+ end
118
+ end
119
+ end
114
120
  end
115
121
  end
@@ -7,6 +7,13 @@ require_relative 'logging'
7
7
  module CemAcpt::Platform
8
8
  class Error < StandardError; end
9
9
 
10
+ # Namespace for per-platform mixin modules. Each platform file in
11
+ # +lib/cem_acpt/platform/<name>.rb+ defines a module
12
+ # +CemAcpt::Platform::Mixin::<CamelCaseName>+ providing
13
+ # +#platform_data+ and +#node_data+, which is included into the
14
+ # dynamically-generated platform class.
15
+ module Mixin; end
16
+
10
17
  PLATFORM_DIR = File.expand_path(File.join(__dir__, 'platform'))
11
18
  BASE_TYPES = %i[base test].freeze
12
19
 
@@ -77,23 +84,19 @@ module CemAcpt::Platform
77
84
  # @param platform [String] the name of the platform.
78
85
  # @return [Class] the platform-specific Object.
79
86
  def platform_class(base_type, platform)
80
- class_name = platform.capitalize
87
+ const_name = platform.split(/[_-]/).map(&:capitalize).join
81
88
  # We require the platform base class here so that we can use it as
82
89
  # a parent class for the platform-specific class.
83
90
  require_relative 'platform/base'
84
- # If the class has already been defined, we can just use it.
85
- if Object.const_defined?(class_name)
86
- logger.debug('CemAcpt::Platform') { "Using existing platform class #{class_name}" }
87
- klass = Object.const_get(class_name)
91
+ # If the class has already been defined under CemAcpt::Platform, reuse
92
+ # it. The +false+ argument skips ancestor lookup so that an unrelated
93
+ # constant of the same name elsewhere in the constant graph cannot
94
+ # silently win the cache check.
95
+ if CemAcpt::Platform.const_defined?(const_name, false)
96
+ logger.debug('CemAcpt::Platform') { "Using existing platform class #{const_name}" }
97
+ klass = CemAcpt::Platform.const_get(const_name, false)
88
98
  else
89
- # Otherwise, we need to create the class. We do this by setting
90
- # a new constant with the name of the platform capitalized, and
91
- # associate that constant with a new instance of Class that inherits
92
- # from the platform base class. We then require the platform file,
93
- # include and extend our class with the Platform module from the file,
94
- # include Logging and Concurrent::Async, and finally call the
95
- # initialize method on the class.
96
- logger.debug "Creating platform class #{class_name}"
99
+ logger.debug "Creating platform class #{const_name}"
97
100
  baseklass = case base_type.to_sym
98
101
  when :base
99
102
  CemAcpt::Platform::Base
@@ -102,12 +105,11 @@ module CemAcpt::Platform
102
105
  else
103
106
  raise Error, "Base type #{base_type} is not supported"
104
107
  end
105
- klass = Object.const_set(
106
- class_name,
107
- Class.new(baseklass) do
108
- require_relative "platform/#{platform}"
109
- include Platform
110
- end,
108
+ require_relative "platform/#{platform}"
109
+ mixin = CemAcpt::Platform::Mixin.const_get(const_name, false)
110
+ klass = CemAcpt::Platform.const_set(
111
+ const_name,
112
+ Class.new(baseklass) { include mixin },
111
113
  )
112
114
  end
113
115
  logger.debug('CemAcpt::Platform') { "Using platform class: #{klass.inspect}" }
@@ -11,7 +11,7 @@ module CemAcpt
11
11
  end
12
12
 
13
13
  def self.valid_versions
14
- %w[7 8 9 2004 2204 2404]
14
+ %w[7 8 9 10 2004 2204 2404]
15
15
  end
16
16
 
17
17
  def systemd_files
@@ -9,6 +9,29 @@ module CemAcpt
9
9
  include CemAcpt::Logging
10
10
  extend CemAcpt::Logging
11
11
 
12
+ # Raised when a test name cannot be classified as either a Linux or Windows test by `os_family_for`.
13
+ class UnknownOsFamilyError < StandardError; end
14
+
15
+ # Classifies a test name as `:linux` or `:windows` using the same `use_for?` predicate that
16
+ # `Provision::Terraform#new_backend` consumes. Anchored on `Linux.valid_names` /
17
+ # `Windows.valid_names` rather than substring matching, so a test like
18
+ # `cis_rhel-8_firewalld_windowserver_2` correctly classifies as `:linux`.
19
+ # @param test_name [String] The test name to classify.
20
+ # @return [Symbol] `:windows` or `:linux`.
21
+ # @raise [UnknownOsFamilyError] if the test name does not match any known OS / version.
22
+ def self.os_family_for(test_name)
23
+ return :windows if CemAcpt::Provision::Windows.use_for?(test_name)
24
+ return :linux if CemAcpt::Provision::Linux.use_for?(test_name)
25
+
26
+ raise UnknownOsFamilyError, [
27
+ "Cannot determine OS family for test name: #{test_name}.",
28
+ "Known OSes: linux=[#{CemAcpt::Provision::Linux.valid_names.join(', ')}], " \
29
+ "windows=[#{CemAcpt::Provision::Windows.valid_names.join(', ')}].",
30
+ "Known versions: linux=[#{CemAcpt::Provision::Linux.valid_versions.join(', ')}], " \
31
+ "windows=[#{CemAcpt::Provision::Windows.valid_versions.join(', ')}].",
32
+ ].join(' ')
33
+ end
34
+
12
35
  # Determines if this OsData implementation should be used for the given test name. This method extracts the OS
13
36
  # name and version from the test name using a regular expression, and checks if they match the valid names and
14
37
  # versions defined by the subclass. The test name is expected to be in the format `<prefix>_osname-version`, where
@@ -23,8 +23,14 @@ module CemAcpt
23
23
  'C:/cem_acpt'
24
24
  end
25
25
 
26
+ # Windows provisioning intentionally does not run any Terraform
27
+ # `remote-exec` commands — the Windows `main.tf` has no provisioner
28
+ # blocks. All shell work happens via {CemAcpt::Utils::WinRMRunner}
29
+ # after Terraform finishes. See ARCHITECTURE.md §13.
26
30
  def provision_commands
27
- ['placeholder']
31
+ raise NotImplementedError,
32
+ 'Windows provisioning is performed via Utils::WinRMRunner, not via ' \
33
+ 'Terraform remote-exec. See ARCHITECTURE.md §13.'
28
34
  end
29
35
  end
30
36
  end
@@ -172,32 +172,23 @@ module CemAcpt
172
172
  terraform.show({ chdir: working_dir, no_color: true }, { environment: environment })
173
173
  end
174
174
 
175
- # Creates a new backend instance based on the OS specified in the test name. This method checks the test name
176
- # against the valid names and versions defined by the Linux and Windows backend classes, and returns an instance
177
- # of the appropriate class. If the test name does not match any known OS, an error is raised.
175
+ # Creates a new backend instance based on the OS specified in the test name. Delegates to
176
+ # `Provision::OsData.os_family_for` so the runner and provisioner share a single source of truth for OS dispatch.
178
177
  # @param test_name [String] The name of the test, which should include the OS name and version in the format
179
178
  # `<prefix>_osname-version`.
180
179
  # @return [CemAcpt::Provision::Linux, CemAcpt::Provision::Windows] An instance of the appropriate backend class
181
180
  # based on the OS specified in the test name.
182
- # @raise [ArgumentError] If the test name does not match any known OS or version.
181
+ # @raise [CemAcpt::Provision::OsData::UnknownOsFamilyError] If the test name does not match any known OS or version.
183
182
  def new_backend(test_name)
184
- if CemAcpt::Provision::Linux.use_for?(test_name)
183
+ case CemAcpt::Provision::OsData.os_family_for(test_name)
184
+ when :linux
185
185
  logger.info('CemAcpt::Provision::Terraform') { 'Using Linux backend' }
186
186
  logger.verbose('CemAcpt::Provision::Terraform') { "Creating backend with provision_data:\n#{JSON.pretty_generate(@provision_data)}" }
187
187
  CemAcpt::Provision::Linux.new(@config, @provision_data)
188
- elsif CemAcpt::Provision::Windows.use_for?(test_name)
188
+ when :windows
189
189
  logger.info('CemAcpt::Provision::Terraform') { 'Using Windows backend' }
190
190
  logger.verbose('CemAcpt::Provision::Terraform') { "Creating backend with provision_data:\n#{JSON.pretty_generate(@provision_data)}" }
191
191
  CemAcpt::Provision::Windows.new(@config, @provision_data)
192
- else
193
- err_msg = [
194
- "Test name #{test_name} does not match any known OS.",
195
- "Known OSes are: #{CemAcpt::Provision::Linux.valid_names.join(', ')}",
196
- "and #{CemAcpt::Provision::Windows.valid_names.join(', ')}.",
197
- "Known versions are: #{CemAcpt::Provision::Linux.valid_versions.join(', ')}",
198
- ", and #{CemAcpt::Provision::Windows.valid_versions.join(', ')}.",
199
- ].join(' ')
200
- raise ArgumentError, err_msg
201
192
  end
202
193
  end
203
194
 
@@ -282,7 +273,7 @@ module CemAcpt
282
273
  puppet_manifest: node.test_data[:puppet_manifest],
283
274
  provision_dir_source: @backend.provision_directory,
284
275
  provision_dir_dest: @backend.destination_provision_directory,
285
- provision_commands: @backend.instance_of?(CemAcpt::Provision::Linux) ? @backend.provision_commands_wrapper(node.node_data[:image]) : @backend.provision_commands,
276
+ provision_commands: provision_commands_for(node),
286
277
  }
287
278
  )
288
279
  end
@@ -291,6 +282,19 @@ module CemAcpt
291
282
  raise e
292
283
  end
293
284
 
285
+ # Windows provisioning runs via {CemAcpt::Utils::WinRMRunner} rather
286
+ # than Terraform `remote-exec`, so its node_data carries no commands.
287
+ def provision_commands_for(node)
288
+ case @backend
289
+ when CemAcpt::Provision::Linux
290
+ @backend.provision_commands_wrapper(node.node_data[:image])
291
+ when CemAcpt::Provision::Windows
292
+ []
293
+ else
294
+ @backend.provision_commands
295
+ end
296
+ end
297
+
294
298
  # @return [Hash] The variables to be passed to Terraform, including the provision data for the nodes and any
295
299
  # necessary credentials and module package paths.
296
300
  # @raise [StandardError] If there is an error formatting the variables.
@@ -6,7 +6,8 @@ require_relative 'base'
6
6
  module CemAcpt
7
7
  module TestRunner
8
8
  module LogFormatter
9
- # Formats the results of a Bolt::SummaryResults object
9
+ # Canonical formatter for Bolt subsystem results. Wired into
10
+ # LogFormatter.new_formatter for CemAcpt::Bolt::SummaryResults.
10
11
  class BoltSummaryResultsFormatter < Base
11
12
  def initialize(config, instance_names_ips, subject: nil)
12
13
  super(subject)
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'log_formatter/bolt_summary_results_formatter'
4
- require_relative 'log_formatter/bolt_output_formatter'
5
4
  require_relative 'log_formatter/goss_action_response'
6
5
  require_relative 'log_formatter/goss_error_formatter'
7
6
  require_relative 'log_formatter/standard_error_formatter'