├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── bin ├── command-config-generator ├── generate_sfn_docs └── sfn ├── docs ├── README.md ├── callbacks.md ├── command-config.md ├── commands.md ├── configuration.md ├── images │ ├── d_create.png │ ├── d_create_prompt.png │ ├── d_describe.png │ ├── d_destroy.png │ ├── d_diff.png │ ├── d_events.png │ ├── d_inspect-attribute.png │ ├── d_inspect-nodes.png │ ├── d_list.png │ ├── d_update.png │ └── d_validate.png ├── lint.md ├── overview.md ├── sparkle-packs.md ├── usage.md └── v │ ├── 0.3.2 │ └── marked.js │ ├── bootstrap.min.css │ ├── finalizer.css │ ├── highlight.min.css │ ├── highlight.min.js │ ├── jquery-2.1.3.min.js │ └── loader.js ├── img └── sfn.jpg ├── lib ├── sfn.rb └── sfn │ ├── api_provider.rb │ ├── api_provider │ ├── google.rb │ └── terraform.rb │ ├── cache.rb │ ├── callback.rb │ ├── callback │ ├── aws_assume_role.rb │ ├── aws_mfa.rb │ └── stack_policy.rb │ ├── command.rb │ ├── command │ ├── conf.rb │ ├── create.rb │ ├── describe.rb │ ├── destroy.rb │ ├── diff.rb │ ├── events.rb │ ├── export.rb │ ├── graph.rb │ ├── graph │ │ ├── aws.rb │ │ ├── provider.rb │ │ └── terraform.rb │ ├── import.rb │ ├── init.rb │ ├── inspect.rb │ ├── lint.rb │ ├── list.rb │ ├── plan.rb │ ├── print.rb │ ├── promote.rb │ ├── realize.rb │ ├── trace.rb │ ├── update.rb │ └── validate.rb │ ├── command_module.rb │ ├── command_module │ ├── base.rb │ ├── callbacks.rb │ ├── planning.rb │ ├── stack.rb │ └── template.rb │ ├── config.rb │ ├── config │ ├── conf.rb │ ├── create.rb │ ├── describe.rb │ ├── destroy.rb │ ├── diff.rb │ ├── events.rb │ ├── export.rb │ ├── graph.rb │ ├── import.rb │ ├── init.rb │ ├── inspect.rb │ ├── lint.rb │ ├── list.rb │ ├── plan.rb │ ├── print.rb │ ├── promote.rb │ ├── realize.rb │ ├── trace.rb │ ├── update.rb │ └── validate.rb │ ├── error.rb │ ├── lint.rb │ ├── lint │ ├── definition.rb │ ├── rule.rb │ └── rule_set.rb │ ├── monkey_patch.rb │ ├── monkey_patch │ ├── stack.rb │ └── stack │ │ ├── azure.rb │ │ └── google.rb │ ├── planner.rb │ ├── planner │ └── aws.rb │ ├── provider.rb │ ├── utils.rb │ ├── utils │ ├── debug.rb │ ├── json.rb │ ├── object_storage.rb │ ├── output.rb │ ├── path_selector.rb │ ├── ssher.rb │ ├── stack_exporter.rb │ ├── stack_parameter_scrubber.rb │ └── stack_parameter_validator.rb │ └── version.rb ├── sfn.gemspec └── test ├── helper.rb ├── rspecs.rb ├── rspecs ├── lib │ ├── callback │ │ └── stack_policy_rspec.rb │ └── command_module │ │ ├── callbacks_rspec.rb │ │ └── stack_rspec.rb └── sfn_rspec.rb └── specs ├── command ├── create_spec.rb ├── describe_spec.rb ├── lint_spec.rb ├── print_spec.rb └── sparkleformation │ ├── dummy.rb │ ├── dummy_azure.rb │ ├── dummy_google.rb │ ├── lint_invalid.rb │ ├── lint_valid.rb │ ├── nested_dummy.rb │ ├── nested_dummy_azure.rb │ └── nested_dummy_google.rb ├── command_module ├── stack_spec.rb └── template_spec.rb ├── config ├── fail-auto-json │ └── .sfn ├── fail-auto-ruby │ └── .sfn ├── fail-auto-yaml │ └── .sfn ├── json-ext │ └── .sfn.json ├── no-ext │ └── .sfn ├── ruby-ext │ └── .sfn.rb └── yaml-ext │ └── .sfn ├── config_spec.rb ├── planner_spec.rb ├── sfn_bin_spec.rb └── utils └── stack_parameter_validator_spec.rb /.gitignore: -------------------------------------------------------------------------------- 1 | .* 2 | !.gitignore 3 | *.gem 4 | Gemfile.lock 5 | !*.sfn 6 | vendor* -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.3 4 | - 2.4 5 | - 2.5 6 | - 2.6 7 | script: bundle exec rake 8 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Branches 4 | 5 | ### `master` branch 6 | 7 | The master branch is the current stable released version. 8 | 9 | ### `develop` branch 10 | 11 | The develop branch is the current edge of development. 12 | 13 | ## Pull requests 14 | 15 | * https://github.com/sparkleformation/sfn/pulls 16 | 17 | Please base all pull requests off the `develop` branch. Merges to 18 | `master` only occur through the `develop` branch. Pull requests 19 | based on `master` will likely be cherry picked. 20 | 21 | ## Tests 22 | 23 | Add a test to your changes. 24 | Tests can be run with bundler: 25 | 26 | ``` 27 | bundle 28 | bundle exec rake 29 | ``` 30 | 31 | ## Issues 32 | 33 | Need to report an issue? Use the github issues: 34 | 35 | * https://github.com/sparkleformation/sfn/issues 36 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![SparkleFormation CLI](img/sfn.jpg) 2 | 3 | # SparkleFormation CLI 4 | 5 | SparkleFormation command line interface for interacting 6 | with orchestration APIs. 7 | 8 | ## API Compatibility 9 | 10 | * AWS 11 | * Azure 12 | * Google 13 | * Heat 14 | * OpenStack 15 | * Rackspace 16 | * Terraform 17 | 18 | ## Documentation 19 | 20 | * [User Documentation](http://www.sparkleformation.io/docs/sfn/) 21 | * [sfn API Documentation](http://www.sparkleformation.io/docs/sfn/) 22 | 23 | # Info 24 | 25 | * Repository: https://github.com/sparkleformation/sfn 26 | * Website: http://www.sparkleformation.io/docs/sfn/ 27 | * Mailing List: https://groups.google.com/forum/#!forum/sparkleformation 28 | * IRC: [#sparkleformation @ Freenode](https://webchat.freenode.net/?channels=#sparkleformation) 29 | * Gitter: [![Join at https://gitter.im/SparkleFormation/sparkleformation](https://badges.gitter.im/SparkleFormation/sparkleformation.svg)](https://gitter.im/SparkleFormation/sparkleformation?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 30 | 31 | 32 | [miasma]: http://miasma-rb.github.io/miasma/ 33 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require 'rake/testtask' 3 | require 'rspec/core/rake_task' 4 | 5 | RSpec::Core::RakeTask.new(:spec) do |task| 6 | task.rspec_opts = "--pattern test/rspecs/**{,/*/**}/*_rspec.rb" 7 | end 8 | 9 | Rake::TestTask.new do |test| 10 | test.pattern = 'test/**/*_spec.rb' 11 | test.verbose = true 12 | end 13 | 14 | namespace :rufo do 15 | desc 'Validate Ruby file formatting' 16 | task :validate => [] do 17 | base_path = File.dirname(__FILE__) 18 | [ 19 | File.join(base_path, 'lib'), 20 | File.join(base_path, 'test'), 21 | ].each do |path| 22 | if !system("rufo -c #{path}") 23 | $stderr.puts "Files in #{path} directory require formatting!" 24 | $stderr.puts " - Run `rake rufo:fmt`" 25 | exit -1 26 | end 27 | end 28 | end 29 | 30 | desc 'Format Ruby files in this project' 31 | task :fmt => [] do 32 | base_path = File.dirname(__FILE__) 33 | [ 34 | File.join(base_path, 'lib'), 35 | File.join(base_path, 'test'), 36 | ].each do |path| 37 | $stdout.puts "Formatting files in #{path} directory..." 38 | system("rufo #{path}") 39 | if $?.exitstatus != 0 && $?.exitstatus != 3 40 | $stderr.puts "ERROR: Formatting files in #{path} failed!" 41 | exit -1 42 | end 43 | end 44 | $stdout.puts " -> File formatting complete!" 45 | end 46 | end 47 | 48 | desc 'Run all tests' 49 | task :default => [] do 50 | Rake::Task['rufo:validate'].invoke 51 | Rake::Task[:spec].invoke 52 | Rake::Task[:test].invoke 53 | end 54 | -------------------------------------------------------------------------------- /bin/command-config-generator: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "sfn" 4 | 5 | print "Generating command and configuration page..." 6 | 7 | klasses = Sfn::Config.constants.sort.map do |const| 8 | klass = Sfn::Config.const_get(const) 9 | klass if klass.is_a?(Class) && klass.respond_to?(:attributes) 10 | end.compact 11 | 12 | file = File.open(File.join(File.dirname(__FILE__), "..", "docs", "command-config.md"), "w") 13 | 14 | file.puts <<-EOS 15 | --- 16 | title: "Configuration" 17 | weight: 6 18 | anchors: 19 | EOS 20 | 21 | klasses.each do |klass| 22 | file.puts " - title: \"#{klass.name.split("::").last} Command\"" 23 | file.puts " url: \"##{klass.name.split("::").last.downcase}-command\"" 24 | end 25 | file.puts "---" 26 | file.puts <<-EOS 27 | 28 | # Command configurations 29 | 30 | This lists commands and the options which the commands accept. It also 31 | includes the valid types which the options will accept. Options can be 32 | set with the `.sfn` configuration file to apply default values which 33 | can be overridden on the CLI. Options defined within the `.sfn` configuration 34 | file are the option name with `-` characters replaced with `_`. 35 | 36 | For example, the option `--apply-mapping` can be defined in the `.sfn` 37 | configuration file as: 38 | 39 | ~~~ruby 40 | Configuration.new do 41 | apply_mapping true 42 | end 43 | ~~~ 44 | 45 | EOS 46 | 47 | klasses.each do |klass| 48 | file.puts "## #{klass.name.split("::").last} Command" 49 | file.puts 50 | file.puts "~~~" 51 | file.puts "$ sfn #{klass.name.split("::").last.downcase}" 52 | file.puts "~~~" 53 | file.puts "\n" 54 | file.puts "| Option | Attribute | Value" 55 | file.puts "|--------|-----------|------" 56 | klass.attributes.sort_by(&:first).map do |name, value| 57 | file.puts "| `--#{name.to_s.tr("_", "-")}` | Description | #{value[:description]} |" 58 | file.puts "| | Valid | `#{[value[:type]].flatten.compact.join("`, `")}` |" 59 | file.puts "| | Default | #{value[:default].inspect unless value[:default].nil?}|\n" 60 | end 61 | file.puts 62 | end 63 | 64 | puts "done" 65 | -------------------------------------------------------------------------------- /bin/generate_sfn_docs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'fileutils' 3 | 4 | unless(system("yardoc")) 5 | $stderr.puts 'ACK: Failed to create docs!' 6 | exit -1 7 | end 8 | 9 | FileUtils.mkdir_p('doc/UserDocs') 10 | 11 | Dir.glob('docs/**/*').each do |path| 12 | next unless File.file?(path) 13 | content = File.read(path) 14 | rel_path = path.sub(/.*?docs\//, '') 15 | new_path = File.join('doc/UserDocs', rel_path) 16 | user_doc_root = (['..'] * rel_path.scan('/').size).join('/') 17 | unless(user_doc_root.to_s.empty?) 18 | user_doc_root << '/' 19 | end 20 | FileUtils.mkdir_p(File.dirname(new_path)) 21 | File.open(new_path, 'w') do |file| 22 | file.puts content 23 | end 24 | if(new_path.end_with?('.md')) 25 | File.open(new_path.sub('.md', '.html'), 'w') do |file| 26 | file.print "SparkleFormation CLI User Documentation" 27 | file.print "
" 28 | file.print "" 29 | end 30 | end 31 | end 32 | 33 | File.open('doc/UserDocs/index.html', 'w') do |file| 34 | file.puts '' 35 | end 36 | 37 | FileUtils.mkdir('doc/img') 38 | FileUtils.cp('img/sfn.jpg', 'doc/img/') 39 | 40 | puts 'done.' 41 | -------------------------------------------------------------------------------- /bin/sfn: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bogo-cli" 4 | require "sfn" 5 | 6 | if defined?(Bundler) 7 | begin 8 | Bundler.require(:sfn) 9 | rescue Bundler::GemfileNotFound 10 | # ignore load error 11 | end 12 | end 13 | 14 | Bogo::Cli::Setup.define do 15 | on :v, :version, "Print version " do 16 | puts "sfn - SparkleFormation CLI - [Version: #{Sfn::VERSION}]" 17 | exit 18 | end 19 | 20 | Sfn::Config.constants.map do |konst| 21 | const = Sfn::Config.const_get(konst) 22 | if const.is_a?(Class) && const.ancestors.include?(Bogo::Config) 23 | const 24 | end 25 | end.compact.sort_by(&:to_s).each do |klass| 26 | klass_name = klass.name.split("::").last.downcase 27 | 28 | command klass_name do 29 | if klass.const_defined?(:DESCRIPTION) 30 | description klass.const_get(:DESCRIPTION) 31 | end 32 | 33 | Sfn::Config.options_for(klass).each do |name, info| 34 | on_name = info[:boolean] ? info[:long] : "#{info[:long]}=" 35 | opts = Hash.new.tap do |o| 36 | o[:default] = info[:default] if info.has_key?(:default) 37 | if info[:multiple] 38 | o[:as] = Array 39 | o[:delimiter] = nil 40 | end 41 | end 42 | if info[:short] 43 | on info[:short], on_name, info[:description], opts 44 | else 45 | on on_name, info[:description], opts 46 | end 47 | end 48 | 49 | run do |opts, args| 50 | command = Bogo::Utility.constantize(klass.to_s.sub("Config", "Command")).new(opts, args) 51 | begin 52 | command.execute! 53 | rescue Bogo::Ui::ConfirmationDeclined 54 | command.ui.error "Confirmation declined!" 55 | exit 1 56 | end 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | ## Table of Contents 2 | 3 | - [Overview](overview.md) 4 | - [Feature Summary](overview.md#feature-summary) 5 | - [Installation](overview.md#installation) 6 | - [Configuration](configuration.md) 7 | - [sfn based](configuration.md#sfn-based) 8 | - [knife based](configuration.md#knife-based) 9 | - [Usage](usage.md) 10 | - [General Commands](usage.md#commands) 11 | - [Commands List](command-config.md) 12 | - [Callbacks](callbacks.md) 13 | - [Enabling Callbacks](callbacks.md#enabling-callbacks) 14 | - [Builtin Callbacks](callbacks.md#builtin-callbacks) 15 | - [Custom Callbacks](callbacks.md#custom-callbacks) 16 | - [SparklePacks](sparkle-packs.md) 17 | -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Configuration" 3 | weight: 2 4 | anchors: 5 | - title: "sfn-based" 6 | url: "#sfn-based" 7 | - title: "knife-based" 8 | url: "#knife-based" 9 | - title: "Generate sfn configuration" 10 | url: "#generate-sfn-configuration" 11 | - title: "configuration-options" 12 | url: "#configuration-options" 13 | --- 14 | 15 | 16 | ## Configuration 17 | 18 | The configuration location of the `sfn` command is 19 | dependent on the invocation method used. Since the 20 | CLI application can be invoked as a standalone 21 | application, or as a knife subcommand, two styles 22 | of configuration are supported. 23 | 24 | ### `sfn`-based 25 | 26 | Configuration for the `sfn` standalone application 27 | utilizes the bogo-config library. This allows the 28 | configuration file to be defined in multiple formats. 29 | Supported formats: 30 | 31 | * Ruby 32 | * YAML 33 | * JSON 34 | * XML 35 | 36 | The configuration is contained within a file named 37 | `.sfn`. 38 | 39 | #### Generate sfn configuration 40 | 41 | The `sfn` command provides a `conf` subcommand. By 42 | default this command will display current local 43 | configuration values. It can also be used to generate 44 | an initial configuration file which can then be 45 | customized: 46 | 47 | ~~~ 48 | $ sfn conf --generate 49 | ~~~ 50 | 51 | This will create a new `.sfn` file in the working directory. 52 | 53 | ### `knife`-based 54 | 55 | The `sfn` application includes a plugin for the 56 | [knife][knife] CLI tool. Configuration can be 57 | provided in the `.chef/knife.rb` file and commands 58 | can be accessed via: 59 | 60 | ~~~ 61 | $ knife sparkleformation --help 62 | ~~~ 63 | 64 | [knife]: https://docs.chef.io/knife.html 65 | -------------------------------------------------------------------------------- /docs/images/d_create.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sparkleformation/sfn/cc601844a4208613803f784759242e88ae6c3546/docs/images/d_create.png -------------------------------------------------------------------------------- /docs/images/d_create_prompt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sparkleformation/sfn/cc601844a4208613803f784759242e88ae6c3546/docs/images/d_create_prompt.png -------------------------------------------------------------------------------- /docs/images/d_describe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sparkleformation/sfn/cc601844a4208613803f784759242e88ae6c3546/docs/images/d_describe.png -------------------------------------------------------------------------------- /docs/images/d_destroy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sparkleformation/sfn/cc601844a4208613803f784759242e88ae6c3546/docs/images/d_destroy.png -------------------------------------------------------------------------------- /docs/images/d_diff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sparkleformation/sfn/cc601844a4208613803f784759242e88ae6c3546/docs/images/d_diff.png -------------------------------------------------------------------------------- /docs/images/d_events.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sparkleformation/sfn/cc601844a4208613803f784759242e88ae6c3546/docs/images/d_events.png -------------------------------------------------------------------------------- /docs/images/d_inspect-attribute.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sparkleformation/sfn/cc601844a4208613803f784759242e88ae6c3546/docs/images/d_inspect-attribute.png -------------------------------------------------------------------------------- /docs/images/d_inspect-nodes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sparkleformation/sfn/cc601844a4208613803f784759242e88ae6c3546/docs/images/d_inspect-nodes.png -------------------------------------------------------------------------------- /docs/images/d_list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sparkleformation/sfn/cc601844a4208613803f784759242e88ae6c3546/docs/images/d_list.png -------------------------------------------------------------------------------- /docs/images/d_update.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sparkleformation/sfn/cc601844a4208613803f784759242e88ae6c3546/docs/images/d_update.png -------------------------------------------------------------------------------- /docs/images/d_validate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sparkleformation/sfn/cc601844a4208613803f784759242e88ae6c3546/docs/images/d_validate.png -------------------------------------------------------------------------------- /docs/lint.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Lint" 3 | weight: 8 4 | anchors: 5 | - title: "Lint Framework" 6 | url: "#lint-framework" 7 | - title: "Composition" 8 | url: "#composition" 9 | - title: "Usage" 10 | url: "#usage" 11 | --- 12 | 13 | ## Lint 14 | 15 | The lint framework built within the sfn tool utilizes the [JMESPath][jmespath] query language 16 | for identifying patterns and apply validation rules. 17 | 18 | ### Lint Framework 19 | 20 | A rule set is a named collection of rules to be applied to a template. Each rule 21 | is composed of one or more definitions. A rule passes only if _all_ definitions 22 | can be successfully applied. Linting related classes: 23 | 24 | * `Sfn::Lint::RuleSet` 25 | * `Sfn::Lint::Rule` 26 | * `Sfn::Lint::Definition` 27 | 28 | ### Composition 29 | 30 | #### Long Form 31 | 32 | ##### `Sfn::Lint::Definition` 33 | 34 | Definitions define a search expression to be applied to a given template. The search 35 | expression is a [JMESPath compatible query expression][jmespath-expr]. The matches 36 | of the search expression are then processed. If the results are valid, a `true` result 37 | is expected. If the results are invalid, a `false` value is expected, or an `Array` 38 | value is expected which provides the list of invalid items. 39 | 40 | ~~~ruby 41 | Sfn::Lint::Definition.new('Resources.[*][0][*].Type') do |matches, template| 42 | unless(search.nil?) 43 | result = search.find_all{|i| !i.start_with?('AWS')} 44 | result.empty? ? true : result 45 | else 46 | true 47 | end 48 | end 49 | ~~~ 50 | 51 | The processing block is provided two arguments. The first is the match result of the 52 | search expression. The second is the full template `Hash` that is being processed. 53 | 54 | ##### `Sfn::Lint::Rule` 55 | 56 | Rules are composed of definitions. When a rule is applied to a template it will only 57 | pass if _all_ definitions are successfully applied. A rule also includes a failure 58 | message to provide user context of the failure. 59 | 60 | ~~~ruby 61 | definition = Sfn::Lint::Definition.new('Resources.[*][0][*].Type') do |matches, template| 62 | unless(search.nil?) 63 | result = search.find_all{|i| !i.start_with?('AWS')} 64 | result.empty? ? true : result 65 | else 66 | true 67 | end 68 | end 69 | 70 | Sfn::Lint::Rule.new( 71 | :aws_resources_only, 72 | [definition], 73 | 'All types must be within AWS root namespace' 74 | ) 75 | ~~~ 76 | 77 | ##### `Sfn::Lint::RuleSet` 78 | 79 | A rule set is a named collection of rules. It allows logically grouping related 80 | rules together. Rule sets are the entry point of linting actions on templates. Once 81 | a rule set has been created, it must then be registered to be made available. 82 | 83 | ~~~ruby 84 | definition = Sfn::Lint::Definition.new('Resources.[*][0][*].Type') do |matches, template| 85 | unless(search.nil?) 86 | result = search.find_all{|i| !i.start_with?('AWS')} 87 | result.empty? ? true : result 88 | else 89 | true 90 | end 91 | end 92 | 93 | rule = Sfn::Lint::Rule.new( 94 | :aws_resources_only, 95 | [definition], 96 | 'All types must be within AWS root namespace' 97 | ) 98 | 99 | rule_set = Sfn::Lint::RuleSet.new(:aws_rules, [rule]) 100 | Sfn::Lint::RuleSet.register(rule_set) 101 | ~~~ 102 | 103 | #### Short Form 104 | 105 | Rule sets can also be created using a generator which reduces the amount of effort required 106 | for composition. The same rule set defined above can be created using the `RuleSet.build` 107 | generator: 108 | 109 | ~~~ruby 110 | rule_set = Sfn::Lint::RuleSet.build(:aws_rules) do 111 | rule :aws_resources_only do 112 | definition 'Resources.[*][0][*].Type' do |search| 113 | unless(search.nil?) 114 | result = search.find_all{|i| !i.start_with?('AWS')} 115 | result.empty? ? true : result 116 | else 117 | true 118 | end 119 | end 120 | 121 | fail_message 'All types must be within AWS root namespace' 122 | end 123 | end 124 | 125 | Sfn::Lint::RuleSet.register(rule_set) 126 | ~~~ 127 | 128 | ### Usage 129 | 130 | Linting functionality is available via the `lint` command. The only requirement of the `lint` 131 | command is a template provided by the `--file` flag: 132 | 133 | ~~~ 134 | $ sfn lint --file my-template 135 | ~~~ 136 | 137 | By default all registered rule sets applicable to the template will be applied. Rule sets can 138 | be disabled by name to prevent them from being applied: 139 | 140 | ~~~ 141 | $ sfn lint --file my-template --disable-rule-set aws_rules 142 | ~~~ 143 | 144 | or you can explicitly specify what rule sets should be applied: 145 | 146 | ~~~ 147 | $ sfn lint --file my-template --enable-rule-set aws_rules 148 | ~~~ 149 | 150 | #### Local rule sets 151 | 152 | Rule sets can be created for a local project. These rule sets are kept within a directory, and 153 | should be defined as a single rule set per file. For example, having a directory `tests/lint` 154 | the rule set can be created: 155 | 156 | ~~~ruby 157 | # tests/lint/resource_type_check.rb 158 | RuleSet.build(:aws_rules) do 159 | rule :aws_resources_only do 160 | definition 'Resources.[*][0][*].Type' do |search| 161 | unless(search.nil?) 162 | result = search.find_all{|i| !i.start_with?('AWS')} 163 | result.empty? ? true : result 164 | else 165 | true 166 | end 167 | end 168 | 169 | fail_message 'All types must be within AWS root namespace' 170 | end 171 | end 172 | ~~~ 173 | 174 | To include the local rule sets the target directory must be provided: 175 | 176 | ~~~ 177 | $ sfn lint --file my-template --lint-directory tests/lint 178 | ~~~ 179 | 180 | and if _only_ local rule sets should be applied, it is possible to disable all registered 181 | rule sets: 182 | 183 | ~~~ 184 | $ sfn lint --file my-template --lint-directory tests/lint --local-rule-sets-only 185 | ~~~ 186 | 187 | [jmespath]: http://jmespath.org/ 188 | [jmespath-expr]: http://jmespath.org/specification.html -------------------------------------------------------------------------------- /docs/overview.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Overview" 3 | weight: 1 4 | --- 5 | 6 | # Overview 7 | 8 | The SparkleFormation CLI (`sfn`) is a Ruby based command line interface 9 | for interacting with remote orchestration API. It is an application 10 | implementation of the SparkleFormation library and provides access to 11 | all the underlying features provided by the SparkleFormation library. 12 | 13 | ## Feature Summary 14 | 15 | Notable features available via the SparkleFormation CLI: 16 | 17 | - SparkleFormation template processing 18 | - Template processing helpers 19 | - Custom callback support 20 | - Remote orchestration API support 21 | - AWS CloudFormation 22 | - Eucalyptus 23 | - Rackspace Orchestration 24 | - OpenStack Heat 25 | - Google Cloud Deployment Manager 26 | - Chef `knife` plugin support 27 | - Deep resource inspection 28 | 29 | ## Installation 30 | 31 | The SparkleFormation CLI is available from [Ruby Gems](https://rubygems.org/gems/sfn). To install, simply execute: 32 | 33 | ~~~sh 34 | $ gem install sfn 35 | ~~~ 36 | 37 | or, if you use [Bundler](http://bundler.io/), add the following to your Gemfile: 38 | 39 | ~~~sh 40 | gem 'sfn' 41 | ~~~ 42 | 43 | See [Configuration](configuration.md) and [Usage](usage.md) for further instructions. 44 | -------------------------------------------------------------------------------- /docs/sparkle-packs.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "SparklePacks" 3 | weight: 6 4 | anchors: 5 | - title: "Enabling SparklePacks" 6 | url: "#enabling-sparklepacks" 7 | --- 8 | 9 | ## What is a SparklePack? 10 | 11 | SparklePacks are implemented as a feature of the SparkleFormation library, 12 | providing a means to package SparkleFormation building blocks 13 | and templates as reusable, redistributable software artifacts. 14 | A SparklePack may package up any combination of SparkleFormation 15 | [building blocks](http://www.sparkleformation.io/docs/sparkle_formation/building-blocks.html) and templates. 16 | 17 | sfn supports loading SparklePacks distributed as [Ruby gems](http://www.sparkleformation.io/docs/sparkle_formation/sparkle-packs.html#distribution). 18 | You can find published SparklePacks on the RubyGems site by 19 | [searching for the sparkle-pack prefix](https://rubygems.org/search?query=sparkle-pack). 20 | 21 | ### Enabling SparklePacks 22 | 23 | The following examples use the [sparkle-pack-aws-availability-zones](https://rubygems.org/gems/sparkle-pack-aws-availability-zones) gem. 24 | In reviewing [the source code of that project on Github](https://github.com/hw-labs/sparkle-pack-aws-availability-zones), 25 | note that it provides a [`zones` registry](https://github.com/hw-labs/sparkle-pack-aws-availability-zones/blob/v0.1.2/lib/sparkleformation/registry/get_azs.rb) 26 | which uses the aws-sdk-core library to return an array of available AZs. 27 | 28 | When using sfn with Bundler, we'll add any SparklePacks we 29 | want to enable to the `sfn` group in our Gemfile: 30 | 31 | ~~~ruby 32 | # Gemfile 33 | source 'https://rubygems.org' 34 | 35 | gem 'sfn' 36 | 37 | group :sfn do 38 | gem 'sparkle-pack-aws-availability-zones' 39 | end 40 | ~~~ 41 | 42 | After running `bundle`, the SparklePack is installed but not yet enabled: 43 | 44 | ~~~ 45 | $ cat sparkleformation/zones_test.rb 46 | SparkleFormation.new(:zones_test) do 47 | zones registry!(:zones) 48 | end 49 | 50 | $ bundle exec sfn print --file zones_test 51 | ERROR: SparkleFormation::Error::NotFound::Registry: Failed to locate item named: `zones` 52 | ~~~ 53 | 54 | Adding the gem to an array of `sparkle_packs` in 55 | the `.sfn` configuration file will activate it for use: 56 | 57 | ~~~ruby 58 | Configuration.new do 59 | sparkle_pack [ 'sparkle-pack-aws-availability-zones' ] 60 | end 61 | ~~~ 62 | 63 | Invoking `zones` registry in the template is now functional: 64 | 65 | ~~~ 66 | $ bundle exec sfn print --file zones_test 67 | { 68 | "Zones": [ 69 | "us-east-1a", 70 | "us-east-1b", 71 | "us-east-1c", 72 | "us-east-1e" 73 | ] 74 | } 75 | ~~~ -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Usage" 3 | weight: 3 4 | anchors: 5 | - title: "Directory Structure" 6 | url: "#directory-structure" 7 | - title: "Template Commands" 8 | url: "#template-commands" 9 | - title: "Stack Commands" 10 | url: "#stack-commands" 11 | --- 12 | 13 | ## Usage 14 | 15 | The `sfn` command can be invoked in two ways. The 16 | first is directly: 17 | 18 | ~~~ 19 | $ sfn --help 20 | ~~~ 21 | 22 | The second is via the [knife][knife] plugin: 23 | 24 | ~~~ 25 | $ knife sparkleformation --help 26 | ~~~ 27 | 28 | Both invocations will generate the same result. The 29 | direct `sfn` command can be preferable as it does not 30 | require loading outside libraries nor does it traverse 31 | the filesystem loading plugins. Because of this, the 32 | direct command will generally be faster than the knife 33 | plugin. 34 | 35 | ### Directory Structure 36 | 37 | The `sfn` command utilizes the [SparkleFormation][sparkle_formation] 38 | library and supports template compilation. To use SparkleFormation, 39 | just create the directory structure within the local project 40 | working directory: 41 | 42 | ~~~ 43 | > tree 44 | . 45 | |____sparkleformation 46 | | |____dynamics 47 | | |____components 48 | | |____registry 49 | ~~~ 50 | 51 | ### Commands 52 | 53 | #### Template Commands 54 | 55 | These are the commands that support an orchestration template. 56 | By default, `sfn` does not enable the [SparkleFormation][sparkle_formation] 57 | integration. This means that any innvocation when a template is 58 | required _must_ provide a path to the serialized document using 59 | the `--file` option. 60 | 61 | To enable the [SparkleFormation][sparkle_formation] integration 62 | simply include the `---processing` flag, or enable it via the 63 | configuration file: 64 | 65 | ~~~ruby 66 | Configuration.new do 67 | processing true 68 | end 69 | ~~~ 70 | 71 | When processing is enabled and no path is provided via the `--file` 72 | option, `sfn` will prompt for template selection allowing the user 73 | to choose from local templates, as well as any templates distributed 74 | in loaded [SparklePacks][sparkle_packs]. 75 | 76 | Available template related commands: 77 | 78 | * `sfn create` 79 | * `sfn plan` 80 | * `sfn update` 81 | * `sfn validate` 82 | 83 | The `sfn` command supports the advanced nesting functionality provided 84 | by the [SparkleFormation][sparkle_formation] library. There are two 85 | styles of nesting functionality available: shallow and deep. The required 86 | style can be set via the configuration file: 87 | 88 | ~~~ruby 89 | Configuration.new do 90 | apply_nesting 'deep' 91 | end 92 | ~~~ 93 | 94 | The default nesting functionality is `"deep"`. To learn more about 95 | the nesting functionality please refer to the [SparkleFormation nested 96 | stacks][nested_stacks] documentation. 97 | 98 | When using nested stacks, a bucket is required for storage of the 99 | nested stack templates. `sfn` will automatically store nested templates 100 | into the defined bucket, but the bucket name _must_ be provided and 101 | the bucket _must_ exist. The bucket name can be defined within the 102 | configuration: 103 | 104 | ~~~ruby 105 | Configuration.new do 106 | nesting_bucket 'my-nested-templates' 107 | end 108 | ~~~ 109 | 110 | #### Stack Commands 111 | 112 | These commands are used for inspection or removal of existing stacks: 113 | 114 | * `sfn describe` 115 | * `sfn inspect` 116 | * `sfn diff` 117 | * `sfn events` 118 | * `sfn destroy` 119 | 120 | While the `describe` command is good for an overview of a stack contents 121 | (resources and outputs), the `inspect` command allows for deeper inspection 122 | of a given stack. The `--attribute` option allows access to the underlying 123 | data model that represents the given resource and can be inspected for 124 | information. The data modeling is provided by the [miasma][miasma] cloud 125 | library which can be referenced for supported methods available. As an 126 | example, given an AWS CloudFormation stack with a single EC2 resource, 127 | the `inspect` command can be used to provide all addresses associated 128 | with the instance: 129 | 130 | ~~~ 131 | $ sfn inspect my-stack --attribute 'resources.all.at(0).expand.addresses' 132 | ~~~ 133 | 134 | [knife]: https://docs.chef.io/knife.html 135 | [sparkle_formation]: https://github.com/sparkleformation/sparkle_formation 136 | [nested_stacks]: http://www.sparkleformation.io/docs/sparkle_formation/nested-stacks.html 137 | [sparkle_packs]: https://sparkleformation.github.io/sparkle_formation/UserDocs/sparkle-packs.html 138 | [miasma]: https://github.com/miasma-rb/miasma 139 | -------------------------------------------------------------------------------- /docs/v/finalizer.css: -------------------------------------------------------------------------------- 1 | .markdown-body { 2 | min-width: 200px; 3 | max-width: 790px; 4 | margin: 0 auto; 5 | padding-bottom: 30px; 6 | } 7 | -------------------------------------------------------------------------------- /docs/v/highlight.min.css: -------------------------------------------------------------------------------- 1 | .hljs{display:block;overflow-x:auto;padding:0.5em;background:#f0f0f0;-webkit-text-size-adjust:none}.hljs,.hljs-subst,.hljs-tag .hljs-title,.nginx .hljs-title{color:black}.hljs-string,.hljs-title,.hljs-constant,.hljs-parent,.hljs-tag .hljs-value,.hljs-rules .hljs-value,.hljs-preprocessor,.hljs-pragma,.haml .hljs-symbol,.ruby .hljs-symbol,.ruby .hljs-symbol .hljs-string,.hljs-template_tag,.django .hljs-variable,.smalltalk .hljs-class,.hljs-addition,.hljs-flow,.hljs-stream,.bash .hljs-variable,.apache .hljs-tag,.apache .hljs-cbracket,.tex .hljs-command,.tex .hljs-special,.erlang_repl .hljs-function_or_atom,.asciidoc .hljs-header,.markdown .hljs-header,.coffeescript .hljs-attribute{color:#800}.smartquote,.hljs-comment,.hljs-annotation,.diff .hljs-header,.hljs-chunk,.asciidoc .hljs-blockquote,.markdown .hljs-blockquote{color:#888}.hljs-number,.hljs-date,.hljs-regexp,.hljs-literal,.hljs-hexcolor,.smalltalk .hljs-symbol,.smalltalk .hljs-char,.go .hljs-constant,.hljs-change,.lasso .hljs-variable,.makefile .hljs-variable,.asciidoc .hljs-bullet,.markdown .hljs-bullet,.asciidoc .hljs-link_url,.markdown .hljs-link_url{color:#080}.hljs-label,.hljs-javadoc,.ruby .hljs-string,.hljs-decorator,.hljs-filter .hljs-argument,.hljs-localvars,.hljs-array,.hljs-attr_selector,.hljs-important,.hljs-pseudo,.hljs-pi,.haml .hljs-bullet,.hljs-doctype,.hljs-deletion,.hljs-envvar,.hljs-shebang,.apache .hljs-sqbracket,.nginx .hljs-built_in,.tex .hljs-formula,.erlang_repl .hljs-reserved,.hljs-prompt,.asciidoc .hljs-link_label,.markdown .hljs-link_label,.vhdl .hljs-attribute,.clojure .hljs-attribute,.asciidoc .hljs-attribute,.lasso .hljs-attribute,.coffeescript .hljs-property,.hljs-phony{color:#88f}.hljs-keyword,.hljs-id,.hljs-title,.hljs-built_in,.css .hljs-tag,.hljs-javadoctag,.hljs-phpdoc,.hljs-dartdoc,.hljs-yardoctag,.smalltalk .hljs-class,.hljs-winutils,.bash .hljs-variable,.apache .hljs-tag,.hljs-type,.hljs-typename,.tex .hljs-command,.asciidoc .hljs-strong,.markdown .hljs-strong,.hljs-request,.hljs-status{font-weight:bold}.asciidoc .hljs-emphasis,.markdown .hljs-emphasis{font-style:italic}.nginx .hljs-built_in{font-weight:normal}.coffeescript .javascript,.javascript .xml,.lasso .markup,.tex .hljs-formula,.xml .javascript,.xml .vbscript,.xml .css,.xml .hljs-cdata{opacity:0.5} -------------------------------------------------------------------------------- /docs/v/loader.js: -------------------------------------------------------------------------------- 1 | function load_page(){ 2 | $.get( 3 | window.location.pathname.replace('.html', '.md'), 4 | function(data){ 5 | display_page(data) 6 | }, 7 | 'text' 8 | ); 9 | } 10 | 11 | function display_page(content){ 12 | content = content.replace(/.md/g, '.html'); 13 | renderer = new marked.Renderer(); 14 | renderer.heading = function(text, level){ 15 | escapedText = text.toLowerCase().replace(/[^\w]+/g, '-'); 16 | 17 | return '' + 22 | text + ''; 23 | } 24 | $('#content').html($('#content').html() + marked(content, {renderer: renderer})); 25 | hljs.initHighlighting(); 26 | if(window.location.hash){ 27 | $('html, body').scrollTop( 28 | $("a[name='"+ window.location.hash.replace('#', '') + "']").offset().top 29 | ); 30 | } 31 | } 32 | 33 | $(document).ready(load_page); 34 | $(document).on('page:load', load_page); -------------------------------------------------------------------------------- /img/sfn.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sparkleformation/sfn/cc601844a4208613803f784759242e88ae6c3546/img/sfn.jpg -------------------------------------------------------------------------------- /lib/sfn.rb: -------------------------------------------------------------------------------- 1 | require "sfn/version" 2 | require "miasma" 3 | require "bogo" 4 | require "sparkle_formation" 5 | 6 | module Sfn 7 | autoload :ApiProvider, "sfn/api_provider" 8 | autoload :Callback, "sfn/callback" 9 | autoload :Error, "sfn/error" 10 | autoload :Provider, "sfn/provider" 11 | autoload :Cache, "sfn/cache" 12 | autoload :Config, "sfn/config" 13 | autoload :Export, "sfn/export" 14 | autoload :Utils, "sfn/utils" 15 | autoload :MonkeyPatch, "sfn/monkey_patch" 16 | autoload :Knife, "sfn/knife" 17 | autoload :Command, "sfn/command" 18 | autoload :CommandModule, "sfn/command_module" 19 | autoload :Planner, "sfn/planner" 20 | autoload :Lint, "sfn/lint" 21 | end 22 | -------------------------------------------------------------------------------- /lib/sfn/api_provider.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | module ApiProvider 5 | autoload :Google, "sfn/api_provider/google" 6 | autoload :Terraform, "sfn/api_provider/terraform" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/sfn/api_provider/google.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | module ApiProvider 5 | module Google 6 | 7 | # Disable remote template storage 8 | def store_template(*_) 9 | end 10 | 11 | # No formatting required on stack results 12 | def format_nested_stack_results(*_) 13 | {} 14 | end 15 | 16 | # Extract current parameters from parent template 17 | # 18 | # @param stack [SparkleFormation] 19 | # @param stack_name [String] 20 | # @param c_stack [Miasma::Models::Orchestration::Stack] 21 | # @return [Hash] 22 | def extract_current_nested_template_parameters(stack, stack_name, c_stack) 23 | if c_stack && c_stack.data[:parent_stack] 24 | c_stack.data[:parent_stack].sparkleish_template(:remove_wrapper).fetch( 25 | :resources, stack_name, :properties, :parameters, Smash.new 26 | ) 27 | elsif stack.parent 28 | val = stack.parent.compile.resources.set!(stack_name).properties 29 | val.nil? ? Smash.new : val._dump 30 | else 31 | Smash.new 32 | end 33 | end 34 | 35 | # Disable parameter validate as we can't adjust them without template modifications 36 | def validate_stack_parameter(*_) 37 | true 38 | end 39 | 40 | # Determine if parameter was set via intrinsic function 41 | # 42 | # @param val [Object] 43 | # @return [TrueClass, FalseClass] 44 | def function_set_parameter?(val) 45 | if val 46 | val.start_with?("$(") || val.start_with?("{{") 47 | end 48 | end 49 | 50 | # Set parameters into parent resource properites 51 | def populate_parameters!(template, opts = {}) 52 | result = super 53 | result.each_pair do |key, value| 54 | if template.parent 55 | template.parent.compile.resources.set!(template.name).properties.set!(key, value) 56 | else 57 | template.compile.resources.set!(template.name).properties.set!(key, value) 58 | end 59 | end 60 | {} 61 | end 62 | 63 | # Override requirement of nesting bucket 64 | def validate_nesting_bucket! 65 | true 66 | end 67 | 68 | # Override template content extraction to disable scrub behavior 69 | # 70 | # @param thing [SparkleFormation, Hash] 71 | # @return [Hash] 72 | def template_content(thing, *_) 73 | if thing.is_a?(SparkleFormation) 74 | config[:sparkle_dump] ? thing.sparkle_dump : thing.dump 75 | else 76 | thing 77 | end 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/sfn/api_provider/terraform.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | module ApiProvider 5 | module Terraform 6 | 7 | # Disable remote template storage 8 | def store_template(*_) 9 | end 10 | 11 | # No formatting required on stack results 12 | def format_nested_stack_results(*_) 13 | {} 14 | end 15 | 16 | # Extract current parameters from parent template 17 | # 18 | # @param stack [SparkleFormation] 19 | # @param stack_name [String] 20 | # @param c_stack [Miasma::Models::Orchestration::Stack] 21 | # @return [Hash] 22 | def extract_current_nested_template_parameters(stack, stack_name, c_stack) 23 | if c_stack && c_stack.data[:parent_stack] 24 | c_stack.data[:parent_stack].sparkleish_template(:remove_wrapper).fetch( 25 | :resources, stack_name, :properties, :parameters, Smash.new 26 | ) 27 | elsif stack.parent 28 | val = stack.parent.compile.resources.set!(stack_name).properties 29 | val.nil? ? Smash.new : val._dump 30 | else 31 | Smash.new 32 | end 33 | end 34 | 35 | # Disable parameter validate as we can't adjust them without template modifications 36 | def validate_stack_parameter(*_) 37 | true 38 | end 39 | 40 | # Determine if parameter was set via intrinsic function 41 | # 42 | # @param val [Object] 43 | # @return [TrueClass, FalseClass] 44 | def function_set_parameter?(val) 45 | if val 46 | val.start_with?("${") 47 | end 48 | end 49 | 50 | # Override requirement of nesting bucket 51 | def validate_nesting_bucket! 52 | true 53 | end 54 | 55 | # Override template content extraction to disable scrub behavior 56 | # 57 | # @param thing [SparkleFormation, Hash] 58 | # @return [Hash] 59 | def template_content(thing, *_) 60 | if thing.is_a?(SparkleFormation) 61 | config[:sparkle_dump] ? thing.sparkle_dump : thing.dump 62 | else 63 | thing 64 | end 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/sfn/callback.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | # Interface for injecting custom functionality 5 | class Callback 6 | autoload :AwsAssumeRole, "sfn/callback/aws_assume_role" 7 | autoload :AwsMfa, "sfn/callback/aws_mfa" 8 | autoload :StackPolicy, "sfn/callback/stack_policy" 9 | 10 | # @return [Bogo::Ui] 11 | attr_reader :ui 12 | # @return [Smash] 13 | attr_reader :config 14 | # @return [Array] CLI arguments 15 | attr_reader :arguments 16 | # @return [Miasma::Models::Orchestration] remote API 17 | attr_reader :api 18 | 19 | # Create a new callback instance 20 | # 21 | # @param ui [Bogo::Ui] 22 | # @param config [Smash] configuration hash 23 | # @param arguments [Array] arguments from the CLI 24 | # @param api [Provider] API connection 25 | # 26 | # @return [self] 27 | def initialize(ui, config, arguments, api) 28 | @ui = ui 29 | @config = config 30 | @arguments = arguments 31 | @api = api 32 | end 33 | 34 | # Wrap action within status text 35 | # 36 | # @param msg [String] action text 37 | # @yieldblock action to perform 38 | # @return [Object] result of yield 39 | def run_action(msg) 40 | ui.info("#{msg}... ", :nonewline) 41 | begin 42 | result = yield 43 | ui.puts ui.color("complete!", :green, :bold) 44 | result 45 | rescue => e 46 | ui.puts ui.color("error!", :red, :bold) 47 | ui.error "Reason - #{e}" 48 | raise 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/sfn/callback/aws_assume_role.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | class Callback 5 | # Support for AWS STS role credential caching 6 | class AwsAssumeRole < Callback 7 | 8 | # Items to cache in local file 9 | STS_STORE_ITEMS = [ 10 | :aws_sts_token, 11 | :aws_sts_access_key_id, 12 | :aws_sts_secret_access_key, 13 | :aws_sts_token_expires, 14 | ] 15 | 16 | # Prevent callback output to user 17 | def quiet 18 | true 19 | end 20 | 21 | # Inject STS related configuration into 22 | # API provider credentials 23 | def after_config(*_) 24 | if enabled? && config.fetch(:credentials, :aws_sts_role_arn) 25 | load_stored_session 26 | end 27 | end 28 | 29 | # Store session token if available for 30 | # later use 31 | def after(*_) 32 | if enabled? 33 | if api.connection.aws_sts_role_arn && api.connection.aws_sts_token 34 | path = config.fetch(:aws_assume_role, :cache_file, ".sfn-aws") 35 | FileUtils.touch(path) 36 | File.chmod(0600, path) 37 | values = load_stored_values(path) 38 | STS_STORE_ITEMS.map do |key| 39 | values[key] = api.connection.data[key] 40 | end 41 | File.open(path, "w") do |file| 42 | file.puts MultiJson.dump(values) 43 | end 44 | end 45 | end 46 | end 47 | 48 | # @return [TrueClass, FalseClass] 49 | def enabled? 50 | config.fetch(:aws_assume_role, :status, "enabled").to_s == "enabled" 51 | end 52 | 53 | # Load stored configuration data into the api connection 54 | # 55 | # @return [TrueClass, FalseClass] 56 | def load_stored_session 57 | path = config.fetch(:aws_assume_role, :cache_file, ".sfn-aws") 58 | if File.exists?(path) 59 | values = load_stored_values(path) 60 | STS_STORE_ITEMS.each do |key| 61 | api.connection.data[key] = values[key] 62 | end 63 | if values[:aws_sts_token_expires] 64 | begin 65 | api.connection.data[:aws_sts_token_expires] = Time.parse(values[:aws_sts_token_expires]) 66 | rescue 67 | end 68 | end 69 | true 70 | else 71 | false 72 | end 73 | end 74 | 75 | # Load stored values 76 | # 77 | # @param path [String] 78 | # @return [Hash] 79 | def load_stored_values(path) 80 | begin 81 | if File.exists?(path) 82 | MultiJson.load(File.read(path)).to_smash 83 | else 84 | Smash.new 85 | end 86 | rescue MultiJson::ParseError 87 | Smash.new 88 | end 89 | end 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/sfn/callback/aws_mfa.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | class Callback 5 | # Support for AWS MFA 6 | class AwsMfa < Callback 7 | 8 | # Items to cache in local file 9 | SESSION_STORE_ITEMS = [ 10 | :aws_sts_session_token, 11 | :aws_sts_session_access_key_id, 12 | :aws_sts_session_secret_access_key, 13 | :aws_sts_session_token_expires, 14 | ] 15 | 16 | # Prevent callback output to user 17 | def quiet 18 | true 19 | end 20 | 21 | # Inject MFA related configuration into 22 | # API provider credentials 23 | def after_config(*_) 24 | if enabled? 25 | load_stored_session 26 | api.connection.aws_sts_session_token_code = method(:prompt_for_code) 27 | end 28 | end 29 | 30 | # Store session token if available for 31 | # later use 32 | def after(*_) 33 | if enabled? 34 | if api.connection.aws_sts_session_token 35 | path = config.fetch(:aws_mfa, :cache_file, ".sfn-aws") 36 | FileUtils.touch(path) 37 | File.chmod(0600, path) 38 | values = load_stored_values(path) 39 | SESSION_STORE_ITEMS.map do |key| 40 | values[key] = api.connection.data[key] 41 | end 42 | File.open(path, "w") do |file| 43 | file.puts MultiJson.dump(values) 44 | end 45 | end 46 | end 47 | end 48 | 49 | alias_method :failed, :after 50 | 51 | # @return [TrueClass, FalseClass] 52 | def enabled? 53 | config.fetch(:aws_mfa, :status, "enabled").to_s == "enabled" 54 | end 55 | 56 | # Load stored configuration data into the api connection 57 | # 58 | # @return [TrueClass, FalseClass] 59 | def load_stored_session 60 | path = config.fetch(:aws_mfa, :cache_file, ".sfn-aws") 61 | if File.exists?(path) 62 | values = load_stored_values(path) 63 | SESSION_STORE_ITEMS.each do |key| 64 | api.connection.data[key] = values[key] 65 | end 66 | if values[:aws_sts_session_token_expires] 67 | begin 68 | api.connection.data[:aws_sts_session_token_expires] = Time.parse(values[:aws_sts_session_token_expires]) 69 | rescue 70 | end 71 | end 72 | true 73 | else 74 | false 75 | end 76 | end 77 | 78 | # Load stored values 79 | # 80 | # @param path [String] 81 | # @return [Hash] 82 | def load_stored_values(path) 83 | begin 84 | if File.exists?(path) 85 | MultiJson.load(File.read(path)).to_smash 86 | else 87 | Smash.new 88 | end 89 | rescue MultiJson::ParseError 90 | Smash.new 91 | end 92 | end 93 | 94 | # Request MFA code from user 95 | # 96 | # @return [String] 97 | def prompt_for_code 98 | result = ui.ask "AWS MFA code", :valid => /^\d{6}$/ 99 | result.strip 100 | end 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /lib/sfn/callback/stack_policy.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | class Callback 5 | class StackPolicy < Callback 6 | 7 | # Policy to apply prior to stack deletion 8 | DEFENSELESS_POLICY = { 9 | "Statement" => [{ 10 | "Effect" => "Allow", 11 | "Action" => "Update:*", 12 | "Resource" => "*", 13 | "Principal" => "*", 14 | }], 15 | } 16 | 17 | # @return [Smash] cached policies 18 | attr_reader :policies 19 | 20 | # Overload to init policy cache 21 | # 22 | # @return [self] 23 | def initialize(*args) 24 | super 25 | @policies = Smash.new 26 | end 27 | 28 | # Submit all cached policies 29 | # 30 | # @param args [Hash] 31 | def submit_policy(args) 32 | ui.info "Submitting stack policy documents" 33 | stack = args[:api_stack] 34 | ([stack] + stack.nested_stacks).compact.each do |p_stack| 35 | run_action "Applying stack policy to #{ui.color(p_stack.name, :yellow)}" do 36 | save_stack_policy(p_stack) 37 | end 38 | end 39 | ui.info "Stack policy documents successfully submitted!" 40 | end 41 | 42 | alias_method :after_create, :submit_policy 43 | alias_method :after_update, :submit_policy 44 | 45 | # Disable all existing policies prior to update 46 | # 47 | # @param args [Hash] 48 | def before_update(args) 49 | if config.get(:stack_policy, :update).to_s == "defenseless" 50 | ui.warn "Disabling all stack policies for update." 51 | stack = args[:api_stack] 52 | ([stack] + stack.nested_stacks).compact.each do |p_stack| 53 | @policies[p_stack.name] = DEFENSELESS_POLICY 54 | run_action "Disabling stack policy for #{ui.color(p_stack.name, :yellow)}" do 55 | save_stack_policy(p_stack) 56 | end 57 | end 58 | end 59 | end 60 | 61 | # Generate stack policy for stack and cache for the after hook 62 | # to handle 63 | # 64 | # @param info [Hash] 65 | def template(info) 66 | if info[:sparkle_stack] 67 | @policies.set(info.fetch(:stack_name, "unknown"), 68 | info[:sparkle_stack].generate_policy) 69 | end 70 | end 71 | 72 | # Save the cached policy for the given stack 73 | # 74 | # @param p_stack [Miasma::Models::Orchestration::Stack] 75 | # @return [NilClass] 76 | def save_stack_policy(p_stack) 77 | valid_logical_ids = p_stack.resources.reload.all.map(&:logical_id) 78 | stack_policy = @policies.fetch(p_stack.id, 79 | @policies.fetch(p_stack.data[:logical_id]), 80 | @policies[p_stack.name]).to_smash 81 | if stack_policy 82 | stack_policy[:Statement].delete_if do |policy_item| 83 | policy_match = policy_item[:Resource].to_s.match( 84 | %r{LogicalResourceId/(?.+)$} 85 | ) 86 | if policy_match 87 | !valid_logical_ids.include?(policy_match["logical_id"]) 88 | end 89 | end 90 | end 91 | result = p_stack.api.request( 92 | :path => "/", 93 | :method => :post, 94 | :form => Smash.new( 95 | "Action" => "SetStackPolicy", 96 | "StackName" => p_stack.id, 97 | "StackPolicyBody" => MultiJson.dump(stack_policy), 98 | ), 99 | ) 100 | end 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /lib/sfn/command.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | require "bogo-cli" 3 | 4 | module Sfn 5 | class Command < Bogo::Cli::Command 6 | include CommandModule::Callbacks 7 | 8 | autoload :Conf, "sfn/command/conf" 9 | autoload :Create, "sfn/command/create" 10 | autoload :Describe, "sfn/command/describe" 11 | autoload :Destroy, "sfn/command/destroy" 12 | autoload :Diff, "sfn/command/diff" 13 | autoload :Events, "sfn/command/events" 14 | autoload :Export, "sfn/command/export" 15 | autoload :Graph, "sfn/command/graph" 16 | autoload :Import, "sfn/command/import" 17 | autoload :Init, "sfn/command/init" 18 | autoload :Inspect, "sfn/command/inspect" 19 | autoload :Lint, "sfn/command/lint" 20 | autoload :List, "sfn/command/list" 21 | autoload :Plan, "sfn/command/plan" 22 | autoload :Print, "sfn/command/print" 23 | autoload :Promote, "sfn/command/promote" 24 | autoload :Realize, "sfn/command/realize" 25 | autoload :Trace, "sfn/command/trace" 26 | autoload :Update, "sfn/command/update" 27 | autoload :Validate, "sfn/command/validate" 28 | 29 | # Base name of configuration file 30 | CONFIG_BASE_NAME = ".sfn" 31 | 32 | # Supported configuration file extensions 33 | VALID_CONFIG_EXTENSIONS = [ 34 | "", 35 | ".rb", 36 | ".json", 37 | ".yaml", 38 | ".yml", 39 | ".xml", 40 | ] 41 | 42 | # Override to provide config file searching 43 | def initialize(cli_opts, args) 44 | unless cli_opts["config"] 45 | discover_config(cli_opts) 46 | end 47 | unless ENV["DEBUG"] 48 | ENV["DEBUG"] = "true" if cli_opts[:debug] 49 | end 50 | super(cli_opts, args) 51 | load_api_provider_extensions! 52 | run_callbacks_for(:after_config) 53 | run_callbacks_for("after_config_#{Bogo::Utility.snake(self.class.name.split("::").last)}") 54 | end 55 | 56 | # @return [Smash] 57 | def config 58 | memoize(:config) do 59 | super 60 | end 61 | end 62 | 63 | protected 64 | 65 | # Load API provider specific overrides to customize behavior 66 | # 67 | # @return [TrueClass, FalseClass] 68 | def load_api_provider_extensions! 69 | if config.get(:credentials, :provider) 70 | base_ext = Bogo::Utility.camel(config.get(:credentials, :provider)).to_sym 71 | targ_ext = self.class.name.split("::").last 72 | if ApiProvider.constants.include?(base_ext) 73 | base_module = ApiProvider.const_get(base_ext) 74 | ui.debug "Loading core provider extensions via `#{base_module}`" 75 | extend base_module 76 | if base_module.constants.include?(targ_ext) 77 | targ_module = base_module.const_get(targ_ext) 78 | ui.debug "Loading targeted provider extensions via `#{targ_module}`" 79 | extend targ_module 80 | end 81 | true 82 | end 83 | end 84 | end 85 | 86 | # Start with current working directory and traverse to root 87 | # looking for a `.sfn` configuration file 88 | # 89 | # @param opts [Slop] 90 | # @return [Slop] 91 | def discover_config(opts) 92 | cwd = Dir.pwd.split(File::SEPARATOR) 93 | detected_path = "" 94 | until cwd.empty? || File.exists?(detected_path.to_s) 95 | detected_path = Dir.glob( 96 | (cwd + ["#{CONFIG_BASE_NAME}{#{VALID_CONFIG_EXTENSIONS.join(",")}}"]).join( 97 | File::SEPARATOR 98 | ) 99 | ).first 100 | cwd.pop 101 | end 102 | if opts.respond_to?(:fetch_option) 103 | opts.fetch_option("config").value = detected_path if detected_path 104 | else 105 | opts["config"] = detected_path if detected_path 106 | end 107 | opts 108 | end 109 | 110 | # @return [Class] attempt to return customized configuration class 111 | def config_class 112 | klass_name = self.class.name.split("::").last 113 | if Sfn::Config.const_defined?(klass_name) 114 | Sfn::Config.const_get(klass_name) 115 | else 116 | super 117 | end 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /lib/sfn/command/conf.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | class Command 5 | # Config command 6 | class Conf < Command 7 | include Sfn::CommandModule::Base 8 | 9 | # Run the list command 10 | def execute! 11 | ui.info ui.color("Current configuration state:") 12 | Config::Conf.attributes.sort_by(&:first).each do |k, val| 13 | if config.has_key?(k) 14 | ui.print " #{ui.color(k, :bold, :green)}: " 15 | format_value(config[k], " ") 16 | end 17 | end 18 | if config[:generate] 19 | ui.puts 20 | ui.info "Generating .sfn configuration file.." 21 | generate_config! 22 | ui.info "Generation of .sfn configuration file #{ui.color("complete!", :green, :bold)}" 23 | end 24 | end 25 | 26 | def generate_config! 27 | if File.exists?(".sfn") 28 | ui.warn "Existing .sfn configuration file detected!" 29 | ui.confirm "Overwrite current .sfn configuration file?" 30 | end 31 | run_action "Writing .sfn file" do 32 | File.open(".sfn", "w") do |file| 33 | file.write SFN_CONFIG_CONTENTS 34 | end 35 | nil 36 | end 37 | end 38 | 39 | def format_value(value, indent = "") 40 | if value.is_a?(Hash) 41 | ui.puts 42 | value.sort_by(&:first).each do |k, v| 43 | ui.print "#{indent} #{ui.color(k, :bold)}: " 44 | format_value(v, indent + " ") 45 | end 46 | elsif value.is_a?(Array) 47 | ui.puts 48 | value.map(&:to_s).sort.each do |v| 49 | ui.print "#{indent} " 50 | format_value(v, indent + " ") 51 | end 52 | else 53 | ui.puts value.to_s 54 | end 55 | end 56 | 57 | SFN_CONFIG_CONTENTS = <<-EOF 58 | # This is an auto-generated configuration file for 59 | # the sfn CLI. To view all available configuration 60 | # options, please see: 61 | # http://www.sparkleformation.io/docs/sfn/configuration.html 62 | Configuration.new do 63 | # Set style of stack nesting 64 | apply_nesting 'deep' 65 | # Enable processing of SparkleFormation templates 66 | processing true 67 | # Provider specific options used when creating 68 | # new stacks. Options defined here are AWS specific. 69 | options do 70 | on_failure 'nothing' 71 | notification_topics [] 72 | capabilities ['CAPABILITY_IAM'] 73 | tags do 74 | creator ENV['USER'] 75 | end 76 | end 77 | # Name of bucket in object store to hold nested 78 | # stack templates 79 | # nesting_bucket 'BUCKET_NAME' 80 | # Prefix used on generated template path prior to storage 81 | # in the object store 82 | # nesting_prefix 'nested-templates' 83 | # Remote provider credentials 84 | credentials do 85 | # Remote provider name (:aws, :azure, :google, :open_stack, :rackspace, :terraform) 86 | provider :aws 87 | # AWS credentials information 88 | aws_access_key_id ENV['AWS_ACCESS_KEY_ID'] 89 | aws_secret_access_key ENV['AWS_SECRET_ACCESS_KEY'] 90 | aws_region ENV['AWS_REGION'] 91 | aws_bucket_region ENV['AWS_REGION'] 92 | # aws_sts_role_arn ENV['AWS_STS_ROLE_ARN'] 93 | # Eucalyptus related additions 94 | # api_endpoint ENV['EUCA_ENDPOINT'] 95 | # euca_compat 'path' 96 | # ssl_enabled false 97 | # Azure credentials information 98 | azure_tenant_id ENV['AZURE_TENANT_ID'] 99 | azure_client_id ENV['AZURE_CLIENT_ID'] 100 | azure_subscription_id ENV['AZURE_SUBSCRIPTION_ID'] 101 | azure_client_secret ENV['AZURE_CLIENT_SECRET'] 102 | azure_region ENV['AZURE_REGION'] 103 | azure_blob_account_name ENV['AZURE_BLOB_ACCOUNT_NAME'] 104 | azure_blob_secret_key ENV['AZURE_BLOB_SECRET_KEY'] 105 | # Defaults to "miasma-orchestration-templates" 106 | azure_root_orchestration_container ENV['AZURE_ROOT_ORCHESTRATION_CONTAINER'] 107 | # OpenStack credentials information 108 | open_stack_identity_url ENV['OPENSTACK_IDENTITY_URL'] 109 | open_stack_username ENV['OPENSTACK_USERNAME'] 110 | open_stack_user_id ENV['OPENSTACK_USER_ID'] 111 | open_stack_password ENV['OPENSTACK_PASSWORD'] 112 | open_stack_token ENV['OPENSTACK_TOKEN'] 113 | open_stack_region ENV['OPENSTACK_REGION'] 114 | open_stack_tenant_name ENV['OPENSTACK_TENANT_NAME'] 115 | open_stack_domain ENV['OPENSTACK_DOMAIN'] 116 | open_stack_project ENV['OPENSTACK_PROJECT'] 117 | # Rackspace credentials information 118 | rackspace_api_key ENV['RACKSPACE_API_KEY'] 119 | rackspace_username ENV['RACKSPACE_USERNAME'] 120 | rackspace_region ENV['RACKSPACE_REGION'] 121 | # Google Cloud Deployment Manager credentials 122 | google_service_account_email ENV['GOOGLE_SERVICE_ACCOUNT_EMAIL'] 123 | google_service_account_private_key ENV['GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY'] 124 | google_project ENV['GOOGLE_PROJECT'] 125 | # Terraform credentials information 126 | # Valid driver names: :tfe, :boule, :local 127 | terraform_driver :local 128 | terraform_tfe_endpoint ENV['TFE_URL'] 129 | terraform_tfe_token ENV['TFE_TOKEN'] 130 | terraform_boule_endpoint ENV['BOULE_URL'] 131 | terraform_local_directory './terraform-stacks' 132 | terraform_local_scrub_destroyed false 133 | end 134 | end 135 | EOF 136 | end 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /lib/sfn/command/create.rb: -------------------------------------------------------------------------------- 1 | require "sparkle_formation" 2 | require "sfn" 3 | 4 | module Sfn 5 | class Command 6 | # Create command 7 | class Create < Command 8 | include Sfn::CommandModule::Base 9 | include Sfn::CommandModule::Template 10 | include Sfn::CommandModule::Stack 11 | 12 | # Run the stack creation command 13 | def execute! 14 | name_required! 15 | name = name_args.first 16 | 17 | # NOTE: Always disable plans on create 18 | config[:plan] = false 19 | 20 | if config[:template] 21 | file = config[:template] 22 | else 23 | file = load_template_file 24 | end 25 | 26 | unless config[:print_only] 27 | ui.info "#{ui.color("SparkleFormation:", :bold)} #{ui.color("create", :green)}" 28 | end 29 | 30 | stack_info = "#{ui.color("Name:", :bold)} #{name}" 31 | if config[:path] 32 | stack_info << " #{ui.color("Path:", :bold)} #{config[:file]}" 33 | end 34 | 35 | if config[:print_only] 36 | ui.puts format_json(parameter_scrub!(template_content(file))) 37 | return 38 | else 39 | ui.info " -> #{stack_info}" 40 | end 41 | 42 | stack = provider.connection.stacks.build( 43 | config.fetch(:options, Smash.new).dup.merge( 44 | :name => name, 45 | :template => template_content(file), 46 | :parameters => Smash.new, 47 | ) 48 | ) 49 | 50 | apply_stacks!(stack) 51 | populate_parameters!(file, :current_parameters => stack.parameters) 52 | 53 | stack.parameters = config_root_parameters 54 | 55 | if config[:upload_root_template] 56 | upload_result = store_template(name, file, Smash.new) 57 | stack.template_url = upload_result[:url] 58 | else 59 | stack.template = parameter_scrub!(template_content(file, :scrub)) 60 | end 61 | 62 | api_action!(:api_stack => stack) do 63 | stack.save 64 | if config[:poll] 65 | poll_stack(stack.name) 66 | stack = provider.stack(name) 67 | 68 | if stack.reload.state == :create_complete 69 | ui.info "Stack create complete: #{ui.color("SUCCESS", :green)}" 70 | namespace.const_get(:Describe).new({:outputs => true}, [name]).execute! 71 | else 72 | ui.fatal "Create of new stack #{ui.color(name, :bold)}: #{ui.color("FAILED", :red, :bold)}" 73 | raise "Stack did not reach a successful completion state." 74 | end 75 | else 76 | ui.warn "Stack state polling has been disabled." 77 | ui.info "Stack creation initialized for #{ui.color(name, :green)}" 78 | end 79 | end 80 | end 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/sfn/command/describe.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | class Command 5 | # Cloudformation describe command 6 | class Describe < Command 7 | include Sfn::CommandModule::Base 8 | 9 | # information available 10 | unless defined?(AVAILABLE_DISPLAYS) 11 | AVAILABLE_DISPLAYS = [:resources, :outputs, :tags] 12 | end 13 | 14 | # Run the stack describe action 15 | def execute! 16 | name_required! 17 | stack_name = name_args.last 18 | root_stack = api_action! do 19 | provider.stack(stack_name) 20 | end 21 | if root_stack 22 | ([root_stack] + root_stack.nested_stacks).compact.each do |stack| 23 | ui.info "Stack description of #{ui.color(stack.name, :bold)}:" 24 | display = [].tap do |to_display| 25 | AVAILABLE_DISPLAYS.each do |display_option| 26 | if config[display_option] 27 | to_display << display_option 28 | end 29 | end 30 | end 31 | display = AVAILABLE_DISPLAYS.dup if display.empty? 32 | display.each do |display_method| 33 | self.send(display_method, stack) 34 | end 35 | ui.puts 36 | end 37 | else 38 | ui.fatal "Failed to find requested stack: #{ui.color(stack_name, :bold, :red)}" 39 | raise "Requested stack not found: #{stack_name}" 40 | end 41 | end 42 | 43 | # Display resources 44 | # 45 | # @param stack [Miasma::Models::Orchestration::Stack] 46 | def resources(stack) 47 | stack_resources = stack.resources.all.sort do |x, y| 48 | y.updated <=> x.updated 49 | end.map do |resource| 50 | Smash.new(resource.attributes) 51 | end 52 | ui.table(self) do 53 | table(:border => false) do 54 | row(:header => true) do 55 | allowed_attributes.each do |attr| 56 | column as_title(attr), :width => stack_resources.map { |r| r[attr].to_s.length }.push(as_title(attr).length).max + 2 57 | end 58 | end 59 | stack_resources.each do |resource| 60 | row do 61 | allowed_attributes.each do |attr| 62 | column resource[attr] 63 | end 64 | end 65 | end 66 | end 67 | end.display 68 | end 69 | 70 | # Display outputs 71 | # 72 | # @param stack [Miasma::Models::Orchestration::Stack] 73 | def outputs(stack) 74 | ui.info "Outputs for stack: #{ui.color(stack.name, :bold)}" 75 | unless stack.outputs.nil? || stack.outputs.empty? 76 | stack.outputs.each do |output| 77 | key, value = output.key, output.value 78 | key = snake(key).to_s.split("_").map(&:capitalize).join(" ") 79 | ui.info [" ", ui.color("#{key}:", :bold), value].join(" ") 80 | end 81 | else 82 | ui.info " #{ui.color("No outputs found")}" 83 | end 84 | end 85 | 86 | # Display tags 87 | # 88 | # @param stack [Miasma::Models::Orchestration::Stack] 89 | def tags(stack) 90 | ui.info "Tags for stack: #{ui.color(stack.name, :bold)}" 91 | if stack.tags && !stack.tags.empty? 92 | stack.tags.each do |key, value| 93 | ui.info [" ", ui.color("#{key}:", :bold), value].join(" ") 94 | end 95 | else 96 | ui.info " #{ui.color("No tags found")}" 97 | end 98 | end 99 | 100 | # @return [Array] default attributes 101 | def default_attributes 102 | %w(updated logical_id type status status_reason) 103 | end 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /lib/sfn/command/destroy.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | class Command 5 | class Destroy < Command 6 | include Sfn::CommandModule::Base 7 | 8 | # Run the stack destruction action 9 | def execute! 10 | name_required! 11 | stacks = name_args.sort 12 | plural = "s" if stacks.size > 1 13 | globs = stacks.find_all do |s| 14 | s !~ /^[a-zA-Z0-9-]+$/ 15 | end 16 | unless globs.empty? 17 | glob_stacks = provider.connection.stacks.all.find_all do |remote_stack| 18 | globs.detect do |glob| 19 | File.fnmatch(glob, remote_stack.name) 20 | end 21 | end 22 | stacks += glob_stacks.map(&:name) 23 | stacks -= globs 24 | stacks.sort! 25 | end 26 | ui.warn "Destroying Stack#{plural}: #{ui.color(stacks.join(", "), :bold)}" 27 | ui.confirm "Destroy listed stack#{plural}?" 28 | stacks.each do |stack_name| 29 | stack = provider.connection.stacks.get(stack_name) 30 | if stack 31 | nested_stack_cleanup!(stack) 32 | begin 33 | api_action!(:api_stack => stack) do 34 | stack.destroy 35 | end 36 | rescue Miasma::Error::ApiError::RequestError => error 37 | raise unless error.response.code == 404 38 | # if stack is already gone, disable polling 39 | config[:poll] = false 40 | end 41 | ui.info "Destroy request complete for stack: #{ui.color(stack_name, :red)}" 42 | else 43 | ui.warn "Failed to locate requested stack: #{ui.color(stack_name, :bold)}" 44 | end 45 | end 46 | if config[:poll] 47 | if stacks.size == 1 48 | pstack = stacks.first 49 | begin 50 | poll_stack(pstack) 51 | stack = provider.connection.stacks.get(pstack) 52 | stack.reload 53 | if stack.state.to_s.end_with?("failed") 54 | ui.error("Stack #{ui.color(pstack, :bold)} still exists after polling complete.") 55 | raise "Failed to successfully destroy stack!" 56 | end 57 | rescue Miasma::Error::ApiError::RequestError => error 58 | # Ignore if stack cannot be reloaded 59 | end 60 | else 61 | ui.error "Stack polling is not available when multiple stack deletion is requested!" 62 | end 63 | end 64 | ui.info " -> Destroyed SparkleFormation#{plural}: #{ui.color(stacks.join(", "), :bold, :red)}" 65 | end 66 | 67 | # Cleanup persisted templates if nested stack resources are included 68 | def nested_stack_cleanup!(stack) 69 | stack.nested_stacks.each do |n_stack| 70 | nested_stack_cleanup!(n_stack) 71 | end 72 | nest_stacks = stack.template.fetch("Resources", {}).values.find_all do |resource| 73 | provider.connection.data[:stack_types].include?(resource["Type"]) 74 | end.each do |resource| 75 | url = resource["Properties"]["TemplateURL"] 76 | if url && url.is_a?(String) 77 | _, bucket_name, path = URI.parse(url).path.split("/", 3) 78 | bucket = provider.connection.api_for(:storage).buckets.get(bucket_name) 79 | if bucket 80 | file = bucket.files.get(path) 81 | if file 82 | file.destroy 83 | ui.info "Deleted nested stack template! (Bucket: #{bucket_name} Template: #{path})" 84 | else 85 | ui.warn "Failed to locate template file within bucket for deletion! (#{path})" 86 | end 87 | else 88 | ui.warn "Failed to locate bucket containing template file for deletion! (#{bucket_name})" 89 | end 90 | end 91 | end 92 | end 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/sfn/command/diff.rb: -------------------------------------------------------------------------------- 1 | require "sparkle_formation" 2 | require "sfn" 3 | require "hashdiff" 4 | 5 | module Sfn 6 | class Command 7 | # Diff command 8 | class Diff < Command 9 | include Sfn::CommandModule::Base 10 | include Sfn::CommandModule::Template 11 | include Sfn::CommandModule::Stack 12 | 13 | # Diff the stack with existing stack 14 | def execute! 15 | name_required! 16 | name = name_args.first 17 | 18 | begin 19 | stack = provider.stack(name) 20 | rescue Miasma::Error::ApiError::RequestError 21 | stack = nil 22 | end 23 | 24 | if stack 25 | config[:print_only] = true 26 | file = load_template_file 27 | file = parameter_scrub!(file.dump) 28 | 29 | ui.info "#{ui.color("SparkleFormation:", :bold)} #{ui.color("diff", :blue)} - #{name}" 30 | ui.puts 31 | 32 | diff_stack(stack, MultiJson.load(MultiJson.dump(file)).to_smash) 33 | else 34 | ui.fatal "Failed to locate requested stack: #{ui.color(name, :red, :bold)}" 35 | raise "Failed to locate stack: #{name}" 36 | end 37 | end 38 | 39 | # @todo needs updates for better provider compat 40 | def diff_stack(stack, file, parent_names = []) 41 | stack_template = stack.template 42 | nested_stacks = Hash[ 43 | file.fetch("Resources", file.fetch("resources", {})).find_all do |name, value| 44 | value.fetch("Properties", {})["Stack"] 45 | end 46 | ] 47 | nested_stacks.each do |name, value| 48 | n_stack = stack.nested_stacks(false).detect do |ns| 49 | ns.data[:logical_id] == name 50 | end 51 | if n_stack 52 | diff_stack(n_stack, value["Properties"]["Stack"], [*parent_names, stack.data.fetch(:logical_id, stack.name)].compact) 53 | end 54 | file["Resources"][name]["Properties"].delete("Stack") 55 | end 56 | 57 | ui.info "#{ui.color("Stack diff:", :bold)} #{ui.color((parent_names + [stack.data.fetch(:logical_id, stack.name)]).compact.join(" > "), :blue)}" 58 | 59 | stack_diff = HashDiff.diff(stack.template, file) 60 | 61 | if config[:raw_diff] 62 | ui.info "Dumping raw template diff:" 63 | require "pp" 64 | pp stack_diff 65 | else 66 | added_resources = stack_diff.find_all do |item| 67 | item.first == "+" && item[1].match(/Resources\.[^.]+$/) 68 | end 69 | removed_resources = stack_diff.find_all do |item| 70 | item.first == "-" && item[1].match(/Resources\.[^.]+$/) 71 | end 72 | modified_resources = stack_diff.find_all do |item| 73 | item[1].start_with?("Resources.") && 74 | !item[1].end_with?("TemplateURL") && 75 | !item[1].include?("Properties.Parameters") 76 | end - added_resources - removed_resources 77 | 78 | if added_resources.empty? && removed_resources.empty? && modified_resources.empty? 79 | ui.info "No changes detected" 80 | ui.puts 81 | else 82 | unless added_resources.empty? 83 | ui.info ui.color("Added Resources:", :green, :bold) 84 | added_resources.each do |item| 85 | ui.print ui.color(" -> #{item[1].split(".").last}", :green) 86 | ui.puts " [#{item[2]["Type"]}]" 87 | end 88 | ui.puts 89 | end 90 | 91 | unless modified_resources.empty? 92 | ui.info ui.color("Modified Resources:", :yellow, :bold) 93 | m_resources = Hash.new.tap do |hash| 94 | modified_resources.each do |item| 95 | _, key, path = item[1].split(".", 3) 96 | hash[key] ||= {} 97 | prefix, a_key = path.split(".", 2) 98 | hash[key][prefix] ||= [] 99 | matched = hash[key][prefix].detect do |i| 100 | i[:path] == a_key 101 | end 102 | if matched 103 | if item.first == "-" 104 | matched[:original] = item[2] 105 | else 106 | matched[:new] = item[2] 107 | end 108 | else 109 | hash[key][prefix] << Hash.new.tap do |info| 110 | info[:path] = a_key 111 | case item.first 112 | when "~" 113 | info[:original] = item[2] 114 | info[:new] = item[3] 115 | when "+" 116 | info[:new] = item[2] 117 | else 118 | info[:original] = item[2] 119 | end 120 | end 121 | end 122 | end 123 | end.to_smash(:sorted).each do |key, value| 124 | ui.puts ui.color(" - #{key}", :yellow) + " [#{stack.template["Resources"][key]["Type"]}]" 125 | value.each do |prefix, items| 126 | ui.puts ui.color(" #{prefix}:", :bold) 127 | items.each do |item| 128 | original = item[:original].nil? ? ui.color("(none)", :yellow) : ui.color(item[:original].inspect, :red) 129 | new_val = item[:new].nil? ? ui.color("(deleted)", :red) : ui.color(item[:new].inspect, :green) 130 | ui.puts " #{item[:path]}: #{original} -> #{new_val}" 131 | end 132 | end 133 | end 134 | ui.puts 135 | end 136 | 137 | unless removed_resources.empty? 138 | ui.info ui.color("Removed Resources:", :red, :bold) 139 | removed_resources.each do |item| 140 | ui.print ui.color(" <- #{item[1].split(".").last}", :red) 141 | ui.puts " [#{item[2]["Type"]}]" 142 | end 143 | ui.puts 144 | end 145 | 146 | run_callbacks_for(:after_stack_diff, 147 | :diff => stack_diff, 148 | :diff_info => { 149 | :added => added_resources, 150 | :modified => modified_resources, 151 | :removed => removed_resources, 152 | }, 153 | :api_stack => stack, 154 | :new_template => file) 155 | end 156 | end 157 | end 158 | end 159 | end 160 | end 161 | -------------------------------------------------------------------------------- /lib/sfn/command/events.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | class Command 5 | # Events command 6 | class Events < Command 7 | include Sfn::CommandModule::Base 8 | 9 | # @return [Miasma::Models::Orchestration::Stack] 10 | attr_reader :stack 11 | 12 | # Run the events list action 13 | def execute! 14 | name_required! 15 | name = name_args.first 16 | ui.info "Events for Stack: #{ui.color(name, :bold)}\n" 17 | @seen_events = [] 18 | @stack = provider.stack(name) 19 | if stack 20 | api_action!(:api_stack => stack) do 21 | table = ui.table(self) do 22 | table(:border => false) do 23 | events = get_events 24 | row(:header => true) do 25 | allowed_attributes.each do |attr| 26 | width_val = events.map { |e| e[attr].to_s.length }.push(attr.length).max + 2 27 | width_val = width_val > 70 ? 70 : width_val < 20 ? 20 : width_val 28 | column attr.split("_").map(&:capitalize).join(" "), :width => width_val 29 | end 30 | end 31 | events.each do |event| 32 | row do 33 | allowed_attributes.each do |attr| 34 | column event[attr] 35 | end 36 | end 37 | end 38 | end 39 | end.display 40 | if config[:poll] 41 | while (stack.reload.in_progress?) 42 | to_wait = config.fetch(:poll_wait_time, 10).to_f 43 | while (to_wait > 0) 44 | sleep(0.1) 45 | to_wait -= 0.1 46 | end 47 | stack.resources.reload 48 | table.display 49 | end 50 | end 51 | end 52 | else 53 | ui.fatal "Failed to locate requested stack: #{ui.color(name, :bold, :red)}" 54 | raise "Failed to locate stack: #{name}!" 55 | end 56 | end 57 | 58 | # Fetch events from stack 59 | # 60 | # @param stack [Miasma::Models::Orchestration::Stack] 61 | # @param last_id [String] only return events after this ID 62 | # @return [Array] 63 | def get_events(*args) 64 | stack_events = discover_stacks(stack).map do |i_stack| 65 | i_events = [] 66 | begin 67 | if @initial_complete && i_stack.in_progress? 68 | i_events = i_stack.events.update! 69 | else 70 | i_events = i_stack.events.all 71 | end 72 | rescue => e 73 | if e.class.to_s.start_with?("Errno") 74 | ui.warn "Connection error encountered: #{e.message} (retrying)" 75 | ui.debug "#{e.class}: #{e}\n#{e.backtrace.join("\n")}" 76 | else 77 | ui.error "Unexpected error received fetching events: #{e.message}" 78 | ui.debug "#{e.class}: #{e}\n#{e.backtrace.join("\n")}" 79 | end 80 | sleep(5) 81 | retry 82 | end 83 | if i_events 84 | i_events.map do |e| 85 | e.attributes.merge(:stack_name => i_stack.name).to_smash 86 | end 87 | end 88 | end.flatten.compact.find_all { |e| e[:time] }.reverse 89 | stack_events.delete_if { |evt| @seen_events.include?(evt) } 90 | @seen_events.concat(stack_events) 91 | unless @initial_complete 92 | stack_events = stack_events.sort_by { |e| e[:time] } 93 | unless config[:all_events] 94 | start_index = stack_events.rindex do |item| 95 | item[:stack_name] == stack.name && 96 | item[:resource_state].to_s.end_with?("in_progress") && 97 | item[:resource_status_reason].to_s.downcase.include?("user init") 98 | end 99 | if start_index 100 | stack_events.slice!(0, start_index) 101 | end 102 | end 103 | @initial_complete = true 104 | end 105 | stack_events 106 | end 107 | 108 | # Discover stacks defined within the resources of given stack 109 | # 110 | # @param stack [Miasma::Models::Orchestration::Stack] 111 | def discover_stacks(stack) 112 | @stacks = [stack] + stack.nested_stacks.reverse 113 | end 114 | 115 | # @return [Array] default attributes for events 116 | def default_attributes 117 | %w(stack_name time resource_logical_id resource_status resource_status_reason) 118 | end 119 | 120 | # @return [Array] allowed attributes for events 121 | def allowed_attributes 122 | result = super 123 | unless @stacks.size > 1 124 | result.delete("stack_name") 125 | end 126 | result 127 | end 128 | end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /lib/sfn/command/export.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | class Command 5 | # Export command 6 | class Export < Command 7 | include Sfn::CommandModule::Base 8 | include Sfn::Utils::ObjectStorage 9 | 10 | # Run export action 11 | def execute! 12 | raise NotImplementedError.new "Implementation updates required" 13 | stack_name = name_args.first 14 | ui.info "#{ui.color("Stack Export:", :bold)} #{stack_name}" 15 | ui.confirm "Perform export" 16 | stack = provider.stacks.get(stack_name) 17 | if stack 18 | export_options = Smash.new.tap do |opts| 19 | [:chef_popsicle, :chef_environment_parameter, :ignore_parameters].each do |key| 20 | opts[key] = config[key] unless config[key].nil? 21 | end 22 | end 23 | exporter = Sfn::Utils::StackExporter.new(stack, export_options) 24 | result = exporter.export 25 | outputs = [ 26 | write_to_file(result, stack), 27 | write_to_bucket(result, stack), 28 | ].compact 29 | if outputs.empty? 30 | ui.warn "No persistent output location defined. Printing export:" 31 | ui.info _format_json(result) 32 | end 33 | ui.info "#{ui.color("Stack export", :bold)} (#{name_args.first}): #{ui.color("complete", :green)}" 34 | unless outputs.empty? 35 | outputs.each do |output| 36 | ui.info ui.color(" -> #{output}", :blue) 37 | end 38 | end 39 | else 40 | ui.fatal "Failed to discover requested stack: #{ui.color(stack_name, :red, :bold)}" 41 | exit -1 42 | end 43 | end 44 | 45 | # Generate file name for stack export JSON contents 46 | # 47 | # @param stack [Miasma::Models::Orchestration::Stack] 48 | # @return [String] file name 49 | def export_file_name(stack) 50 | name = config[:file] 51 | if name 52 | if name.respond_to?(:call) 53 | name.call(stack) 54 | else 55 | name.to_s 56 | end 57 | else 58 | "#{stack.stack_name}-#{Time.now.to_i}.json" 59 | end 60 | end 61 | 62 | # Write stack export to local file 63 | # 64 | # @param payload [Hash] stack export payload 65 | # @param stack [Misama::Stack::Orchestration::Stack] 66 | # @return [String, NilClass] path to file 67 | def write_to_file(payload, stack) 68 | raise NotImplementedError 69 | if config[:path] 70 | full_path = File.join( 71 | config[:path], 72 | export_file_name(stack) 73 | ) 74 | _, bucket, path = full_path.split("/", 3) 75 | directory = provider.service_for(:storage, 76 | :provider => :local, 77 | :local_root => "/").directories.get(bucket) 78 | file_store(payload, path, directory) 79 | end 80 | end 81 | 82 | # Write stack export to remote bucket 83 | # 84 | # @param payload [Hash] stack export payload 85 | # @param stack [Miasma::Models::Orchestration::Stack] 86 | # @return [String, NilClass] remote bucket key 87 | def write_to_bucket(payload, stack) 88 | raise NotImplementedError 89 | if bucket = config[:bucket] 90 | key_path = File.join(*[ 91 | bucket_prefix(stack), 92 | export_file_name(stack), 93 | ].compact) 94 | file_store(payload, key_path, provider.service_for(:storage).directories.get(bucket)) 95 | end 96 | end 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /lib/sfn/command/graph.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | require "graph" 3 | 4 | module Sfn 5 | class Command 6 | # Graph command 7 | class Graph < Command 8 | autoload :Provider, "sfn/command/graph/provider" 9 | 10 | include Sfn::CommandModule::Base 11 | include Sfn::CommandModule::Template 12 | include Sfn::CommandModule::Stack 13 | 14 | # Valid graph styles 15 | GRAPH_STYLES = [ 16 | "creation", 17 | "dependency", 18 | ] 19 | 20 | # Generate graph 21 | def execute! 22 | config[:print_only] = true 23 | validate_graph_style! 24 | file = load_template_file 25 | provider = Bogo::Utility.camel(file.provider).to_sym 26 | if Provider.constants.include?(provider) 27 | graph_const = Provider.const_get(provider) 28 | ui.debug "Loading provider graph implementation - #{graph_const}" 29 | extend graph_const 30 | @outputs = Smash.new 31 | ui.info "Template resource graph generation - Style: #{ui.color(config[:graph_style], :bold)}" 32 | if config[:file] 33 | ui.puts " -> path: #{config[:file]}" 34 | end 35 | template_dump = file.compile.sparkle_dump!.to_smash 36 | run_action "Pre-processing template for graphing" do 37 | output_discovery(template_dump, @outputs, nil, nil) 38 | ui.debug "Output remapping results from pre-processing:" 39 | @outputs.each_pair do |o_key, o_value| 40 | ui.debug "#{o_key} -> #{o_value}" 41 | end 42 | nil 43 | end 44 | graph = nil 45 | run_action "Generating resource graph" do 46 | graph = generate_graph(template_dump) 47 | nil 48 | end 49 | run_action "Writing graph result" do 50 | FileUtils.mkdir_p(File.dirname(config[:output_file])) 51 | if config[:output_type] == "dot" 52 | File.open("#{config[:output_file]}.dot", "w") do |o_file| 53 | o_file.puts graph.to_s 54 | end 55 | else 56 | graph.save config[:output_file], config[:output_type] 57 | end 58 | nil 59 | end 60 | else 61 | valid_providers = Provider.constants.sort.map { |provider| 62 | Bogo::Utility.snake(provider) 63 | }.join("`, `") 64 | ui.error "Graphing for provider `#{file.provider}` not currently supported." 65 | ui.error "Currently supported providers: `#{valid_providers}`." 66 | end 67 | end 68 | 69 | def generate_graph(template, args = {}) 70 | graph = ::Graph.new 71 | @root_graph = graph unless @root_graph 72 | graph.graph_attribs << ::Graph::Attribute.new("overlap = false") 73 | graph.graph_attribs << ::Graph::Attribute.new("splines = true") 74 | graph.graph_attribs << ::Graph::Attribute.new("pack = true") 75 | graph.graph_attribs << ::Graph::Attribute.new('start = "random"') 76 | if args[:name] 77 | graph.name = "cluster_#{args[:name]}" 78 | labelnode_key = "cluster_#{args[:name]}" 79 | graph.plaintext << graph.node(labelnode_key) 80 | graph.node(labelnode_key).label args[:name] 81 | else 82 | graph.name = "root" 83 | end 84 | edge_detection(template, graph, args[:name].to_s.sub("cluster_", ""), args.fetch(:resource_names, [])) 85 | graph 86 | end 87 | 88 | def colorize(string) 89 | hash = string.chars.inject(0) do |memo, chr| 90 | if memo + chr.ord > 127 91 | (memo - chr.ord).abs 92 | else 93 | memo + chr.ord 94 | end 95 | end 96 | color = "#" 97 | 3.times do |i| 98 | color << (255 ^ hash).to_s(16) 99 | new_val = hash + (hash * (1 / (i + 1.to_f))).to_i 100 | if hash * (i + 1) < 127 101 | hash = new_val 102 | else 103 | hash = hash / (i + 1) 104 | end 105 | end 106 | color 107 | end 108 | 109 | def validate_graph_style! 110 | if config[:luckymike] 111 | ui.warn "Detected luckymike power override. Forcing `dependency` style!" 112 | config[:graph_style] = "dependency" 113 | end 114 | config[:graph_style] = config[:graph_style].to_s 115 | unless GRAPH_STYLES.include?(config[:graph_style]) 116 | raise ArgumentError.new "Invalid graph style provided `#{config[:graph_style]}`. Valid: `#{GRAPH_STYLES.join("`, `")}`" 117 | end 118 | end 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /lib/sfn/command/graph/provider.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | class Command 5 | # Graph command 6 | class Graph < Command 7 | module Provider 8 | autoload :Aws, "sfn/command/graph/aws" 9 | autoload :Terraform, "sfn/command/graph/terraform" 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/sfn/command/import.rb: -------------------------------------------------------------------------------- 1 | require "stringio" 2 | require "sfn" 3 | 4 | module Sfn 5 | class Command 6 | # Import command 7 | class Import < Command 8 | include Sfn::CommandModule::Base 9 | include Sfn::Utils::JSON 10 | include Sfn::Utils::ObjectStorage 11 | include Sfn::Utils::PathSelector 12 | 13 | # Run the import action 14 | def execute! 15 | raise NotImplementedError.new "Implementation updates required" 16 | stack_name, json_file = name_args 17 | ui.info "#{ui.color("Stack Import:", :bold)} #{stack_name}" 18 | unless json_file 19 | entries = [].tap do |_entries| 20 | _entries.push("s3") if config[:bucket] 21 | _entries.push("fs") if config[:path] 22 | end 23 | if entries.size > 1 24 | valid = false 25 | until valid 26 | if config[:interactive_parameters] 27 | answer = ui.ask_question( 28 | "Import via file system (fs) or remote bucket (remote)?", 29 | :default => "remote", 30 | ) 31 | else 32 | answer = "remote" 33 | end 34 | valid = true if %w(remote fs).include?(answer) 35 | entries = [answer] 36 | end 37 | elsif entries.size < 1 38 | ui.fatal "No path or bucket set. Unable to perform dynamic lookup!" 39 | exit 1 40 | end 41 | case entries.first 42 | when "remote" 43 | json_file = remote_discovery 44 | else 45 | json_file = local_discovery 46 | end 47 | end 48 | if File.exists?(json_file) || json_file.is_a?(IO) 49 | content = json_file.is_a?(IO) ? json_file.read : File.read(json_file) 50 | export = Mash.new(_from_json(content)) 51 | begin 52 | creator = namespace.const_val(:Create).new( 53 | Smash.new( 54 | :template => _from_json(export[:stack][:template]), 55 | :options => _from_json(export[:stack][:options]), 56 | ), 57 | [stack_name] 58 | ) 59 | ui.info " - Starting creation of import" 60 | creator.execute! 61 | ui.info "#{ui.color("Stack Import", :bold)} (#{json_file}): #{ui.color("complete", :green)}" 62 | rescue => e 63 | ui.fatal "Failed to import stack: #{e}" 64 | debug "#{e.class}: #{e}\n#{e.backtrace.join("\n")}" 65 | raise 66 | end 67 | else 68 | ui.fatal "Failed to locate JSON export file (#{json_file})" 69 | raise 70 | end 71 | end 72 | 73 | # Generate bucket prefix 74 | # 75 | # @return [String, NilClass] 76 | def bucket_prefix 77 | if prefix = config[:bucket_prefix] 78 | if prefix.respond_to?(:call) 79 | prefix.call 80 | else 81 | prefix.to_s 82 | end 83 | end 84 | end 85 | 86 | # Discover remote file 87 | # 88 | # @return [IO] stack export IO 89 | def remote_discovery 90 | storage = provider.service_for(:storage) 91 | directory = storage.directories.get(config[:bucket]) 92 | file = prompt_for_file( 93 | directory, 94 | :directories_name => "Collections", 95 | :files_names => "Exports", 96 | :filter_prefix => bucket_prefix, 97 | ) 98 | if file 99 | remote_file = storage.files.get(file) 100 | StringIO.new(remote_file.body) 101 | end 102 | end 103 | 104 | # Discover remote file 105 | # 106 | # @return [IO] stack export IO 107 | def local_discovery 108 | _, bucket = config[:path].split("/", 2) 109 | storage = provider.service_for(:storage, 110 | :provider => :local, 111 | :local_root => "/") 112 | directory = storage.directories.get(bucket) 113 | prompt_for_file( 114 | directory, 115 | :directories_name => "Collections", 116 | :files_names => "Exports", 117 | ) 118 | end 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /lib/sfn/command/init.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | require "fileutils" 3 | 4 | module Sfn 5 | class Command 6 | # Init command 7 | class Init < Command 8 | include Sfn::CommandModule::Base 9 | 10 | INIT_DIRECTORIES = [ 11 | "sparkleformation/dynamics", 12 | "sparkleformation/components", 13 | "sparkleformation/registry", 14 | ] 15 | 16 | # Run the init command to initialize new project 17 | def execute! 18 | unless name_args.size == 1 19 | raise ArgumentError.new "Please provide path argument only for project initialization" 20 | else 21 | path = name_args.first 22 | end 23 | if File.file?(path) 24 | raise "Cannot create project directory. Given path is a file. (`#{path}`)" 25 | end 26 | if File.directory?(path) 27 | ui.warn "Project directory already exists at given path. (`#{path}`)" 28 | ui.confirm "Overwrite existing files?" 29 | end 30 | run_action "Creating base project directories" do 31 | INIT_DIRECTORIES.each do |new_dir| 32 | FileUtils.mkdir_p(File.join(path, new_dir)) 33 | end 34 | nil 35 | end 36 | run_action "Creating project bundle" do 37 | File.open(File.join(path, "Gemfile"), "w") do |file| 38 | file.puts "source 'https://rubygems.org'\n\ngem 'sfn'" 39 | end 40 | nil 41 | end 42 | ui.info "Generating .sfn configuration file" 43 | Dir.chdir(path) do 44 | Conf.new({:generate => true}, []).execute! 45 | end 46 | ui.info "Installing project bundle" 47 | Dir.chdir(path) do 48 | if defined?(Bundler) 49 | Bundler.clean_system("bundle install") 50 | else 51 | system("bundle install") 52 | end 53 | end 54 | ui.info "Project initialization complete!" 55 | ui.puts " Project path -> #{File.expand_path(path)}" 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/sfn/command/lint.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | class Command 5 | # Lint command 6 | class Lint < Command 7 | include Sfn::CommandModule::Base 8 | include Sfn::CommandModule::Template 9 | 10 | # Perform linting 11 | def execute! 12 | print_only_original = config[:print_only] 13 | config[:print_only] = true 14 | file = load_template_file 15 | ui.info "#{ui.color("Template Linting (#{provider.connection.provider}): ", :bold)} #{config[:file].sub(Dir.pwd, "").sub(%r{^/}, "")}" 16 | config[:print_only] = print_only_original 17 | 18 | raw_template = parameter_scrub!(template_content(file)) 19 | 20 | if config[:print_only] 21 | ui.puts raw_template 22 | else 23 | result = lint_template(raw_template) 24 | if result == true 25 | ui.info ui.color(" -> VALID", :green, :bold) 26 | else 27 | ui.info ui.color(" -> INVALID", :red, :bold) 28 | result.each do |failure| 29 | ui.error "Result Set: #{ui.color(failure[:rule_set].name, :red, :bold)}" 30 | failure[:failures].each do |f_msg| 31 | ui.fatal f_msg 32 | end 33 | end 34 | raise "Linting failure" 35 | end 36 | end 37 | end 38 | 39 | # Apply linting to given template 40 | # 41 | # @param template [Hash] 42 | # @return [TrueClass, Array] 43 | def lint_template(template) 44 | results = rule_sets.map do |set| 45 | result = set.apply(template) 46 | unless result == true 47 | Smash.new(:rule_set => set, :failures => result) 48 | end 49 | end.compact 50 | results.empty? ? true : results 51 | end 52 | 53 | # @return [Array] 54 | def rule_sets 55 | sets = [config[:lint_directory]].flatten.compact.map do |directory| 56 | if File.directory?(directory) 57 | files = Dir.glob(File.join(directory, "**", "**", "*.rb")) 58 | files.map do |path| 59 | begin 60 | Sfn::Lint.class_eval( 61 | IO.read(path), path, 1 62 | ) 63 | rescue 64 | ui.warn "Failed to load detected file: #{path}" 65 | nil 66 | end 67 | end 68 | end 69 | end.flatten.compact.find_all { |rs| rs.provider == provider.connection.provider } 70 | unless config[:local_rule_sets_only] 71 | sets += Sfn::Lint::RuleSet.get_all(provider.connection.provider) 72 | end 73 | if config[:disabled_rule_set] 74 | disabled = [config[:disabled_rule_set]].flatten.compact 75 | sets.delete_if { |i| disabled.include?(i.name.to_s) } 76 | end 77 | if config[:enabled_rule_set] 78 | enabled = [config[:enabled_rule_set]].flatten.compact 79 | sets.delete_if { |i| enabled.include?(i.name.to_s) } 80 | end 81 | sets 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/sfn/command/list.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | class Command 5 | # List command 6 | class List < Command 7 | include Sfn::CommandModule::Base 8 | 9 | # Run the list command 10 | def execute! 11 | ui.table(self) do 12 | table(:border => false) do 13 | stacks = api_action! { get_stacks } 14 | row(:header => true) do 15 | allowed_attributes.each do |attr| 16 | width_val = stacks.map { |e| e[attr].to_s.length }.push(attr.length).max + 2 17 | width_val = width_val > 70 ? 70 : width_val < 20 ? 20 : width_val 18 | column attr.split("_").map(&:capitalize).join(" "), :width => width_val 19 | end 20 | end 21 | get_stacks.each do |stack| 22 | row do 23 | allowed_attributes.each do |attr| 24 | column stack[attr] 25 | end 26 | end 27 | end 28 | end 29 | end.display 30 | end 31 | 32 | # Get the list of stacks to display 33 | # 34 | # @return [Array] 35 | def get_stacks 36 | provider.stacks.all.map do |stack| 37 | Smash.new(stack.attributes) 38 | end.sort do |x, y| 39 | if y[:created].to_s.empty? 40 | -1 41 | elsif x[:created].to_s.empty? 42 | 1 43 | else 44 | Time.parse(x[:created].to_s) <=> Time.parse(y[:created].to_s) 45 | end 46 | end 47 | end 48 | 49 | # @return [Array] default attributes to display 50 | def default_attributes 51 | if provider.connection.provider == :aws 52 | %w(name created updated status template_description) 53 | else 54 | %w(name created updated status description) 55 | end 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/sfn/command/print.rb: -------------------------------------------------------------------------------- 1 | require "sparkle_formation" 2 | require "sfn" 3 | 4 | module Sfn 5 | class Command 6 | # Print command 7 | class Print < Command 8 | include Sfn::CommandModule::Base 9 | include Sfn::CommandModule::Template 10 | include Sfn::CommandModule::Stack 11 | 12 | # Print the requested template 13 | def execute! 14 | config[:print_only] = true 15 | file = load_template_file 16 | 17 | output_content = parameter_scrub!(template_content(file)) 18 | if config[:yaml] 19 | require "yaml" 20 | output_content = YAML.dump(output_content) 21 | else 22 | output_content = format_json(output_content) 23 | end 24 | 25 | if config[:write_to_file] 26 | unless File.directory?(File.dirname(config[:write_to_file])) 27 | run_action "Creating parent directory" do 28 | FileUtils.mkdir_p(File.dirname(config[:write_to_file])) 29 | nil 30 | end 31 | end 32 | run_action "Writing template to file - #{config[:write_to_file]}" do 33 | File.write(config[:write_to_file], output_content) 34 | nil 35 | end 36 | else 37 | ui.puts output_content 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/sfn/command/promote.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | class Command 5 | # Promote command 6 | class Promote < Command 7 | include Sfn::CommandModule::Base 8 | 9 | def execute! 10 | raise NotImplementedError.new "Implementation updates required" 11 | stack_name, destination = name_args 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/sfn/command/realize.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | class Command 5 | # Realize command 6 | class Realize < Command 7 | include Sfn::CommandModule::Base 8 | include Sfn::CommandModule::Planning 9 | 10 | # Run the stack realize command 11 | def execute! 12 | name_required! 13 | name = name_args.first 14 | 15 | stack_info = "#{ui.color("Name:", :bold)} #{name}" 16 | begin 17 | stack = provider.stacks.get(name) 18 | rescue Miasma::Error::ApiError::RequestError 19 | raise Error::StackNotFound, 20 | "Failed to locate stack: #{name}" 21 | end 22 | 23 | if config[:plan_name] 24 | ui.debug "Setting custom plan name - #{config[:plan_name]}" 25 | # ensure custom attribute is dirty so we can modify 26 | stack.custom = stack.custom.dup 27 | stack.custom[:plan_name] = config[:plan_name] 28 | end 29 | 30 | ui.info " -> Loading plan information..." 31 | 32 | plan = stack.plan 33 | if plan.nil? 34 | raise Error::StackPlanNotFound, 35 | "Failed to locate plan for stack `#{name}`" 36 | end 37 | 38 | display_plan_information(plan) 39 | 40 | return if config[:plan_only] 41 | 42 | if config[:merge_api_options] 43 | config.fetch(:options, Smash.new).each_pair do |key, value| 44 | if stack.respond_to?("#{key}=") 45 | stack.send("#{key}=", value) 46 | end 47 | end 48 | end 49 | 50 | begin 51 | api_action!(:api_stack => stack) do 52 | stack.plan_execute 53 | if config[:poll] 54 | poll_stack(stack.name) 55 | if [:update_complete, :create_complete]. 56 | include?(stack.reload.state) 57 | ui.info "Stack plan apply complete: " \ 58 | "#{ui.color("SUCCESS", :green)}" 59 | namespace.const_get(:Describe). 60 | new({:outputs => true}, [name]).execute! 61 | else 62 | ui.fatal "Update of stack #{ui.color(name, :bold)}: " \ 63 | "#{ui.color("FAILED", :red, :bold)}" 64 | raise Error::StackStateIncomplete 65 | end 66 | else 67 | ui.warn "Stack state polling has been disabled." 68 | ui.info "Stack plan apply initialized for " \ 69 | "#{ui.color(name, :green)}" 70 | end 71 | end 72 | rescue Miasma::Error::ApiError::RequestError => e 73 | if e.message.downcase.include?("no updates") 74 | ui.warn "No changes detected for stack (#{stack.name})" 75 | else 76 | raise 77 | end 78 | end 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/sfn/command/trace.rb: -------------------------------------------------------------------------------- 1 | require "sparkle_formation" 2 | require "sfn" 3 | 4 | module Sfn 5 | class Command 6 | # Trace command 7 | class Trace < Command 8 | include Sfn::CommandModule::Base 9 | include Sfn::CommandModule::Template 10 | include Sfn::CommandModule::Stack 11 | 12 | # Print the requested template 13 | def execute! 14 | config[:sparkle_dump] = true 15 | config[:print_only] = true 16 | file = load_template_file 17 | 18 | if !file.is_a?(SparkleFormation) 19 | raise "Cannot trace non-SparkleFormation template" 20 | else 21 | writer = proc do |audit_log, indent = ""| 22 | audit_log.each do |record| 23 | header = "#{indent}-> " 24 | header << ui.color(record.type.to_s.capitalize, :bold) 25 | header << " - #{record.name}" 26 | source = "#{indent} | source: " 27 | if record.location.line > 0 28 | source << "#{record.location.path} @ #{record.location.line}" 29 | else 30 | source << ui.color(record.location.path, :yellow) 31 | end 32 | origin = "#{indent} | caller: " 33 | if record.caller.line > 0 34 | origin << "#{record.caller.path} @ #{record.caller.line}" 35 | else 36 | origin << ui.color(record.caller.path, :yellow) 37 | end 38 | duration = "#{indent} | duration: " 39 | if record.compile_duration 40 | duration << Kernel.sprintf("%0.4f", record.compile_duration) 41 | duration << "s" 42 | else 43 | duration < "N/A" 44 | end 45 | ui.info header 46 | ui.info source 47 | ui.info origin 48 | ui.info duration 49 | if record.audit_log.count > 0 50 | writer.call(record.audit_log, indent + " |") 51 | end 52 | end 53 | end 54 | ui.info ui.color("Trace information:", :bold) 55 | writer.call(file.audit_log) 56 | end 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/sfn/command/update.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | class Command 5 | # Update command 6 | class Update < Command 7 | include Sfn::CommandModule::Base 8 | include Sfn::CommandModule::Template 9 | include Sfn::CommandModule::Stack 10 | include Sfn::CommandModule::Planning 11 | 12 | # Run the stack update command 13 | def execute! 14 | name_required! 15 | name = name_args.first 16 | 17 | stack_info = "#{ui.color("Name:", :bold)} #{name}" 18 | begin 19 | stack = provider.stacks.get(name) 20 | rescue Miasma::Error::ApiError::RequestError 21 | stack = nil 22 | end 23 | 24 | config[:compile_parameters] ||= Smash.new 25 | 26 | if config[:file] 27 | s_name = [name] 28 | 29 | c_setter = lambda do |c_stack| 30 | if c_stack.outputs 31 | compile_params = c_stack.outputs.detect do |output| 32 | output.key == "CompileState" 33 | end 34 | end 35 | if compile_params 36 | compile_params = MultiJson.load(compile_params.value) 37 | c_current = config[:compile_parameters].fetch(s_name.join("__"), Smash.new) 38 | config[:compile_parameters][s_name.join("__")] = compile_params.merge(c_current) 39 | end 40 | c_stack.nested_stacks(false).each do |n_stack| 41 | s_name.push(n_stack.data.fetch(:logical_id, n_stack.name)) 42 | c_setter.call(n_stack) 43 | s_name.pop 44 | end 45 | end 46 | 47 | if stack 48 | c_setter.call(stack) 49 | end 50 | 51 | ui.debug "Compile parameters - #{config[:compile_parameters]}" 52 | file = load_template_file(:stack => stack) 53 | stack_info << " #{ui.color("Path:", :bold)} #{config[:file]}" 54 | else 55 | file = stack.template.dup if config[:plan] 56 | end 57 | 58 | unless stack 59 | ui.fatal "Failed to locate requested stack: #{ui.color(name, :red, :bold)}" 60 | raise "Failed to locate stack: #{name}" 61 | end 62 | 63 | unless config[:print_only] 64 | ui.info "#{ui.color("SparkleFormation:", :bold)} #{ui.color("update", :green)}" 65 | end 66 | 67 | unless file 68 | if config[:template] 69 | file = config[:template] 70 | stack_info << " #{ui.color("(template provided)", :green)}" 71 | else 72 | stack_info << " #{ui.color("(no template update)", :yellow)}" 73 | end 74 | end 75 | unless config[:print_only] 76 | ui.info " -> #{stack_info}" 77 | end 78 | if file 79 | if config[:print_only] 80 | ui.puts format_json(parameter_scrub!(template_content(file))) 81 | return 82 | end 83 | 84 | original_template = stack.template 85 | original_parameters = stack.parameters 86 | 87 | apply_stacks!(stack) 88 | 89 | populate_parameters!(file, :current_parameters => stack.root_parameters) 90 | update_template = stack.template 91 | 92 | if config[:plan] 93 | begin 94 | stack.template = original_template 95 | stack.parameters = original_parameters 96 | plan = build_planner(stack) 97 | if plan 98 | result = plan.generate_plan( 99 | file.respond_to?(:dump) ? file.dump : file, 100 | config_root_parameters 101 | ) 102 | display_plan_information(result) 103 | end 104 | rescue => e 105 | unless e.message.include?("Confirmation declined") 106 | ui.error "Unexpected error when generating plan information: #{e.class} - #{e}" 107 | ui.debug "#{e.class}: #{e}\n#{e.backtrace.join("\n")}" 108 | ui.confirm "Continue with stack update?" unless config[:plan_only] 109 | else 110 | raise 111 | end 112 | end 113 | if config[:plan_only] 114 | ui.info "Plan only mode requested. Exiting." 115 | return 116 | end 117 | end 118 | stack.parameters = config_root_parameters 119 | 120 | if config[:upload_root_template] 121 | upload_result = store_template(name, file, Smash.new) 122 | stack.template_url = upload_result[:url] 123 | else 124 | stack.template = parameter_scrub!(template_content(file, :scrub)) 125 | end 126 | else 127 | apply_stacks!(stack) 128 | original_parameters = stack.parameters 129 | populate_parameters!(stack.template, :current_parameters => stack.root_parameters) 130 | stack.parameters = config_root_parameters 131 | end 132 | 133 | # Set options defined within config into stack instance for update request 134 | if config[:merge_api_options] 135 | config.fetch(:options, Smash.new).each_pair do |key, value| 136 | if stack.respond_to?("#{key}=") 137 | stack.send("#{key}=", value) 138 | end 139 | end 140 | end 141 | 142 | begin 143 | api_action!(:api_stack => stack) do 144 | stack.save 145 | if config[:poll] 146 | poll_stack(stack.name) 147 | if stack.reload.state == :update_complete 148 | ui.info "Stack update complete: #{ui.color("SUCCESS", :green)}" 149 | namespace.const_get(:Describe).new({:outputs => true}, [name]).execute! 150 | else 151 | ui.fatal "Update of stack #{ui.color(name, :bold)}: #{ui.color("FAILED", :red, :bold)}" 152 | raise "Stack did not reach a successful update completion state." 153 | end 154 | else 155 | ui.warn "Stack state polling has been disabled." 156 | ui.info "Stack update initialized for #{ui.color(name, :green)}" 157 | end 158 | end 159 | rescue Miasma::Error::ApiError::RequestError => e 160 | if e.message.downcase.include?("no updates") 161 | ui.warn "No updates detected for stack (#{stack.name})" 162 | else 163 | raise 164 | end 165 | end 166 | end 167 | end 168 | end 169 | end 170 | -------------------------------------------------------------------------------- /lib/sfn/command/validate.rb: -------------------------------------------------------------------------------- 1 | require "sparkle_formation" 2 | require "sfn" 3 | 4 | module Sfn 5 | class Command 6 | # Validate command 7 | class Validate < Command 8 | include Sfn::CommandModule::Base 9 | include Sfn::CommandModule::Template 10 | include Sfn::CommandModule::Stack 11 | 12 | def execute! 13 | if config[:all] 14 | validate_templates = sparkle_collection.templates[sparkle_collection.provider].keys 15 | elsif config[:group] 16 | validate_templates = sparkle_collection.templates[sparkle_collection.provider].keys.select do |template| 17 | template.split("__").first == config[:group] 18 | end 19 | else 20 | validate_templates = [config[:file]] 21 | end 22 | 23 | if validate_templates.empty? 24 | load_template_file 25 | validate_templates.push(config[:file]) 26 | end 27 | 28 | validate_templates.each do |template| 29 | config[:file] = template 30 | print_only_original = config[:print_only] 31 | config[:print_only] = true 32 | ui.info "#{ui.color("Template Validation (#{provider.connection.provider}): ", :bold)} #{config[:file].sub(Dir.pwd, "").sub(%r{^/}, "")}" 33 | file = load_template_file 34 | config[:print_only] = print_only_original 35 | raw_template = _format_json(parameter_scrub!(template_content(file))) 36 | 37 | if config[:print_only] 38 | ui.puts raw_template 39 | else 40 | validate_stack( 41 | file.respond_to?(:dump) ? file.dump : file, 42 | if config[:processing] 43 | sparkle_collection.get(:template, config[:file])[:name] 44 | else 45 | config[:file] 46 | end 47 | ) 48 | end 49 | end 50 | end 51 | 52 | # Validate template with remote API and unpack nested templates if required 53 | # 54 | # @param template [Hash] template data structure 55 | # @param name [String] name of template 56 | # @return [TrueClass] 57 | def validate_stack(template, name) 58 | resources = template.fetch("Resources", {}) 59 | nested_stacks = resources.find_all do |r_name, r_value| 60 | r_value.is_a?(Hash) && 61 | provider.connection.data[:stack_types].include?(r_value["Type"]) 62 | end 63 | nested_stacks.each do |n_name, n_resource| 64 | validate_stack(n_resource.fetch("Properties", {}).fetch("Stack", {}), "#{name} > #{n_name}") 65 | n_resource["Properties"].delete("Stack") 66 | end 67 | begin 68 | ui.info "Validating: #{ui.color(name, :bold)}" 69 | if config[:upload_root_template] 70 | upload_result = store_template("validation-stack", template, Smash.new) 71 | stack = provider.connection.stacks.build( 72 | :name => "validation-stack", 73 | :template_url => upload_result[:url], 74 | ) 75 | else 76 | stack = provider.connection.stacks.build( 77 | :name => "validation-stack", 78 | :template => parameter_scrub!(template), 79 | ) 80 | end 81 | result = api_action!(:api_stack => stack) do 82 | stack.validate 83 | end 84 | ui.info ui.color(" -> VALID", :bold, :green) 85 | true 86 | rescue => e 87 | ui.info ui.color(" -> INVALID", :bold, :red) 88 | ui.fatal e.message 89 | raise e 90 | end 91 | # Clear Compile Time Parameters from Config 92 | config[:compile_parameters] = {} 93 | end 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/sfn/command_module.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | module CommandModule 5 | autoload :Base, "sfn/command_module/base" 6 | autoload :Callbacks, "sfn/command_module/callbacks" 7 | autoload :Planning, "sfn/command_module/planning" 8 | autoload :Stack, "sfn/command_module/stack" 9 | autoload :Template, "sfn/command_module/template" 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/sfn/command_module/callbacks.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | require "sparkle_formation" 3 | 4 | module Sfn 5 | module CommandModule 6 | # Callback processor helpers 7 | module Callbacks 8 | include Bogo::Memoization 9 | 10 | # Run expected callbacks around action 11 | # 12 | # @yieldblock api action to run 13 | # @yieldresult [Object] result from call 14 | # @return [Object] result of yield block 15 | def api_action!(*args) 16 | type = self.class.name.split("::").last.downcase 17 | run_callbacks_for(["before_#{type}", :before], *args) 18 | result = nil 19 | begin 20 | result = yield if block_given? 21 | run_callbacks_for(["after_#{type}", :after], *args) 22 | result 23 | rescue => err 24 | run_callbacks_for(["failed_#{type}", :failed], *(args + [err])) 25 | raise 26 | end 27 | end 28 | 29 | # Process requested callbacks 30 | # 31 | # @param type [Symbol, String] name of callback type 32 | # @return [NilClass] 33 | def run_callbacks_for(type, *args) 34 | types = [type].flatten.compact 35 | type = types.first 36 | clbks = types.map do |c_type| 37 | callbacks_for(c_type) 38 | end.flatten(1).compact.uniq.each do |item| 39 | callback_name, callback, quiet = item 40 | quiet = true if config[:print_only] 41 | ui.info "Callback #{ui.color(type.to_s, :bold)} #{callback_name}: #{ui.color("starting", :yellow)}" unless quiet 42 | if args.empty? 43 | callback.call 44 | else 45 | callback.call(*args) 46 | end 47 | ui.info "Callback #{ui.color(type.to_s, :bold)} #{callback_name}: #{ui.color("complete", :green)}" unless quiet 48 | end 49 | nil 50 | end 51 | 52 | # Fetch valid callbacks for given type 53 | # 54 | # @param type [Symbol, String] name of callback type 55 | # @param responder [Array] matching response methods 56 | # @return [Array] 57 | def callbacks_for(type) 58 | ([config.fetch(:callbacks, type, [])].flatten.compact + [config.fetch(:callbacks, :default, [])].flatten.compact).map do |c_name| 59 | instance = memoize(c_name) do 60 | begin 61 | klass = Sfn::Callback.const_get(Bogo::Utility.camel(c_name.to_s)) 62 | klass.new(ui, config, arguments, provider) 63 | rescue NameError => e 64 | ui.debug "Callback type lookup error: #{e.class} - #{e}" 65 | raise NameError.new("Unknown #{type} callback requested: #{c_name} (not found)") 66 | end 67 | end 68 | if instance.respond_to?(type) 69 | [c_name, instance.method(type), instance.respond_to?(:quiet) ? instance.quiet : false] 70 | end 71 | end.compact 72 | end 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/sfn/command_module/planning.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | require "sparkle_formation" 3 | 4 | module Sfn 5 | module CommandModule 6 | # Planning helpers 7 | module Planning 8 | # Create a new planner instance 9 | # 10 | # @param [Miasma::Models::Orchestration::Stack] 11 | # @return [Sfn::Planner] 12 | def build_planner(stack) 13 | klass_name = stack.api.class.to_s.split("::").last 14 | if Planner.const_defined?(klass_name) 15 | Planner.const_get(klass_name).new(ui, config, arguments, stack) 16 | else 17 | warn "Failed to build planner for current provider. No provider implemented. (`#{klass_name}`)" 18 | nil 19 | end 20 | end 21 | 22 | # Display plan result on the UI 23 | # 24 | # @param result [Miasma::Models::Orchestration::Stack::Plan] 25 | def display_plan_information(result) 26 | ui.info ui.color("Pre-update resource planning report:", :bold) 27 | unless print_plan_result(result, [result.name]) 28 | ui.info "No resources life cycle changes detected in this update!" 29 | end 30 | if config[:plan_apply] 31 | return ui.info "Realizing this stack plan..." 32 | elsif config[:plan_only] 33 | return 34 | end 35 | ui.confirm "Realize this stack plan?" 36 | end 37 | 38 | # Print plan information to the UI 39 | # 40 | # @param info [Miasma::Models::Orchestration::Stack::Plan] 41 | # @param names [Array] nested names 42 | def print_plan_result(info, names = []) 43 | said_any_things = false 44 | unless Array(info.stacks).empty? 45 | info.stacks.each do |s_name, s_info| 46 | result = print_plan_result(s_info, [*names, s_name].compact) 47 | said_any_things ||= result 48 | end 49 | end 50 | if !names.flatten.compact.empty? || info.name 51 | said_things = false 52 | output_name = names.empty? ? info.name : names.join(" > ") 53 | ui.puts 54 | ui.puts " #{ui.color("Update plan for:", :bold)} #{ui.color(names.join(" > "), :blue)}" 55 | unless Array(info.unknown).empty? 56 | ui.puts " #{ui.color("!!! Unknown update effect:", :red, :bold)}" 57 | print_plan_items(info, :unknown, :red) 58 | ui.puts 59 | said_any_things = said_things = true 60 | end 61 | unless Array(info.unavailable).empty? 62 | ui.puts " #{ui.color("Update request not allowed:", :red, :bold)}" 63 | print_plan_items(info, :unavailable, :red) 64 | ui.puts 65 | said_any_things = said_things = true 66 | end 67 | unless Array(info.replace).empty? 68 | ui.puts " #{ui.color("Resources to be replaced:", :red, :bold)}" 69 | print_plan_items(info, :replace, :red) 70 | ui.puts 71 | said_any_things = said_things = true 72 | end 73 | unless Array(info.interrupt).empty? 74 | ui.puts " #{ui.color("Resources to be interrupted:", :yellow, :bold)}" 75 | print_plan_items(info, :interrupt, :yellow) 76 | ui.puts 77 | said_any_things = said_things = true 78 | end 79 | unless Array(info.remove).empty? 80 | ui.puts " #{ui.color("Resources to be removed:", :red, :bold)}" 81 | print_plan_items(info, :remove, :red) 82 | ui.puts 83 | said_any_things = said_things = true 84 | end 85 | unless Array(info.add).empty? 86 | ui.puts " #{ui.color("Resources to be added:", :green, :bold)}" 87 | print_plan_items(info, :add, :green) 88 | ui.puts 89 | said_any_things = said_things = true 90 | end 91 | unless said_things 92 | ui.puts " #{ui.color("No resource lifecycle changes detected!", :green)}" 93 | ui.puts 94 | said_any_things = true 95 | end 96 | end 97 | said_any_things 98 | end 99 | 100 | # Print planning items 101 | # 102 | # @param info [Miasma::Models::Orchestration::Stack::Plan] plan 103 | # @param key [Symbol] key of items 104 | # @param color [Symbol] color to flag 105 | def print_plan_items(info, key, color) 106 | collection = info.send(key) 107 | max_name = collection.map(&:name).map(&:size).max 108 | max_type = collection.map(&:type).map(&:size).max 109 | max_p = collection.map(&:diffs).flatten(1).map(&:name).map(&:to_s).map(&:size).max 110 | max_o = collection.map(&:diffs).flatten(1).map(&:current).map(&:to_s).map(&:size).max 111 | collection.each do |val| 112 | name = val.name 113 | ui.print " " * 6 114 | ui.print ui.color("[#{val.type}]", color) 115 | ui.print " " * (max_type - val.type.size) 116 | ui.print " " * 4 117 | ui.print ui.color(name, :bold) 118 | properties = Array(val.diffs).map(&:name) 119 | unless properties.empty? 120 | ui.print " " * (max_name - name.size) 121 | ui.print " " * 4 122 | ui.print "Reason: `#{properties.join("`, `")}`" 123 | end 124 | ui.puts 125 | if config[:diffs] 126 | unless val.diffs.empty? 127 | p_name = nil 128 | val.diffs.each do |diff| 129 | if !diff.proposed.nil? || !diff.current.nil? 130 | p_name = diff.name 131 | ui.print " " * 8 132 | ui.print "#{p_name}: " 133 | ui.print " " * (max_p - p_name.size) 134 | ui.print ui.color("-#{diff.current}", :red) if diff.current 135 | ui.print " " * (max_o - diff.current.to_s.size) 136 | ui.print " " 137 | if diff.proposed == Sfn::Planner::RUNTIME_MODIFIED 138 | ui.puts ui.color("+#{diff.current} ", :green) 139 | else 140 | if diff.proposed.nil? 141 | ui.puts 142 | else 143 | ui.puts ui.color("+#{diff.proposed.to_s.gsub("__MODIFIED_REFERENCE_VALUE__", "")}", :green) 144 | end 145 | end 146 | end 147 | end 148 | ui.puts if p_name 149 | end 150 | end 151 | end 152 | end 153 | end 154 | end 155 | end 156 | -------------------------------------------------------------------------------- /lib/sfn/config.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | require "bogo-config" 3 | 4 | module Sfn 5 | 6 | # Top level configuration 7 | class Config < Bogo::Config 8 | 9 | # Override attribute helper to detect Hash types and automatically 10 | # add type conversion for CLI provided values + description update 11 | # 12 | # @param name [String, Symbol] name of attribute 13 | # @param type [Class, Array] valid types 14 | # @param info [Hash] attribute information 15 | # @return [Hash] 16 | def self.attribute(name, type, info = Smash.new) 17 | if [type].flatten.any? { |t| t.ancestors.include?(Hash) } 18 | unless info[:coerce] 19 | info[:coerce] = lambda do |v| 20 | case v 21 | when String 22 | Smash[ 23 | v.split(/,(?=[^,]*:)/).map do |item_pair| 24 | item_pair.split(/[=:]/, 2) 25 | end 26 | ] 27 | when Hash 28 | v.to_smash 29 | else 30 | v 31 | end 32 | end 33 | info[:description] ||= "" 34 | info[:description] << " (Key:Value[,Key:Value,...])" 35 | end 36 | end 37 | super(name, type, info) 38 | end 39 | 40 | # Only values allowed designating bool type 41 | BOOLEAN = BOOLEAN_VALUES = [TrueClass, FalseClass].freeze 42 | # Boolean type with nil included 43 | TRISTATE_BOOLEAN = (BOOLEAN + [NilClass]).freeze 44 | 45 | autoload :Conf, "sfn/config/conf" 46 | autoload :Create, "sfn/config/create" 47 | autoload :Describe, "sfn/config/describe" 48 | autoload :Destroy, "sfn/config/destroy" 49 | autoload :Describe, "sfn/config/describe" 50 | autoload :Diff, "sfn/config/diff" 51 | autoload :Events, "sfn/config/events" 52 | autoload :Export, "sfn/config/export" 53 | autoload :Graph, "sfn/config/graph" 54 | autoload :Import, "sfn/config/import" 55 | autoload :Init, "sfn/config/init" 56 | autoload :Inspect, "sfn/config/inspect" 57 | autoload :Lint, "sfn/config/lint" 58 | autoload :List, "sfn/config/list" 59 | autoload :Plan, "sfn/config/plan" 60 | autoload :Print, "sfn/config/print" 61 | autoload :Promote, "sfn/config/promote" 62 | autoload :Realize, "sfn/config/realize" 63 | autoload :Trace, "sfn/config/trace" 64 | autoload :Update, "sfn/config/update" 65 | autoload :Validate, "sfn/config/validate" 66 | 67 | attribute( 68 | :config, String, 69 | :description => "Configuration file path", 70 | :short_flag => "c", 71 | ) 72 | 73 | attribute( 74 | :credentials, Smash, 75 | :description => "Provider credentials", 76 | :short_flag => "C", 77 | ) 78 | attribute( 79 | :ignore_parameters, String, 80 | :multiple => true, 81 | :description => "Parameters to ignore during modifications", 82 | :short_flag => "i", 83 | ) 84 | attribute( 85 | :interactive_parameters, [TrueClass, FalseClass], 86 | :default => true, 87 | :description => "Prompt for template parameters", 88 | :short_flag => "I", 89 | ) 90 | attribute( 91 | :poll, [TrueClass, FalseClass], 92 | :default => true, 93 | :description => "Poll stack events on modification actions", 94 | :short_flag => "p", 95 | ) 96 | attribute( 97 | :defaults, [TrueClass, FalseClass], 98 | :description => "Automatically accept default values", 99 | :short_flag => "d", 100 | ) 101 | attribute( 102 | :yes, [TrueClass, FalseClass], 103 | :description => "Automatically accept any requests for confirmation", 104 | :short_flag => "y", 105 | ) 106 | attribute( 107 | :debug, [TrueClass, FalseClass], 108 | :description => "Enable debug output", 109 | :short_flag => "u", 110 | ) 111 | attribute( 112 | :log, String, 113 | :description => "Enable logging with given level", 114 | :short_flag => "G", 115 | ) 116 | attribute( 117 | :colors, [TrueClass, FalseClass], 118 | :description => "Enable colorized output", 119 | :default => true, 120 | ) 121 | 122 | attribute :conf, Conf, :coerce => proc { |v| Conf.new(v) } 123 | attribute :create, Create, :coerce => proc { |v| Create.new(v) } 124 | attribute :update, Update, :coerce => proc { |v| Update.new(v) } 125 | attribute :destroy, Destroy, :coerce => proc { |v| Destroy.new(v) } 126 | attribute :events, Events, :coerce => proc { |v| Events.new(v) } 127 | attribute :export, Export, :coerce => proc { |v| Export.new(v) } 128 | attribute :import, Import, :coerce => proc { |v| Import.new(v) } 129 | attribute :inspect, Inspect, :coerce => proc { |v| Inpsect.new(v) } 130 | attribute :describe, Describe, :coerce => proc { |v| Describe.new(v) } 131 | attribute :list, List, :coerce => proc { |v| List.new(v) } 132 | attribute :promote, Promote, :coerce => proc { |v| Promote.new(v) } 133 | attribute :validate, Validate, :coerce => proc { |v| Validate.new(v) } 134 | 135 | # Provide all options for config class (includes global configs) 136 | # 137 | # @param klass [Class] 138 | # @return [Smash] 139 | def self.options_for(klass) 140 | shorts = ["h"] # always reserve `-h` for help 141 | _options_for(klass, shorts) 142 | end 143 | 144 | # Provide options for config class 145 | # 146 | # @param klass [Class] 147 | # @param shorts [Array] 148 | # @return [Smash] 149 | def self._options_for(klass, shorts) 150 | opts = Smash[ 151 | [klass].map do |a| 152 | if a.ancestors.include?(Bogo::Config) && !a.attributes.empty? 153 | a.attributes 154 | end 155 | end.compact.reverse.inject(Smash.new) { |m, n| m.deep_merge(n) }.map do |name, info| 156 | next unless info[:description] 157 | short = info[:short_flag] 158 | if !short.to_s.empty? && shorts.include?(short) 159 | raise ArgumentError.new "Short flag already in use! (`#{short}` not available for `#{klass}`)" 160 | end 161 | unless short.to_s.empty? 162 | shorts << short 163 | info[:short] = short 164 | end 165 | info[:long] = name.tr("_", "-") 166 | info[:boolean] = [info[:type]].compact.flatten.all? { |t| BOOLEAN_VALUES.include?(t) } 167 | [name, info] 168 | end.compact 169 | ] 170 | opts 171 | end 172 | end 173 | end 174 | -------------------------------------------------------------------------------- /lib/sfn/config/conf.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | class Config 5 | # Config command configuration (subclass create to get all the configs) 6 | class Conf < Create 7 | attribute( 8 | :generate, [TrueClass, FalseClass], 9 | :description => "Generate a basic configuration file", 10 | :short_flag => "g", 11 | ) 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/sfn/config/create.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | class Config 5 | # Create command configuration 6 | class Create < Update 7 | attribute( 8 | :timeout, Integer, 9 | :coerce => proc { |v| v.to_i }, 10 | :description => "Seconds to wait for stack to complete", 11 | :short_flag => "M", 12 | ) 13 | attribute( 14 | :rollback, [TrueClass, FalseClass], 15 | :description => "Rollback stack on failure", 16 | :short_flag => "O", 17 | ) 18 | attribute( 19 | :options, Smash, 20 | :description => "Extra options to apply to the API call", 21 | :short_flag => "S", 22 | ) 23 | attribute( 24 | :notification_topics, String, 25 | :multiple => true, 26 | :description => "Notification endpoints for stack events", 27 | :short_flag => "z", 28 | ) 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/sfn/config/describe.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | class Config 5 | class Describe < Config 6 | attribute( 7 | :resources, [TrueClass, FalseClass], 8 | :description => "Display stack resource list", 9 | :short_flag => "r", 10 | ) 11 | 12 | attribute( 13 | :outputs, [TrueClass, FalseClass], 14 | :description => "Display stack outputs", 15 | :short_flag => "o", 16 | ) 17 | 18 | attribute( 19 | :tags, [TrueClass, FalseClass], 20 | :description => "Display stack tags", 21 | :short_flag => "t", 22 | ) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/sfn/config/destroy.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | class Config 5 | # Destroy command configuration 6 | class Destroy < Config 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/sfn/config/diff.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | class Config 5 | # Diff new template with existing stack template 6 | class Diff < Update 7 | attribute( 8 | :raw_diff, [TrueClass, FalseClass], 9 | :default => false, 10 | :description => "Display raw diff information", 11 | :short_flag => "w", 12 | ) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/sfn/config/events.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | class Config 5 | # Events command configuration 6 | class Events < Config 7 | attribute( 8 | :attribute, String, 9 | :multiple => true, 10 | :description => "Event attribute to display", 11 | :short_flag => "a", 12 | ) 13 | attribute( 14 | :poll_delay, Integer, 15 | :default => 20, 16 | :description => "Seconds to pause between each event poll", 17 | :coerce => lambda { |v| v.to_i }, 18 | :short_flag => "P", 19 | ) 20 | attribute( 21 | :all_attributes, [TrueClass, FalseClass], 22 | :description => "Display all event attributes", 23 | :short_flag => "A", 24 | ) 25 | attribute( 26 | :all_events, [TrueClass, FalseClass], 27 | :description => "Display all available events", 28 | :short_flag => "L", 29 | ) 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/sfn/config/export.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | class Config 5 | 6 | # Export command configuration 7 | class Export < Config 8 | attribute( 9 | :name, String, 10 | :description => "Export file base name", 11 | ) 12 | attribute( 13 | :path, String, 14 | :description => "Local path prefix for dump file", 15 | ) 16 | attribute( 17 | :bucket, String, 18 | :description => "Remote storage bucket", 19 | ) 20 | attribute( 21 | :bucket_prefix, String, 22 | :description => "Remote key prefix within bucket for dump file", 23 | ) 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/sfn/config/graph.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | class Config 5 | # Generate graph 6 | class Graph < Validate 7 | attribute( 8 | :output_file, String, 9 | :description => "Directory to write graph files", 10 | :short_flag => "O", 11 | :default => File.join(Dir.pwd, "sfn-graph"), 12 | ) 13 | 14 | attribute( 15 | :output_type, String, 16 | :description => "File output type (Requires graphviz package for non-dot types)", 17 | :short_flag => "e", 18 | :default => "dot", 19 | ) 20 | 21 | attribute( 22 | :graph_style, String, 23 | :description => "Style of graph (`dependency`, `creation`)", 24 | :default => "creation", 25 | ) 26 | 27 | attribute( 28 | :luckymike, [TrueClass, FalseClass], 29 | :description => "Force `dependency` style graph", 30 | :default => false, 31 | ) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/sfn/config/import.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | class Config 5 | 6 | # Import command configuration 7 | class Import < Config 8 | attribute( 9 | :path, String, 10 | :description => "Directory path JSON export files are located", 11 | ) 12 | attribute( 13 | :bucket, String, 14 | :description => "Remote storage bucket JSON export files are located", 15 | ) 16 | attribute( 17 | :bucket_prefix, String, 18 | :description => "Remote key prefix within bucket for dump file", 19 | ) 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/sfn/config/init.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | class Config 5 | # Init command configuration 6 | class Init < Config 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/sfn/config/inspect.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | class Config 5 | # Inspect command configuration 6 | class Inspect < Config 7 | attribute( 8 | :attribute, String, 9 | :multiple => true, 10 | :description => "Dot delimited attribute to view", 11 | :short_flag => "a", 12 | ) 13 | attribute( 14 | :nodes, [TrueClass, FalseClass], 15 | :description => "Locate all instances and display addresses", 16 | :short_flag => "n", 17 | ) 18 | attribute( 19 | :load_balancers, [TrueClass, FalseClass], 20 | :description => "Locate all load balancers, display addresses and server states", 21 | :short_flag => "l", 22 | ) 23 | attribute( 24 | :instance_failure, [TrueClass, FalseClass], 25 | :description => "Display log file error from failed not if possible", 26 | :short_flag => "N", 27 | ) 28 | attribute( 29 | :failure_log_path, String, 30 | :description => "Path to remote log file for display on failure", 31 | :default => "/var/log/chef/client.log", 32 | :short_flag => "f", 33 | ) 34 | attribute( 35 | :identity_file, String, 36 | :description => "SSH identity file for authentication", 37 | :short_flag => "D", 38 | ) 39 | attribute( 40 | :ssh_user, String, 41 | :description => "SSH username for inspection connect", 42 | :short_flag => "s", 43 | ) 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/sfn/config/lint.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | class Config 5 | # Lint command configuration 6 | class Lint < Validate 7 | attribute( 8 | :lint_directory, String, 9 | :description => "Directory containing lint rule sets", 10 | :multiple => true, 11 | ) 12 | attribute( 13 | :disabled_rule_set, String, 14 | :description => "Disable rule set from being applied", 15 | :multiple => true, 16 | ) 17 | attribute( 18 | :enabled_rule_set, String, 19 | :description => "Only apply this rule set", 20 | :multiple => true, 21 | ) 22 | attribute( 23 | :local_rule_sets_only, [TrueClass, FalseClass], 24 | :description => "Only apply rule sets provided by lint directory", 25 | :default => false, 26 | ) 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/sfn/config/list.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | class Config 5 | # List command configuration 6 | class List < Sfn::Config 7 | attribute( 8 | :attribute, String, 9 | :multiple => true, 10 | :description => "Attribute of stack to print", 11 | :short_flag => "a", 12 | ) 13 | attribute( 14 | :all_attributes, [TrueClass, FalseClass], 15 | :description => "Print all available attributes", 16 | :short_flag => "A", 17 | ) 18 | attribute( 19 | :status, String, 20 | :multiple => true, 21 | :description => 'Match stacks with given status. Use "none" to disable.', 22 | :short_flag => "s", 23 | ) 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/sfn/config/plan.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | class Config 5 | # Plan command configuration 6 | class Plan < Create 7 | # Remove the plan option. Command specific options will 8 | # cause a conflict if same option name as command is used. 9 | # Also, since this is a plan command, we are always running 10 | # a plan, because that's the command. 11 | attributes.delete(:plan) 12 | # Default diffs to be enabled 13 | attributes.set(:diffs, :default, true) 14 | 15 | attribute( 16 | :plan_name, String, 17 | :description => "Custom plan name or ID (not applicable to all providers)", 18 | ) 19 | 20 | attribute( 21 | :load_existing, TRISTATE_BOOLEAN, 22 | :description => "Load existing plan if exists", 23 | :default => nil, 24 | ) 25 | 26 | attribute( 27 | :auto_destroy_stack, TRISTATE_BOOLEAN, 28 | :description => "Automatically destroy empty stack", 29 | :default => nil, 30 | ) 31 | 32 | attribute( 33 | :auto_destroy_plan, TRISTATE_BOOLEAN, 34 | :description => "Automatically destroy generated plan", 35 | :default => nil, 36 | ) 37 | 38 | attribute( 39 | :list, BOOLEAN, 40 | :description => "List all available plans for stack", 41 | :default => false, 42 | :short_flag => "l", 43 | ) 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/sfn/config/print.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | class Config 5 | # Print command configuration 6 | class Print < Validate 7 | attribute( 8 | :write_to_file, String, 9 | :description => "Write compiled SparkleFormation template to path provided", 10 | :short_flag => "w", 11 | ) 12 | 13 | attribute( 14 | :sparkle_dump, [TrueClass, FalseClass], 15 | :description => "Do not use provider customized dump behavior", 16 | ) 17 | 18 | attribute( 19 | :yaml, [TrueClass, FalseClass], 20 | :description => "Output template content in YAML format", 21 | ) 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/sfn/config/promote.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | class Config 5 | # Promote command configuration 6 | class Promote < Config 7 | attribute( 8 | :accounts, String, 9 | :description => "JSON accounts file path", 10 | ) 11 | attribute( 12 | :bucket, String, 13 | :description => "Bucket name containing the exports", 14 | ) 15 | attribute( 16 | :bucket_prefix, String, 17 | :description => "Key prefix within remote bucket", 18 | ) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/sfn/config/realize.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | class Config 5 | # Realize command configuration 6 | class Realize < Config 7 | attribute( 8 | :plan_name, String, 9 | :description => "Custom plan name or ID", 10 | ) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/sfn/config/trace.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | class Config 5 | # Print command configurationUpdate command configuration 6 | class Trace < Validate 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/sfn/config/update.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | class Config 5 | # Update command configuration 6 | class Update < Validate 7 | attribute( 8 | :apply_stack, String, 9 | :multiple => true, 10 | :description => "Apply outputs from stack to input parameters", 11 | :short_flag => "A", 12 | ) 13 | attribute( 14 | :apply_mapping, Smash, 15 | :description => "Customize apply stack mapping as [StackName__]OutputName:ParameterName", 16 | ) 17 | attribute( 18 | :parameter, Smash, 19 | :multiple => true, 20 | :description => "[DEPRECATED - use `parameters`] Pass template parameters directly (ParamName:ParamValue)", 21 | :coerce => lambda { |v, inst| 22 | result = inst.data[:parameter] || Array.new 23 | case v 24 | when String 25 | v.split(",").each do |item| 26 | result.push(Smash[*item.split(/[=:]/, 2)]) 27 | end 28 | else 29 | result.push(v.to_smash) 30 | end 31 | {:bogo_multiple => result} 32 | }, 33 | :short_flag => "R", 34 | ) 35 | attribute( 36 | :parameters, Smash, 37 | :description => "Pass template parameters directly", 38 | :short_flag => "m", 39 | ) 40 | attribute( 41 | :plan, [TrueClass, FalseClass], 42 | :default => true, 43 | :description => "Provide planning information prior to update", 44 | :short_flag => "l", 45 | ) 46 | attribute( 47 | :plan_only, [TrueClass, FalseClass], 48 | :default => false, 49 | :description => "Exit after plan display", 50 | ) 51 | attribute( 52 | :diffs, [TrueClass, FalseClass], 53 | :description => "Show planner content diff", 54 | :short_flag => "D", 55 | ) 56 | attribute( 57 | :merge_api_options, [TrueClass, FalseClass], 58 | :description => "Merge API options defined within configuration on update", 59 | :default => false, 60 | ) 61 | attribute( 62 | :parameter_validation, String, 63 | :allowed => ["default", "none", "current", "expected"], 64 | :description => "Stack parameter validation behavior", 65 | :default => "default", 66 | ) 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/sfn/config/validate.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | class Config 5 | # Validate command configuration 6 | class Validate < Config 7 | attribute( 8 | :processing, [TrueClass, FalseClass], 9 | :description => "Call the unicorns and explode the glitter bombs", 10 | :default => true, 11 | :short_flag => "P", 12 | ) 13 | attribute( 14 | :file, String, 15 | :description => "Path to template file", 16 | :default => nil, 17 | :short_flag => "f", 18 | ) 19 | attribute( 20 | :file_path_prompt, [TrueClass, FalseClass], 21 | :default => true, 22 | :description => "Enable interactive prompt for template path discovery", 23 | :short_flag => "F", 24 | ) 25 | attribute( 26 | :base_directory, String, 27 | :description => "Path to root of of templates directory", 28 | :short_flag => "b", 29 | ) 30 | attribute( 31 | :no_base_directory, [TrueClass, FalseClass], 32 | :description => "Unset any value used for the template root directory path", 33 | :short_flag => "n", 34 | ) 35 | attribute( 36 | :translate, String, 37 | :description => "Translate generated template to given provider", 38 | :short_flag => "t", 39 | ) 40 | attribute( 41 | :translate_chunk, Integer, 42 | :description => "Chunk length for serialization", 43 | :coerce => lambda { |v| v.to_i }, 44 | :short_flag => "T", 45 | ) 46 | attribute( 47 | :apply_nesting, [String, Symbol], 48 | :default => "deep", 49 | :description => "Apply stack nesting", 50 | :short_flag => "a", 51 | ) 52 | attribute( 53 | :nesting_bucket, String, 54 | :description => "Bucket to use for storing nested stack templates", 55 | :short_flag => "N", 56 | ) 57 | attribute( 58 | :nesting_prefix, String, 59 | :description => "File name prefix for storing template in bucket", 60 | :short_flag => "Y", 61 | ) 62 | attribute( 63 | :print_only, [TrueClass, FalseClass], 64 | :description => "Print the resulting stack template", 65 | :short_flag => "r", 66 | ) 67 | attribute( 68 | :sparkle_pack, String, 69 | :multiple => true, 70 | :description => "Load SparklePack gem", 71 | :coerce => lambda { |s| s.to_s }, 72 | :short_flag => "s", 73 | ) 74 | attribute( 75 | :compile_parameters, Smash, 76 | :description => "Pass template compile time parameters directly", 77 | :short_flag => "o", 78 | :coerce => lambda { |v| 79 | case v 80 | when String 81 | result = Smash.new 82 | v.split(",").each do |item_pair| 83 | key, value = item_pair.split(/[=:]/, 2) 84 | if !result[key] 85 | result[key] = value 86 | next 87 | end 88 | if !result.is_a?(Array) 89 | result[key] = [result[key]] 90 | end 91 | result[key] << value 92 | end 93 | result 94 | when Hash 95 | result = Smash.new 96 | extractor = lambda do |data, prefix| 97 | data.each_pair do |key, value| 98 | local_key = "#{prefix}__#{key}" 99 | if value.is_a?(Hash) 100 | extractor.call(value, local_key) 101 | else 102 | result[local_key] = data 103 | end 104 | end 105 | end 106 | result 107 | else 108 | v 109 | end 110 | }, 111 | ) 112 | attribute( 113 | :upload_root_template, [TrueClass, FalseClass], 114 | :description => "Upload root template to storage bucket", 115 | ) 116 | attribute( 117 | :all, [TrueClass, FalseClass], 118 | :description => "Validate all templates", 119 | ) 120 | attribute( 121 | :group, String, 122 | :description => "Validate templates with the specified group prefix", 123 | ) 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /lib/sfn/error.rb: -------------------------------------------------------------------------------- 1 | module Sfn 2 | class Error < StandardError 3 | # @return [Integer] exit code to report 4 | attr_reader :exit_code 5 | # @return [Exception, nil] original exception 6 | attr_reader :original 7 | 8 | # Exit code used when no custom code provided 9 | DEFAULT_EXIT_CODE = 1 10 | 11 | def self.exit_code(c = nil) 12 | if c || !defined?(@exit_code) 13 | @exit_code = c.to_i != 0 ? c : DEFAULT_EXIT_CODE 14 | end 15 | @exit_code 16 | end 17 | 18 | def self.error_msg(m = nil) 19 | if m || !defined?(@error_msg) 20 | @error_msg = m 21 | end 22 | @error_msg 23 | end 24 | 25 | def initialize(*args) 26 | opts = args.detect { |a| a.is_a?(Hash) } || {} 27 | opts = opts.to_smash 28 | msg = args.first.is_a?(String) ? args.first : self.class.error_msg 29 | super(msg) 30 | @exit_code = opts.fetch(:exit_code, self.class.exit_code).to_i 31 | if opts[:original] 32 | if opts[:original].is_a?(Exception) 33 | @original = opts[:original] 34 | else 35 | raise TypeError.new "Expected `Exception` type in `:original` " \ 36 | "option but received `#{opts[:original].class}`" 37 | end 38 | end 39 | end 40 | 41 | class InteractionDisabled < Error 42 | error_msg "Interactive prompting is disabled" 43 | exit_code 2 44 | end 45 | 46 | class StackNotFound < Error 47 | error_msg "Failed to locate requested stack" 48 | exit_code 3 49 | end 50 | 51 | class StackPlanNotFound < Error 52 | error_msg "Failed to locate requested stack plan" 53 | exit_code 4 54 | end 55 | 56 | class StackStateIncomplete < Error 57 | error_msg "Stack did not reach a successful completion state" 58 | exit_code 5 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/sfn/lint.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | require "jmespath" 3 | 4 | module Sfn 5 | module Lint 6 | autoload :Definition, "sfn/lint/definition" 7 | autoload :Rule, "sfn/lint/rule" 8 | autoload :RuleSet, "sfn/lint/rule_set" 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/sfn/lint/definition.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | module Lint 5 | # Lint defition 6 | class Definition 7 | 8 | # @return [String] search expression used for matching 9 | attr_reader :search_expression 10 | # @return [Proc-ish] must respond to #call 11 | attr_reader :evaluator 12 | # @return [Symbol] target provider 13 | attr_reader :provider 14 | 15 | # Create a new definition 16 | # 17 | # @param expr [String] search expression used for matching 18 | # @param provider [String, Symbol] target provider 19 | # @param evaluator [Proc] logic used to handle match 20 | # @return [self] 21 | def initialize(expr, provider = :aws, evaluator = nil, &block) 22 | if evaluator && block 23 | raise ArgumentError.new "Only evaluator or block can be provided, not both." 24 | end 25 | @provider = Bogo::Utility.snake(provider).to_sym 26 | @search_expression = expr 27 | @evaluator = evaluator || block 28 | end 29 | 30 | # Apply definition to template 31 | # 32 | # @param template [Hash] template being processed 33 | # @return [TrueClass, Array] true if passed. List of string results that failed 34 | def apply(template) 35 | result = JMESPath.search(search_expression, template) 36 | run(result, template) 37 | end 38 | 39 | protected 40 | 41 | # Check result of search expression 42 | # 43 | # @param result [Object] result(s) of search expression 44 | # @param template [Hash] full template 45 | # @return [TrueClass, Array] true if passed. List of string results that failed 46 | # @note override this method when subclassing 47 | def run(result, template) 48 | unless evaluator 49 | raise NotImplementedError.new "No evaluator has been defined for this definition!" 50 | end 51 | evaluator.call(result, template) 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/sfn/lint/rule.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | module Lint 5 | # Composition of definitions 6 | class Rule 7 | 8 | # @return [Symbol] name of rule 9 | attr_reader :name 10 | # @return [Array] definitions composing rule 11 | attr_reader :definitions 12 | # @return [String] message describing failure 13 | attr_reader :fail_message 14 | # @return [Symbol] target provider 15 | attr_reader :provider 16 | 17 | # Create a new rule 18 | # 19 | # @param name [String, Symbol] name of rule 20 | # @param definitions [Array] definitions composing rule 21 | # @param fail_message [String] message to describe failure 22 | # @param provider [String, Symbol] target provider 23 | # @return [self] 24 | def initialize(name, definitions, fail_message, provider = :aws) 25 | @name = name.to_sym 26 | @definitions = definitions.dup.uniq.freeze 27 | @fail_message = fail_message 28 | @provider = Bogo::Utility.snake(provider).to_sym 29 | validate_definitions! 30 | end 31 | 32 | # Generate the failure message for this rule with given failure 33 | # result set. 34 | def generate_fail_message(results) 35 | msg = fail_message.dup 36 | unless results.empty? 37 | failed_items = results.map do |item| 38 | f_item = item[:failures] 39 | next if f_item.nil? || f_item == true || f_item == false 40 | f_item 41 | end.flatten.compact.map(&:to_s) 42 | unless failed_items.empty? 43 | msg = "#{msg} (failures: `#{failed_items.join("`, `")}`)" 44 | end 45 | end 46 | msg 47 | end 48 | 49 | # Apply all definitions to template 50 | # 51 | # @param template [Hash] 52 | # @return [TrueClass, Array] true if passed. Definition failures if failed. 53 | def apply(template) 54 | results = definitions.map do |definition| 55 | result = definition.apply(template) 56 | result == true ? result : Smash.new(:definition => definition, :failures => result) 57 | end 58 | if results.all? { |item| item == true } 59 | true 60 | else 61 | results.delete_if { |item| item == true } 62 | results 63 | end 64 | end 65 | 66 | # Check if template passes this rule 67 | # 68 | # @param template [Hash] 69 | # @return [TrueClass, FalseClass] 70 | def pass?(template) 71 | apply(template) == true 72 | end 73 | 74 | # Check if template fails this rule 75 | # 76 | # @param template [Hash] 77 | # @return [TrueClass, FalseClass] 78 | def fail?(template) 79 | !pass?(template) 80 | end 81 | 82 | # Add a new definition to the collection 83 | # 84 | # @param definition [Definition] new definition to add 85 | # @return [self] 86 | def add_definition(definition) 87 | new_defs = definitions.dup 88 | new_defs << definition 89 | @definitions = new_defs.uniq.freeze 90 | validate_definitions! 91 | self 92 | end 93 | 94 | # Remove a definition from the collection 95 | # 96 | # @param definition [Definition] definition to remove 97 | # @return [self] 98 | def remove_definition(definition) 99 | new_defs = definitions.dup 100 | new_defs.delete(definition) 101 | @definitions = new_defs.uniq.freeze 102 | self 103 | end 104 | 105 | # Check that provided definitions provider match rule defined provider 106 | def validate_definitions! 107 | non_match = definitions.find_all do |definition| 108 | definition.provider != provider 109 | end 110 | unless non_match.empty? 111 | raise ArgumentError.new "Rule defines `#{provider}` as provider but includes definitions for " \ 112 | "non matching providers. (#{non_match.map(&:provider).map(&:to_s).uniq.sort.join(", ")})" 113 | end 114 | end 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /lib/sfn/lint/rule_set.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | module Lint 5 | # Named collection of rules 6 | class RuleSet 7 | 8 | # Helper class for ruleset generation 9 | class Creator 10 | attr_reader :items, :provider 11 | 12 | def initialize(provider) 13 | @provider = provider 14 | @items = [] 15 | end 16 | 17 | class RuleSet < Creator 18 | def rule(name, &block) 19 | r = Rule.new(provider) 20 | r.instance_exec(&block) 21 | items << Sfn::Lint::Rule.new(name, r.items, r.fail_message, provider) 22 | end 23 | end 24 | 25 | class Rule < Creator 26 | def definition(expr, evaluator = nil, &block) 27 | items << Sfn::Lint::Definition.new(expr, provider, evaluator, &block) 28 | end 29 | 30 | def fail_message(val = nil) 31 | unless val.nil? 32 | @fail_message = val 33 | end 34 | @fail_message 35 | end 36 | end 37 | end 38 | 39 | class << self 40 | @@_rule_set_registry = Smash.new 41 | 42 | # RuleSet generator helper for quickly building simple rule sets 43 | # 44 | # @param name [String] name of rule set 45 | # @param provider [String, Symbol] target provider 46 | # @yieldblock rule set content 47 | def build(name, provider = :aws, &block) 48 | provider = Bogo::Utility.snake(provider).to_sym 49 | rs = Creator::RuleSet.new(provider) 50 | rs.instance_exec(&block) 51 | self.new(name, provider, rs.items) 52 | end 53 | 54 | # Register a rule set 55 | # 56 | # @param rule_set [RuleSet] 57 | # @return [TrueClass] 58 | def register(rule_set) 59 | @@_rule_set_registry.set(rule_set.provider, rule_set.name, rule_set) 60 | true 61 | end 62 | 63 | # Get registered rule set 64 | # 65 | # @param name [String] name of rule set 66 | # @param provider [String] target provider 67 | # @return [RuleSet, NilClass] 68 | def get(name, provider = :aws) 69 | provider = Bogo::Utility.snake(provider) 70 | @@_rule_set_registry.get(provider, name) 71 | end 72 | 73 | # Get all rule sets for specified provider 74 | # 75 | # @param provider [String] target provider 76 | # @return [Array] 77 | def get_all(provider = :aws) 78 | @@_rule_set_registry.fetch(provider, {}).values 79 | end 80 | end 81 | 82 | include Bogo::Memoization 83 | 84 | # @return [Symbol] name 85 | attr_reader :name 86 | # @return [Symbol] target provider 87 | attr_reader :provider 88 | # @return [Array] rules of set 89 | attr_reader :rules 90 | 91 | # Create new rule set 92 | # 93 | # @param name [String, Symbol] name of rule set 94 | # @param provider [String, Symbol] name of target provider 95 | # @param rules [Array] list of rules defining this set 96 | # @return [self] 97 | def initialize(name, provider = :aws, rules = []) 98 | @name = name.to_sym 99 | @provider = Bogo::Utility.snake(provider).to_sym 100 | @rules = rules.dup.uniq.freeze 101 | validate_rules! 102 | end 103 | 104 | # Add a new rule to the collection 105 | # 106 | # @param rule [Rule] new rule to add 107 | # @return [self] 108 | def add_rule(rule) 109 | new_rules = rules.dup 110 | new_rules << rule 111 | @rules = new_rules.uniq.freeze 112 | validate_rules! 113 | self 114 | end 115 | 116 | # Remove a rule from the collection 117 | # 118 | # @param rule [Rule] rule to remove 119 | # @return [self] 120 | def remove_rule(rule) 121 | new_rules = rules.dup 122 | new_rules.delete(rule) 123 | @rules = new_rules.uniq.freeze 124 | self 125 | end 126 | 127 | # Apply rule set to template. 128 | # 129 | # @param template [Hash] 130 | # @return [TrueClass, Array] true on success, list failure messages on failure 131 | def apply(template) 132 | failures = collect_failures(template) 133 | if failures.empty? 134 | true 135 | else 136 | failures.map do |failure| 137 | failure[:rule].generate_fail_message(failure[:result]) 138 | end 139 | end 140 | end 141 | 142 | # Process template through rules defined in this set and 143 | # store failure information 144 | # 145 | # @param template [Hash] 146 | # @return [Array] list of failures 147 | def collect_failures(template) 148 | results = rules.map do |rule| 149 | result = rule.apply(template) 150 | result == true ? true : Smash.new(:rule => rule, :result => result) 151 | end 152 | results.delete_if { |i| i == true } 153 | results 154 | end 155 | 156 | # Check that provided rules provider match rule set defined provider 157 | def validate_rules! 158 | non_match = rules.find_all do |rule| 159 | rule.provider != provider 160 | end 161 | unless non_match.empty? 162 | raise ArgumentError.new "Rule set defines `#{provider}` as provider but includes rules for " \ 163 | "non matching providers. (#{non_match.map(&:provider).map(&:to_s).uniq.sort.join(", ")})" 164 | end 165 | end 166 | end 167 | end 168 | end 169 | -------------------------------------------------------------------------------- /lib/sfn/monkey_patch.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | # Container for monkey patches 5 | module MonkeyPatch 6 | autoload :Stack, "sfn/monkey_patch/stack" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/sfn/monkey_patch/stack/azure.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | module MonkeyPatch 5 | module Stack 6 | # Azure specific monkey patch implementations 7 | module Azure 8 | 9 | # @return [Hash] restructured azure template 10 | # @note Will return #template if name collision encountered within resources 11 | def sparkleish_template_azure 12 | new_template = template.to_smash 13 | resources = new_template.delete(:resources) 14 | resources.each do |resource| 15 | new_template.set(:resources, resource.delete(:name), resource) 16 | end 17 | resources.size == new_template[:resources].size ? new_template : template 18 | end 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/sfn/monkey_patch/stack/google.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | module MonkeyPatch 5 | module Stack 6 | # Google specific monkey patch implementations 7 | module Google 8 | 9 | # Helper module to allow nested stack behavior to function as expected 10 | # internally within sfn 11 | module PretendStack 12 | 13 | # disable reload 14 | def reload 15 | self 16 | end 17 | 18 | # disable template load 19 | def perform_template_load 20 | Smash.new 21 | end 22 | 23 | # only show resources associated to this stack 24 | def resources 25 | collection = Miasma::Models::Orchestration::Stack::Resources.new(self) 26 | collection.define_singleton_method(:perform_population) do 27 | valid = stack.sparkleish_template.fetch(:resources, {}).keys 28 | stack.custom[:resources].find_all { |r| valid.include?(r[:name]) }.map do |attrs| 29 | Miasma::Models::Orchestration::Stack::Resource.new(stack, attrs).valid_state 30 | end 31 | end 32 | collection 33 | end 34 | 35 | # Sub-stacks never provide events 36 | def events 37 | collection = Miasma::Models::Orchestration::Stack::Events.new(self) 38 | collection.define_singleton_method(:perform_population) { [] } 39 | collection 40 | end 41 | end 42 | 43 | # Return all stacks contained within this stack 44 | # 45 | # @param recurse [TrueClass, FalseClass] recurse to fetch _all_ stacks 46 | # @return [Array] 47 | def nested_stacks_google(recurse = true) 48 | my_template = sparkleish_template 49 | if my_template[:resources][name] 50 | my_template = my_template.get(:resources, name, :properties, :stack) 51 | end 52 | n_stacks = my_template[:resources].map do |s_name, content| 53 | if content[:type] == "sparkleformation.stack" 54 | n_stack = self.class.new(api) 55 | n_stack.extend PretendStack 56 | n_layout = custom.fetch(:layout, {}).fetch(:resources, []).detect { |r| r[:name] == name } 57 | n_layout = (n_layout || custom.fetch(:layout, {})).fetch(:resources, []).detect { |r| r[:name] == s_name } || Smash.new 58 | n_stack.load_data( 59 | :name => s_name, 60 | :id => s_name, 61 | :template => content.get(:properties, :stack), 62 | :outputs => n_layout.fetch("outputs", []).map { |o_val| Smash.new(:key => o_val[:name], :value => o_val["finalValue"]) }, 63 | :custom => { 64 | :resources => resources.all.map(&:attributes), 65 | :layout => n_layout, 66 | }, 67 | ).valid_state 68 | n_stack.data[:logical_id] = s_name 69 | n_stack.data[:parent_stack] = self 70 | n_stack 71 | end 72 | end.compact 73 | if recurse 74 | (n_stacks + n_stacks.map(&:nested_stacks)).flatten.compact 75 | else 76 | n_stacks 77 | end 78 | end 79 | 80 | # @return [Hash] restructured google template 81 | def sparkleish_template_google(*args) 82 | copy_template = template.to_smash 83 | deref = lambda do |template| 84 | result = template.to_smash 85 | (result.delete(:resources) || []).each do |t_resource| 86 | t_name = t_resource.delete(:name) 87 | if t_resource[:type].to_s.end_with?(".jinja") 88 | schema = copy_template.fetch(:config, :content, :imports, []).delete("#{t_resource[:type]}.schema") 89 | schema_content = copy_template.fetch(:imports, []).detect do |s_item| 90 | s_item[:name] == schema 91 | end 92 | if schema_content 93 | t_resource.set(:parameters, schema_content.get(:content, :properties)) 94 | end 95 | n_template = copy_template.fetch(:imports, []).detect do |s_item| 96 | s_item[:name] == t_resource[:type] 97 | end 98 | if n_template 99 | t_resource[:type] = "sparkleformation.stack" 100 | current_properties = t_resource.delete(:properties) 101 | t_resource.set(:properties, :parameters, current_properties) if current_properties 102 | t_resource.set(:properties, :stack, deref.call(n_template[:content])) 103 | end 104 | end 105 | result.set(:resources, t_name, t_resource) 106 | end 107 | result 108 | end 109 | s_template = deref.call(Smash.new(:resources => copy_template.get(:config, :content, :resources))) 110 | if s_template.empty? 111 | template.to_smash 112 | else 113 | layout = custom.fetch(:layout, {}).to_smash 114 | (layout.delete(:resources) || []).each do |l_resource| 115 | layout.set(:resources, l_resource.delete(:name), l_resource) 116 | end 117 | args.include?(:remove_wrapper) ? s_template.get(:resources, name, :properties, :stack) : s_template 118 | end 119 | end 120 | 121 | # @return [Hash] 122 | def root_parameters_google 123 | sparkleish_template.fetch(:resources, name, :properties, :parameters, Smash.new) 124 | end 125 | end 126 | end 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /lib/sfn/planner.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | # Interface for generating plan report 5 | class Planner 6 | autoload :Aws, "sfn/planner/aws" 7 | 8 | # Value to flag runtime modification 9 | RUNTIME_MODIFIED = "__MODIFIED_REFERENCE_VALUE__" 10 | 11 | # @return [Bogo::Ui] 12 | attr_reader :ui 13 | # @return [Smash] 14 | attr_reader :config 15 | # @return [Array] CLI arguments 16 | attr_reader :arguments 17 | # @return [Miasma::Models::Orchestration::Stack] existing remote stack 18 | attr_reader :origin_stack 19 | # @return [Hash] custom options 20 | attr_reader :options 21 | 22 | # Create a new planner instance 23 | # 24 | # @param ui [Bogo::Ui] 25 | # @param config [Smash] 26 | # @param arguments [Array] 27 | # @param stack [Miasma::Models::Orchestration::Stack] 28 | # @param opts [Hash] 29 | # 30 | # @return [self] 31 | def initialize(ui, config, arguments, stack, opts = {}) 32 | @ui = ui 33 | @config = config 34 | @arguments = arguments 35 | @origin_stack = stack 36 | @options = opts 37 | end 38 | 39 | # Generate update report 40 | # 41 | # @param template [Hash] updated template 42 | # @param parameters [Hash] runtime parameters for update 43 | # 44 | # @return [Miasma::Models::Orchestration::Stack::Plan] report 45 | def generate_plan(template, parameters) 46 | raise NotImplementedError 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/sfn/utils.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | # Utility classes and modules 5 | module Utils 6 | autoload :Output, "sfn/utils/output" 7 | autoload :StackParameterValidator, "sfn/utils/stack_parameter_validator" 8 | autoload :StackParameterScrubber, "sfn/utils/stack_parameter_scrubber" 9 | autoload :StackExporter, "sfn/utils/stack_exporter" 10 | autoload :Debug, "sfn/utils/debug" 11 | autoload :JSON, "sfn/utils/json" 12 | autoload :Ssher, "sfn/utils/ssher" 13 | autoload :ObjectStorage, "sfn/utils/object_storage" 14 | autoload :PathSelector, "sfn/utils/path_selector" 15 | 16 | # Provide methods directly from module for previous version compatibility 17 | extend JSON 18 | extend ObjectStorage 19 | extend Bogo::AnimalStrings 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/sfn/utils/debug.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | module Utils 5 | # Debug helpers 6 | module Debug 7 | # Output helpers 8 | module Output 9 | # Write debug message 10 | # 11 | # @param msg [String] 12 | def debug(msg) 13 | if ENV["DEBUG"] || (respond_to?(:config) && config[:debug]) 14 | puts ": #{msg}" 15 | end 16 | end 17 | end 18 | 19 | class << self 20 | # Load module into class 21 | # 22 | # @param klass [Class] 23 | def included(klass) 24 | klass.class_eval do 25 | include Output 26 | extend Output 27 | end 28 | end 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/sfn/utils/json.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | module Utils 5 | 6 | # JSON helper methods 7 | module JSON 8 | 9 | # Convert to JSON 10 | # 11 | # @param thing [Object] 12 | # @return [String] 13 | def _to_json(thing) 14 | MultiJson.dump(thing) 15 | end 16 | 17 | alias_method :dump_json, :_to_json 18 | 19 | # Load JSON data 20 | # 21 | # @param thing [String] 22 | # @return [Object] 23 | def _from_json(thing) 24 | MultiJson.load(thing) 25 | end 26 | 27 | alias_method :load_json, :_from_json 28 | 29 | # Format object into pretty JSON 30 | # 31 | # @param thing [Object] 32 | # @return [String] 33 | def _format_json(thing) 34 | thing = _from_json(thing) if thing.is_a?(String) 35 | MultiJson.dump(thing, :pretty => true) 36 | end 37 | 38 | alias_method :format_json, :_format_json 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/sfn/utils/object_storage.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | module Utils 5 | 6 | # Storage helpers 7 | module ObjectStorage 8 | 9 | # Write to file 10 | # 11 | # @param object [Object] 12 | # @param path [String] path to write object 13 | # @param directory [Miasma::Models::Storage::Directory] 14 | # @return [String] file path 15 | def file_store(object, path, directory) 16 | raise NotImplementedError.new "Internal updated required! :(" 17 | content = object.is_a?(String) ? object : Utils._format_json(object) 18 | directory.files.create( 19 | :identity => path, 20 | :body => content, 21 | ) 22 | loc = directory.service.service.name.split("::").last.downcase 23 | "#{loc}://#{directory.identity}/#{path}" 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/sfn/utils/output.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | module Utils 5 | # Output Helpers 6 | module Output 7 | 8 | # Process things and return items 9 | # 10 | # @param things [Array] items to process 11 | # @param args [Hash] options 12 | # @option args [TrueClass, FalseClass] :flat flatten result array 13 | # @option args [Array] :attributes attributes to extract 14 | # @todo this was extracted from events and needs to be cleaned up 15 | def process(things, args = {}) 16 | @event_ids ||= [] 17 | processed = things.reverse.map do |thing| 18 | next if @event_ids.include?(thing["id"]) 19 | @event_ids.push(thing["id"]).compact! 20 | if args[:attributes] 21 | args[:attributes].map do |key| 22 | thing[key].to_s 23 | end 24 | else 25 | thing.values 26 | end 27 | end 28 | args[:flat] ? processed.flatten : processed 29 | end 30 | 31 | # Generate formatted titles 32 | # 33 | # @param thing [Object] thing being processed 34 | # @param args [Hash] 35 | # @option args [Array] :attributes 36 | # @return [Array] formatted titles 37 | def get_titles(thing, args = {}) 38 | attrs = args[:attributes] || [] 39 | if attrs.empty? 40 | hash = thing.is_a?(Array) ? thing.first : thing 41 | hash ||= {} 42 | attrs = hash.keys 43 | end 44 | titles = attrs.map do |key| 45 | camel(key).gsub(/([a-z])([A-Z])/, '\1 \2') 46 | end.compact 47 | if args[:format] 48 | titles.map { |s| @ui.color(s, :bold) } 49 | else 50 | titles 51 | end 52 | end 53 | 54 | # Output stack related things in nice format 55 | # 56 | # @param stack [String] name of stack 57 | # @param things [Array] things to display 58 | # @param what [String] description of things for output 59 | # @param args [Symbol] options (:ignore_empty_output) 60 | def things_output(stack, things, what, *args) 61 | unless args.include?(:no_title) 62 | output = get_titles(things, :format => true, :attributes => allowed_attributes) 63 | else 64 | output = [] 65 | end 66 | columns = allowed_attributes.size 67 | output += process(things, :flat => true, :attributes => allowed_attributes) 68 | output.compact! 69 | if output.empty? 70 | ui.warn "No information found" unless args.include?(:ignore_empty_output) 71 | else 72 | ui.info "#{what.to_s.capitalize} for stack: #{ui.color(stack, :bold)}" if stack 73 | ui.info "#{ui.list(output, :uneven_columns_across, columns)}" 74 | end 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/sfn/utils/path_selector.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | require "pathname" 3 | 4 | module Sfn 5 | module Utils 6 | 7 | # Helper methods for path selection 8 | module PathSelector 9 | 10 | # Humanize the base name of path 11 | # 12 | # @param path [String] 13 | # @return [String] 14 | def humanize_path_basename(path) 15 | File.basename(path).sub( 16 | File.extname(path), "" 17 | ).split(/[-_]/).map(&:capitalize).join(" ") 18 | end 19 | 20 | # Prompt user for file selection 21 | # 22 | # @param directory [String] path to directory 23 | # @param opts [Hash] options 24 | # @option opts [Array] :ignore_directories directory names 25 | # @option opts [String] :directories_name title for directories 26 | # @option opts [String] :files_name title for files 27 | # @option opts [String] :filter_prefix only return results matching filter 28 | # @return [String] file path 29 | def prompt_for_file(directory, opts = {}) 30 | file_list = Dir.glob(File.join(directory, "**", "**", "*")).find_all do |file| 31 | File.file?(file) 32 | end 33 | if opts[:filter_prefix] 34 | file_list = file_list.find_all do |file| 35 | file.start_with?(options[:filter_prefix]) 36 | end 37 | end 38 | directories = file_list.map do |file| 39 | File.dirname(file) 40 | end.uniq 41 | files = file_list.find_all do |path| 42 | path.sub(directory, "").split("/").size == 2 43 | end 44 | if opts[:ignore_directories] 45 | directories.delete_if do |dir| 46 | opts[:ignore_directories].include?(File.basename(dir)) 47 | end 48 | end 49 | if directories.empty? && files.empty? 50 | ui.fatal "No formation paths discoverable!" 51 | else 52 | output = ["Please select an entry"] 53 | output << "(or directory to list):" unless directories.empty? 54 | ui.info output.join(" ") 55 | output.clear 56 | idx = 1 57 | valid = {} 58 | unless directories.empty? 59 | output << ui.color("#{opts.fetch(:directories_name, "Directories")}:", :bold) 60 | directories.each do |dir| 61 | valid[idx] = {:path => dir, :type => :directory} 62 | output << [idx, humanize_path_basename(dir)] 63 | idx += 1 64 | end 65 | end 66 | unless files.empty? 67 | output << ui.color("#{opts.fetch(:files_name, "Files")}:", :bold) 68 | files.each do |file| 69 | valid[idx] = {:path => file, :type => :file} 70 | output << [idx, humanize_path_basename(file)] 71 | idx += 1 72 | end 73 | end 74 | max = idx.to_s.length 75 | output.map! do |o| 76 | if o.is_a?(Array) 77 | " #{o.first}.#{" " * (max - o.first.to_s.length)} #{o.last}" 78 | else 79 | o 80 | end 81 | end 82 | ui.info "#{output.join("\n")}\n" 83 | response = ui.ask_question("Enter selection: ").to_i 84 | unless valid[response] 85 | ui.fatal "How about using a real value" 86 | exit 1 87 | else 88 | entry = valid[response.to_i] 89 | if entry[:type] == :directory 90 | prompt_for_file(entry[:path], opts) 91 | elsif Pathname(entry[:path]).absolute? 92 | entry[:path] 93 | else 94 | "/#{entry[:path]}" 95 | end 96 | end 97 | end 98 | end 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /lib/sfn/utils/ssher.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | module Utils 5 | 6 | # Helper methods for SSH interactions 7 | module Ssher 8 | 9 | # Retrieve file from remote node 10 | # 11 | # @param address [String] 12 | # @param user [String] 13 | # @param path [String] remote file path 14 | # @param ssh_opts [Hash] 15 | # @return [String, NilClass] 16 | def remote_file_contents(address, user, path, ssh_opts = {}) 17 | if path.to_s.strip.empty? 18 | raise ArgumentError.new "No file path provided!" 19 | end 20 | require "net/ssh" 21 | content = "" 22 | ssh_session = Net::SSH.start(address, user, ssh_opts) 23 | content = ssh_session.exec!("sudo cat #{path}") 24 | content.empty? ? nil : content 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/sfn/utils/stack_parameter_scrubber.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | 3 | module Sfn 4 | module Utils 5 | # Helper for scrubbing stack parameters 6 | module StackParameterScrubber 7 | 8 | # Validate attributes within Parameter blocks 9 | ALLOWED_PARAMETER_ATTRIBUTES = [ 10 | "Type", "Default", "NoEcho", "AllowedValues", "AllowedPattern", 11 | "MaxLength", "MinLength", "MaxValue", "MinValue", "Description", 12 | "ConstraintDescription", 13 | ] 14 | 15 | # Clean the parameters of the template 16 | # 17 | # @param template [Hash] 18 | # @return [Hash] template 19 | def parameter_scrub!(template) 20 | parameters = template["Parameters"] 21 | if parameters 22 | parameters.each do |name, options| 23 | options.delete_if do |attribute, value| 24 | !ALLOWED_PARAMETER_ATTRIBUTES.include?(attribute) 25 | end 26 | end 27 | template["Parameters"] = parameters 28 | end 29 | template 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/sfn/version.rb: -------------------------------------------------------------------------------- 1 | module Sfn 2 | # Current library version 3 | VERSION = Gem::Version.new("3.1.9") 4 | end 5 | -------------------------------------------------------------------------------- /sfn.gemspec: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path(File.dirname(__FILE__)) + "/lib/" 2 | require "sfn/version" 3 | Gem::Specification.new do |s| 4 | s.name = "sfn" 5 | s.version = Sfn::VERSION.version 6 | s.summary = "SparkleFormation CLI" 7 | s.author = "Chris Roberts" 8 | s.email = "code@chrisroberts.org" 9 | s.homepage = "http://github.com/sparkleformation/sfn" 10 | s.description = "SparkleFormation CLI" 11 | s.license = "Apache-2.0" 12 | s.require_path = "lib" 13 | s.add_runtime_dependency "bogo-cli", ">= 0.2.5", "< 0.4" 14 | s.add_runtime_dependency "bogo-ui", ">= 0.1.28", "< 0.4" 15 | s.add_runtime_dependency "miasma", ">= 0.3.3", "< 0.4" 16 | s.add_runtime_dependency "miasma-aws", ">= 0.3.15", "< 0.4" 17 | s.add_runtime_dependency "miasma-azure", ">= 0.1.0", "< 0.3" 18 | s.add_runtime_dependency "miasma-open-stack", ">= 0.1.0", "< 0.3" 19 | s.add_runtime_dependency "miasma-rackspace", ">= 0.1.0", "< 0.3" 20 | s.add_runtime_dependency "miasma-google", ">= 0.1.0", "< 0.3" 21 | s.add_runtime_dependency "miasma-terraform", ">= 0.1.0", "< 0.2.0" 22 | s.add_runtime_dependency "jmespath" 23 | s.add_runtime_dependency "net-ssh" 24 | s.add_runtime_dependency "sparkle_formation", ">= 3.0.39", "< 4" 25 | s.add_runtime_dependency "hashdiff", "~> 0.2.2" 26 | s.add_runtime_dependency "graph", "~> 2.8.1" 27 | s.add_development_dependency "rake", "~> 10" 28 | s.add_development_dependency "minitest" 29 | s.add_development_dependency "rspec", "~> 3.5" 30 | s.add_development_dependency "rufo", "~> 0.3.0" 31 | s.add_development_dependency "mocha" 32 | s.add_development_dependency "yard" 33 | s.executables << "sfn" 34 | s.files = Dir["{lib,bin,docs}/**/*"] + %w(sfn.gemspec README.md CHANGELOG.md LICENSE) 35 | end 36 | -------------------------------------------------------------------------------- /test/helper.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | require "bogo-ui" 3 | require "minitest/autorun" 4 | require "mocha/mini_test" 5 | require "tempfile" 6 | require "openssl" 7 | 8 | # Stub out HTTP so we can easily intercept remote calls 9 | require "http" 10 | 11 | module HTTP 12 | class << self 13 | HTTP::Request::METHODS.each do |h_method| 14 | define_method(h_method) do |*args| 15 | $mock.send(h_method, *args) 16 | end 17 | end 18 | end 19 | 20 | class Client 21 | HTTP::Request::METHODS.each do |h_method| 22 | define_method(h_method) do |*args| 23 | $mock.send(h_method, *args) 24 | end 25 | end 26 | end 27 | end 28 | 29 | module SfnHttpMock 30 | def setup 31 | $mock = Mocha::Mock.new(Mocha::Mockery.instance) 32 | end 33 | 34 | def teardown 35 | $mock = nil 36 | @ui = nil 37 | @stream = nil 38 | if @google_key && File.exist?(@google_key) 39 | File.delete(@google_key) 40 | end 41 | end 42 | 43 | def stream 44 | @stream ||= StringIO.new("") 45 | end 46 | 47 | def ui 48 | @ui ||= Bogo::Ui.new( 49 | :app_name => "TestUi", 50 | :output_to => stream, 51 | :colors => false, 52 | ) 53 | end 54 | 55 | def aws_creds 56 | Smash[ 57 | %w(aws_access_key_id aws_secret_access_key aws_region).map do |key| 58 | [key, key.upcase] 59 | end 60 | ].merge(:provider => :aws) 61 | end 62 | 63 | def azure_creds 64 | Smash[ 65 | %w(azure_tenant_id azure_client_id azure_subscription_id azure_client_secret 66 | azure_region azure_blob_account_name azure_blob_secret_key).map do |key| 67 | [key, key.upcase] 68 | end 69 | ].merge(:provider => :azure) 70 | end 71 | 72 | def google_creds 73 | key_file = Tempfile.new("sfn-test") 74 | key_path = key_file.path 75 | key_file.delete 76 | key_file = File.open(key_path, "w") 77 | key_file.puts OpenSSL::PKey::RSA.new(2048).to_pem 78 | key_file.close 79 | @google_key = key_file.path 80 | Smash[ 81 | %w(google_service_account_email google_auth_scope google_project).map do |key| 82 | [key, key.upcase] 83 | end 84 | ].merge( 85 | :provider => :google, 86 | :google_service_account_private_key => @google_key, 87 | ) 88 | end 89 | 90 | def heat_creds 91 | end 92 | 93 | def rackspace_creds 94 | Smash[ 95 | %w(rackspace_api_key rackspace_username rackspace_region).map do |key| 96 | [key, key.upcase] 97 | end 98 | ].merge(:provider => :rackspace) 99 | end 100 | 101 | def http_response(opts = {}) 102 | opts[:version] ||= "1.1" 103 | opts[:status] ||= 200 104 | opts[:body] ||= "{}" 105 | HTTP::Response.new(opts) 106 | end 107 | end 108 | 109 | class MiniTest::Test 110 | include SfnHttpMock 111 | end 112 | -------------------------------------------------------------------------------- /test/rspecs.rb: -------------------------------------------------------------------------------- 1 | require "sfn" 2 | -------------------------------------------------------------------------------- /test/rspecs/lib/callback/stack_policy_rspec.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../../rspecs" 2 | 3 | RSpec.describe Sfn::Callback::StackPolicy do 4 | let(:ui) { double(:ui) } 5 | let(:config) { double(:config) } 6 | let(:arguments) { double(:arguments) } 7 | let(:api) { double(:api) } 8 | 9 | let(:instance) { subject.new(ui, config, arguments, api) } 10 | 11 | context "with no stack polciies defined" do 12 | before do 13 | end 14 | it "should not error if no policies are defined" do 15 | end 16 | end 17 | 18 | it "should fail" do 19 | pending 20 | expect(true).to be false 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/rspecs/lib/command_module/callbacks_rspec.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../../rspecs" 2 | 3 | RSpec.describe Sfn::CommandModule::Callbacks do 4 | let(:ui) { double(:ui) } 5 | let(:callbacks) { [] } 6 | let(:config) { double(:config) } 7 | let(:arguments) { double(:arguments) } 8 | let(:provider) { double(:provider) } 9 | let(:instance) { klass.new(config, ui, arguments, provider) } 10 | let(:klass) { 11 | Class.new { 12 | include Sfn::CommandModule::Callbacks 13 | attr_reader :config, :ui, :arguments, :provider 14 | 15 | def initialize(c, u, a, p) 16 | @config = c 17 | @ui = u 18 | @arguments = a 19 | @provider = p 20 | end 21 | 22 | def self.name 23 | "Sfn::Callback::Status" 24 | end 25 | } 26 | } 27 | 28 | before do 29 | allow(Sfn::Callback).to receive(:const_get).and_return(klass) 30 | allow(config).to receive(:fetch).with(:callbacks, any_args).and_return(callbacks) 31 | allow(ui).to receive(:debug) 32 | allow(ui).to receive(:info) 33 | allow(config).to receive(:[]) 34 | allow(ui).to receive(:color) 35 | end 36 | 37 | describe "#api_action!" do 38 | before { allow(instance).to receive(:run_callbacks_for) } 39 | 40 | it "should run specific and general before and after callbacks" do 41 | expect(instance).to receive(:run_callbacks_for).with(["before_status", :before], any_args) 42 | expect(instance).to receive(:run_callbacks_for).with(["after_status", :after], any_args) 43 | instance.api_action! 44 | end 45 | 46 | it "should run failed callbacks on error" do 47 | expect(instance).to receive(:run_callbacks_for).with(["before_status", :before], any_args) 48 | expect(instance).to receive(:run_callbacks_for).with(["failed_status", :failed], any_args) 49 | expect { instance.api_action! { raise "error" } }.to raise_error(RuntimeError) 50 | end 51 | 52 | it "should provide exception to callbacks on error" do 53 | expect(instance).to receive(:run_callbacks_for).with(["failed_status", :failed], instance_of(RuntimeError)) 54 | expect { instance.api_action! { raise "error" } }.to raise_error(RuntimeError) 55 | end 56 | end 57 | 58 | describe "#run_callbacks_for" do 59 | let(:callbacks) { ["status"] } 60 | 61 | it "should run the callback" do 62 | expect_any_instance_of(klass).to receive(:before) 63 | instance.run_callbacks_for(:before) 64 | end 65 | end 66 | 67 | describe "#callbacks_for" do 68 | it "should load callbacks defined within configuration" do 69 | expect(config).to receive(:fetch).with(:callbacks, :before, []).and_return([]) 70 | expect(config).to receive(:fetch).with(:callbacks, :default, []).and_return([]) 71 | expect(instance.callbacks_for(:before)).to eq([]) 72 | end 73 | 74 | context "callback name configured" do 75 | let(:callbacks) { ["status"] } 76 | 77 | it "should lookup callbacks within namespace" do 78 | expect(Sfn::Callback).to receive(:const_get).with("Status").and_return(klass) 79 | expect(instance.callbacks_for(:before)).to be_a(Array) 80 | end 81 | 82 | it "should raise error when class not found" do 83 | expect(Sfn::Callback).to receive(:const_get).and_call_original 84 | expect { instance.callbacks_for(:before) }.to raise_error(NameError) 85 | end 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /test/rspecs/lib/command_module/stack_rspec.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../../rspecs" 2 | 3 | RSpec.describe Sfn::CommandModule::Stack::InstanceMethods do 4 | let(:subject) do 5 | Class.tap { |c| c.include(described_class) }.new 6 | end 7 | 8 | describe "#generate_custom_apply_mappings" do 9 | let(:config) { {apply_mapping: mappings} } 10 | let(:mappings) { nil } 11 | let(:provider_stack) { double("provider_stack", api: api, name: stack_name) } 12 | let(:stack_name) { nil } 13 | let(:api) { double("api", data: api_data) } 14 | let(:api_data) { {} } 15 | 16 | before { allow(subject).to receive(:config).and_return(config) } 17 | 18 | it "should return nil by default" do 19 | expect(subject.generate_custom_apply_mappings(provider_stack)). 20 | to be_nil 21 | end 22 | 23 | context "when mappings are set" do 24 | let(:mappings) { 25 | {"test-stack__OriginKey" => "DestKey", 26 | "other-stack__StartKey" => "EndKey"} 27 | } 28 | 29 | it "should return empty hash" do 30 | expect(subject.generate_custom_apply_mappings(provider_stack)). 31 | to eq({}) 32 | end 33 | 34 | context "when stack name is set" do 35 | let(:stack_name) { "test-stack" } 36 | 37 | it "should return hash with single item" do 38 | expect(subject.generate_custom_apply_mappings(provider_stack).size). 39 | to eq(1) 40 | end 41 | 42 | it "should include the origin key" do 43 | expect(subject.generate_custom_apply_mappings(provider_stack).keys). 44 | to include("OriginKey") 45 | end 46 | 47 | it "should include the dest key value" do 48 | expect(subject.generate_custom_apply_mappings(provider_stack).values). 49 | to include("DestKey") 50 | end 51 | end 52 | 53 | context "when mappings are plain" do 54 | let(:mappings) { 55 | {"OriginKey" => "DestKey", 56 | "other-stack__StartKey" => "EndKey"} 57 | } 58 | 59 | it "should return hash with single item" do 60 | expect(subject.generate_custom_apply_mappings(provider_stack).size). 61 | to eq(1) 62 | end 63 | 64 | it "should include the origin key" do 65 | expect(subject.generate_custom_apply_mappings(provider_stack).keys). 66 | to include("OriginKey") 67 | end 68 | 69 | it "should include the dest key value" do 70 | expect(subject.generate_custom_apply_mappings(provider_stack).values). 71 | to include("DestKey") 72 | end 73 | end 74 | 75 | context "when mapping includes remote location" do 76 | let(:mappings) { 77 | {"remote_provider__test-stack__OriginKey" => "DestKey", 78 | "other-stack__StartKey" => "EndKey"} 79 | } 80 | let(:stack_name) { "test-stack" } 81 | 82 | it "should return empty hash" do 83 | expect(subject.generate_custom_apply_mappings(provider_stack)). 84 | to eq({}) 85 | end 86 | 87 | context "with location set in api data" do 88 | let(:api_data) { 89 | {location: "remote_provider"} 90 | } 91 | 92 | it "should return hash with single item" do 93 | expect(subject.generate_custom_apply_mappings(provider_stack).size). 94 | to eq(1) 95 | end 96 | 97 | it "should include the origin key" do 98 | expect(subject.generate_custom_apply_mappings(provider_stack).keys). 99 | to include("OriginKey") 100 | end 101 | 102 | it "should include the dest key value" do 103 | expect(subject.generate_custom_apply_mappings(provider_stack).values). 104 | to include("DestKey") 105 | end 106 | end 107 | 108 | context "when mapping format is invalid" do 109 | let(:mappings) { 110 | {"invalid__provider__stack__OriginKey" => "DestKey"} 111 | } 112 | 113 | it "should raise an error" do 114 | expect { 115 | subject.generate_custom_apply_mappings(provider_stack) 116 | }.to raise_error(ArgumentError) 117 | end 118 | end 119 | end 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /test/rspecs/sfn_rspec.rb: -------------------------------------------------------------------------------- 1 | require_relative "../rspecs" 2 | 3 | RSpec.describe Sfn do 4 | end 5 | -------------------------------------------------------------------------------- /test/specs/command/lint_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../helper" 2 | 3 | describe Sfn::Command::Lint do 4 | let(:creds) { aws_creds } 5 | 6 | before do 7 | rs = Sfn::Lint::RuleSet.build(:resource_check) do 8 | rule :aws_resources_only do 9 | definition "Resources.[*][0][*].Type" do |search| 10 | unless search.nil? 11 | result = search.find_all { |i| !i.start_with?("AWS") } 12 | result.empty? ? true : result 13 | else 14 | true 15 | end 16 | end 17 | 18 | fail_message "All types must be within AWS root namespace" 19 | end 20 | end 21 | Sfn::Lint::RuleSet.register(rs) 22 | end 23 | 24 | it "should successfully run on valid template" do 25 | instance = Sfn::Command::Lint.new( 26 | Smash.new( 27 | :ui => ui, 28 | :base_directory => File.join(File.dirname(__FILE__), "sparkleformation"), 29 | :credentials => aws_creds, 30 | :file => "lint_valid", 31 | ), 32 | [] 33 | ) 34 | instance.execute! 35 | stream.rewind 36 | stream.read.must_include "VALID" 37 | end 38 | 39 | it "should fail on invalid template" do 40 | instance = Sfn::Command::Lint.new( 41 | Smash.new( 42 | :ui => ui, 43 | :base_directory => File.join(File.dirname(__FILE__), "sparkleformation"), 44 | :credentials => aws_creds, 45 | :file => "lint_invalid", 46 | ), 47 | [] 48 | ) 49 | -> { instance.execute! }.must_raise RuntimeError 50 | stream.rewind 51 | stream.read.must_include "INVALID" 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/specs/command/print_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../helper" 2 | 3 | describe Sfn::Command::Print do 4 | it "should print the template only" do 5 | instance = Sfn::Command::Print.new( 6 | Smash.new( 7 | :ui => ui, 8 | :base_directory => File.join(File.dirname(__FILE__), "sparkleformation"), 9 | :credentials => aws_creds, 10 | :file => "lint_valid", 11 | ), 12 | [] 13 | ) 14 | instance.execute! 15 | stream.rewind 16 | stream.read.start_with?("{").must_equal true 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/specs/command/sparkleformation/dummy.rb: -------------------------------------------------------------------------------- 1 | SparkleFormation.new(:dummy) do 2 | value true 3 | end 4 | -------------------------------------------------------------------------------- /test/specs/command/sparkleformation/dummy_azure.rb: -------------------------------------------------------------------------------- 1 | SparkleFormation.new(:dummy_azure, :provider => :azure) do 2 | value true 3 | end 4 | -------------------------------------------------------------------------------- /test/specs/command/sparkleformation/dummy_google.rb: -------------------------------------------------------------------------------- 1 | SparkleFormation.new(:dummy_google, :provider => :google) do 2 | value true 3 | end 4 | -------------------------------------------------------------------------------- /test/specs/command/sparkleformation/lint_invalid.rb: -------------------------------------------------------------------------------- 1 | SparkleFormation.new(:lint_invalid) do 2 | dynamic!(:s3_bucket, :test) 3 | resources.bad_resource.type "Invalid::Resource" 4 | end 5 | -------------------------------------------------------------------------------- /test/specs/command/sparkleformation/lint_valid.rb: -------------------------------------------------------------------------------- 1 | SparkleFormation.new(:lint_valid) do 2 | dynamic!(:s3_bucket, :test) 3 | end 4 | -------------------------------------------------------------------------------- /test/specs/command/sparkleformation/nested_dummy.rb: -------------------------------------------------------------------------------- 1 | SparkleFormation.new(:nested_dummy) do 2 | nest!(:dummy) 3 | end 4 | -------------------------------------------------------------------------------- /test/specs/command/sparkleformation/nested_dummy_azure.rb: -------------------------------------------------------------------------------- 1 | SparkleFormation.new(:nested_dummy_azure, :provider => :azure) do 2 | nest!(:dummy_azure) 3 | end 4 | -------------------------------------------------------------------------------- /test/specs/command/sparkleformation/nested_dummy_google.rb: -------------------------------------------------------------------------------- 1 | SparkleFormation.new(:nested_dummy_google, :provider => :google) do 2 | nest!(:dummy_google) 3 | end 4 | -------------------------------------------------------------------------------- /test/specs/command_module/stack_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../helper" 2 | 3 | describe Sfn::CommandModule::Stack do 4 | before do 5 | @stack = Class.new do 6 | def initialize 7 | @config = Smash.new 8 | @ui = Class.new do 9 | def debug(*_) 10 | end 11 | end.new 12 | end 13 | 14 | attr_reader :config, :ui 15 | end 16 | @stack.include Sfn::CommandModule::Stack 17 | end 18 | 19 | let(:instance) { @instance ||= @stack.new } 20 | 21 | describe "Parameter population helpers" do 22 | describe "Config parameters location" do 23 | before do 24 | instance.config.set(:parameters, :nested__StackItem__MyParameter, "value") 25 | instance.config.set(:parameters, :NotNestedParameter, "value") 26 | instance.config.set(:parameters, :snake_cased_parameter, "value") 27 | end 28 | 29 | it "should match camel cased parameter" do 30 | instance.locate_config_parameter_key([], "NotNestedParameter", "root").must_equal "NotNestedParameter" 31 | instance.config.get(:parameters, "NotNestedParameter").must_equal "value" 32 | end 33 | 34 | it "should match snake cased parameter when camel cased" do 35 | instance.locate_config_parameter_key([], "not_nested_parameter", "root").must_equal "not_nested_parameter" 36 | instance.config.get(:parameters, "NotNestedParameter").must_be_nil 37 | instance.config.get(:parameters, :not_nested_parameter).must_equal "value" 38 | end 39 | 40 | it "should match snake cased parameter" do 41 | instance.locate_config_parameter_key([], "snake_cased_parameter", "root").must_equal "snake_cased_parameter" 42 | instance.config.get(:parameters, :snake_cased_parameter).must_equal "value" 43 | end 44 | 45 | it "should match camel cased parameter when snake cased" do 46 | instance.locate_config_parameter_key([], "SnakeCasedParameter", "root").must_equal "SnakeCasedParameter" 47 | instance.config.get(:parameters, :snake_cased_parameter).must_be_nil 48 | instance.config.get(:parameters, "SnakeCasedParameter").must_equal "value" 49 | end 50 | 51 | it "should match camel cased nested stack parameter" do 52 | instance.locate_config_parameter_key(["nested", "StackItem"], "MyParameter", "root").must_equal "nested__StackItem__MyParameter" 53 | instance.config.get(:parameters, "nested__StackItem__MyParameter").must_equal "value" 54 | end 55 | 56 | it "should match snake cased nested stack parameter when camel cased" do 57 | instance.locate_config_parameter_key(["nested", "stack_item"], "my_parameter", "root").must_equal "nested__stack_item__my_parameter" 58 | instance.config.get(:parameters, "nested__StackItem__MyParameter").must_be_nil 59 | instance.config.get(:parameters, "nested__stack_item__my_parameter").must_equal "value" 60 | end 61 | 62 | it "should return composite key via arg values when not found" do 63 | instance.locate_config_parameter_key(["nested", "StackItem"], "Unknown", "root").must_equal "nested__StackItem__Unknown" 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /test/specs/command_module/template_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../helper" 2 | 3 | describe Sfn::CommandModule::Template do 4 | before do 5 | @template = Class.new do 6 | def initialize 7 | @config = Smash.new 8 | @arguments = [] 9 | @ui = AttributeStruct.new 10 | end 11 | 12 | attr_reader :config, :arguments, :ui 13 | end 14 | @template.include Sfn::CommandModule::Template 15 | end 16 | 17 | let(:instance) { @instance ||= @template.new } 18 | 19 | describe "Compile time parameter merging" do 20 | it "should automatically merge items when stack name is not used" do 21 | instance.arguments << "stack-name" 22 | instance.config.set(:compile_parameters, "stack-name__Fubar", "key1", "value") 23 | instance.config.set(:compile_parameters, "Fubar", "key2", "value2") 24 | result = instance.merge_compile_time_parameters 25 | result.get("stack-name__Fubar", "key1").must_equal "value" 26 | result.get("stack-name__Fubar", "key2").must_equal "value2" 27 | result.get("Fubar").must_be_nil 28 | end 29 | 30 | it "should automatically prefix stack name when not provided" do 31 | instance.arguments << "stack-name" 32 | instance.config.set(:compile_parameters, "Fubar", "key2", "value2") 33 | result = instance.merge_compile_time_parameters 34 | result.get("stack-name__Fubar", "key2").must_equal "value2" 35 | result.get("Fubar").must_be_nil 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/specs/config/fail-auto-json/.sfn: -------------------------------------------------------------------------------- 1 | { 2 | "processing": [} 3 | } -------------------------------------------------------------------------------- /test/specs/config/fail-auto-ruby/.sfn: -------------------------------------------------------------------------------- 1 | Configuration.new do 2 | processing [} 3 | end -------------------------------------------------------------------------------- /test/specs/config/fail-auto-yaml/.sfn: -------------------------------------------------------------------------------- 1 | --- 2 | :processing: [} -------------------------------------------------------------------------------- /test/specs/config/json-ext/.sfn.json: -------------------------------------------------------------------------------- 1 | { 2 | "processing": false 3 | } 4 | -------------------------------------------------------------------------------- /test/specs/config/no-ext/.sfn: -------------------------------------------------------------------------------- 1 | Configuration.new do 2 | processing false 3 | end -------------------------------------------------------------------------------- /test/specs/config/ruby-ext/.sfn.rb: -------------------------------------------------------------------------------- 1 | Configuration.new do 2 | processing false 3 | end 4 | -------------------------------------------------------------------------------- /test/specs/config/yaml-ext/.sfn: -------------------------------------------------------------------------------- 1 | --- 2 | :processing: false -------------------------------------------------------------------------------- /test/specs/config_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative "../helper" 2 | 3 | describe Sfn::Config do 4 | describe "Core configuration" do 5 | it "should properly coerce string value to hash" do 6 | config = Sfn::Config.new(:credentials => "key1:value1,key2:value2") 7 | config[:credentials].must_equal "key1" => "value1", "key2" => "value2" 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/specs/sfn_bin_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative "../helper" 2 | 3 | describe "sfn" do 4 | def be_sh(command, options = {}) 5 | result = `#{command} 2>&1` 6 | unless $?.success? 7 | unless options[:fail] == true || options[:fail] == $?.exitstatus 8 | raise "Command Failed `#{command}` - #{result}" 9 | end 10 | end 11 | result 12 | end 13 | 14 | it "shows help without arguments" do 15 | be_sh("sfn", :fail => true).must_include "Available commands:" 16 | end 17 | 18 | it "shows help when asking for help" do 19 | be_sh("sfn --help").must_include "Available commands:" 20 | end 21 | 22 | it "shows version" do 23 | be_sh("sfn --version").must_include "SparkleFormation CLI - [Version:" 24 | end 25 | 26 | it "errors on unknown flags" do 27 | -> { be_sh("sfn create --fubar") }.must_raise StandardError 28 | be_sh("sfn create --fubar", :fail => true).must_include "--fubar" 29 | end 30 | 31 | it "should include stack trace when debug flag provided" do 32 | be_sh("sfn create --fubar --debug", :fail => true).must_include "in `validate_arguments!" 33 | end 34 | 35 | describe "configuration file" do 36 | let(:config_dir) { File.join(File.dirname(__FILE__), "config") } 37 | 38 | it "should load configuration file with no extension" do 39 | result = Dir.chdir(File.join(config_dir, "no-ext")) do 40 | be_sh("sfn conf") 41 | end 42 | result.must_match /processing.*?:.*?false/ 43 | end 44 | 45 | it "should load Ruby configuration file with extension" do 46 | result = Dir.chdir(File.join(config_dir, "ruby-ext")) do 47 | be_sh("sfn conf") 48 | end 49 | result.must_match /processing.*?:.*?false/ 50 | end 51 | 52 | it "should load YAML configuration file with extension" do 53 | result = Dir.chdir(File.join(config_dir, "yaml-ext")) do 54 | be_sh("sfn conf") 55 | end 56 | result.must_match /processing.*?:.*?false/ 57 | end 58 | 59 | it "should load JSON configuration file with extension" do 60 | result = Dir.chdir(File.join(config_dir, "json-ext")) do 61 | be_sh("sfn conf") 62 | end 63 | result.must_match /processing.*?:.*?false/ 64 | end 65 | 66 | it "should display JSON specific load error when JSON load fails" do 67 | result = Dir.chdir(File.join(config_dir, "fail-auto-json")) do 68 | be_sh("sfn conf", :fail => true) 69 | end 70 | result.must_include "unexpected token" 71 | end 72 | 73 | it "should display Ruby specific load error when Ruby load fails" do 74 | result = Dir.chdir(File.join(config_dir, "fail-auto-ruby")) do 75 | be_sh("sfn conf", :fail => true) 76 | end 77 | result.must_include "syntax error" 78 | end 79 | 80 | it "should display YAML specific load error when YAML load fails" do 81 | result = Dir.chdir(File.join(config_dir, "fail-auto-yaml")) do 82 | be_sh("sfn conf", :fail => true) 83 | end 84 | result.must_include "did not find expected node" 85 | end 86 | 87 | it "should display stacktrace in debug mode on load error" do 88 | result = Dir.chdir(File.join(config_dir, "fail-auto-yaml")) do 89 | be_sh("sfn conf --debug", :fail => true) 90 | end 91 | result.must_include "Stacktrace" 92 | result.must_include "SyntaxError" 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /test/specs/utils/stack_parameter_validator_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../helper" 2 | 3 | describe Sfn::Utils::StackParameterValidator do 4 | let(:validator) do 5 | klass = Class.new 6 | klass.include Sfn::Utils::StackParameterValidator 7 | klass.new 8 | end 9 | 10 | it "should detect list types" do 11 | validator.list_type?("CommaDelimitedString").must_equal true 12 | validator.list_type?("comma_delimited_string").must_equal true 13 | validator.list_type?("List").must_equal true 14 | validator.list_type?("List").must_equal true 15 | end 16 | 17 | it "should not detect non-list types" do 18 | validator.list_type?("Number").must_equal false 19 | validator.list_type?("json").must_equal false 20 | validator.list_type?("AWS::EC2::Image::Id").must_equal false 21 | end 22 | 23 | it "should reject value under min value" do 24 | validator.min_value(2, 3).wont_equal true 25 | end 26 | 27 | it "should accept value above min value" do 28 | validator.min_value(4, 3).must_equal true 29 | end 30 | 31 | it "should accept value equal to min value" do 32 | validator.min_value(3, 3).must_equal true 33 | end 34 | 35 | it "should reject value over max value" do 36 | validator.max_value(4, 3).wont_equal true 37 | end 38 | 39 | it "should accept value below max value" do 40 | validator.max_value(3, 4).must_equal true 41 | end 42 | 43 | it "should accept value equal to max value" do 44 | validator.max_value(3, 3).must_equal true 45 | end 46 | 47 | it "should reject value under min length" do 48 | validator.min_length("fubar", 6).wont_equal true 49 | end 50 | 51 | it "should accept value over min length" do 52 | validator.min_length("fubar", 4).must_equal true 53 | end 54 | 55 | it "should accept value equal to min length" do 56 | validator.min_length("fubar", 5).must_equal true 57 | end 58 | 59 | it "should reject value over max length" do 60 | validator.max_length("fubar", 4).wont_equal true 61 | end 62 | 63 | it "should accept value under max length" do 64 | validator.max_length("fubar", 6).must_equal true 65 | end 66 | 67 | it "should accept value equal to max length" do 68 | validator.max_length("fubar", 5).must_equal true 69 | end 70 | 71 | it "should reject value not matching pattern" do 72 | validator.allowed_pattern("invalid-ami-9999", '^ami-\d+$').wont_equal true 73 | end 74 | 75 | it "should accept value matching pattern" do 76 | validator.allowed_pattern("ami-9999", '^ami-\d+$').must_equal true 77 | end 78 | 79 | it "should reject value not defined within allowed values" do 80 | validator.allowed_values("ack", ["valid1", "valid2"]).wont_equal true 81 | end 82 | 83 | it "should accept value defined within allowed values" do 84 | validator.allowed_values("ack", ["valid1", "ack", "valid2"]).must_equal true 85 | end 86 | 87 | describe "Definition validation" do 88 | it "should reject value as too long" do 89 | result = validator.validate_parameter("fubar", 90 | "MaxLength" => 4) 91 | result.wont_equal true 92 | result.size.must_equal 1 93 | result = result.first 94 | result.first.must_equal "max_length" 95 | result.last.must_be_kind_of String 96 | end 97 | 98 | it "should process multiple values when list" do 99 | result = validator.validate_parameter("fubar,ack", 100 | "Type" => "CommaDelimitedList", 101 | "MaxLength" => 4) 102 | result.wont_equal true 103 | result.size.must_equal 1 104 | result = result.first 105 | result.first.must_equal "max_length" 106 | result.last.must_be_kind_of String 107 | end 108 | 109 | it "should process multiple values when list and reject all" do 110 | results = validator.validate_parameter("fubar,ack", 111 | "Type" => "CommaDelimitedList", 112 | "MaxLength" => 2) 113 | results.wont_equal true 114 | results.size.must_equal 2 115 | results.each do |result| 116 | result.first.must_equal "max_length" 117 | result.last.must_be_kind_of String 118 | end 119 | end 120 | 121 | it "should process CFN List type" do 122 | results = validator.validate_parameter("fubar,ack", 123 | "Type" => "List", 124 | "MaxLength" => 2) 125 | results.wont_equal true 126 | results.size.must_equal 2 127 | results.each do |result| 128 | result.first.must_equal "max_length" 129 | result.last.must_be_kind_of String 130 | end 131 | end 132 | 133 | it "should process HOT list type" do 134 | results = validator.validate_parameter("fubar,ack", 135 | "type" => "comma_delimited_string", 136 | "max_length" => 2) 137 | results.wont_equal true 138 | results.size.must_equal 2 139 | results.each do |result| 140 | result.first.must_equal "max_length" 141 | result.last.must_be_kind_of String 142 | end 143 | end 144 | end 145 | end 146 | --------------------------------------------------------------------------------