cpflow 4.1.0 → 4.1.1

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +4 -0
  3. data/CHANGELOG.md +4 -0
  4. data/Gemfile.lock +1 -1
  5. data/README.md +7 -7
  6. data/docs/commands.md +16 -0
  7. data/docs/terraform/details.md +415 -0
  8. data/docs/terraform/example/.controlplane/controlplane.yml +29 -0
  9. data/docs/terraform/example/.controlplane/templates/app.yml +38 -0
  10. data/docs/terraform/example/.controlplane/templates/postgres.yml +30 -0
  11. data/docs/terraform/example/.controlplane/templates/rails.yml +26 -0
  12. data/docs/terraform/overview.md +105 -0
  13. data/lib/command/base.rb +29 -5
  14. data/lib/command/base_sub_command.rb +15 -0
  15. data/lib/command/generate.rb +1 -1
  16. data/lib/command/ps.rb +1 -1
  17. data/lib/command/ps_stop.rb +2 -1
  18. data/lib/command/run.rb +1 -1
  19. data/lib/command/terraform/base.rb +35 -0
  20. data/lib/command/terraform/generate.rb +99 -0
  21. data/lib/command/terraform/import.rb +79 -0
  22. data/lib/core/controlplane.rb +3 -3
  23. data/lib/core/shell.rb +9 -4
  24. data/lib/core/terraform_config/agent.rb +31 -0
  25. data/lib/core/terraform_config/audit_context.rb +31 -0
  26. data/lib/core/terraform_config/base.rb +25 -0
  27. data/lib/core/terraform_config/dsl.rb +102 -0
  28. data/lib/core/terraform_config/generator.rb +184 -0
  29. data/lib/core/terraform_config/gvc.rb +63 -0
  30. data/lib/core/terraform_config/identity.rb +35 -0
  31. data/lib/core/terraform_config/local_variable.rb +30 -0
  32. data/lib/core/terraform_config/policy.rb +151 -0
  33. data/lib/core/terraform_config/provider.rb +22 -0
  34. data/lib/core/terraform_config/required_provider.rb +23 -0
  35. data/lib/core/terraform_config/secret.rb +138 -0
  36. data/lib/core/terraform_config/volume_set.rb +155 -0
  37. data/lib/core/terraform_config/workload/main.tf +316 -0
  38. data/lib/core/terraform_config/workload/required_providers.tf +8 -0
  39. data/lib/core/terraform_config/workload/variables.tf +263 -0
  40. data/lib/core/terraform_config/workload.rb +132 -0
  41. data/lib/cpflow/version.rb +1 -1
  42. data/lib/cpflow.rb +50 -9
  43. data/lib/generator_templates/templates/postgres.yml +1 -1
  44. data/lib/patches/array.rb +8 -0
  45. data/lib/patches/hash.rb +47 -0
  46. data/lib/patches/string.rb +34 -0
  47. data/script/update_command_docs +6 -2
  48. metadata +33 -3
