cpl 2.0.1 → 2.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/docs/tips.md CHANGED
@@ -3,7 +3,7 @@
3
3
  1. [GVCs vs. Orgs](#gvcs-vs-orgs)
4
4
  2. [RAM](#ram)
5
5
  3. [Remote IP](#remote-ip)
6
- 4. [ENV Values](#env-values)
6
+ 4. [Secrets and ENV Values](/docs/secrets-and-env-values.md)
7
7
  5. [CI](#ci)
8
8
  6. [Memcached](#memcached)
9
9
  7. [Sidekiq](#sidekiq)
@@ -70,45 +70,6 @@ pick those up and automatically populate `request.remote_ip`.
70
70
 
71
71
  So `REMOTE_ADDR` should not be used directly, only `request.remote_ip`.
72
72
 
73
- ## ENV Values
74
-
75
- You can store ENV values used by a container (within a workload) within Control Plane at the following levels:
76
-
77
- 1. Workload Container
78
- 2. GVC
79
-
80
- For your "review apps," it is convenient to have simple ENVs stored in plain text in your source code. You will want to
81
- keep some ENVs, like the Rails' `SECRET_KEY_BASE`, out of your source code. For staging and production apps, you will
82
- set these values directly at the GVC or workload levels, so none of these ENV values are committed to the source code.
83
-
84
- For storing ENVs in the source code, we can use a level of indirection so that you can store an ENV value in your source
85
- code like `cpln://secret/my-app-review-env-secrets.SECRET_KEY_BASE` and then have the secret value stored at the org
86
- level, which applies to your GVCs mapped to that org.
87
-
88
- You can do this during the initial app setup, like this:
89
-
90
- 1. Add the templates for `app` and `secrets` to `.controlplane/templates`
91
- 2. Ensure that the `app` template includes the `identity`
92
- 3. Ensure that the `app` template is listed in `setup_app_templates` for the app in `.controlplane/controlplane.yml`
93
- 4. Run `cpl apply-template secrets -a $APP_NAME` (one-time setup)
94
- 5. Run `cpl setup-app -a $APP_NAME`
95
- 6. The secrets, secrets policy and identity will be automatically created, along with the proper binding
96
- 7. In the Control Plane console, upper left "Manage Org" menu, click on "Secrets"
97
- 8. Find the created secret (it will be in the `$APP_PREFIX-secrets` format) and add the secret env vars there
98
- 9. Use `cpln://secret/...` in the app to access the secret env vars (e.g., `cpln://secret/$APP_PREFIX-secrets.SOME_VAR`)
99
-
100
- Here are the manual steps for reference. We recommend that you follow the steps above:
101
-
102
- 1. In the upper left of the Control Plane console, "Manage Org" menu, click on "Secrets"
103
- 2. Create a secret with `Secret Type: Dictionary` (e.g., `my-secrets`) and add the secret env vars there
104
- 3. In the upper left "Manage GVC" menu, click on "Identities"
105
- 4. Create an identity (e.g., `my-identity`)
106
- 5. Navigate to the workload that you want to associate with the identity created
107
- 6. Click "Identity" on the left menu and select the identity created
108
- 7. In the lower left "Access Control" menu, click on "Policies"
109
- 8. Create a policy with `Target Kind: Secret` and add a binding with the `reveal` permission for the identity created
110
- 9. Use `cpln://secret/...` in the app to access the secret env vars (e.g., `cpln://secret/my-secrets.SOME_VAR`)
111
-
112
73
  ## CI
113
74
 
114
75
  **Note:** Docker builds much slower on Apple Silicon, so try configuring CI to build the images when using Apple
@@ -31,9 +31,6 @@ aliases:
31
31
  # 2. Each file can contain many objects, such as in the case of templates that create a resource, like `postgres`.
32
32
  # 3. While the naming often corresponds to a workload or other object name, the naming is arbitrary.
33
33
  # Naming does not need to match anything other than the file name without the `.yml` extension.
34
- #
35
- # If you're going to use secrets, you need to apply the `secrets.yml` template separately (one-time setup):
36
- # `cpl apply-template secrets -a my-app`
37
34
  setup_app_templates:
38
35
  - app
39
36
  - redis
@@ -42,6 +39,9 @@ aliases:
42
39
  - rails
43
40
  - sidekiq
44
41
 
42
+ # Uncomment next line to skips secrets setup when running `cpl setup-app`.
43
+ # skip_secrets_setup: true
44
+
45
45
  # Only needed if using a custom secrets name.
46
46
  # The default is '{APP_PREFIX}-secrets'. For example:
47
47
  # - for an app 'my-app-staging' with `match_if_app_name_starts_with` set to `false`,
@@ -108,6 +108,15 @@ apps:
108
108
  # e.g., "my-app-review-pr123", "my-app-review-anything-goes", etc.
109
109
  match_if_app_name_starts_with: true
110
110
 
111
+ # Hooks can be either a script path that exists in the app image or a command.
112
+ # They're run in the context of `cpl run` with the latest image.
113
+ hooks:
114
+ # Used by the command `cpl setup-app` to run a hook after creating the app.
115
+ post_creation: bundle exec rake db:prepare
116
+
117
+ # Used by the command `cpl delete` to run a hook before deleting the app.
118
+ pre_deletion: bundle exec rake db:drop
119
+
111
120
  my-app-production:
112
121
  <<: *common
113
122
 
@@ -8,7 +8,8 @@ module Command
8
8
  OPTIONS = [
9
9
  app_option(required: true),
10
10
  location_option,
11
- skip_confirm_option
11
+ skip_confirm_option,
12
+ add_app_identity_option
12
13
  ].freeze
13
14
  DESCRIPTION = "Applies application-specific configs from templates"
14
15
  LONG_DESCRIPTION = <<~DESC
@@ -39,45 +40,27 @@ module Command
39
40
  cpl apply-template app postgres redis rails -a $APP_NAME
40
41
  ```
41
42
  EX
43
+ VALIDATIONS = %w[config templates].freeze
44
+
45
+ def call # rubocop:disable Metrics/MethodLength
46
+ @template_parser = TemplateParser.new(config)
47
+ @names_to_filenames = config.args.to_h do |name|
48
+ [name, @template_parser.template_filename(name)]
49
+ end
42
50
 
43
- def call # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
44
51
  ensure_templates!
45
52
 
46
53
  @created_items = []
47
54
  @failed_templates = []
48
55
  @skipped_templates = []
49
56
 
50
- @asked_for_confirmation = false
51
-
52
- pending_templates = templates.select do |template|
53
- if template == "app"
54
- confirm_app(template)
55
- else
56
- confirm_workload(template)
57
- end
58
- end
59
-
60
- progress.puts if @asked_for_confirmation
61
-
62
- @deprecated_variables = []
63
-
64
- pending_templates.each do |template, filename|
65
- step("Applying template '#{template}'", abort_on_error: false) do
66
- items = apply_template(filename)
67
- unless items
68
- report_failure(template)
69
- next false
70
- end
71
-
72
- items.each do |item|
73
- report_success(item)
74
- end
75
- true
76
- end
57
+ templates = @template_parser.parse(@names_to_filenames.values)
58
+ pending_templates = confirm_templates(templates)
59
+ add_app_identity_template(pending_templates) if config.options[:add_app_identity]
60
+ pending_templates.each do |template|
61
+ apply_template(template)
77
62
  end
78
63
 
79
- warn_deprecated_variables
80
-
81
64
  print_created_items
82
65
  print_failed_templates
83
66
  print_skipped_templates
@@ -87,18 +70,21 @@ module Command
87
70
 
88
71
  private
89
72
 
90
- def templates
91
- @templates ||= config.args.to_h do |template|
92
- [template, "#{config.app_cpln_dir}/templates/#{template}.yml"]
73
+ def template_kind(template)
74
+ case template["kind"]
75
+ when "gvc"
76
+ "app"
77
+ else
78
+ template["kind"]
93
79
  end
94
80
  end
95
81
 
96
82
  def ensure_templates!
97
- missing_templates = templates.reject { |_template, filename| File.exist?(filename) }.to_h
83
+ missing_templates = @names_to_filenames.reject { |_, filename| File.exist?(filename) }
98
84
  return if missing_templates.empty?
99
85
 
100
- missing_templates_str = missing_templates.map do |template, filename|
101
- " - #{template} (#{filename})"
86
+ missing_templates_str = missing_templates.map do |name, filename|
87
+ " - #{name} (#{filename})"
102
88
  end.join("\n")
103
89
  progress.puts("#{Shell.color('Missing templates:', :red)}\n#{missing_templates_str}\n\n")
104
90
 
@@ -113,10 +99,10 @@ module Command
113
99
  end
114
100
 
115
101
  def confirm_app(template)
116
- app = cp.fetch_gvc
102
+ app = cp.fetch_gvc(template["name"])
117
103
  return true unless app
118
104
 
119
- confirmed = confirm_apply("App '#{config.app}' already exists, do you want to re-create it?")
105
+ confirmed = confirm_apply("App '#{template['name']}' already exists, do you want to re-create it?")
120
106
  return true if confirmed
121
107
 
122
108
  report_skipped(template)
@@ -124,63 +110,67 @@ module Command
124
110
  end
125
111
 
126
112
  def confirm_workload(template)
127
- workload = cp.fetch_workload(template)
113
+ workload = cp.fetch_workload(template["name"])
128
114
  return true unless workload
129
115
 
130
- confirmed = confirm_apply("Workload '#{template}' already exists, do you want to re-create it?")
116
+ confirmed = confirm_apply("Workload '#{template['name']}' already exists, do you want to re-create it?")
131
117
  return true if confirmed
132
118
 
133
119
  report_skipped(template)
134
120
  false
135
121
  end
136
122
 
137
- def apply_template(filename) # rubocop:disable Metrics/MethodLength
138
- data = File.read(filename)
139
- .gsub("{{APP_ORG}}", config.org)
140
- .gsub("{{APP_NAME}}", config.app)
141
- .gsub("{{APP_LOCATION}}", config.location)
142
- .gsub("{{APP_LOCATION_LINK}}", app_location_link)
143
- .gsub("{{APP_IMAGE}}", latest_image)
144
- .gsub("{{APP_IMAGE_LINK}}", app_image_link)
145
- .gsub("{{APP_IDENTITY}}", app_identity)
146
- .gsub("{{APP_IDENTITY_LINK}}", app_identity_link)
147
- .gsub("{{APP_SECRETS}}", app_secrets)
148
- .gsub("{{APP_SECRETS_POLICY}}", app_secrets_policy)
123
+ def confirm_templates(templates) # rubocop:disable Metrics/MethodLength
124
+ @asked_for_confirmation = false
125
+
126
+ pending_templates = templates.select do |template|
127
+ case template["kind"]
128
+ when "gvc"
129
+ confirm_app(template)
130
+ when "workload"
131
+ confirm_workload(template)
132
+ else
133
+ true
134
+ end
135
+ end
149
136
 
150
- find_deprecated_variables(data)
137
+ progress.puts if @asked_for_confirmation
138
+
139
+ pending_templates
140
+ end
151
141
 
152
- # Kept for backwards compatibility
153
- data = data
154
- .gsub("APP_ORG", config.org)
155
- .gsub("APP_GVC", config.app)
156
- .gsub("APP_LOCATION", config.location)
157
- .gsub("APP_IMAGE", latest_image)
142
+ def add_app_identity_template(templates)
143
+ app_template_index = templates.index { |template| template["name"] == config.app }
144
+ app_identity_template_index = templates.index { |template| template["name"] == config.identity }
158
145
 
159
- # Don't read in YAML.safe_load as that doesn't handle multiple documents
160
- cp.apply_template(data)
146
+ return unless app_template_index && app_identity_template_index.nil?
147
+
148
+ # Adding the identity template right after the app template is important since:
149
+ # a) we can't create the identity at the beginning because the app doesn't exist yet
150
+ # b) we also can't create it at the end because any workload templates associated with it will fail to apply
151
+ templates.insert(app_template_index + 1, build_app_identity_hash)
161
152
  end
162
153
 
163
- def new_variables
154
+ def build_app_identity_hash
164
155
  {
165
- "APP_ORG" => "{{APP_ORG}}",
166
- "APP_GVC" => "{{APP_NAME}}",
167
- "APP_LOCATION" => "{{APP_LOCATION}}",
168
- "APP_IMAGE" => "{{APP_IMAGE}}"
156
+ "kind" => "identity",
157
+ "name" => config.identity
169
158
  }
170
159
  end
171
160
 
172
- def find_deprecated_variables(data)
173
- @deprecated_variables.push(*new_variables.keys.select { |old_key| data.include?(old_key) })
174
- @deprecated_variables = @deprecated_variables.uniq.sort
175
- end
176
-
177
- def warn_deprecated_variables
178
- return unless @deprecated_variables.any?
161
+ def apply_template(template) # rubocop:disable Metrics/MethodLength
162
+ step("Applying template for #{template_kind(template)} '#{template['name']}'", abort_on_error: false) do
163
+ items = cp.apply_hash(template)
164
+ unless items
165
+ report_failure(template)
166
+ next false
167
+ end
179
168
 
180
- message = "Please replace these variables in the templates, " \
181
- "as support for them will be removed in a future major version bump:"
182
- deprecated = @deprecated_variables.map { |old_key| " - #{old_key} -> #{new_variables[old_key]}" }.join("\n")
183
- progress.puts("\n#{Shell.color("DEPRECATED: #{message}", :yellow)}\n#{deprecated}")
169
+ items.each do |item|
170
+ report_success(item)
171
+ end
172
+ true
173
+ end
184
174
  end
185
175
 
186
176
  def report_success(item)
@@ -205,14 +195,14 @@ module Command
205
195
  def print_failed_templates
206
196
  return unless @failed_templates.any?
207
197
 
208
- failed = @failed_templates.map { |template| " - #{template}" }.join("\n")
198
+ failed = @failed_templates.map { |template| " - [#{template_kind(template)}] #{template['name']}" }.join("\n")
209
199
  progress.puts("\n#{Shell.color('Failed to apply templates:', :red)}\n#{failed}")
210
200
  end
211
201
 
212
202
  def print_skipped_templates
213
203
  return unless @skipped_templates.any?
214
204
 
215
- skipped = @skipped_templates.map { |template| " - #{template}" }.join("\n")
205
+ skipped = @skipped_templates.map { |template| " - [#{template_kind(template)}] #{template['name']}" }.join("\n")
216
206
  progress.puts("\n#{Shell.color('Skipped templates (already exist):', :blue)}\n#{skipped}")
217
207
  end
218
208
  end
data/lib/command/base.rb CHANGED
@@ -8,6 +8,10 @@ module Command
8
8
 
9
9
  include Helpers
10
10
 
11
+ VALIDATIONS_WITHOUT_ADDITIONAL_OPTIONS = %w[config].freeze
12
+ VALIDATIONS_WITH_ADDITIONAL_OPTIONS = %w[templates].freeze
13
+ ALL_VALIDATIONS = VALIDATIONS_WITHOUT_ADDITIONAL_OPTIONS + VALIDATIONS_WITH_ADDITIONAL_OPTIONS
14
+
11
15
  # Used to call the command (`cpl NAME`)
12
16
  # NAME = ""
13
17
  # Displayed when running `cpl help` or `cpl help NAME` (defaults to `NAME`)
@@ -32,8 +36,8 @@ module Command
32
36
  HIDE = false
33
37
  # Whether or not to show key information like ORG and APP name in commands
34
38
  WITH_INFO_HEADER = true
35
-
36
- NO_IMAGE_AVAILABLE = "NO_IMAGE_AVAILABLE"
39
+ # Which validations to run before the command
40
+ VALIDATIONS = %w[config].freeze
37
41
 
38
42
  def initialize(config)
39
43
  @config = config
@@ -269,6 +273,7 @@ module Command
269
273
  def self.skip_secret_access_binding_option(required: false)
270
274
  {
271
275
  name: :skip_secret_access_binding,
276
+ new_name: :skip_secrets_setup,
272
277
  params: {
273
278
  desc: "Skips secret access binding",
274
279
  type: :boolean,
@@ -277,6 +282,17 @@ module Command
277
282
  }
278
283
  end
279
284
 
285
+ def self.skip_secrets_setup_option(required: false)
286
+ {
287
+ name: :skip_secrets_setup,
288
+ params: {
289
+ desc: "Skips secrets setup",
290
+ type: :boolean,
291
+ required: required
292
+ }
293
+ }
294
+ end
295
+
280
296
  def self.run_release_phase_option(required: false)
281
297
  {
282
298
  name: :run_release_phase,
@@ -378,57 +394,65 @@ module Command
378
394
  }
379
395
  }
380
396
  end
381
- # rubocop:enable Metrics/MethodLength
382
397
 
383
- def self.all_options
384
- methods.grep(/_option$/).map { |method| send(method.to_s) }
398
+ def self.validations_option(required: false)
399
+ {
400
+ name: :validations,
401
+ params: {
402
+ banner: "VALIDATION_1,VALIDATION_2,...",
403
+ desc: "Which validations to run " \
404
+ "(must be separated by a comma)",
405
+ type: :string,
406
+ required: required,
407
+ default: VALIDATIONS_WITHOUT_ADDITIONAL_OPTIONS.join(","),
408
+ valid_regex: /^(#{ALL_VALIDATIONS.join("|")})(,(#{ALL_VALIDATIONS.join("|")}))*$/
409
+ }
410
+ }
385
411
  end
386
412
 
387
- def self.all_options_by_key_name
388
- all_options.each_with_object({}) do |option, result|
389
- option[:params][:aliases]&.each { |current_alias| result[current_alias.to_s] = option }
390
- result["--#{option[:name]}"] = option
391
- end
413
+ def self.skip_post_creation_hook_option(required: false)
414
+ {
415
+ name: :skip_post_creation_hook,
416
+ params: {
417
+ desc: "Skips post-creation hook",
418
+ type: :boolean,
419
+ required: required
420
+ }
421
+ }
392
422
  end
393
423
 
394
- def latest_image_from(items, app_name: config.app, name_only: true)
395
- matching_items = items.select { |item| item["name"].start_with?("#{app_name}:") }
396
-
397
- # Or special string to indicate no image available
398
- if matching_items.empty?
399
- name_only ? "#{app_name}:#{NO_IMAGE_AVAILABLE}" : nil
400
- else
401
- latest_item = matching_items.max_by { |item| extract_image_number(item["name"]) }
402
- name_only ? latest_item["name"] : latest_item
403
- end
424
+ def self.skip_pre_deletion_hook_option(required: false)
425
+ {
426
+ name: :skip_pre_deletion_hook,
427
+ params: {
428
+ desc: "Skips pre-deletion hook",
429
+ type: :boolean,
430
+ required: required
431
+ }
432
+ }
404
433
  end
405
434
 
406
- def latest_image(app = config.app, org = config.org, refresh: false)
407
- @latest_image ||= {}
408
- @latest_image[app] = nil if refresh
409
- @latest_image[app] ||=
410
- begin
411
- items = cp.query_images(app, org)["items"]
412
- latest_image_from(items, app_name: app)
413
- end
435
+ def self.add_app_identity_option(required: false)
436
+ {
437
+ name: :add_app_identity,
438
+ params: {
439
+ desc: "Adds app identity template if it does not exist",
440
+ type: :boolean,
441
+ required: required
442
+ }
443
+ }
414
444
  end
445
+ # rubocop:enable Metrics/MethodLength
415
446
 
416
- def latest_image_next(app = config.app, org = config.org, commit: nil)
417
- # debugger
418
- commit ||= config.options[:commit]
419
-
420
- @latest_image_next ||= {}
421
- @latest_image_next[app] ||= begin
422
- latest_image_name = latest_image(app, org)
423
- image = latest_image_name.split(":").first
424
- image += ":#{extract_image_number(latest_image_name) + 1}"
425
- image += "_#{commit}" if commit
426
- image
427
- end
447
+ def self.all_options
448
+ methods.grep(/_option$/).map { |method| send(method.to_s) }
428
449
  end
429
450
 
430
- def extract_image_commit(image_name)
431
- image_name.match(/_(\h+)$/)&.captures&.first
451
+ def self.all_options_by_key_name
452
+ all_options.each_with_object({}) do |option, result|
453
+ option[:params][:aliases]&.each { |current_alias| result[current_alias.to_s] = option }
454
+ result["--#{option[:name]}"] = option
455
+ end
432
456
  end
433
457
 
434
458
  # NOTE: use simplified variant atm, as shelljoin do different escaping
@@ -486,30 +510,6 @@ module Command
486
510
  @cp ||= Controlplane.new(config)
487
511
  end
488
512
 
489
- def app_location_link
490
- "/org/#{config.org}/location/#{config.location}"
491
- end
492
-
493
- def app_image_link
494
- "/org/#{config.org}/image/#{latest_image}"
495
- end
496
-
497
- def app_identity
498
- "#{config.app}-identity"
499
- end
500
-
501
- def app_identity_link
502
- "/org/#{config.org}/gvc/#{config.app}/identity/#{app_identity}"
503
- end
504
-
505
- def app_secrets
506
- config.current[:secrets_name] || "#{config.app_prefix}-secrets"
507
- end
508
-
509
- def app_secrets_policy
510
- config.current[:secrets_policy_name] || "#{app_secrets}-policy"
511
- end
512
-
513
513
  def ensure_docker_running!
514
514
  result = Shell.cmd("docker", "version", capture_stderr: true)
515
515
  return if result[:success]
@@ -517,13 +517,24 @@ module Command
517
517
  raise "Can't run Docker. Please make sure that it's installed and started, then try again."
518
518
  end
519
519
 
520
- private
520
+ def run_command_in_latest_image(command, title:)
521
+ # Need to prefix the command with '.controlplane/'
522
+ # if it's a file in the '.controlplane' directory,
523
+ # for backwards compatibility
524
+ path = Pathname.new("#{config.app_cpln_dir}/#{command}").expand_path
525
+ command = ".controlplane/#{command}" if File.exist?(path)
526
+
527
+ progress.puts("Running #{title}...\n\n")
521
528
 
522
- # returns 0 if no prior image
523
- def extract_image_number(image_name)
524
- return 0 if image_name.end_with?(NO_IMAGE_AVAILABLE)
529
+ begin
530
+ Cpl::Cli.start(["run", "-a", config.app, "--image", "latest", "--", command])
531
+ rescue SystemExit => e
532
+ progress.puts
525
533
 
526
- image_name.match(/:(\d+)/)&.captures&.first.to_i
534
+ raise "Failed to run #{title}." if e.status.nonzero?
535
+
536
+ progress.puts("Finished running #{title}.\n\n")
537
+ end
527
538
  end
528
539
  end
529
540
  end
@@ -27,7 +27,7 @@ module Command
27
27
 
28
28
  progress.puts("Building image from Dockerfile '#{dockerfile}'...\n\n")
29
29
 
30
- image_name = latest_image_next
30
+ image_name = cp.latest_image_next
31
31
  image_url = "#{config.org}.registry.cpln.io/#{image_name}"
32
32
 
33
33
  commit = config.options[:commit]
@@ -41,7 +41,7 @@ module Command
41
41
  progress.puts("\nPushed image to '/org/#{config.org}/image/#{image_name}'.\n\n")
42
42
 
43
43
  step("Waiting for image to be available", retry_on_failure: true) do
44
- image_name == latest_image(refresh: true)
44
+ image_name == cp.latest_image(refresh: true)
45
45
  end
46
46
  end
47
47
  end
@@ -56,7 +56,7 @@ module Command
56
56
  return images unless cp.fetch_gvc(app)
57
57
 
58
58
  # If app exists, remove latest image, because we don't want to delete the image that is currently deployed
59
- latest_image_name = latest_image_from(images, app_name: app)
59
+ latest_image_name = cp.latest_image_from(images, app_name: app)
60
60
  images.reject { |image| image["name"] == latest_image_name }
61
61
  end
62
62
 
@@ -49,7 +49,7 @@ module Command
49
49
  app_name = gvc["name"]
50
50
 
51
51
  images = cp.query_images(app_name)["items"].select { |item| item["name"].start_with?("#{app_name}:") }
52
- image = latest_image_from(images, app_name: app_name, name_only: false)
52
+ image = cp.latest_image_from(images, app_name: app_name, name_only: false)
53
53
  next unless image
54
54
 
55
55
  created_date = DateTime.parse(image["created"])
@@ -66,8 +66,8 @@ module Command
66
66
  step("Fetching upstream image URL") do
67
67
  cp.profile_switch(@upstream_profile)
68
68
  upstream_image = config.options[:image]
69
- upstream_image = latest_image(@upstream, @upstream_org) if !upstream_image || upstream_image == "latest"
70
- @commit = extract_image_commit(upstream_image)
69
+ upstream_image = cp.latest_image(@upstream, @upstream_org) if !upstream_image || upstream_image == "latest"
70
+ @commit = cp.extract_image_commit(upstream_image)
71
71
  @upstream_image_url = "#{@upstream_org}.registry.cpln.io/#{upstream_image}"
72
72
  end
73
73
  end
@@ -75,7 +75,7 @@ module Command
75
75
  def fetch_app_image_url
76
76
  step("Fetching app image URL") do
77
77
  cp.profile_switch("default")
78
- app_image = latest_image_next(config.app, config.org, commit: @commit)
78
+ app_image = cp.latest_image_next(config.app, config.org, commit: @commit)
79
79
  @app_image_url = "#{config.org}.registry.cpln.io/#{app_image}"
80
80
  end
81
81
  end
@@ -6,13 +6,17 @@ module Command
6
6
  OPTIONS = [
7
7
  app_option(required: true),
8
8
  workload_option,
9
- skip_confirm_option
9
+ skip_confirm_option,
10
+ skip_pre_deletion_hook_option
10
11
  ].freeze
11
12
  DESCRIPTION = "Deletes the whole app (GVC with all workloads, all volumesets and all images) or a specific workload"
12
13
  LONG_DESCRIPTION = <<~DESC
13
14
  - Deletes the whole app (GVC with all workloads, all volumesets and all images) or a specific workload
14
15
  - Also unbinds the app from the secrets policy, as long as both the identity and the policy exist (and are bound)
15
16
  - Will ask for explicit user confirmation
17
+ - Runs a pre-deletion hook before the app is deleted if `hooks.pre_deletion` is specified in the `.controlplane/controlplane.yml` file
18
+ - If the hook exits with a non-zero code, the command will stop executing and also exit with a non-zero code
19
+ - Use `--skip-pre-deletion-hook` to skip the hook if specified in `controlplane.yml`
16
20
  DESC
17
21
  EXAMPLES = <<~EX
18
22
  ```sh
@@ -51,6 +55,7 @@ module Command
51
55
  check_images
52
56
  return unless confirm_delete(config.app)
53
57
 
58
+ run_pre_deletion_hook unless config.options[:skip_pre_deletion_hook]
54
59
  unbind_identity_from_policy
55
60
  delete_volumesets
56
61
  delete_gvc
@@ -119,19 +124,26 @@ module Command
119
124
  end
120
125
 
121
126
  def unbind_identity_from_policy
122
- return if cp.fetch_identity(app_identity).nil?
127
+ return if cp.fetch_identity(config.identity).nil?
123
128
 
124
- policy = cp.fetch_policy(app_secrets_policy)
129
+ policy = cp.fetch_policy(config.secrets_policy)
125
130
  return if policy.nil?
126
131
 
127
132
  is_bound = policy["bindings"].any? do |binding|
128
- binding["principalLinks"].any? { |link| link == app_identity_link }
133
+ binding["principalLinks"].any? { |link| link == config.identity_link }
129
134
  end
130
135
  return unless is_bound
131
136
 
132
137
  step("Unbinding identity from policy for app '#{config.app}'") do
133
- cp.unbind_identity_from_policy(app_identity_link, app_secrets_policy)
138
+ cp.unbind_identity_from_policy(config.identity_link, config.secrets_policy)
134
139
  end
135
140
  end
141
+
142
+ def run_pre_deletion_hook
143
+ pre_deletion_hook = config.current.dig(:hooks, :pre_deletion)
144
+ return unless pre_deletion_hook
145
+
146
+ run_command_in_latest_image(pre_deletion_hook, title: "pre-deletion hook")
147
+ end
136
148
  end
137
149
  end