cem_acpt 0.11.2 → 0.12.0

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.
@@ -7,7 +7,8 @@ require_relative 'bolt'
7
7
  require_relative 'goss'
8
8
  require_relative 'logging'
9
9
  require_relative 'platform'
10
- require_relative 'provision'
10
+ require_relative 'provision/terraform'
11
+ require_relative 'scan'
11
12
  require_relative 'test_data'
12
13
  require_relative 'utils'
13
14
  require_relative 'version'
@@ -53,6 +54,7 @@ module CemAcpt
53
54
  @old_dir = Dir.pwd
54
55
  Dir.chdir(module_dir)
55
56
  configure_actions
57
+ validate_scan_mode_constraints!
56
58
  logger.start_ci_group("CemAcpt v#{CemAcpt::VERSION} run started at #{@start_time}")
57
59
  logger.info('CemAcpt::TestRunner') { "Using module directory: #{module_dir}..." }
58
60
  pre_provision_test_nodes
@@ -116,9 +118,24 @@ module CemAcpt
116
118
 
117
119
  attr_reader :config
118
120
 
119
- # Configures the actions to run based on the config
121
+ # Configures the actions to run based on the config. Acceptance-mode
122
+ # configs register the existing :goss + :bolt groups; scan-mode configs
123
+ # register a single :scan group instead. The dispatch keys off
124
+ # `config.scan_mode?`, which is overridden to true on
125
+ # {Config::CemAcptScan} and false everywhere else.
120
126
  def configure_actions
121
127
  logger.info('CemAcpt::TestRunner') { 'Configuring and registering actions...' }
128
+ if config.respond_to?(:scan_mode?) && config.scan_mode?
129
+ configure_scan_actions
130
+ else
131
+ configure_acceptance_actions
132
+ end
133
+ logger.info('CemAcpt::TestRunner') do
134
+ "Configured and registered actions, will run actions: #{CemAcpt::Actions.config.action_names.join(', ')}"
135
+ end
136
+ end
137
+
138
+ def configure_acceptance_actions
122
139
  CemAcpt::Actions.configure(config) do |c|
123
140
  c.register_group(:goss, order: 0, async: true)
124
141
  CemAcpt::Goss::Api::ACTIONS.each_key do |a|
@@ -137,8 +154,15 @@ module CemAcpt
137
154
  run_bolt_tests(context)
138
155
  end
139
156
  end
140
- logger.info('CemAcpt::TestRunner') do
141
- "Configured and registered actions, will run actions: #{CemAcpt::Actions.config.action_names.join(', ')}"
157
+ end
158
+
159
+ def configure_scan_actions
160
+ CemAcpt::Actions.configure(config) do |c|
161
+ c.register_group(:scan, order: 0).register_action(:scan) do |context|
162
+ context[:results] = @results
163
+ run_scan_action
164
+ context[:results]
165
+ end
142
166
  end
143
167
  end
144
168
 
@@ -224,7 +248,7 @@ module CemAcpt
224
248
 
225
249
  def provision_test_nodes
226
250
  logger.info('CemAcpt::TestRunner') { 'Provisioning test nodes...' }
227
- @provisioner = CemAcpt::Provision.new_provisioner(config, @run_data)
251
+ @provisioner = CemAcpt::Provision::Terraform.new(config, @run_data)
228
252
  @provisioner.provision
229
253
  end
230
254
 
@@ -281,6 +305,80 @@ module CemAcpt
281
305
  @results << @bolt_test_runner.results
282
306
  end
283
307
 
