Skip to content

Commit 137094e

Browse files
Alan YeoPair
Alan Yeo
authored and
Pair
committed
Add support for Cloud Foundry
Signed-off-by: Gamaliel Amaudruz <[email protected]>
1 parent 5e67861 commit 137094e

16 files changed

+348
-21
lines changed

README.md

+10
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,16 @@ filesystem gets recreated from the git sources on each instance refresh. To use
333333

334334
To upload your local values to Heroku you could ran `bundle exec rake config:heroku`.
335335

336+
### Working with Cloud Foundry
337+
338+
Cloud Foundry integration will generate a manifest adding to your CF manifest.yml the defined ENV variables under the `env` section of specified app in the yaml file.
339+
You must specify the app name and optionally the name of your CF manifest file:
340+
341+
bundle exec rake config:cf[app_name, cf_manifest.yml]
342+
343+
The result of this command will have the manifest file name suffixed with the environment you ran the task in. You can then push your app with the generated manifest.
344+
345+
336346
### Fine-tuning
337347

338348
You can customize how environment variables are processed:
+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
require 'bundler'
2+
require 'yaml'
3+
require_relative '../../../lib/config/integrations/helpers/cf_manifest_merger'
4+
5+
module Config
6+
module Integrations
7+
class CloudFoundry < Struct.new(:app_name, :file_path)
8+
9+
def invoke
10+
manifest_path = file_path || 'manifest.yml'
11+
file_name, _ext = manifest_path.split('.yml')
12+
13+
manifest_hash = YAML.load(IO.read(File.join(::Rails.root, manifest_path)))
14+
15+
puts "Generating manifest... (base cf manifest: #{manifest_path})"
16+
17+
merged_hash = Config::CFManifestMerger.new(app_name, manifest_hash).add_to_env
18+
19+
target_manifest_path = File.join(::Rails.root, "#{file_name}-#{::Rails.env}.yml")
20+
IO.write(target_manifest_path, merged_hash.to_yaml)
21+
22+
puts "File #{target_manifest_path} generated."
23+
end
24+
25+
end
26+
end
27+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
require_relative 'helpers'
2+
3+
module Config
4+
class CFManifestMerger
5+
include Integrations::Helpers
6+
7+
def initialize(app_name, manifest_hash)
8+
@app_name = app_name
9+
@manifest_hash = manifest_hash
10+
raise ArgumentError.new("Manifest path & app name must be specified") unless @app_name && @manifest_hash
11+
end
12+
13+
def add_to_env
14+
15+
settings_hash = Config.const_get(Config.const_name).to_hash.stringify_keys
16+
17+
prefix_keys_with_const_name_hash = to_dotted_hash(settings_hash, namespace: Config.const_name)
18+
19+
app_hash = @manifest_hash['applications'].detect { |hash| hash['name'] == @app_name }
20+
21+
raise ArgumentError, "Application '#{@app_name}' is not specified in your manifest" if app_hash.nil?
22+
23+
check_conflicting_keys(app_hash['env'], settings_hash)
24+
25+
app_hash['env'].merge!(prefix_keys_with_const_name_hash)
26+
27+
@manifest_hash
28+
end
29+
30+
private
31+
32+
def check_conflicting_keys(env_hash, settings_hash)
33+
conflicting_keys = env_hash.keys & settings_hash.keys
34+
raise ArgumentError.new("Conflicting keys: #{conflicting_keys.join(', ')}") if conflicting_keys.any?
35+
end
36+
37+
end
38+
end
+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
module Config::Integrations::Helpers
2+
3+
def to_dotted_hash(source, target: {}, namespace: nil)
4+
raise ArgumentError, "target must be a hash (given: #{target.class.name})" unless target.kind_of? Hash
5+
prefix = "#{namespace}." if namespace
6+
case source
7+
when Hash
8+
source.each do |key, value|
9+
to_dotted_hash(value, target: target, namespace: "#{prefix}#{key}")
10+
end
11+
when Array
12+
source.each_with_index do |value, index|
13+
to_dotted_hash(value, target: target, namespace: "#{prefix}#{index}")
14+
end
15+
else
16+
target[namespace] = source
17+
end
18+
target
19+
end
20+
21+
end

lib/config/integrations/heroku.rb

+7-20
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
require 'bundler'
2+
require_relative 'helpers/helpers'
23

