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.
- checksums.yaml +4 -4
- data/.agents/agent-workflow.yml +15 -0
- data/.agents/bin/README.md +19 -0
- data/.agents/bin/docs +5 -0
- data/.agents/bin/lint +5 -0
- data/.agents/bin/setup +5 -0
- data/.agents/bin/test +5 -0
- data/.agents/bin/validate +5 -0
- data/.agents/trusted-github-actors.yml +32 -0
- data/.agents/workflows/ai-rollout-e2e-test.md +166 -0
- data/.github/actions/cpflow-wait-for-health/action.yml +11 -4
- data/.github/workflows/claude-code-review.yml +2 -0
- data/.github/workflows/claude.yml +2 -0
- data/.github/workflows/cpflow-deploy-review-app.yml +16 -1
- data/.github/workflows/cpflow-promote-staging-to-production.yml +224 -37
- data/.github/workflows/rspec-shared.yml +15 -1
- data/.github/workflows/rspec-specific.yml +1 -0
- data/.github/workflows/rspec.yml +58 -1
- data/AGENTS.md +57 -0
- data/CHANGELOG.md +36 -1
- data/CLAUDE.md +3 -0
- data/CONTRIBUTING.md +6 -2
- data/Gemfile.lock +1 -1
- data/README.md +25 -7
- data/docs/ai-github-flow-prompt.md +18 -16
- data/docs/assets/logo/favicon.ico +0 -0
- data/docs/assets/logo/icon-1024.png +0 -0
- data/docs/assets/logo/icon-128.png +0 -0
- data/docs/assets/logo/icon-16.png +0 -0
- data/docs/assets/logo/icon-192.png +0 -0
- data/docs/assets/logo/icon-24.png +0 -0
- data/docs/assets/logo/icon-32.png +0 -0
- data/docs/assets/logo/icon-48.png +0 -0
- data/docs/assets/logo/icon-512.png +0 -0
- data/docs/assets/logo/icon-64.png +0 -0
- data/docs/assets/logo/icon-tile.svg +17 -0
- data/docs/assets/logo/mark-transparent.svg +16 -0
- data/docs/ci-automation.md +203 -15
- data/docs/commands.md +16 -1
- data/docs/grafana-opentelemetry.md +699 -0
- data/docs/secrets-and-env-values.md +29 -2
- data/docs/sidebars.ts +70 -0
- data/docs/telemetry/application-instrumentation.md +161 -0
- data/docs/telemetry/collector.md +297 -0
- data/docs/telemetry/index.md +152 -0
- data/docs/telemetry/pipelines.md +98 -0
- data/docs/telemetry/review-apps.md +55 -0
- data/docs/telemetry/troubleshooting.md +92 -0
- data/docs/terraform/example/.controlplane/controlplane.yml +0 -1
- data/docs/terraform/overview.md +11 -0
- data/docs/tips.md +458 -29
- data/examples/controlplane.yml +2 -0
- data/lib/command/ai_github_flow_prompt.rb +2 -2
- data/lib/command/base.rb +17 -2
- data/lib/command/deploy_image.rb +77 -5
- data/lib/command/maintenance_off.rb +1 -0
- data/lib/command/maintenance_on.rb +1 -0
- data/lib/command/promote_app_from_upstream.rb +1 -0
- data/lib/command/ps_wait.rb +2 -10
- data/lib/command/run.rb +25 -5
- data/lib/core/config.rb +94 -0
- data/lib/core/doctor_service.rb +44 -3
- data/lib/core/maintenance_mode.rb +93 -6
- data/lib/core/template_parser.rb +43 -9
- data/lib/cpflow/version.rb +1 -1
- data/lib/generator_templates/controlplane.yml +1 -2
- data/lib/github_flow_templates/.github/cpflow-help.md +23 -1
- data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +224 -39
- metadata +33 -2
data/lib/command/deploy_image.rb
CHANGED
|
@@ -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
|
-
|
|
47
|
+
deploy_image(image, workload_data_by_name)
|
|
32
48
|
end
|
|
33
49
|
|
|
34
50
|
private
|
|
35
51
|
|
|
36
|
-
def
|
|
37
|
-
config[:app_workloads]
|
|
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
|
data/lib/command/ps_wait.rb
CHANGED
|
@@ -26,19 +26,11 @@ module Command
|
|
|
26
26
|
```
|
|
27
27
|
EX
|
|
28
28
|
|
|
29
|
-
def call
|
|
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
|
|
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
|
-
|
|
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
|
|
data/lib/core/doctor_service.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
data/lib/core/template_parser.rb
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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}}",
|
data/lib/cpflow/version.rb
CHANGED
|
@@ -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
|
|
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
|