cpflow 5.3.0 → 6.0.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/agent-workflow.yml +16 -25
  3. data/.agents/bin/README.md +19 -9
  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/.agents/legacy-workflow-policy.yml +11 -0
  10. data/.agents/shaka.md +20 -0
  11. data/.agents/trusted-actions.yml +12 -0
  12. data/.coderabbit.yaml +14 -0
  13. data/.github/actions/cpflow-build-docker-image/action.yml +153 -22
  14. data/.github/actions/cpflow-delete-control-plane-app/action.yml +2 -1
  15. data/.github/actions/cpflow-delete-control-plane-app/delete-app.sh +3 -0
  16. data/.github/actions/cpflow-preview-promotion/action.yml +82 -0
  17. data/.github/actions/cpflow-preview-promotion/preview-promotion.sh +90 -0
  18. data/.github/actions/cpflow-setup-environment/action.yml +8 -6
  19. data/.github/dependabot.yml +10 -0
  20. data/.github/workflows/check_cpln_links.yml +6 -1
  21. data/.github/workflows/claude-code-review.yml +5 -2
  22. data/.github/workflows/claude.yml +6 -4
  23. data/.github/workflows/coderabbit-lifecycle-review.yml +29 -0
  24. data/.github/workflows/command_docs.yml +7 -2
  25. data/.github/workflows/cpflow-cleanup-stale-review-apps.yml +12 -5
  26. data/.github/workflows/cpflow-delete-review-app.yml +18 -11
  27. data/.github/workflows/cpflow-deploy-review-app.yml +38 -17
  28. data/.github/workflows/cpflow-deploy-staging.yml +28 -11
  29. data/.github/workflows/cpflow-help-command.yml +3 -3
  30. data/.github/workflows/cpflow-promote-staging-to-production.yml +135 -16
  31. data/.github/workflows/cpflow-review-app-help.yml +1 -1
  32. data/.github/workflows/rspec-shared.yml +34 -26
  33. data/.github/workflows/rspec-specific.yml +10 -2
  34. data/.github/workflows/rspec.yml +95 -4
  35. data/.github/workflows/rubocop.yml +9 -2
  36. data/.github/workflows/trigger-docs-site.yml +2 -0
  37. data/.rubocop.yml +6 -1
  38. data/AGENTS.md +18 -3
  39. data/CHANGELOG.md +39 -1
  40. data/CONTRIBUTING.md +39 -2
  41. data/Gemfile.lock +1 -1
  42. data/README.md +10 -2
  43. data/cpflow.gemspec +3 -15
  44. data/docs/ai-github-flow-prompt.md +3 -3
  45. data/docs/ci-automation.md +284 -68
  46. data/docs/commands.md +38 -15
  47. data/docs/postgres-review-apps.md +90 -0
  48. data/docs/postgres.md +233 -375
  49. data/docs/rds-private-networking.md +12 -12
  50. data/docs/releasing.md +15 -4
  51. data/docs/secrets-and-env-values.md +42 -0
  52. data/docs/sidebars.ts +1 -0
  53. data/examples/controlplane.yml +5 -0
  54. data/lib/command/ai_github_flow_prompt.rb +2 -2
  55. data/lib/command/apply_template.rb +36 -4
  56. data/lib/command/base.rb +56 -3
  57. data/lib/command/cleanup_stale_apps.rb +28 -4
  58. data/lib/command/copy_image_from_upstream.rb +3 -0
  59. data/lib/command/delete.rb +174 -4
  60. data/lib/command/deploy_image.rb +44 -1
  61. data/lib/command/generate.rb +247 -3
  62. data/lib/command/generate_github_actions.rb +43 -13
  63. data/lib/command/run.rb +224 -34
  64. data/lib/command/setup_app.rb +92 -10
  65. data/lib/command/update_github_actions.rb +82 -43
  66. data/lib/core/config.rb +73 -0
  67. data/lib/core/controlplane.rb +167 -80
  68. data/lib/core/controlplane_api.rb +28 -2
  69. data/lib/core/controlplane_api_direct.rb +17 -0
  70. data/lib/core/disposable_postgres_helpers.rb +167 -0
  71. data/lib/core/doctor_service.rb +7 -0
  72. data/lib/core/github_flow_readiness/checks.rb +2 -1
  73. data/lib/core/github_flow_readiness_service.rb +4 -0
  74. data/lib/core/repo_introspection.rb +112 -2
  75. data/lib/core/shell.rb +35 -10
  76. data/lib/core/timed_command.rb +111 -0
  77. data/lib/cpflow/version.rb +1 -1
  78. data/lib/cpflow.rb +1 -1
  79. data/lib/generator_templates/.dockerignore +16 -0
  80. data/lib/generator_templates/Dockerfile +9 -5
  81. data/lib/generator_templates/entrypoint.sh +2 -0
  82. data/lib/generator_templates/templates/postgres.yml +9 -0
  83. data/lib/generator_templates_sqlite/controlplane.yml +2 -1
  84. data/lib/generator_templates_sqlite/release_script.sh +1 -1
  85. data/lib/generator_templates_sqlite/templates/rails.yml +2 -1
  86. data/lib/github_flow_templates/.github/cpflow-help.md +39 -12
  87. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +1 -0
  88. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-staging.yml +1 -0
  89. data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +135 -16
  90. data/lib/github_flow_templates/bin/pin-cpflow-github-ref +37 -13
  91. data/lib/github_flow_templates/bin/test-cpflow-github-flow +125 -46
  92. data/lib/patches/hash.rb +2 -2
  93. data/rakelib/create_release.rake +14 -14
  94. data/script/check_shell_scripts +66 -0
  95. metadata +18 -17
