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.
@@ -10,9 +10,9 @@ module Command
10
10
  DESCRIPTION = "Deploys the latest image to app workloads, and runs a release script (optional)"
11
11
  LONG_DESCRIPTION = <<~DESC
12
12
  - Deploys the latest image to app workloads
13
- - Optionally runs a release script before deploying if specified through `release_script` in the `.controlplane/controlplane.yml` file and `--run-release-phase` is provided
13
+ - Runs a release script before deploying if `release_script` is specified in the `.controlplane/controlplane.yml` file and `--run-release-phase` is provided
14
14
  - The release script is run in the context of `cpl run` with the latest image
15
- - The deploy will fail if the release script exits with a non-zero code or doesn't exist
15
+ - If the release script exits with a non-zero code, the command will stop executing and also exit with a non-zero code
16
16
  DESC
17
17
 
18
18
  def call # rubocop:disable Metrics/MethodLength
@@ -20,7 +20,7 @@ module Command
20
20
 
21
21
  deployed_endpoints = {}
22
22
 
23
- image = latest_image
23
+ image = cp.latest_image
24
24
  if cp.fetch_image_details(image).nil?
25
25
  raise "Image '#{image}' does not exist in the Docker repository on Control Plane " \
26
26
  "(see https://console.cpln.io/console/org/#{config.org}/repository/#{config.app}). " \
@@ -48,24 +48,9 @@ module Command
48
48
 
49
49
  private
50
50
 
51
- def run_release_script # rubocop:disable Metrics/MethodLength
52
- release_script_name = config[:release_script]
53
- release_script_path = Pathname.new("#{config.app_cpln_dir}/#{release_script_name}").expand_path
54
-
55
- raise "Can't find release script in '#{release_script_path}'." unless File.exist?(release_script_path)
56
-
57
- progress.puts("Running release script...\n\n")
58
-
59
- release_script = File.read(release_script_path)
60
- begin
61
- Cpl::Cli.start(["run", "-a", config.app, "--image", "latest", "--", release_script])
62
- rescue SystemExit => e
63
- progress.puts
64
-
65
- raise "Failed to run release script." if e.status.nonzero?
66
-
67
- progress.puts("Finished running release script.\n\n")
68
- end
51
+ def run_release_script
52
+ release_script = config[:release_script]
53
+ run_command_in_latest_image(release_script, title: "release script")
69
54
  end
70
55
  end
71
56
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Command
4
+ class Doctor < Base
5
+ NAME = "doctor"
6
+ OPTIONS = [
7
+ validations_option,
8
+ app_option
9
+ ].freeze
10
+ DESCRIPTION = "Runs validations"
11
+ LONG_DESCRIPTION = <<~DESC
12
+ - Runs validations
13
+ DESC
14
+ EXAMPLES = <<~EX
15
+ ```sh
16
+ # Runs all validations that don't require additional options by default.
17
+ cpl doctor
18
+
19
+ # Runs config validation.
20
+ cpl doctor --validations config
21
+
22
+ # Runs templates validation (requires app).
23
+ cpl doctor --validations templates -a $APP_NAME
24
+ ```
25
+ EX
26
+ VALIDATIONS = [].freeze
27
+
28
+ def call
29
+ validations = config.options[:validations].split(",")
30
+ ensure_required_options!(validations)
31
+
32
+ doctor_service = DoctorService.new(config)
33
+ doctor_service.run_validations(validations)
34
+ end
35
+
36
+ private
37
+
38
+ def ensure_required_options!(validations)
39
+ validations.each do |validation|
40
+ case validation
41
+ when "templates"
42
+ raise "App is required for templates validation." unless config.app
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -13,7 +13,7 @@ module Command
13
13
  WITH_INFO_HEADER = false
14
14
 
15
15
  def call
16
- puts latest_image
16
+ puts cp.latest_image
17
17
  end
18
18
  end
19
19
  end
@@ -10,6 +10,7 @@ module Command
10
10
  DESC
11
11
  HIDE = true
