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.
@@ -9,7 +9,8 @@ module Command
9
9
  app_option(required: true),
10
10
  location_option,
11
11
  skip_confirm_option,
12
- add_app_identity_option
12
+ add_app_identity_option,
13
+ preserve_existing_runtime_option
13
14
  ].freeze
14
15
  DESCRIPTION = "Applies application-specific configs from templates"
15
16
  LONG_DESCRIPTION = <<~DESC
@@ -17,6 +18,8 @@ module Command
17
18
  - Publishes (creates or updates) those at Control Plane infrastructure
18
19
  - Picks templates from the `.controlplane/templates` directory
19
20
  - Templates are ordinary Control Plane templates but with variable preprocessing
21
+ - Use `--preserve-existing-runtime` to retain each workload container's configured app image, even when the workload is unready, and skip existing secret resources entirely while applying other template changes
22
+ - Missing or invalid workload images use only an unambiguous app image from ready workloads; refresh fails before applying templates when no safe fallback exists
20
23
 
21
24
  **Preprocessed template variables:**
22
25
 
@@ -58,6 +61,7 @@ module Command
58
61
  @skipped_templates = []
59
62
 
60
63
  templates = @template_parser.parse(@names_to_filenames.values)
64
+ templates = preserve_existing_runtime(templates) if config.options[:preserve_existing_runtime]
61
65
  pending_templates = confirm_templates(templates)
62
66
  add_app_identity_template(pending_templates) if config.options[:add_app_identity]
63
67
  pending_templates.each do |template|
@@ -117,7 +121,7 @@ module Command
117
121
  end
118
122
 
119
123
  def confirm_workload(template) # rubocop:disable Naming/PredicateMethod
120
- workload = cp.fetch_workload(template["name"])
124
+ workload = fetch_workload(template["name"])
121
125
  return true unless workload
122
126
 
123
127
  confirmed = confirm_apply("Workload '#{template['name']}' already exists, do you want to re-create it?")
@@ -146,6 +150,104 @@ module Command
146
150
  pending_templates
147
151
  end
148
152
 
