cpflow 5.2.0 → 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.
- checksums.yaml +4 -4
- data/.agents/agent-workflow.yml +13 -2
- data/.agents/bin/README.md +4 -3
- data/.github/actions/cpflow-setup-environment/action.yml +1 -1
- data/.github/actions/cpflow-wait-for-health/action.yml +87 -15
- data/.github/pull_request_template.md +18 -0
- data/.github/workflows/claude.yml +92 -1
- data/.github/workflows/cpflow-delete-review-app.yml +621 -33
- data/.github/workflows/cpflow-deploy-review-app.yml +640 -20
- data/.github/workflows/cpflow-review-app-help.yml +5 -13
- data/AGENTS.md +6 -49
- data/CHANGELOG.md +33 -1
- data/CONTRIBUTING.md +9 -1
- data/Gemfile.lock +1 -1
- data/docs/ci-automation.md +105 -40
- data/docs/commands.md +19 -2
- data/docs/secrets-and-env-values.md +8 -0
- data/docs/tips.md +18 -0
- data/lib/command/apply_template.rb +104 -2
- data/lib/command/base.rb +52 -3
- data/lib/command/deploy_image.rb +16 -2
- data/lib/command/run.rb +133 -10
- data/lib/command/setup_app.rb +10 -5
- data/lib/core/controlplane.rb +38 -5
- data/lib/core/controlplane_api.rb +8 -0
- data/lib/core/controlplane_api_direct.rb +257 -63
- data/lib/core/shell.rb +9 -2
- data/lib/cpflow/version.rb +1 -1
- data/lib/github_flow_templates/.github/cpflow-help.md +24 -10
- data/lib/github_flow_templates/.github/workflows/cpflow-delete-review-app.yml +10 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +9 -0
- metadata +3 -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 =
|
|
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
|
|
@@ -459,6 +461,28 @@ module Command
|
|
|
459
461
|
}
|
|
460
462
|
end
|
|
461
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
|
+
|
|
462
486
|
def self.skip_pre_deletion_hook_option(required: false)
|
|
463
487
|
{
|
|
464
488
|
name: :skip_pre_deletion_hook,
|
|
@@ -528,10 +552,12 @@ module Command
|
|
|
528
552
|
end
|
|
529
553
|
end
|
|
530
554
|
|
|
531
|
-
# NOTE: use simplified variant atm, as shelljoin do different escaping
|
|
532
|
-
# TODO: most probably need better logic for escaping various quotes
|
|
533
555
|
def args_join(args)
|
|
534
|
-
|
|
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)
|
|
535
561
|
end
|
|
536
562
|
|
|
537
563
|
def progress
|
|
@@ -619,9 +645,32 @@ module Command
|
|
|
619
645
|
raise shared_secret_policy_missing_message(grant) if policy.nil?
|
|
620
646
|
|
|
621
647
|
ensure_shared_secret_policy_targets_secret!(grant, policy)
|
|
648
|
+
warn_if_shared_secret_uses_generated_password_placeholder(grant)
|
|
622
649
|
policy
|
|
623
650
|
end
|
|
624
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
|
+
|
|
625
674
|
def bind_shared_secret_policy_grant(grant, policy)
|
|
626
675
|
policy_name = grant.fetch(:policy_name)
|
|
627
676
|
return if identity_bound_to_policy_with_reveal?(policy)
|
data/lib/command/deploy_image.rb
CHANGED
|
@@ -107,17 +107,25 @@ module Command
|
|
|
107
107
|
@requested_workload_names ||= Array(config.options[:workload]).map(&:to_s).uniq
|
|
108
108
|
end
|
|
109
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
|
+
|
|
110
116
|
def deploy_image_to_workloads(image, workload_data_by_name) # rubocop:disable Metrics/MethodLength
|
|
111
117
|
deployed_endpoints = {}
|
|
112
118
|
|
|
113
119
|
workload_data_by_name.each do |workload, workload_data|
|
|
114
120
|
workload_data.dig("spec", "containers").each do |container|
|
|
115
|
-
next unless container["image"]
|
|
121
|
+
next unless app_image?(container["image"])
|
|
116
122
|
|
|
117
123
|
container_name = container["name"]
|
|
118
124
|
step("Deploying image '#{image}' for workload '#{workload}'") do
|
|
119
125
|
cp.workload_set_image_ref(workload, container: container_name, image: image)
|
|
120
126
|
deployed_endpoints[workload] = endpoint_for_workload(workload_data)
|
|
127
|
+
# A missing public endpoint is valid; the image update still completed successfully.
|
|
128
|
+
true
|
|
121
129
|
end
|
|
122
130
|
# Deploy the first matching app-image container per workload; CPLN workloads
|
|
123
131
|
# are expected to have a single container that runs the app image.
|
|
@@ -131,7 +139,7 @@ module Command
|
|
|
131
139
|
def print_deployed_endpoints(deployed_endpoints)
|
|
132
140
|
progress.puts("\nDeployed endpoints:")
|
|
133
141
|
deployed_endpoints.each do |workload, endpoint|
|
|
134
|
-
progress.puts(" - #{workload}: #{endpoint}")
|
|
142
|
+
progress.puts(" - #{workload}: #{endpoint || '(no public endpoint)'}")
|
|
135
143
|
end
|
|
136
144
|
end
|
|
137
145
|
|
|
@@ -172,9 +180,15 @@ module Command
|
|
|
172
180
|
|
|
173
181
|
def endpoint_for_workload(workload_data)
|
|
174
182
|
endpoint = workload_data.dig("status", "endpoint")
|
|
183
|
+
return fallback_endpoint_for_workload(workload_data) unless endpoint
|
|
184
|
+
|
|
175
185
|
Resolv.getaddress(endpoint.split("/").last)
|
|
176
186
|
endpoint
|
|
177
187
|
rescue Resolv::ResolvError
|
|
188
|
+
fallback_endpoint_for_workload(workload_data)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def fallback_endpoint_for_workload(workload_data)
|
|
178
192
|
deployments = cp.fetch_workload_deployments(workload_data["name"])
|
|
179
193
|
deployments.dig("items", 0, "status", "endpoint")
|
|
180
194
|
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
|
-
#
|
|
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
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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
|
|
280
|
-
|
|
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
|
-
|
|
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 =
|
|
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=""
|
data/lib/command/setup_app.rb
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Command
|
|
4
|
-
class SetupApp < Base
|
|
4
|
+
class SetupApp < Base # rubocop:disable Metrics/ClassLength
|
|
5
5
|
NAME = "setup-app"
|
|
6
6
|
OPTIONS = [
|
|
7
7
|
app_option(required: true),
|
|
8
8
|
skip_secret_access_binding_option,
|
|
9
9
|
skip_secrets_setup_option,
|
|
10
|
-
skip_post_creation_hook_option
|
|
10
|
+
skip_post_creation_hook_option,
|
|
11
|
+
refresh_templates_option
|
|
11
12
|
].freeze
|
|
12
13
|
DESCRIPTION = "Creates an app and all its workloads"
|
|
13
14
|
LONG_DESCRIPTION = <<~DESC
|
|
@@ -23,18 +24,21 @@ module Command
|
|
|
23
24
|
- Runs a post-creation hook after the app is created if `hooks.post_creation` is specified in the `.controlplane/controlplane.yml` file
|
|
24
25
|
- If the hook exits with a non-zero code, the command will stop executing and also exit with a non-zero code
|
|
25
26
|
- Use `--skip-post-creation-hook` to skip the hook if specified in `controlplane.yml`
|
|
27
|
+
- Use `--refresh-templates` to apply configured templates noninteractively to an existing app while preserving each workload's configured app image even when workloads are unready or use mixed image versions, skipping existing secret resources entirely, repairing secrets access bindings, and skipping the post-creation hook
|
|
26
28
|
DESC
|
|
27
29
|
VALIDATIONS = %w[config templates].freeze
|
|
28
30
|
|
|
29
|
-
def call # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
31
|
+
def call # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
30
32
|
templates = config[:setup_app_templates]
|
|
33
|
+
refresh_templates = config.options[:refresh_templates]
|
|
31
34
|
|
|
32
35
|
app = cp.fetch_gvc
|
|
33
|
-
if app
|
|
36
|
+
if app && !refresh_templates
|
|
34
37
|
raise "App '#{config.app}' already exists. If you want to update this app, " \
|
|
35
38
|
"either run 'cpflow delete -a #{config.app}' and then re-run this command, " \
|
|
36
39
|
"or run 'cpflow apply-template #{templates.join(' ')} -a #{config.app}'."
|
|
37
40
|
end
|
|
41
|
+
raise "App '#{config.app}' does not exist, so its templates cannot be refreshed." if !app && refresh_templates
|
|
38
42
|
|
|
39
43
|
skip_secrets_setup = skip_secrets_setup?
|
|
40
44
|
|
|
@@ -45,11 +49,12 @@ module Command
|
|
|
45
49
|
|
|
46
50
|
args = []
|
|
47
51
|
args.push("--add-app-identity") unless skip_secrets_setup
|
|
52
|
+
args.push("--yes", "--preserve-existing-runtime") if refresh_templates
|
|
48
53
|
run_cpflow_command("apply-template", *templates, "-a", config.app, *args)
|
|
49
54
|
|
|
50
55
|
bind_identity_to_policy unless skip_secrets_setup
|
|
51
56
|
bind_shared_secret_policy_grants(shared_secret_policy_grant_pairs) unless skip_secrets_setup
|
|
52
|
-
run_post_creation_hook unless config.options[:skip_post_creation_hook]
|
|
57
|
+
run_post_creation_hook unless refresh_templates || config.options[:skip_post_creation_hook]
|
|
53
58
|
end
|
|
54
59
|
|
|
55
60
|
private
|
data/lib/core/controlplane.rb
CHANGED
|
@@ -177,6 +177,28 @@ class Controlplane # rubocop:disable Metrics/ClassLength
|
|
|
177
177
|
api.workload_get(workload: workload, gvc: gvc, org: org)
|
|
178
178
|
end
|
|
179
179
|
|
|
180
|
+
def fetch_workload_with_status(workload)
|
|
181
|
+
result = workload_status_result(workload)
|
|
182
|
+
|
|
183
|
+
unless result[:success]
|
|
184
|
+
Shell.warn("Failed to fetch status for '#{workload}': #{result[:error_output].to_s.strip}")
|
|
185
|
+
return
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
JSON.parse(result[:output])
|
|
189
|
+
rescue JSON::ParserError => e
|
|
190
|
+
Shell.warn("Failed to parse status for '#{workload}': #{e.message}")
|
|
191
|
+
nil
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def workload_status_result(workload)
|
|
195
|
+
# The direct API response omits computed fields such as status.readyLatest.
|
|
196
|
+
args = ["cpln", "workload", "get", workload, "--gvc", gvc, "--org", org, "-o", "json"]
|
|
197
|
+
Shell.debug("CMD", Shellwords.join(args))
|
|
198
|
+
Shell.cmd(*args, separate_stderr: true)
|
|
199
|
+
end
|
|
200
|
+
private :workload_status_result
|
|
201
|
+
|
|
180
202
|
def fetch_workload!(workload)
|
|
181
203
|
workload_data = fetch_workload(workload)
|
|
182
204
|
return workload_data if workload_data
|
|
@@ -279,9 +301,14 @@ class Controlplane # rubocop:disable Metrics/ClassLength
|
|
|
279
301
|
end
|
|
280
302
|
|
|
281
303
|
def workload_exec(workload, replica, location:, container: nil, command: nil)
|
|
282
|
-
cmd =
|
|
283
|
-
|
|
284
|
-
|
|
304
|
+
cmd = [
|
|
305
|
+
"cpln", "workload", "exec", workload,
|
|
306
|
+
"--gvc", gvc, "--org", org,
|
|
307
|
+
"--replica", replica, "--location", location, "-it"
|
|
308
|
+
]
|
|
309
|
+
cmd.push("--container", container) if container
|
|
310
|
+
cmd << "--"
|
|
311
|
+
cmd.concat(Array(command))
|
|
285
312
|
perform(cmd, output_mode: :all)
|
|
286
313
|
end
|
|
287
314
|
|
|
@@ -383,6 +410,10 @@ class Controlplane # rubocop:disable Metrics/ClassLength
|
|
|
383
410
|
api.fetch_secret(org: org, secret: secret)
|
|
384
411
|
end
|
|
385
412
|
|
|
413
|
+
def reveal_secret(secret)
|
|
414
|
+
api.reveal_secret(org: org, secret: secret)
|
|
415
|
+
end
|
|
416
|
+
|
|
386
417
|
# identities
|
|
387
418
|
|
|
388
419
|
def fetch_identity(identity, a_gvc = gvc)
|
|
@@ -509,6 +540,7 @@ class Controlplane # rubocop:disable Metrics/ClassLength
|
|
|
509
540
|
# or the return value of `Shell.should_hide_output?`.
|
|
510
541
|
def build_command(cmd, output_mode: nil) # rubocop:disable Metrics/MethodLength
|
|
511
542
|
output_mode ||= determine_command_output_mode
|
|
543
|
+
raise "Array commands require output mode 'all'." if cmd.is_a?(Array) && %i[errors_only none].include?(output_mode)
|
|
512
544
|
|
|
513
545
|
case output_mode
|
|
514
546
|
when :all
|
|
@@ -535,7 +567,8 @@ class Controlplane # rubocop:disable Metrics/ClassLength
|
|
|
535
567
|
def perform(cmd, output_mode: nil, sensitive_data_pattern: nil)
|
|
536
568
|
cmd = build_command(cmd, output_mode: output_mode)
|
|
537
569
|
|
|
538
|
-
|
|
570
|
+
debug_cmd = cmd.is_a?(Array) ? Shellwords.join(cmd) : cmd
|
|
571
|
+
Shell.debug("CMD", debug_cmd, sensitive_data_pattern: sensitive_data_pattern)
|
|
539
572
|
|
|
540
573
|
kernel_system_with_pid_handling(cmd)
|
|
541
574
|
end
|
|
@@ -544,7 +577,7 @@ class Controlplane # rubocop:disable Metrics/ClassLength
|
|
|
544
577
|
# Returns true on zero exit, false on non-zero exit, nil when the process was signal-killed.
|
|
545
578
|
# SystemCallError (e.g. cpln binary missing) propagates — startup checks ensure this is unreachable in practice.
|
|
546
579
|
def kernel_system_with_pid_handling(cmd)
|
|
547
|
-
pid = Process.spawn(cmd)
|
|
580
|
+
pid = cmd.is_a?(Array) ? Process.spawn(*cmd) : Process.spawn(cmd)
|
|
548
581
|
$child_pids << pid # rubocop:disable Style/GlobalVars
|
|
549
582
|
|
|
550
583
|
_, status = Process.wait2(pid)
|
|
@@ -122,6 +122,14 @@ class ControlplaneApi # rubocop:disable Metrics/ClassLength
|
|
|
122
122
|
api_json("/org/#{org}/secret/#{secret}", method: :get)
|
|
123
123
|
end
|
|
124
124
|
|
|
125
|
+
def reveal_secret(org:, secret:)
|
|
126
|
+
api_json(
|
|
127
|
+
"/org/#{org}/secret/#{secret}/-reveal",
|
|
128
|
+
method: :get,
|
|
129
|
+
request_policy: ControlplaneApiDirect::BEST_EFFORT_SENSITIVE_REQUEST_POLICY
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
|
|
125
133
|
def delete_secret(org:, secret:)
|
|
126
134
|
api_json("/org/#{org}/secret/#{secret}", method: :delete)
|
|
127
135
|
end
|