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/command/deploy_image.rb
CHANGED
|
@@ -4,6 +4,12 @@ require "resolv"
|
|
|
4
4
|
|
|
5
5
|
module Command
|
|
6
6
|
class DeployImage < Base # rubocop:disable Metrics/ClassLength
|
|
7
|
+
WORKLOAD_IMAGE_UPDATE_MAX_ATTEMPTS = 30
|
|
8
|
+
# Preserve the general failure cap; only optimistic-concurrency conflicts get the longer convergence window.
|
|
9
|
+
WORKLOAD_IMAGE_UPDATE_CONFLICT_MAX_ATTEMPTS = 120
|
|
10
|
+
WORKLOAD_IMAGE_UPDATE_CONFLICT_PATTERN =
|
|
11
|
+
/(?:\b409\b|(?:\A|\n)[ \t]*(?:error:[ \t]*)?conflict[ \t]*(?:\r?\n|\z))/i
|
|
12
|
+
|
|
7
13
|
NAME = "deploy-image"
|
|
8
14
|
OPTIONS = [
|
|
9
15
|
app_option(required: true),
|
|
@@ -122,7 +128,8 @@ module Command
|
|
|
122
128
|
|
|
123
129
|
container_name = container["name"]
|
|
124
130
|
step("Deploying image '#{image}' for workload '#{workload}'") do
|
|
125
|
-
|
|
131
|
+
update_workload_image_ref(workload, container_name, image)
|
|
132
|
+
|
|
126
133
|
deployed_endpoints[workload] = endpoint_for_workload(workload_data)
|
|
127
134
|
# A missing public endpoint is valid; the image update still completed successfully.
|
|
128
135
|
true
|
|
@@ -136,6 +143,36 @@ module Command
|
|
|
136
143
|
deployed_endpoints
|
|
137
144
|
end
|
|
138
145
|
|
|
146
|
+
def update_workload_image_ref(workload, container, image)
|
|
147
|
+
attempts = 0
|
|
148
|
+
|
|
149
|
+
loop do
|
|
150
|
+
attempts += 1
|
|
151
|
+
result = cp.workload_set_image_ref(workload, container: container, image: image)
|
|
152
|
+
return true if result.fetch(:success)
|
|
153
|
+
|
|
154
|
+
output = result.fetch(:output).to_s
|
|
155
|
+
raise workload_image_update_error(output) if attempts >= workload_image_update_limit(output)
|
|
156
|
+
|
|
157
|
+
wait_before_workload_image_update_retry
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def workload_image_update_limit(output)
|
|
162
|
+
return WORKLOAD_IMAGE_UPDATE_CONFLICT_MAX_ATTEMPTS if output.match?(WORKLOAD_IMAGE_UPDATE_CONFLICT_PATTERN)
|
|
163
|
+
|
|
164
|
+
WORKLOAD_IMAGE_UPDATE_MAX_ATTEMPTS
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def workload_image_update_error(output)
|
|
168
|
+
output.strip.empty? ? "Command exited with non-zero status." : output
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def wait_before_workload_image_update_retry
|
|
172
|
+
progress.print(".")
|
|
173
|
+
Kernel.sleep(1)
|
|
174
|
+
end
|
|
175
|
+
|
|
139
176
|
def print_deployed_endpoints(deployed_endpoints)
|
|
140
177
|
progress.puts("\nDeployed endpoints:")
|
|
141
178
|
deployed_endpoints.each do |workload, endpoint|
|
|
@@ -16,7 +16,7 @@ module Command
|
|
|
16
16
|
def copy_files
|
|
17
17
|
relative_paths = generated_files
|
|
18
18
|
replacements = template_variables
|
|
19
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
134
|
+
def self.generated_file_sources
|
|
130
135
|
ensure_template_root!
|
|
131
136
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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 = [
|
|
@@ -49,6 +51,12 @@ module Command
|
|
|
49
51
|
(can be configured though `runner_job_timeout` in `controlplane.yml`)
|
|
50
52
|
- Waiting for a runner replica is limited to the smaller of `runner_job_timeout` and 1000 seconds.
|
|
51
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
|
|
52
60
|
- Non-interactive jobs return the Control Plane cron job status even when the job finishes before
|
|
53
61
|
Control Plane exposes a runner replica to attach logs to
|
|
54
62
|
- Injects `CPFLOW_GVC_ID` and `CPFLOW_GVC_CREATED` into the job, exposing the app's immutable GVC
|
|
@@ -108,15 +116,24 @@ module Command
|
|
|
108
116
|
DEFAULT_JOB_CPU = "1"
|
|
109
117
|
DEFAULT_JOB_MEMORY = "2Gi"
|
|
110
118
|
DEFAULT_JOB_TIMEOUT = 21_600 # 6 hours
|
|
119
|
+
DEFAULT_JOB_STATUS_RECONCILIATION_TIMEOUT = 1_200 # 20 minutes
|
|
111
120
|
DEFAULT_JOB_HISTORY_LIMIT = 10
|
|
112
121
|
MAX_REPLICA_OBSERVATION_SECONDS = 1_000
|
|
113
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
|
|
114
130
|
NORMALIZED_JOB_STATUS_PATTERN = /\A[a-z][a-z0-9_-]{0,31}\z/
|
|
115
131
|
MAGIC_END = "---cpflow run command finished---"
|
|
116
132
|
|
|
117
133
|
attr_reader :interactive, :detached, :location, :original_workload, :runner_workload,
|
|
118
134
|
:default_image, :default_cpu, :default_memory, :job_timeout, :job_history_limit,
|
|
119
|
-
:container, :job, :replica, :command,
|
|
135
|
+
:job_status_reconciliation_timeout, :container, :job, :replica, :command,
|
|
136
|
+
:job_completed_before_replica_exit_status
|
|
120
137
|
|
|
121
138
|
def call # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
122
139
|
@interactive = config.options[:interactive] || interactive_command?
|
|
@@ -130,6 +147,8 @@ module Command
|
|
|
130
147
|
@default_cpu = config.current[:runner_job_default_cpu] || DEFAULT_JOB_CPU
|
|
131
148
|
@default_memory = config.current[:runner_job_default_memory] || DEFAULT_JOB_MEMORY
|
|
132
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
|
|
133
152
|
@job_history_limit = DEFAULT_JOB_HISTORY_LIMIT
|
|
134
153
|
|
|
135
154
|
unless interactive
|
|
@@ -596,10 +615,12 @@ module Command
|
|
|
596
615
|
if @log_method == 1 || @interactive
|
|
597
616
|
args_join(config.args)
|
|
598
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.
|
|
599
620
|
<<~SCRIPT
|
|
600
|
-
( #{args_join(config.args)} )
|
|
621
|
+
( #{args_join(config.args)} ) 2>&1
|
|
601
622
|
CPFLOW_EXIT_CODE=$?
|
|
602
|
-
|
|
623
|
+
printf '\\n%s\\n' '#{MAGIC_END}'
|
|
603
624
|
exit $CPFLOW_EXIT_CODE
|
|
604
625
|
SCRIPT
|
|
605
626
|
end
|
|
@@ -614,6 +635,7 @@ module Command
|
|
|
614
635
|
|
|
615
636
|
def wait_for_job_status_and_log(logs_pipe) # rubocop:disable Metrics/MethodLength
|
|
616
637
|
no_logs_counter = 0
|
|
638
|
+
command_finished = false
|
|
617
639
|
|
|
618
640
|
loop do
|
|
619
641
|
no_logs_counter += 1
|
|
@@ -623,12 +645,16 @@ module Command
|
|
|
623
645
|
|
|
624
646
|
no_logs_counter = 0
|
|
625
647
|
line = logs_pipe.gets
|
|
626
|
-
|
|
648
|
+
if line.chomp == MAGIC_END
|
|
649
|
+
command_finished = true
|
|
650
|
+
break
|
|
651
|
+
end
|
|
627
652
|
|
|
628
653
|
puts(line)
|
|
629
654
|
end
|
|
630
655
|
|
|
631
|
-
|
|
656
|
+
status_deadline = job_status_reconciliation_deadline if command_finished
|
|
657
|
+
resolve_job_status(status_deadline: status_deadline)
|
|
632
658
|
end
|
|
633
659
|
|
|
634
660
|
def print_detached_commands
|
|
@@ -642,51 +668,198 @@ module Command
|
|
|
642
668
|
)
|
|
643
669
|
end
|
|
644
670
|
|
|
645
|
-
|
|
671
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
672
|
+
def resolve_job_status(status_deadline: nil)
|
|
673
|
+
last_status = @last_job_status
|
|
646
674
|
loop do
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
case status
|
|
652
|
-
when "active", "pending"
|
|
653
|
-
sleep 1
|
|
654
|
-
when "successful"
|
|
655
|
-
break ExitCode::SUCCESS
|
|
656
|
-
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))
|
|
657
678
|
break ExitCode::ERROR_DEFAULT
|
|
658
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?
|
|
659
700
|
end
|
|
660
701
|
end
|
|
661
702
|
|
|
662
|
-
def
|
|
663
|
-
|
|
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
|
|
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]
|
|
724
|
+
end
|
|
725
|
+
|
|
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
|
|
664
744
|
job_details = result&.dig("items")&.find { |item| item["id"] == job }
|
|
665
745
|
job_details&.dig("status")
|
|
666
746
|
end
|
|
667
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
|
+
|
|
668
765
|
###########################################
|
|
669
766
|
### temporary extaction from run:detached
|
|
670
767
|
###########################################
|
|
671
|
-
|
|
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
|
|
672
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
|
+
|
|
673
778
|
begin
|
|
674
|
-
|
|
779
|
+
# rubocop:disable Metrics/BlockLength
|
|
675
780
|
loop do
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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
|
|
686
803
|
end
|
|
687
|
-
|
|
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?
|
|
688
837
|
|
|
689
|
-
|
|
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
|
|
690
863
|
rescue RuntimeError => e
|
|
691
864
|
raise "#{e} Exiting..." unless retries < 10 # MAX_RETRIES
|
|
692
865
|
|
|
@@ -696,12 +869,20 @@ module Command
|
|
|
696
869
|
end
|
|
697
870
|
end
|
|
698
871
|
|
|
699
|
-
def
|
|
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)
|
|
700
881
|
status = nil
|
|
701
882
|
|
|
702
883
|
@printed_log_entries ||= []
|
|
703
884
|
ts = Time.now.to_i
|
|
704
|
-
entries =
|
|
885
|
+
entries = fetch_log_entries(ts, timeout_seconds)
|
|
705
886
|
|
|
706
887
|
(entries - @printed_log_entries).sort.each do |(_ts, val)|
|
|
707
888
|
status ||= :changed
|
|
@@ -713,6 +894,15 @@ module Command
|
|
|
713
894
|
status || :unchanged
|
|
714
895
|
end
|
|
715
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
|
+
|
|
716
906
|
def normalized_log_entries(from:, to:)
|
|
717
907
|
log = cp.log_get(workload: runner_workload, from: from, to: to, replica: replica)
|
|
718
908
|
|
|
@@ -10,12 +10,13 @@ module Command
|
|
|
10
10
|
|
|
11
11
|
NAME = "update-github-actions"
|
|
12
12
|
OPTIONS = [staging_branch_option].freeze
|
|
13
|
-
DESCRIPTION = "Regenerates
|
|
13
|
+
DESCRIPTION = "Regenerates GitHub Actions files for the installed cpflow version"
|
|
14
14
|
LONG_DESCRIPTION = <<~DESC.freeze
|
|
15
|
-
Regenerates the
|
|
16
|
-
from the currently installed cpflow gem. Use this after
|
|
17
|
-
cpflow gem so checked-in workflow wrappers move to the
|
|
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
|
|
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
|