cpflow 5.3.0 → 6.0.0.rc.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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/agent-workflow.yml +13 -0
  3. data/.agents/bin/README.md +12 -1
  4. data/.agents/bin/docs +1 -1
  5. data/.agents/bin/lint +1 -1
  6. data/.agents/bin/setup +1 -1
  7. data/.agents/bin/test +1 -1
  8. data/.agents/bin/validate +2 -1
  9. data/.coderabbit.yaml +13 -0
  10. data/.github/actions/cpflow-delete-control-plane-app/action.yml +2 -1
  11. data/.github/actions/cpflow-setup-environment/action.yml +8 -6
  12. data/.github/dependabot.yml +10 -0
  13. data/.github/workflows/check_cpln_links.yml +6 -1
  14. data/.github/workflows/claude-code-review.yml +5 -2
  15. data/.github/workflows/claude.yml +6 -4
  16. data/.github/workflows/coderabbit-lifecycle-review.yml +29 -0
  17. data/.github/workflows/command_docs.yml +7 -2
  18. data/.github/workflows/cpflow-cleanup-stale-review-apps.yml +12 -5
  19. data/.github/workflows/cpflow-delete-review-app.yml +18 -11
  20. data/.github/workflows/cpflow-deploy-review-app.yml +25 -16
  21. data/.github/workflows/cpflow-deploy-staging.yml +16 -10
  22. data/.github/workflows/cpflow-help-command.yml +3 -3
  23. data/.github/workflows/cpflow-promote-staging-to-production.yml +7 -7
  24. data/.github/workflows/cpflow-review-app-help.yml +1 -1
  25. data/.github/workflows/rspec-shared.yml +34 -26
  26. data/.github/workflows/rspec-specific.yml +10 -2
  27. data/.github/workflows/rspec.yml +71 -4
  28. data/.github/workflows/rubocop.yml +9 -2
  29. data/.github/workflows/trigger-docs-site.yml +2 -0
  30. data/.rubocop.yml +6 -1
  31. data/CHANGELOG.md +24 -1
  32. data/CONTRIBUTING.md +39 -2
  33. data/Gemfile.lock +1 -1
  34. data/README.md +6 -1
  35. data/cpflow.gemspec +3 -15
  36. data/docs/ai-github-flow-prompt.md +2 -2
  37. data/docs/ci-automation.md +112 -60
  38. data/docs/commands.md +18 -8
  39. data/docs/rds-private-networking.md +12 -12
  40. data/docs/releasing.md +15 -4
  41. data/examples/controlplane.yml +5 -0
  42. data/lib/command/ai_github_flow_prompt.rb +1 -1
  43. data/lib/command/cleanup_stale_apps.rb +28 -4
  44. data/lib/command/copy_image_from_upstream.rb +3 -0
  45. data/lib/command/deploy_image.rb +38 -1
  46. data/lib/command/generate_github_actions.rb +36 -12
  47. data/lib/command/run.rb +224 -34
  48. data/lib/command/update_github_actions.rb +7 -6
  49. data/lib/core/controlplane.rb +145 -78
  50. data/lib/core/shell.rb +35 -10
  51. data/lib/core/timed_command.rb +111 -0
  52. data/lib/cpflow/version.rb +1 -1
  53. data/lib/cpflow.rb +1 -1
  54. data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +7 -7
  55. data/lib/github_flow_templates/bin/test-cpflow-github-flow +63 -3
  56. data/lib/patches/hash.rb +2 -2
  57. data/rakelib/create_release.rake +14 -14
  58. data/script/check_shell_scripts +66 -0
  59. metadata +11 -18
@@ -24,18 +24,18 @@ class Controlplane # rubocop:disable Metrics/ClassLength
24
24
  end
25
25
 
26
26
  def profile_exists?(profile)
27
- cmd = "cpln profile get #{profile} -o yaml"
27
+ cmd = ["cpln", "profile", "get", profile, "-o", "yaml"]
28
28
  perform_yaml!(cmd).length.positive?
29
29
  end
30
30
 
31
31
  def profile_create(profile, token)
32
- sensitive_data_pattern = /(?<=--token )(\S+)/
33
- cmd = "cpln profile create #{profile} --token #{token}"
32
+ sensitive_data_pattern = /(?<=--token ).+\z/m
33
+ cmd = ["cpln", "profile", "create", profile, "--token", token]
34
34
  perform!(cmd, sensitive_data_pattern: sensitive_data_pattern)