12
12
  WITH_INFO_HEADER = false
13
+ VALIDATIONS = [].freeze
13
14
 
14
15
  def call
15
16
  if config.options[:version]
@@ -14,7 +14,7 @@ module Command
14
14
  - Runs `cpl copy-image-from-upstream` to copy the latest image from upstream
15
15
  - Runs `cpl deploy-image` to deploy the image
16
16
  - If `.controlplane/controlplane.yml` includes the `release_script`, `cpl deploy-image` will use the `--run-release-phase` option
17
- - The deploy will fail if the release script exits with a non-zero code
17
+ - If the release script exits with a non-zero code, the command will stop executing and also exit with a non-zero code
18
18
  DESC
19
19
 
20
20
  def call
data/lib/command/run.rb CHANGED
@@ -183,7 +183,7 @@ module Command
183
183
  # Override image if specified
184
184
  image = config.options[:image]
185
185
  image_link = if image
186
- image = latest_image if image == "latest"
186
+ image = cp.latest_image if image == "latest"
187
187
  "/org/#{config.org}/image/#{image}"
188
188
  else
189
189
  original_container_spec["image"]
@@ -395,7 +395,7 @@ module Command
395
395
  script += interactive_runner_script if interactive
396
396
 
397
397
  script +=
398
- if @log_method == 1
398
+ if @log_method == 1 || @interactive
399
399
  args_join(config.args)
400
400
  else
401
401
  <<~SCRIPT
@@ -442,16 +442,22 @@ module Command
442
442
  )
443
443
  end
444
444
 
445
- def resolve_job_status
446
- result = cp.fetch_cron_workload(runner_workload, location: location)
447
- job_details = result&.dig("items")&.find { |item| item["id"] == job }
448
- status = job_details&.dig("status")
445
+ def resolve_job_status # rubocop:disable Metrics/MethodLength
446
+ loop do
447
+ result = cp.fetch_cron_workload(runner_workload, location: location)
448
+ job_details = result&.dig("items")&.find { |item| item["id"] == job }
449
+ status = job_details&.dig("status")
450
+
451
+ Shell.debug("JOB STATUS", status)
449
452
 
450
- case status
451
- when "failed"
452
- ExitCode::ERROR_DEFAULT
453
- when "successful"
454
- ExitCode::SUCCESS
453
+ case status
454
+ when "active"
455
+ sleep 1
456
+ when "successful"
457
+ break ExitCode::SUCCESS
458
+ else
459
+ break ExitCode::ERROR_DEFAULT
460
+ end
455
461
  end
456
462
  end
457
463
 
@@ -5,18 +5,27 @@ module Command
5
5
  NAME = "setup-app"
6
6
  OPTIONS = [
7
7
  app_option(required: true),
8
- skip_secret_access_binding_option
8
+ skip_secret_access_binding_option,
9
+ skip_secrets_setup_option,
10
+ skip_post_creation_hook_option
9
11
  ].freeze
10
12
  DESCRIPTION = "Creates an app and all its workloads"
11
13
  LONG_DESCRIPTION = <<~DESC
12
14
  - Creates an app and all its workloads
13
15
  - Specify the templates for the app and workloads through `setup_app_templates` in the `.controlplane/controlplane.yml` file
14
- - This should only be used for temporary apps like review apps, never for persistent apps like production (to update workloads for those, use 'cpl apply-template' instead)
15
- - Automatically binds the app to the secrets policy, as long as both the identity and the policy exist
16
- - Use `--skip-secret-access-binding` to prevent the automatic bind
16
+ - This should only be used for temporary apps like review apps, never for persistent apps like production or staging (to update workloads for those, use 'cpl apply-template' instead)
17
+ - Configures app to have org-level secrets with default name "{APP_PREFIX}-secrets"
18
+ using org-level policy with default name "{APP_PREFIX}-secrets-policy" (names can be customized, see docs)
19
+ - Creates identity for secrets if it does not exist
20
+ - Use `--skip-secrets-setup` to prevent the automatic setup of secrets,
21
+ or set it through `skip_secrets_setup` in the `.controlplane/controlplane.yml` file
22
+ - Runs a post-creation hook after the app is created if `hooks.post_creation` is specified in the `.controlplane/controlplane.yml` file
23
+ - If the hook exits with a non-zero code, the command will stop executing and also exit with a non-zero code
24
+ - Use `--skip-post-creation-hook` to skip the hook if specified in `controlplane.yml`
17
25
  DESC