308
+ # Per-host scan loop. For each provisioned instance, look up the test
309
+ # case it represents in the run's test_data, build a DaemonClient
310
+ # against the node, wait for the scan daemon to come up, hit /scan,
311
+ # and append the resulting {CemAcpt::Scan::Result} to @results. The
312
+ # JSON view of each result goes to stdout and to disk if
313
+ # `cem_acpt_scan.scan_output` is set.
314
+ def run_scan_action
315
+ logger.info('CemAcpt::TestRunner') { 'Running scan action...' }
316
+ port = config.get('cem_acpt_scan.daemon.port') || CemAcpt::Scan::DaemonClient::DEFAULT_PORT
317
+ ready_timeout = config.get('cem_acpt_scan.daemon.ready_timeout') || CemAcpt::Scan::DaemonClient::DEFAULT_READY_TIMEOUT
318
+ global_threshold = (config.get('cem_acpt_scan.threshold') || 80.0).to_f
319
+ per_case_thresholds = config.get('cem_acpt_scan.test_thresholds') || {}
320
+ scan_output = config.get('cem_acpt_scan.scan_output')
321
+
322
+ scan_results = []
323
+ @instance_names_ips.each do |_instance_name, host_data|
324
+ host = host_data['ip']
325
+ test_name = host_data['test_name']
326
+ scan_meta = scan_meta_for(test_name)
327
+ threshold = per_case_thresholds[test_name] || per_case_thresholds[test_name.to_sym] || global_threshold
328
+
329
+ client = CemAcpt::Scan::DaemonClient.new(host: host, port: port, ready_timeout: ready_timeout)
330
+ client.wait_until_ready
331
+ result = client.scan(
332
+ test_case: test_name,
333
+ scanner: scan_meta[:scanner],
334
+ profile: scan_meta[:profile],
335
+ threshold: threshold.to_f,
336
+ )
337
+ scan_results << result
338
+ @results << result
339
+ end
340
+
341
+ write_scan_output!(scan_output, scan_results) if scan_output
342
+ end
343
+
344
+ # Writes scan results to disk per the `--scan-output FILE` contract:
345
+ # single-case runs write `FILE`, multi-case runs fan out to
346
+ # `FILE.<test_case>.json` so reports don't collide.
347
+ def write_scan_output!(path, scan_results)
348
+ if scan_results.size <= 1
349
+ File.write(path, JSON.pretty_generate(scan_results.first&.to_h || {}))
350
+ else
351
+ scan_results.each do |r|
352
+ File.write("#{path}.#{r.test_case}.json", JSON.pretty_generate(r.to_h))
353
+ end
354
+ end
355
+ end
356
+
357
+ # Fail early in scan mode if any requested test is a Windows test or
358
+ # if a required CIS-CAT Pro source is missing. Catches misconfiguration
359
+ # before we spend time provisioning a node we cannot use.
360
+ def validate_scan_mode_constraints!
361
+ return unless config.respond_to?(:scan_mode?) && config.scan_mode?
362
+
363
+ tests = config.get('tests') || []
364
+ windows_tests = tests.select { |t| CemAcpt::Provision::OsData.os_family_for(t) == :windows }
365
+ unless windows_tests.empty?
366
+ raise "Windows scanning is not supported by cem_acpt_scan in this version (got: #{windows_tests.join(', ')})"
367
+ end
368
+ end
369
+
370
+ def scan_meta_for(test_name)
371
+ td = (@run_data[:test_data] || []).find { |t| (t[:test_name] || t['test_name']) == test_name }
372
+ scan_block = td && (td[:scan] || td['scan'])
373
+ raise "No scan metadata found for test '#{test_name}'" unless scan_block
374
+
375
+ {
376
+ scanner: (scan_block[:scanner] || scan_block['scanner']).to_sym,
377
+ profile: scan_block[:profile] || scan_block['profile'],
378
+ level: scan_block[:level] || scan_block['level'],
379
+ }
380
+ end
381
+
284
382
  def filtered_bolt_hosts
285
383
  tests_only = config.get('bolt.tests.only')
286
384
  tests_only_unset = tests_only.nil? || tests_only.empty?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CemAcpt
4
- VERSION = '0.11.2'
4
+ VERSION = '0.12.0'
5
5
  end
data/lib/cem_acpt.rb CHANGED
@@ -4,6 +4,7 @@ module CemAcpt
4
4
  require_relative 'cem_acpt/config'
5
5
  require_relative 'cem_acpt/logging'
6
6
  require_relative 'cem_acpt/image_builder'
7
+ require_relative 'cem_acpt/scan'
7
8
  require_relative 'cem_acpt/test_runner'
8
9
  require_relative 'cem_acpt/version'
9
10
 
@@ -34,6 +35,8 @@ module CemAcpt
34
35
  trace_it(options[:trace], options[:trace_events]) { run_cem_acpt(options) }
35
36
  when :cem_acpt_image
36
37
  trace_it(options[:trace], options[:trace_events]) { run_cem_acpt_image(options) }
38
+ when :cem_acpt_scan
39
+ trace_it(options[:trace], options[:trace_events]) { run_cem_acpt_scan(options) }
37
40
  else