35
35
  end
36
36
 
37
37
  def profile_delete(profile)
38
- cmd = "cpln profile delete #{profile}"
38
+ cmd = ["cpln", "profile", "delete", profile]
39
39
  perform!(cmd)
40
40
  end
41
41
 
@@ -102,7 +102,7 @@ class Controlplane # rubocop:disable Metrics/ClassLength
102
102
  build_args.each { |build_arg| cmd.push("--build-arg", build_arg) }
103
103
  cmd << docker_context
104
104
 
105
- perform!(Shellwords.join(cmd))
105
+ perform!(cmd)
106
106
  end
107
107
 
108
108
  def fetch_image_details(image)
@@ -114,22 +114,22 @@ class Controlplane # rubocop:disable Metrics/ClassLength
114
114
  end
115
115
 
116
116
  def image_login(org_name = config.org)
117
- cmd = "cpln image docker-login --org #{org_name}"
117
+ cmd = ["cpln", "image", "docker-login", "--org", org_name]
118
118
  perform!(cmd, output_mode: :none)
119
119
  end
120
120
 
121
121
  def image_pull(image)
122
- cmd = "docker pull #{image}"
122
+ cmd = ["docker", "pull", image]
123
123
  perform!(cmd, output_mode: :none)
124
124
  end
125
125
 
126
126
  def image_tag(old_tag, new_tag)
127
- cmd = "docker tag #{old_tag} #{new_tag}"
127
+ cmd = ["docker", "tag", old_tag, new_tag]
128
128
  perform!(cmd)
129
129
  end
130
130
 
131
131
  def image_push(image)
132
- cmd = "docker push #{image}"
132
+ cmd = ["docker", "push", image]
133
133
  perform!(cmd)
134
134
  end
135
135
 
@@ -144,7 +144,7 @@ class Controlplane # rubocop:disable Metrics/ClassLength
144
144
  # otherwise we query for a gvc with the exact name.
145
145
  op = config.should_app_start_with?(app_name) ? "~" : "="
146
146
 
147
- cmd = "cpln gvc query --org #{org} -o yaml --prop name#{op}#{app_name}"
147
+ cmd = ["cpln", "gvc", "query", "--org", org, "-o", "yaml", "--prop", "name#{op}#{app_name}"]
148
148
  perform_yaml!(cmd)
149
149
  end
150
150
 
@@ -173,8 +173,8 @@ class Controlplane # rubocop:disable Metrics/ClassLength
173
173
  api.workload_list_by_org(org: a_org)
174
174
  end
175
175
 
176
- def fetch_workload(workload)
177
- api.workload_get(workload: workload, gvc: gvc, org: org)
176
+ def fetch_workload(workload, a_gvc = gvc)
177
+ api.workload_get(workload: workload, gvc: a_gvc, org: org)
178
178
  end
179
179
 
180
180
  def fetch_workload_with_status(workload)
@@ -199,12 +199,12 @@ class Controlplane # rubocop:disable Metrics/ClassLength
199
199
  end
200
200
  private :workload_status_result
201
201
 
202
- def fetch_workload!(workload)
203
- workload_data = fetch_workload(workload)
202
+ def fetch_workload!(workload, a_gvc = gvc)
203
+ workload_data = fetch_workload(workload, a_gvc)
204
204
  return workload_data if workload_data
205
205
 
206
206
  raise "Can't find workload '#{workload}', " \
207
- "please create it with 'cpflow apply-template #{workload} -a #{config.app}'."
207
+ "please create it with 'cpflow apply-template #{workload} -a #{a_gvc}'."
208
208
  end
209
209
 
210
210
  def query_workloads(workload, a_gvc = gvc, a_org = org, partial_workload_match: false, partial_gvc_match: nil)
@@ -216,12 +216,18 @@ class Controlplane # rubocop:disable Metrics/ClassLength
216
216
  end
217
217
 
218
218
  def fetch_workload_replicas(workload, location:)
219
- cmd = "cpln workload replica get #{workload} #{gvc_org} --location #{location} -o yaml 2> /dev/null"
220
- perform_yaml(cmd)
219
+ cmd = [
220
+ "cpln", "workload", "replica", "get", workload,
221
+ *gvc_org_args, "--location", location, "-o", "yaml"
222
+ ]
223
+ perform_yaml(cmd, err: File::NULL)
221
224
  end
