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
@@ -61,8 +61,13 @@ module CemAcpt
61
61
  @provisioned = true
62
62
  logger.info('CemAcpt::TestRunner') { 'Provisioned test nodes...' }
63
63
  logger.debug('CemAcpt::TestRunner') { "Instance names and IPs: #{@instance_names_ips}" }
64
- # Verifying that we're running on windows nodes or not
65
- if config.get('tests').first.include? 'windows'
64
+ # Classify every test in the run by OS family rather than substring-matching the first test name.
65
+ windows_tests, linux_tests = config.get('tests').partition do |t|
66
+ CemAcpt::Provision::OsData.os_family_for(t) == :windows
67
+ end
68
+ unless windows_tests.empty?
69
+ raise 'Mixed Windows and Linux tests in one run are not supported' unless linux_tests.empty?
70
+
66
71
  logger.info('CemAcpt') { 'Running on windows nodes...' }
67
72
  upload_module_to_bucket
68
73
 
@@ -71,7 +76,7 @@ module CemAcpt
71
76
  # instance_names_ips. It contains the username, password, and ip of the
72
77
  # windows node, as well as the test name that will be run on that node.
73
78
  login_info = CemAcpt::Utils.get_windows_login_info(k, v)
74
- win_node = CemAcpt::Utils::WinRMRunner::WinNode.new(login_info, @run_data[:win_remote_module_name])
79
+ win_node = CemAcpt::Utils::WinRMRunner::WinNode.new(login_info, @run_data[:win_remote_module_name], windows_bucket_uri)
75
80
  win_node.run
76
81
  end
77
82
  end
@@ -203,9 +208,9 @@ module CemAcpt
203
208
  @bolt_test_runner = CemAcpt::Bolt::TestRunner.new(config, run_data: @run_data)
204
209
  @bolt_test_runner.setup!
205
210
  rescue CemAcpt::ShellCommandNotFoundError => e
206
- logger.warning('CemAcpt::TestRunner') { e.message }
207
- logger.warning('CemAcpt::TestRunner') { 'Adding Bolt action to ignore list...' }
208
- CemAcpt::Actions.config.ignore << :bolt
211
+ logger.warn('CemAcpt::TestRunner') { e.message }
212
+ logger.warn('CemAcpt::TestRunner') { 'Bolt binary not found on PATH; skipping :bolt action.' }
213
+ CemAcpt::Actions.config.except = (CemAcpt::Actions.config.except + [:bolt]).uniq
209
214
  return
210
215
  end
211
216
  return unless @bolt_test_runner.tests.to_a.empty?
@@ -378,14 +383,22 @@ module CemAcpt
378
383
  logger.error { result.log_formatter.results.join("\n") }
379
384
  end
380
385
 
386
+ # Returns the configured Windows GCS bucket URI (e.g. "gs://win_cem_acpt").
387
+ # Raises if the bucket is unset or empty so we fail before invoking gcloud.
388
+ def windows_bucket_uri
389
+ bucket = config.get('platform.gcp.windows_bucket')
390
+ raise 'platform.gcp.windows_bucket is not configured' if bucket.nil? || bucket.to_s.empty?
391
+ "gs://#{bucket}"
392
+ end
393
+
381
394
  # Upload the cem_windows module to the bucket if we're testing the cem_windows module
382
395
  # This should only be done once per cem_acpt run. It's important to update the module_package_path
383
396
  # in the run_data to reflect the new module path if we do end up changing the module name
384
397
  def upload_module_to_bucket
385
398
  @run_data[:win_remote_module_name] = SecureRandom.uuid << File.split(@run_data[:module_package_path]).last
386
- @run_data[:win_remote_module_path] = File.join('gs://win_cem_acpt', @run_data[:win_remote_module_name])
399
+ @run_data[:win_remote_module_path] = File.join(windows_bucket_uri, @run_data[:win_remote_module_name])
387
400
  # Upload the module from the local host to the bucket