38
41
  raise "Command #{command} does not exist"
39
42
  end
@@ -47,6 +50,8 @@ module CemAcpt
47
50
  CemAcpt::Config::CemAcpt.new(opts: options, config_file: options[:config_file])
48
51
  when :cem_acpt_image
49
52
  CemAcpt::Config::CemAcptImage.new(opts: options, config_file: options[:config_file])
53
+ when :cem_acpt_scan
54
+ CemAcpt::Config::CemAcptScan.new(opts: options, config_file: options[:config_file])
50
55
  else
51
56
  raise "Config does not exist for command: #{command}"
52
57
  end
@@ -157,5 +162,18 @@ module CemAcpt
157
162
  logger.debug('CemAcptImage') { 'Building images...' }
158
163
  CemAcpt::ImageBuilder.build_images(@config)
159
164
  end
165
+
166
+ def run_cem_acpt_scan(options)
167
+ @config = new_config(options, command: :cem_acpt_scan)
168
+ initialize_logger!
169
+ logger.debug('CemAcptScan') { 'Config set and logger initialized...' }
170
+ runner = new_runner
171
+ logger.debug('CemAcptScan') { 'Runner initialized in scan mode...' }
172
+
173
+ logger.debug('CemAcptScan') { 'Running scan suite...' }
174
+ runner.run
175
+ logger.debug('CemAcptScan') { "Scan suite run complete, exiting with code #{runner.exit_code}" }
176
+ exit runner.exit_code
177
+ end
160
178
  end
161
179
  end
@@ -2,7 +2,7 @@ terraform {
2
2
  required_providers {
3
3
  google = {
4
4
  source = "hashicorp/google"
5
- version = "7.24.0"
5
+ version = "7.31.0"
6
6
  }
7
7
  }
8
8
  }
@@ -56,6 +56,21 @@ variable "node_data" {
56
56
  provision_dir_source = string
57
57
  provision_dir_dest = string
58
58
  provision_commands = list(string)
59
+ # Scan-mode fields. Empty strings in acceptance mode; populated by
60
+ # cem_acpt_scan to upload the CIS-CAT Pro bundle, the CIS-CAT Pro
61
+ # license, and the scan daemon config. The scan_config_upload and
62
+ # cis_cat_pro_upload null_resources below filter on these.
63
+ # cis_cat_pro_format and cis_cat_pro_license_format are each "tar.gz"
64
+ # or "zip" depending on what the vendor ships; the cis_cat_pro_upload
65
+ # null_resource dispatches to the matching extractor for each. Ruby
66
+ # always populates the *_format fields alongside their *_bundle /
67
+ # *_license paths via archive_format, so the empty default on
68
+ # cis_cat_pro_license_format is purely defensive.
69
+ cis_cat_pro_bundle = optional(string, "")
70
+ cis_cat_pro_format = optional(string, "tar.gz")
71
+ cis_cat_pro_license = optional(string, "")
72
+ cis_cat_pro_license_format = optional(string, "")
73
+ scan_config_json = optional(string, "")
59
74
  }))
60
75
  }
61
76
 
@@ -202,6 +217,119 @@ resource "google_compute_instance" "acpt-test-node" {
202
217
  tags = ["cem-acpt-test-node"]
203
218
  }
204
219
 