222
225
 
223
226
  def stop_workload_replica(workload, replica, location:)
224
- cmd = "cpln workload replica stop #{workload} #{gvc_org} --replica-name #{replica} --location #{location}"
227
+ cmd = [
228
+ "cpln", "workload", "replica", "stop", workload,
229
+ *gvc_org_args, "--replica-name", replica, "--location", location
230
+ ]
225
231
  perform(cmd, output_mode: :none)
226
232
  end
227
233
 
@@ -252,9 +258,13 @@ class Controlplane # rubocop:disable Metrics/ClassLength
252
258
  end
253
259
 
254
260
  def workload_set_image_ref(workload, container:, image:)
255
- cmd = "cpln workload update #{workload} #{gvc_org}"
256
- cmd += " --set spec.containers.#{container}.image=/org/#{config.org}/image/#{image}"
257
- perform!(cmd)
261
+ cmd = [
262
+ "cpln", "workload", "update", workload,
263
+ "--gvc", gvc, "--org", org,
264
+ "--set", "spec.containers.#{container}.image=/org/#{config.org}/image/#{image}"
265
+ ]
266
+ Shell.debug("CMD", Shellwords.join(cmd))
267
+ perform_with_output(cmd)
258
268
  end
259
269
 
260
270
  def set_workload_env_var(workload, container:, name:, value:)
@@ -272,11 +282,16 @@ class Controlplane # rubocop:disable Metrics/ClassLength
272
282
  api.update_workload(org: org, gvc: gvc, workload: workload, data: data)
273
283
  end
274
284
 
275
- def set_workload_suspend(workload, value)
276
- data = fetch_workload!(workload)
285
+ def set_workload_suspend(workload, value, a_gvc = gvc, missing_ok: false)
286
+ data = missing_ok ? fetch_workload(workload, a_gvc) : fetch_workload!(workload, a_gvc)
287
+ return true if missing_ok && data.nil?
288
+
277
289
  data["spec"]["defaultOptions"]["suspend"] = value
278
290
 
279
- api.update_workload(org: org, gvc: gvc, workload: workload, data: data)
291
+ result = api.update_workload(org: org, gvc: a_gvc, workload: workload, data: data)
292
+ return true if missing_ok && result.nil?
293
+
294
+ result
280
295
  end
281
296
 
282
297
  def workload_suspended?(workload)
@@ -285,7 +300,7 @@ class Controlplane # rubocop:disable Metrics/ClassLength
285
300
  end
286
301
 
287
302
  def workload_force_redeployment(workload)
288
- cmd = "cpln workload force-redeployment #{workload} #{gvc_org}"
303
+ cmd = ["cpln", "workload", "force-redeployment", workload, *gvc_org_args]
289
304
  perform!(cmd)
290
305
  end
291
306
 
@@ -294,9 +309,9 @@ class Controlplane # rubocop:disable Metrics/ClassLength
294
309
  end
295
310
 
296
311
  def workload_connect(workload, location:, container: nil, shell: nil)
297
- cmd = "cpln workload connect #{workload} #{gvc_org} --location #{location}"
298
- cmd += " --container #{container}" if container
299
- cmd += " --shell #{shell}" if shell
312
+ cmd = ["cpln", "workload", "connect", workload, *gvc_org_args, "--location", location]
313
+ cmd.push("--container", container) if container
314
+ cmd.push("--shell", shell) if shell
300
315
  perform!(cmd, output_mode: :all)
301
316
  end
302
317
 
@@ -317,14 +332,20 @@ class Controlplane # rubocop:disable Metrics/ClassLength
317
332
  f.write(job_start_yaml)
318
333
  f.rewind
319
334
 
320
- cmd = "cpln workload cron start #{workload} #{gvc_org} --file #{f.path} --location #{location} -o yaml"
335
+ cmd = [
336
+ "cpln", "workload", "cron", "start", workload,
337
+ *gvc_org_args, "--file", f.path, "--location", location, "-o", "yaml"
338
+ ]
321
339
  perform_yaml(cmd)
322
340
  end
323
341
  end
324
342
 
