cpflow 5.1.1 → 5.3.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/agent-workflow.yml +26 -0
  3. data/.agents/bin/README.md +20 -0
  4. data/.agents/bin/docs +5 -0
  5. data/.agents/bin/lint +5 -0
  6. data/.agents/bin/setup +5 -0
  7. data/.agents/bin/test +5 -0
  8. data/.agents/bin/validate +5 -0
  9. data/.agents/trusted-github-actors.yml +32 -0
  10. data/.agents/workflows/ai-rollout-e2e-test.md +166 -0
  11. data/.github/actions/cpflow-setup-environment/action.yml +1 -1
  12. data/.github/actions/cpflow-wait-for-health/action.yml +87 -15
  13. data/.github/pull_request_template.md +18 -0
  14. data/.github/workflows/claude-code-review.yml +2 -0
  15. data/.github/workflows/claude.yml +94 -1
  16. data/.github/workflows/cpflow-delete-review-app.yml +621 -33
  17. data/.github/workflows/cpflow-deploy-review-app.yml +656 -21
  18. data/.github/workflows/cpflow-review-app-help.yml +5 -13
  19. data/.github/workflows/rspec-shared.yml +10 -3
  20. data/.github/workflows/rspec-specific.yml +1 -0
  21. data/.github/workflows/rspec.yml +58 -1
  22. data/AGENTS.md +14 -0
  23. data/CHANGELOG.md +54 -1
  24. data/CLAUDE.md +3 -0
  25. data/CONTRIBUTING.md +15 -3
  26. data/Gemfile.lock +1 -1
  27. data/README.md +21 -7
  28. data/docs/ai-github-flow-prompt.md +18 -16
  29. data/docs/ci-automation.md +239 -27
  30. data/docs/commands.md +30 -2
  31. data/docs/grafana-opentelemetry.md +699 -0
  32. data/docs/secrets-and-env-values.md +37 -2
  33. data/docs/sidebars.ts +70 -0
  34. data/docs/telemetry/application-instrumentation.md +161 -0
  35. data/docs/telemetry/collector.md +297 -0
  36. data/docs/telemetry/index.md +152 -0
  37. data/docs/telemetry/pipelines.md +98 -0
  38. data/docs/telemetry/review-apps.md +55 -0
  39. data/docs/telemetry/troubleshooting.md +92 -0
  40. data/docs/terraform/example/.controlplane/controlplane.yml +0 -1
  41. data/docs/terraform/overview.md +11 -0
  42. data/docs/tips.md +475 -28
  43. data/examples/controlplane.yml +2 -0
  44. data/lib/command/ai_github_flow_prompt.rb +2 -2
  45. data/lib/command/apply_template.rb +104 -2
  46. data/lib/command/base.rb +69 -5
  47. data/lib/command/deploy_image.rb +93 -7
  48. data/lib/command/promote_app_from_upstream.rb +1 -0
  49. data/lib/command/ps_wait.rb +2 -10
  50. data/lib/command/run.rb +133 -10
  51. data/lib/command/setup_app.rb +10 -5
  52. data/lib/core/config.rb +94 -0
  53. data/lib/core/controlplane.rb +38 -5
  54. data/lib/core/controlplane_api.rb +8 -0
  55. data/lib/core/controlplane_api_direct.rb +257 -63
  56. data/lib/core/doctor_service.rb +44 -3
  57. data/lib/core/shell.rb +9 -2
  58. data/lib/core/template_parser.rb +43 -9
  59. data/lib/cpflow/version.rb +1 -1
  60. data/lib/generator_templates/controlplane.yml +1 -2
  61. data/lib/github_flow_templates/.github/cpflow-help.md +34 -10
  62. data/lib/github_flow_templates/.github/workflows/cpflow-delete-review-app.yml +10 -0
  63. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +9 -0
  64. metadata +22 -2
@@ -9,7 +9,8 @@ module Command
9
9
  app_option(required: true),
10
10
  location_option,
11
11
  skip_confirm_option,
12
- add_app_identity_option
12
+ add_app_identity_option,
13
+ preserve_existing_runtime_option
13
14
  ].freeze
14
15
  DESCRIPTION = "Applies application-specific configs from templates"
15
16
  LONG_DESCRIPTION = <<~DESC
@@ -17,6 +18,8 @@ module Command
17
18
  - Publishes (creates or updates) those at Control Plane infrastructure
18
19
  - Picks templates from the `.controlplane/templates` directory
19
20
  - Templates are ordinary Control Plane templates but with variable preprocessing
