cpflow 5.1.0 → 5.2.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/agent-workflow.yml +15 -0
  3. data/.agents/bin/README.md +19 -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-wait-for-health/action.yml +11 -4
  12. data/.github/workflows/claude-code-review.yml +2 -0
  13. data/.github/workflows/claude.yml +2 -0
  14. data/.github/workflows/cpflow-deploy-review-app.yml +16 -1
  15. data/.github/workflows/cpflow-promote-staging-to-production.yml +224 -37
  16. data/.github/workflows/rspec-shared.yml +15 -1
  17. data/.github/workflows/rspec-specific.yml +1 -0
  18. data/.github/workflows/rspec.yml +58 -1
  19. data/AGENTS.md +57 -0
  20. data/CHANGELOG.md +36 -1
  21. data/CLAUDE.md +3 -0
  22. data/CONTRIBUTING.md +6 -2
  23. data/Gemfile.lock +1 -1
  24. data/README.md +25 -7
  25. data/docs/ai-github-flow-prompt.md +18 -16
  26. data/docs/assets/logo/favicon.ico +0 -0
  27. data/docs/assets/logo/icon-1024.png +0 -0
  28. data/docs/assets/logo/icon-128.png +0 -0
  29. data/docs/assets/logo/icon-16.png +0 -0
  30. data/docs/assets/logo/icon-192.png +0 -0
  31. data/docs/assets/logo/icon-24.png +0 -0
  32. data/docs/assets/logo/icon-32.png +0 -0
  33. data/docs/assets/logo/icon-48.png +0 -0
  34. data/docs/assets/logo/icon-512.png +0 -0
  35. data/docs/assets/logo/icon-64.png +0 -0
  36. data/docs/assets/logo/icon-tile.svg +17 -0
  37. data/docs/assets/logo/mark-transparent.svg +16 -0
  38. data/docs/ci-automation.md +203 -15
  39. data/docs/commands.md +16 -1
  40. data/docs/grafana-opentelemetry.md +699 -0
  41. data/docs/secrets-and-env-values.md +29 -2
  42. data/docs/sidebars.ts +70 -0
  43. data/docs/telemetry/application-instrumentation.md +161 -0
  44. data/docs/telemetry/collector.md +297 -0
  45. data/docs/telemetry/index.md +152 -0
  46. data/docs/telemetry/pipelines.md +98 -0
  47. data/docs/telemetry/review-apps.md +55 -0
  48. data/docs/telemetry/troubleshooting.md +92 -0
  49. data/docs/terraform/example/.controlplane/controlplane.yml +0 -1
  50. data/docs/terraform/overview.md +11 -0
  51. data/docs/tips.md +458 -29
  52. data/examples/controlplane.yml +2 -0
  53. data/lib/command/ai_github_flow_prompt.rb +2 -2
  54. data/lib/command/base.rb +17 -2
  55. data/lib/command/deploy_image.rb +77 -5
  56. data/lib/command/maintenance_off.rb +1 -0
  57. data/lib/command/maintenance_on.rb +1 -0
  58. data/lib/command/promote_app_from_upstream.rb +1 -0
  59. data/lib/command/ps_wait.rb +2 -10
  60. data/lib/command/run.rb +25 -5
  61. data/lib/core/config.rb +94 -0
  62. data/lib/core/doctor_service.rb +44 -3
  63. data/lib/core/maintenance_mode.rb +93 -6
  64. data/lib/core/template_parser.rb +43 -9
  65. data/lib/cpflow/version.rb +1 -1
  66. data/lib/generator_templates/controlplane.yml +1 -2
  67. data/lib/github_flow_templates/.github/cpflow-help.md +23 -1
  68. data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +224 -39
  69. metadata +33 -2
@@ -3,42 +3,110 @@
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
+
42
110
  def deploy_image_to_workloads(image, workload_data_by_name) # rubocop:disable Metrics/MethodLength
43
111
  deployed_endpoints = {}
44
112
 
@@ -57,6 +125,10 @@ module Command
57
125
  end
58
126
  end
59
127
 
128
+ deployed_endpoints
129
+ end
130
+
131
+ def print_deployed_endpoints(deployed_endpoints)
60
132
  progress.puts("\nDeployed endpoints:")