325
- def fetch_cron_workload(workload, location:)
326
- cmd = "cpln workload cron get #{workload} #{gvc_org} --location #{location} -o yaml"
327
- perform_yaml(cmd)
343
+ def fetch_cron_workload(workload, location:, timeout_seconds: nil)
344
+ cmd = [
345
+ "cpln", "workload", "cron", "get", workload,
346
+ *gvc_org_args, "--location", location, "-o", "yaml"
347
+ ]
348
+ perform_yaml(cmd, timeout_seconds: timeout_seconds)
328
349
  end
329
350
 
330
351
  def cron_workload_deployed_version(workload)
@@ -396,7 +417,10 @@ class Controlplane # rubocop:disable Metrics/ClassLength
396
417
  query_parts.push("replica=\"#{replica}\"") if replica
397
418
  query = "{#{query_parts.join(',')}}"
398
419
 
399
- cmd = "cpln logs '#{query}' --org #{org} -t -o raw --limit #{limit} --since #{since}"
420
+ cmd = [
421
+ "cpln", "logs", query, "--org", org, "-t", "-o", "raw",
422
+ "--limit", limit.to_s, "--since", since.to_s
423
+ ]
400
424
  perform!(cmd, output_mode: :all)
401
425
  end
402
426
 
@@ -433,7 +457,7 @@ class Controlplane # rubocop:disable Metrics/ClassLength
433
457
  "--identity", identity_link,
434
458
  "--permission", "reveal"
435
459
  ]
436
- perform!(Shellwords.join(cmd))
460
+ perform!(cmd)
437
461
  end
438
462
 
439
463
  def unbind_identity_from_policy(identity_link, policy, permission: "reveal")
@@ -443,33 +467,25 @@ class Controlplane # rubocop:disable Metrics/ClassLength
443
467
  "--identity", identity_link,
444
468
  "--permission", permission
445
469
  ]
446
- perform!(Shellwords.join(cmd))
470
+ perform!(cmd)
447
471
  end
448
472
 
449
473
  # apply
450
- def apply_template(data, wait: false) # rubocop:disable Metrics/MethodLength, Metrics/PerceivedComplexity
474
+ def apply_template(data, wait: false) # rubocop:disable Metrics/MethodLength
451
475
  Tempfile.create do |f|
452
476
  f.write(data)
453
477
  f.rewind
454
- cmd = "cpln apply #{gvc_org} --file #{f.path}"
455
- cmd += " --ready" if wait && ENV.fetch("DISABLE_APPLY_READY", nil).nil?
456
- if Shell.tmp_stderr
457
- cmd += " 2> #{Shell.tmp_stderr.path}" if Shell.should_hide_output?
478
+ cmd = ["cpln", "apply", *gvc_org_args, "--file", f.path]
479
+ cmd << "--ready" if wait && ENV.fetch("DISABLE_APPLY_READY", nil).nil?
480
+ spawn_options = {}
481
+ spawn_options[:err] = Shell.tmp_stderr if Shell.should_hide_output?
458
482
 
459
- Shell.debug("CMD", cmd)
483
+ Shell.debug("CMD", Shellwords.join(cmd))
460
484
 
461
- result = Shell.cmd(cmd)
462
- parse_apply_result(result[:output]) if result[:success]
463
- else
464
- Shell.debug("CMD", cmd)
485
+ result = Shell.cmd(*cmd, **spawn_options)
486
+ return parse_apply_result(result[:output]) if result[:success]
465
487
 
466
- result = Shell.cmd(cmd)
467
- if result[:success]
468
- parse_apply_result(result[:output])
469
- else
470
- Shell.abort("Command exited with non-zero status.")
471
- end
472
- end
488
+ Shell.abort("Command exited with non-zero status.") unless Shell.tmp_stderr
473
489
  end
474
490
  end
475
491
 
@@ -535,23 +551,15 @@ class Controlplane # rubocop:disable Metrics/ClassLength
535
551
  "or ensure that the name is correct."
536
552
  end
537
553
 
538
- # `output_mode` can be :all, :errors_only or :none.
539
- # If not provided, it will be determined based on the `HIDE_COMMAND_OUTPUT` env var
540
- # or the return value of `Shell.should_hide_output?`.
541
- def build_command(cmd, output_mode: nil) # rubocop:disable Metrics/MethodLength
554
+ # `output_mode` can be :all, :errors_only or :none. Redirection is expressed as
555
+ # Process.spawn options so argv commands never need to pass through a shell.
556
+ def command_spawn_options(output_mode = nil)
542
557
  output_mode ||= determine_command_output_mode
