cpflow 5.2.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/agent-workflow.yml +26 -2
  3. data/.agents/bin/README.md +16 -4
  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 +9 -7
  12. data/.github/actions/cpflow-wait-for-health/action.yml +87 -15
  13. data/.github/dependabot.yml +10 -0
  14. data/.github/pull_request_template.md +18 -0
  15. data/.github/workflows/check_cpln_links.yml +6 -1
  16. data/.github/workflows/claude-code-review.yml +5 -2
  17. data/.github/workflows/claude.yml +97 -4
  18. data/.github/workflows/coderabbit-lifecycle-review.yml +29 -0
  19. data/.github/workflows/command_docs.yml +7 -2
  20. data/.github/workflows/cpflow-cleanup-stale-review-apps.yml +12 -5
  21. data/.github/workflows/cpflow-delete-review-app.yml +638 -43
  22. data/.github/workflows/cpflow-deploy-review-app.yml +665 -36
  23. data/.github/workflows/cpflow-deploy-staging.yml +16 -10
  24. data/.github/workflows/cpflow-help-command.yml +3 -3
  25. data/.github/workflows/cpflow-promote-staging-to-production.yml +7 -7
  26. data/.github/workflows/cpflow-review-app-help.yml +6 -14
  27. data/.github/workflows/rspec-shared.yml +34 -26
  28. data/.github/workflows/rspec-specific.yml +10 -2
  29. data/.github/workflows/rspec.yml +71 -4
  30. data/.github/workflows/rubocop.yml +9 -2
  31. data/.github/workflows/trigger-docs-site.yml +2 -0
  32. data/.rubocop.yml +6 -1
  33. data/AGENTS.md +6 -49
  34. data/CHANGELOG.md +56 -1
  35. data/CONTRIBUTING.md +48 -3
  36. data/Gemfile.lock +1 -1
  37. data/README.md +6 -1
  38. data/cpflow.gemspec +3 -15
  39. data/docs/ai-github-flow-prompt.md +2 -2
  40. data/docs/ci-automation.md +216 -99
  41. data/docs/commands.md +36 -9
  42. data/docs/rds-private-networking.md +12 -12
  43. data/docs/releasing.md +15 -4
  44. data/docs/secrets-and-env-values.md +8 -0
  45. data/docs/tips.md +18 -0
  46. data/examples/controlplane.yml +5 -0
  47. data/lib/command/ai_github_flow_prompt.rb +1 -1
  48. data/lib/command/apply_template.rb +104 -2
  49. data/lib/command/base.rb +52 -3
  50. data/lib/command/cleanup_stale_apps.rb +28 -4
  51. data/lib/command/copy_image_from_upstream.rb +3 -0
  52. data/lib/command/deploy_image.rb +54 -3
  53. data/lib/command/generate_github_actions.rb +36 -12
  54. data/lib/command/run.rb +357 -44
  55. data/lib/command/setup_app.rb +10 -5
  56. data/lib/command/update_github_actions.rb +7 -6
  57. data/lib/core/controlplane.rb +179 -79
  58. data/lib/core/controlplane_api.rb +8 -0
  59. data/lib/core/controlplane_api_direct.rb +257 -63
  60. data/lib/core/shell.rb +39 -7
  61. data/lib/core/timed_command.rb +111 -0
  62. data/lib/cpflow/version.rb +1 -1
  63. data/lib/cpflow.rb +1 -1
  64. data/lib/github_flow_templates/.github/cpflow-help.md +24 -10
  65. data/lib/github_flow_templates/.github/workflows/cpflow-delete-review-app.yml +10 -0
  66. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +9 -0
  67. data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +7 -7
  68. data/lib/github_flow_templates/bin/test-cpflow-github-flow +63 -3
  69. data/lib/patches/hash.rb +2 -2
  70. data/rakelib/create_release.rake +14 -14
  71. data/script/check_shell_scripts +66 -0
  72. metadata +12 -18
@@ -1,13 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Command
4
- class SetupApp < Base
4
+ class SetupApp < Base # rubocop:disable Metrics/ClassLength
5
5
  NAME = "setup-app"
