cpflow 4.1.0 → 4.2.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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/claude-code-review.yml +44 -0
  3. data/.github/workflows/claude.yml +50 -0
  4. data/.gitignore +6 -0
  5. data/CHANGELOG.md +17 -1
  6. data/Gemfile.lock +2 -2
  7. data/README.md +17 -14
  8. data/docs/ci-automation.md +28 -0
  9. data/docs/commands.md +21 -1
  10. data/docs/terraform/details.md +415 -0
  11. data/docs/terraform/example/.controlplane/controlplane.yml +29 -0
  12. data/docs/terraform/example/.controlplane/templates/app.yml +38 -0
  13. data/docs/terraform/example/.controlplane/templates/postgres.yml +30 -0
  14. data/docs/terraform/example/.controlplane/templates/rails.yml +26 -0
  15. data/docs/terraform/overview.md +105 -0
  16. data/lib/command/base.rb +29 -5
  17. data/lib/command/base_sub_command.rb +15 -0
  18. data/lib/command/generate.rb +1 -1
  19. data/lib/command/ps.rb +1 -1
  20. data/lib/command/ps_stop.rb +2 -1
  21. data/lib/command/ps_wait.rb +5 -1
  22. data/lib/command/run.rb +4 -21
  23. data/lib/command/terraform/base.rb +35 -0
  24. data/lib/command/terraform/generate.rb +99 -0
  25. data/lib/command/terraform/import.rb +79 -0
  26. data/lib/core/config.rb +1 -1
  27. data/lib/core/controlplane.rb +7 -6
  28. data/lib/core/controlplane_api_direct.rb +23 -1
  29. data/lib/core/shell.rb +9 -4
  30. data/lib/core/terraform_config/agent.rb +31 -0
  31. data/lib/core/terraform_config/audit_context.rb +31 -0
  32. data/lib/core/terraform_config/base.rb +25 -0
  33. data/lib/core/terraform_config/dsl.rb +102 -0
  34. data/lib/core/terraform_config/generator.rb +184 -0
  35. data/lib/core/terraform_config/gvc.rb +63 -0
  36. data/lib/core/terraform_config/identity.rb +35 -0
  37. data/lib/core/terraform_config/local_variable.rb +30 -0
  38. data/lib/core/terraform_config/policy.rb +151 -0
  39. data/lib/core/terraform_config/provider.rb +22 -0
  40. data/lib/core/terraform_config/required_provider.rb +23 -0
  41. data/lib/core/terraform_config/secret.rb +138 -0
  42. data/lib/core/terraform_config/volume_set.rb +155 -0
  43. data/lib/core/terraform_config/workload/main.tf +316 -0
  44. data/lib/core/terraform_config/workload/required_providers.tf +8 -0
  45. data/lib/core/terraform_config/workload/variables.tf +263 -0
  46. data/lib/core/terraform_config/workload.rb +132 -0
  47. data/lib/cpflow/version.rb +1 -1
  48. data/lib/cpflow.rb +51 -9
  49. data/lib/generator_templates/templates/postgres.yml +1 -1
  50. data/lib/patches/array.rb +8 -0
  51. data/lib/patches/hash.rb +47 -0
  52. data/lib/patches/string.rb +34 -0
  53. data/script/update_command_docs +6 -2
  54. metadata +37 -4
  55. /data/docs/{migrating.md → migrating-heroku-to-control-plane.md} +0 -0