153
+ def preserve_existing_runtime(templates)
154
+ cache_existing_workloads(templates)
155
+ ready_fallback_image = unambiguous_ready_app_image
156
+
157
+ templates.filter_map do |template|
158
+ if template["kind"] == "secret" && cp.fetch_secret(template["name"])
159
+ report_skipped(template)
160
+ next
161
+ end
162
+
163
+ preserve_workload_images(template, ready_fallback_image) if template["kind"] == "workload"
164
+ template
165
+ end
166
+ end
167
+
168
+ def cache_existing_workloads(templates)
169
+ template_names = templates.filter_map { |template| template["name"] if template["kind"] == "workload" }
170
+ workloads = cp.fetch_workloads
171
+ cp.fetch_gvc! unless workloads
172
+ @existing_workloads = Array(workloads.fetch("items")).to_h do |workload|
173
+ [workload.fetch("name"), workload]
174
+ end
175
+ template_names.each { |name| @existing_workloads[name] = nil unless @existing_workloads.key?(name) }
176
+ end
177
+
178
+ def fetch_workload(name)
179
+ return @existing_workloads[name] if @existing_workloads&.key?(name)
180
+
181
+ cp.fetch_workload(name)
182
+ end
183
+
184
+ def unambiguous_ready_app_image
185
+ app_images = ready_existing_app_workloads.flat_map do |workload|
186
+ Array(workload.dig("spec", "containers")).filter_map do |container|
187
+ container["image"] if deployable_app_image?(container["image"])
188
+ end
189
+ end
190
+ image_prefix = "/org/#{config.org}/image/"
191
+ canonical_images = app_images.map { |image| image.delete_prefix(image_prefix) }
192
+ app_images.first if canonical_images.uniq.one?
193
+ end
194
+
195
+ def ready_existing_app_workloads
196
+ @existing_workloads.values.compact.filter_map do |workload|
197
+ next unless Array(workload.dig("spec", "containers")).any? do |container|
198
+ deployable_app_image?(container["image"])
199
+ end
200
+
201
+ workload = workload_with_readiness(workload)
202
+ workload if workload_ready?(workload)
203
+ end
204
+ end
205
+
206
+ def preserve_workload_images(template, ready_fallback_image) # rubocop:disable Metrics/MethodLength
207
+ existing_workload = fetch_workload(template["name"])
208
+ existing_containers = Array(existing_workload&.dig("spec", "containers"))
209
+ .to_h { |container| [container["name"], container] }
210
+
211
+ Array(template.dig("spec", "containers")).each do |container|
212
+ next unless app_image?(container["image"])
213
+
214
+ existing_image = existing_containers.dig(container["name"], "image")
215
+ preserved_image = preserved_image(existing_image, ready_fallback_image)
216
+ unless preserved_image
217
+ raise "Cannot safely refresh app image for workload '#{template['name']}' " \
218
+ "without a valid existing workload image or an unambiguous ready fallback image."
219
+ end
220
+
221
+ container["image"] = preserved_image
222
+ end
223
+ end
224
+
225
+ def preserved_image(existing_image, ready_fallback_image)
226
+ return existing_image if deployable_app_image?(existing_image)
227
+
228
+ ready_fallback_image
229
+ end
230
+
231
+ def workload_ready?(workload)
232
+ workload&.dig("status", "readyLatest") == true
233
+ end
234
+
235
+ def workload_with_readiness(workload)
236
+ return workload unless workload.dig("status", "readyLatest").nil?
237
+
238
+ cp.fetch_workload_with_status(workload.fetch("name")) || workload
239
+ end
240
+
241
+ def app_image?(image)
242
+ image.to_s.match?(
243
+ %r{\A(?:/org/#{Regexp.escape(config.org)}/image/)?#{Regexp.escape(config.app)}[:@]}
244
+ )
245
+ end
246
+
247
+ def deployable_app_image?(image)
248
+ app_image?(image) && !image.to_s.end_with?(Controlplane::NO_IMAGE_AVAILABLE)
249
+ end
250
+
149
251
  def add_app_identity_template(templates)
150
252
  app_template_index = templates.index { |template| template["name"] == config.app }
151
253
  app_identity_template_index = templates.index { |template| template["name"] == config.identity }
data/lib/command/base.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "shellwords"
3
4
  require_relative "../core/helpers"
4
5
 
5
6
  module Command
@@ -11,6 +12,7 @@ module Command
11
12
  VALIDATIONS_WITHOUT_ADDITIONAL_OPTIONS = %w[config].freeze
12
13
  VALIDATIONS_WITH_ADDITIONAL_OPTIONS = %w[templates].freeze
13
14
  ALL_VALIDATIONS = VALIDATIONS_WITHOUT_ADDITIONAL_OPTIONS + VALIDATIONS_WITH_ADDITIONAL_OPTIONS
15
+ GENERATED_POSTGRES_PASSWORD_PLACEHOLDER = "the_password"
14
16
 
15
17
  # Used to call the command (`cpflow SUBCOMMAND_NAME NAME`)
16
18
  SUBCOMMAND_NAME = nil
@@ -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
- args.join(" ")
556
+ # A single CLI argument is an intentional shell program (for example, an env assignment or pipeline).
557
+ # Multiple CLI arguments are argv elements and must be escaped before entering the remote shell script.
558
+ return args.first if args.size == 1
559
+
560
+ Shellwords.join(args)
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)
@@ -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"].match?(%r{^/org/#{config.org}/image/#{config.app}[:@]})
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
- # The command needs to be quoted if setting an env variable or passing args.
85
+ # Quote the whole command to intentionally opt into shell syntax such as an env assignment.
86
+ # Separately supplied command arguments are passed literally.
73
87
  cpflow run -a $APP_NAME -- 'SOME_ENV_VAR=some_value rails db:migrate'
74
88
 
75
89
  # Uses a different image (which may not be promoted yet).
@@ -95,6 +109,9 @@ module Command
95
109
  DEFAULT_JOB_MEMORY = "2Gi"
96
110
  DEFAULT_JOB_TIMEOUT = 21_600 # 6 hours
97
111
  DEFAULT_JOB_HISTORY_LIMIT = 10
112
+ MAX_REPLICA_OBSERVATION_SECONDS = 1_000
113
+ REPLICA_OBSERVATION_POLL_INTERVAL_SECONDS = 1
114
+ NORMALIZED_JOB_STATUS_PATTERN = /\A[a-z][a-z0-9_-]{0,31}\z/
98
115
  MAGIC_END = "---cpflow run command finished---"
99
116
 
100
117
  attr_reader :interactive, :detached, :location, :original_workload, :runner_workload,
@@ -268,27 +285,80 @@ module Command
268
285
  end
269
286
 
270
287
  def wait_for_replica_for_job
271
- step("Waiting for replica to start, which runs job '#{job}'", retry_on_failure: true) do
272
- result = cp.fetch_workload_replicas(runner_workload, location: location)
273
- @replica = result&.dig("items")&.find { |item| item.include?(job) }
288
+ observation_limit = [job_timeout, MAX_REPLICA_OBSERVATION_SECONDS].min
289
+ observation_deadline = monotonic_time + observation_limit
274
290
 
275
- replica || completed_job_before_replica? || false
291
+ step("Waiting for runner replica to start") do
292
+ observe_replica_until(observation_deadline, observation_limit)
276
293
  end
277
294
  end
278
295
 
279
- def completed_job_before_replica?
280
- case current_job_status
296
+ def observe_replica_until(observation_deadline, observation_limit)
297
+ last_status = nil
298
+
299
+ loop do
300
+ ensure_before_replica_observation_deadline!(observation_deadline, observation_limit, last_status)
301
+
302
+ @replica = replica_for_job
303
+ return replica if replica
304
+
305
+ ensure_before_replica_observation_deadline!(observation_deadline, observation_limit, last_status)
306
+
307
+ last_status = current_job_status
308
+ return true if completed_job_before_replica?(last_status)
309
+
310
+ sleep_before_replica_observation_retry(observation_deadline, observation_limit, last_status)
311
+ end
312
+ end
313
+
314
+ def ensure_before_replica_observation_deadline!(observation_deadline, observation_limit, status)
315
+ return if monotonic_time < observation_deadline
316
+
317
+ raise replica_observation_timeout_message(observation_limit, status)
318
+ end
319
+
320
+ def replica_for_job
321
+ result = cp.fetch_workload_replicas(runner_workload, location: location)
322
+ result&.dig("items")&.find { |item| item.include?(job) }
323
+ end
324
+
325
+ def sleep_before_replica_observation_retry(observation_deadline, observation_limit, status)
326
+ progress.print(".")
327
+ remaining = observation_deadline - monotonic_time
328
+ raise replica_observation_timeout_message(observation_limit, status) unless remaining.positive?
329
+
330
+ Kernel.sleep([REPLICA_OBSERVATION_POLL_INTERVAL_SECONDS, remaining].min)
331
+ end
332
+
333
+ # Returns true for success, false while pending, and raises for a terminal non-success status.
334
+ def completed_job_before_replica?(status)
335
+ case status
281
336
  when "successful"
282
337
  @job_completed_before_replica_exit_status = ExitCode::SUCCESS
283
338
  true
284
339
  when nil, "active", "pending"
285
340
  false
286
341
  else
287
- @job_completed_before_replica_exit_status = ExitCode::ERROR_DEFAULT
288
- true
342
+ raise "Runner job ended before a replica was observed (status: #{normalized_job_status(status)})."
289
343
  end
290
344
  end
291
345
 
346
+ def normalized_job_status(status)
347
+ return "unavailable" if status.nil?
348
+
349
+ token = status.to_s.downcase
350
+ token.match?(NORMALIZED_JOB_STATUS_PATTERN) ? token : "unknown"
351
+ end
352
+
353
+ def replica_observation_timeout_message(observation_limit, status)
354
+ "Runner replica was not observed before the observation limit: #{format('%g', observation_limit)} seconds " \
355
+ "(status: #{normalized_job_status(status)})."
356
+ end
357
+
358
+ def monotonic_time
359
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
360
+ end
361
+
292
362
  def run_interactive
293
363
  progress.puts("Connecting to replica '#{replica}'...\n\n")
294
364
  # workload_exec returns false on non-zero exit, nil when signal-killed (e.g. Ctrl-C).
@@ -389,11 +459,12 @@ module Command
389
459
  job_start_hash["args"].push("-c")
390
460
  job_start_hash["env"] ||= []
391
461
  job_start_hash["env"].push({ "name" => "CPFLOW_RUNNER_SCRIPT", "value" => runner_script })
462
+ job_start_hash["env"].concat(gvc_identity_env_vars)
392
463
  if interactive
393
464
  job_start_hash["env"].push({ "name" => "CPFLOW_MONITORING_SCRIPT", "value" => interactive_monitoring_script })
394
465
 
395
466
  job_start_hash["args"].push('eval "$CPFLOW_MONITORING_SCRIPT"')
396
- @command = %(bash -c 'eval "$CPFLOW_RUNNER_SCRIPT"')
467
+ @command = ["bash", "-c", 'eval "$CPFLOW_RUNNER_SCRIPT"']
397
468
  else
398
469
  job_start_hash["args"].push('eval "$CPFLOW_RUNNER_SCRIPT"')
399
470
  end
@@ -413,6 +484,58 @@ module Command
413
484
  job_start_hash.to_yaml
414
485
  end
415
486
 
487
+ # Exposes the GVC's immutable identity to the job. cpflow authenticates client-side with the
488
+ # operator/CI credentials, so reading the GVC here adds no GVC-view binding to the app's workload
489
+ # identity and leaves nothing behind for the delete lifecycle to clean up.
490
+ #
491
+ # Both variables are always emitted, empty when unknown, rather than omitted. `update_runner_workload`
492
+ # copies the original workload's env wholesale into the runner, so omitting them would let a value
493
+ # inherited from the workload survive a failed read and be mistaken for a live identity. Emitting an
494
+ # explicit empty value is the only way a consumer can actually fail closed.
495
+ def gvc_identity_env_vars
496
+ gvc_data = fetch_gvc_for_identity
497
+ gvc_id = (gvc_data && gvc_data["id"]).to_s
498
+ # Gated on the id so a fresh id can never be paired with an inherited timestamp.
499
+ gvc_created = gvc_id.empty? ? "" : gvc_data["created"].to_s
500
+
501
+ [
502
+ { "name" => "CPFLOW_GVC_ID", "value" => gvc_id },
503
+ { "name" => "CPFLOW_GVC_CREATED", "value" => gvc_created }
504
+ ]
505
+ end
506
+
507
+ # The identity is an optional enrichment, so failing to read it must never stop the job, and the
508
+ # variables are still emitted (empty) rather than dropped.
509
+ # Two reasons the rescue is deliberately broad rather than a narrow class list:
510
+ #
511
+ # 1. Availability. `cpflow run` is also the release-phase mechanism for `deploy-image`, `setup-app`,
512
+ # and `delete`, so a transient error on the GVC endpoint would otherwise fail a deploy over a
513
+ # variable the command does not need.
514
+ # 2. Taxonomy. `handle_response` raises a bare `RuntimeError` for 401 and 5xx responses, so any
515
+ # "narrow" list that actually covered the real failures would have to include `RuntimeError`.
516
+ #
517
+ # `MaintenanceMode#domain_workload_update_confirmed?` rescues `StandardError` against this same API
518
+ # client for the same reason. The rescued body is a single external call, so a bug in this file's own
519
+ # logic still raises.
520
+ def fetch_gvc_for_identity
521
+ cp.fetch_gvc
522
+ rescue StandardError => e
523
+ # Deliberately `Shell.warn` rather than a `step`. `step_finish` prints a red "failed!" banner
524
+ # whenever its block is falsy, regardless of `abort_on_error`, so routing an optional lookup
525
+ # through it would show a failure on every deploy for anyone whose token omits `gvc` view -- for
526
+ # a read that stops nothing and leaves the exit code at 0.
527
+ Shell.warn(gvc_identity_error_message(e))
528
+ nil
529
+ end
530
+
531
+ # A 403 here is normally a missing `view` grant on kind `gvc`, but `ForbiddenError` renders any
532
+ # `/org/...` URL as "Double check your org", which sends the operator after the wrong thing.
533
+ def gvc_identity_error_message(error)
534
+ "Continuing without the GVC identity, so CPFLOW_GVC_ID and CPFLOW_GVC_CREATED are set to empty " \
535
+ "strings. A permission failure here usually means the token lacks `view` on kind `gvc` for this " \
536
+ "app, even when the message below mentions the org. #{error.message}"
537
+ end
538
+
416
539
  def interactive_monitoring_script
417
540
  <<~SCRIPT
418
541
  primary_pid=""
@@ -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
@@ -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 = "cpln workload exec #{workload} #{gvc_org} --replica #{replica} --location #{location} -it"
283
- cmd += " --container #{container}" if container
284
- cmd += " -- #{command}"
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
- Shell.debug("CMD", cmd, sensitive_data_pattern: sensitive_data_pattern)
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