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.
- checksums.yaml +4 -4
- data/.agents/agent-workflow.yml +26 -2
- data/.agents/bin/README.md +16 -4
- 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 +9 -7
- data/.github/actions/cpflow-wait-for-health/action.yml +87 -15
- data/.github/dependabot.yml +10 -0
- data/.github/pull_request_template.md +18 -0
- data/.github/workflows/check_cpln_links.yml +6 -1
- data/.github/workflows/claude-code-review.yml +5 -2
- data/.github/workflows/claude.yml +97 -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 +638 -43
- data/.github/workflows/cpflow-deploy-review-app.yml +665 -36
- 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 +6 -14
- 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/AGENTS.md +6 -49
- data/CHANGELOG.md +56 -1
- data/CONTRIBUTING.md +48 -3
- 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 +216 -99
- data/docs/commands.md +36 -9
- data/docs/rds-private-networking.md +12 -12
- data/docs/releasing.md +15 -4
- data/docs/secrets-and-env-values.md +8 -0
- data/docs/tips.md +18 -0
- data/examples/controlplane.yml +5 -0
- data/lib/command/ai_github_flow_prompt.rb +1 -1
- data/lib/command/apply_template.rb +104 -2
- data/lib/command/base.rb +52 -3
- 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 +54 -3
- data/lib/command/generate_github_actions.rb +36 -12
- data/lib/command/run.rb +357 -44
- data/lib/command/setup_app.rb +10 -5
- data/lib/command/update_github_actions.rb +7 -6
- data/lib/core/controlplane.rb +179 -79
- data/lib/core/controlplane_api.rb +8 -0
- data/lib/core/controlplane_api_direct.rb +257 -63
- data/lib/core/shell.rb +39 -7
- 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/cpflow-help.md +24 -10
- data/lib/github_flow_templates/.github/workflows/cpflow-delete-review-app.yml +10 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +9 -0
- 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 +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
|
-
|
|
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 = [
|
|
@@ -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
|
-
#
|
|
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,
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
|
280
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
|
540
|
-
|
|
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
|
-
|
|
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
|
-
|
|
779
|
+
# rubocop:disable Metrics/BlockLength
|
|
552
780
|
loop do
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|