220
+ # Scan-mode config upload. for_each filters to nodes whose node_data
221
+ # carries a non-empty scan_config_json, so this resource is a no-op for
222
+ # cem_acpt acceptance runs and for any scan-mode node that does not need
223
+ # scan_config.json materialized.
224
+ resource "null_resource" "scan_config_upload" {
225
+ for_each = {
226
+ for k, v in var.node_data : k => v if v.scan_config_json != ""
227
+ }
228
+
229
+ triggers = {
230
+ instance_id = google_compute_instance.acpt-test-node[each.key].id
231
+ }
232
+
233
+ connection {
234
+ type = "ssh"
235
+ user = var.username
236
+ timeout = "5m"
237
+ host = google_compute_instance.acpt-test-node[each.key].network_interface.0.access_config.0.nat_ip
238
+ port = 22
239
+ private_key = file(var.private_key)
240
+ agent = false
241
+ script_path = "/var/tmp/terraform_%RAND%.sh"
242
+ }
243
+
244
+ provisioner "remote-exec" {
245
+ inline = [
246
+ "sudo mkdir -p ${each.value.provision_dir_dest}/scan",
247
+ "sudo chown -R ${var.username}:${var.username} ${each.value.provision_dir_dest}/scan",
248
+ ]
249
+ }
250
+
251
+ provisioner "file" {
252
+ content = each.value.scan_config_json
253
+ destination = "${each.value.provision_dir_dest}/scan/scan_config.json"
254
+ }
255
+ }
256
+
257
+ # CIS-CAT Pro bundle upload. Filtered separately so OpenSCAP-only scan
258
+ # runs do not require a CIS-CAT Pro bundle to be present.
259
+ resource "null_resource" "cis_cat_pro_upload" {
260
+ for_each = {
261
+ for k, v in var.node_data : k => v if v.cis_cat_pro_bundle != ""
262
+ }
263
+
264
+ triggers = {
265
+ instance_id = google_compute_instance.acpt-test-node[each.key].id
266
+ }
267
+
268
+ connection {
269
+ type = "ssh"
270
+ user = var.username
271
+ timeout = "10m"
272
+ host = google_compute_instance.acpt-test-node[each.key].network_interface.0.access_config.0.nat_ip
273
+ port = 22
274
+ private_key = file(var.private_key)
275
+ agent = false
276
+ script_path = "/var/tmp/terraform_%RAND%.sh"
277
+ }
278
+
279
+ provisioner "file" {
280
+ source = each.value.cis_cat_pro_bundle
281
+ destination = "${each.value.provision_dir_dest}/cis-cat-pro.${each.value.cis_cat_pro_format}"
282
+ }
283
+
284
+ # Extract immediately so /opt/cis-cat-pro/Assessor-CLI.sh exists by the time
285
+ # the host-side scan daemon hits /scan. Provisioners within a single
286
+ # resource run sequentially, so the file is guaranteed to be on disk before
287
+ # the extraction commands run. The vendor packages a single top-level
288
+ # Assessor-CLI-<version>/ directory in either format; tar's
289
+ # --strip-components=1 strips it directly, while the zip path stages into a
290
+ # temp dir and moves the inner directory's contents (including dotfiles via
291
+ # `shopt -s dotglob`) up into /opt/cis-cat-pro/.
292
+ provisioner "remote-exec" {
293
+ inline = each.value.cis_cat_pro_format == "zip" ? [
294
+ "sudo dnf install -y unzip || sudo apt-get install -y unzip",
295
+ "sudo mkdir -p /opt/cis-cat-pro",
296
+ "sudo rm -rf /tmp/cis-cat-pro-extract",
297
+ "sudo unzip -q -o ${each.value.provision_dir_dest}/cis-cat-pro.zip -d /tmp/cis-cat-pro-extract",
298
+ "sudo bash -c 'shopt -s dotglob && mv /tmp/cis-cat-pro-extract/*/* /opt/cis-cat-pro/'",
299
+ "sudo rm -rf /tmp/cis-cat-pro-extract",
300
+ "sudo chmod +x /opt/cis-cat-pro/Assessor-CLI.sh",
301
+ ] : [
302
+ "sudo mkdir -p /opt/cis-cat-pro",
303
+ "sudo tar -xzf ${each.value.provision_dir_dest}/cis-cat-pro.tar.gz -C /opt/cis-cat-pro --strip-components=1",
304
+ "sudo chmod +x /opt/cis-cat-pro/Assessor-CLI.sh",
305
+ ]
306
+ }
307
+
308
+ # License upload + extract. Provisioners 1 and 2 (above) place the
309
+ # assessor at /opt/cis-cat-pro/Assessor-CLI.sh; provisioners 3 and 4
310
+ # (below) place the license bundle's contents at /opt/cis-cat-pro/license/.
311
+ # The vendor's license archive contents are dropped in as-packaged: tar
312
+ # uses no --strip-components and zip extracts straight into the license
313
+ # dir, so any subdirectories the vendor ships are preserved. Permissions
314
+ # are left at the archive defaults until a real end-to-end scan tells us
315
+ # what the assessor actually wants.
316
+ provisioner "file" {
317
+ source = each.value.cis_cat_pro_license
318
+ destination = "${each.value.provision_dir_dest}/cis-cat-pro-license.${each.value.cis_cat_pro_license_format}"
319
+ }
320
+
321
+ provisioner "remote-exec" {
322
+ inline = each.value.cis_cat_pro_license_format == "zip" ? [
323
+ "sudo dnf install -y unzip || sudo apt-get install -y unzip",
324
+ "sudo mkdir -p /opt/cis-cat-pro/license",
325
+ "sudo unzip -q -o ${each.value.provision_dir_dest}/cis-cat-pro-license.zip -d /opt/cis-cat-pro/license",
326
+ ] : [
327
+ "sudo mkdir -p /opt/cis-cat-pro/license",
328
+ "sudo tar -xzf ${each.value.provision_dir_dest}/cis-cat-pro-license.tar.gz -C /opt/cis-cat-pro/license",
329
+ ]
330
+ }
331
+ }
332
+
205
333
  output "instance_name_ip" {
206
334
  value = {
207
335
  for k, v in google_compute_instance.acpt-test-node : v.name => {
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # On-node scan daemon installed by cem_acpt_scan. Runs as a systemd unit
5
+ # and exposes /health and /scan endpoints over HTTP. The host-side
6
+ # CemAcpt::Scan::DaemonClient hits these endpoints to drive the scan and
7
+ # pull back results.
8
+ #
9
+ # scan_config.json is uploaded to /opt/cem_acpt/scan/scan_config.json by the
10
+ # scan_config_upload null_resource in main.tf and carries the resolved
11
+ # scanner choice, profile id, and level for the test case running on this
12
+ # node. The daemon reads it on each /scan call so reruns pick up rewritten
13
+ # config without restart.
14
+
15
+ require 'fileutils'
16
+ require 'json'
17
+ require 'open3'
18
+ require 'rexml/document'
19
+ require 'webrick'
20
+
21
+ CONFIG_PATH = '/opt/cem_acpt/scan/scan_config.json'
22
+ REPORT_DIR = '/opt/cem_acpt/scan/reports'
23
+ FileUtils.mkdir_p(REPORT_DIR)
24
+
25
+ def load_config
26
+ JSON.parse(File.read(CONFIG_PATH))
27
+ rescue StandardError => e
28
+ { 'error' => "Cannot read #{CONFIG_PATH}: #{e.class}: #{e.message}" }
29
+ end
30
+
31
+ def run_openscap(profile, datastream)
32
+ xml_out = File.join(REPORT_DIR, 'openscap-results.xml')
33
+ html_out = File.join(REPORT_DIR, 'openscap-results.html')
34
+ cmd = [
35
+ 'sudo', 'oscap', 'xccdf', 'eval',
36
+ '--profile', profile,
37
+ '--results', xml_out,
38
+ '--report', html_out,
39
+ datastream,
40
+ ]
41
+ _stdout, stderr, status = Open3.capture3(*cmd)
42
+ parsed = parse_openscap_xml(xml_out)
43
+ unless status.success? || File.exist?(xml_out)
44
+ oscap_msg = "oscap exited #{status.exitstatus}: #{stderr.strip}"
45
+ parsed['error'] = parsed['error'] ? "#{oscap_msg}; #{parsed['error']}" : oscap_msg
46
+ end
47
+ parsed
48
+ end
49
+
50
+ def parse_openscap_xml(path)
51
+ return { 'error' => "Result file missing: #{path}" } unless File.exist?(path)
52
+
53
+ doc = REXML::Document.new(File.read(path))
54
+ results = REXML::XPath.match(doc, '//rule-result').map do |r|
55
+ {
56
+ 'id' => r.attributes['idref'],
57
+ 'severity' => r.attributes['severity'],
58
+ 'result' => REXML::XPath.first(r, 'result')&.text,
59
+ }
60
+ end
61
+ score_el = REXML::XPath.first(doc, '//score')
62
+ score = score_el ? score_el.text.to_f : 0.0
63
+ counts = results.group_by { |r| r['result'] }.transform_values(&:size)
64
+ {
65
+ 'score' => score,
66
+ 'passed_count' => counts['pass'] || 0,
67
+ 'failed_count' => counts['fail'] || 0,
68
+ 'not_applicable_count' => (counts['notapplicable'] || 0) + (counts['notselected'] || 0),
69
+ 'error_count' => (counts['error'] || 0) + (counts['unknown'] || 0),
70
+ 'rules' => results,
71
+ }
72
+ end
73
+
74
+ def run_ciscat(profile, benchmark)
75
+ json_out = File.join(REPORT_DIR, 'ciscat-results.json')
76
+ cmd = [
77
+ 'sudo', '/opt/cis-cat-pro/Assessor-CLI.sh',
78
+ '-rd', REPORT_DIR,
79
+ '-rp', 'ciscat-results',
80
+ '-nts',
81
+ '-json',
82
+ '-p', profile,
83
+ ]
84
+ cmd += ['-b', "/opt/cis-cat-pro/benchmarks/#{benchmark}"] if benchmark
85
+ _stdout, stderr, status = Open3.capture3(*cmd)
86
+ parsed = parse_ciscat_json(json_out)
87
+ unless status.success? || File.exist?(json_out)
88
+ assessor_msg = "Assessor-CLI exited #{status.exitstatus}: #{stderr.strip}"
89
+ parsed['error'] = parsed['error'] ? "#{assessor_msg}; #{parsed['error']}" : assessor_msg
90
+ end
91
+ parsed
92
+ end
93
+
94
+ def parse_ciscat_json(path)
95
+ return { 'error' => "Result file missing: #{path}" } unless File.exist?(path)
96
+
97
+ raw = JSON.parse(File.read(path))
98
+ results = (raw['rules'] || []).map do |r|
99
+ {
100
+ 'id' => r['rule-id'],
101
+ 'result' => r['result'],
102
+ }
103
+ end
104
+ score = (raw['score'] || 0.0).to_f
105
+ counts = results.group_by { |r| (r['result'] || '').to_s.downcase }.transform_values(&:size)
106
+ {
107
+ 'score' => score,
108
+ 'passed_count' => counts['pass'] || 0,
109
+ 'failed_count' => counts['fail'] || 0,
110
+ 'not_applicable_count' => (counts['notchecked'] || 0) + (counts['notapplicable'] || 0),
111
+ 'error_count' => (counts['error'] || 0) + (counts['informational'] || 0),
112
+ 'rules' => results,
113
+ }
114
+ end
115
+
116
+ def perform_scan
117
+ cfg = load_config
118
+ return cfg if cfg['error']
119
+
120
+ case cfg['scanner']
121
+ when 'openscap'
122
+ run_openscap(cfg['profile'], cfg['datastream'] || '/usr/share/xml/scap/ssg/content/ssg-rhel8-ds.xml')
123
+ when 'ciscat'
124
+ run_ciscat(cfg['profile'], cfg['benchmark'])
125
+ else
126
+ { 'error' => "Unknown scanner '#{cfg['scanner']}'" }
127
+ end
128
+ end
129
+
130
+ port = ENV.fetch('SCAN_DAEMON_PORT', '8084').to_i
131
+ server = WEBrick::HTTPServer.new(Port: port, BindAddress: '0.0.0.0')
132
+
133
+ server.mount_proc('/health') do |_req, res|
134
+ res.status = 200
135
+ res['Content-Type'] = 'application/json'
136
+ res.body = JSON.generate('status' => 'ok')
137
+ end
138
+
139
+ server.mount_proc('/scan') do |_req, res|
140
+ payload = perform_scan
141
+ res.status = payload['error'] ? 500 : 200
142
+ res['Content-Type'] = 'application/json'
143
+ res.body = JSON.generate(payload)
144
+ end
145
+
146
+ trap('INT') { server.shutdown }
147
+ trap('TERM') { server.shutdown }
148
+ server.start
@@ -0,0 +1,12 @@
1
+ [Unit]
2
+ Description=cem_acpt scan daemon
3
+ After=network.target
4
+
5
+ [Service]
6
+ Type=simple
7
+ Environment=SCAN_DAEMON_PORT=8084
8
+ ExecStart=/opt/puppetlabs/puppet/bin/ruby /opt/cem_acpt/scan/scan_service.rb
9
+ Restart=on-failure
10
+
11
+ [Install]
12
+ WantedBy=multi-user.target
@@ -2,7 +2,7 @@ terraform {
2
2
  required_providers {
3
3
  google = {
4
4
  source = "hashicorp/google"
5
- version = "7.24.0"
5
+ version = "7.31.0"
6
6
  }
7
7
  }
8
8
  }
@@ -2,7 +2,7 @@ terraform {
2
2
  required_providers {
3
3
  google = {
4
4
  source = "hashicorp/google"
5
- version = "7.24.0"
5
+ version = "7.31.0"
6
6
  }
7
7
  }
8
8
  }