@@ -0,0 +1,263 @@
1
+ variable "containers" {
2
+ type = map(
3
+ object({
4
+ args = optional(list(string))
5
+ command = optional(string)
6
+ cpu = optional(string, "1000m")
7
+ envs = optional(map(string))
8
+ image = string
9
+ inherit_env = optional(bool)
10
+ liveness_probe = optional(
11
+ object({
12
+ exec = optional(
13
+ object({
14
+ command = list(string)
15
+ })
16
+ )
17
+ failure_threshold = optional(number)
18
+ grpc = optional(
19
+ object({
20
+ port = optional(number)
21
+ })
22
+ )
23
+ http_get = optional(
24
+ object({
25
+ http_headers = optional(map(string))
26
+ path = optional(string)
27
+ port = optional(number)
28
+ scheme = optional(string)
29
+ })
30
+ )
31
+ initial_delay_seconds = optional(number)
32
+ period_seconds = optional(number)
33
+ success_threshold = optional(number)
34
+ timeout_seconds = optional(number)
35
+ tcp_socket = optional(
36
+ object({
37
+ port = optional(number)
38
+ })
39
+ )
40
+ })
41
+ )
42
+ memory = optional(string, "2048Mi")
43
+ ports = optional(
44
+ list(
45
+ object({
46
+ number = number
47
+ protocol = string
48
+ })
49
+ ),
50
+ [],
51
+ )
52
+ post_start_command = optional(string)
53
+ pre_stop_command = optional(string)
54
+ readiness_probe = optional(
55
+ object({
56
+ exec = optional(
57
+ object({
58
+ command = list(string)
59
+ })
60
+ )
61
+ failure_threshold = optional(number)
62
+ grpc = optional(
63
+ object({
64
+ port = optional(number)
65
+ })
66
+ )
67
+ http_get = optional(
68
+ object({
69
+ http_headers = optional(map(string))
70
+ path = optional(string)
71
+ port = optional(number)
72
+ scheme = optional(string)
73
+ })
74
+ )
75
+ initial_delay_seconds = optional(number)
76
+ period_seconds = optional(number)
77
+ success_threshold = optional(number)
78
+ timeout_seconds = optional(number)
79
+ tcp_socket = optional(
80
+ object({
81
+ port = optional(number)
82
+ })
83
+ )
84
+ })
85
+ )
86
+ volumes = optional(
87
+ list(
88
+ object({
89
+ path = string
90
+ uri = string
91
+ })
92
+ ),
93
+ [],
94
+ )
95
+ })
96
+ )
97
+ }
98
+
99
+ variable "description" {
100
+ type = string
101
+ default = null
102
+ }
103
+
104
+ variable "firewall_spec" {
105
+ type = object({
106
+ external = optional(
107
+ object({
108
+ inbound_allow_cidr = optional(list(string))
109
+ outbound_allow_hostname = optional(list(string))
110
+ outbound_allow_cidr = optional(list(string))
111
+ outbound_allow_port = optional(
112
+ list(
113
+ object({
114
+ number = number
115
+ protocol = optional(string, "tcp")
116
+ })
117
+ ),
118
+ []
119
+ )
120
+ })
121
+ )
122
+ internal = optional(
123
+ object({
124
+ inbound_allow_type = optional(string)
125
+ inbound_allow_workload = optional(list(string))
126
+ }),
127
+ )
128
+ })
129
+ default = null
130
+ }
131
+
132
+ variable "gvc" {
133
+ type = string
134
+ }
135
+
136
+ variable "identity_link" {
137
+ type = string
138
+ default = null
139
+ }
140
+
141
+ variable "job" {
142
+ type = object({
143
+ active_deadline_seconds = optional(number)
144
+ concurrency_policy = optional(string, "Forbid")
145
+ history_limit = optional(number, 5)
146
+ restart_policy = optional(string, "Never")
147
+ schedule = string
148
+ })
149
+ default = null
150
+ }
151
+
152
+ variable "load_balancer" {
153
+ type = object({
154
+ direct = optional(
155
+ object({
156
+ enabled = bool
157
+ port = optional(
158
+ list(
159
+ object({
160
+ container_port = optional(number)
161
+ external_port = number
162
+ protocol = string
163
+ scheme = optional(string)
164
+ })
165
+ ),
166
+ []
167
+ )
168
+ })
169
+ )
170
+ geo_location = optional(
171
+ object({
172
+ enabled = optional(bool)
173
+ headers = optional(
174
+ object({
175
+ asn = optional(string)
176
+ city = optional(string)
177
+ country = optional(string)
178
+ region = optional(string)
179
+ })
180
+ )
181
+ })
182
+ )
183
+ })
184
+ default = null
185
+ }
186
+
187
+ variable "local_options" {
188
+ type = object({
189
+ autoscaling = optional(
190
+ object({
191
+ metric = optional(string)
192
+ metric_percentile = optional(string)
193
+ target = optional(number)
194
+ max_scale = optional(number)
195
+ min_scale = optional(number)
196
+ scale_to_zero_delay = optional(number)
197
+ max_concurrency = optional(number)
198
+ })
199
+ )
200
+ location = string
201
+ capacity_ai = optional(bool, true)
202
+ debug = optional(bool, false)
203
+ suspend = optional(bool, false)
204
+ timeout_seconds = optional(number, 5)
205
+ })
206
+ default = null
207
+ }
208
+
209
+ variable "name" {
210
+ type = string
211
+ }
212
+
213
+ variable "options" {
214
+ type = object({
215
+ autoscaling = optional(
216
+ object({
217
+ max_concurrency = optional(number)
218
+ max_scale = optional(number)
219
+ metric = optional(string)
220
+ metric_percentile = optional(string)
221
+ min_scale = optional(number)
222
+ scale_to_zero_delay = optional(number)
223
+ target = optional(number)
224
+ })
225
+ )
226
+ capacity_ai = optional(bool, true)
227
+ debug = optional(bool, false)
228
+ suspend = optional(bool, false)
229
+ timeout_seconds = optional(number, 5)
230
+ })
231
+ default = null
232
+ }
233
+
234
+ variable "rollout_options" {
235
+ type = object({
236
+ max_surge_replicas = optional(string)
237
+ max_unavailable_replicas = optional(string)
238
+ min_ready_seconds = optional(number)
239
+ scaling_policy = optional(string, "OrderedReady")
240
+ })
241
+ default = null
242
+ }
243
+
244
+ variable "security_options" {
245
+ type = object({
246
+ file_system_group_id = optional(number)
247
+ })
248
+ default = null
249
+ }
250
+
251
+ variable "support_dynamic_tags" {
252
+ type = bool
253
+ default = false
254
+ }
255
+
256
+ variable "tags" {
257
+ type = map(string)
258
+ default = {}
259
+ }
260
+
261
+ variable "type" {
262
+ type = string
263
+ }
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TerraformConfig
4
+ class Workload < Base # rubocop:disable Metrics/ClassLength
5
+ RAW_ARGS = %i[
6
+ containers options local_options rollout_options security_options
7
+ firewall_spec load_balancer job
8
+ ].freeze
9
+
10
+ OPTIONS_KEYS = %i[autoscaling capacity_ai suspend timeout_seconds].freeze
11
+ LOCAL_OPTIONS_KEYS = (OPTIONS_KEYS + %i[location]).freeze
12
+ ROLLOUT_OPTIONS_KEYS = %i[min_ready_seconds max_unavailable_replicas max_surge_replicas scaling_policy].freeze
13
+ SECURITY_OPTIONS_KEYS = %i[file_system_group_id].freeze
14
+ LOAD_BALANCER_KEYS = %i[direct geo_location].freeze
15
+ FIREWALL_SPEC_KEYS = %i[internal external].freeze
16
+ VOLUME_KEYS = %i[uri path].freeze
17
+ JOB_KEYS = %i[schedule concurrency_policy history_limit restart_policy active_deadline_seconds].freeze
18
+ LIVENESS_PROBE_KEYS = %i[
19
+ exec http_get tcp_socket grpc
20
+ failure_threshold initial_delay_seconds period_seconds success_threshold timeout_seconds
21
+ ].freeze
22
+
23
+ attr_reader :type, :name, :gvc, :containers,
24
+ :description, :tags, :support_dynamic_tags, :firewall_spec, :identity_link,
25
+ :options, :local_options, :rollout_options, :security_options, :load_balancer, :job
26
+
27
+ def initialize( # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength
28
+ type:,
29
+ gvc:,
30
+ name:,
31
+ containers:,
32
+ description: nil,
33
+ tags: nil,
34
+ support_dynamic_tags: false,
35
+ firewall_spec: nil,
36
+ identity_link: nil,
37
+ options: nil,
38
+ local_options: nil,
39
+ rollout_options: nil,
40
+ security_options: nil,
41
+ load_balancer: nil,
42
+ job: nil
43
+ )
44
+ super()
45
+
46
+ @type = type
47
+ @gvc = gvc
48
+
49
+ @name = name
50
+ @description = description
51
+ @tags = tags
52
+
53
+ @containers = containers
54
+ @firewall_spec = firewall_spec
55
+ @identity_link = identity_link
56
+
57
+ @options = options
58
+ @local_options = local_options
59
+ @rollout_options = rollout_options
60
+ @security_options = security_options
61
+
62
+ @load_balancer = load_balancer
63
+ @support_dynamic_tags = support_dynamic_tags
64
+ @job = job
65
+ end
66
+
67
+ def importable?
68
+ true
69
+ end
70
+
71
+ def reference
72
+ "module.#{name}.cpln_workload.workload"
73
+ end
74
+
75
+ def to_tf
76
+ block :module, name do
77
+ argument :source, "../workload"
78
+
79
+ argument :type, type
80
+ argument :name, name
81
+ argument :gvc, gvc
82
+ argument :identity_link, identity_link, optional: true
83
+ argument :support_dynamic_tags, support_dynamic_tags, optional: true
84
+
85
+ RAW_ARGS.each { |arg_name| argument arg_name, send(:"#{arg_name}_arg"), raw: true, optional: true }
86
+ end
87
+ end
88
+
89
+ def locals
90
+ containers.reduce({}) do |result, container|
91
+ envs = container[:env].to_h { |env_var| [env_var[:name], env_var[:value]] }
92
+ next result if envs.empty?
93
+
94
+ envs_name = :"#{container.fetch(:name)}_envs"
95
+ result.merge("#{envs_name}.tf" => LocalVariable.new(envs_name => envs))
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ def containers_arg
102
+ containers.reduce({}) { |result, container| result.merge(container_args(container)) }.crush
103
+ end
104
+
105
+ def container_args(container) # rubocop:disable Metrics/MethodLength
106
+ container_name = container.fetch(:name)
107
+
108
+ args = container.slice(:args, :command, :image, :cpu, :memory).merge(
109
+ post_start: container.dig(:lifecycle, :post_start, :exec, :command),
110
+ pre_stop: container.dig(:lifecycle, :pre_stop, :exec, :command),
111
+ inherit_env: container.fetch(:inherit_env, nil),
112
+ envs: ("local.#{container_name}_envs" if container[:env]&.any?),
113
+ ports: container.fetch(:ports, nil),
114
+ readiness_probe: container.fetch(:readiness_probe, nil)&.slice(*LIVENESS_PROBE_KEYS),
115
+ liveness_probe: container.fetch(:liveness_probe, nil)&.slice(*LIVENESS_PROBE_KEYS),
116
+ volumes: container.fetch(:volumes, nil)&.map { |volume| volume.slice(*VOLUME_KEYS) }
117
+ )
118
+
119
+ { container_name => args }
120
+ end
121
+
122
+ RAW_ARGS.each do |spec|
123
+ next if spec == :containers
124
+
125
+ define_method("#{spec}_arg") do
126
+ return if send(spec).nil?
127
+
128
+ send(spec).slice(*self.class.const_get("#{spec.upcase}_KEYS"))
129
+ end
130
+ end
131
+ end
132
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cpflow
4
- VERSION = "4.1.0"
4
+ VERSION = "4.2.0"
5
5
  MIN_CPLN_VERSION = "3.1.0"