26
+ VALIDATIONS = %w[config templates].freeze
18
27
 
19
- def call # rubocop:disable Metrics/MethodLength
28
+ def call # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
20
29
  templates = config[:setup_app_templates]
21
30
 
22
31
  app = cp.fetch_gvc
@@ -26,24 +35,79 @@ module Command
26
35
  "or run 'cpl apply-template #{templates.join(' ')} -a #{config.app}'."
27
36
  end
28
37
 
29
- Cpl::Cli.start(["apply-template", *templates, "-a", config.app])
38
+ skip_secrets_setup = config.options[:skip_secret_access_binding] ||
39
+ config.options[:skip_secrets_setup] || config.current[:skip_secrets_setup]
30
40
 
31
- return if config.options[:skip_secret_access_binding]
41
+ create_secret_and_policy_if_not_exist unless skip_secrets_setup
42
+
43
+ args = []
44
+ args.push("--add-app-identity") unless skip_secrets_setup
45
+ Cpl::Cli.start(["apply-template", *templates, "-a", config.app, *args])
46
+
47
+ bind_identity_to_policy unless skip_secrets_setup
48
+ run_post_creation_hook unless config.options[:skip_post_creation_hook]
49
+ end
50
+
51
+ private
52
+
53
+ def create_secret_and_policy_if_not_exist
54
+ create_secret_if_not_exists
55
+ create_policy_if_not_exists
32
56
 
33
57
  progress.puts
58
+ end
34
59
 
35
- if cp.fetch_identity(app_identity).nil? || cp.fetch_policy(app_secrets_policy).nil?
36
- raise "Can't bind identity to policy: identity '#{app_identity}' or " \
37
- "policy '#{app_secrets_policy}' doesn't exist. " \
38
- "Please create them or use `--skip-secret-access-binding` to ignore this message." \
39
- "You can also set a custom secrets name with `secrets_name` " \
40
- "and a custom secrets policy name with `secrets_policy_name` " \
41
- "in the `.controlplane/controlplane.yml` file."
60
+ def create_secret_if_not_exists
61
+ if cp.fetch_secret(config.secrets)
62
+ progress.puts("Secret '#{config.secrets}' already exists. Skipping creation...")
63
+ else
64
+ step("Creating secret '#{config.secrets}'") do
65
+ cp.apply_hash(build_secret_hash)
66
+ end
42
67
  end
68
+ end
43
69
 
44
- step("Binding identity to policy") do
45
- cp.bind_identity_to_policy(app_identity_link, app_secrets_policy)
70
+ def create_policy_if_not_exists
71
+ if cp.fetch_policy(config.secrets_policy)
72
+ progress.puts("Policy '#{config.secrets_policy}' already exists. Skipping creation...")
73
+ else
74
+ step("Creating policy '#{config.secrets_policy}'") do
75
+ cp.apply_hash(build_policy_hash)
76
+ end
46
77
  end
47
78
  end
79
+
80
+ def build_secret_hash
81
+ {
82
+ "kind" => "secret",
83
+ "name" => config.secrets,
84
+ "type" => "dictionary",
85
+ "data" => {}
86
+ }
87
+ end
88
+
89
+ def build_policy_hash
90
+ {
91
+ "kind" => "policy",
92
+ "name" => config.secrets_policy,
93
+ "targetKind" => "secret",
94
+ "targetLinks" => ["//secret/#{config.secrets}"]
95
+ }
96
+ end
97
+
98
+ def bind_identity_to_policy
99
+ progress.puts
100
+
101
+ step("Binding identity '#{config.identity}' to policy '#{config.secrets_policy}'") do
102
+ cp.bind_identity_to_policy(config.identity_link, config.secrets_policy)
103
+ end
104
+ end
105
+
106
+ def run_post_creation_hook
107
+ post_creation_hook = config.current.dig(:hooks, :post_creation)
108
+ return unless post_creation_hook
109
+
110
+ run_command_in_latest_image(post_creation_hook, title: "post-creation hook")
111
+ end
48
112
  end
