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,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Inspired by https://github.com/rails/thor/wiki/Subcommands
4
+ class BaseSubCommand < Thor
5
+ def self.banner(command, _namespace = nil, _subcommand = false) # rubocop:disable Style/OptionalBooleanParameter
6
+ "#{basename} #{subcommand_prefix} #{command.usage}"
7
+ end
8
+
9
+ def self.subcommand_prefix
10
+ name
11
+ .gsub(/.*::/, "")
12
+ .gsub(/^[A-Z]/) { |match| match[0].downcase }
13
+ .gsub(/[A-Z]/) { |match| "-#{match[0].downcase}" }
14
+ end
15
+ end
@@ -9,7 +9,7 @@ module Command
9
9
  end
10
10
 
11
11
  def self.source_root
12
- File.expand_path("../", __dir__)
12
+ Cpflow.root_path.join("lib")
13
13
  end
14
14
  end
15
15
 
data/lib/command/ps.rb CHANGED
@@ -34,7 +34,7 @@ module Command
34
34
  cp.fetch_workload!(workload)
35
35
 
36
36
  result = cp.fetch_workload_replicas(workload, location: location)
37
- result["items"].each { |replica| puts replica }
37
+ result&.dig("items")&.each { |replica| puts replica }
38
38
  end
39
39
  end
40
40
  end
@@ -75,7 +75,8 @@ module Command
75
75
 
76
76
  step("Waiting for replica '#{replica}' to not be ready", retry_on_failure: true) do
77
77
  result = cp.fetch_workload_replicas(workload, location: config.location)
78
- !result["items"].include?(replica)
78
+ items = result&.dig("items")
79
+ items && !items.include?(replica)
79
80
  end
80
81
  end
81
82
  end
@@ -11,6 +11,7 @@ module Command
11
11
  DESCRIPTION = "Waits for workloads in app to be ready after re-deployment"
12
12
  LONG_DESCRIPTION = <<~DESC
13
13
  - Waits for workloads in app to be ready after re-deployment
14
+ - Use Unix timeout command to set a maximum wait time (e.g., `timeout 300 cpflow ps:wait ...`)
14
15
  DESC
15
16
  EXAMPLES = <<~EX
16
17
  ```sh
@@ -18,7 +19,10 @@ module Command
18
19
  cpflow ps:wait -a $APP_NAME
19
20
 
20
21
  # Waits for a specific workload in app.
21
- cpflow ps:swait -a $APP_NAME -w $WORKLOAD_NAME
22
+ cpflow ps:wait -a $APP_NAME -w $WORKLOAD_NAME
23
+
24
+ # Waits for all workloads with a 5-minute timeout.
25
+ timeout 300 cpflow ps:wait -a $APP_NAME
22
26
  ```
23
27
  EX
24
28
 
data/lib/command/run.rb CHANGED
@@ -97,7 +97,7 @@ module Command
97
97
 
98
98
  attr_reader :interactive, :detached, :location, :original_workload, :runner_workload,
99
99
  :default_image, :default_cpu, :default_memory, :job_timeout, :job_history_limit,
100
- :container, :expected_deployed_version, :job, :replica, :command
100
+ :container, :job, :replica, :command
101
101
 
102
102
  def call # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
103
103
  @interactive = config.options[:interactive] || interactive_command?
@@ -126,10 +126,7 @@ module Command
126
126
  end
127
127
 
128
128
  create_runner_workload if cp.fetch_workload(runner_workload).nil?
129
- wait_for_runner_workload_deploy
130
129
  update_runner_workload
131
- wait_for_runner_workload_update if expected_deployed_version
132
-
133
130
  start_job
134
131
  wait_for_replica_for_job
135
132
 
@@ -191,7 +188,7 @@ module Command
191
188
  }
192
189
 
193
190
  # Create runner workload
194
- cp.apply_hash("kind" => "workload", "name" => runner_workload, "spec" => spec)
191
+ cp.apply_hash({ "kind" => "workload", "name" => runner_workload, "spec" => spec }, wait: true)
195
192
  end
196
193
  end
197
194
 
@@ -242,21 +239,7 @@ module Command
242
239
  return unless should_update
243
240
 
244
241
  step("Updating runner workload '#{runner_workload}'") do