34
module Config
45
module Integrations
56
class Heroku < Struct.new(:app)
7+
include Integrations::Helpers
8+
69
def invoke
710
puts 'Setting vars...'
811
heroku_command = "config:set #{vars}"
@@ -14,13 +17,13 @@ def invoke
1417
def vars
1518
# Load only local options to Heroku
1619
Config.load_and_set_settings(
17-
Rails.root.join("config", "settings.local.yml").to_s,
18-
Rails.root.join("config", "settings", "#{environment}.local.yml").to_s,
19-
Rails.root.join("config", "environments", "#{environment}.local.yml").to_s
20+
::Rails.root.join("config", "settings.local.yml").to_s,
21+
::Rails.root.join("config", "settings", "#{environment}.local.yml").to_s,
22+
::Rails.root.join("config", "environments", "#{environment}.local.yml").to_s
2023
)
2124

2225
out = ''
23-
dotted_hash = to_dotted_hash Kernel.const_get(Config.const_name).to_hash, {}, Config.const_name
26+
dotted_hash = to_dotted_hash Kernel.const_get(Config.const_name).to_hash, namespace: Config.const_name
2427
dotted_hash.each {|key, value| out += " #{key}=#{value} "}
2528
out
2629
end
@@ -38,22 +41,6 @@ def `(command)
3841
Bundler.with_clean_env { super }
3942
end
4043

41-
def to_dotted_hash(source, target = {}, namespace = nil)
42-
prefix = "#{namespace}." if namespace
43-
case source
44-
when Hash
45-
source.each do |key, value|
46-
to_dotted_hash(value, target, "#{prefix}#{key}")
47-
end
48-
when Array
49-
source.each_with_index do |value, index|
50-
to_dotted_hash(value, target, "#{prefix}#{index}")
51-
end
52-
else
53-
target[namespace] = source
54-
end
55-
target
56-
end
5744
end
5845
end
5946
end

lib/config/integrations/rails/railtie.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ def preload
1515

1616
# Load rake tasks (eg. Heroku)
1717
rake_tasks do
18-
Dir[File.join(File.dirname(__FILE__), '../tasks/*.rake')].each { |f| load f }
18+
Dir[File.join(File.dirname(__FILE__), '../../tasks/*.rake')].each { |f| load f }
1919
end
2020

2121
config.before_configuration { preload }

lib/config/tasks/cloud_foundry.rake

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
require 'config/integrations/cloud_foundry'
2+
3+
namespace 'config' do
4+
5+
desc 'Create a cf manifest with the env variables defined by config under current environment'
6+
task :'cf', [:app_name, :file_path] => :environment do |_, args|
7+
Config::Integrations::CloudFoundry.new(args[:app_name], args[:file_path]).invoke
8+
end
9+
10+
end

lib/config/tasks/heroku.rake

+3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
require 'config/integrations/heroku'
22

33
namespace 'config' do
4+
5+
desc 'Upload to Heroku all env variables defined by config under current environment'
46
task :heroku, [:app] => :environment do |_, args|
57
Config::Integrations::Heroku.new(args[:app]).invoke
68
end
9+
710
end

spec/fixtures/cf/cf_conflict.yml

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
DEFAULT_HOST: host
2+
DEFAULT_PORT: port

spec/fixtures/cf/cf_manifest.yml

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
applications:
2+
- name: some-cf-app
3+
instances: 1
4+
env:
5+
DEFAULT_HOST: host
6+
DEFAULT_PORT: port
7+
FOO: BAR
8+
9+
- name: app_name
10+
env:
11+
DEFAULT_HOST: host

spec/fixtures/cf/cf_multilevel.yml

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
world:
2+
capitals:
3+
europe:
4+
germany: 'Berlin'
5+
poland: 'Warsaw'
6+
array:
7+
- name: 'Alan'
8+
- name: 'Gam'
9+
array_with_index:
10+
0:
11+
name: 'Bob'
12+
1:
13+
name: 'William'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
require 'spec_helper'
2+
require_relative '../../../lib/config/integrations/helpers/cf_manifest_merger'
3+
4+
describe Config::CFManifestMerger do
5+
6+
after do
7+
Settings.reload_from_files("#{fixture_path}/settings.yml")
8+
end
9+
10+
it 'raises an argument error if you do not specify an app name' do
11+
expect {
12+
Config::CFManifestMerger.new(nil, load_manifest('cf_manifest.yml'))
13+
}.to raise_error(ArgumentError, 'Manifest path & app name must be specified')
14+
end
15+
16+
it 'raises an argument error if the application name is not found in the manifest' do
17+
expect {
18+
Config::CFManifestMerger.new('undefined', load_manifest('cf_manifest.yml')).add_to_env
19+
}.to raise_error(ArgumentError, "Application 'undefined' is not specified in your manifest")
20+
end
21+
22+
it 'returns the cf manifest template if no settings available' do
23+
merger = Config::CFManifestMerger.new('app_name', load_manifest('cf_manifest.yml'))
24+
Config.load_and_set_settings ''
25+
26+
resulting_hash = merger.add_to_env
27+
expect(resulting_hash).to eq(load_manifest('cf_manifest.yml'))
28+
end
29+
30+
it 'merges the given YAML file with the cf manifest YAML file' do
31+
merger = Config::CFManifestMerger.new('some-cf-app', load_manifest('cf_manifest.yml'))
32+
Config.load_and_set_settings "#{fixture_path}/cf/cf_multilevel.yml"
33+
34+
resulting_hash = merger.add_to_env
35+
expect(resulting_hash).to eq({
36+
"applications" => [
37+
{
38+
"name" => "some-cf-app",
39+
"instances" => 1,
40+
"env" => {
41+
"DEFAULT_HOST" => "host",
42+
"DEFAULT_PORT" => "port",
43+
"FOO" => "BAR",
44+
"Settings.world.capitals.europe.germany" => "Berlin",
45+
"Settings.world.capitals.europe.poland" => "Warsaw",
46+
"Settings.world.array.0.name" => "Alan",
47+
"Settings.world.array.1.name" => "Gam",
48+
"Settings.world.array_with_index.0.name" => "Bob",
49+
"Settings.world.array_with_index.1.name" => "William"
50+
}
51+
},
52+
{"name"=>"app_name", "env"=>{"DEFAULT_HOST"=>"host"}}
53+
]
54+
})
55+
end
56+
57+
it 'raises an exception if there is conflicting keys' do
58+
merger = Config::CFManifestMerger.new('some-cf-app', load_manifest('cf_manifest.yml'))
59+
Config.load_and_set_settings "#{fixture_path}/cf/cf_conflict.yml"
60+
61+
expect {
62+
merger.add_to_env
63+
}.to raise_error(ArgumentError, 'Conflicting keys: DEFAULT_HOST, DEFAULT_PORT')
64+
end
65+
66+
def load_manifest filename
67+
YAML.load(IO.read("#{fixture_path}/cf/#{filename}"))
68+
end
69+
end
+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
require 'spec_helper'
2+
require_relative '../../../lib/config/integrations/helpers/helpers'
3+
4+
describe 'Helpers' do
5+
6+
subject { Class.new.send(:include, Config::Integrations::Helpers).new }
7+
8+
describe '#to_dotted_hash' do
9+
10+
context 'only the source is specified' do
11+
12+
it 'returns a hash with a nil key (default)' do
13+
expect(subject.to_dotted_hash 3).to eq({nil => 3})
14+
end
15+
end
16+
17+
context 'with invalid arguments' do
18+
it 'raises an error' do
19+
expect { subject.to_dotted_hash(3, target: [1, 2, 7], namespace: 2) }
20+
.to raise_error(ArgumentError, 'target must be a hash (given: Array)')
21+
end
22+
end
23+
24+
context 'all arguments specified' do
25+
26+
it 'returns a hash with the namespace as the key' do
27+
expect(subject.to_dotted_hash(3, namespace: 'ns')).to eq({'ns' => 3})
28+
end
29+
30+
it 'returns a new hash with a dotted string key prefixed with namespace' do
31+
expect(subject.to_dotted_hash({hello: {cruel: 'world'}}, namespace: 'ns'))
32+
.to eq({'ns.hello.cruel' => 'world'})
33+
end
34+
35+
it 'returns the same hash as passed as a parameter' do
36+
target = {something: 'inside'}
37+
target_id = target.object_id
38+
result = subject.to_dotted_hash(2, target: target, namespace: 'ns')
39+
expect(result).to eq({:something => 'inside', 'ns' => 2})
40+
expect(result.object_id).to eq target_id
41+
end
42+
43+
it 'returns a hash when given a source with mixed nested types (hashes & arrays)' do
44+
expect(subject.to_dotted_hash(
45+
{hello: {evil: [:cruel, 'world', and: {dark: 'universe'}]}}, namespace: 'ns'))
46+
.to eq(
47+
{"ns.hello.evil.0" => :cruel,
48+
"ns.hello.evil.1" => "world",
49+
"ns.hello.evil.2.and.dark" => "universe"}
50+
)
51+
end
52+
end
53+
end
54+
end

0 commit comments

Comments
 (0)