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
@@ -16,7 +16,7 @@ module Command
16
16
  def copy_files
17
17
  relative_paths = generated_files
18
18
  replacements = template_variables
19
- copy_template_files(relative_paths)
19
+ copy_generated_files(relative_paths)
20
20
  substitute_template_variables(relative_paths, replacements)
21
21
  make_shell_scripts_executable(relative_paths)
22
22
  end
@@ -27,11 +27,11 @@ module Command
27
27
 
28
28
  private
29
29
 
30
- def copy_template_files(relative_paths)
30
+ def copy_generated_files(relative_paths)
31
31
  relative_paths.each do |relative_path|
32
32
  empty_directory(File.dirname(relative_path), verbose: false)
33
33
  copy_file(
34
- File.join("github_flow_templates", relative_path),
34
+ GenerateGithubActions.source_file(relative_path),
35
35
  relative_path,
36
36
  force: true,
37
37
  verbose: ENV.fetch("HIDE_COMMAND_OUTPUT", nil) != "true"
@@ -99,6 +99,9 @@ module Command
99
99
  - manual promotion from staging to production
100
100
  - nightly cleanup and PR help workflows
101
101
 
102
+ It also copies cpflow's composite actions into `.github/actions/cpflow-*`
103
+ so every local `uses:` target is checked in and can be audited directly.
104
+
102
105
  Pass `--staging-branch BRANCH` when staging should auto-deploy from a branch
103
106
  other than `main` or `master`; the generator will bake that branch into the
104
107
  GitHub Actions push trigger and use it as the default STAGING_APP_BRANCH.
@@ -108,13 +111,13 @@ module Command
108
111
  DESC
109
112
  EXAMPLES = <<~EX
110
113
  ```sh
111
- # Creates thin .github/workflows wrappers for the Control Plane flow
114
+ # Creates workflow wrappers, local composite actions, and validation helpers
112
115
  cpflow generate-github-actions
113
116
 
114
117
  # Creates the flow with staging deploys triggered from develop
115
118
  cpflow generate-github-actions --staging-branch develop
116
119
 
117
- # Overwrites existing generated wrappers from the installed cpflow gem
120
+ # Overwrites existing generated GitHub Actions files from the installed cpflow gem
118
121
  cpflow generate-github-actions --force
119
122
  ```
120
123
  EX
@@ -122,22 +125,43 @@ module Command
122
125
  VALIDATIONS = [].freeze
123
126
  REQUIRES_STARTUP_CHECKS = false
124
127
 
125
- # Resolve template root from __dir__ rather than Cpflow.root_path because this file is
128
+ # Resolve source roots from __dir__ rather than Cpflow.root_path because this file is
126
129
  # loaded before `module Cpflow` finishes defining its class methods.
130
+ REPOSITORY_ROOT = Pathname.new(File.expand_path("../..", __dir__))
127
131
  TEMPLATE_ROOT = Pathname.new(File.expand_path("../github_flow_templates", __dir__))
132
+ ACTIONS_ROOT = REPOSITORY_ROOT.join(".github/actions")
128
133
 
129
- def self.generated_files
134
+ def self.generated_file_sources
130
135
  ensure_template_root!
131
136
 
132
- Dir.glob(TEMPLATE_ROOT.join("**", "*").to_s, File::FNM_DOTMATCH)
133
- .select { |path| File.file?(path) }
134
- .map { |path| Pathname.new(path).relative_path_from(TEMPLATE_ROOT).to_s }
135
- .sort
136
- .freeze
137
+ template_sources = files_beneath(TEMPLATE_ROOT).to_h do |source|
138
+ [source.relative_path_from(TEMPLATE_ROOT).to_s, source]
139
+ end
140
+ action_sources = files_beneath(ACTIONS_ROOT, "cpflow-*/**/*").to_h do |source|
141
+ [source.relative_path_from(REPOSITORY_ROOT).to_s, source]
142
+ end
143
+
144
+ template_sources.merge(action_sources).sort.to_h.freeze
145
+ end
146
+
147
+ def self.generated_files
148
+ generated_file_sources.keys.freeze
149
+ end
150
+
151
+ def self.source_file(relative_path)
152
+ generated_file_sources.fetch(relative_path).to_s
137
153
  end
138
154
 
139
155
  def self.ensure_template_root!
140
156
  raise "cpflow template directory not found: #{TEMPLATE_ROOT}" unless TEMPLATE_ROOT.directory?
157
+ raise "cpflow action directory not found: #{ACTIONS_ROOT}" unless ACTIONS_ROOT.directory?
158
+ end
159
+
160
+ def self.files_beneath(root, pattern = "**/*")
161
+ Dir.glob(root.join(pattern).to_s, File::FNM_DOTMATCH)
162
+ .select { |path| File.file?(path) }
163
+ .map { |path| Pathname.new(path) }
164
+ .sort
141
165
  end
142
166
 
143
167
  def call
data/lib/command/run.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "timeout"
4
+
3
5
  module Command
4
6
  class Run < Base # rubocop:disable Metrics/ClassLength
5
7
  INTERACTIVE_COMMANDS = [
@@ -47,8 +49,27 @@ module Command
47
49
  and also overridden per job through `--cpu` and `--memory`)
48
50
  - By default, the job is stopped if it takes longer than 6 hours to finish
49
51
  (can be configured though `runner_job_timeout` in `controlplane.yml`)
52
+ - Waiting for a runner replica is limited to the smaller of `runner_job_timeout` and 1000 seconds.
53
+ A terminal cron status fails immediately, and reaching the observation deadline reports the last safe status
54
+ - With non-interactive log methods 2 and 3, after a command prints its completion marker, Control Plane
55
+ has up to 20 minutes to reconcile the cron job to a terminal status. This can be configured through
56
+ `runner_job_status_reconciliation_timeout` in `controlplane.yml`; timing out exits nonzero and reports
57
+ the job, replica, and last observed status
58
+ - Log method 1 does not emit a completion marker, so its job-status polling is not covered by the
59
+ post-command reconciliation timeout
50
60
  - Non-interactive jobs return the Control Plane cron job status even when the job finishes before
51
61
  Control Plane exposes a runner replica to attach logs to
62
+ - Injects `CPFLOW_GVC_ID` and `CPFLOW_GVC_CREATED` into the job, exposing the app's immutable GVC
63
+ identity, so that a command such as a release script can tell which GVC incarnation it is running in.
64
+ These change when a GVC is deleted and recreated under the same name, and only then, unlike
65
+ `CPLN_GVC_ALIAS`, which is also embedded in mutable derived values such as the app domain and so
66
+ cannot be attributed to recreation alone
67
+ - `CPFLOW_GVC_CREATED` is the GVC's creation timestamp as returned by the Control Plane API and passed
68
+ through unmodified, currently an ISO 8601 UTC timestamp with millisecond precision and a `Z` suffix
69
+ (e.g. `2026-08-28T00:54:48.648Z`)
70
+ - Both variables are always set, and are empty when the GVC cannot be read, so that a consumer can
71
+ fail closed. They are never omitted, because the runner inherits the original workload's
72
+ environment and an omitted variable could otherwise expose a stale inherited value
52
73
  DESC
53
74
  EXAMPLES = <<~EX.freeze
54
75
  ```sh
@@ -69,7 +90,8 @@ module Command
69
90
  # - stop the job
70
91
  cpflow run -a $APP_NAME --detached -- rails db:migrate
71
92
 
72
- # The command needs to be quoted if setting an env variable or passing args.
93
+ # Quote the whole command to intentionally opt into shell syntax such as an env assignment.
94
+ # Separately supplied command arguments are passed literally.
73
95
  cpflow run -a $APP_NAME -- 'SOME_ENV_VAR=some_value rails db:migrate'
74
96
 
75
97
  # Uses a different image (which may not be promoted yet).
@@ -94,12 +116,24 @@ module Command
94
116
  DEFAULT_JOB_CPU = "1"
95
117
  DEFAULT_JOB_MEMORY = "2Gi"
96
118
  DEFAULT_JOB_TIMEOUT = 21_600 # 6 hours
119
+ DEFAULT_JOB_STATUS_RECONCILIATION_TIMEOUT = 1_200 # 20 minutes
97
120
  DEFAULT_JOB_HISTORY_LIMIT = 10
121
+ MAX_REPLICA_OBSERVATION_SECONDS = 1_000
122
+ REPLICA_OBSERVATION_POLL_INTERVAL_SECONDS = 1
123
+ LOG_QUERY_LOOKBACK_SECONDS = 60
124
+ POST_TERMINAL_LOG_DRAIN_SECONDS = 120
125
+ POST_TERMINAL_LOG_POLL_INTERVAL_SECONDS = 1
126
+ LOG_REQUEST_TIMEOUT_SECONDS = 30
127
+ JOB_STATUS_UNAVAILABLE_RETRY_LIMIT = 5
128
+ JOB_STATUS_POLL_INTERVAL_SECONDS = 1
129
+ JOB_STATUS_REQUEST_TIMEOUT_SECONDS = 30
130
+ NORMALIZED_JOB_STATUS_PATTERN = /\A[a-z][a-z0-9_-]{0,31}\z/
98
131
  MAGIC_END = "---cpflow run command finished---"
99
132
 
100
133
  attr_reader :interactive, :detached, :location, :original_workload, :runner_workload,
101
134
  :default_image, :default_cpu, :default_memory, :job_timeout, :job_history_limit,
102
- :container, :job, :replica, :command, :job_completed_before_replica_exit_status
135
+ :job_status_reconciliation_timeout, :container, :job, :replica, :command,
136
+ :job_completed_before_replica_exit_status
103
137
 
104
138
  def call # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
105
139
  @interactive = config.options[:interactive] || interactive_command?
@@ -113,6 +147,8 @@ module Command
113
147
  @default_cpu = config.current[:runner_job_default_cpu] || DEFAULT_JOB_CPU
114
148
  @default_memory = config.current[:runner_job_default_memory] || DEFAULT_JOB_MEMORY
115
149
  @job_timeout = config.current[:runner_job_timeout] || DEFAULT_JOB_TIMEOUT
150
+ @job_status_reconciliation_timeout =
151
+ config.current[:runner_job_status_reconciliation_timeout] || DEFAULT_JOB_STATUS_RECONCILIATION_TIMEOUT
116
152
  @job_history_limit = DEFAULT_JOB_HISTORY_LIMIT
117
153
 
118
154
  unless interactive
@@ -268,27 +304,80 @@ module Command
268
304
  end
269
305
 
270
306
  def wait_for_replica_for_job
271
- step("Waiting for replica to start, which runs job '#{job}'", retry_on_failure: true) do
272
- result = cp.fetch_workload_replicas(runner_workload, location: location)
273
- @replica = result&.dig("items")&.find { |item| item.include?(job) }
307
+ observation_limit = [job_timeout, MAX_REPLICA_OBSERVATION_SECONDS].min
308
+ observation_deadline = monotonic_time + observation_limit
309
+
310
+ step("Waiting for runner replica to start") do
311
+ observe_replica_until(observation_deadline, observation_limit)
312
+ end
313
+ end
314
+
315
+ def observe_replica_until(observation_deadline, observation_limit)
316
+ last_status = nil
317
+
318
+ loop do
319
+ ensure_before_replica_observation_deadline!(observation_deadline, observation_limit, last_status)
320
+
321
+ @replica = replica_for_job
322
+ return replica if replica
323
+
324
+ ensure_before_replica_observation_deadline!(observation_deadline, observation_limit, last_status)
274
325
 
275
- replica || completed_job_before_replica? || false
326
+ last_status = current_job_status
327
+ return true if completed_job_before_replica?(last_status)
328
+
329
+ sleep_before_replica_observation_retry(observation_deadline, observation_limit, last_status)
276
330
  end
277
331
  end
278
332
 
279
- def completed_job_before_replica?
280
- case current_job_status
333
+ def ensure_before_replica_observation_deadline!(observation_deadline, observation_limit, status)
334
+ return if monotonic_time < observation_deadline
335
+
336
+ raise replica_observation_timeout_message(observation_limit, status)
337
+ end
338
+
339
+ def replica_for_job
340
+ result = cp.fetch_workload_replicas(runner_workload, location: location)
341
+ result&.dig("items")&.find { |item| item.include?(job) }
342
+ end
343
+
344
+ def sleep_before_replica_observation_retry(observation_deadline, observation_limit, status)
345
+ progress.print(".")
346
+ remaining = observation_deadline - monotonic_time
347
+ raise replica_observation_timeout_message(observation_limit, status) unless remaining.positive?
348
+
349
+ Kernel.sleep([REPLICA_OBSERVATION_POLL_INTERVAL_SECONDS, remaining].min)
350
+ end
351
+
352
+ # Returns true for success, false while pending, and raises for a terminal non-success status.
353
+ def completed_job_before_replica?(status)
354
+ case status
281
355
  when "successful"
282
356
  @job_completed_before_replica_exit_status = ExitCode::SUCCESS
283
357
  true
284
358
  when nil, "active", "pending"
285
359
  false
286
360
  else
287
- @job_completed_before_replica_exit_status = ExitCode::ERROR_DEFAULT
288
- true
361
+ raise "Runner job ended before a replica was observed (status: #{normalized_job_status(status)})."
289
362
  end
290
363
  end
291
364
 
365
+ def normalized_job_status(status)
366
+ return "unavailable" if status.nil?
367
+
368
+ token = status.to_s.downcase
369
+ token.match?(NORMALIZED_JOB_STATUS_PATTERN) ? token : "unknown"
370
+ end
371
+
372
+ def replica_observation_timeout_message(observation_limit, status)
373
+ "Runner replica was not observed before the observation limit: #{format('%g', observation_limit)} seconds " \
374
+ "(status: #{normalized_job_status(status)})."
375
+ end
376
+
377
+ def monotonic_time
378
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
379
+ end
380
+
292
381
  def run_interactive
293
382
  progress.puts("Connecting to replica '#{replica}'...\n\n")
294
383
  # workload_exec returns false on non-zero exit, nil when signal-killed (e.g. Ctrl-C).
@@ -389,11 +478,12 @@ module Command
389
478
  job_start_hash["args"].push("-c")
390
479
  job_start_hash["env"] ||= []
391
480
  job_start_hash["env"].push({ "name" => "CPFLOW_RUNNER_SCRIPT", "value" => runner_script })
481
+ job_start_hash["env"].concat(gvc_identity_env_vars)
392
482
  if interactive
393
483
  job_start_hash["env"].push({ "name" => "CPFLOW_MONITORING_SCRIPT", "value" => interactive_monitoring_script })
394
484
 
395
485
  job_start_hash["args"].push('eval "$CPFLOW_MONITORING_SCRIPT"')
396
- @command = %(bash -c 'eval "$CPFLOW_RUNNER_SCRIPT"')
486
+ @command = ["bash", "-c", 'eval "$CPFLOW_RUNNER_SCRIPT"']
397
487
  else
398
488
  job_start_hash["args"].push('eval "$CPFLOW_RUNNER_SCRIPT"')
399
489
  end
@@ -413,6 +503,58 @@ module Command
413
503
  job_start_hash.to_yaml
414
504
  end
415
505
 
506
+ # Exposes the GVC's immutable identity to the job. cpflow authenticates client-side with the
507
+ # operator/CI credentials, so reading the GVC here adds no GVC-view binding to the app's workload
508
+ # identity and leaves nothing behind for the delete lifecycle to clean up.
509
+ #
510
+ # Both variables are always emitted, empty when unknown, rather than omitted. `update_runner_workload`
511
+ # copies the original workload's env wholesale into the runner, so omitting them would let a value
512
+ # inherited from the workload survive a failed read and be mistaken for a live identity. Emitting an
513
+ # explicit empty value is the only way a consumer can actually fail closed.
514
+ def gvc_identity_env_vars
515
+ gvc_data = fetch_gvc_for_identity
516
+ gvc_id = (gvc_data && gvc_data["id"]).to_s
517
+ # Gated on the id so a fresh id can never be paired with an inherited timestamp.
518
+ gvc_created = gvc_id.empty? ? "" : gvc_data["created"].to_s
519
+
520
+ [
521
+ { "name" => "CPFLOW_GVC_ID", "value" => gvc_id },
522
+ { "name" => "CPFLOW_GVC_CREATED", "value" => gvc_created }
523
+ ]
524
+ end
525
+
526
+ # The identity is an optional enrichment, so failing to read it must never stop the job, and the
527
+ # variables are still emitted (empty) rather than dropped.
528
+ # Two reasons the rescue is deliberately broad rather than a narrow class list:
529
+ #
530
+ # 1. Availability. `cpflow run` is also the release-phase mechanism for `deploy-image`, `setup-app`,
531
+ # and `delete`, so a transient error on the GVC endpoint would otherwise fail a deploy over a
532
+ # variable the command does not need.
533
+ # 2. Taxonomy. `handle_response` raises a bare `RuntimeError` for 401 and 5xx responses, so any
534
+ # "narrow" list that actually covered the real failures would have to include `RuntimeError`.
535
+ #
536
+ # `MaintenanceMode#domain_workload_update_confirmed?` rescues `StandardError` against this same API
537
+ # client for the same reason. The rescued body is a single external call, so a bug in this file's own
538
+ # logic still raises.
539
+ def fetch_gvc_for_identity
540
+ cp.fetch_gvc
541
+ rescue StandardError => e
542
+ # Deliberately `Shell.warn` rather than a `step`. `step_finish` prints a red "failed!" banner
543
+ # whenever its block is falsy, regardless of `abort_on_error`, so routing an optional lookup
544
+ # through it would show a failure on every deploy for anyone whose token omits `gvc` view -- for
545
+ # a read that stops nothing and leaves the exit code at 0.
546
+ Shell.warn(gvc_identity_error_message(e))
547
+ nil
548
+ end
549
+
550
+ # A 403 here is normally a missing `view` grant on kind `gvc`, but `ForbiddenError` renders any
551
+ # `/org/...` URL as "Double check your org", which sends the operator after the wrong thing.
552
+ def gvc_identity_error_message(error)
553
+ "Continuing without the GVC identity, so CPFLOW_GVC_ID and CPFLOW_GVC_CREATED are set to empty " \
554
+ "strings. A permission failure here usually means the token lacks `view` on kind `gvc` for this " \
555
+ "app, even when the message below mentions the org. #{error.message}"
556
+ end
557
+
416
558
  def interactive_monitoring_script
417
559
  <<~SCRIPT
418
560
  primary_pid=""
@@ -473,10 +615,12 @@ module Command
473
615
  if @log_method == 1 || @interactive
474
616
  args_join(config.args)
475
617
  else
618
+ # MAGIC_END must be a same-stream barrier after all payload output. Merge stderr into stdout before
619
+ # capturing the payload exit status and emitting the marker.
476
620
  <<~SCRIPT
477
- ( #{args_join(config.args)} )
621
+ ( #{args_join(config.args)} ) 2>&1
478
622
  CPFLOW_EXIT_CODE=$?
479
- echo '#{MAGIC_END}'
623
+ printf '\\n%s\\n' '#{MAGIC_END}'
480
624
  exit $CPFLOW_EXIT_CODE
481
625
  SCRIPT
482
626
  end
@@ -491,6 +635,7 @@ module Command
491
635
 
492
636
  def wait_for_job_status_and_log(logs_pipe) # rubocop:disable Metrics/MethodLength
493
637
  no_logs_counter = 0
638
+ command_finished = false
494
639
 
495
640
  loop do
496
641
  no_logs_counter += 1
@@ -500,12 +645,16 @@ module Command
500
645
 
501
646
  no_logs_counter = 0
502
647
  line = logs_pipe.gets
503
- break if line.chomp == MAGIC_END
648
+ if line.chomp == MAGIC_END
649
+ command_finished = true
650
+ break
651
+ end
504
652
 
505
653
  puts(line)
506
654
  end
507
655
 
508
- resolve_job_status
656
+ status_deadline = job_status_reconciliation_deadline if command_finished
657
+ resolve_job_status(status_deadline: status_deadline)
509
658
  end
510
659
 
511
660
  def print_detached_commands
@@ -519,51 +668,198 @@ module Command
519
668
  )
520
669
  end
521
670
 
522
- def resolve_job_status # rubocop:disable Metrics/MethodLength
671
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
672
+ def resolve_job_status(status_deadline: nil)
673
+ last_status = @last_job_status
523
674
  loop do
524
- status = current_job_status
525
-
526
- Shell.debug("JOB STATUS", status)
527
-
528
- case status
529
- when "active", "pending"
530
- sleep 1
531
- when "successful"
532
- break ExitCode::SUCCESS
533
- else
675
+ remaining_seconds = status_deadline && (status_deadline - monotonic_time)
676
+ if remaining_seconds && remaining_seconds <= 0
677
+ progress.puts(Shell.color(job_status_reconciliation_timeout_message(last_status), :red))
534
678
  break ExitCode::ERROR_DEFAULT
535
679
  end
680
+
681
+ request_timeout = if remaining_seconds
682
+ [JOB_STATUS_REQUEST_TIMEOUT_SECONDS, remaining_seconds].min
683
+ else
684
+ JOB_STATUS_REQUEST_TIMEOUT_SECONDS
685
+ end
686
+ begin
687
+ status = current_job_status(timeout_seconds: request_timeout)
688
+ rescue Shell::CommandTimeout
689
+ next
690
+ end
691
+ last_status = status
692
+ @last_job_status = status
693
+
694
+ exit_status = job_exit_status(status, unavailable_is_pending: false)
695
+ break exit_status if exit_status
696
+
697
+ sleep_seconds = JOB_STATUS_POLL_INTERVAL_SECONDS
698
+ sleep_seconds = [sleep_seconds, remaining_seconds].min if remaining_seconds
699
+ Kernel.sleep(sleep_seconds) if sleep_seconds.positive?
700
+ end
701
+ end
702
+
703
+ def reconciled_job_status(unavailable_status_streak, reconciliation_deadline, reconciliation_deadline_origin,
704
+ request_timeout:)
705
+ status = begin
706
+ current_job_status(timeout_seconds: request_timeout)
707
+ rescue Shell::CommandTimeout
708
+ nil
536
709
  end
710
+ next_unavailable_status_streak = status.nil? ? unavailable_status_streak + 1 : 0
711
+ exit_status = job_exit_status(status, unavailable_is_pending: status.nil?)
712
+
713
+ if next_unavailable_status_streak > JOB_STATUS_UNAVAILABLE_RETRY_LIMIT && reconciliation_deadline.nil?
714
+ reconciliation_deadline = start_reconciliation_deadline(
715
+ reconciliation_deadline,
716
+ duration: POST_TERMINAL_LOG_DRAIN_SECONDS
717
+ )
718
+ reconciliation_deadline_origin = :status_outage
719
+ elsif reconciliation_deadline_origin == :status_outage && %w[active pending].include?(status)
720
+ reconciliation_deadline = reconciliation_deadline_origin = @post_terminal_log_from = nil
721
+ end
722
+
723
+ [exit_status, next_unavailable_status_streak, status, reconciliation_deadline, reconciliation_deadline_origin]
537
724
  end
538
725
 
539
- def current_job_status
540
- result = cp.fetch_cron_workload(runner_workload, location: location)
726
+ def job_exit_status(status, unavailable_is_pending:)
727
+ Shell.debug("JOB STATUS", normalized_job_status(status))
728
+
729
+ case status
730
+ when nil then unavailable_is_pending ? nil : ExitCode::ERROR_DEFAULT
731
+ when "active", "pending" then nil
732
+ when "successful" then ExitCode::SUCCESS
733
+ else ExitCode::ERROR_DEFAULT
734
+ end
735
+ end
736
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
737
+
738
+ def current_job_status(timeout_seconds: nil)
739
+ result = if timeout_seconds
740
+ cp.fetch_cron_workload(runner_workload, location: location, timeout_seconds: timeout_seconds)
741
+ else
742
+ cp.fetch_cron_workload(runner_workload, location: location)
743
+ end
541
744
  job_details = result&.dig("items")&.find { |item| item["id"] == job }
542
745
  job_details&.dig("status")
543
746
  end
544
747
 
748
+ def job_status_reconciliation_deadline
749
+ monotonic_time + job_status_reconciliation_timeout
750
+ end
751
+
752
+ def job_status_reconciliation_timeout_message(status)
753
+ replica_args = app_workload_replica_args.join(" ")
754
+
755
+ <<~MESSAGE.chomp
756
+ ERROR: Control Plane job status did not reconcile within #{job_status_reconciliation_timeout} seconds after the command finished.
757
+ job: #{job}
758
+ replica: #{replica}
759
+ status: #{status || 'unknown'}
760
+ Inspect logs: cpflow logs #{replica_args}
761
+ Stop the stale runner: cpflow ps:stop #{replica_args}
762
+ MESSAGE
763
+ end
764
+
545
765
  ###########################################
546
766
  ### temporary extaction from run:detached
547
767
  ###########################################
548
- def show_logs_waiting # rubocop:disable Metrics/MethodLength
768
+ # Tracks log completion, authoritative job status, provisional status outages, and bounded deadlines.
769
+ def show_logs_waiting # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
549
770
  retries = 0
771
+ exit_status = nil
772
+ reconciliation_deadline = nil
773
+ reconciliation_deadline_origin = nil
774
+ unavailable_status_streak = 0
775
+ finish_marker_seen = false
776
+ last_job_status = nil
777
+
550
778
  begin
551
- job_finished_count = 0
779
+ # rubocop:disable Metrics/BlockLength
552
780
  loop do
553
- case print_uniq_logs
554
- when :finished
555
- break
556
- when :changed
557
- next
558
- else
559
- job_finished_count += 1 if resolve_job_status
560
- break if job_finished_count > 5
561
-
562
- sleep(1)
781
+ remaining = reconciliation_deadline && (reconciliation_deadline - monotonic_time)
782
+ break if remaining && !remaining.positive?
783
+
784
+ unless finish_marker_seen
785
+ log_request_timeout = remaining ? [LOG_REQUEST_TIMEOUT_SECONDS, remaining].min : LOG_REQUEST_TIMEOUT_SECONDS
786
+ log_status = begin
787
+ print_uniq_logs(timeout_seconds: log_request_timeout)
788
+ rescue Shell::CommandTimeout
789
+ :unchanged
790
+ end
791
+ finish_marker_seen = log_status == :finished
792
+ if finish_marker_seen && reconciliation_deadline.nil?
793
+ reconciliation_deadline = start_reconciliation_deadline(
794
+ reconciliation_deadline,
795
+ duration: job_status_reconciliation_timeout || POST_TERMINAL_LOG_DRAIN_SECONDS
796
+ )
797
+ reconciliation_deadline_origin = :finish_marker
798
+ elsif finish_marker_seen && reconciliation_deadline_origin == :status_outage
799
+ reconciliation_deadline = monotonic_time +
800
+ (job_status_reconciliation_timeout || POST_TERMINAL_LOG_DRAIN_SECONDS)
801
+ reconciliation_deadline_origin = :finish_marker
802
+ end
563
803
  end
564
- end
804
+ remaining = reconciliation_deadline && (reconciliation_deadline - monotonic_time)
805
+ break if remaining && !remaining.positive?
806
+
807
+ request_timeout = if remaining
808
+ [JOB_STATUS_REQUEST_TIMEOUT_SECONDS, remaining].min
809
+ else
810
+ JOB_STATUS_REQUEST_TIMEOUT_SECONDS
811
+ end
812
+
813
+ exit_status ||= begin
814
+ job_status_state = reconciled_job_status(
815
+ unavailable_status_streak, reconciliation_deadline, reconciliation_deadline_origin,
816
+ request_timeout: request_timeout
817
+ )
818
+ current_exit_status, unavailable_status_streak, observed_status,
819
+ reconciliation_deadline, reconciliation_deadline_origin = job_status_state
820
+ last_job_status = observed_status unless observed_status.nil?
821
+ current_exit_status
822
+ end
823
+ if exit_status && reconciliation_deadline_origin == :status_outage
824
+ reconciliation_deadline = monotonic_time + POST_TERMINAL_LOG_DRAIN_SECONDS
825
+ reconciliation_deadline_origin = :terminal
826
+ elsif exit_status && reconciliation_deadline.nil?
827
+ reconciliation_deadline = start_reconciliation_deadline(
828
+ reconciliation_deadline,
829
+ duration: POST_TERMINAL_LOG_DRAIN_SECONDS
830
+ )
831
+ reconciliation_deadline_origin = :terminal
832
+ end
833
+ break if finish_marker_seen && exit_status
834
+
835
+ remaining = reconciliation_deadline && (reconciliation_deadline - monotonic_time)
836
+ break if remaining && !remaining.positive?
565
837
 
566
- resolve_job_status
838
+ poll_interval = POST_TERMINAL_LOG_POLL_INTERVAL_SECONDS
839
+ poll_interval = [poll_interval, remaining].min if remaining
840
+ Kernel.sleep(poll_interval)
841
+ end
842
+ # rubocop:enable Metrics/BlockLength
843
+
844
+ if exit_status && finish_marker_seen
845
+ exit_status
846
+ elsif exit_status
847
+ Shell.warn(
848
+ "Runner job reached terminal status #{normalized_job_status(last_job_status)} but the command " \
849
+ "completion marker was unavailable after #{POST_TERMINAL_LOG_DRAIN_SECONDS} seconds; " \
850
+ "returning authoritative job exit status #{exit_status} with incomplete output."
851
+ )
852
+ exit_status
853
+ elsif reconciliation_deadline_origin == :finish_marker && job_status_reconciliation_timeout
854
+ progress.puts(Shell.color(job_status_reconciliation_timeout_message(last_job_status), :red))
855
+ ExitCode::ERROR_DEFAULT
856
+ else
857
+ Shell.warn(
858
+ "Runner job status reconciliation reached the #{POST_TERMINAL_LOG_DRAIN_SECONDS}-second deadline " \
859
+ "(last_status: #{normalized_job_status(last_job_status)}); returning exit status #{ExitCode::ERROR_DEFAULT}."
860
+ )
861
+ ExitCode::ERROR_DEFAULT
862
+ end
567
863
  rescue RuntimeError => e
568
864
  raise "#{e} Exiting..." unless retries < 10 # MAX_RETRIES
569
865
 
@@ -573,12 +869,20 @@ module Command
573
869
  end
574
870
  end
575
871
 
576
- def print_uniq_logs
872
+ def start_reconciliation_deadline(current_deadline, duration:)
873
+ return current_deadline if current_deadline
874
+
875
+ # Freeze the pre-terminal overlap so late-ingested entries do not age out of the query window.
876
+ @post_terminal_log_from = Time.now.to_i - LOG_QUERY_LOOKBACK_SECONDS
877
+ monotonic_time + duration
878
+ end
879
+
880
+ def print_uniq_logs(timeout_seconds: nil)
577
881
  status = nil
578
882
 
579
883
  @printed_log_entries ||= []
580
884
  ts = Time.now.to_i
581
- entries = normalized_log_entries(from: ts - 60, to: ts)
885
+ entries = fetch_log_entries(ts, timeout_seconds)
582
886
 
583
887
  (entries - @printed_log_entries).sort.each do |(_ts, val)|
584
888
  status ||= :changed
@@ -590,6 +894,15 @@ module Command
590
894
  status || :unchanged
591
895
  end
592
896
 
897
+ def fetch_log_entries(to, timeout_seconds)
898
+ from = @post_terminal_log_from || (to - LOG_QUERY_LOOKBACK_SECONDS)
899
+ return normalized_log_entries(from: from, to: to) unless timeout_seconds
900
+
901
+ Timeout.timeout(timeout_seconds, Shell::CommandTimeout) do
902
+ normalized_log_entries(from: from, to: to)
903
+ end
904
+ end
905
+
593
906
  def normalized_log_entries(from:, to:)
594
907
  log = cp.log_get(workload: runner_workload, from: from, to: to, replica: replica)
595
908