6
6
  end
data/lib/cpflow.rb CHANGED
@@ -17,6 +17,9 @@ require_relative "constants/exit_code"
17
17
 
18
18
  # We need to require base before all commands, since the commands inherit from it
19
19
  require_relative "command/base"
20
+ require_relative "command/terraform/base"
21
+ # We need to require base terraform config before all commands, since the terraform configs inherit from it
22
+ require_relative "core/terraform_config/base"
20
23
 
21
24
  modules = Dir["#{__dir__}/**/*.rb"].reject do |file|
22
25
  file == __FILE__ || file.end_with?("base.rb")
@@ -33,15 +36,22 @@ end
33
36
 
34
37
  require_relative "patches/thor"
35
38
 
39
+ require_relative "patches/string"
40
+
36
41
  module Cpflow
37
42
  class Error < StandardError; end
38
43
 
44
+ def self.root_path
45
+ Pathname.new(File.expand_path("../", __dir__))
46
+ end
47
+
39
48
  class Cli < Thor # rubocop:disable Metrics/ClassLength
40
49
  package_name "cpflow"
41
50
  default_task :no_command
42
51
 
43
52
  def self.start(*args)
44
53
  ENV["CPLN_SKIP_UPDATE_CHECK"] = "true"
54
+ ENV["NODE_NO_WARNINGS"] = "1"
45
55
 