21
+ - Use `--preserve-existing-runtime` to retain each workload container's configured app image, even when the workload is unready, and skip existing secret resources entirely while applying other template changes
22
+ - Missing or invalid workload images use only an unambiguous app image from ready workloads; refresh fails before applying templates when no safe fallback exists
20
23
 
21
24
  **Preprocessed template variables:**
22
25
 
@@ -58,6 +61,7 @@ module Command
58
61
  @skipped_templates = []
59
62
 
60
63
  templates = @template_parser.parse(@names_to_filenames.values)
64
+ templates = preserve_existing_runtime(templates) if config.options[:preserve_existing_runtime]
61
65
  pending_templates = confirm_templates(templates)
62
66
  add_app_identity_template(pending_templates) if config.options[:add_app_identity]
63
67
  pending_templates.each do |template|
@@ -117,7 +121,7 @@ module Command
117
121
  end
118
122
 
119
123
  def confirm_workload(template) # rubocop:disable Naming/PredicateMethod
120
- workload = cp.fetch_workload(template["name"])
124
+ workload = fetch_workload(template["name"])
121
125
  return true unless workload
122
126
 
123
127
  confirmed = confirm_apply("Workload '#{template['name']}' already exists, do you want to re-create it?")
@@ -146,6 +150,104 @@ module Command
146
150
  pending_templates
147
151
  end
148
152
 
153
+ def preserve_existing_runtime(templates)
154
+ cache_existing_workloads(templates)
155
+ ready_fallback_image = unambiguous_ready_app_image
156
+
157
+ templates.filter_map do |template|
158
+ if template["kind"] == "secret" && cp.fetch_secret(template["name"])
159
+ report_skipped(template)
160
+ next
161
+ end
162
+
163
+ preserve_workload_images(template, ready_fallback_image) if template["kind"] == "workload"
164
+ template
165
+ end
166
+ end
167
+
168
+ def cache_existing_workloads(templates)
169
+ template_names = templates.filter_map { |template| template["name"] if template["kind"] == "workload" }
170
+ workloads = cp.fetch_workloads
171
+ cp.fetch_gvc! unless workloads
172
+ @existing_workloads = Array(workloads.fetch("items")).to_h do |workload|
173
+ [workload.fetch("name"), workload]
174
+ end
175
+ template_names.each { |name| @existing_workloads[name] = nil unless @existing_workloads.key?(name) }
176
+ end
177
+
178
+ def fetch_workload(name)
179
+ return @existing_workloads[name] if @existing_workloads&.key?(name)
180
+
181
+ cp.fetch_workload(name)
182
+ end
183
+
184
+ def unambiguous_ready_app_image
185
+ app_images = ready_existing_app_workloads.flat_map do |workload|
186
+ Array(workload.dig("spec", "containers")).filter_map do |container|
187
+ container["image"] if deployable_app_image?(container["image"])
188
+ end
189
+ end
190
+ image_prefix = "/org/#{config.org}/image/"
191
+ canonical_images = app_images.map { |image| image.delete_prefix(image_prefix) }
192
+ app_images.first if canonical_images.uniq.one?
193
+ end
194
+
195
+ def ready_existing_app_workloads
196
+ @existing_workloads.values.compact.filter_map do |workload|
197
+ next unless Array(workload.dig("spec", "containers")).any? do |container|
198
+ deployable_app_image?(container["image"])
199
+ end
200
+
201
+ workload = workload_with_readiness(workload)
202
+ workload if workload_ready?(workload)
203
+ end
204
+ end
205
+
206
+ def preserve_workload_images(template, ready_fallback_image) # rubocop:disable Metrics/MethodLength
207
+ existing_workload = fetch_workload(template["name"])
208
+ existing_containers = Array(existing_workload&.dig("spec", "containers"))
209
+ .to_h { |container| [container["name"], container] }
210
+
211
+ Array(template.dig("spec", "containers")).each do |container|
212
+ next unless app_image?(container["image"])
213
+
214
+ existing_image = existing_containers.dig(container["name"], "image")
215
+ preserved_image = preserved_image(existing_image, ready_fallback_image)
216
+ unless preserved_image
217
+ raise "Cannot safely refresh app image for workload '#{template['name']}' " \
218
+ "without a valid existing workload image or an unambiguous ready fallback image."
219
+ end
220
+
221
+ container["image"] = preserved_image
222
+ end
223
+ end
224
+
225
+ def preserved_image(existing_image, ready_fallback_image)
226
+ return existing_image if deployable_app_image?(existing_image)
227
+
228
+ ready_fallback_image
229
+ end
230
+
231
+ def workload_ready?(workload)
232
+ workload&.dig("status", "readyLatest") == true
233
+ end
234
+
235
+ def workload_with_readiness(workload)
236
+ return workload unless workload.dig("status", "readyLatest").nil?
237
+
238
+ cp.fetch_workload_with_status(workload.fetch("name")) || workload
239
+ end
240
+
241
+ def app_image?(image)
242
+ image.to_s.match?(
243
+ %r{\A(?:/org/#{Regexp.escape(config.org)}/image/)?#{Regexp.escape(config.app)}[:@]}
244
+ )
245
+ end
246
+
247
+ def deployable_app_image?(image)
248
+ app_image?(image) && !image.to_s.end_with?(Controlplane::NO_IMAGE_AVAILABLE)
249
+ end
250
+
149
251
  def add_app_identity_template(templates)
150
252
  app_template_index = templates.index { |template| template["name"] == config.app }
151
253
  app_identity_template_index = templates.index { |template| template["name"] == config.identity }
data/lib/command/base.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "shellwords"
3
4
  require_relative "../core/helpers"
4
5
 
5
6
  module Command
@@ -11,6 +12,7 @@ module Command
11
12
  VALIDATIONS_WITHOUT_ADDITIONAL_OPTIONS = %w[config].freeze
12
13
  VALIDATIONS_WITH_ADDITIONAL_OPTIONS = %w[templates].freeze
13
14
  ALL_VALIDATIONS = VALIDATIONS_WITHOUT_ADDITIONAL_OPTIONS + VALIDATIONS_WITH_ADDITIONAL_OPTIONS
15
+ GENERATED_POSTGRES_PASSWORD_PLACEHOLDER = "the_password"
14
16
 
15
17
  # Used to call the command (`cpflow SUBCOMMAND_NAME NAME`)
16
18
  SUBCOMMAND_NAME = nil
@@ -49,7 +51,7 @@ module Command
49
51
 
50
52
  def self.all_commands # rubocop:disable Metrics/MethodLength
51
53
  Dir["#{__dir__}/**/*.rb"].each_with_object({}) do |file, result|
52
- content = File.read(file)
54
+ content = File.read(file, encoding: "UTF-8")
53
55
 
54
56
  classname = content.match(/^\s+class (?!Base\b)(\w+) < (?:.*(?!Command::)Base)(?:$| .*$)/)&.captures&.first
55
57
  next unless classname
@@ -96,7 +98,7 @@ module Command
96
98
  }
97
99
  end
98
100
 
99
- def self.workload_option(required: false)
101
+ def self.workload_option(required: false, repeatable: false)
100
102
  {
101
103
  name: :workload,
102
104
  params: {
@@ -104,6 +106,7 @@ module Command
104
106
  banner: "WORKLOAD_NAME",
105
107
  desc: "Workload name",
106
108
  type: :string,
109
+ repeatable: repeatable,
107
110
  required: required
108
111
  }
109
112
  }
@@ -458,6 +461,28 @@ module Command
458
461
  }