6
6
  OPTIONS = [
7
7
  app_option(required: true),
8
8
  skip_secret_access_binding_option,
9
9
  skip_secrets_setup_option,
10
- skip_post_creation_hook_option
10
+ skip_post_creation_hook_option,
11
+ refresh_templates_option
11
12
  ].freeze
12
13
  DESCRIPTION = "Creates an app and all its workloads"
13
14
  LONG_DESCRIPTION = <<~DESC
@@ -23,18 +24,21 @@ module Command
23
24
  - Runs a post-creation hook after the app is created if `hooks.post_creation` is specified in the `.controlplane/controlplane.yml` file
24
25
  - If the hook exits with a non-zero code, the command will stop executing and also exit with a non-zero code
25
26
  - Use `--skip-post-creation-hook` to skip the hook if specified in `controlplane.yml`
27
+ - Use `--refresh-templates` to apply configured templates noninteractively to an existing app while preserving each workload's configured app image even when workloads are unready or use mixed image versions, skipping existing secret resources entirely, repairing secrets access bindings, and skipping the post-creation hook
26
28
  DESC
27
29
  VALIDATIONS = %w[config templates].freeze
28
30
 
29
- def call # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
31
+ def call # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
30
32
  templates = config[:setup_app_templates]
33
+ refresh_templates = config.options[:refresh_templates]
31
34
 
32
35
  app = cp.fetch_gvc
33
- if app
36
+ if app && !refresh_templates
34
37
  raise "App '#{config.app}' already exists. If you want to update this app, " \
35
38
  "either run 'cpflow delete -a #{config.app}' and then re-run this command, " \
36
39
  "or run 'cpflow apply-template #{templates.join(' ')} -a #{config.app}'."
37
40
  end
41
+ raise "App '#{config.app}' does not exist, so its templates cannot be refreshed." if !app && refresh_templates
38
42
 
39
43
  skip_secrets_setup = skip_secrets_setup?
40
44
 
@@ -45,11 +49,12 @@ module Command
45
49
 
46
50
  args = []
47
51
  args.push("--add-app-identity") unless skip_secrets_setup
52
+ args.push("--yes", "--preserve-existing-runtime") if refresh_templates
48
53
  run_cpflow_command("apply-template", *templates, "-a", config.app, *args)
49
54
 
50
55
  bind_identity_to_policy unless skip_secrets_setup
51
56
  bind_shared_secret_policy_grants(shared_secret_policy_grant_pairs) unless skip_secrets_setup
52
- run_post_creation_hook unless config.options[:skip_post_creation_hook]
57
+ run_post_creation_hook unless refresh_templates || config.options[:skip_post_creation_hook]
53
58
  end
54
59
 
55
60
  private
@@ -10,12 +10,13 @@ module Command
10
10
 
11
11
  NAME = "update-github-actions"
12
12
  OPTIONS = [staging_branch_option].freeze
13
- DESCRIPTION = "Regenerates generated GitHub Actions wrappers for the installed cpflow version"
13
+ DESCRIPTION = "Regenerates GitHub Actions files for the installed cpflow version"
14
14
  LONG_DESCRIPTION = <<~DESC.freeze
15
- Regenerates the generated cpflow GitHub Actions wrappers and helper files
16
- from the currently installed cpflow gem. Use this after updating the
17
- cpflow gem so checked-in workflow wrappers move to the matching upstream
18
- release tag, for example `v#{Cpflow::VERSION}`.
15
+ Regenerates the cpflow workflow wrappers, local composite actions, and
16
+ helper files from the currently installed cpflow gem. Use this after
17
+ updating the cpflow gem so checked-in workflow wrappers move to the
18
+ matching upstream release tag, for example `v#{Cpflow::VERSION}`, and the
19
+ generated action implementations move with them.
19
20
 
20
21
  If the existing generated staging workflow uses a custom single staging
21
22
  branch, the command preserves it. Pass `--staging-branch BRANCH` to set or
@@ -23,7 +24,7 @@ module Command
23
24
  DESC
24
25
  EXAMPLES = <<~EX