245
- # Update runner workload
246
- @expected_deployed_version = (cp.cron_workload_deployed_version(runner_workload) || 0) + 1
247
- cp.apply_hash("kind" => "workload", "name" => runner_workload, "spec" => spec)
248
- end
249
- end
250
-
251
- def wait_for_runner_workload_deploy
252
- step("Waiting for runner workload '#{runner_workload}' to be deployed", retry_on_failure: true) do
253
- !cp.cron_workload_deployed_version(runner_workload).nil?
254
- end
255
- end
256
-
257
- def wait_for_runner_workload_update
258
- step("Waiting for runner workload '#{runner_workload}' to be updated", retry_on_failure: true) do
259
- (cp.cron_workload_deployed_version(runner_workload) || 0) >= expected_deployed_version
242
+ cp.apply_hash({ "kind" => "workload", "name" => runner_workload, "spec" => spec }, wait: true)
260
243
  end
261
244
  end
262
245
 
@@ -274,7 +257,7 @@ module Command
274
257
  def wait_for_replica_for_job
275
258
  step("Waiting for replica to start, which runs job '#{job}'", retry_on_failure: true) do
276
259
  result = cp.fetch_workload_replicas(runner_workload, location: location)
277
- @replica = result["items"].find { |item| item.include?(job) }
260
+ @replica = result&.dig("items")&.find { |item| item.include?(job) }
278
261
 
279
262
  replica || false