@@ -0,0 +1,105 @@
1
+ # Terraform
2
+
3
+ ## Overview
4
+
5
+ The Terraform feature in this project allows you to manage your Control Plane (CPLN) configurations using Terraform by:
6
+ 1. Generating Terraform configuration files from existing CPLN YAML configuration files
7
+ 2. Easily importing existing infrastructure into Terraform management
8
+
9
+ You can continue working with CPLN configuration files in YAML format and start using Terraform at any time.
10
+
11
+ ## Benefits of Using Terraform Over YAML Configs
12
+
13
+ 1. **State Management**: Terraform maintains a state file that tracks the current state of your infrastructure, making it easier to manage changes and updates.
14
+ 2. **Dependency Management**: Terraform automatically handles dependencies between resources, ensuring that they are created or destroyed in the correct order.
15
+ 3. **Multi-Cloud Support**: With Terraform, you can manage resources across multiple cloud providers seamlessly, allowing for a more flexible architecture.
16
+ 4. **Plan and Apply**: Terraform provides a clear plan of what changes will be made before applying them, reducing the risk of unintended modifications.
17
+
18
+ ## Usage
19
+
20
+ Let's take a look at how to deploy a [simple Rails application](https://github.com/shakacode/control-plane-flow/tree/main/docs/terraform/example/.controlplane/controlplane.yml) on CPLN using Terraform:
21
+
22
+ ```
23
+ .controlplane/
24
+ ├── templates/
25
+ │ ├── app.yml -- GVC config
26
+ │ ├── postgres.yml -- Workload config for PostgreSQL
27
+ │ └── rails.yml -- Workload config for Rails
28
+ └── controlplane.yml -- Configs for overall application
29
+ ```
30
+
31
+ ### Generating Terraform configurations
32
+
33
+ To generate Terraform configurations, run the following command from the project root:
34
+
35
+ ```sh
36
+ cpflow terraform generate
37
+ ```
38
+
39
+ Invoking this command will generate a new `terraform` folder with subfolders containing Terraform configurations for each application described in `controlplane.yml`:
40
+
41
+ ```
42
+ terraform/
43
+ ├── rails-app-production/ -- Terraform configurations for production environment
44
+ │ ├── gvc.tf -- GVC config in HCL
45
+ │ ├── identities.tf -- Identities config in HCL
46
+ │ ├── postgres.tf -- Postgres workload config in HCL
47
+ │ ├── postgres_envs.tf -- ENV variables for Postgres workload in HCL
48
+ │ ├── providers.tf -- Providers config in HCL
49
+ │ ├── rails.tf -- Rails workload config in HCL
50
+ │ ├── rails_envs.tf -- ENV variables for Rails workload in HCL
51
+ │ ├── required_providers.tf -- Required providers config in HCL
52
+ │ └── secrets.tf -- Secrets config in HCL
53
+ ├── rails-app-staging/ -- Terraform configurations for staging environment
54
+ │ ├── gvc.tf -- GVC config in HCL
55
+ │ ├── identities.tf -- Identities config in HCL
56
+ │ ├── postgres.tf -- Postgres workload config in HCL
57
+ │ ├── postgres_envs.tf -- ENV variables for Postgres workload in HCL
58
+ │ ├── providers.tf -- Providers config in HCL
59
+ │ ├── rails.tf -- Rails workload config in HCL
60
+ │ ├── rails_envs.tf -- ENV variables for Rails workload in HCL
61
+ │ ├── required_providers.tf -- Required providers config in HCL
62
+ │ └── secrets.tf -- Secrets config in HCL
63
+ ├── workload/ -- Terraform configurations for workload module
64
+ │ ├── main.tf -- Main config for workload resource in HCL
65
+ │ ├── required_providers.tf -- Required providers for Terraform in HCL
66
+ │ └── variables.tf -- Variables used to create config for workload resource in HCL
67
+ ```
68
+
69
+ ### Importing existing infrastructure
70
+
71
+ Now we need to import existing infrastructure into Terraform management because some resources can already exist on CPLN and Terraform needs to know about this:
72
+
73
+ ```sh
74
+ cpflow terraform import
75
+ ```
76
+
77
+ This command will initialize Terraform and import resources defined in your `controlplane.yml` and `templates` folder into the Terraform state for each application.
78
+
79
+ Please note that during the import process, you may encounter errors indicating that non-existing resources are being imported. This is expected behavior and can be safely ignored.
80
+
81
+ ### Application deployment using Terraform
82
+
83
+ Preparations are complete, and now we can use Terraform commands directly to deploy our application.
84
+
85
+ 1. **Navigate to the Application Folder**:
86
+ ```sh
87
+ cd terraform/rails-app-staging
88
+ ```
89
+
90
+ 2. **Plan the Deployment**:
91
+ ```sh
92
+ terraform plan
93
+ ```
94
+
95
+ 3. **Apply the Configuration**:
96
+ ```sh
97
+ terraform apply
98
+ ```
99
+
100
+ You can visit [Details](https://github.com/shakacode/control-plane-flow/tree/main/docs/terraform/details.md) to learn more about how CPLN templates in YAML format are transformed to Terraform configurations.
101
+
102
+ ## References
103
+
104
+ - [Terraform Provider Plugin](https://shakadocs.controlplane.com/terraform/installation#terraform-provider-plugin)
105
+ - [Terraform - Control Plane Examples](https://github.com/controlplane-com/examples/tree/main/terraform)
data/lib/command/base.rb CHANGED
@@ -12,6 +12,8 @@ module Command
12
12
  VALIDATIONS_WITH_ADDITIONAL_OPTIONS = %w[templates].freeze
13
13
  ALL_VALIDATIONS = VALIDATIONS_WITHOUT_ADDITIONAL_OPTIONS + VALIDATIONS_WITH_ADDITIONAL_OPTIONS
14
14
 
15
+ # Used to call the command (`cpflow SUBCOMMAND_NAME NAME`)
16
+ SUBCOMMAND_NAME = nil
15
17
  # Used to call the command (`cpflow NAME`)
16
18
  # NAME = ""
17
19
  # Displayed when running `cpflow help` or `cpflow help NAME` (defaults to `NAME`)
@@ -43,11 +45,21 @@ module Command
43
45
  @config = config
44
46
  end
45
47
 
46
- def self.all_commands
47
- Dir["#{__dir__}/*.rb"].each_with_object({}) do |file, result|
48
- filename = File.basename(file, ".rb")
49
- classname = File.read(file).match(/^\s+class (\w+) < Base($| .*$)/)&.captures&.first
50
- result[filename.to_sym] = Object.const_get("::Command::#{classname}") if classname
48
+ def self.all_commands # rubocop:disable Metrics/MethodLength
49
+ Dir["#{__dir__}/**/*.rb"].each_with_object({}) do |file, result|
50
+ content = File.read(file)
51
+
52
+ classname = content.match(/^\s+class (?!Base\b)(\w+) < (?:.*(?!Command::)Base)(?:$| .*$)/)&.captures&.first
53
+ next unless classname
54
+
55
+ namespaces = content.scan(/^\s+module (\w+)/).flatten
56
+ full_classname = [*namespaces, classname].join("::").prepend("::")
57
+
58
+ command_key = File.basename(file, ".rb")
59
+ prefix = namespaces[1..].map(&:downcase).join("_")
60
+ command_key.prepend(prefix.concat("_")) unless prefix.empty?
61
+
62
+ result[command_key.to_sym] = Object.const_get(full_classname)
51
63
  end
52
64
  end
53
65
 
@@ -453,6 +465,18 @@ module Command
453
465
  }
454
466
  }
455
467
  end
468
+
469
+ def self.dir_option(required: false)
470
+ {
471
+ name: :dir,
472
+ params: {
473
+ banner: "DIR",
474
+ desc: "Output directory",
475
+ type: :string,
476
+ required: required
477
+ }
478
+ }
479
+ end
456
480
  # rubocop:enable Metrics/MethodLength
457
481
 
458
482
  def self.all_options
@@ -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
data/lib/command/run.rb CHANGED
@@ -274,7 +274,7 @@ module Command
274
274
  def wait_for_replica_for_job
275
275
  step("Waiting for replica to start, which runs job '#{job}'", retry_on_failure: true) do
276
276
  result = cp.fetch_workload_replicas(runner_workload, location: location)
277
- @replica = result["items"].find { |item| item.include?(job) }
277
+ @replica = result&.dig("items")&.find { |item| item.include?(job) }
278
278
 
279
279
  replica || false
280
280
  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
@@ -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"]
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