61
133
  deployed_endpoints.each do |workload, endpoint|
62
134
  progress.puts(" - #{workload}: #{endpoint}")
@@ -10,6 +10,7 @@ module Command
10
10
  DESCRIPTION = "Disables maintenance mode for an app"
11
11
  LONG_DESCRIPTION = <<~DESC
12
12
  - Disables maintenance mode for an app
13
+ - Safe to re-run: if a previous run timed out after switching the domain but before stopping the maintenance workload, re-running while maintenance mode is already disabled stops the maintenance workload to finish it (so it is not a pure no-op)
13
14
  - Specify the one-off workload through `one_off_workload` in the `.controlplane/controlplane.yml` file
14
15
  - Optionally specify the maintenance workload through `maintenance_workload` in the `.controlplane/controlplane.yml` file (defaults to 'maintenance')
15
16
  - Maintenance mode is only supported for domains that use path based routing mode and have a route configured for the prefix '/' on either port 80 or 443
@@ -10,6 +10,7 @@ module Command
10
10
  DESCRIPTION = "Enables maintenance mode for an app"
11
11
  LONG_DESCRIPTION = <<~DESC
12
12
  - Enables maintenance mode for an app
13
+ - Safe to re-run: if a previous run timed out after switching the domain but before stopping the app workloads, re-running while maintenance mode is already enabled stops the app workloads to finish it (so it is not a pure no-op)
13
14
  - Specify the one-off workload through `one_off_workload` in the `.controlplane/controlplane.yml` file
14
15
  - Optionally specify the maintenance workload through `maintenance_workload` in the `.controlplane/controlplane.yml` file (defaults to 'maintenance')
15
16
  - Maintenance mode is only supported for domains that use path based routing mode and have a route configured for the prefix '/' on either port 80 or 443
@@ -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,6 +47,8 @@ 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
+ - Non-interactive jobs return the Control Plane cron job status even when the job finishes before
51
+ Control Plane exposes a runner replica to attach logs to
50
52
  DESC
51
53
  EXAMPLES = <<~EX.freeze