46
56
  check_cpln_version
47
57
  check_cpflow_version
@@ -95,12 +105,21 @@ module Cpflow
95
105
  def self.fix_help_option
96
106
  help_mappings = Thor::HELP_MAPPINGS + ["help"]
97
107
  matches = help_mappings & ARGV
108
+
109
+ # Help option works correctly for subcommands
110
+ return if matches && subcommand?
111
+
98
112
  matches.each do |match|
99
113
  ARGV.delete(match)
100
114
  ARGV.unshift(match)
101
115
  end
102
116
  end
103
117
 
118
+ def self.subcommand?
119
+ (subcommand_names & ARGV).any?
120
+ end
121
+ private_class_method :subcommand?
122
+
104
123
  # Needed to silence deprecation warning
105
124
  def self.exit_on_failure?
106
125
  true
@@ -131,6 +150,10 @@ module Cpflow
131
150
  ::Command::Base.all_commands.merge(deprecated_commands)
132
151
  end
133
152
 
153
+ def self.subcommand_names
154
+ Dir["#{__dir__}/command/*"].filter_map { |name| File.basename(name) if File.directory?(name) }
155
+ end
156
+
134
157
  def self.process_option_params(params)
135
158
  # Ensures that if no value is provided for a non-boolean option (e.g., `cpflow command --option`),
