diff --git a/README.md b/README.md index 39fa2688..73030f59 100644 --- a/README.md +++ b/README.md @@ -333,6 +333,16 @@ filesystem gets recreated from the git sources on each instance refresh. To use To upload your local values to Heroku you could ran `bundle exec rake config:heroku`. +### Working with Cloud Foundry + +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. +You must specify the app name and optionally the name of your CF manifest file: + + bundle exec rake config:cf[app_name, cf_manifest.yml] + +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. + + ### Fine-tuning You can customize how environment variables are processed: diff --git a/lib/config/integrations/cloud_foundry.rb b/lib/config/integrations/cloud_foundry.rb new file mode 100644 index 00000000..c541ee5f --- /dev/null +++ b/lib/config/integrations/cloud_foundry.rb @@ -0,0 +1,27 @@ +require 'bundler' +require 'yaml' +require_relative '../../../lib/config/integrations/helpers/cf_manifest_merger' + +module Config + module Integrations + class CloudFoundry < Struct.new(:app_name, :file_path) + + def invoke + manifest_path = file_path || 'manifest.yml' + file_name, _ext = manifest_path.split('.yml') + + manifest_hash = YAML.load(IO.read(File.join(::Rails.root, manifest_path))) + + puts "Generating manifest... (base cf manifest: #{manifest_path})" + + merged_hash = Config::CFManifestMerger.new(app_name, manifest_hash).add_to_env + + target_manifest_path = File.join(::Rails.root, "#{file_name}-#{::Rails.env}.yml") + IO.write(target_manifest_path, merged_hash.to_yaml) + + puts "File #{target_manifest_path} generated." + end + + end + end +end diff --git a/lib/config/integrations/helpers/cf_manifest_merger.rb b/lib/config/integrations/helpers/cf_manifest_merger.rb new file mode 100644 index 00000000..11b57417 --- /dev/null +++ b/lib/config/integrations/helpers/cf_manifest_merger.rb @@ -0,0 +1,38 @@ +require_relative 'helpers' + +module Config + class CFManifestMerger + include Integrations::Helpers + + def initialize(app_name, manifest_hash) + @app_name = app_name + @manifest_hash = manifest_hash + raise ArgumentError.new("Manifest path & app name must be specified") unless @app_name && @manifest_hash + end + + def add_to_env + + settings_hash = Config.const_get(Config.const_name).to_hash.stringify_keys + + prefix_keys_with_const_name_hash = to_dotted_hash(settings_hash, namespace: Config.const_name) + + app_hash = @manifest_hash['applications'].detect { |hash| hash['name'] == @app_name } + + raise ArgumentError, "Application '#{@app_name}' is not specified in your manifest" if app_hash.nil? + + check_conflicting_keys(app_hash['env'], settings_hash) + + app_hash['env'].merge!(prefix_keys_with_const_name_hash) + + @manifest_hash + end + + private + + def check_conflicting_keys(env_hash, settings_hash) + conflicting_keys = env_hash.keys & settings_hash.keys + raise ArgumentError.new("Conflicting keys: #{conflicting_keys.join(', ')}") if conflicting_keys.any? + end + + end +end \ No newline at end of file diff --git a/lib/config/integrations/helpers/helpers.rb b/lib/config/integrations/helpers/helpers.rb new file mode 100644 index 00000000..449ff443 --- /dev/null +++ b/lib/config/integrations/helpers/helpers.rb @@ -0,0 +1,21 @@ +module Config::Integrations::Helpers + + def to_dotted_hash(source, target: {}, namespace: nil) + raise ArgumentError, "target must be a hash (given: #{target.class.name})" unless target.kind_of? Hash + prefix = "#{namespace}." if namespace + case source + when Hash + source.each do |key, value| + to_dotted_hash(value, target: target, namespace: "#{prefix}#{key}") + end + when Array + source.each_with_index do |value, index| + to_dotted_hash(value, target: target, namespace: "#{prefix}#{index}") + end + else + target[namespace] = source + end + target + end + +end \ No newline at end of file diff --git a/lib/config/integrations/heroku.rb b/lib/config/integrations/heroku.rb index 8e41bc73..d3eafbe0 100644 --- a/lib/config/integrations/heroku.rb +++ b/lib/config/integrations/heroku.rb @@ -1,8 +1,11 @@ require 'bundler' +require_relative 'helpers/helpers' module Config module Integrations class Heroku < Struct.new(:app) + include Integrations::Helpers + def invoke puts 'Setting vars...' heroku_command = "config:set #{vars}" @@ -14,13 +17,13 @@ def invoke def vars # Load only local options to Heroku Config.load_and_set_settings( - Rails.root.join("config", "settings.local.yml").to_s, - Rails.root.join("config", "settings", "#{environment}.local.yml").to_s, - Rails.root.join("config", "environments", "#{environment}.local.yml").to_s + ::Rails.root.join("config", "settings.local.yml").to_s, + ::Rails.root.join("config", "settings", "#{environment}.local.yml").to_s, + ::Rails.root.join("config", "environments", "#{environment}.local.yml").to_s ) out = '' - dotted_hash = to_dotted_hash Kernel.const_get(Config.const_name).to_hash, {}, Config.const_name + dotted_hash = to_dotted_hash Kernel.const_get(Config.const_name).to_hash, namespace: Config.const_name dotted_hash.each {|key, value| out += " #{key}=#{value} "} out end @@ -38,22 +41,6 @@ def `(command) Bundler.with_clean_env { super } end - def to_dotted_hash(source, target = {}, namespace = nil) - prefix = "#{namespace}." if namespace - case source - when Hash - source.each do |key, value| - to_dotted_hash(value, target, "#{prefix}#{key}") - end - when Array - source.each_with_index do |value, index| - to_dotted_hash(value, target, "#{prefix}#{index}") - end - else - target[namespace] = source - end - target - end end end end diff --git a/lib/config/integrations/rails/railtie.rb b/lib/config/integrations/rails/railtie.rb index a3897476..8425eba7 100644 --- a/lib/config/integrations/rails/railtie.rb +++ b/lib/config/integrations/rails/railtie.rb @@ -15,7 +15,7 @@ def preload # Load rake tasks (eg. Heroku) rake_tasks do - Dir[File.join(File.dirname(__FILE__), '../tasks/*.rake')].each { |f| load f } + Dir[File.join(File.dirname(__FILE__), '../../tasks/*.rake')].each { |f| load f } end config.before_configuration { preload } diff --git a/lib/config/tasks/cloud_foundry.rake b/lib/config/tasks/cloud_foundry.rake new file mode 100644 index 00000000..68de16c2 --- /dev/null +++ b/lib/config/tasks/cloud_foundry.rake @@ -0,0 +1,10 @@ +require 'config/integrations/cloud_foundry' + +namespace 'config' do + + desc 'Create a cf manifest with the env variables defined by config under current environment' + task :'cf', [:app_name, :file_path] => :environment do |_, args| + Config::Integrations::CloudFoundry.new(args[:app_name], args[:file_path]).invoke + end + +end diff --git a/lib/config/tasks/heroku.rake b/lib/config/tasks/heroku.rake index caeebe00..2607f5c9 100644 --- a/lib/config/tasks/heroku.rake +++ b/lib/config/tasks/heroku.rake @@ -1,7 +1,10 @@ require 'config/integrations/heroku' namespace 'config' do + + desc 'Upload to Heroku all env variables defined by config under current environment' task :heroku, [:app] => :environment do |_, args| Config::Integrations::Heroku.new(args[:app]).invoke end + end diff --git a/spec/fixtures/cf/cf_conflict.yml b/spec/fixtures/cf/cf_conflict.yml new file mode 100644 index 00000000..31d486c9 --- /dev/null +++ b/spec/fixtures/cf/cf_conflict.yml @@ -0,0 +1,2 @@ +DEFAULT_HOST: host +DEFAULT_PORT: port diff --git a/spec/fixtures/cf/cf_manifest.yml b/spec/fixtures/cf/cf_manifest.yml new file mode 100644 index 00000000..4164ad78 --- /dev/null +++ b/spec/fixtures/cf/cf_manifest.yml @@ -0,0 +1,11 @@ +applications: +- name: some-cf-app + instances: 1 + env: + DEFAULT_HOST: host + DEFAULT_PORT: port + FOO: BAR + +- name: app_name + env: + DEFAULT_HOST: host diff --git a/spec/fixtures/cf/cf_multilevel.yml b/spec/fixtures/cf/cf_multilevel.yml new file mode 100644 index 00000000..57155214 --- /dev/null +++ b/spec/fixtures/cf/cf_multilevel.yml @@ -0,0 +1,13 @@ +world: + capitals: + europe: + germany: 'Berlin' + poland: 'Warsaw' + array: + - name: 'Alan' + - name: 'Gam' + array_with_index: + 0: + name: 'Bob' + 1: + name: 'William' diff --git a/spec/integrations/helpers/cf_manifest_merger_spec.rb b/spec/integrations/helpers/cf_manifest_merger_spec.rb new file mode 100644 index 00000000..bcf815bb --- /dev/null +++ b/spec/integrations/helpers/cf_manifest_merger_spec.rb @@ -0,0 +1,69 @@ +require 'spec_helper' +require_relative '../../../lib/config/integrations/helpers/cf_manifest_merger' + +describe Config::CFManifestMerger do + + after do + Settings.reload_from_files("#{fixture_path}/settings.yml") + end + + it 'raises an argument error if you do not specify an app name' do + expect { + Config::CFManifestMerger.new(nil, load_manifest('cf_manifest.yml')) + }.to raise_error(ArgumentError, 'Manifest path & app name must be specified') + end + + it 'raises an argument error if the application name is not found in the manifest' do + expect { + Config::CFManifestMerger.new('undefined', load_manifest('cf_manifest.yml')).add_to_env + }.to raise_error(ArgumentError, "Application 'undefined' is not specified in your manifest") + end + + it 'returns the cf manifest template if no settings available' do + merger = Config::CFManifestMerger.new('app_name', load_manifest('cf_manifest.yml')) + Config.load_and_set_settings '' + + resulting_hash = merger.add_to_env + expect(resulting_hash).to eq(load_manifest('cf_manifest.yml')) + end + + it 'merges the given YAML file with the cf manifest YAML file' do + merger = Config::CFManifestMerger.new('some-cf-app', load_manifest('cf_manifest.yml')) + Config.load_and_set_settings "#{fixture_path}/cf/cf_multilevel.yml" + + resulting_hash = merger.add_to_env + expect(resulting_hash).to eq({ + "applications" => [ + { + "name" => "some-cf-app", + "instances" => 1, + "env" => { + "DEFAULT_HOST" => "host", + "DEFAULT_PORT" => "port", + "FOO" => "BAR", + "Settings.world.capitals.europe.germany" => "Berlin", + "Settings.world.capitals.europe.poland" => "Warsaw", + "Settings.world.array.0.name" => "Alan", + "Settings.world.array.1.name" => "Gam", + "Settings.world.array_with_index.0.name" => "Bob", + "Settings.world.array_with_index.1.name" => "William" + } + }, + {"name"=>"app_name", "env"=>{"DEFAULT_HOST"=>"host"}} + ] + }) + end + + it 'raises an exception if there is conflicting keys' do + merger = Config::CFManifestMerger.new('some-cf-app', load_manifest('cf_manifest.yml')) + Config.load_and_set_settings "#{fixture_path}/cf/cf_conflict.yml" + + expect { + merger.add_to_env + }.to raise_error(ArgumentError, 'Conflicting keys: DEFAULT_HOST, DEFAULT_PORT') + end + + def load_manifest filename + YAML.load(IO.read("#{fixture_path}/cf/#{filename}")) + end +end \ No newline at end of file diff --git a/spec/integrations/helpers/helpers_spec.rb b/spec/integrations/helpers/helpers_spec.rb new file mode 100644 index 00000000..a51f6a47 --- /dev/null +++ b/spec/integrations/helpers/helpers_spec.rb @@ -0,0 +1,54 @@ +require 'spec_helper' +require_relative '../../../lib/config/integrations/helpers/helpers' + +describe 'Helpers' do + + subject { Class.new.send(:include, Config::Integrations::Helpers).new } + + describe '#to_dotted_hash' do + + context 'only the source is specified' do + + it 'returns a hash with a nil key (default)' do + expect(subject.to_dotted_hash 3).to eq({nil => 3}) + end + end + + context 'with invalid arguments' do + it 'raises an error' do + expect { subject.to_dotted_hash(3, target: [1, 2, 7], namespace: 2) } + .to raise_error(ArgumentError, 'target must be a hash (given: Array)') + end + end + + context 'all arguments specified' do + + it 'returns a hash with the namespace as the key' do + expect(subject.to_dotted_hash(3, namespace: 'ns')).to eq({'ns' => 3}) + end + + it 'returns a new hash with a dotted string key prefixed with namespace' do + expect(subject.to_dotted_hash({hello: {cruel: 'world'}}, namespace: 'ns')) + .to eq({'ns.hello.cruel' => 'world'}) + end + + it 'returns the same hash as passed as a parameter' do + target = {something: 'inside'} + target_id = target.object_id + result = subject.to_dotted_hash(2, target: target, namespace: 'ns') + expect(result).to eq({:something => 'inside', 'ns' => 2}) + expect(result.object_id).to eq target_id + end + + it 'returns a hash when given a source with mixed nested types (hashes & arrays)' do + expect(subject.to_dotted_hash( + {hello: {evil: [:cruel, 'world', and: {dark: 'universe'}]}}, namespace: 'ns')) + .to eq( + {"ns.hello.evil.0" => :cruel, + "ns.hello.evil.1" => "world", + "ns.hello.evil.2.and.dark" => "universe"} + ) + end + end + end +end \ No newline at end of file diff --git a/spec/tasks/cloud_foundry_spec.rb b/spec/tasks/cloud_foundry_spec.rb new file mode 100644 index 00000000..e4ea7230 --- /dev/null +++ b/spec/tasks/cloud_foundry_spec.rb @@ -0,0 +1,65 @@ +require 'spec_helper' + +describe 'config:cf' do + include_context 'rake' + + before :all do + load File.expand_path("../../../lib/config/tasks/cloud_foundry.rake", __FILE__) + Rake::Task.define_task(:environment) + end + + before { allow($stdout).to receive(:puts) } # suppressing console output during testing + + after :all do + Settings.reload_from_files("#{fixture_path}/settings.yml") + end + + it 'creates the merge manifest file for cf' do + Config.load_and_set_settings "#{fixture_path}/cf/cf_multilevel.yml" + + orig_rails_root = Rails.root + + begin + Rails.application.config.root = Dir.mktmpdir + + FileUtils.cp("#{fixture_path}/cf/cf_manifest.yml", File.join(Rails.root, 'manifest.yml')) + + Rake::Task['config:cf'].execute({:app_name => 'app_name'}) + + target_file_path = File.join(Rails.root, 'manifest-test.yml') + target_file_contents = YAML.load(IO.read(target_file_path)) + + expect(target_file_contents["applications"][1]["name"]).to eq "app_name" + expect(target_file_contents["applications"][1]["env"]["DEFAULT_HOST"]).to eq "host" + expect(target_file_contents["applications"][1]["env"]["Settings.world.array.0.name"]).to eq "Alan" + ensure + Rails.application.config.root = orig_rails_root + end + end + + it 'handles a custom manifest name' do + + orig_rails_root = Rails.root + + begin + Rails.application.config.root = Dir.mktmpdir + + FileUtils.cp("#{fixture_path}/cf/cf_manifest.yml", File.join(Rails.root, 'cf_manifest.yml')) + + Rake::Task['config:cf'].execute({app_name: 'app_name', file_path: 'cf_manifest.yml'}) + + target_file_path = File.join(Rails.root, 'cf_manifest-test.yml') + + expect(File.size? target_file_path).to be + + ensure + Rails.application.config.root = orig_rails_root + end + end + + it 'raises an error if the specified file is missing' do + expect { + Rake::Task['config:cf'].execute({app_name: 'app_name', file_path: 'null.yml'}) + }.to raise_error(SystemCallError) + end +end \ No newline at end of file diff --git a/spec/tasks/db_spec.rb b/spec/tasks/db_spec.rb index 7e401642..325ccc3f 100644 --- a/spec/tasks/db_spec.rb +++ b/spec/tasks/db_spec.rb @@ -3,6 +3,8 @@ describe 'db:create' do include_context 'rake' + before { allow($stdout).to receive(:puts) } # suppressing console output during testing + it 'has access to Settings object and can read databases from settings.yml file' do Rake::Task['db:create'].invoke end diff --git a/spec/tasks/heroku_spec.rb b/spec/tasks/heroku_spec.rb new file mode 100644 index 00000000..639d0bb4 --- /dev/null +++ b/spec/tasks/heroku_spec.rb @@ -0,0 +1,15 @@ +require 'spec_helper' + +describe 'config:heroku' do + include_context 'rake' + + before do + load File.expand_path("../../../lib/config/tasks/heroku.rake", __FILE__) + Rake::Task.define_task(:environment) + end + + it 'includes the helper module that defines to_dotted_hash' do + h = Config::Integrations::Heroku.new + expect(h.public_methods(:true)).to include(:to_dotted_hash) + end +end \ No newline at end of file