52
54
  ```sh
@@ -97,7 +99,7 @@ module Command
97
99
 
98
100
  attr_reader :interactive, :detached, :location, :original_workload, :runner_workload,
99
101
  :default_image, :default_cpu, :default_memory, :job_timeout, :job_history_limit,
100
- :container, :job, :replica, :command
102
+ :container, :job, :replica, :command, :job_completed_before_replica_exit_status
101
103
 
102
104
  def call # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
103
105
  @interactive = config.options[:interactive] || interactive_command?
@@ -129,6 +131,7 @@ module Command
129
131
  update_runner_workload
130
132
  start_job
131
133
  wait_for_replica_for_job
134
+ exit(job_completed_before_replica_exit_status) if job_completed_before_replica_exit_status
132
135
 
133
136
  progress.puts
134
137
  if interactive
@@ -269,7 +272,20 @@ module Command
269
272
  result = cp.fetch_workload_replicas(runner_workload, location: location)
270
273
  @replica = result&.dig("items")&.find { |item| item.include?(job) }
271
274
 
272
- replica || false
275
+ replica || completed_job_before_replica? || false
276
+ end
277
+ end
278
+
279
+ def completed_job_before_replica?
280
+ case current_job_status
281
+ when "successful"
282
+ @job_completed_before_replica_exit_status = ExitCode::SUCCESS
283
+ true
284
+ when nil, "active", "pending"
285
+ false
286
+ else
287
+ @job_completed_before_replica_exit_status = ExitCode::ERROR_DEFAULT
288
+ true
273
289
  end
274
290
  end
275
291
 
@@ -505,9 +521,7 @@ module Command
505
521
 
506
522
  def resolve_job_status # rubocop:disable Metrics/MethodLength
507
523
  loop do
508
- result = cp.fetch_cron_workload(runner_workload, location: location)
509
- job_details = result&.dig("items")&.find { |item| item["id"] == job }
510
- status = job_details&.dig("status")
524
+ status = current_job_status
511
525
 
512
526
  Shell.debug("JOB STATUS", status)
513
527
 
@@ -522,6 +536,12 @@ module Command
522
536
  end
523
537
  end
524
538
 
539
+ def current_job_status
540
+ result = cp.fetch_cron_workload(runner_workload, location: location)
541
+ job_details = result&.dig("items")&.find { |item| item["id"] == job }
542
+ job_details&.dig("status")
543
+ end
544
+
525
545
  ###########################################
526
546
  ### temporary extaction from run:detached
527
547
  ###########################################
data/lib/core/config.rb CHANGED
@@ -166,6 +166,28 @@ class Config # rubocop:disable Metrics/ClassLength
166
166
  current&.dig(:use_digest_image_ref) == true
167
167
  end
168
168
 
169
+ def deploy_order
170
+ return nil unless current&.key?(:deploy_order)
171
+
172
+ @deploy_order ||= normalize_deploy_order(
173
+ current.fetch(:deploy_order),
174
+ app_workloads_for_deploy_order!(current, app),
175
+ app
176
+ )
177
+ end
178
+
179
+ def validate_deploy_orders!
180
+ apps.each do |app_name, app_options|
181
+ next unless app_options.key?(:deploy_order)
182
+
183
+ normalize_deploy_order(
184
+ app_options.fetch(:deploy_order),
185
+ app_workloads_for_deploy_order!(app_options, app_name),
186
+ app_name
187
+ )
188
+ end
189
+ end
190
+
169
191
  private
170
192
 
171
193
  def ensure_current_config!
@@ -252,6 +274,78 @@ class Config # rubocop:disable Metrics/ClassLength
252
274
  end
253
275
  end
254
276
 
277
+ def app_workloads_for_deploy_order!(app_options, app_name)
278
+ app_workloads = app_options[:app_workloads]
279
+ return app_workloads if app_workloads.is_a?(Array)
280
+
281
+ raise "deploy_order for app '#{app_name}' requires app_workloads to be an array."
282
+ end
283
+
284
+ def normalize_deploy_order(raw_deploy_order, app_workloads, app_name)
285
+ ensure_deploy_order_array!(raw_deploy_order, app_name)
286
+ context = {
287
+ app_workload_names: app_workloads.map(&:to_s),
288
+ seen_workloads: {},
289
+ app_name: app_name
290
+ }
291
+
292
+ raw_deploy_order.map.with_index do |group, index|
293
+ normalize_deploy_order_group(group, index, context)
294
+ end
295
+ end
296
+
297
+ def ensure_deploy_order_array!(raw_deploy_order, app_name)
298
+ raise "deploy_order for app '#{app_name}' must be an array of workload groups." unless raw_deploy_order.is_a?(Array)
299
+ return unless raw_deploy_order.empty?
300
+
301
+ raise "deploy_order for app '#{app_name}' must include at least one workload group."
302
+ end
303
+
304
+ def normalize_deploy_order_group(group, index, context)
305
+ group_number = index + 1
306
+ ensure_deploy_order_group_array!(group, group_number, context.fetch(:app_name))
307
+
308
+ group.map.with_index do |workload, workload_index|
309
+ normalize_deploy_order_workload(workload, group_number, workload_index, context)
310
+ end
311
+ end
312
+
313
+ def ensure_deploy_order_group_array!(group, group_number, app_name)
314
+ unless group.is_a?(Array)
315
+ raise "deploy_order group ##{group_number} for app '#{app_name}' must be an array of workload names."
316
+ end
317
+ return unless group.empty?
318
+
319
+ raise "deploy_order group ##{group_number} for app '#{app_name}' must include at least one workload."
320
+ end
321
+
322
+ def normalize_deploy_order_workload(workload, group_number, workload_index, context)
323
+ app_name = context.fetch(:app_name)
324
+ unless workload.is_a?(String) && !workload.strip.empty?
325
+ raise "deploy_order group ##{group_number} entry ##{workload_index + 1} " \
326
+ "for app '#{app_name}' must be a workload name."
327
+ end
328
+
329
+ workload_name = workload.strip
330
+ ensure_deploy_order_workload_configured!(workload_name, context.fetch(:app_workload_names), app_name)
331
+ ensure_deploy_order_workload_unique!(workload_name, context.fetch(:seen_workloads), app_name)
332
+ workload_name
333
+ end
334
+
335
+ def ensure_deploy_order_workload_configured!(workload_name, app_workload_names, app_name)
336
+ return if app_workload_names.include?(workload_name)
337
+
338
+ raise "deploy_order workload '#{workload_name}' must be listed in app_workloads for app '#{app_name}'."
339
+ end
340
+
341
+ def ensure_deploy_order_workload_unique!(workload_name, seen_workloads, app_name)
342
+ if seen_workloads[workload_name]
343
+ raise "deploy_order workload '#{workload_name}' must appear only once for app '#{app_name}'."
344
+ end
345
+
346
+ seen_workloads[workload_name] = true
347
+ end
348
+
255
349
  def ensure_app!
256
350
  return if app
257
351
 
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class DoctorService
3
+ class DoctorService # rubocop:disable Metrics/ClassLength
4
4
  class ValidationError < StandardError; end
5
5
 
6
6
  extend Forwardable
@@ -36,12 +36,12 @@ class DoctorService
36
36
 
37
37
  def validate_config
38
38
  check_for_app_names_contained_in_others
39
+ check_deploy_orders
39
40
  end
40
41
 
41
42
  def validate_templates
42
43
  @template_parser = TemplateParser.new(@command)
43
- filenames = Dir.glob("#{@template_parser.template_dir}/*.yml")
44
- templates = @template_parser.parse(filenames)
44
+ templates = @template_parser.parse(template_filenames)
45
45
 
46
46
  check_for_duplicate_templates(templates)
47
47
  warn_deprecated_template_variables
@@ -60,6 +60,12 @@ class DoctorService
60
60
  raise ValidationError, "#{Shell.color("ERROR: #{message}", :red)}\n#{list}"
61
61
  end
62
62
 
63
+ def check_deploy_orders
64
+ config.validate_deploy_orders!
65
+ rescue RuntimeError => e
66
+ raise ValidationError, Shell.color("ERROR: #{e.message}", :red)
67
+ end
68
+
63
69
  def find_app_names_contained_in_others # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
64
70
  app_names = config.apps.keys.map(&:to_s).sort
65
71
  app_prefixes = config.apps
@@ -88,6 +94,41 @@ class DoctorService
88
94
  raise ValidationError, "#{Shell.color("ERROR: #{message}", :red)}\n#{list}"
89
95
  end
90
96
 
97
+ def template_filenames
98
+ return existing_arg_template_filenames if config.args.any?
99
+
100
+ message = "ERROR: Can't find current config, please specify an app."
101
+ raise ValidationError, Shell.color(message, :red) if config.current.nil?
102
+
103
+ template_names = config.current[:setup_app_templates]
104
+ # Fall back to every template in the directory when setup_app_templates is unconfigured.
105
+ return Dir.glob("#{@template_parser.template_dir}/*.yml") if template_names.nil? || template_names.empty?
106
+
107
+ # When setup_app_templates is configured, validate only that selected subset,
108
+ # including the deprecation scan.
109
+ resolve_template_filenames(template_names)
110
+ end
111
+
112
+ def existing_arg_template_filenames = resolve_template_filenames(config.args)
113
+
114
+ def resolve_template_filenames(template_names)
115
+ unique_template_names = template_names.uniq
116
+ filenames = unique_template_names.map { |name| @template_parser.template_filename(name) }
117
+ ensure_templates_exist!(unique_template_names, filenames)
118
+ filenames
119
+ end
120
+
121
+ def ensure_templates_exist!(template_names, filenames)
122
+ missing_templates = template_names.zip(filenames).reject { |_, filename| File.exist?(filename) }
123
+ return if missing_templates.empty?
124
+
125
+ missing_templates_str = missing_templates.map do |name, filename|
126
+ " - #{name} (#{filename})"
127
+ end.join("\n")
128
+ message = "#{Shell.color('Missing templates:', :red)}\n#{missing_templates_str}"
129
+ raise ValidationError, message
130
+ end
131
+
91
132
  def warn_deprecated_template_variables
92
133
  deprecated_variables = @template_parser.deprecated_variables
93
134
  return if deprecated_variables.empty?
@@ -1,8 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class MaintenanceMode
3
+ class MaintenanceMode # rubocop:disable Metrics/ClassLength
4
4
  extend Forwardable
5
5
 
6
+ DOMAIN_WORKLOAD_UPDATE_MAX_POLL_ATTEMPTS = 30
7
+ DOMAIN_WORKLOAD_UPDATE_RETRY_WAIT_SECONDS = 1
8
+ DOMAIN_WORKLOAD_UPDATE_STEP_OPTIONS = {
9
+ retry_on_failure: true,
10
+ # `with_retry` loops while `retry_count <= max_retry_count` starting from 0, so
11
+ # total attempts == max_retry_count + 1. Subtract 1 so the bounded poll runs
12
+ # exactly DOMAIN_WORKLOAD_UPDATE_MAX_POLL_ATTEMPTS times.
13
+ max_retry_count: DOMAIN_WORKLOAD_UPDATE_MAX_POLL_ATTEMPTS - 1,
14
+ wait: DOMAIN_WORKLOAD_UPDATE_RETRY_WAIT_SECONDS
15
+ }.freeze
16
+
6
17
  def_delegators :@command, :config, :progress, :cp, :step, :run_cpflow_command
7
18
 
8
19
  def initialize(command)
@@ -22,6 +33,7 @@ class MaintenanceMode
22
33
  def enable!
23
34
  if enabled?
24
35
  progress.puts("Maintenance mode is already enabled for app '#{config.app}'.")
36
+ ensure_app_workloads_stopped
25
37
  else
26
38
  enable_maintenance_mode
27
39
  end
@@ -30,6 +42,7 @@ class MaintenanceMode
30
42
  def disable!
31
43
  if disabled?
32
44
  progress.puts("Maintenance mode is already disabled for app '#{config.app}'.")
45
+ ensure_maintenance_workload_stopped
33
46
  else
34
47
  disable_maintenance_mode
35
48
  end
@@ -69,6 +82,28 @@ class MaintenanceMode
69
82
  cp.fetch_workload!(maintenance_workload)
70
83
  end
71
84
 
85
+ # A run that already switched the route but hit the poll timeout aborts before
86
+ # its final workload-stop step runs. The next `enable!`/`disable!` short-circuits
87
+ # on the route check, so do the matching stop here — once the route is on the
88
+ # target, this brings the workloads into the state that route implies. `ps:stop`
89
+ # is idempotent, so each is a no-op once the target workload is already stopped.
90
+ #
91
+ # The stop target differs by direction. `ps:stop -a` covers only
92
+ # `app_workloads` + `additional_workloads`, never the maintenance workload:
93
+ # - enable!: the route now points at the maintenance workload, so the *app*
94
+ # workloads are the ones left running and `ps:stop -a` is correct.
95
+ # - disable!: the route now points at the app workloads (and a short-circuit
96
+ # `disable!` can run on an app whose app workloads are serving live traffic),
97
+ # so stopping all workloads would cause an outage. The workload a timed-out
98
+ # `disable!` leaves running is the maintenance workload, so stop only that.
99
+ def ensure_app_workloads_stopped
100
+ start_or_stop_all_workloads(:stop)
101
+ end
102
+
103
+ def ensure_maintenance_workload_stopped
104
+ start_or_stop_maintenance_workload(:stop)
105
+ end
106
+
72
107
  def start_or_stop_all_workloads(action)
73
108
  run_cpflow_command("ps:#{action}", "-a", config.app, "--wait")
74
109
 
@@ -82,16 +117,68 @@ class MaintenanceMode
82
117
  end
83
118
 
84
119
  def switch_domain_workload(to:)
85
- step("Switching workload for domain '#{domain_data['name']}' to '#{to}'") do
86
- cp.set_domain_workload(domain_data, to)
87
-
88
- # Give it a bit of time for the domain to update
89
- Kernel.sleep(30)
120
+ domain_name = domain_data["name"]
121
+
122
+ # Unlike the polling step below, the switch request is intentionally not
123
+ # retried: if it fails, nothing has changed yet, so aborting and letting the
124
+ # user re-run is the safe outcome. (Retrying would not help here anyway —
125
+ # `with_retry` retries on a falsy return, and `set_domain_workload` raises
126
+ # rather than returning false.)
127
+ step("Requesting workload switch for domain '#{domain_name}' to '#{to}'") do
128
+ # `set_domain_workload` mutates the route in place, so send a deep copy
129
+ # (round-tripped through JSON, since the domain is plain parsed-API data
130
+ # with string keys and JSON-native values) to keep the cached
131
+ # `@domain_data` reflecting the real server route. The poll re-fetches and
132
+ # matches on that fresh data, but if every poll times out without a routable
133
+ # fetch, `@domain_data` is what a re-run's `enabled?`/`disabled?` check reads
134
+ # — mutating it here would make that check report the requested route, not
135
+ # the actual one.
136
+ domain_data_for_update = JSON.parse(JSON.generate(domain_data))
137
+ cp.set_domain_workload(domain_data_for_update, to)
90
138
  end
91
139
 
140
+ wait_for_domain_workload_switch(domain_name, to)
141
+
92
142
  progress.puts
93
143
  end
94
144
 
145
+ # If the route never switches within the bounded poll window, this step aborts
146
+ # (abort_on_error) before any workloads are stopped, so traffic stays on the
147
+ # current workload. The label tells the user how to recover, since an exhausted
148
+ # poll has no error message of its own to print.
149
+ def wait_for_domain_workload_switch(domain_name, to)
150
+ @last_poll_error = nil # reset the poll-error dedup state for this poll window
151
+ step("Waiting for domain '#{domain_name}' workload to switch to '#{to}' " \
152
+ "(re-run this command if it times out)", **DOMAIN_WORKLOAD_UPDATE_STEP_OPTIONS) do
153
+ domain_workload_update_confirmed?(domain_name, to)
154
+ end
155
+ end
156
+
157
+ # Refetches the domain, refreshes the cached `@domain_data` when the fetch
158
+ # returns a routable domain, and reports whether the route now points at
159
+ # `workload`. Any error — a 5xx mid-propagation, a transient 403
160
+ # (`ForbiddenError < StandardError`, not a `RuntimeError`), or a network blip —
161
+ # is treated as "not switched yet" so the poll keeps retrying. The broad rescue
162
+ # logs the error to the step's stderr, so a latent bug (e.g. `NoMethodError`)
163
+ # surfaces in the "failed!" output on timeout instead of being swallowed.
164
+ def domain_workload_update_confirmed?(domain_name, workload)
165
+ refreshed_domain_data = cp.fetch_domain(domain_name)
166
+ @domain_data = refreshed_domain_data if refreshed_domain_data
167
+ refreshed_domain_data && cp.domain_workload_matches?(refreshed_domain_data, workload)
168
+ rescue StandardError => e
169
+ # A persistent failure (bad domain name, network outage, a latent bug) repeats
170
+ # the same error on every poll attempt, so only log when the message changes —
171
+ # otherwise the timeout output would carry up to MAX_POLL_ATTEMPTS identical
172
+ # lines. Guard on `tmp_stderr` so this stays safe if ever called outside a
173
+ # `step` block, where no tmp stderr is set up.
174
+ message = "#{e.class}: #{e.message} (#{e.backtrace&.first})\n"
175
+ if message != @last_poll_error && Shell.tmp_stderr
176
+ Shell.write_to_tmp_stderr(message)
177
+ @last_poll_error = message
178
+ end
179
+ false
180
+ end
181
+
95
182
  def domain_data
96
183
  @domain_data ||=
97
184
  if config.domain
@@ -37,38 +37,72 @@ class TemplateParser
37
37
  private
38
38
 
39
39
  def replace_variables(yaml_file) # rubocop:disable Metrics/MethodLength
40
+ original_yaml_file = yaml_file
41
+ yaml_file = replace_legacy_variables(yaml_file)
42
+
40
43
  yaml_file = yaml_file
41
44
  .gsub("{{APP_ORG}}", config.org)
42
45
  .gsub("{{APP_NAME}}", config.app)
43
46
  .gsub("{{APP_LOCATION}}", config.location)
44
47
  .gsub("{{APP_LOCATION_LINK}}", config.location_link)
45
- .gsub("{{APP_IMAGE}}", cp.latest_image)
46
- .gsub("{{APP_IMAGE_LINK}}", config.image_link(cp.latest_image))
47
48
  .gsub("{{APP_IDENTITY}}", config.identity)
48
49
  .gsub("{{APP_IDENTITY_LINK}}", config.identity_link)
49
50
  .gsub("{{APP_SECRETS}}", config.secrets)
50
51
  .gsub("{{APP_SECRETS_POLICY}}", config.secrets_policy)
52
+ yaml_file = replace_image_variables(yaml_file)
51
53
 
52
54
  config.shared_secret_placeholders.each do |placeholder, secret_name|
53
55
  yaml_file = yaml_file.gsub(placeholder, secret_name)
54
56
  end
55
57
 
56
- find_deprecated_variables(yaml_file)
58
+ find_deprecated_variables(original_yaml_file)
59
+ yaml_file
60
+ end
61
+
62
+ def replace_image_variables(yaml_file)
63
+ has_image = yaml_file.include?("{{APP_IMAGE}}")
64
+ has_image_link = yaml_file.include?("{{APP_IMAGE_LINK}}")
65
+ return yaml_file unless has_image || has_image_link
57
66
 
58
- # Kept for backwards compatibility
67
+ yaml_file = yaml_file.gsub("{{APP_IMAGE}}", latest_image) if has_image
68
+ yaml_file = yaml_file.gsub("{{APP_IMAGE_LINK}}", config.image_link(latest_image)) if has_image_link
59
69
  yaml_file
60
- .gsub("APP_ORG", config.org)
61
- .gsub("APP_GVC", config.app)
62
- .gsub("APP_LOCATION", config.location)
63
- .gsub("APP_IMAGE", cp.latest_image)
70
+ end
71
+
72
+ # Kept for backwards compatibility.
73
+ def replace_legacy_variables(yaml_file)
74
+ yaml_file
75
+ .gsub(deprecated_variable_pattern("APP_ORG"), config.org)
76
+ .gsub(deprecated_variable_pattern("APP_GVC"), config.app)
77
+ .gsub(deprecated_variable_pattern("APP_LOCATION"), config.location)
78
+ .then { |updated_yaml| replace_legacy_image_variable(updated_yaml) }
79
+ end
80
+
81
+ def replace_legacy_image_variable(yaml_file)
82
+ return yaml_file unless deprecated_variable_used?(yaml_file, "APP_IMAGE")
83
+
84
+ yaml_file.gsub(deprecated_variable_pattern("APP_IMAGE"), latest_image)
85
+ end
86
+
87
+ def latest_image
88
+ # Share one image value across modern and legacy image replacements in this parser instance.
89
+ @latest_image ||= cp.latest_image
64
90
  end
65
91
 
66
92
  def find_deprecated_variables(yaml_file)
67
93
  new_variables.each do |old_key, new_key|
68
- @deprecated_variables[old_key] = new_key if yaml_file.include?(old_key)
94
+ @deprecated_variables[old_key] = new_key if deprecated_variable_used?(yaml_file, old_key)
69
95
  end
70
96
  end
71
97
 
98
+ def deprecated_variable_used?(yaml_file, old_key)
99
+ yaml_file.match?(deprecated_variable_pattern(old_key))
100
+ end
101
+
102
+ def deprecated_variable_pattern(old_key)
103
+ /(?<!\{\{)\b#{Regexp.escape(old_key)}\b(?!\}\})/
104
+ end
105
+
72
106
  def new_variables
73
107
  {
74
108
  "APP_ORG" => "{{APP_ORG}}",
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cpflow
4
- VERSION = "5.1.0"
4
+ VERSION = "5.2.0"
5
5
  MIN_CPLN_VERSION = "3.1.0"
6
6
  end
@@ -48,10 +48,9 @@ apps:
48
48
  # - name: database
49
49
  # secret_name: __APP_PREFIX__-review-database-secrets
50
50
  # policy_name: __APP_PREFIX__-review-database-secrets-policy
51
- # Uncomment to automatically initialize and tear down review-app databases:
51
+ # Uncomment to automatically initialize review-app databases:
52
52
  # hooks:
53
53
  # post_creation: bundle exec rails db:prepare
54
- # pre_deletion: bundle exec rails db:drop
55
54
 
56
55
  __APP_PREFIX__-production:
57
56
  <<: *production