49
113
  end
data/lib/command/test.rb CHANGED
@@ -11,6 +11,7 @@ module Command
11
11
  - For debugging purposes
12
12
  DESC
13
13
  HIDE = true
14
+ VALIDATIONS = [].freeze
14
15
 
15
16
  def call
16
17
  # Modify this method to trigger the code you want to test.
@@ -9,6 +9,7 @@ module Command
9
9
  - Can also be done with `cpl --version` or `cpl -v`
10
10
  DESC
11
11
  WITH_INFO_HEADER = false
12
+ VALIDATIONS = [].freeze
12
13
 
13
14
  def call
14
15
  puts Cpl::VERSION
data/lib/core/config.rb CHANGED
@@ -18,6 +18,8 @@ class Config # rubocop:disable Metrics/ClassLength
18
18
 
19
19
  ensure_required_options!
20
20
 
21
+ warn_deprecated_options
22
+
21
23
  Shell.verbose_mode(options[:verbose])
22
24
  trace_mode = options[:trace]
23
25
  return unless trace_mode
@@ -38,10 +40,34 @@ class Config # rubocop:disable Metrics/ClassLength
38
40
  current&.fetch(:name)
39
41
  end
40
42
 
43
+ def identity
44
+ "#{app}-identity"
45
+ end
46
+
47
+ def identity_link
48
+ "/org/#{org}/gvc/#{app}/identity/#{identity}"
49
+ end
50
+
51
+ def secrets
52
+ current&.dig(:secrets_name) || "#{app_prefix}-secrets"
53
+ end
54
+
55
+ def secrets_policy
56
+ current&.dig(:secrets_policy_name) || "#{secrets}-policy"
57
+ end
58
+
41
59
  def location
42
60
  @location ||= load_location_from_options || load_location_from_env || load_location_from_file
43
61
  end
44
62
 
63
+ def location_link
64
+ "/org/#{org}/location/#{location}"
65
+ end
66
+
67
+ def image_link(image)
68
+ "/org/#{org}/image/#{image}"
69
+ end
70
+
45
71
  def domain
46
72
  @domain ||= load_domain_from_options || load_domain_from_file
47
73
  end
@@ -84,6 +110,8 @@ class Config # rubocop:disable Metrics/ClassLength
84
110
  @apps ||= config[:apps].to_h do |app_name, app_options|
85
111
  ensure_config_app!(app_name, app_options)
86
112
 
113
+ check_deprecated_options(app_options)
114
+
87
115
  app_options_with_new_keys = app_options.to_h do |key, value|
88
116
  new_key = new_option_keys[key]
89
117
  new_key ? [new_key, value] : [key, value]
@@ -96,14 +124,7 @@ class Config # rubocop:disable Metrics/ClassLength
96
124
  def current
97
125
  return unless app
98
126
 
99
- @current ||= begin
100
- app_config = find_app_config(app)
101
- ensure_config_app!(app, app_config)
102
-
103
- warn_deprecated_options(app_config)
104
-
105
- app_config
106
- end
127
+ @current ||= find_app_config(app)
107
128
  end
108
129
 
109
130
  def app_matches?(app_name1, app_name2, app_options)
@@ -275,11 +296,18 @@ class Config # rubocop:disable Metrics/ClassLength
275
296
  strip_str_and_validate(current.fetch(:default_domain))
276
297
  end
277
298
 
