pangea-orchestrator 0.0.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 (40) hide show
  1. checksums.yaml +7 -0
  2. data/.envrc +1 -0
  3. data/.gitignore +1 -0
  4. data/Gemfile +2 -0
  5. data/Gemfile.lock +114 -0
  6. data/LICENSE +201 -0
  7. data/README.md +3 -0
  8. data/Rakefile +15 -0
  9. data/flake.lock +93 -0
  10. data/flake.nix +41 -0
  11. data/gemset.nix +451 -0
  12. data/lib/pangea-orchestrator/cli.rb +73 -0
  13. data/lib/pangea-orchestrator/errors/incorrect_subcommand_error.rb +2 -0
  14. data/lib/pangea-orchestrator/errors/namespace_not_found_error.rb +2 -0
  15. data/lib/pangea-orchestrator/errors/no_infra_target_error.rb +2 -0
  16. data/lib/pangea-orchestrator/errors/project_not_found_error.rb +2 -0
  17. data/lib/pangea-orchestrator/errors/site_not_found_error.rb +2 -0
  18. data/lib/pangea-orchestrator/executor.rb +10 -0
  19. data/lib/pangea-orchestrator/modules.rb +134 -0
  20. data/lib/pangea-orchestrator/renderer.rb +241 -0
  21. data/lib/pangea-orchestrator/resources/eks.rb +22 -0
  22. data/lib/pangea-orchestrator/resources.rb +8 -0
  23. data/lib/pangea-orchestrator/sandbox.rb +90 -0
  24. data/lib/pangea-orchestrator/say/init.rb +27 -0
  25. data/lib/pangea-orchestrator/shell/README.md +1 -0
  26. data/lib/pangea-orchestrator/shell/terraform.rb +21 -0
  27. data/lib/pangea-orchestrator/shell.rb +27 -0
  28. data/lib/pangea-orchestrator/stack.rb +11 -0
  29. data/lib/pangea-orchestrator/state.rb +96 -0
  30. data/lib/pangea-orchestrator/structures/README.md +3 -0
  31. data/lib/pangea-orchestrator/structures/abstract.rb +2 -0
  32. data/lib/pangea-orchestrator/structures/namespace.rb +4 -0
  33. data/lib/pangea-orchestrator/structures/project.rb +4 -0
  34. data/lib/pangea-orchestrator/structures/site.rb +4 -0
  35. data/lib/pangea-orchestrator/synthesizer/config.rb +38 -0
  36. data/lib/pangea-orchestrator/utils.rb +32 -0
  37. data/lib/pangea-orchestrator/version.rb +3 -0
  38. data/lib/pangea-orchestrator.rb +5 -0
  39. data/pangea-orchestrator.gemspec +41 -0
  40. metadata +222 -0