459
462
  end
460
463
 
464
+ def self.refresh_templates_option(required: false)
465
+ {
466
+ name: :refresh_templates,
467
+ params: {
468
+ desc: "Refreshes configured templates for an existing app without running post-creation hooks",
469
+ type: :boolean,
470
+ required: required
471
+ }
472
+ }
473
+ end
474
+
475
+ def self.preserve_existing_runtime_option(required: false)
476
+ {
477
+ name: :preserve_existing_runtime,
478
+ params: {
479
+ desc: "Preserves deployed app images and skips existing secret resources entirely while applying templates",
480
+ type: :boolean,
481
+ required: required
482
+ }
483
+ }
484
+ end
485
+
461
486
  def self.skip_pre_deletion_hook_option(required: false)
462
487
  {
463
488
  name: :skip_pre_deletion_hook,
@@ -527,10 +552,12 @@ module Command
527
552
  end
528
553
  end
529
554
 
530
- # NOTE: use simplified variant atm, as shelljoin do different escaping
531
- # TODO: most probably need better logic for escaping various quotes
532
555
  def args_join(args)
533
- args.join(" ")
556
+ # A single CLI argument is an intentional shell program (for example, an env assignment or pipeline).
557
+ # Multiple CLI arguments are argv elements and must be escaped before entering the remote shell script.
558
+ return args.first if args.size == 1
559
+
560
+ Shellwords.join(args)
534
561
  end
535
562
 
536
563
  def progress
@@ -583,6 +610,20 @@ module Command
583
610
  success
584
611
  end
585
612
 
613
+ def wait_for_workloads_ready(workloads, reverse: false)
614
+ workloads_to_wait_for = reverse ? workloads.reverse : workloads
615
+
616
+ workloads_to_wait_for.each do |workload|
617
+ if cp.workload_suspended?(workload)
618
+ progress.puts("Workload '#{workload}' is suspended. Skipping...")
619
+ else
620
+ step("Waiting for workload '#{workload}' to be ready", retry_on_failure: true) do
621
+ cp.workload_deployments_ready?(workload, location: config.location, expected_status: true)
622
+ end
623
+ end
624
+ end
625
+ end
626
+
586
627
  def cp
587
628
  @cp ||= Controlplane.new(config)
588
629
  end
@@ -604,9 +645,32 @@ module Command
604
645
  raise shared_secret_policy_missing_message(grant) if policy.nil?
605
646
 
606
647
  ensure_shared_secret_policy_targets_secret!(grant, policy)
648
+ warn_if_shared_secret_uses_generated_password_placeholder(grant)
607
649
  policy
608
650
  end
609
651
 
652
+ def warn_if_shared_secret_uses_generated_password_placeholder(grant)
653
+ secret_name = grant.fetch(:secret_name)
654
+ secret = cp.reveal_secret(secret_name)
655
+ return unless secret&.dig("data", "password") == GENERATED_POSTGRES_PASSWORD_PLACEHOLDER
656
+
657
+ Shell.warn(
658
+ "Shared secret grant '#{grant.fetch(:name)}' targets secret '#{secret_name}', whose password " \
659
+ "is still the generated placeholder. Review apps will fail authentication until it is replaced."
660
+ )
661
+ # This is a best-effort warning. API, transport, and response-shape failures
662
+ # must not turn an optional diagnostic into a deployment blocker.
663
+ rescue StandardError
664
+ debug_shared_secret_placeholder_check_failure(secret_name)
665
+ end
666
+
667
+ def debug_shared_secret_placeholder_check_failure(secret_name)
668
+ Shell.debug(
669
+ "WARN",
670
+ "Could not inspect shared secret '#{secret_name}'; continuing without the optional placeholder diagnostic."
671
+ )
672
+ end
673
+
610
674
  def bind_shared_secret_policy_grant(grant, policy)
611
675
  policy_name = grant.fetch(:policy_name)
612
676
  return if identity_bound_to_policy_with_reveal?(policy)
@@ -3,53 +3,129 @@
3
3
  require "resolv"
4
4
 
5
5
  module Command
6
- class DeployImage < Base
6
+ class DeployImage < Base # rubocop:disable Metrics/ClassLength
7
7
  NAME = "deploy-image"
8
8
  OPTIONS = [
9
9
  app_option(required: true),
10
+ workload_option(repeatable: true),
10
11
  run_release_phase_option,
11
12
  use_digest_image_ref_option
12
13
  ].freeze
13
14
  DESCRIPTION = "Deploys the latest image to app workloads, and runs a release script (optional)"
14
15
  LONG_DESCRIPTION = <<~DESC
15
16
  - Deploys the latest image to app workloads
17
+ - Use `--workload`/`-w` one or more times to deploy only selected app workloads
18
+ - If `deploy_order` is configured and no `--workload` is provided, deploys ordered workload groups one at a time and waits for each group to be ready before continuing
19
+ - Workloads listed in `app_workloads` but omitted from `deploy_order` deploy last as an implicit final group
16
20
  - Runs a release script before deploying if `release_script` is specified in the `.controlplane/controlplane.yml` file and `--run-release-phase` is provided
17
21
  - The release script is run in the context of `cpflow run` with the latest image
18
22
  - If the release script exits with a non-zero code, the command will stop executing and also exit with a non-zero code
19
23
  - If `use_digest_image_ref` is `true` in the `.controlplane/controlplane.yml` file or `--use-digest-image-ref` option is provided, deployed image's reference will include its digest
20
24
  - Repairs missing `shared_secret_grants` policy bindings before running a release phase or updating workloads
21
25
  DESC
26
+ EXAMPLES = <<~EX
27
+ ```sh
28
+ # Deploys the latest image to all app workloads.
29
+ cpflow deploy-image -a $APP_NAME
30
+
31
+ # Deploys only one app workload.
32
+ cpflow deploy-image -a $APP_NAME -w node-renderer
33
+
34
+ # Deploys only selected app workloads.
35
+ cpflow deploy-image -a $APP_NAME -w node-renderer -w sidekiq
36
+ ```
37
+ EX
22
38
 
23
39
  def call
24
40
  release_script = release_script_to_run
25
41
  image = resolve_image_to_deploy
26
42
  shared_secret_policy_grant_pairs = resolve_shared_secret_policy_grants
27
- workload_data_by_name = app_workload_data
43
+ workload_data_by_name = app_workload_data(workload_names_to_deploy)
28
44
 
29
45
  bind_shared_secret_policy_grants(shared_secret_policy_grant_pairs)
30
46
  run_release_script(release_script) if release_script
31
- deploy_image_to_workloads(image, workload_data_by_name)
47
+ deploy_image(image, workload_data_by_name)
32
48
  end
33
49
 
34
50
  private
35
51
 
36
- def app_workload_data
37
- config[:app_workloads].to_h do |workload|
52
+ def workload_names_to_deploy
53
+ app_workloads = config[:app_workloads]
54
+ requested_workloads = requested_workload_names
55
+ return app_workloads if requested_workloads.empty?
56
+
57
+ ensure_workloads_configured!(requested_workloads, app_workloads)
58
+ requested_workloads
59
+ end
60
+
61
+ def ensure_workloads_configured!(requested_workloads, app_workloads)
62
+ requested_workloads.each do |workload|
63
+ next if app_workloads.include?(workload)
64
+
65
+ raise "Workload '#{workload}' must be listed in app_workloads for app '#{config.app}'."
66
+ end
67
+ end
68
+
69
+ def app_workload_data(workloads)
70
+ workloads.to_h do |workload|
38
71
  [workload, cp.fetch_workload!(workload)]
39
72
  end
40
73
  end
41
74
 
75
+ def deploy_image(image, workload_data_by_name)
76
+ deployed_endpoints = {}
77
+
78
+ if deploy_in_order?
79
+ deploy_image_to_ordered_workloads(image, workload_data_by_name, deployed_endpoints)
80
+ else
81
+ deployed_endpoints.merge!(deploy_image_to_workloads(image, workload_data_by_name))
82
+ end
83
+ ensure
84
+ print_deployed_endpoints(deployed_endpoints)
85
+ end
86
+
87
+ def deploy_in_order?
88
+ requested_workload_names.empty? && config.deploy_order
89
+ end
90
+
91
+ def deployment_groups(workload_data_by_name)
92
+ ordered_groups = config.deploy_order
93
+ ordered_workloads = ordered_groups.flatten
94
+ unordered_workloads = workload_data_by_name.keys - ordered_workloads
95
+
96
+ [*ordered_groups, unordered_workloads].reject(&:empty?)
97
+ end
98
+
99
+ def deploy_image_to_ordered_workloads(image, workload_data_by_name, deployed_endpoints)
100
+ deployment_groups(workload_data_by_name).each do |group|
101
+ deployed_endpoints.merge!(deploy_image_to_workloads(image, workload_data_by_name.slice(*group)))
102
+ wait_for_workloads_ready(group)
103
+ end
104
+ end
105
+
106
+ def requested_workload_names
107
+ @requested_workload_names ||= Array(config.options[:workload]).map(&:to_s).uniq
108
+ end
109
+
110
+ def app_image?(image)
111
+ image.to_s.match?(
112
+ %r{\A(?:/org/#{Regexp.escape(config.org)}/image/)?#{Regexp.escape(config.app)}[:@]}
113
+ )
114
+ end
115
+
42
116
  def deploy_image_to_workloads(image, workload_data_by_name) # rubocop:disable Metrics/MethodLength
43
117
  deployed_endpoints = {}
44
118
 
45
119
  workload_data_by_name.each do |workload, workload_data|
46
120
  workload_data.dig("spec", "containers").each do |container|
47
- next unless container["image"].match?(%r{^/org/#{config.org}/image/#{config.app}[:@]})
121
+ next unless app_image?(container["image"])
48
122
 
49
123
  container_name = container["name"]
50
124
  step("Deploying image '#{image}' for workload '#{workload}'") do
51
125
  cp.workload_set_image_ref(workload, container: container_name, image: image)
52
126
  deployed_endpoints[workload] = endpoint_for_workload(workload_data)
127
+ # A missing public endpoint is valid; the image update still completed successfully.
128
+ true
53
129
  end
54
130
  # Deploy the first matching app-image container per workload; CPLN workloads
55
131
  # are expected to have a single container that runs the app image.
@@ -57,9 +133,13 @@ module Command
57
133
  end
58
134
  end
59
135
 
136
+ deployed_endpoints
137
+ end
138
+
139
+ def print_deployed_endpoints(deployed_endpoints)
60
140
  progress.puts("\nDeployed endpoints:")
61
141
  deployed_endpoints.each do |workload, endpoint|
62
- progress.puts(" - #{workload}: #{endpoint}")
142
+ progress.puts(" - #{workload}: #{endpoint || '(no public endpoint)'}")
63
143
  end
64
144
  end
65
145
 
@@ -100,9 +180,15 @@ module Command
100
180
 
101
181
  def endpoint_for_workload(workload_data)
102
182
  endpoint = workload_data.dig("status", "endpoint")
183
+ return fallback_endpoint_for_workload(workload_data) unless endpoint
184
+
103
185
  Resolv.getaddress(endpoint.split("/").last)
104
186
  endpoint
105
187
  rescue Resolv::ResolvError
188
+ fallback_endpoint_for_workload(workload_data)
189
+ end
190
+
191
+ def fallback_endpoint_for_workload(workload_data)
106
192
  deployments = cp.fetch_workload_deployments(workload_data["name"])
107
193
  deployments.dig("items", 0, "status", "endpoint")
108
194
  end
@@ -14,6 +14,7 @@ module Command
14
14
  - It performs the following steps:
15
15
  - Runs `cpflow copy-image-from-upstream` to copy the latest image from upstream
16
16
  - Runs `cpflow deploy-image` to deploy the image
17
+ - Honors `deploy_order` from `.controlplane/controlplane.yml` through `cpflow deploy-image`
17
18
  - If `.controlplane/controlplane.yml` includes the `release_script`, `cpflow deploy-image` will use the `--run-release-phase` option
18
19
  - If the release script exits with a non-zero code, the command will stop executing and also exit with a non-zero code
19
20
  - If `use_digest_image_ref` is `true` in the `.controlplane/controlplane.yml` file or `--use-digest-image-ref` option is provided, deployed image's reference will include its digest
@@ -26,19 +26,11 @@ module Command
26
26
  ```
27
27
  EX
28
28
 
29
- def call # rubocop:disable Metrics/MethodLength
29
+ def call
30
30
  @workloads = [config.options[:workload]] if config.options[:workload]
31
31
  @workloads ||= config[:app_workloads] + config[:additional_workloads]
32
32
 
33
- @workloads.reverse_each do |workload|
34
- if cp.workload_suspended?(workload)
35
- progress.puts("Workload '#{workload}' is suspended. Skipping...")
36
- else
37
- step("Waiting for workload '#{workload}' to be ready", retry_on_failure: true) do
38
- cp.workload_deployments_ready?(workload, location: config.location, expected_status: true)
39
- end
40
- end
41
- end
33
+ wait_for_workloads_ready(@workloads, reverse: true)
42
34
  end
43
35
  end
44
36
  end
data/lib/command/run.rb CHANGED
@@ -47,8 +47,21 @@ module Command
47
47
  and also overridden per job through `--cpu` and `--memory`)
48
48
  - By default, the job is stopped if it takes longer than 6 hours to finish
49
49
  (can be configured though `runner_job_timeout` in `controlplane.yml`)
50
+ - Waiting for a runner replica is limited to the smaller of `runner_job_timeout` and 1000 seconds.
51
+ A terminal cron status fails immediately, and reaching the observation deadline reports the last safe status
50
52
  - Non-interactive jobs return the Control Plane cron job status even when the job finishes before
51
53
  Control Plane exposes a runner replica to attach logs to
54
+ - Injects `CPFLOW_GVC_ID` and `CPFLOW_GVC_CREATED` into the job, exposing the app's immutable GVC
55
+ identity, so that a command such as a release script can tell which GVC incarnation it is running in.
56
+ These change when a GVC is deleted and recreated under the same name, and only then, unlike
57
+ `CPLN_GVC_ALIAS`, which is also embedded in mutable derived values such as the app domain and so
58
+ cannot be attributed to recreation alone
59
+ - `CPFLOW_GVC_CREATED` is the GVC's creation timestamp as returned by the Control Plane API and passed
60
+ through unmodified, currently an ISO 8601 UTC timestamp with millisecond precision and a `Z` suffix
61
+ (e.g. `2026-08-28T00:54:48.648Z`)
62
+ - Both variables are always set, and are empty when the GVC cannot be read, so that a consumer can
63
+ fail closed. They are never omitted, because the runner inherits the original workload's
64
+ environment and an omitted variable could otherwise expose a stale inherited value
52
65
  DESC
53
66
  EXAMPLES = <<~EX.freeze
54
67
  ```sh
@@ -69,7 +82,8 @@ module Command
69
82
  # - stop the job
70
83
  cpflow run -a $APP_NAME --detached -- rails db:migrate
71
84
 
72
- # The command needs to be quoted if setting an env variable or passing args.
85
+ # Quote the whole command to intentionally opt into shell syntax such as an env assignment.
86
+ # Separately supplied command arguments are passed literally.
73
87
  cpflow run -a $APP_NAME -- 'SOME_ENV_VAR=some_value rails db:migrate'
74
88
 
75
89
  # Uses a different image (which may not be promoted yet).
@@ -95,6 +109,9 @@ module Command
95
109
  DEFAULT_JOB_MEMORY = "2Gi"
96
110
  DEFAULT_JOB_TIMEOUT = 21_600 # 6 hours
97
111
  DEFAULT_JOB_HISTORY_LIMIT = 10
112
+ MAX_REPLICA_OBSERVATION_SECONDS = 1_000
113
+ REPLICA_OBSERVATION_POLL_INTERVAL_SECONDS = 1
114
+ NORMALIZED_JOB_STATUS_PATTERN = /\A[a-z][a-z0-9_-]{0,31}\z/
98
115
  MAGIC_END = "---cpflow run command finished---"
99
116
 
100
117
  attr_reader :interactive, :detached, :location, :original_workload, :runner_workload,
@@ -268,27 +285,80 @@ module Command
268
285
  end
269
286
 
270
287
  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) }
288
+ observation_limit = [job_timeout, MAX_REPLICA_OBSERVATION_SECONDS].min
289
+ observation_deadline = monotonic_time + observation_limit
274
290
 
275
- replica || completed_job_before_replica? || false
291
+ step("Waiting for runner replica to start") do
292
+ observe_replica_until(observation_deadline, observation_limit)
276
293
  end
277
294
  end
278
295
 
279
- def completed_job_before_replica?
280
- case current_job_status
296
+ def observe_replica_until(observation_deadline, observation_limit)
297
+ last_status = nil
298
+
299
+ loop do
300
+ ensure_before_replica_observation_deadline!(observation_deadline, observation_limit, last_status)
301
+
302
+ @replica = replica_for_job
303
+ return replica if replica
304
+
305
+ ensure_before_replica_observation_deadline!(observation_deadline, observation_limit, last_status)
306
+
307
+ last_status = current_job_status
308
+ return true if completed_job_before_replica?(last_status)
309
+
310
+ sleep_before_replica_observation_retry(observation_deadline, observation_limit, last_status)
311
+ end
312
+ end
313
+
314
+ def ensure_before_replica_observation_deadline!(observation_deadline, observation_limit, status)
315
+ return if monotonic_time < observation_deadline
316
+
317
+ raise replica_observation_timeout_message(observation_limit, status)
318
+ end
319
+
320
+ def replica_for_job
321
+ result = cp.fetch_workload_replicas(runner_workload, location: location)
322
+ result&.dig("items")&.find { |item| item.include?(job) }
323
+ end
324
+
325
+ def sleep_before_replica_observation_retry(observation_deadline, observation_limit, status)
326
+ progress.print(".")
327
+ remaining = observation_deadline - monotonic_time
328
+ raise replica_observation_timeout_message(observation_limit, status) unless remaining.positive?
329
+
330
+ Kernel.sleep([REPLICA_OBSERVATION_POLL_INTERVAL_SECONDS, remaining].min)
331
+ end
332
+
333
+ # Returns true for success, false while pending, and raises for a terminal non-success status.
334
+ def completed_job_before_replica?(status)
335
+ case status
281
336
  when "successful"
282
337
  @job_completed_before_replica_exit_status = ExitCode::SUCCESS
283
338
  true
284
339
  when nil, "active", "pending"
285
340
  false
286
341
  else
287
- @job_completed_before_replica_exit_status = ExitCode::ERROR_DEFAULT
288
- true
342
+ raise "Runner job ended before a replica was observed (status: #{normalized_job_status(status)})."
289
343
  end
290
344
  end
291
345
 
346
+ def normalized_job_status(status)
347
+ return "unavailable" if status.nil?
348
+
349
+ token = status.to_s.downcase
350
+ token.match?(NORMALIZED_JOB_STATUS_PATTERN) ? token : "unknown"
351
+ end
352
+
353
+ def replica_observation_timeout_message(observation_limit, status)
354
+ "Runner replica was not observed before the observation limit: #{format('%g', observation_limit)} seconds " \
355
+ "(status: #{normalized_job_status(status)})."
356
+ end
357
+
358
+ def monotonic_time
359
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
360
+ end
361
+
292
362
  def run_interactive
293
363
  progress.puts("Connecting to replica '#{replica}'...\n\n")
294
364
  # workload_exec returns false on non-zero exit, nil when signal-killed (e.g. Ctrl-C).
@@ -389,11 +459,12 @@ module Command
389
459
  job_start_hash["args"].push("-c")
390
460
  job_start_hash["env"] ||= []
391
461
  job_start_hash["env"].push({ "name" => "CPFLOW_RUNNER_SCRIPT", "value" => runner_script })
462
+ job_start_hash["env"].concat(gvc_identity_env_vars)
392
463
  if interactive
393
464
  job_start_hash["env"].push({ "name" => "CPFLOW_MONITORING_SCRIPT", "value" => interactive_monitoring_script })
394
465
 
395
466
  job_start_hash["args"].push('eval "$CPFLOW_MONITORING_SCRIPT"')
396
- @command = %(bash -c 'eval "$CPFLOW_RUNNER_SCRIPT"')
467
+ @command = ["bash", "-c", 'eval "$CPFLOW_RUNNER_SCRIPT"']
397
468
  else
398
469
  job_start_hash["args"].push('eval "$CPFLOW_RUNNER_SCRIPT"')
399
470
  end
@@ -413,6 +484,58 @@ module Command
413
484
  job_start_hash.to_yaml
414
485
  end
415
486
 
487
+ # Exposes the GVC's immutable identity to the job. cpflow authenticates client-side with the
488
+ # operator/CI credentials, so reading the GVC here adds no GVC-view binding to the app's workload
489
+ # identity and leaves nothing behind for the delete lifecycle to clean up.
490
+ #
491
+ # Both variables are always emitted, empty when unknown, rather than omitted. `update_runner_workload`
492
+ # copies the original workload's env wholesale into the runner, so omitting them would let a value
493
+ # inherited from the workload survive a failed read and be mistaken for a live identity. Emitting an
494
+ # explicit empty value is the only way a consumer can actually fail closed.
495
+ def gvc_identity_env_vars
496
+ gvc_data = fetch_gvc_for_identity
497
+ gvc_id = (gvc_data && gvc_data["id"]).to_s
498
+ # Gated on the id so a fresh id can never be paired with an inherited timestamp.
499
+ gvc_created = gvc_id.empty? ? "" : gvc_data["created"].to_s
500
+
501
+ [
502
+ { "name" => "CPFLOW_GVC_ID", "value" => gvc_id },
503
+ { "name" => "CPFLOW_GVC_CREATED", "value" => gvc_created }
504
+ ]
505
+ end
506
+
507
+ # The identity is an optional enrichment, so failing to read it must never stop the job, and the
508
+ # variables are still emitted (empty) rather than dropped.
509
+ # Two reasons the rescue is deliberately broad rather than a narrow class list:
510
+ #
511
+ # 1. Availability. `cpflow run` is also the release-phase mechanism for `deploy-image`, `setup-app`,
512
+ # and `delete`, so a transient error on the GVC endpoint would otherwise fail a deploy over a
513
+ # variable the command does not need.
514
+ # 2. Taxonomy. `handle_response` raises a bare `RuntimeError` for 401 and 5xx responses, so any
515
+ # "narrow" list that actually covered the real failures would have to include `RuntimeError`.
516
+ #
517
+ # `MaintenanceMode#domain_workload_update_confirmed?` rescues `StandardError` against this same API
518
+ # client for the same reason. The rescued body is a single external call, so a bug in this file's own
519
+ # logic still raises.
520
+ def fetch_gvc_for_identity
521
+ cp.fetch_gvc
522
+ rescue StandardError => e
523
+ # Deliberately `Shell.warn` rather than a `step`. `step_finish` prints a red "failed!" banner
524
+ # whenever its block is falsy, regardless of `abort_on_error`, so routing an optional lookup
525
+ # through it would show a failure on every deploy for anyone whose token omits `gvc` view -- for
526
+ # a read that stops nothing and leaves the exit code at 0.
527
+ Shell.warn(gvc_identity_error_message(e))
528
+ nil
529
+ end
530
+
531
+ # A 403 here is normally a missing `view` grant on kind `gvc`, but `ForbiddenError` renders any
532
+ # `/org/...` URL as "Double check your org", which sends the operator after the wrong thing.
533
+ def gvc_identity_error_message(error)
534
+ "Continuing without the GVC identity, so CPFLOW_GVC_ID and CPFLOW_GVC_CREATED are set to empty " \
535
+ "strings. A permission failure here usually means the token lacks `view` on kind `gvc` for this " \
536
+ "app, even when the message below mentions the org. #{error.message}"
537
+ end
538
+
416
539
  def interactive_monitoring_script
417
540
  <<~SCRIPT
418
541
  primary_pid=""