@@ -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
 
@@ -410,8 +434,24 @@ class Controlplane # rubocop:disable Metrics/ClassLength
410
434
  api.fetch_secret(org: org, secret: secret)
411
435
  end
412
436
 
413
- def reveal_secret(secret)
414
- api.reveal_secret(org: org, secret: secret)
437
+ def reveal_secret(secret, required: false)
438
+ api.reveal_secret(org: org, secret: secret, required: required)
439
+ end
440
+
441
+ def create_sensitive_secret(secret, data)
442
+ api.create_sensitive_secret(org: org, secret: secret, app: gvc, data: data)
443
+ end
444
+
445
+ def patch_sensitive_secret_data(secret, data)
446
+ api.patch_sensitive_secret_data(org: org, secret: secret, data: data)
447
+ end
448
+
449
+ def secret_access_report(secret)
450
+ api.secret_access_report(org: org, secret: secret)
451
+ end
452
+
453
+ def delete_secret(secret)
454
+ api.delete_secret(org: org, secret: secret)
415
455
  end
416
456
 
417
457
  # identities
@@ -426,6 +466,10 @@ class Controlplane # rubocop:disable Metrics/ClassLength
426
466
  api.fetch_policy(org: org, policy: policy)
427
467
  end
428
468
 
469
+ def delete_policy(policy)
470
+ api.delete_policy(org: org, policy: policy)
471
+ end
472
+
429
473
  def bind_identity_to_policy(identity_link, policy)
430
474
  cmd = [
431
475
  "cpln", "policy", "add-binding", policy,
@@ -433,7 +477,7 @@ class Controlplane # rubocop:disable Metrics/ClassLength
433
477
  "--identity", identity_link,
434
478
  "--permission", "reveal"
435
479
  ]
436
- perform!(Shellwords.join(cmd))
480
+ perform!(cmd)
437
481
  end
438
482
 
439
483
  def unbind_identity_from_policy(identity_link, policy, permission: "reveal")
@@ -443,33 +487,25 @@ class Controlplane # rubocop:disable Metrics/ClassLength
443
487
  "--identity", identity_link,
444
488
  "--permission", permission
445
489
  ]
446
- perform!(Shellwords.join(cmd))
490
+ perform!(cmd)
447
491
  end
448
492
 
449
493
  # apply
450
- def apply_template(data, wait: false) # rubocop:disable Metrics/MethodLength, Metrics/PerceivedComplexity
494
+ def apply_template(data, wait: false) # rubocop:disable Metrics/MethodLength
451
495
  Tempfile.create do |f|
452
496
  f.write(data)
453
497
  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?
498
+ cmd = ["cpln", "apply", *gvc_org_args, "--file", f.path]
499
+ cmd << "--ready" if wait && ENV.fetch("DISABLE_APPLY_READY", nil).nil?
500
+ spawn_options = {}
501
+ spawn_options[:err] = Shell.tmp_stderr if Shell.should_hide_output?
458
502
 
459
- Shell.debug("CMD", cmd)
503
+ Shell.debug("CMD", Shellwords.join(cmd))
460
504
 
461
- result = Shell.cmd(cmd)
462
- parse_apply_result(result[:output]) if result[:success]
463
- else
464
- Shell.debug("CMD", cmd)
505
+ result = Shell.cmd(*cmd, **spawn_options)
506
+ return parse_apply_result(result[:output]) if result[:success]
465
507
 
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
508
+ Shell.abort("Command exited with non-zero status.") unless Shell.tmp_stderr
473
509
  end
474
510
  end
475
511
 
@@ -535,23 +571,15 @@ class Controlplane # rubocop:disable Metrics/ClassLength
535
571
  "or ensure that the name is correct."
536
572
  end
537
573
 
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
574
+ # `output_mode` can be :all, :errors_only or :none. Redirection is expressed as
575
+ # Process.spawn options so argv commands never need to pass through a shell.
576
+ def command_spawn_options(output_mode = nil)
542
577
  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
