cpl 2.0.2 → 2.1.0

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -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"]
@@ -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