278
- def warn_deprecated_options(app_options)
279
- deprecated_option_keys = new_option_keys.select { |old_key| app_options.key?(old_key) }
280
- return if deprecated_option_keys.empty?
299
+ def check_deprecated_options(app_options)
300
+ @deprecated_option_keys ||= {}
301
+
302
+ new_option_keys.each do |old_key, new_key|
303
+ @deprecated_option_keys[old_key] = new_key if app_options.key?(old_key)
304
+ end
305
+ end
306
+
307
+ def warn_deprecated_options
308
+ return if !@deprecated_option_keys || @deprecated_option_keys.empty?
281
309
 
282
- deprecated_option_keys.each do |old_key, new_key|
310
+ @deprecated_option_keys.each do |old_key, new_key|
283
311
  Shell.warn_deprecated("Option '#{old_key}' is deprecated, " \
284
312
  "please use '#{new_key}' instead (in 'controlplane.yml').")
285
313
  end
@@ -3,6 +3,8 @@
3
3
  class Controlplane # rubocop:disable Metrics/ClassLength
4
4
  attr_reader :config, :api, :gvc, :org
5
5
 
6
+ NO_IMAGE_AVAILABLE = "NO_IMAGE_AVAILABLE"
7
+
6
8
  def initialize(config)
7
9
  @config = config
8
10
  @api = ControlplaneApi.new
@@ -37,6 +39,51 @@ class Controlplane # rubocop:disable Metrics/ClassLength
37
39
 
38
40
  # image
39
41
 
42
+ def latest_image(a_gvc = gvc, a_org = org, refresh: false)
43
+ @latest_image ||= {}
44
+ @latest_image[a_gvc] = nil if refresh
45
+ @latest_image[a_gvc] ||=
46
+ begin
47
+ items = query_images(a_gvc, a_org)["items"]
48
+ latest_image_from(items, app_name: a_gvc)
49
+ end
50
+ end
51
+
52
+ def latest_image_next(a_gvc = gvc, a_org = org, commit: nil)
53
+ commit ||= config.options[:commit]
54
+
55
+ @latest_image_next ||= {}
56
+ @latest_image_next[a_gvc] ||= begin
57
+ latest_image_name = latest_image(a_gvc, a_org)
58
+ image = latest_image_name.split(":").first
59
+ image += ":#{extract_image_number(latest_image_name) + 1}"
60
+ image += "_#{commit}" if commit
61
+ image
62
+ end
63
+ end
64
+
65
+ def latest_image_from(items, app_name: gvc, name_only: true)
66
+ matching_items = items.select { |item| item["name"].start_with?("#{app_name}:") }
67
+
68
+ # Or special string to indicate no image available
69
+ if matching_items.empty?
70
+ name_only ? "#{app_name}:#{NO_IMAGE_AVAILABLE}" : nil
71
+ else
72
+ latest_item = matching_items.max_by { |item| extract_image_number(item["name"]) }
73
+ name_only ? latest_item["name"] : latest_item
74
+ end
75
+ end
76
+
77
+ def extract_image_number(image_name)
78
+ return 0 if image_name.end_with?(NO_IMAGE_AVAILABLE)
79
+
80
+ image_name.match(/:(\d+)/)&.captures&.first.to_i
81
+ end
82
+
83
+ def extract_image_commit(image_name)
84
+ image_name.match(/_(\h+)$/)&.captures&.first
85
+ end
86
+
40
87
  def query_images(a_gvc = gvc, a_org = org, partial_gvc_match: nil)
41
88
  partial_gvc_match = config.should_app_start_with?(a_gvc) if partial_gvc_match.nil?
42
89
  gvc_op = partial_gvc_match ? "~" : "="
@@ -324,6 +371,12 @@ class Controlplane # rubocop:disable Metrics/ClassLength
324
371
  api.log_get(org: org, gvc: gvc, workload: workload, replica: replica, from: from, to: to)
325
372
  end
326
373
 
374
+ # secrets
375
+
376
+ def fetch_secret(secret)
377
+ api.fetch_secret(org: org, secret: secret)
378
+ end
379
+
327
380
  # identities
328
381
 
329
382
  def fetch_identity(identity, a_gvc = gvc)