543
- raise "Array commands require output mode 'all'." if cmd.is_a?(Array) && %i[errors_only none].include?(output_mode)
544
-
545
- case output_mode
546
- when :all
547
- cmd
548
- when :errors_only
549
- "#{cmd} > /dev/null"
550
- when :none
551
- "#{cmd} > /dev/null 2>&1"
552
- else
553
- raise "Invalid command output mode '#{output_mode}'."
554
- end
558
+ return {} if output_mode == :all
559
+ return { out: File::NULL } if output_mode == :errors_only
560
+ return { out: File::NULL, err: File::NULL } if output_mode == :none
561
+
562
+ raise "Invalid command output mode '#{output_mode}'."
555
563
  end
556
564
 
557
565
  def determine_command_output_mode
@@ -565,19 +573,22 @@ class Controlplane # rubocop:disable Metrics/ClassLength
565
573
  end
566
574
 
567
575
  def perform(cmd, output_mode: nil, sensitive_data_pattern: nil)
568
- cmd = build_command(cmd, output_mode: output_mode)
576
+ validate_argv!(cmd)
577
+ spawn_options = command_spawn_options(output_mode)
569
578
 
570
- debug_cmd = cmd.is_a?(Array) ? Shellwords.join(cmd) : cmd
579
+ debug_cmd = Shellwords.join(cmd)
571
580
  Shell.debug("CMD", debug_cmd, sensitive_data_pattern: sensitive_data_pattern)
572
581
 
573
- kernel_system_with_pid_handling(cmd)
582
+ kernel_system_with_pid_handling(cmd, **spawn_options)
574
583
  end
575
584
 
576
585
  # NOTE: full analogue of Kernel.system which returns pids and saves it to child_pids for proper killing.
577
586
  # Returns true on zero exit, false on non-zero exit, nil when the process was signal-killed.
578
587
  # SystemCallError (e.g. cpln binary missing) propagates — startup checks ensure this is unreachable in practice.
579
- def kernel_system_with_pid_handling(cmd)
580
- pid = cmd.is_a?(Array) ? Process.spawn(*cmd) : Process.spawn(cmd)
588
+ def kernel_system_with_pid_handling(cmd, **spawn_options)
589
+ validate_argv!(cmd)
590
+
591
+ pid = Process.spawn(*cmd, **spawn_options)
581
592
  $child_pids << pid # rubocop:disable Style/GlobalVars
582
593
 
583
594
  _, status = Process.wait2(pid)
@@ -586,15 +597,65 @@ class Controlplane # rubocop:disable Metrics/ClassLength
586
597
  status.exited? ? status.success? : nil
587
598
  end
588
599
 
600
+ def perform_with_output(cmd)
601
+ readers, writers = 2.times.map { IO.pipe }.transpose
602
+ pid = spawn_captured_process(cmd, writers)
603
+ captured_output = drain_captured_output(readers, determine_command_output_mode)
604
+ _, status = Process.wait2(pid)
605
+ reaped = true
606
+ { output: captured_output, success: status.exited? && status.success? }
607
+ ensure
608
+ [*readers, *writers].compact.each { |pipe| pipe.close unless pipe.closed? }
609
+ $child_pids.delete(pid) if reaped # rubocop:disable Style/GlobalVars
610
+ end
611
+
612
+ def spawn_captured_process(cmd, writers)
613
+ validate_argv!(cmd)
614
+ pid = Process.spawn(*cmd, out: writers.fetch(0), err: writers.fetch(1))
615
+ $child_pids << pid # rubocop:disable Style/GlobalVars
616
+ writers.each(&:close)
617
+ pid
618
+ end
619
+
620
+ def drain_captured_output(readers, output_mode)
621
+ stream_roles = { readers.fetch(0) => :stdout, readers.fetch(1) => :stderr }
622
+ pending_readers = readers.dup
623
+ captured_output = +""
624
+
625
+ until pending_readers.empty?
626
+ IO.select(pending_readers).first.each do |reader|
627
+ drain_ready_output(reader, stream_roles.fetch(reader), pending_readers, captured_output, output_mode)
628
+ end
629
+ end
630
+
631
+ captured_output
632
+ end
633
+
634
+ def drain_ready_output(reader, stream_role, pending_readers, captured_output, output_mode)
635
+ chunk = reader.read_nonblock(4096)
636
+ captured_output << chunk
637
+ return if output_mode == :none || (stream_role == :stdout && output_mode != :all)
638
+
639
+ stream = stream_role == :stdout ? $stdout : $stderr
640
+ stream.write(chunk)
641
+ stream.flush
642
+ rescue IO::WaitReadable
643
+ nil
644
+ rescue EOFError
645
+ pending_readers.delete(reader)
646
+ end
647
+ private :spawn_captured_process, :drain_captured_output, :drain_ready_output
648
+
589
649
  def perform!(cmd, output_mode: nil, sensitive_data_pattern: nil)
