cpl 2.0.1 → 2.1.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.
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