578
+ return {} if output_mode == :all
579
+ return { out: File::NULL } if output_mode == :errors_only
580
+ return { out: File::NULL, err: File::NULL } if output_mode == :none
581
+
582
+ raise "Invalid command output mode '#{output_mode}'."
555
583
  end
556
584
 
557
585
  def determine_command_output_mode
@@ -565,19 +593,22 @@ class Controlplane # rubocop:disable Metrics/ClassLength
565
593
  end
566
594
 
567
595
  def perform(cmd, output_mode: nil, sensitive_data_pattern: nil)
568
- cmd = build_command(cmd, output_mode: output_mode)
596
+ validate_argv!(cmd)
597
+ spawn_options = command_spawn_options(output_mode)
569
598
 
570
- debug_cmd = cmd.is_a?(Array) ? Shellwords.join(cmd) : cmd
599
+ debug_cmd = Shellwords.join(cmd)
571
600
  Shell.debug("CMD", debug_cmd, sensitive_data_pattern: sensitive_data_pattern)
572
601
 
573
- kernel_system_with_pid_handling(cmd)
602
+ kernel_system_with_pid_handling(cmd, **spawn_options)
574
603
  end
575
604
 
576
605
  # NOTE: full analogue of Kernel.system which returns pids and saves it to child_pids for proper killing.
577
606
  # Returns true on zero exit, false on non-zero exit, nil when the process was signal-killed.
578
607
  # 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)
608
+ def kernel_system_with_pid_handling(cmd, **spawn_options)
609
+ validate_argv!(cmd)
610
+
611
+ pid = Process.spawn(*cmd, **spawn_options)
581
612
  $child_pids << pid # rubocop:disable Style/GlobalVars
582
613
 
583
614
  _, status = Process.wait2(pid)
@@ -586,15 +617,65 @@ class Controlplane # rubocop:disable Metrics/ClassLength
586
617
  status.exited? ? status.success? : nil
587
618
  end
588
619
 
620
+ def perform_with_output(cmd)
621
+ readers, writers = 2.times.map { IO.pipe }.transpose
622
+ pid = spawn_captured_process(cmd, writers)
623
+ captured_output = drain_captured_output(readers, determine_command_output_mode)
624
+ _, status = Process.wait2(pid)
625
+ reaped = true
626
+ { output: captured_output, success: status.exited? && status.success? }
627
+ ensure
628
+ [*readers, *writers].compact.each { |pipe| pipe.close unless pipe.closed? }
629
+ $child_pids.delete(pid) if reaped # rubocop:disable Style/GlobalVars
630
+ end
631
+
632
+ def spawn_captured_process(cmd, writers)
633
+ validate_argv!(cmd)
634
+ pid = Process.spawn(*cmd, out: writers.fetch(0), err: writers.fetch(1))
635
+ $child_pids << pid # rubocop:disable Style/GlobalVars
636
+ writers.each(&:close)
637
+ pid
638
+ end
639
+
640
+ def drain_captured_output(readers, output_mode)
641
+ stream_roles = { readers.fetch(0) => :stdout, readers.fetch(1) => :stderr }
642
+ pending_readers = readers.dup
643
+ captured_output = +""
644
+
645
+ until pending_readers.empty?
646
+ IO.select(pending_readers).first.each do |reader|
647
+ drain_ready_output(reader, stream_roles.fetch(reader), pending_readers, captured_output, output_mode)
648
+ end
649
+ end
650
+
651
+ captured_output
652
+ end
653
+
654
+ def drain_ready_output(reader, stream_role, pending_readers, captured_output, output_mode)
655
+ chunk = reader.read_nonblock(4096)
656
+ captured_output << chunk
657
+ return if output_mode == :none || (stream_role == :stdout && output_mode != :all)
658
+
659
+ stream = stream_role == :stdout ? $stdout : $stderr
660
+ stream.write(chunk)
661
+ stream.flush
662
+ rescue IO::WaitReadable
663
+ nil
664
+ rescue EOFError
665
+ pending_readers.delete(reader)
666
+ end
667
+ private :spawn_captured_process, :drain_captured_output, :drain_ready_output
668
+
589
669
  def perform!(cmd, output_mode: nil, sensitive_data_pattern: nil)
590
670
  success = perform(cmd, output_mode: output_mode, sensitive_data_pattern: sensitive_data_pattern)
591
671
  success || Shell.abort("Command exited with non-zero status.")
592
672
  end
593
673
 
594
- def perform_yaml(cmd)
595
- Shell.debug("CMD", cmd)
674
+ def perform_yaml(cmd, timeout_seconds: nil, **spawn_options)
675
+ validate_argv!(cmd)
676
+ Shell.debug("CMD", Shellwords.join(cmd))
596
677
 