@@ -52,7 +52,7 @@ class ControlplaneApi # rubocop:disable Metrics/ClassLength
52
52
  # params << "direction=forward"
53
53
  params = params.map { |k, v| %(#{k}=#{CGI.escape(v)}) }.join("&")
54
54
 
55
- api_json_direct("/logs/org/#{org}/loki/api/v1/query_range?#{params}", method: :get, host: :logs)
55
+ api_json("/logs/org/#{org}/loki/api/v1/query_range?#{params}", method: :get, host: :logs)
56
56
  end
57
57
 
58
58
  def query_workloads(org:, gvc:, workload:, gvc_op_type:, workload_op_type:) # rubocop:disable Metrics/MethodLength
@@ -116,6 +116,14 @@ class ControlplaneApi # rubocop:disable Metrics/ClassLength
116
116
  api_json("/org/#{org}/domain/#{domain}", method: :patch, body: data)
117
117
  end
118
118
 
119
+ def fetch_secret(org:, secret:)
120
+ api_json("/org/#{org}/secret/#{secret}", method: :get)
121
+ end
122
+
123
+ def delete_secret(org:, secret:)
124
+ api_json("/org/#{org}/secret/#{secret}", method: :delete)
125
+ end
126
+
119
127
  def fetch_identity(org:, gvc:, identity:)
120
128
  api_json("/org/#{org}/gvc/#{gvc}/identity/#{identity}", method: :get)
121
129
  end
@@ -124,6 +132,10 @@ class ControlplaneApi # rubocop:disable Metrics/ClassLength
124
132
  api_json("/org/#{org}/policy/#{policy}", method: :get)
125
133
  end
126
134
 
135
+ def delete_policy(org:, policy:)
136
+ api_json("/org/#{org}/policy/#{policy}", method: :delete)
137
+ end
138
+
127
139
  private
128
140
 
129
141
  def fetch_query_pages(result)
@@ -152,13 +164,7 @@ class ControlplaneApi # rubocop:disable Metrics/ClassLength
152
164
  result
153
165
  end
154
166
 
155
- # switch between cpln rest and api
156
167
  def api_json(...)
157
168
  ControlplaneApiDirect.new.call(...)
158
169
  end
159
-
160
- # only for api (where not impelemented in cpln rest)
161
- def api_json_direct(...)
162
- ControlplaneApiDirect.new.call(...)
163
- end
164
170
  end
@@ -98,7 +98,7 @@ class ControlplaneApiDirect
98
98
  end
99
99
 
100
100
  def refresh_api_token
101
- @@api_token[:token] = `cpln profile token`.chomp
101
+ @@api_token[:token] = Shell.cmd("cpln", "profile", "token")[:output].chomp
102
102
  end
103
103
 
104
104
  def self.reset_api_token
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ValidationError < StandardError; end
4
+
5
+ class DoctorService
6
+ attr_reader :config
7
+
8
+ def initialize(config)
9
+ @config = config
10
+ end
11
+
12
+ def run_validations(validations, silent_if_passing: false) # rubocop:disable Metrics/MethodLength
13
+ @any_failed_validation = false
14
+
15
+ validations.each do |validation|
16
+ case validation
17
+ when "config"
18
+ validate_config
19
+ when "templates"
20
+ validate_templates
21
+ else
22
+ raise ValidationError, Shell.color("ERROR: Invalid validation '#{validation}'.", :red)
23
+ end
24
+
25
+ progress.puts("#{Shell.color('[PASS]', :green)} #{validation}") unless silent_if_passing
26
+ rescue ValidationError => e
27
+ @any_failed_validation = true
28
+
29
+ progress.puts("#{Shell.color('[FAIL]', :red)} #{validation}\n\n#{e.message}\n\n")
30
+ end
31
+
32
+ exit(ExitCode::ERROR_DEFAULT) if @any_failed_validation
33
+ end
34
+
35
+ def validate_config
36
+ check_for_app_names_contained_in_others
37
+ end
38
+
39
+ def validate_templates
40
+ @template_parser = TemplateParser.new(config)
41
+ filenames = Dir.glob("#{@template_parser.template_dir}/*.yml")
42
+ templates = @template_parser.parse(filenames)
43
+
44
+ check_for_duplicate_templates(templates)
45
+ warn_deprecated_template_variables
46
+ end
47
+
48
+ private
49
+
50
+ def check_for_app_names_contained_in_others
51
+ app_names_contained_in_others = find_app_names_contained_in_others
52
+ return if app_names_contained_in_others.empty?
53
+
54
+ message = "App names contained in others found below. Please ensure that app names are unique."
55
+ list = app_names_contained_in_others
56
+ .map { |app_prefix, app_name| " - '#{app_prefix}' is a prefix of '#{app_name}'" }
57
+ .join("\n")
58
+ raise ValidationError, "#{Shell.color("ERROR: #{message}", :red)}\n#{list}"
59
+ end
60
+
61
+ def find_app_names_contained_in_others # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
62
+ app_names = config.apps.keys.map(&:to_s).sort
63
+ app_prefixes = config.apps
64
+ .select { |_, app_options| app_options[:match_if_app_name_starts_with] }
65
+ .keys
66
+ .map(&:to_s)
67
+ .sort
68
+ app_prefixes.each_with_object([]) do |app_prefix, app_names_contained_in_others|
69
+ app_names.each do |app_name|
70
+ if app_prefix != app_name && app_name.start_with?(app_prefix)
71
+ app_names_contained_in_others.push([app_prefix, app_name])
72
+ end
73
+ end
74
+ end
75
+ end
76
+
77
+ def check_for_duplicate_templates(templates)
78
+ grouped_templates = templates.group_by { |template| [template["kind"], template["name"]] }
79
+ duplicate_templates = grouped_templates.select { |_, group| group.size > 1 }
80
+ return if duplicate_templates.empty?
81
+
82
+ message = "Duplicate templates found with the kind/names below. Please ensure that templates are unique."
83
+ list = duplicate_templates
84
+ .map { |(kind, name), _| " - kind: #{kind}, name: #{name}" }
85
+ .join("\n")
86
+ raise ValidationError, "#{Shell.color("ERROR: #{message}", :red)}\n#{list}"
87
+ end
88
+
89
+ def warn_deprecated_template_variables
90
+ deprecated_variables = @template_parser.deprecated_variables
91
+ return if deprecated_variables.empty?
92
+
93
+ message = "Please replace these variables in the templates, " \
94
+ "as support for them will be removed in a future major version bump:"
95
+ list = deprecated_variables
96
+ .map { |old_key, new_key| " - #{old_key} -> #{new_key}" }
97
+ .join("\n")
98
+ progress.puts("\n#{Shell.color("DEPRECATED: #{message}", :yellow)}\n#{list}\n\n")
99
+ end
100
+
101
+ def progress
102
+ $stderr
103
+ end
104
+ end
data/lib/core/helpers.rb CHANGED
@@ -3,6 +3,8 @@
3
3
  require "securerandom"
4
4
 
5
5
  module Helpers
6
+ module_function
7
+
6
8
  def strip_str_and_validate(str)
7
9
  return str if str.nil?
8
10
 
@@ -13,4 +15,12 @@ module Helpers
13
15
  def random_four_digits
14
16
  SecureRandom.random_number(1000..9999)
15
17
  end
18
+
19
+ def normalize_command_name(name)
20
+ name.to_s.tr("_", "-")
21
+ end
22
+
23
+ def normalize_option_name(name)
24
+ "--#{name.to_s.tr('_', '-')}"
25
+ end
16
26
  end
data/lib/core/shell.rb CHANGED
@@ -90,4 +90,11 @@ class Shell
90
90
 
91
91
  message.gsub(pattern, "XXXXXXX")
92
92
  end
93
+
94
+ def self.trap_interrupt
95
+ trap("SIGINT") do
96
+ puts
97
+ exit(ExitCode::INTERRUPT)
98
+ end
99
+ end
93
100
  end