25
26
  ```sh
26
- # After updating the cpflow gem, refresh generated GitHub Actions wrappers
27
+ # After updating the cpflow gem, refresh every generated GitHub Actions file
27
28
  cpflow update-github-actions
28
29
 
29
30
  # When running cpflow through Bundler
@@ -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,16 +173,38 @@ 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
+ end
179
+
180
+ def fetch_workload_with_status(workload)
181
+ result = workload_status_result(workload)
182
+
183
+ unless result[:success]
184
+ Shell.warn("Failed to fetch status for '#{workload}': #{result[:error_output].to_s.strip}")
185
+ return
186
+ end
187
+
188
+ JSON.parse(result[:output])
189
+ rescue JSON::ParserError => e
190
+ Shell.warn("Failed to parse status for '#{workload}': #{e.message}")
191
+ nil
192
+ end
193
+
194
+ def workload_status_result(workload)
195
+ # The direct API response omits computed fields such as status.readyLatest.
196
+ args = ["cpln", "workload", "get", workload, "--gvc", gvc, "--org", org, "-o", "json"]
197
+ Shell.debug("CMD", Shellwords.join(args))
198
+ Shell.cmd(*args, separate_stderr: true)
178
199
  end
200
+ private :workload_status_result
179
201
 
180
- def fetch_workload!(workload)
181
- workload_data = fetch_workload(workload)
202
+ def fetch_workload!(workload, a_gvc = gvc)
203
+ workload_data = fetch_workload(workload, a_gvc)
182
204
  return workload_data if workload_data
183
205
 
184
206
  raise "Can't find workload '#{workload}', " \
185
- "please create it with 'cpflow apply-template #{workload} -a #{config.app}'."
207
+ "please create it with 'cpflow apply-template #{workload} -a #{a_gvc}'."
186
208
  end
187
209
 
188
210
  def query_workloads(workload, a_gvc = gvc, a_org = org, partial_workload_match: false, partial_gvc_match: nil)
@@ -194,12 +216,18 @@ class Controlplane # rubocop:disable Metrics/ClassLength
194
216
  end
195
217
 
196
218
  def fetch_workload_replicas(workload, location:)
197
- cmd = "cpln workload replica get #{workload} #{gvc_org} --location #{location} -o yaml 2> /dev/null"
198
- 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)
199
224
  end
200
225
 
201
226
  def stop_workload_replica(workload, replica, location:)
202
- 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
+ ]
203
231
  perform(cmd, output_mode: :none)
204
232
  end
205
233
 
@@ -230,9 +258,13 @@ class Controlplane # rubocop:disable Metrics/ClassLength
230
258
  end
231
259
 
232
260
  def workload_set_image_ref(workload, container:, image:)
233
- cmd = "cpln workload update #{workload} #{gvc_org}"
234
- cmd += " --set spec.containers.#{container}.image=/org/#{config.org}/image/#{image}"
235
- 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)
236
268
  end
237
269
 
238
270
  def set_workload_env_var(workload, container:, name:, value:)
@@ -250,11 +282,16 @@ class Controlplane # rubocop:disable Metrics/ClassLength
250
282
  api.update_workload(org: org, gvc: gvc, workload: workload, data: data)
251
283
  end
252
284
 
253
- def set_workload_suspend(workload, value)
254
- 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
+
255
289
  data["spec"]["defaultOptions"]["suspend"] = value
256
290
 
257
- 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
258
295
  end
259
296
 
260
297
  def workload_suspended?(workload)
@@ -263,7 +300,7 @@ class Controlplane # rubocop:disable Metrics/ClassLength
263
300
  end
264
301
 
265
302
  def workload_force_redeployment(workload)
266
- cmd = "cpln workload force-redeployment #{workload} #{gvc_org}"
303
+ cmd = ["cpln", "workload", "force-redeployment", workload, *gvc_org_args]
267
304
  perform!(cmd)
268
305
  end
269
306
 
@@ -272,16 +309,21 @@ class Controlplane # rubocop:disable Metrics/ClassLength
272
309
  end
273
310
 
274
311
  def workload_connect(workload, location:, container: nil, shell: nil)
275
- cmd = "cpln workload connect #{workload} #{gvc_org} --location #{location}"
276
- cmd += " --container #{container}" if container
277
- 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
278
315
  perform!(cmd, output_mode: :all)
279
316
  end
280
317
 
281
318
  def workload_exec(workload, replica, location:, container: nil, command: nil)
282
- cmd = "cpln workload exec #{workload} #{gvc_org} --replica #{replica} --location #{location} -it"
283
- cmd += " --container #{container}" if container
284
- cmd += " -- #{command}"
319
+ cmd = [
320
+ "cpln", "workload", "exec", workload,
321
+ "--gvc", gvc, "--org", org,
322
+ "--replica", replica, "--location", location, "-it"
323
+ ]
324
+ cmd.push("--container", container) if container
325
+ cmd << "--"
326
+ cmd.concat(Array(command))
285
327
  perform(cmd, output_mode: :all)
286
328
  end
287
329
 
@@ -290,14 +332,20 @@ class Controlplane # rubocop:disable Metrics/ClassLength
290
332
  f.write(job_start_yaml)
291
333
  f.rewind
292
334
 
293
- 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
+ ]
294
339
  perform_yaml(cmd)
295
340
  end
296
341
  end
297
342
 
298
- def fetch_cron_workload(workload, location:)
299
- cmd = "cpln workload cron get #{workload} #{gvc_org} --location #{location} -o yaml"
300
- 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)
301
349
  end
302
350
 
303
351
  def cron_workload_deployed_version(workload)
@@ -369,7 +417,10 @@ class Controlplane # rubocop:disable Metrics/ClassLength
369
417
  query_parts.push("replica=\"#{replica}\"") if replica
370
418
  query = "{#{query_parts.join(',')}}"
371
419
 
372
- 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
+ ]
373
424
  perform!(cmd, output_mode: :all)
374
425
  end
375
426
 
@@ -383,6 +434,10 @@ class Controlplane # rubocop:disable Metrics/ClassLength
383
434
  api.fetch_secret(org: org, secret: secret)
384
435
  end
385
436
 
437
+ def reveal_secret(secret)
438
+ api.reveal_secret(org: org, secret: secret)
439
+ end
440
+
386
441
  # identities
387
442
 
388
443
  def fetch_identity(identity, a_gvc = gvc)
@@ -402,7 +457,7 @@ class Controlplane # rubocop:disable Metrics/ClassLength
402
457
  "--identity", identity_link,
403
458
  "--permission", "reveal"
404
459
  ]
405
- perform!(Shellwords.join(cmd))
460
+ perform!(cmd)
406
461
  end
407
462
 
408
463
  def unbind_identity_from_policy(identity_link, policy, permission: "reveal")
@@ -412,33 +467,25 @@ class Controlplane # rubocop:disable Metrics/ClassLength
412
467
  "--identity", identity_link,
413
468
  "--permission", permission
414
469
  ]
415
- perform!(Shellwords.join(cmd))
470
+ perform!(cmd)
416
471
  end
417
472
 
418
473
  # apply
419
- def apply_template(data, wait: false) # rubocop:disable Metrics/MethodLength, Metrics/PerceivedComplexity
474
+ def apply_template(data, wait: false) # rubocop:disable Metrics/MethodLength
420
475
  Tempfile.create do |f|
421
476
  f.write(data)
422
477
  f.rewind
423
- cmd = "cpln apply #{gvc_org} --file #{f.path}"
424
- cmd += " --ready" if wait && ENV.fetch("DISABLE_APPLY_READY", nil).nil?
425
- if Shell.tmp_stderr
426
- 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?
427
482
 
428
- Shell.debug("CMD", cmd)
483
+ Shell.debug("CMD", Shellwords.join(cmd))
429
484
 
430
- result = Shell.cmd(cmd)
431
- parse_apply_result(result[:output]) if result[:success]
432
- else
433
- Shell.debug("CMD", cmd)
485
+ result = Shell.cmd(*cmd, **spawn_options)
486
+ return parse_apply_result(result[:output]) if result[:success]
434
487
 
435
- result = Shell.cmd(cmd)
436
- if result[:success]
437
- parse_apply_result(result[:output])
438
- else
439
- Shell.abort("Command exited with non-zero status.")
440
- end
441
- end
488
+ Shell.abort("Command exited with non-zero status.") unless Shell.tmp_stderr
442
489
  end
443
490
  end
444
491
 
@@ -504,22 +551,15 @@ class Controlplane # rubocop:disable Metrics/ClassLength
504
551
  "or ensure that the name is correct."
505
552
  end
506
553
 
507
- # `output_mode` can be :all, :errors_only or :none.
508
- # If not provided, it will be determined based on the `HIDE_COMMAND_OUTPUT` env var
509
- # or the return value of `Shell.should_hide_output?`.
510
- 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)
511
557
  output_mode ||= determine_command_output_mode
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
512
561
 
513
- case output_mode
514
- when :all
515
- cmd
516
- when :errors_only
517
- "#{cmd} > /dev/null"
518
- when :none
519
- "#{cmd} > /dev/null 2>&1"
520
- else
521
- raise "Invalid command output mode '#{output_mode}'."
522
- end
562
+ raise "Invalid command output mode '#{output_mode}'."
523
563
  end
524
564
 
525
565
  def determine_command_output_mode
@@ -533,18 +573,22 @@ class Controlplane # rubocop:disable Metrics/ClassLength
533
573
  end
534
574
 
535
575
  def perform(cmd, output_mode: nil, sensitive_data_pattern: nil)
536
- cmd = build_command(cmd, output_mode: output_mode)
576
+ validate_argv!(cmd)
577
+ spawn_options = command_spawn_options(output_mode)
537
578
 
538
- Shell.debug("CMD", cmd, sensitive_data_pattern: sensitive_data_pattern)
579
+ debug_cmd = Shellwords.join(cmd)
580
+ Shell.debug("CMD", debug_cmd, sensitive_data_pattern: sensitive_data_pattern)
539
581
 
540
- kernel_system_with_pid_handling(cmd)
582
+ kernel_system_with_pid_handling(cmd, **spawn_options)
541
583
  end
542
584
 
543
585
  # NOTE: full analogue of Kernel.system which returns pids and saves it to child_pids for proper killing.
544
586
  # Returns true on zero exit, false on non-zero exit, nil when the process was signal-killed.
545
587
  # SystemCallError (e.g. cpln binary missing) propagates — startup checks ensure this is unreachable in practice.
546
- def kernel_system_with_pid_handling(cmd)
547
- pid = 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)
548
592
  $child_pids << pid # rubocop:disable Style/GlobalVars
549
593
 
550
594
  _, status = Process.wait2(pid)
@@ -553,15 +597,65 @@ class Controlplane # rubocop:disable Metrics/ClassLength
553
597
  status.exited? ? status.success? : nil
554
598
  end
555
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
+
556
649
  def perform!(cmd, output_mode: nil, sensitive_data_pattern: nil)
557
650
  success = perform(cmd, output_mode: output_mode, sensitive_data_pattern: sensitive_data_pattern)
558
651
  success || Shell.abort("Command exited with non-zero status.")
559
652
  end
560
653
 
561
- def perform_yaml(cmd)
562
- 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))
563
657
 
564
- result = Shell.cmd(cmd)
658
+ result = Shell.cmd(*cmd, timeout_seconds: timeout_seconds, **spawn_options)
565
659
  YAML.safe_load(result[:output], permitted_classes: [Time]) if result[:success]
566
660
  end
567
661
 
@@ -569,7 +663,13 @@ class Controlplane # rubocop:disable Metrics/ClassLength
569
663
  perform_yaml(cmd) || Shell.abort("Command exited with non-zero status.")
570
664
  end
571
665
 
572
- def gvc_org
573
- "--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."
574
674
  end
575
675
  end
@@ -122,6 +122,14 @@ class ControlplaneApi # rubocop:disable Metrics/ClassLength
122
122
  api_json("/org/#{org}/secret/#{secret}", method: :get)
123
123
  end
124
124
 
125
+ def reveal_secret(org:, secret:)
126
+ api_json(
127
+ "/org/#{org}/secret/#{secret}/-reveal",
128
+ method: :get,
129
+ request_policy: ControlplaneApiDirect::BEST_EFFORT_SENSITIVE_REQUEST_POLICY
130
+ )
131
+ end
132
+
125
133
  def delete_secret(org:, secret:)
126
134
  api_json("/org/#{org}/secret/#{secret}", method: :delete)
127
135
  end