590
650
  success = perform(cmd, output_mode: output_mode, sensitive_data_pattern: sensitive_data_pattern)
591
651
  success || Shell.abort("Command exited with non-zero status.")
592
652
  end
593
653
 
594
- def perform_yaml(cmd)
595
- Shell.debug("CMD", cmd)
654
+ def perform_yaml(cmd, timeout_seconds: nil, **spawn_options)
655
+ validate_argv!(cmd)
656
+ Shell.debug("CMD", Shellwords.join(cmd))
596
657
 
597
- result = Shell.cmd(cmd)
658
+ result = Shell.cmd(*cmd, timeout_seconds: timeout_seconds, **spawn_options)
598
659
  YAML.safe_load(result[:output], permitted_classes: [Time]) if result[:success]
599
660
  end
600
661
 
@@ -602,7 +663,13 @@ class Controlplane # rubocop:disable Metrics/ClassLength
602
663
  perform_yaml(cmd) || Shell.abort("Command exited with non-zero status.")
603
664
  end
604
665
 
605
- def gvc_org
606
- "--gvc #{gvc} --org #{org}"
666
+ def gvc_org_args
667
+ ["--gvc", gvc, "--org", org]
668
+ end
669
+
670
+ def validate_argv!(cmd)
671
+ return if cmd.is_a?(Array) && cmd.length >= 2
672
+
673
+ raise ArgumentError, "Commands must be argv arrays with at least two elements."
607
674
  end
608
675
  end
data/lib/core/shell.rb CHANGED
@@ -1,17 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Shell
4
+ class CommandTimeout < RuntimeError; end
5
+
4
6
  class << self
5
7
  attr_reader :tmp_stderr, :verbose
6
8
  end
7
9
 
8
10
  def self.use_tmp_stderr
9
- @tmp_stderr = Tempfile.create
10
-
11
- yield
11
+ previous_tmp_stderr = @tmp_stderr
12
12
 
13
- @tmp_stderr.close
14
- @tmp_stderr = nil
13
+ Tempfile.create do |tmp_stderr|
14
+ @tmp_stderr = tmp_stderr
15
+ yield
16
+ ensure
17
+ @tmp_stderr = previous_tmp_stderr
18
+ end
15
19
  end
16
20
 
17
21
  def self.write_to_tmp_stderr(message)
@@ -65,18 +69,39 @@ class Shell
65
69
  tmp_stderr && !verbose
66
70
  end
67
71
 
68
- def self.cmd(*cmd_to_run, capture_stderr: false, separate_stderr: false)
69
- return cmd_with_separate_stderr(*cmd_to_run) if separate_stderr
72
+ def self.cmd(*cmd_to_run, capture_stderr: false, separate_stderr: false, timeout_seconds: nil, **spawn_options)
73
+ return timed_cmd(cmd_to_run, capture_stderr, separate_stderr, timeout_seconds, spawn_options) if timeout_seconds
70
74
 
71
- output, status = capture_stderr ? Open3.capture2e(*cmd_to_run) : Open3.capture2(*cmd_to_run)
75
+ return cmd_with_separate_stderr(*cmd_to_run, **spawn_options) if separate_stderr
76
+
77
+ output, status = capture_output(cmd_to_run, capture_stderr, spawn_options)
72
78
  {
73
79
  output: output,
74
80
  success: status.success?
75
81
  }
76
82
  end
77
83
 