@@ -0,0 +1,241 @@
1
+ # a renderer takes an artifact.json and implements
2
+ # it against a rest api, then potentially runs
3
+ # packaged checks and verifications.
4
+ # it also exports values into state for the
5
+ # child renderer to pick up on
6
+
7
+ require %(terraform-synthesizer)
8
+ require %(pangea/modcache)
9
+ require %(pangea/config)
10
+ require %(pangea/utils)
11
+ require %(aws-sdk-s3)
12
+ require %(digest)
13
+ require %(json)
14
+
15
+ module Pangea
16
+ class DirectoryRenderer
17
+ BIN = %(tofu).freeze
18
+
19
+ def home_dir
20
+ %(#{Dir.home}/.pangea)
21
+ end
22
+
23
+ def infra_dir
24
+ %(#{home_dir}/infra)
25
+ end
26
+
27
+ def init_dir
28
+ %(#{infra_dir}/init)
29
+ end
30
+
31
+ def artifact_json
32
+ File.join(init_dir, %(artifact.tf.json))
33
+ end
34
+
35
+ def pretty(content)
36
+ JSON.pretty_generate(content)
37
+ end
38
+
39
+ def create_prepped_state_directory(dir, synthesis)
40
+ system %(mkdir -p #{dir}) unless Dir.exist?(dir)
41
+ File.write(File.join(dir, %(artifact.tf.json)), JSON[synthesis])
42
+ system %(cd #{dir} && #{BIN} init -json) unless Dir.exist?(File.join(dir, %(.terraform)))
43
+ true
44
+ end
45
+
46
+ def resource(dir)
47
+ JSON[File.read(File.join(dir, %(artifact.tf.json)))]
48
+ end
49
+
50
+ def state(dir)
51
+ pretty(JSON[File.read(File.join(dir, %(terraform.tfstate)))])
52
+ end
53
+
54
+ def plan(dir)
55
+ pretty(JSON[File.read(File.join(dir, %(plan.json)))])
56
+ end
57
+
58
+ # component is a single resource wrapped in state
59
+ # with available attributes
60
+ def render_component(&block)
61
+ synthesizer.synthesize(&block)
62
+ resource_type = synthesizer.synthesis[:resource].keys[0]
63
+ resource_name = synthesizer.synthesis[:resource][synthesizer.synthesis[:resource].keys[0]].keys[0]
64
+ dir = File.join(init_dir, resource_type.to_s, resource_name.to_s)
65
+ create_prepped_state_directory(dir, synthesizer.synthesis)
66
+ system %(cd #{dir} && #{BIN} show -json tfplan > plan.json)
67
+ system %(cd #{dir} && #{BIN} apply -auto-approve)
68
+
69
+ synthesizer.clear_synthesis!
70
+
71
+ {
72
+ resource: resource(dir),
73
+ state: state(dir),
74
+ plan: plan(dir)
75
+ }
76
+ end
77
+ end
78
+
79
+ class S3Renderer
80
+ class << self
81
+ def synthesizer
82
+ @synthesizer ||= TerraformSynthesizer.new
83
+ end
84
+ end
85
+
86
+ # BIN = %(tofu).freeze
87
+
88
+ # attr_reader :namespace
89
+
90
+ # def initialize
91
+ # raise ArgumentError, 'provide PANGEA_NAMESPACE ENVVAR' if ENV.fetch('PANGEA_NAMESPACE').nil?
92
+ #
93
+ # @namespace = ENV.fetch('PANGEA_NAMESPACE', nil)
94
+ # end
95
+
96
+ # def config
97
+ # @config ||= Pangea::Utils.symbolize(
98
+ # Pangea::Config.config
99
+ # )
100
+ # end
101
+
102
+ # def s3
103
+ # @s3 = Aws::S3::Client.new
104
+ # end
105
+
106
+ # def verify_state(state)
107
+ # raise Argumenterror, 'must have a bucket' unless state[:config][:bucket]
108
+ # raise Argumenterror, 'must have a region' unless state[:config][:region]
109
+ # raise Argumenterror, 'must have a lock' unless state[:config][:lock]
110
+ # end
111
+
112
+ # def pangea_home
113
+ # %(#{Dir.home}/.pangea/#{namespace})
114
+ # end
115
+
116
+ # def bin
117
+ # %(tofu)
118
+ # end
119
+
120
+ # def selected_namespace_configuration
121
+ # sns = ''
122
+ # config[:namespaces].each_key do |ns|
123
+ # sns = config[:namespaces][ns] if ns.to_s.eql?(namespace.to_s)
124
+ # end
125
+ # @selected_namespace_configuration ||= sns
126
+ # end
127
+
128
+ # render things in a resource context
129
+ # without using terraform modules
130
+ # def state(name, &block)
131
+ # if block.nil?
132
+ # File.write(File.join(local_cache, 'main.tf.json'), JSON[{}])
133
+ # system("cd #{local_cache} && #{bin} init -input=false")
134
+ # system("cd #{local_cache} && #{bin} plan")
135
+ # system("cd #{local_cache} && #{bin} apply -auto-approve")
136
+ # return {}
137
+ # end
138
+ # S3Renderer.synthesizer.synthesize(&block)
139
+ # synth = Pangea::Utils.symbolize(S3Renderer.synthesizer.synthesis)
140
+ # prefix = "#{name}/pangea"
141
+ # local_cache = File.join(pangea_home, prefix)
142
+ # `mkdir -p #{local_cache}` unless Dir.exist?(local_cache)
143
+ # sns = selected_namespace_configuration
144
+ # verify_state(sns[:state])
145
+ #
146
+ # # apply state configuration
147
+ # unless synth[:terraform]
148
+ # S3Renderer.synthesizer.synthesize do
149
+ # terraform do
150
+ # backend(
151
+ # s3: {
152
+ # key: prefix,
153
+ # dynamodb_table: sns[:state][:config][:lock].to_s,
154
+ # bucket: sns[:state][:config][:bucket].to_s,
155
+ # region: sns[:state][:config][:region].to_s,
156
+ # encrypt: true
157
+ # }
158
+ # )
159
+ # end
160
+ # end
161
+ # end
162
+ #
163
+ # File.write(File.join(local_cache, 'main.tf.json'), JSON[S3Renderer.synthesizer.synthesis])
164
+ # template = Pangea::Utils.symbolize(JSON[File.read(File.join(local_cache, 'main.tf.json'))])
165
+ # system("cd #{local_cache} && #{bin} init -input=false")
166
+ # system("cd #{local_cache} && #{bin} plan")
167
+ # system("cd #{local_cache} && #{bin} apply -auto-approve")
168
+ # # puts s3.list_objects_v2(bucket: sns[:state][:config][:bucket], prefix: prefix).contents.map(&:key)
169
+ # { template: template }
170
+ # end
171
+
172
+ # def state_keys
173
+ # sns = selected_namespace_configuration
174
+ # end
175
+
176
+ # def state; end
177
+
178
+ # def render_component(template)
179
+ # mod = 'stump'
180
+ # sns = ''
181
+ # config[:namespaces].each_key do |ns|
182
+ # sns = config[:namespaces][ns] if ns.to_s.eql?(namespace.to_s)
183
+ # end
184
+ #
185
+ # unless sns[:state][:type].to_s.eql?('s3')
186
+ # raise ArgumentError,
187
+ # 'state type must be s3 '
188
+ # end
189
+ #
190
+ # if sns.nil? || sns.empty?
191
+ # raise ArgumentError,
192
+ # "namespace #{namespace} not found in #{Pangea::Utils.pretty(config)}"
193
+ # end
194
+ #
195
+ # synthesizer.synthesize(template)
196
+ # syn = Pangea::Utils.symbolize(synthesizer.synthesis)
197
+ # raise ArgumentError, 'must provide at least one resource' if syn[:resource].nil?
198
+ #
199
+ # resource_name = syn[:resource].keys[0]
200
+ # virtual_name = syn[:resource][syn[:resource].keys[0]].keys[0]
201
+ #
202
+ # synthesizer.synthesize do
203
+ # provider do
204
+ # aws(region: sns[:state][:config][:region].to_s)
205
+ # end
206
+ # variable do
207
+ # name(type: 'string', description: 'the module name')
208
+ # end
209
+ # end
210
+ #
211
+ # synthesizer.synthesize do
212
+ # terraform do
213
+ # backend(
214
+ # s3: {
215
+ # key: "#{sns[:name]}/#{mod}/#{resource_name}/#{virtual_name}/module",
216
+ # dynamodb_table: sns[:state][:config][:lock].to_s,
217
+ # bucket: sns[:state][:config][:bucket].to_s,
218
+ # region: sns[:state][:config][:region].to_s,
219
+ # encrypt: true
220
+ # }
221
+ # )
222
+ # end
223
+ # end
224
+ #
225
+ # # modcache_address = "#{sns[:name]}/#{mod}/#{resource_name}/#{virtual_name}/module"
226
+ #
227
+ # # create the modcache directory
228
+ # modcache = Pangea::ModCache.new(
229
+ # sns[:name],
230
+ # mod,
231
+ # resource_name,
232
+ # virtual_name
233
+ # )
234
+ # # place the internal module
235
+ # modcache.place_internal_module(syn)
236
+ # modcache.place_caller_template
237
+ #
238
+ # Pangea::Utils.symbolize(synthesizer.synthesis)
239
+ # end
240
+ end
241
+ end
@@ -0,0 +1,22 @@
1
+ # lib/pangea-orchestrator/resources/eks.rb
2
+ require 'json'
3
+ module PangeaOrchestrator
4
+ module Resources
5
+ class EKS
6
+ class << self
7
+ def symbolize(hash)
8
+ JSON[JSON[hash, symbolic_names: true]]
9
+ end
10
+
11
+ def cluster(*_args, **kwargs)
12
+ kwargs = symbolize(kwargs)
13
+ resource_name = kwargs[:resource_name]
14
+ name = kwargs[:name]
15
+ resource :aws_eks_cluster, resource_name do
16
+ name name
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,8 @@
1
+ # lib/pangea-orchestrator/resources.rb
2
+ module PangeaOrchestrator
3
+ module Resources
4
+ autoload :EKS, File.join(__dir__, 'resources', 'eks')
5
+ # autoload :NOMAD, File.join(__dir__, 'resources', 'nomad')
6
+ # autoload :NIX, File.join(__dir__, 'resources', 'nix')
7
+ end
8
+ end
@@ -0,0 +1,90 @@
1
+ ###############################################################################
2
+ # sandbox
3
+ #
4
+ # modules need an execution space which I'm naming sandboxes
5
+ # sandboxes will contain the ruby interpreter, associated packages
6
+ # and pangea DSL code for the execution of a single module
7
+ ###############################################################################
8
+
9
+ class SandboxRuby
10
+ attr_reader(*%i[base_dir name gemset version])
11
+
12
+ def initialize(base_dir:, name:, gemset:, version:)
13
+ @base_dir = base_dir
14
+ @version = version
15
+ @name = name
16
+ @gemset = gemset
17
+ end
18
+ end
19
+
20
+ class SandBox
21
+ attr_reader(*%i[base_dir name rubies])
22
+
23
+ def initialize(
24
+ name:,
25
+ base_dir: %(~/.pangea/sandbox),
26
+ rubies: []
27
+ )
28
+ @base_dir = base_dir
29
+ @name = name
30
+ @rubies = rubies
31
+
32
+ @rubies_dir = File.join(@base_dir, @name, %(rubies))
33
+
34
+ @ruby_build_cmd = %(ruby-build)
35
+ end
36
+
37
+ def prepare_sandbox
38
+ ensure_base_dir_exists
39
+ ensure_rubies_directories_exist
40
+ check_ruby_build_installed
41
+ ensure_rubies_installed
42
+ end
43
+
44
+ def clean_sandbox
45
+ system %(rm -rf #{base_dir})
46
+ end
47
+
48
+ private
49
+
50
+ def ensure_base_dir_exists
51
+ system %(mkdir -p #{base_dir}) unless Dir.exist?(base_dir)
52
+ end
53
+
54
+ def ensure_rubies_directories_exist
55
+ rubies.each do |ruby|
56
+ ruby_dir = File.join(@rubies_dir, ruby.version, ruby.gemset, ruby.name)
57
+ system %(mkdir -p #{ruby_dir}) unless Dir.exist?(ruby_dir)
58
+ end
59
+ end
60
+
61
+ def command_exists_on_path?(cmd)
62
+ system("which #{cmd} > /dev/null 2>&1")
63
+ end
64
+
65
+ def ruby_build_installed?
66
+ command_exists_on_path?(@ruby_build_cmd)
67
+ end
68
+
69
+ def ensure_rubies_installed
70
+ rubies.each do |ruby|
71
+ ruby_dir = File.join(@rubies_dir, ruby.version, ruby.gemset, ruby.name)
72
+ system %(#{@ruby_build_cmd} #{ruby.version} #{ruby_dir}) unless File.exist?(
73
+ File.join(
74
+ ruby_dir,
75
+ %(.installed_by_pangea)
76
+ )
77
+ )
78
+ system %(touch #{File.join(ruby_dir, %(.installed_by_pangea))}) unless File.exist?(
79
+ File.join(
80
+ ruby_dir,
81
+ %(.installed_by_pangea)
82
+ )
83
+ )
84
+ end
85
+ end
86
+
87
+ def check_ruby_build_installed
88
+ raise %(ruby-build not installed) unless ruby_build_installed?
89
+ end
90
+ end
@@ -0,0 +1,27 @@
1
+ require %(tty-color)
2
+ require %(tty-box)
3
+
4
+ module Say
5
+ class << self
6
+ def terminal(msg)
7
+ spec = {
8
+ width: 80,
9
+ style: {
10
+ fg: :yellow,
11
+ bg: :blue,
12
+ border: { fg: :green, bg: :black }
13
+ },
14
+ align: :right,
15
+ border: :thick
16
+ # padding: 0,
17
+ # height: 1
18
+ }
19
+
20
+ box = TTY::Box.frame(**spec)
21
+
22
+ puts box + "\n"
23
+ puts msg.strip
24
+ puts "\n" + box
25
+ end
26
+ end
27
+ end
@@ -0,0 +1 @@
1
+ # localize calling out to the shell
@@ -0,0 +1,21 @@
1
+ module Shell
2
+ module Terraform
3
+ BIN = ENV[%(TERRAFORM_BIN)] || %(terraform).freeze
4
+
5
+ class << self
6
+ def run(terraform_cmd)
7
+ cmd = []
8
+ # cmd << %(cd)
9
+ # cmd << Dir.pwd
10
+ # cmd << %(&&)
11
+ cmd << BIN
12
+ cmd << terraform_cmd
13
+ system cmd.join(%( ))
14
+ end
15
+
16
+ def plan
17
+ run(%(plan))
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,27 @@
1
+ require 'open3'
2
+ module Pangea
3
+ module Shell
4
+ class << self
5
+ def run(command)
6
+ Open3.popen3(command) do |_stdin, stdout, stderr, wait_thr|
7
+ # Process standard output
8
+ stdout.each_line do |line|
9
+ parsed = JSON.parse(line.strip)
10
+ puts JSON.pretty_generate(parsed)
11
+ puts "\n---\n" # Separator between JSON objects
12
+ rescue JSON::ParserError
13
+ warn "⚠️ Invalid JSON received: #{line.inspect}"
14
+ end
15
+
16
+ # Handle standard error
17
+ unless (err = stderr.read).empty?
18
+ warn "\n❌ Command errors:\n#{err}"
19
+ end
20
+
21
+ exit_status = wait_thr.value
22
+ warn "\n🔥 Command failed with status #{exit_status.exitstatus}" unless exit_status.success?
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,11 @@
1
+ # stack is the most top level executable element
2
+
3
+ module Pangea
4
+ class Stack
5
+ attr_reader :name
6
+
7
+ def initialize(name)
8
+ @name = name
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,96 @@
1
+ require 'aws-sdk-s3'
2
+ require 'aws-sdk-dynamodb'
3
+
4
+ module Pangea
5
+ # Base state management class
6
+ class State
7
+ def initialize
8
+ # Initialize common state if needed.
9
+ end
10
+ end
11
+
12
+ # Manage local state
13
+ class LocalState < Pangea::State; end
14
+
15
+ # Manage S3 state
16
+ class S3State < Pangea::State
17
+ # Creates a DynamoDB table to be used for state locking.
18
+ # Optional parameters allow you to customize the table name, AWS region, and whether to check for existing table.
19
+ def create_dynamodb_table_for_lock(name:, region:, check: true)
20
+ dynamodb = Aws::DynamoDB::Client.new(region: region)
21
+
22
+ if check
23
+ begin
24
+ # Check if the table already exists
25
+ dynamodb.describe_table(table_name: name)
26
+ puts "DynamoDB table '#{name}' already exists, skipping creation."
27
+ return
28
+ rescue Aws::DynamoDB::Errors::ResourceNotFoundException
29
+ # Table does not exist; proceed with creation.
30
+ rescue Aws::DynamoDB::Errors::ServiceError => e
31
+ puts "Failed to check DynamoDB table existence: #{e.message}"
32
+ return
33
+ end
34
+ end
35
+
36
+ begin
37
+ dynamodb.create_table(
38
+ {
39
+ table_name: name,
40
+ attribute_definitions: [
41
+ {
42
+ attribute_name: 'LockID',
43
+ attribute_type: 'S'
44
+ }
45
+ ],
46
+ key_schema: [
47
+ {
48
+ attribute_name: 'LockID',
49
+ key_type: 'HASH'
50
+ }
51
+ ],
52
+ provisioned_throughput: {
53
+ read_capacity_units: 1,
54
+ write_capacity_units: 1
55
+ }
56
+ }
57
+ )
58
+ puts "DynamoDB table '#{name}' created successfully!"
59
+ rescue Aws::DynamoDB::Errors::ResourceInUseException
60
+ puts "DynamoDB table '#{name}' already exists."
61
+ rescue Aws::DynamoDB::Errors::ServiceError => e
62
+ puts "Failed to create DynamoDB table: #{e.message}"
63
+ end
64
+ end
65
+
66
+ # Creates an S3 bucket for state storage.
67
+ # If `check` is true, the method first checks if the bucket exists.
68
+ def create_bucket(name:, region:, check: true)
69
+ s3 = Aws::S3::Client.new(region: region)
70
+
71
+ if check
72
+ begin
73
+ # Attempt to check if the bucket exists
74
+ s3.head_bucket(bucket: name)
75
+ # Bucket exists; nothing to do.
76
+ return
77
+ rescue Aws::S3::Errors::NotFound, Aws::S3::Errors::NoSuchBucket
78
+ # Bucket does not exist; proceed with creation.
79
+ rescue Aws::S3::Errors::Forbidden => e
80
+ # The bucket exists but is inaccessible (perhaps owned by someone else).
81
+ puts "Bucket '#{name}' exists but is not accessible: #{e.message}"
82
+ return
83
+ end
84
+ end
85
+
86
+ begin
87
+ s3.create_bucket(bucket: name)
88
+ puts "Bucket '#{name}' created successfully!"
89
+ rescue Aws::S3::Errors::BucketAlreadyOwnedByYou
90
+ puts "Bucket '#{name}' already exists and is owned by you."
91
+ rescue Aws::S3::Errors::ServiceError => e
92
+ puts "Failed to create bucket: #{e.message}"
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,3 @@
1
+ # structures
2
+
3
+ The principle structures driving the pangea cli
@@ -0,0 +1,2 @@
1
+ # class AbstractPangeaStructure
2
+ # end
@@ -0,0 +1,4 @@
1
+ require %(pangea/structures/abstract)
2
+
3
+ class Namespace < AbstractPangeaStructure
4
+ end
@@ -0,0 +1,4 @@
1
+ require %(pangea/structures/abstract)
2
+
3
+ class Project < AbstractPangeaStructure
4
+ end
@@ -0,0 +1,4 @@
1
+ require %(pangea/structures/abstract)
2
+
3
+ class Site < AbstractPangeaStructure
4
+ end
@@ -0,0 +1,38 @@
1
+ require %(pangea/cli/constants)
2
+ require %(abstract-synthesizer)
3
+ require %(toml-rb)
4
+ require %(json)
5
+ require %(yaml)
6
+
7
+ ###############################################################################
8
+ # read files merge config data and provide a single configuation structure
9
+ ###############################################################################
10
+
11
+ class ConfigSynthesizer < AbstractSynthesizer
12
+ include Constants
13
+
14
+ def synthesize(content, ext)
15
+ case ext.to_s
16
+ when %(yaml), %(yml)
17
+ translation[:template] = YAML.safe_load(content)
18
+ when %(toml)
19
+ translation[:template] = TomlRB.parse(content)
20
+ when %(json)
21
+ translation[:template] = JSON.parse(content)
22
+ when %(rb)
23
+ if block_given?
24
+ yield
25
+ else
26
+ instance_eval(content)
27
+ end
28
+ end
29
+ end
30
+
31
+ def method_missing(method_name, ...)
32
+ abstract_method_missing(
33
+ method_name,
34
+ %i[namespace],
35
+ ...
36
+ )
37
+ end
38
+ end
@@ -0,0 +1,32 @@
1
+ module Pangea
2
+ module Utils
3
+ class << self
4
+ def component(kwargs)
5
+ resource(kwargs[:type], kwargs[:name]) do
6
+ kwargs[:attrs].each_key do |k|
7
+ send(k, kwargs[:attrs][k])
8
+ end
9
+ end
10
+ end
11
+
12
+ def pretty(hash)
13
+ JSON.pretty_generate(hash)
14
+ end
15
+
16
+ def symbolize(hash)
17
+ JSON[JSON[hash], symbolize_names: true]
18
+ end
19
+
20
+ # Recursively deep merges two hashes.
21
+ def deep_merge(hash1, hash2)
22
+ hash1.merge(hash2) do |_, old_val, new_val|
23
+ if old_val.is_a?(Hash) && new_val.is_a?(Hash)
24
+ deep_merge(old_val, new_val)
25
+ else
26
+ new_val
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,3 @@
1
+ module PangeaOrchestrator
2
+ VERSION = %(0.0.1).freeze
3
+ end
@@ -0,0 +1,5 @@
1
+ # lib/pangea-orchestrator.rb
2
+
3
+ module PangeaOrchestrator
4
+ autoload :Resources, File.join(__dir__, 'pangea-orchestrator', 'resources')
5
+ end