388
- logger.info('CemAcpt') { "Uploading #{@run_data[:module_pakage_path]} to #{@run_data[:win_remote_module_path]}..." }
401
+ logger.info('CemAcpt') { "Uploading #{@run_data[:module_package_path]} to #{@run_data[:win_remote_module_path]}..." }
389
402
  CemAcpt::Utils::Shell.run_cmd("gcloud storage cp #{@run_data[:module_package_path]} #{@run_data[:win_remote_module_path]}")
390
403
  logger.debug('CemAcpt') { 'Successfully uploaded module' }
391
404
  end
@@ -36,11 +36,12 @@ module CemAcpt
36
36
  class WinNode
37
37
  include CemAcpt::Logging
38
38
 
39
- attr_reader :mod_name
39
+ attr_reader :mod_name, :bucket_uri
40
40
 
41
- def initialize(login_info, mod_name)
41
+ def initialize(login_info, mod_name, bucket_uri)
42
42
  @login_info = login_info
43
43
  @mod_name = mod_name
44
+ @bucket_uri = bucket_uri
44
45
  end
45
46
 
46
47
  def run_winrm
@@ -80,7 +81,7 @@ module CemAcpt
80
81
  logger.debug('CemAcpt') { 'Creating cem_acpt directory and downloading necessary files...' }
81
82
  winrm_runner.run("mkdir C:\\cem_acpt")
82
83
  # Download the cem_windows module from the bucket to cem_acpt directory
83
- winrm_runner.run("gcloud storage cp gs://win_cem_acpt/#{@mod_name} C:\\cem_acpt")
84
+ winrm_runner.run("gcloud storage cp #{@bucket_uri}/#{@mod_name} C:\\cem_acpt")
84
85
  # Install the cem_windows module
85
86
  logger.info('CemAcpt') { 'Installing cem_windows module...' }
86
87
  winrm_runner.run("Start-Process -FilePath 'C:\\Program Files\\Puppet Labs\\Puppet\\bin\\puppet.bat' -ArgumentList 'module install C:\\cem_acpt\\#{@mod_name}' -Wait -NoNewWindow")
@@ -13,18 +13,6 @@ module CemAcpt
13
13
  class << self
14
14
  include CemAcpt::Logging
15
15
 
16
- # This is method currently unused, see lib/cem_acpt/utils/puppet.rb for details.
17
- def package_win_module(module_dir)
18
- # Path to the package file
19
- package_file = File.join(module_dir, 'puppetlabs-cem_windows.tar.gz')
20
-
21
- # Remove the old package file if it exists
22
- FileUtils.rm_f(package_file)
23
- `cd #{module_dir} && touch puppetlabs-cem_windows.tar.gz && tar -czf puppetlabs-cem_windows.tar.gz --exclude=puppetlabs-cem_windows.tar.gz *`
24
- logger.info('CemAcpt') { "Windows module packaged at #{package_file}" }
25
- package_file
26
- end
27
-
28
16
  def reset_password_readiness_polling(instance_name)
29
17
  attempts = 0
30
18
  last_error = nil
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CemAcpt
4
- VERSION = '0.11.0'
4
+ VERSION = '0.11.2'
5
5
  end
data/lib/cem_acpt.rb CHANGED
@@ -56,26 +56,38 @@ module CemAcpt
56
56
  CemAcpt::TestRunner::Runner.new(@config)
57
57
  end
58
58
 
59
+ # Build the logger destinations from `--quiet`, `--log-file`, and CI mode.
60
+ # See docs/rfcs/0007-logging-quiet-and-typos.md for the matrix.
59
61
  def initialize_logger!
60
62
  raise 'Config must be loaded before logger can be initialized' if config.nil? || config.empty?
61
63
 
62
64
  log_formatter = config.get('log_format')&.to_sym || :text
63
- logdevs = [$stdout]
64
- # If log_file is set, and quiet is set, only log to the file
65
- if config.get('log_file') && config.get('quiet')
66
- logdevs = [config.get('log_file')]
67
- elsif config.get('log_file') && !config.get('quiet')
68
- logdevs << config.get('log_file')
69
- end
65
+ quiet = !!config.get('quiet')
66
+ log_file = config.get('log_file')
67
+
68
+ logdevs = []
69
+ logdevs << $stdout unless quiet
70
+ logdevs << log_file if log_file
71
+
72
+ ci_override = false
70
73
  if config.ci_mode? && !logdevs.include?($stdout)