280
263
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Command
4
+ module Terraform
5
+ class Base < Command::Base
6
+ private
7
+
8
+ def templates
9
+ parser = TemplateParser.new(self)
10
+ template_files = Dir["#{parser.template_dir}/*.yml"]
11
+
12
+ if template_files.empty?
13
+ Shell.warn("No templates found in #{parser.template_dir}")
14
+ return []
15
+ end
16
+
17
+ parser.parse(template_files)
18
+ rescue StandardError => e
19
+ Shell.warn("Error parsing templates: #{e.message}")
20
+ []
21
+ end
22
+
23
+ def terraform_dir
24
+ @terraform_dir ||= begin
25
+ full_path = config.options.fetch(:dir, Cpflow.root_path.join("terraform"))
26
+ Pathname.new(full_path).tap do |path|
27
+ FileUtils.mkdir_p(path)
28
+ rescue StandardError => e
29
+ Shell.abort("Invalid directory: #{e.message}")
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Command
4
+ module Terraform
5
+ class Generate < Base
6
+ SUBCOMMAND_NAME = "terraform"
7
+ NAME = "generate"
8
+ OPTIONS = [
9
+ app_option,
10
+ dir_option
11
+ ].freeze
12
+ DESCRIPTION = "Generates terraform configuration files"
13
+ LONG_DESCRIPTION = <<~DESC
14
+ - Generates terraform configuration files based on `controlplane.yml` and `templates/` config
15
+ DESC
16
+ WITH_INFO_HEADER = false
17
+
18
+ def call
19
+ Array(config.app || config.apps.keys).each do |app|
20
+ config.instance_variable_set(:@app, app.to_s)
21
+ generate_app_config
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def generate_app_config
28
+ copy_workload_module
29
+
30
+ terraform_app_dir = cleaned_terraform_app_dir
31
+ generate_provider_configs(terraform_app_dir)
32
+
33
+ templates.each do |template|
34
+ TerraformConfig::Generator.new(config: config, template: template).tf_configs.each do |filename, tf_config|
35
+ File.write(terraform_app_dir.join(filename), tf_config.to_tf, mode: "a+")
36
+ end
37
+ rescue TerraformConfig::Generator::InvalidTemplateError => e
38
+ Shell.warn(e.message)
39
+ end
40
+ end
41
+
42
+ def copy_workload_module
43
+ FileUtils.copy_entry(
44
+ Cpflow.root_path.join("lib/core/terraform_config/workload"),
45
+ terraform_dir.join("workload")
46
+ )
47
+ end
48
+
49
+ def generate_provider_configs(terraform_app_dir)
50
+ generate_required_providers(terraform_app_dir)
51
+ generate_providers(terraform_app_dir)
52
+ rescue StandardError => e
53
+ Shell.abort("Failed to generate provider config files: #{e.message}")
54
+ end
55
+
56
+ def generate_required_providers(terraform_app_dir)
57
+ File.write(terraform_app_dir.join("required_providers.tf"), required_cpln_provider.to_tf)
58
+ end
59
+
60
+ def generate_providers(terraform_app_dir)
61
+ cpln_provider = TerraformConfig::Provider.new(name: "cpln", org: config.org)
62
+ File.write(terraform_app_dir.join("providers.tf"), cpln_provider.to_tf)
63
+ end
64
+
65
+ def required_cpln_provider
66
+ TerraformConfig::RequiredProvider.new(
67
+ name: "cpln",
68
+ org: config.org,
69
+ source: "controlplane-com/cpln",
70
+ version: "~> 1.0"
71
+ )
72
+ end
73
+
74
+ def cleaned_terraform_app_dir
75
+ full_path = terraform_dir.join(config.app)
76
+
77
+ unless File.expand_path(full_path).include?(Cpflow.root_path.to_s)
78
+ Shell.abort("Directory to save terraform configuration files cannot be outside of current directory")
79
+ end
80
+
81
+ if Dir.exist?(full_path)
82
+ clean_terraform_app_dir(full_path)
83
+ else
84
+ FileUtils.mkdir_p(full_path)
85
+ end
86
+
87
+ full_path
88
+ end
89
+
90
+ def clean_terraform_app_dir(terraform_app_dir)
91
+ Dir.children(terraform_app_dir).each do |child|
92
+ next if child == ".terraform.lock.hcl"
93
+
94
+ FileUtils.rm_rf(terraform_app_dir.join(child))
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Command
4
+ module Terraform
5
+ class Import < Base
6
+ SUBCOMMAND_NAME = "terraform"
7
+ NAME = "import"
8
+ OPTIONS = [
9
+ app_option,
10
+ dir_option
11
+ ].freeze
12
+ DESCRIPTION = "Imports terraform resources"
13
+ LONG_DESCRIPTION = <<~DESC
14
+ - Imports terraform resources from the generated configuration files
15
+ DESC
16
+ WITH_INFO_HEADER = false
17
+
18
+ def call
19
+ Array(config.app || config.apps.keys).each do |app|
20
+ config.instance_variable_set(:@app, app.to_s)
21
+
22
+ Dir.chdir(terraform_app_dir) do
23
+ run_terraform_init
24
+
25
+ resources.each do |resource|
26
+ run_terraform_import(resource[:address], resource[:id])
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def run_terraform_init
35
+ result = Shell.cmd("terraform", "init", capture_stderr: true)
36
+
37
+ if result[:success]
38
+ Shell.info(result[:output])
39
+ else
40
+ Shell.abort("Failed to initialize terraform - #{result[:output]}")
41
+ end
42
+ end
43
+
44
+ def run_terraform_import(address, id)
45
+ result = Shell.cmd("terraform", "import", address, id, capture_stderr: true)
46
+ Shell.info(result[:output])
47
+ end
48
+
49
+ def resources
50
+ tf_configs.filter_map do |tf_config|
51
+ next unless tf_config.importable?
52
+
53
+ { address: tf_config.reference, id: resource_id(tf_config) }
54
+ end
55
+ end
56
+
57
+ def tf_configs
58
+ templates.flat_map do |template|
59
+ TerraformConfig::Generator.new(config: config, template: template).tf_configs.values
60
+ end
61
+ end
62
+
63
+ def resource_id(tf_config)
64
+ case tf_config
65
+ when TerraformConfig::Gvc, TerraformConfig::Policy,
66
+ TerraformConfig::Secret, TerraformConfig::Agent,
67
+ TerraformConfig::AuditContext
68
+ tf_config.name
69
+ else
70
+ "#{config.app}:#{tf_config.name}"
71
+ end
72
+ end
73
+
74
+ def terraform_app_dir
75
+ terraform_dir.join(config.app)
76
+ end
77
+ end
78
+ end
79
+ end
data/lib/core/config.rb CHANGED
@@ -25,7 +25,7 @@ class Config # rubocop:disable Metrics/ClassLength
25
25
  return unless trace_mode
26
26
 
27
27
  ControlplaneApiDirect.trace = trace_mode
28
- Shell.warn("Trace mode is enabled, this will print sensitive information to the console.")
28
+ Shell.warn("Trace mode is enabled. Sensitive data is redacted, but please review output before sharing.")
29
29
  end
30
30
 
31
31
  def org
@@ -192,7 +192,7 @@ class Controlplane # rubocop:disable Metrics/ClassLength
192
192
  end
193
193
 
194
194
  def fetch_workload_replicas(workload, location:)
195
- cmd = "cpln workload replica get #{workload} #{gvc_org} --location #{location} -o yaml"
195
+ cmd = "cpln workload replica get #{workload} #{gvc_org} --location #{location} -o yaml 2> /dev/null"
196
196
  perform_yaml(cmd)
197
197
  end
198
198
 
@@ -213,8 +213,8 @@ class Controlplane # rubocop:disable Metrics/ClassLength
213
213
  end
214
214
  end
215
215
 