136
159
  # it defaults to an empty string instead of the option name (which is the default Thor behavior)
@@ -139,8 +162,21 @@ module Cpflow
139
162
  params
140
163
  end
141
164
 
165
+ def self.klass_for(subcommand_name)
166
+ klass_name = subcommand_name.to_s.split("-").map(&:capitalize).join
167
+ full_klass_name = "Cpflow::#{klass_name}"
168
+ return const_get(full_klass_name) if const_defined?(full_klass_name)
169
+
170
+ Cpflow.const_set(klass_name, Class.new(BaseSubCommand)).tap do |subcommand_klass|
171
+ desc(subcommand_name, "#{subcommand_name.capitalize} commands")
172
+ subcommand(subcommand_name, subcommand_klass)
173
+ end
174
+ end
175
+ private_class_method :klass_for
176
+
142
177
  @commands_with_required_options = []
143
178
  @commands_with_extra_options = []
179
+ cli_package_name = @package_name
144
180
 
145
181
  ::Command::Base.common_options.each do |option|
146
182
  params = process_option_params(option[:params])
@@ -151,6 +187,7 @@ module Cpflow
151
187
  deprecated = deprecated_commands[command_key]
152
188
 
153
189
  name = command_class::NAME
190
+ subcommand_name = command_class::SUBCOMMAND_NAME
154
191
  name_for_method = deprecated ? command_key : name.tr("-", "_")
155
192
  usage = command_class::USAGE.empty? ? name : command_class::USAGE
156
193
  requires_args = command_class::REQUIRES_ARGS
@@ -170,21 +207,26 @@ module Cpflow
170
207
  # so we store it here to be able to use it
171
208
  raise_args_error = ->(*args) { handle_argument_error(commands[name_for_method], ArgumentError, *args) }
172
209
 
173
- desc(usage, description, hide: hide)
174
- long_desc(long_description)
175
-
176
- command_options.each do |option|
177
- params = process_option_params(option[:params])
178
- method_option(option[:name], **params)
179
- end
180
-
181
210
  # We'll handle required options manually in `Config`
182
211
  required_options = command_options.select { |option| option[:params][:required] }.map { |option| option[:name] }
183
212
  @commands_with_required_options.push(name_for_method.to_sym) if required_options.any?
184
213
 
185
214
  @commands_with_extra_options.push(name_for_method.to_sym) if accepts_extra_options
186
215
 
187
- define_method(name_for_method) do |*provided_args| # rubocop:disable Metrics/BlockLength, Metrics/MethodLength
216
+ klass = subcommand_name ? klass_for(subcommand_name) : self
217
+
218
+ klass.class_eval do
219
+ package_name(cli_package_name) if subcommand_name
220
+ desc(usage, description, hide: hide)
221
+ long_desc(long_description)
222
+
223
+ command_options.each do |option|
224
+ params = Cpflow::Cli.process_option_params(option[:params])
225
+ method_option(option[:name], **params)
226
+ end
227
+ end
228
+
229
+ klass.define_method(name_for_method) do |*provided_args| # rubocop:disable Metrics/BlockLength, Metrics/MethodLength
188
230
  if deprecated
189
231
  normalized_old_name = ::Helpers.normalize_command_name(command_key)
