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.
- checksums.yaml +4 -4
- data/.agents/agent-workflow.yml +13 -0
- data/.agents/bin/README.md +12 -1
- data/.agents/bin/docs +1 -1
- data/.agents/bin/lint +1 -1
- data/.agents/bin/setup +1 -1
- data/.agents/bin/test +1 -1
- data/.agents/bin/validate +2 -1
- data/.coderabbit.yaml +13 -0
- data/.github/actions/cpflow-delete-control-plane-app/action.yml +2 -1
- data/.github/actions/cpflow-setup-environment/action.yml +8 -6
- data/.github/dependabot.yml +10 -0
- data/.github/workflows/check_cpln_links.yml +6 -1
- data/.github/workflows/claude-code-review.yml +5 -2
- data/.github/workflows/claude.yml +6 -4
- data/.github/workflows/coderabbit-lifecycle-review.yml +29 -0
- data/.github/workflows/command_docs.yml +7 -2
- data/.github/workflows/cpflow-cleanup-stale-review-apps.yml +12 -5
- data/.github/workflows/cpflow-delete-review-app.yml +18 -11
- data/.github/workflows/cpflow-deploy-review-app.yml +25 -16
- data/.github/workflows/cpflow-deploy-staging.yml +16 -10
- data/.github/workflows/cpflow-help-command.yml +3 -3
- data/.github/workflows/cpflow-promote-staging-to-production.yml +7 -7
- data/.github/workflows/cpflow-review-app-help.yml +1 -1
- data/.github/workflows/rspec-shared.yml +34 -26
- data/.github/workflows/rspec-specific.yml +10 -2
- data/.github/workflows/rspec.yml +71 -4
- data/.github/workflows/rubocop.yml +9 -2
- data/.github/workflows/trigger-docs-site.yml +2 -0
- data/.rubocop.yml +6 -1
- data/CHANGELOG.md +24 -1
- data/CONTRIBUTING.md +39 -2
- data/Gemfile.lock +1 -1
- data/README.md +6 -1
- data/cpflow.gemspec +3 -15
- data/docs/ai-github-flow-prompt.md +2 -2
- data/docs/ci-automation.md +112 -60
- data/docs/commands.md +18 -8
- data/docs/rds-private-networking.md +12 -12
- data/docs/releasing.md +15 -4
- data/examples/controlplane.yml +5 -0
- data/lib/command/ai_github_flow_prompt.rb +1 -1
- data/lib/command/cleanup_stale_apps.rb +28 -4
- data/lib/command/copy_image_from_upstream.rb +3 -0
- data/lib/command/deploy_image.rb +38 -1
- data/lib/command/generate_github_actions.rb +36 -12
- data/lib/command/run.rb +224 -34
- data/lib/command/update_github_actions.rb +7 -6
- data/lib/core/controlplane.rb +145 -78
- data/lib/core/shell.rb +35 -10
- data/lib/core/timed_command.rb +111 -0
- data/lib/cpflow/version.rb +1 -1
- data/lib/cpflow.rb +1 -1
- data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +7 -7
- data/lib/github_flow_templates/bin/test-cpflow-github-flow +63 -3
- data/lib/patches/hash.rb +2 -2
- data/rakelib/create_release.rake +14 -14
- data/script/check_shell_scripts +66 -0
- metadata +11 -18
data/lib/core/controlplane.rb
CHANGED
|
@@ -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
|
|
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 )
|
|
33
|
-
cmd = "cpln profile create
|
|
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
|
|
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!(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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 #{
|
|
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 =
|
|
220
|
-
|
|
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 =
|
|
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 =
|
|
256
|
-
|
|
257
|
-
|
|
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:
|
|
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
|
|
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
|
|
298
|
-
cmd
|
|
299
|
-
cmd
|
|
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 =
|
|
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 =
|
|
327
|
-
|
|
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 =
|
|
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!(
|
|
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!(
|
|
470
|
+
perform!(cmd)
|
|
447
471
|
end
|
|
448
472
|
|
|
449
473
|
# apply
|
|
450
|
-
def apply_template(data, wait: false) # rubocop:disable Metrics/MethodLength
|
|
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
|
|
455
|
-
cmd
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
483
|
+
Shell.debug("CMD", Shellwords.join(cmd))
|
|
460
484
|
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
540
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
|
|
576
|
+
validate_argv!(cmd)
|
|
577
|
+
spawn_options = command_spawn_options(output_mode)
|
|
569
578
|
|
|
570
|
-
debug_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
|
-
|
|
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
|
-
|
|
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
|
|
606
|
-
"--gvc
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
yield
|
|
11
|
+
previous_tmp_stderr = @tmp_stderr
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
79
|
-
|
|
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
|
data/lib/cpflow/version.rb
CHANGED
data/lib/cpflow.rb
CHANGED
|
@@ -61,12 +61,12 @@ jobs:
|
|
|
61
61
|
|
|
62
62
|
steps:
|
|
63
63
|
- name: Checkout repository
|
|
64
|
-
uses: actions/checkout@
|
|
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@
|
|
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: ./.
|
|
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: ./.
|
|
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: ./.
|
|
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@
|
|
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: ./.
|
|
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 }}
|