78
- def self.cmd_with_separate_stderr(*cmd_to_run)
79
- output, error_output, status = Open3.capture3(*cmd_to_run)
84
+ def self.capture_output(cmd_to_run, capture_stderr, spawn_options)
85
+ return Open3.capture2e(*cmd_to_run, **spawn_options) if capture_stderr
86
+
87
+ Open3.capture2(*cmd_to_run, **spawn_options)
88
+ end
89
+ private_class_method :capture_output
90
+
91
+ def self.timed_cmd(cmd_to_run, capture_stderr, separate_stderr, timeout_seconds, spawn_options)
92
+ raise ArgumentError, "Process options cannot be combined with timeout_seconds." unless spawn_options.empty?
93
+
94
+ TimedCommand.capture(
95
+ *cmd_to_run,
96
+ capture_stderr: capture_stderr,
97
+ separate_stderr: separate_stderr,
98
+ timeout_seconds: timeout_seconds
99
+ )
100
+ end
101
+ private_class_method :timed_cmd
102
+
103
+ def self.cmd_with_separate_stderr(*cmd_to_run, **spawn_options)
104
+ output, error_output, status = Open3.capture3(*cmd_to_run, **spawn_options)
80
105
  { output: output, error_output: error_output, success: status.success? }
81
106
  end
82
107
  private_class_method :cmd_with_separate_stderr
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ class TimedCommand
4
+ def self.capture(*command, capture_stderr:, separate_stderr:, timeout_seconds:)
5
+ new(command, capture_stderr, separate_stderr, timeout_seconds).capture
6
+ end
7
+
8
+ def initialize(command, capture_stderr, separate_stderr, timeout_seconds)
9
+ raise ArgumentError, "timeout_seconds must be positive" unless timeout_seconds.positive?
10
+
11
+ @command = command
12
+ @capture_stderr = capture_stderr
13
+ @separate_stderr = separate_stderr
14
+ @timeout_seconds = timeout_seconds
15
+ end
16
+
17
+ def capture
18
+ @deadline = monotonic_time + @timeout_seconds
19
+ @command_cleaned_up = false
20
+ return capture_separate_streams if @separate_stderr
21
+ return capture_merged_streams if @capture_stderr
22
+
23
+ capture_stdout
24
+ end
25
+
26
+ private
27
+
28
+ def capture_stdout # rubocop:disable Metrics/MethodLength
29
+ completed = false
30
+ Open3.popen2(*@command, pgroup: true) do |stdin, stdout, wait_thread|
31
+ stdin.close
32
+ output_reader = Thread.new { stdout.read }
33
+ wait_for_command(wait_thread, output_reader)
34
+ completed = true
35
+ { output: output_reader.value, success: wait_thread.value.success? }
36
+ ensure
37
+ cleanup_unfinished_command(wait_thread, output_reader) unless completed || @command_cleaned_up
38
+ output_reader&.join
39
+ end
40
+ end
41
+
42
+ def capture_merged_streams # rubocop:disable Metrics/MethodLength
43
+ completed = false
44
+ Open3.popen2e(*@command, pgroup: true) do |stdin, output, wait_thread|
45
+ stdin.close
46
+ output_reader = Thread.new { output.read }
47
+ wait_for_command(wait_thread, output_reader)
48
+ completed = true
49
+ { output: output_reader.value, success: wait_thread.value.success? }
50
+ ensure
51
+ cleanup_unfinished_command(wait_thread, output_reader) unless completed || @command_cleaned_up
52
+ output_reader&.join
53
+ end
54
+ end
55
+
56
+ def capture_separate_streams # rubocop:disable Metrics/MethodLength
57
+ completed = false
58
+ Open3.popen3(*@command, pgroup: true) do |stdin, stdout, stderr, wait_thread|
59
+ stdin.close
60
+ output_reader = Thread.new { stdout.read }
61
+ error_reader = Thread.new { stderr.read }
62
+ wait_for_command(wait_thread, output_reader, error_reader)
63
+ completed = true
64
+ {
65
+ output: output_reader.value,
66
+ error_output: error_reader.value,
67
+ success: wait_thread.value.success?
68
+ }
69
+ ensure
70
+ cleanup_unfinished_command(wait_thread, output_reader, error_reader) unless completed || @command_cleaned_up
71
+ output_reader&.join
72
+ error_reader&.join
73
+ end
74
+ end
75
+
76
+ def wait_for_command(wait_thread, *output_readers)
77
+ observed_threads = [wait_thread, *output_readers]
78
+ return if observed_threads.all? { |thread| thread.join(remaining_timeout) }
79
+
80
+ terminate_process_group(wait_thread)
81
+ output_readers.each(&:kill)
82
+ output_readers.each(&:join)
83
+ @command_cleaned_up = true
84
+ raise Shell::CommandTimeout, "Command exceeded the #{@timeout_seconds}-second timeout"
85
+ end
86
+
87
+ def remaining_timeout
88
+ [@deadline - monotonic_time, 0].max
89
+ end
90
+
91
+ def monotonic_time
92
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
93
+ end
94
+
95
+ def terminate_process_group(wait_thread)
96
+ Process.kill("TERM", -wait_thread.pid)
97
+ wait_thread.join(1)
98
+
99
+ Process.kill("KILL", -wait_thread.pid)
100
+ wait_thread.join
101
+ rescue Errno::ESRCH, Errno::ECHILD
102
+ nil
103
+ end
104
+
105
+ def cleanup_unfinished_command(wait_thread, *output_readers)
106
+ return unless wait_thread
107
+
108
+ terminate_process_group(wait_thread)
109
+ output_readers.compact.each(&:kill)
110
+ end
111
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cpflow
4
- VERSION = "5.3.0"
4
+ VERSION = "6.0.0.rc.0"
5
5
  MIN_CPLN_VERSION = "3.1.0"