216
- def workload_deployments_ready?(workload, location:, expected_status:)
217
- deployed_replicas = fetch_workload_replicas(workload, location: location)["items"].length
216
+ def workload_deployments_ready?(workload, location:, expected_status:) # rubocop:disable Metrics/CyclomaticComplexity
217
+ deployed_replicas = fetch_workload_replicas(workload, location: location)&.dig("items")&.length || 0
218
218
  return deployed_replicas.zero? if expected_status == false
219
219
 
220
220
  deployments = fetch_workload_deployments(workload)["items"]
@@ -404,11 +404,12 @@ class Controlplane # rubocop:disable Metrics/ClassLength
404
404
  end
405
405
 
406
406
  # apply
407
- def apply_template(data) # rubocop:disable Metrics/MethodLength
407
+ def apply_template(data, wait: false) # rubocop:disable Metrics/MethodLength, Metrics/PerceivedComplexity
408
408
  Tempfile.create do |f|
409
409
  f.write(data)
410
410
  f.rewind
411
411
  cmd = "cpln apply #{gvc_org} --file #{f.path}"
412
+ cmd += " --ready" if wait && ENV.fetch("DISABLE_APPLY_READY", nil).nil?
412
413
  if Shell.tmp_stderr
413
414
  cmd += " 2> #{Shell.tmp_stderr.path}" if Shell.should_hide_output?
414
415
 
@@ -429,8 +430,8 @@ class Controlplane # rubocop:disable Metrics/ClassLength
429
430
  end
430
431
  end
431
432
 
432
- def apply_hash(data)
433
- apply_template(data.to_yaml)
433
+ def apply_hash(data, wait: false)
434
+ apply_template(data.to_yaml, wait: wait)
434
435
  end
435
436
 
436
437
  def parse_apply_result(result) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
@@ -1,5 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ class RedactedDebugOutput
4
+ SAFE_HEADERS = %w[Content-Type Content-Length Accept Host Date Cache-Control Connection].freeze
5
+ HEADER_REGEX = /^([A-Za-z\-]+): (.+)$/.freeze
6
+
7
+ def <<(msg)
8
+ $stdout << redact(msg)
9
+ end
10
+
11
+ private
12
+
13
+ def redact(msg)
14
+ msg.lines.map { |line| redact_line(line) }.join
15
+ end
16
+
17
+ def redact_line(line)
18
+ match = line.match(HEADER_REGEX)
19
+ return line.gsub(/[\w\-._]{50,}/, "[REDACTED]") unless match
20
+
21
+ SAFE_HEADERS.any? { |h| h.casecmp(match[1]).zero? } ? line : "#{match[1]}: [REDACTED]\n"
22
+ end
23
+ end
24
+
3
25
  class ControlplaneApiDirect