71
74
  logdevs << $stdout
75
+ ci_override = quiet
72
76
  end
77
+
78
+ if logdevs.empty?
79
+ raise '--quiet without --log-file would silence all output; pass --log-file or drop --quiet'
80
+ end
81
+
73
82
  new_log = new_logger(
74
83
  *logdevs,
75
84
  level: config.get('log_level'),
76
85
  formatter: log_formatter,
77
86
  )
78
87
  new_log.set_verbose(!!config.get('verbose'))
88
+ if ci_override
89
+ new_log.warn('CemAcpt') { '--quiet is overridden in CI mode; logging to stdout for ::group:: support' }
90
+ end
79
91
  new_log
80
92
  end
81
93
 
@@ -189,7 +189,12 @@ resource "google_compute_instance" "acpt-test-node" {
189
189
  }
190
190
 
191
191
  metadata = {
192
- "enable-oslogin" = "TRUE"
192
+ # Keep OSLogin disabled on test instances. The metadata "ssh-keys" entry
193
+ # below is what cem_acpt relies on for SSH auth (terraform's remote-exec
194
+ # provisioner connects with the ephemeral key generated per run). With
195
+ # enable-oslogin=TRUE, GCP ignores the metadata "ssh-keys" entry, and we
196
+ # currently have no plumbing for OSLogin key registration.
197
+ "enable-oslogin" = "FALSE"
193
198
  "ssh-keys" = "${var.username}:${file(var.public_key)}"
194
199
  "cem-acpt-test" = each.value.test_name
195
200
  }
@@ -116,7 +116,14 @@ resource "google_compute_instance" "acpt-test-node" {
116
116
  }
117
117
 
118
118
  metadata = {
119
- "enable-oslogin" = "TRUE"
119
+ # Keep OSLogin disabled on the throwaway build instance. The metadata
120
+ # "ssh-keys" entry below is what terraform's remote-exec provisioner
121
+ # uses for SSH auth (with the ephemeral key generated per run). With
122
+ # enable-oslogin=TRUE, GCP ignores the metadata "ssh-keys" entry, and
123
+ # we have no plumbing for OSLogin key registration. The build instance
124
+ # is destroyed immediately after the disk is snapshotted, so disabling
125
+ # OSLogin here only affects the throwaway VM, not the resulting image.
126
+ "enable-oslogin" = "FALSE"
120
127
  "ssh-keys" = "${var.username}:${file(var.public_key)}"
121
128
  "for-image-family" = each.value.image_family
122
129
  }