6
6
  end
data/lib/cpflow.rb CHANGED
@@ -122,7 +122,7 @@ module Cpflow
122
122
  end
123
123
 
124
124
  def self.subcommand?
125
- (subcommand_names & ARGV).any?
125
+ subcommand_names.intersect?(ARGV)
126
126
  end
127
127
  private_class_method :subcommand?
128
128
 
@@ -61,12 +61,12 @@ jobs:
61
61
 
62
62
  steps:
63
63
  - name: Checkout repository
64
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
64
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
65
65
  with:
66
66
  persist-credentials: false
67
67
 
68
68
  - name: Checkout control-plane-flow actions
69
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
69
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
70
70
  with:
71
71
  repository: shakacode/control-plane-flow
72
72
  ref: __CPFLOW_GITHUB_ACTIONS_REF__
@@ -90,7 +90,7 @@ jobs:
90
90
  fi
91
91
 
92
92
  - name: Validate required secrets and variables
93
- uses: ./.cpflow/.github/actions/cpflow-validate-config
93
+ uses: ./.github/actions/cpflow-validate-config
94
94
  # Pass secrets via env so the composite action checks indirect shell
95
95
  # variables instead of interpolating secret values into a run script.
96
96
  env:
@@ -171,7 +171,7 @@ jobs:
171
171
  } >> "$GITHUB_OUTPUT"
172
172
 
173
173
  - name: Setup production environment
174
- uses: ./.cpflow/.github/actions/cpflow-setup-environment
174
+ uses: ./.github/actions/cpflow-setup-environment
175
175
  with:
176
176
  token: ${{ secrets.CPLN_TOKEN_PRODUCTION }}
177
177
  org: ${{ steps.cpln-orgs.outputs.production }}
@@ -237,7 +237,7 @@ jobs:
237
237
 
238
238
  - name: Detect release phase support
239
239
  id: release-phase
240
- uses: ./.cpflow/.github/actions/cpflow-detect-release-phase
240
+ uses: ./.github/actions/cpflow-detect-release-phase
241
241
  with:
242
242
  app_name: ${{ vars.PRODUCTION_APP_NAME }}
243
243
 
@@ -441,7 +441,7 @@ jobs:
441
441
  echo "image=${staging_image}" >> "$GITHUB_OUTPUT"
442
442
 
443
443
  - name: Set up Docker Buildx
444
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
444
+ uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
445
445
 
446
446
  - name: Copy image from staging
447
447
  id: copy-image
@@ -589,7 +589,7 @@ jobs:
589
589
 
590
590
  - name: Wait for deployment health
591
591
  id: health-check
592
- uses: ./.cpflow/.github/actions/cpflow-wait-for-health
592
+ uses: ./.github/actions/cpflow-wait-for-health
593
593
  with:
594
594
  workload_name: ${{ steps.workloads.outputs.primary }}
595
595
  app_name: ${{ vars.PRODUCTION_APP_NAME }}