597
- result = Shell.cmd(cmd)
678
+ result = Shell.cmd(*cmd, timeout_seconds: timeout_seconds, **spawn_options)
598
679
  YAML.safe_load(result[:output], permitted_classes: [Time]) if result[:success]
599
680
  end
600
681
 
@@ -602,7 +683,13 @@ class Controlplane # rubocop:disable Metrics/ClassLength
602
683
  perform_yaml(cmd) || Shell.abort("Command exited with non-zero status.")
603
684
  end
604
685
 
605
- def gvc_org
606
- "--gvc #{gvc} --org #{org}"
686
+ def gvc_org_args
687
+ ["--gvc", gvc, "--org", org]
688
+ end
689
+
690
+ def validate_argv!(cmd)
691
+ return if cmd.is_a?(Array) && cmd.length >= 2
692
+
693
+ raise ArgumentError, "Commands must be argv arrays with at least two elements."
607
694
  end
608
695
  end
@@ -122,14 +122,40 @@ 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:)
125
+ def create_sensitive_secret(org:, secret:, app:, data:)
126
+ api_json(
127
+ "/org/#{org}/secret", method: :post,
128
+ body: { kind: "secret", name: secret, type: "dictionary", data: data,
129
+ tags: { Config::GENERATED_REVIEW_APP_TAG => app } },
130
+ request_policy: ControlplaneApiDirect::SENSITIVE_MUTATION_REQUEST_POLICY
131
+ )
132
+ end
133
+
134
+ def patch_sensitive_secret_data(org:, secret:, data:)
135
+ api_json(
136
+ "/org/#{org}/secret/#{secret}", method: :patch,
137
+ body: { data: data },
138
+ request_policy: ControlplaneApiDirect::SENSITIVE_MUTATION_REQUEST_POLICY
139
+ )
140
+ end
141
+
142
+ def reveal_secret(org:, secret:, required: false)
143
+ request_policy = if required
144
+ ControlplaneApiDirect::REQUIRED_SENSITIVE_READ_REQUEST_POLICY
145
+ else
146
+ ControlplaneApiDirect::BEST_EFFORT_SENSITIVE_REQUEST_POLICY
147
+ end
126
148
  api_json(
127
149
  "/org/#{org}/secret/#{secret}/-reveal",
128
150
  method: :get,
129
- request_policy: ControlplaneApiDirect::BEST_EFFORT_SENSITIVE_REQUEST_POLICY
151
+ request_policy: request_policy
130
152
  )
131
153
  end
132
154
 
155
+ def secret_access_report(org:, secret:)
156
+ api_json("/org/#{org}/secret/#{secret}/-accessreport", method: :get)
157
+ end
158
+
133
159
  def delete_secret(org:, secret:)
134
160
  api_json("/org/#{org}/secret/#{secret}", method: :delete)
135
161
  end
@@ -75,6 +75,16 @@ class ControlplaneApiDirect # rubocop:disable Metrics/ClassLength
75
75
  retry_transient: false,
76
76
  timeout: BEST_EFFORT_TIMEOUT_SECONDS
77
77
  )
78
+ REQUIRED_SENSITIVE_READ_REQUEST_POLICY = RequestPolicy.new(
79
+ sensitive: true,
80
+ retry_transient: true,
81
+ timeout: 30
82
+ )
83
+ SENSITIVE_MUTATION_REQUEST_POLICY = RequestPolicy.new(
84
+ sensitive: true,
85
+ retry_transient: false,
86
+ timeout: 30
87
+ )
78
88
 
79
89
  # Thread-safe API token cache. A single instance is shared process-wide by
80
90
  # default (see `default_token_provider`) so each `ControlplaneApiDirect.new`
@@ -328,6 +338,7 @@ class ControlplaneApiDirect # rubocop:disable Metrics/ClassLength
328
338
  def handle_response(response, url, request_policy)
329
339
  case response
330
340
  when Net::HTTPOK then parse_response_body(response, request_policy)
341
+ when Net::HTTPCreated then parse_created_response_body(response, request_policy)
331
342
  when Net::HTTPAccepted then true
332
343
  when Net::HTTPNotFound then nil
333
344
  when Net::HTTPForbidden then raise(ForbiddenError.new(url: url, response: response))
@@ -343,6 +354,12 @@ class ControlplaneApiDirect # rubocop:disable Metrics/ClassLength
343
354
  raise JSON::ParserError, "Control Plane API returned invalid JSON for a sensitive request.", cause: nil
344
355
  end
345
356
 
357
+ def parse_created_response_body(response, request_policy)
358
+ return true if response.body.to_s.empty?
359
+
360
+ parse_response_body(response, request_policy)
361
+ end
362
+
346
363
  def response_error_message(response, request_policy)
347
364
  return response.to_s if request_policy.sensitive
348
365