@@ -0,0 +1,165 @@
1
+ # CEM-6713 — Fix image-name truncation off-by-one
2
+
3
+ ## Background
4
+
5
+ `ImageBuilder::TerraformBuilder#image_name_from_image_family`
6
+ (`lib/cem_acpt/image_builder.rb:114-116`) currently does:
7
+
8
+ ```ruby
9
+ def image_name_from_image_family(image_family)
10
+ "#{image_family}-v#{@start_time.to_i}"[0..64]
11
+ end
12
+ ```
13
+
14
+ `String#[0..64]` is **inclusive** on both ends and returns up to 65
15
+ characters. GCE image names are limited to 63 characters per RFC 1035
16
+ label rules, and must match `[a-z]([-a-z0-9]*[a-z0-9])?`. So the
17
+ current code:
18
+
19
+ 1. Allows names of up to 65 chars, which GCE rejects with no clear
20
+ error path back to the user.
21
+ 2. When it does truncate, the cut can land mid-timestamp or on the
22
+ `-v` separator, allowing two consecutive builds in the same family
23
+ to collide on the truncated name.
24
+
25
+ This work is the implementation half of
26
+ [RFC 0004](../docs/rfcs/0004-image-name-truncation-off-by-one.md).
27
+
28
+ ## Functional behavior
29
+
30
+ `image_name_from_image_family` will produce a string that is
31
+ GCE-name-safe by construction:
32
+
33
+ - **Length ≤ 63 characters.**
34
+ - **Suffix `-v<unix_timestamp>` is preserved verbatim.** Two
35
+ consecutive builds in the same family always differ in the
36
+ timestamp portion, regardless of how long the family is.
37
+ - **No trailing dash.** When the prefix has to be clipped and the
38
+ clip lands on a `-`, trailing dashes are stripped before the suffix
39
+ is appended.
40
+
41
+ ### Method signature (unchanged)
42
+
43
+ ```ruby
44
+ # @param image_family [String] the GCE image family
45
+ # @return [String] the GCE-safe image name, ≤ 63 chars,
46
+ # with the timestamp suffix preserved
47
+ def image_name_from_image_family(image_family)
48
+ ```
49
+
50
+ ### Implementation sketch
51
+
52
+ ```ruby
53
+ GCE_IMAGE_NAME_MAX = 63
54
+
55
+ def image_name_from_image_family(image_family)
56
+ suffix = "-v#{@start_time.to_i}"
57
+ full = "#{image_family}#{suffix}"
58
+ return full if full.length <= GCE_IMAGE_NAME_MAX
59
+
60
+ prefix = image_family[0, GCE_IMAGE_NAME_MAX - suffix.length]
61
+ prefix.sub(/-+\z/, '') + suffix
62
+ end
63
+ ```
64
+
65
+ The constant lives on `TerraformBuilder` next to
66
+ `DEFAULT_PLAN_NAME` / `DEFAULT_VARS_FILE`.
67
+
68
+ ## Input / output contract
69
+
70
+ | Input `image_family` length | `@start_time.to_i` length | Expected output |
71
+ | --------------------------- | ------------------------- | ----------------------------------------- |
72
+ | 5 (`rhel9`) | 10 | `"rhel9-v<ts>"` (17 chars, unchanged) |
73
+ | Any value ≤ 63 chars total | — | unchanged |
74
+ | 64 chars total | — | family clipped by 1, suffix intact |
75
+ | Family clip ends in `-` | — | trailing `-` stripped, suffix intact |
76
+
77
+ `@start_time` is a `Time` set at the start of `run`. Callers
78
+ (`run` → `create_image_from_disk`) already only invoke this method
79
+ during a build, so `@start_time` is non-nil when this method runs.
80
+
81
+ ## Edge cases
82
+
83
+ 1. **Short family** — `image_family.length + suffix.length <= 63`:
84
+ return concatenation unchanged.
85
+ 2. **Exactly 63 chars** — returned unchanged (no truncation).
86
+ 3. **64 chars (one over)** — clip the family to fit; suffix is
87
+ preserved.
88
+ 4. **Family ends in dashes after clipping** — strip *all* trailing
89
+ dashes from the clipped prefix before appending the suffix. (The
90
+ suffix begins with `-v…` so the result still has exactly one `-`
91
+ between the clipped family and the `v`.)
92
+ 5. **Family long enough that clipping reduces it to nothing** — not a
93
+ realistic case (`@start_time.to_i` is 10 digits, suffix is 12
94
+ chars, leaving 51 chars of family room), but the implementation
95
+ tolerates it: `String#[0, 0]` returns `""`, `sub` is a no-op, and
96
+ the result is just the suffix. We are not adding explicit
97
+ handling for this; if it ever fires, GCE will reject (name must
98
+ start with a letter) and the failure surfaces normally.
99
+
100
+ ## Constraints / invariants
101
+
102
+ - The suffix `-v<ts>` must always appear literally at the end of the
103
+ returned name.
104
+ - The returned name's length is always ≤ 63.
105
+ - The returned name never ends in `-`.
106
+ - No other call sites in the codebase grep for `image_name_from_image_family`,
107
+ so the contract change is local to this method.
108
+
109
+ ## Error handling
110
+
111
+ No new exceptions. The method is pure-string, no I/O, no Terraform
112
+ or GCE calls. Existing callers (`create_image_from_disk`) handle
113
+ GCE errors at their own layer.
114
+
115
+ ## Tests
116
+
117
+ Add to `spec/cem_acpt/image_builder_spec.rb`:
118
+
119
+ - A `describe '#image_name_from_image_family'` block that builds a
120
+ `TerraformBuilder` instance, sets `@start_time` via
121
+ `instance_variable_set`, and exercises:
122
+ 1. Short family: result equals `"<family>-v<ts>"` and is unchanged.
123
+ 2. Exact 63-char total: result unchanged, length 63.
124
+ 3. 64-char total (one over): result is exactly 63 chars, ends in
125
+ the full `-v<ts>` suffix, family portion is one shorter.
126
+ 4. Prefix-ending-in-dash: family chosen so the clip lands on `-`
127
+ (or `--`); result has no trailing dash before the suffix and
128
+ length ≤ 63.
129
+
130
+ Tests use a fixed `@start_time` (e.g.
131
+ `Time.utc(2026, 5, 4, 12, 0, 0)`) so timestamp digits are
132
+ deterministic.
133
+
134
+ ## Documentation
135
+
136
+ - Update `docs/ARCHITECTURE.md` §12 — replace the off-by-one note
137
+ (current lines 757-762) with a description of the new bounded
138
+ behavior, referencing `GCE_IMAGE_NAME_MAX` and the suffix-preserving
139
+ rule.
140
+ - Update `docs/rfcs/0004-image-name-truncation-off-by-one.md` to
141
+ flip status from `Proposed` to `Implemented` and append an
142
+ "Implementation" note linking to the commit / PR.
143
+
144
+ ## Non-goals
145
+
146
+ - Refusing to build when the family alone exceeds 63 chars
147
+ (alternative listed in the RFC) — out of scope.
148
+ - Hashing-based naming — out of scope.
149
+ - Refactoring the `TerraformBuilder` ↔ `Provision::Terraform`
150
+ duplication — out of scope; that is its own follow-up.
151
+ - Adding validation for the `[a-z]([-a-z0-9]*[a-z0-9])?` regex —
152
+ the existing code does not validate the family characters, and
153
+ this ticket is scoped to the length / off-by-one issue.
154
+
155
+ ## Acceptance criteria
156
+
157
+ - [ ] `image_name_from_image_family` enforces a 63-char cap.
158
+ - [ ] Generated names never end in `-`.
159
+ - [ ] The `-v<ts>` suffix is preserved intact.
160
+ - [ ] RSpec coverage for: short, exact-63, 64-overflow,
161
+ prefix-ending-in-dash.
162
+ - [ ] `bundle exec rake spec` passes.
163
+ - [ ] `rubocop` is clean on touched files.
164
+ - [ ] `docs/ARCHITECTURE.md` §12 updated.
165
+ - [ ] RFC 0004 status updated to `Implemented`.
@@ -0,0 +1,271 @@
1
+ # CEM-6714 — Replace `tests.first.include?('windows')` with explicit OS dispatch
2
+
3
+ ## Background
4
+
5
+ `TestRunner::Runner#run` (`lib/cem_acpt/test_runner.rb:65`) currently
6
+ decides whether to take the Windows branch with:
7
+
8
+ ```ruby
9
+ if config.get('tests').first.include? 'windows'
10
+ upload_module_to_bucket
11
+ @instance_names_ips.each { |k, v| ... WinRMRunner ... }
12
+ end
13
+ ```
14
+
15
+ This is a substring match against an arbitrary test name. It:
16
+
17
+ 1. Misroutes any test whose name happens to contain the substring
18
+ `windows` (e.g. `cis_rhel-8_firewalld_windowserver_2`).
19
+ 2. Only inspects the first test, so a mixed-OS run silently
20
+ mishandles every test after the first.
21
+ 3. Couples the runner's OS dispatch to test-name spelling.
22
+ 4. Duplicates logic already owned by `Provision::OsData`. The
23
+ provisioner already classifies the run via
24
+ `Linux.use_for?` / `Windows.use_for?` (`lib/cem_acpt/provision/terraform.rb:184-188`).
25
+
26
+ This work is the implementation half of
27
+ [RFC 0005](../docs/rfcs/0005-os-dispatch-replace-windows-heuristic.md).
28
+
29
+ ## Functional behavior
30
+
31
+ Add a single classification helper to `Provision::OsData`, and have
32
+ the runner consume it.
33
+
34
+ ### `Provision::OsData.os_family_for(test_name)`
35
+
36
+ A new module-level method on `CemAcpt::Provision::OsData`:
37
+
38
+ ```ruby
39
+ # @param test_name [String]
40
+ # @return [Symbol] :linux or :windows
41
+ # @raise [CemAcpt::Provision::OsData::UnknownOsFamilyError] if the
42
+ # test_name does not match any known Linux or Windows OS / version.
43
+ def self.os_family_for(test_name)
44
+ return :windows if CemAcpt::Provision::Windows.use_for?(test_name)
45
+ return :linux if CemAcpt::Provision::Linux.use_for?(test_name)
46
+
47
+ raise UnknownOsFamilyError, ...
48
+ end
49
+ ```
50
+
51
+ `UnknownOsFamilyError < StandardError` is defined inside `OsData` and
52
+ its message names the test_name plus the valid OS/version sets — same
53
+ information that `Provision::Terraform#new_backend` raises today, so
54
+ preflight failures are at least as informative as today's
55
+ provisioner-time failures.
56
+
57
+ `Provision::Terraform#new_backend` will be refactored to delegate to
58
+ `os_family_for` so the two callers cannot drift. The error class
59
+ raised there changes from `ArgumentError` to
60
+ `OsData::UnknownOsFamilyError`. No callers of `new_backend` rescue
61
+ `ArgumentError` specifically (verified by grep), so this is a
62
+ behavior-preserving narrowing.
63
+
64
+ ### Runner change
65
+
66
+ `TestRunner::Runner#run` replaces the `if … include? 'windows'`
67
+ block with:
68
+
69
+ ```ruby
70
+ windows_tests, linux_tests = config.get('tests').partition do |t|
71
+ CemAcpt::Provision::OsData.os_family_for(t) == :windows
72
+ end
73
+
74
+ unless windows_tests.empty?
75
+ raise 'Mixed Windows and Linux tests in one run are not supported' \
76
+ unless linux_tests.empty?
77
+
78
+ logger.info('CemAcpt') { 'Running on windows nodes...' }
79
+ upload_module_to_bucket
80
+ @instance_names_ips.each do |k, v|
81
+ login_info = CemAcpt::Utils.get_windows_login_info(k, v)
82
+ win_node = CemAcpt::Utils::WinRMRunner::WinNode.new(
83
+ login_info, @run_data[:win_remote_module_name],
84
+ )
85
+ win_node.run
86
+ end
87
+ end
88
+ ```
89
+
90
+ Two behavior consequences:
91
+
92
+ - A test name that doesn't match either Linux or Windows now fails
93
+ fast at preflight (via `os_family_for`) instead of later in
94
+ `Provision::Terraform#new_backend`. RFC 0005 calls this out as
95
+ desired.
96
+ - A mixed-OS `tests:` list now raises a clear error instead of
97
+ silently treating tests after the first as Linux. This is the
98
+ desired behavior.
99
+
100
+ ## Input / output contract
101
+
102
+ ### `OsData.os_family_for`
103
+
104
+ | Input `test_name` | Output |
105
+ |--------------------------------------------|---------------------------------|
106
+ | `'cis_windows-2022_…'` | `:windows` |
107
+ | `'cis_rhel-8_firewalld_server_2'` | `:linux` |
108
+ | `'cis_rhel-8_firewalld_windowserver_2'` | `:linux` (substring trap fixed) |
109
+ | `'cis_freebsd-14_…'` (unknown OS) | raises `UnknownOsFamilyError` |
110
+ | `'malformed name with no version'` | raises `UnknownOsFamilyError` |
111
+
112
+ The method does no I/O and is pure with respect to `Linux.valid_names`,
113
+ `Linux.valid_versions`, `Windows.valid_names`, `Windows.valid_versions`.
114
+
115
+ ### Runner
116
+
117
+ | `tests:` list | Behavior |
118
+ |----------------------------------------------|---------------------------------------------------------|
119
+ | All Linux test names | Skip Windows branch, run as today. |
120
+ | All Windows test names | Take Windows branch, upload module + WinRM each instance.|
121
+ | Mixed Linux + Windows | Raise `'Mixed Windows and Linux tests in one run are not supported'` from `#run`; `rescue StandardError` records it as a result and `clean_up` runs as today. |
122
+ | Any test name not matching any known OS | Raises `OsData::UnknownOsFamilyError`; same rescue path.|
123
+
124
+ ## Constraints / invariants
125
+
126
+ - The dispatch decision in `TestRunner::Runner` and
127
+ `Provision::Terraform` MUST come from the same source of truth
128
+ (`OsData.os_family_for`).
129
+ - `OsData.os_family_for` MUST NOT do substring matching against the
130
+ test name; it goes through `use_for?`, which already anchors with
131
+ the `^prefix_osname-version` regex.
132
+ - The runner MUST classify every test name in the list, not just the
133
+ first.
134
+ - Both new and pre-existing failure modes route through the runner's
135
+ existing `rescue StandardError` and `ensure clean_up`, so resource
136
+ cleanup is unchanged.
137
+
138
+ ## Error handling
139
+
140
+ - `OsData::UnknownOsFamilyError < StandardError` — raised by
141
+ `os_family_for` when neither `Linux.use_for?` nor `Windows.use_for?`
142
+ matches. Message format:
143
+
144
+ ```
145
+ Cannot determine OS family for test name: <test_name>.
146
+ Known OSes: linux=[...], windows=[...].
147
+ Known versions: linux=[...], windows=[...].
148
+ ```
149
+
150
+ - `Provision::Terraform#new_backend` is refactored to:
151
+
152
+ ```ruby
153
+ case CemAcpt::Provision::OsData.os_family_for(test_name)
154
+ when :linux
155
+ CemAcpt::Provision::Linux.new(@config, @provision_data)
156
+ when :windows
157
+ CemAcpt::Provision::Windows.new(@config, @provision_data)
158
+ end
159
+ ```
160
+
161
+ An unknown test name will surface
162
+ `OsData::UnknownOsFamilyError` instead of `ArgumentError`. No
163
+ call sites in `lib/`, `exe/`, or `spec/` rescue `ArgumentError`
164
+ from this method, so this is a safe narrowing.
165
+
166
+ - Mixed-OS run — runner raises a `RuntimeError` (plain
167
+ `raise '…'`) with a message that names the constraint. It is
168
+ caught by the existing `rescue StandardError => e` in `#run`, so
169
+ resource cleanup runs and the error is reported via the results
170
+ queue.
171
+
172
+ ## Non-goals
173
+
174
+ - **Per-test OS dispatch** in a single `cem_acpt` invocation (i.e.
175
+ actually supporting a mixed list). RFC 0005 explicitly defers this
176
+ to a follow-up; we are only closing the obvious correctness gap.
177
+ - Renaming or moving `Provision::Linux` / `Provision::Windows` /
178
+ `OsData`, or restructuring the provisioner.
179
+ - Touching `Utils::Puppet::ModulePackageBuilder` (which uses
180
+ `metadata['name'].include?('windows')` for a different purpose:
181
+ module packaging on the local machine, not test routing).
182
+ - Touching `spec/cem_acpt/test_runner_spec.rb`'s
183
+ `module_name.include?('windows')` (test fixture matcher, not
184
+ production logic).
185
+
186
+ ## Tests
187
+
188
+ ### New file: `spec/cem_acpt/provision/terraform/os_data_spec.rb`
189
+
190
+ `describe CemAcpt::Provision::OsData` covering `.os_family_for`:
191
+
192
+ 1. Returns `:linux` for a representative Linux test name
193
+ (`'cis_rhel-8_firewalld_server_2'`).
194
+ 2. Returns `:windows` for a representative Windows test name
195
+ (`'cis_windows-2022_firewall_server_2'`).
196
+ 3. Returns `:linux`, not `:windows`, for the substring-trap case
197
+ (`'cis_rhel-8_firewalld_windowserver_2'`).
198
+ 4. Raises `UnknownOsFamilyError` for an unrecognized OS
199
+ (`'cis_freebsd-14_…'`).
200
+ 5. Raises `UnknownOsFamilyError` for a name that doesn't match the
201
+ `^\w+_(\w+)-(\d+).*` regex at all.
202
+
203
+ The spec doesn't need a Config or run_data — `os_family_for` is a
204
+ pure module method.
205
+
206
+ ### Update: `spec/cem_acpt/test_runner_spec.rb`
207
+
208
+ Add a context that constructs a Runner with a mixed-OS `tests:`
209
+ list and asserts the run surfaces the mixed-OS error. The cleanest
210
+ way to test this without spinning up the full provisioning chain is
211
+ to call the partition logic via the same path `#run` uses — which
212
+ means we let the existing `pre_provision_test_nodes` /
213
+ `provision_test_nodes` / `provisioner_output` stubs from the
214
+ "successfully runs a test" test fire, then assert that
215
+ `@results.to_a.last` is the mixed-OS error.
216
+
217
+ Concretely, add inside the existing `'with a Runner object'`
218
+ context:
219
+
220
+ ```ruby
221
+ it 'records an error when tests mixes Windows and Linux' do
222
+ mixed_config = CemAcpt::Config::CemAcpt.new(
223
+ opts: { 'tests' => ['cis_windows-2022_firewall_server_2',
224
+ 'cis_rhel-8_firewalld_server_2'] },
225
+ load_user_config: false,
226
+ )
227
+ runner = CemAcpt::TestRunner::Runner.new(mixed_config)
228
+ allow(runner).to receive(:pre_provision_test_nodes).and_return(true)
229
+ allow(runner).to receive(:provision_test_nodes).and_return(true)
230
+ allow(runner).to receive(:provisioner_output).and_return(instance_names_ips)
231
+ allow(runner).to receive(:clean_up).and_return(true)
232
+ allow(runner).to receive(:process_test_results).and_return(true)
233
+ results = runner.run
234
+ expect(results.last).to be_a(StandardError)
235
+ expect(results.last.message).to match(/Mixed Windows and Linux/)
236
+ end
237
+ ```
238
+
239
+ This relies on `Runner#run`'s `rescue StandardError => e; @results << e`
240
+ behavior, which already exists.
241
+
242
+ ## Documentation
243
+
244
+ - `docs/ARCHITECTURE.md` §4 — replace the `if tests.first.include?('windows')`
245
+ bullet in the lifecycle diagram with text that describes
246
+ `OsData.os_family_for`-driven partition + the mixed-OS guard.
247
+ - `docs/ARCHITECTURE.md` §13 — replace the
248
+ "After Terraform reports back IPs, the runner branches on
249
+ `tests.first.include?('windows')`" sentence with the new dispatch
250
+ description.
251
+ - `docs/rfcs/0005-os-dispatch-replace-windows-heuristic.md` — flip
252
+ status from `Proposed` to `Implemented` and add an
253
+ "Implementation" note linking the commit / PR.
254
+
255
+ ## Acceptance criteria
256
+
257
+ - [ ] `Provision::OsData.os_family_for` exists with `:linux` /
258
+ `:windows` return values and raises `UnknownOsFamilyError` for
259
+ unknown names.
260
+ - [ ] `lib/cem_acpt/test_runner.rb` no longer references
261
+ `tests.first.include?` (or any other substring-on-test-name
262
+ heuristic) for OS dispatch.
263
+ - [ ] `Provision::Terraform#new_backend` consumes
264
+ `OsData.os_family_for` rather than calling
265
+ `Linux.use_for?` / `Windows.use_for?` directly.
266
+ - [ ] RSpec coverage for `os_family_for` (5 cases above) passes.
267
+ - [ ] RSpec coverage for the runner's mixed-OS error passes.
268
+ - [ ] `docs/ARCHITECTURE.md` §4 and §13 updated.
269
+ - [ ] RFC 0005 status flipped to `Implemented`.
270
+ - [ ] `bundle exec rake spec` passes.
271
+ - [ ] `rubocop` is clean on touched files.