4
26
  API_METHODS = {
5
27
  get: Net::HTTP::Get,
@@ -37,7 +59,7 @@ class ControlplaneApiDirect
37
59
 
38
60
  http = Net::HTTP.new(uri.hostname, uri.port)
39
61
  http.use_ssl = uri.scheme == "https"
40
- http.set_debug_output($stdout) if trace
62
+ http.set_debug_output(RedactedDebugOutput.new) if trace
41
63
 
42
64
  response = http.start { |ht| ht.request(request) }
43
65
 
data/lib/core/shell.rb CHANGED
@@ -5,10 +5,6 @@ class Shell
5
5
  attr_reader :tmp_stderr, :verbose
6
6
  end
7
7
 
8
- def self.shell
9
- @shell ||= Thor::Shell::Color.new
10
- end
11
-
12
8
  def self.use_tmp_stderr
13
9
  @tmp_stderr = Tempfile.create
14
10
 
@@ -35,6 +31,10 @@ class Shell
35
31
  shell.yes?("#{message} (y/N)")
36
32
  end
37
33
 
34
+ def self.info(message)
35
+ shell.say(message)
36
+ end
37
+
38
38
  def self.warn(message)
39
39
  Kernel.warn(color("WARNING: #{message}", :yellow))
40
40
  end
@@ -97,4 +97,9 @@ class Shell
97
97
  exit(ExitCode::INTERRUPT)
98
98
  end
99
99
  end
100
+
101
+ def self.shell
102
+ @shell ||= Thor::Shell::Color.new
103
+ end
104
+ private_class_method :shell
100
105
  end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TerraformConfig
4
+ class Agent < Base
5
+ attr_reader :name, :description, :tags
6
+
7
+ def initialize(name:, description: nil, tags: nil)
8
+ super()
9
+
10
+ @name = name
11
+ @description = description
12
+ @tags = tags
13
+ end
14
+
15
+ def importable?
16
+ true
17
+ end
18
+
19
+ def reference
20
+ "cpln_agent.#{name}"
21
+ end
22
+
23
+ def to_tf
24
+ block :resource, :cpln_agent, name do
25
+ argument :name, name
26
+ argument :description, description, optional: true
27
+ argument :tags, tags, optional: true
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TerraformConfig
4
+ class AuditContext < Base
5
+ attr_reader :name, :description, :tags
6
+
7
+ def initialize(name:, description: nil, tags: nil)
8
+ super()
9
+
10
+ @name = name
11
+ @description = description
12
+ @tags = tags
13
+ end
14
+
15
+ def importable?
16
+ true
17
+ end
18
+
19
+ def reference
20
+ "cpln_audit_context.#{name}"
21
+ end
22
+
23
+ def to_tf
24
+ block :resource, :cpln_audit_context, name do
25
+ argument :name, name
26
+ argument :description, description, optional: true
27
+ argument :tags, tags, optional: true
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "dsl"
4
+
5
+ module TerraformConfig
6
+ class Base
7
+ include Dsl
8
+
9
+ def importable?
10
+ false
11
+ end
12
+
13
+ def reference
14
+ raise NotImplementedError if importable?
15
+ end
16
+
17
+ def to_tf
18
+ raise NotImplementedError
19
+ end
20
+
21
+ def locals
22
+ {}
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TerraformConfig
4
+ module Dsl
5
+ extend Forwardable
6
+
7
+ EXPRESSION_PATTERN = /(var|local|cpln_\w+)\./.freeze
8
+
9
+ def_delegators :current_context, :put, :output
10
+
11
+ def block(name, *labels)
12
+ switch_context do
13
+ put("#{block_declaration(name, labels)} {\n")
14
+ yield
15
+ put("}\n")
16
+ end
17
+
18
+ # There is extra indent for whole output that needs to be removed
19
+ output.unindent
20
+ end
21
+
22
+ def argument(name, value, optional: false, raw: false)
23
+ return if value.nil? && optional
24
+
25
+ content =
26
+ if value.is_a?(Hash)
27
+ operator = raw ? ": " : " = "
28
+ "{\n#{value.map { |n, v| "#{n}#{operator}#{tf_value(v)}" }.join("\n").indent(2)}\n}\n"
29
+ else
30
+ "#{tf_value(value)}\n"
31
+ end
32
+
33
+ # remove quotes from expression values
34
+ content = content.gsub(/("#{EXPRESSION_PATTERN}.*")/) { ::Regexp.last_match(1)[1...-1] }
35
+
36
+ put("#{name} = #{content}", indent: 2)
37
+ end
38
+
39
+ private
40
+
41
+ def tf_value(value)
42
+ value = value.to_s if value.is_a?(Symbol)
43
+
44
+ case value
45
+ when String
46
+ tf_string_value(value)
47
+ when Hash
48
+ tf_hash_value(value)
49
+ else
50
+ value
51
+ end
52
+ end
53
+
54
+ def tf_string_value(value)
55
+ return value if expression?(value)
56
+ return "\"#{value}\"" unless value.include?("\n")
57
+
58
+ "EOF\n#{value.indent(2)}\nEOF"
59
+ end
60
+
61
+ def tf_hash_value(value)
62
+ JSON.pretty_generate(value.crush)
63
+ .gsub(/"(\w+)":/) { "#{::Regexp.last_match(1)}:" } # remove quotes from keys
64
+ end
65
+
66
+ def expression?(value)
67
+ value.match?(/^#{EXPRESSION_PATTERN}/)
68
+ end
69
+
70
+ def block_declaration(name, labels)
71
+ result = name.to_s
72
+ return result unless labels.any?
73
+
74
+ result + " #{labels.map { |label| tf_value(label) }.join(' ')}"
75
+ end
76
+
77
+ class Context
78
+ attr_accessor :output
79
+
80
+ def initialize
81
+ @output = ""
82
+ end
83
+
84
+ def put(content, indent: 0)
85
+ @output += content.to_s.indent(indent)
86
+ end
87
+ end
88
+
89
+ def switch_context
90
+ old_context = current_context
91
+ @current_context = Context.new
92
+ yield
93
+ ensure
94
+ old_context.put(current_context.output, indent: 2)
95
+ @current_context = old_context
96
+ end
97
+
98
+ def current_context
99
+ @current_context ||= Context.new
100
+ end
101
+ end
102
+ end