190
232
  ::Shell.warn_deprecated("Command '#{normalized_old_name}' is deprecated, " \
@@ -133,7 +133,7 @@ spec:
133
133
  value: cpln://secret/postgres-poc-credentials.password
134
134
  - name: POSTGRES_USER #The name of the default user
135
135
  value: cpln://secret/postgres-poc-credentials.username
136
- name: stateful
136
+ name: postgres
137
137
  image: postgres:15
138
138
  command: /bin/bash
139
139
  args:
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Array
4
+ def crush
5
+ crushed = map { |el| el.respond_to?(:crush) ? el.crush : el }.compact
6
+ crushed unless crushed.empty?
7
+ end
8
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hash
4
+ # Copied from Rails
5
+ def deep_symbolize_keys
6
+ deep_transform_keys { |key| key.to_sym rescue key } # rubocop:disable Style/RescueModifier
7
+ end
8
+
9
+ def deep_underscore_keys
10
+ deep_transform_keys do |key|
11
+ underscored = key.to_s.underscore
12
+ key.is_a?(Symbol) ? underscored.to_sym : underscored
13
+ rescue StandardError
14
+ key
15
+ end
16
+ end
17
+
18
+ def crush
19
+ crushed = each_with_object({}) do |(key, value), hash|
20
+ crushed_value = value.respond_to?(:crush) ? value.crush : value
21
+ hash[key] = crushed_value unless crushed_value.nil?
22
+ end
23
+
24
+ crushed unless crushed.empty?
25
+ end
26
+
27
+ private
28
+
29
+ # Copied from Rails
30
+ def deep_transform_keys(&block)
31
+ deep_transform_keys_in_object(self, &block)
32
+ end
33
+
34
+ # Copied from Rails
35
+ def deep_transform_keys_in_object(object, &block)
36
+ case object
37
+ when Hash
38
+ object.each_with_object(self.class.new) do |(key, value), result|
39
+ result[yield(key)] = deep_transform_keys_in_object(value, &block)
40
+ end
41
+ when Array
42
+ object.map { |e| deep_transform_keys_in_object(e, &block) }
43
+ else
44
+ object
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Style/OptionalBooleanParameter, Lint/UnderscorePrefixedVariableName
4
+ class String
5
+ # Copied from Rails
6
+ def indent(amount, indent_string = nil, indent_empty_lines = false)
7
+ dup.tap { |_| _.indent!(amount, indent_string, indent_empty_lines) }
8
+ end
9
+
10
+ # Copied from Rails
11
+ def indent!(amount, indent_string = nil, indent_empty_lines = false)
12
+ indent_string = indent_string || self[/^[ \t]/] || " "
13
+ re = indent_empty_lines ? /^/ : /^(?!$)/
14
+ gsub!(re, indent_string * amount)
15
+ end
16
+
17
+ def unindent
18
+ gsub(/^#{scan(/^[ \t]+(?=\S)/).min}/, "")
19
+ end
20
+
21
+ # Copied from Rails
22
+ def underscore
23
+ gsub("::", "/").gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z\d])([A-Z])/, '\1_\2').tr("-", "_").downcase
24
+ end
25
+
26
+ # Covers only simple cases and used for pluralizing controlplane template kinds (`gvc`, `secret`, `policy`, etc.)
27
+ def pluralize
28
+ return self if empty?
29
+ return "#{self[...-1]}ies" if end_with?("y")
30
+
31
+ "#{self}s"
32
+ end
33
+ end
34
+ # rubocop:enable Style/OptionalBooleanParameter, Lint/UnderscorePrefixedVariableName
@@ -12,12 +12,16 @@ commands.keys.sort.each do |command_key|
12
12
  next if command_class::HIDE
13
13
 
14
14
  name = command_class::NAME
15
- usage = command_class::USAGE.empty? ? name : command_class::USAGE
15
+ subcommand_name = command_class::SUBCOMMAND_NAME
16
+
17
+ full_command = [subcommand_name, name].compact.join(" ")
18
+
19
+ usage = command_class::USAGE.empty? ? full_command : command_class::USAGE
16
20
  options = command_class::OPTIONS
17
21
  long_description = command_class::LONG_DESCRIPTION
18
22
  examples = command_class::EXAMPLES
19
23
 
20
- command_str = "### `#{name}`\n\n"
24
+ command_str = "### `#{full_command}`\n\n"
21
25
  command_str += "#{long_description.strip}\n\n"
22
26
 
23
27
  if examples.empty?