├── .github ├── dependabot.yml └── workflows │ └── test.yml ├── .gitignore ├── .rspec ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── apply_demo.gif ├── bin └── stack_master ├── example └── simple │ ├── Gemfile │ ├── parameters │ ├── myapp_vpc.yml │ └── myapp_web.yml │ ├── stack_master.yml │ └── templates │ ├── myapp_vpc.rb │ └── myapp_web.rb ├── features ├── apply.feature ├── apply_with_allowed_accounts.feature ├── apply_with_assume_role_parameter_resolvers.feature ├── apply_with_compile_time_parameters.feature ├── apply_with_dash_in_filenames.feature ├── apply_with_env_parameters.feature ├── apply_with_explicit_parameter_files.feature ├── apply_with_parameter_store_parameters.feature ├── apply_with_s3.feature ├── apply_with_sparkle_pack_template.feature ├── apply_with_stack_definition_parameters.feature ├── apply_without_parameter_file.feature ├── compile_with_cfndsl.feature ├── compile_with_sparkle_formation.feature ├── delete.feature ├── diff.feature ├── events.feature ├── init.feature ├── outputs.feature ├── region_aliases.feature ├── resources.feature ├── stack_defaults.feature ├── status.feature ├── step_definitions │ ├── asume_role_steps.rb │ ├── identity_steps.rb │ ├── parameter_store_steps.rb │ └── stack_steps.rb ├── support │ └── env.rb ├── tidy.feature ├── validate.feature ├── validate_with_missing_parameters.feature └── version.feature ├── lib ├── stack_master.rb └── stack_master │ ├── aws_driver │ ├── cloud_formation.rb │ └── s3.rb │ ├── change_set.rb │ ├── cli.rb │ ├── cloudformation_interpolating_eruby.rb │ ├── cloudformation_template_eruby.rb │ ├── command.rb │ ├── commands │ ├── apply.rb │ ├── compile.rb │ ├── delete.rb │ ├── diff.rb │ ├── drift.rb │ ├── events.rb │ ├── init.rb │ ├── lint.rb │ ├── list_stacks.rb │ ├── nag.rb │ ├── outputs.rb │ ├── resources.rb │ ├── status.rb │ ├── terminal_helper.rb │ ├── tidy.rb │ └── validate.rb │ ├── config.rb │ ├── ctrl_c.rb │ ├── diff.rb │ ├── identity.rb │ ├── paged_response_accumulator.rb │ ├── parameter_loader.rb │ ├── parameter_resolver.rb │ ├── parameter_resolvers │ ├── acm_certificate.rb │ ├── ami_finder.rb │ ├── ejson.rb │ ├── env.rb │ ├── latest_ami.rb │ ├── latest_ami_by_tags.rb │ ├── latest_container.rb │ ├── one_password.rb │ ├── parameter_store.rb │ ├── security_group.rb │ ├── sns_topic_name.rb │ └── stack_output.rb │ ├── parameter_validator.rb │ ├── prompter.rb │ ├── resolver_array.rb │ ├── role_assumer.rb │ ├── security_group_finder.rb │ ├── sns_topic_finder.rb │ ├── sparkle_formation │ ├── compile_time │ │ ├── allowed_pattern_validator.rb │ │ ├── allowed_values_validator.rb │ │ ├── definitions_validator.rb │ │ ├── empty_validator.rb │ │ ├── max_length_validator.rb │ │ ├── max_size_validator.rb │ │ ├── min_length_validator.rb │ │ ├── min_size_validator.rb │ │ ├── number_validator.rb │ │ ├── parameters_validator.rb │ │ ├── state_builder.rb │ │ ├── string_validator.rb │ │ ├── value_builder.rb │ │ ├── value_validator.rb │ │ └── value_validator_factory.rb │ └── template_file.rb │ ├── stack.rb │ ├── stack_definition.rb │ ├── stack_differ.rb │ ├── stack_events │ ├── fetcher.rb │ ├── presenter.rb │ └── streamer.rb │ ├── stack_states.rb │ ├── stack_status.rb │ ├── template_compiler.rb │ ├── template_compilers │ ├── cfndsl.rb │ ├── json.rb │ ├── sparkle_formation.rb │ ├── yaml.rb │ └── yaml_erb.rb │ ├── template_utils.rb │ ├── test_driver │ ├── cloud_formation.rb │ └── s3.rb │ ├── testing.rb │ ├── utils.rb │ ├── validator.rb │ └── version.rb ├── logo.png ├── spec ├── fixtures │ ├── parameters │ │ ├── myapp_vpc.yml │ │ └── myapp_vpc_with_secrets.yml │ ├── sparkle_pack_integration │ │ └── my_sparkle_pack │ │ │ └── lib │ │ │ ├── my_sparkle_pack.rb │ │ │ └── sparkleformation │ │ │ ├── dynamics │ │ │ └── my_dynamic.rb │ │ │ └── templates │ │ │ ├── dynamics │ │ │ └── local_dynamic.rb │ │ │ ├── template_with_dynamic.rb │ │ │ └── template_with_dynamic_from_pack.rb │ ├── stack_master.yml │ ├── stack_master_empty_default.yml │ ├── stack_master_wrong_indent.yml │ ├── templates │ │ ├── erb │ │ │ ├── compile_time_parameters_loop.yml.erb │ │ │ ├── user_data.sh.erb │ │ │ └── user_data.yml.erb │ │ ├── json │ │ │ └── valid_myapp_vpc.json │ │ ├── myapp_vpc.json │ │ ├── mystack-with-parameters.yaml │ │ ├── rb │ │ │ ├── cfndsl │ │ │ │ ├── sample-ctp-repeated.rb │ │ │ │ ├── sample-ctp.json │ │ │ │ ├── sample-ctp.rb │ │ │ │ ├── sample.json │ │ │ │ └── sample.rb │ │ │ └── sparkle_formation │ │ │ │ └── templates │ │ │ │ └── template.rb │ │ └── yml │ │ │ └── valid_myapp_vpc.yml │ └── test │ │ └── .gitkeep ├── integration │ └── drift_spec.rb ├── spec_helper.rb ├── stack_master │ ├── aws_driver │ │ └── s3_spec.rb │ ├── change_set_spec.rb │ ├── cloudformation_interpolating_eruby_spec.rb │ ├── cloudformation_template_eruby_spec.rb │ ├── command_spec.rb │ ├── commands │ │ ├── apply_spec.rb │ │ ├── compile_spec.rb │ │ ├── delete_spec.rb │ │ ├── drift_spec.rb │ │ ├── init_spec.rb │ │ ├── lint_spec.rb │ │ ├── nag_spec.rb │ │ ├── outputs_spec.rb │ │ ├── resources_spec.rb │ │ ├── status_spec.rb │ │ └── validate_spec.rb │ ├── config_spec.rb │ ├── identity_spec.rb │ ├── paged_response_accumulator_spec.rb │ ├── parameter_loader_spec.rb │ ├── parameter_resolver_spec.rb │ ├── parameter_resolvers │ │ ├── acm_certificate_spec.rb │ │ ├── ami_finder_spec.rb │ │ ├── ejson_spec.rb │ │ ├── env_spec.rb │ │ ├── latest_ami_by_tags_spec.rb │ │ ├── latest_ami_spec.rb │ │ ├── latest_container_spec.rb │ │ ├── one_password_spec.rb │ │ ├── parameter_store_spec.rb │ │ ├── security_group_spec.rb │ │ ├── security_groups_spec.rb │ │ ├── sns_topic_name_spec.rb │ │ └── stack_output_spec.rb │ ├── parameter_validator_spec.rb │ ├── prompter_spec.rb │ ├── resolver_array_spec.rb │ ├── role_assumer_spec.rb │ ├── security_group_finder_spec.rb │ ├── sns_topic_finder_spec.rb │ ├── sparkle_formation │ │ ├── compile_time │ │ │ ├── allowed_pattern_validator_spec.rb │ │ │ ├── allowed_values_validator_spec.rb │ │ │ ├── definitions_validator_spec.rb │ │ │ ├── empty_validator_spec.rb │ │ │ ├── max_length_validator_spec.rb │ │ │ ├── max_size_validator_spec.rb │ │ │ ├── min_length_validator_spec.rb │ │ │ ├── min_size_validator_spec.rb │ │ │ ├── number_validator_spec.rb │ │ │ ├── parameters_validator_spec.rb │ │ │ ├── state_builder_spec.rb │ │ │ ├── string_validator_spec.rb │ │ │ ├── value_build_spec.rb │ │ │ └── value_validator_factory_spec.rb │ │ └── template_file_spec.rb │ ├── stack_definition_spec.rb │ ├── stack_differ_spec.rb │ ├── stack_events │ │ ├── fetcher_spec.rb │ │ ├── presenter_spec.rb │ │ └── streamer_spec.rb │ ├── stack_spec.rb │ ├── template_compiler_spec.rb │ ├── template_compilers │ │ ├── cfndsl_spec.rb │ │ ├── json_spec.rb │ │ ├── sparkle_formation_spec.rb │ │ ├── yaml_erb_spec.rb │ │ └── yaml_spec.rb │ ├── template_utils_spec.rb │ ├── test_driver │ │ ├── cloud_formation_spec.rb │ │ └── s3_spec.rb │ ├── utils_spec.rb │ └── validator_spec.rb ├── stack_master_spec.rb └── support │ ├── aruba.rb │ ├── aws_stubs.rb │ └── validator_spec.rb ├── stack_master.gemspec └── stacktemplates ├── parameter_region.yml ├── parameter_stack_name.yml ├── stack.json.erb └── stack_master.yml.erb /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: bundler 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | day: monday 8 | time: "08:00" 9 | timezone: Australia/Melbourne 10 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: tests 3 | on: [ push, pull_request ] 4 | jobs: 5 | test: 6 | name: Test (Ruby ${{ matrix.ruby }}, ${{ matrix.os }}) 7 | runs-on: ${{ matrix.os }}-latest 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | os: [ ubuntu ] 12 | ruby: [ '2.4', '2.5', '2.6', '2.7', '3.0', '3.1', '3.2', '3.3', '3.4' ] 13 | include: 14 | - os: macos 15 | ruby: '2.7' 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Set up Ruby ${{ matrix.ruby }} 19 | uses: ruby/setup-ruby@v1 20 | with: 21 | ruby-version: ${{ matrix.ruby }} 22 | bundler-cache: true 23 | - name: RSpec 24 | run: bundle exec rake spec 25 | env: 26 | CLICOLOR_FORCE: 1 27 | - name: Cucumber 28 | run: bundle exec rake features 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | *.swp 7 | Gemfile.lock 8 | InstalledFiles 9 | _yardoc 10 | coverage 11 | doc/ 12 | lib/bundler/man 13 | pkg 14 | rdoc 15 | spec/reports 16 | test/tmp 17 | test/version_tmp 18 | tmp 19 | /spec/examples.txt 20 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in stack_master.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Steve Hodgkiss 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require 'bundler/setup' 3 | 4 | task :environment do 5 | require 'stack_master' 6 | end 7 | 8 | task :console => :environment do 9 | require 'pry' 10 | binding.pry 11 | end 12 | 13 | # Add specs and features tests 14 | begin 15 | require 'cucumber/rake/task' 16 | Cucumber::Rake::Task.new(:features) do |t| 17 | t.cucumber_opts = "features --format pretty" 18 | end 19 | 20 | require 'rspec/core/rake_task' 21 | RSpec::Core::RakeTask.new(:spec) do |t| 22 | t.rspec_opts = "--format doc" 23 | end 24 | rescue LoadError 25 | end 26 | 27 | task :default => [:features, :spec] 28 | -------------------------------------------------------------------------------- /apply_demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/envato/stack_master/c4f4379758e875a6cbf413cd103633eced39c4c3/apply_demo.gif -------------------------------------------------------------------------------- /bin/stack_master: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'stack_master' 4 | 5 | if ENV['STUB_AWS'] == 'true' 6 | require 'stack_master/testing' 7 | end 8 | 9 | trap("SIGINT") { raise StackMaster::CtrlC } 10 | 11 | begin 12 | StackMaster::CLI.new(ARGV.dup).execute! 13 | rescue StackMaster::CtrlC 14 | StackMaster.stdout.puts "Exiting..." 15 | end 16 | -------------------------------------------------------------------------------- /example/simple/Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'stack_master', path: '../../' 4 | -------------------------------------------------------------------------------- /example/simple/parameters/myapp_vpc.yml: -------------------------------------------------------------------------------- 1 | vpc_az_1: ap-southeast-2a 2 | -------------------------------------------------------------------------------- /example/simple/parameters/myapp_web.yml: -------------------------------------------------------------------------------- 1 | VpcId: 2 | stack_output: myapp-vpc/VpcId 3 | -------------------------------------------------------------------------------- /example/simple/stack_master.yml: -------------------------------------------------------------------------------- 1 | stack_defaults: 2 | tags: 3 | application: my-awesome-app 4 | region_defaults: 5 | ap_southeast_2: 6 | tags: 7 | environment: production 8 | stacks: 9 | ap_southeast_2: 10 | myapp_vpc: 11 | template: myapp_vpc.rb 12 | myapp_web: 13 | template: myapp_web.rb -------------------------------------------------------------------------------- /example/simple/templates/myapp_vpc.rb: -------------------------------------------------------------------------------- 1 | SparkleFormation.new(:myapp_vpc) do 2 | description "A test VPC template" 3 | 4 | resources.vpc do 5 | type 'AWS::EC2::VPC' 6 | properties do 7 | cidr_block '10.200.0.0/16' 8 | end 9 | end 10 | 11 | parameters.vpc_az_1 do 12 | description 'VPC AZ 1' 13 | type 'AWS::EC2::AvailabilityZone::Name' 14 | end 15 | 16 | resources.public_subnet do 17 | type 'AWS::EC2::Subnet' 18 | properties do 19 | vpc_id ref!(:vpc) 20 | cidr_block '10.200.1.0/24' 21 | availability_zone ref!(:vpc_az_1) 22 | tags _array( 23 | { Key: 'Name', Value: 'PublicSubnet' }, 24 | { Key: 'network', Value: 'public' } 25 | ) 26 | end 27 | end 28 | 29 | outputs do 30 | vpc_id do 31 | description 'VPC ID' 32 | value ref!(:vpc) 33 | end 34 | public_subnet do 35 | description 'Public subnet' 36 | value ref!(:public_subnet) 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /example/simple/templates/myapp_web.rb: -------------------------------------------------------------------------------- 1 | SparkleFormation.new(:myapp_web) do 2 | description "Test web template" 3 | 4 | parameters.vpc_id do 5 | description 'VPC ID' 6 | type 'String' 7 | end 8 | 9 | resources.web_sg do 10 | type 'AWS::EC2::SecurityGroup' 11 | properties do 12 | group_description 'Security group for web instances' 13 | vpc_id ref!(:vpc_id) 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /features/apply_with_assume_role_parameter_resolvers.feature: -------------------------------------------------------------------------------- 1 | Feature: Apply command with assume role parameter resolvers 2 | 3 | Background: 4 | Given a file named "stack_master.yml" with: 5 | """ 6 | stacks: 7 | us-east-2: 8 | vpc: 9 | template: vpc.rb 10 | myapp_web: 11 | template: myapp_web.rb 12 | """ 13 | And a directory named "parameters" 14 | And a file named "parameters/myapp_web.yml" with: 15 | """ 16 | vpc_id: 17 | role: my-role 18 | account: 1234567890 19 | stack_output: vpc/vpc_id 20 | """ 21 | And a directory named "templates" 22 | And a file named "templates/myapp_web.rb" with: 23 | """ 24 | SparkleFormation.new(:myapp_web) do 25 | description "Test template" 26 | set!('AWSTemplateFormatVersion', '2010-09-09') 27 | 28 | parameters.vpc_id do 29 | description 'VPC ID' 30 | type 'AWS::EC2::VPC::Id' 31 | end 32 | 33 | resources.test_sg do 34 | type 'AWS::EC2::SecurityGroup' 35 | properties do 36 | group_description 'Test SG' 37 | vpc_id ref!(:vpc_id) 38 | end 39 | end 40 | end 41 | """ 42 | 43 | Scenario: Run apply and create a new stack 44 | Given I stub the CloudFormation driver 45 | Given I stub the following stack events: 46 | | stack_id | event_id | stack_name | logical_resource_id | resource_status | resource_type | timestamp | 47 | | 1 | 1 | myapp-web | myapp-web | CREATE_COMPLETE | AWS::CloudFormation::Stack | 2020-10-29 00:00:00 | 48 | And I stub the following stacks: 49 | | stack_id | stack_name | parameters | outputs | region | 50 | | 1 | vpc | VpcCidr=10.0.0.16/22 | VpcId=vpc-id | us-east-2 | 51 | | 2 | myapp_web | | | us-east-2 | 52 | Then I expect the role "my-role" is assumed in account "1234567890" 53 | When I run `stack_master apply us-east-2 myapp_web --trace` 54 | And the output should contain all of these lines: 55 | | +--- | 56 | | +VpcId: vpc-id | 57 | Then the exit status should be 0 58 | -------------------------------------------------------------------------------- /features/apply_with_dash_in_filenames.feature: -------------------------------------------------------------------------------- 1 | Feature: Apply command 2 | 3 | Background: 4 | Given a file named "stack_master.yml" with: 5 | """ 6 | stacks: 7 | us_east_1: 8 | myapp-web: 9 | template: myapp-web.rb 10 | """ 11 | And a directory named "parameters" 12 | And a file named "parameters/myapp-web.yml" with: 13 | """ 14 | VpcId: vpc-id-in-properties 15 | """ 16 | And a directory named "templates" 17 | And a file named "templates/myapp-web.rb" with: 18 | """ 19 | SparkleFormation.new(:myapp_web) do 20 | description "Test template" 21 | parameters.vpc_id.type 'AWS::EC2::VPC::Id' 22 | resources.test_sg do 23 | type 'AWS::EC2::SecurityGroup' 24 | properties do 25 | group_description 'Test SG' 26 | vpc_id ref!(:vpc_id) 27 | end 28 | end 29 | end 30 | """ 31 | 32 | Scenario: Run apply with dash in filenames 33 | Given I stub the following stack events: 34 | | stack_id | event_id | stack_name | logical_resource_id | resource_status | resource_type | timestamp | 35 | | 1 | 1 | myapp-web | TestSg | CREATE_COMPLETE | AWS::EC2::SecurityGroup | 2020-10-29 00:00:00 | 36 | | 1 | 1 | myapp-web | myapp-web | CREATE_COMPLETE | AWS::CloudFormation::Stack | 2020-10-29 00:00:00 | 37 | When I run `stack_master apply us-east-1 myapp-web --trace` 38 | And the output should contain all of these lines: 39 | | Stack diff: | 40 | | + "TestSg": { | 41 | | Parameters diff: | 42 | | +VpcId: vpc-id-in-properties | 43 | Then the exit status should be 0 44 | -------------------------------------------------------------------------------- /features/apply_with_env_parameters.feature: -------------------------------------------------------------------------------- 1 | Feature: Apply command with environment parameter 2 | 3 | Background: 4 | Given a file named "stack_master.yml" with: 5 | """ 6 | stacks: 7 | us-east-2: 8 | vpc: 9 | template: vpc.rb 10 | """ 11 | And a directory named "parameters" 12 | And a file named "parameters/vpc.yml" with: 13 | """ 14 | vpc_cidr: 15 | env: VPC_CIDR 16 | """ 17 | And a directory named "templates" 18 | And a file named "templates/vpc.rb" with: 19 | """ 20 | SparkleFormation.new(:vpc) do 21 | 22 | parameters.vpc_cidr do 23 | type 'String' 24 | end 25 | 26 | resources.vpc do 27 | type 'AWS::EC2::VPC' 28 | properties do 29 | cidr_block ref!(:vpc_cidr) 30 | end 31 | end 32 | 33 | end 34 | """ 35 | 36 | Scenario: Run apply and create a new stack 37 | Given I stub the following stack events: 38 | | stack_id | event_id | stack_name | logical_resource_id | resource_status | resource_type | timestamp | 39 | | 1 | 1 | vpc | Vpc | CREATE_COMPLETE | AWS::EC2::VPC | 2020-10-29 00:00:00 | 40 | | 1 | 1 | vpc | vpc | CREATE_COMPLETE | AWS::CloudFormation::Stack | 2020-10-29 00:00:00 | 41 | And I set the environment variables to: 42 | | variable | value | 43 | | VPC_CIDR | 10.0.0.0/16 | 44 | When I run `stack_master apply us-east-2 vpc --trace` 45 | And the output should contain all of these lines: 46 | | +--- | 47 | | +VpcCidr: 10.0.0.0/16 | 48 | And the output should match /2020-10-29 00:00:00 (\+|\-)[0-9]{4} vpc AWS::CloudFormation::Stack CREATE_COMPLETE/ 49 | Then the exit status should be 0 -------------------------------------------------------------------------------- /features/apply_with_explicit_parameter_files.feature: -------------------------------------------------------------------------------- 1 | Feature: Apply command with explicit parameter files 2 | 3 | Background: 4 | Given a file named "stack_master.yml" with: 5 | """ 6 | stack_defaults: 7 | tags: 8 | Application: myapp 9 | stacks: 10 | us-east-1: 11 | myapp-web: 12 | template: myapp.rb 13 | parameter_files: 14 | - myapp-web-parameters.yml 15 | """ 16 | And a file named "parameters/us-east-1/myapp-web.yml" with: 17 | """ 18 | Color: blue 19 | """ 20 | And a file named "parameters/myapp-web-parameters.yml" with: 21 | """ 22 | KeyName: my-key 23 | Color: red 24 | """ 25 | And a directory named "templates" 26 | And a file named "templates/myapp.rb" with: 27 | """ 28 | SparkleFormation.new(:myapp) do 29 | description "Test template" 30 | 31 | parameters.key_name do 32 | description 'Key name' 33 | type 'String' 34 | end 35 | 36 | parameters.color do 37 | description 'Color' 38 | type 'String' 39 | end 40 | 41 | resources.instance do 42 | type 'AWS::EC2::Instance' 43 | properties do 44 | image_id 'ami-0080e4c5bc078760e' 45 | instance_type 't2.micro' 46 | end 47 | end 48 | end 49 | """ 50 | 51 | Scenario: Run apply and create stack with explicit parameter files 52 | Given I stub the following stack events: 53 | | stack_id | event_id | stack_name | logical_resource_id | resource_status | resource_type | timestamp | 54 | | 1 | 1 | myapp-web | myapp-web | CREATE_COMPLETE | AWS::CloudFormation::Stack | 2020-10-29 00:00:00 | 55 | When I run `stack_master apply us-east-1 myapp-web --trace` 56 | Then the output should match /2020-10-29 00:00:00 (\+|\-)[0-9]{4} myapp-web AWS::CloudFormation::Stack CREATE_COMPLETE/ 57 | And the output should contain all of these lines: 58 | | Stack diff: | 59 | | + "Instance": { | 60 | | Parameters diff: | 61 | | KeyName: my-key | 62 | | Proposed change set: | 63 | And the output should not contain "Color: blue" 64 | And the output should contain "Color: red" 65 | And the exit status should be 0 66 | -------------------------------------------------------------------------------- /features/apply_with_parameter_store_parameters.feature: -------------------------------------------------------------------------------- 1 | Feature: Apply command with parameter_store parameter 2 | 3 | Background: 4 | Given a file named "stack_master.yml" with: 5 | """ 6 | stacks: 7 | us-east-2: 8 | vpc: 9 | template: vpc.rb 10 | """ 11 | And a directory named "parameters" 12 | And a file named "parameters/vpc.yml" with: 13 | """ 14 | vpc_cidr: 15 | parameter_store: "/cucumber-test-vpc-cidr" 16 | """ 17 | And a SSM parameter named "/cucumber-test-vpc-cidr" with value "10.0.0.0/16" in region "us-east-2" 18 | And a directory named "templates" 19 | And a file named "templates/vpc.rb" with: 20 | """ 21 | SparkleFormation.new(:vpc) do 22 | 23 | parameters.vpc_cidr do 24 | type 'String' 25 | end 26 | 27 | resources.vpc do 28 | type 'AWS::EC2::VPC' 29 | properties do 30 | cidr_block ref!(:vpc_cidr) 31 | end 32 | end 33 | 34 | end 35 | """ 36 | 37 | Scenario: Run apply and create a new stack 38 | Given I stub the following stack events: 39 | | stack_id | event_id | stack_name | logical_resource_id | resource_status | resource_type | timestamp | 40 | | 1 | 1 | vpc | Vpc | CREATE_COMPLETE | AWS::EC2::VPC | 2020-10-29 00:00:00 | 41 | | 1 | 1 | vpc | vpc | CREATE_COMPLETE | AWS::CloudFormation::Stack | 2020-10-29 00:00:00 | 42 | When I run `stack_master apply us-east-2 vpc --trace` 43 | And the output should contain all of these lines: 44 | | +--- | 45 | | +VpcCidr: 10.0.0.0/16 | 46 | And the output should match /2020-10-29 00:00:00 (\+|\-)[0-9]{4} vpc AWS::CloudFormation::Stack CREATE_COMPLETE/ 47 | Then the exit status should be 0 48 | -------------------------------------------------------------------------------- /features/apply_with_sparkle_pack_template.feature: -------------------------------------------------------------------------------- 1 | Feature: Apply command with compile time parameters 2 | 3 | Background: 4 | Given a file named "stack_master.yml" with: 5 | """ 6 | stacks: 7 | us-east-1: 8 | sparkle_pack_test: 9 | template: template_with_dynamic_from_pack 10 | compiler: sparkle_formation 11 | compiler_options: 12 | sparkle_pack_template: true 13 | sparkle_packs: 14 | - my_sparkle_pack 15 | """ 16 | And a directory named "templates" 17 | 18 | Scenario: Run apply and create a new stack 19 | When I run `stack_master apply us-east-1 sparkle_pack_test -q --trace` 20 | Then the output should contain all of these lines: 21 | | +{ | 22 | | + "Outputs": { | 23 | | + "Foo": { | 24 | | + "Value": "bar" | 25 | | + } | 26 | | + } | 27 | | +} | 28 | And the exit status should be 0 29 | 30 | Scenario: An unknown template 31 | Given a file named "stack_master.yml" with: 32 | """ 33 | stacks: 34 | us-east-1: 35 | sparkle_pack_test: 36 | template: template_unknown 37 | compiler: sparkle_formation 38 | compiler_options: 39 | sparkle_pack_template: true 40 | sparkle_packs: 41 | - my_sparkle_pack 42 | """ 43 | When I run `stack_master apply us-east-1 sparkle_pack_test -q --trace` 44 | Then the output should contain all of these lines: 45 | | Template "template_unknown" not found in any sparkle pack | 46 | And the exit status should be 1 47 | 48 | Scenario: An unknown compiler 49 | Given a file named "stack_master.yml" with: 50 | """ 51 | stacks: 52 | us-east-1: 53 | sparkle_pack_test: 54 | template: template_with_dynamic_from_pack 55 | compiler: foobar 56 | """ 57 | When I run `stack_master apply us-east-1 sparkle_pack_test -q --trace` 58 | Then the output should contain all of these lines: 59 | | Unknown compiler "foobar" | 60 | And the exit status should be 1 61 | -------------------------------------------------------------------------------- /features/apply_with_stack_definition_parameters.feature: -------------------------------------------------------------------------------- 1 | Feature: Apply command with stack definition parameters 2 | 3 | Background: 4 | Given a file named "stack_master.yml" with: 5 | """ 6 | stacks: 7 | us-east-1: 8 | myapp_web: 9 | template: myapp.rb 10 | parameters: 11 | KeyName: my-key 12 | """ 13 | And a directory named "templates" 14 | And a file named "templates/myapp.rb" with: 15 | """ 16 | SparkleFormation.new(:myapp) do 17 | description "Test template" 18 | 19 | parameters.key_name do 20 | description 'Key name' 21 | type 'String' 22 | end 23 | 24 | resources.instance do 25 | type 'AWS::EC2::Instance' 26 | properties do 27 | image_id 'ami-0080e4c5bc078760e' 28 | instance_type 't2.micro' 29 | end 30 | end 31 | end 32 | """ 33 | 34 | Scenario: Run apply with parameters contained in 35 | Given I stub the following stack events: 36 | | stack_id | event_id | stack_name | logical_resource_id | resource_status | resource_type | timestamp | 37 | | 1 | 1 | myapp-web | myapp-web | CREATE_COMPLETE | AWS::CloudFormation::Stack | 2020-10-29 00:00:00 | 38 | When I run `stack_master apply us-east-1 myapp-web --trace` 39 | Then the output should match /2020-10-29 00:00:00 (\+|\-)[0-9]{4} myapp-web AWS::CloudFormation::Stack CREATE_COMPLETE/ 40 | And the output should contain all of these lines: 41 | | Stack diff: | 42 | | + "Instance": { | 43 | | Parameters diff: | 44 | | KeyName: my-key | 45 | | Proposed change set: | 46 | And the exit status should be 0 47 | -------------------------------------------------------------------------------- /features/apply_without_parameter_file.feature: -------------------------------------------------------------------------------- 1 | Feature: Apply command without parameter files 2 | 3 | Background: 4 | Given a directory named "templates" 5 | And a file named "templates/myapp.rb" with: 6 | """ 7 | SparkleFormation.new(:myapp) do 8 | parameters.key_name.type 'String' 9 | resources.vpc do 10 | type 'AWS::EC2::VPC' 11 | properties.cidr_block '10.200.0.0/16' 12 | end 13 | outputs.vpc_id.value ref!(:vpc) 14 | end 15 | """ 16 | 17 | Scenario: With a region alias 18 | Given a file named "stack_master.yml" with: 19 | """ 20 | region_aliases: 21 | production: us-east-1 22 | staging: ap-southeast-2 23 | stacks: 24 | production: 25 | myapp: 26 | template: myapp.rb 27 | """ 28 | When I run `stack_master apply production myapp --trace` 29 | Then the output should contain all of these lines: 30 | | Empty/blank parameters detected. Please provide values for these parameters: | 31 | | - KeyName | 32 | | Parameters will be read from files matching the following globs: | 33 | | - parameters/myapp.y*ml | 34 | | - parameters/us-east-1/myapp.y*ml | 35 | | - parameters/production/myapp.y*ml | 36 | And the exit status should be 1 37 | 38 | Scenario: Without a region alias 39 | Given a file named "stack_master.yml" with: 40 | """ 41 | stacks: 42 | us-east-1: 43 | myapp: 44 | template: myapp.rb 45 | """ 46 | When I run `stack_master apply us-east-1 myapp --trace` 47 | Then the output should contain all of these lines: 48 | | Empty/blank parameters detected. Please provide values for these parameters: | 49 | | - KeyName | 50 | | Parameters will be read from files matching the following globs: | 51 | | - parameters/myapp.y*ml | 52 | | - parameters/us-east-1/myapp.y*ml | 53 | And the output should not contain "- parameters/production/myapp.y*ml" 54 | And the exit status should be 1 55 | -------------------------------------------------------------------------------- /features/events.feature: -------------------------------------------------------------------------------- 1 | Feature: Events command 2 | 3 | Background: 4 | Given a file named "stack_master.yml" with: 5 | """ 6 | stacks: 7 | us_east_1: 8 | myapp_vpc: 9 | template: myapp_vpc.rb 10 | """ 11 | And a directory named "templates" 12 | And a file named "templates/myapp_vpc.rb" with: 13 | """ 14 | SparkleFormation.new(:myapp_vpc) do 15 | description "Test template" 16 | set!('AWSTemplateFormatVersion', '2010-09-09') 17 | 18 | resources.vpc do 19 | type 'AWS::EC2::VPC' 20 | properties do 21 | cidr_block '10.200.0.0/16' 22 | end 23 | end 24 | end 25 | """ 26 | 27 | Scenario: View events 28 | Given I stub the following stack events: 29 | | stack_id | event_id | stack_name | logical_resource_id | resource_status | resource_type | timestamp | 30 | | 1 | 1 | myapp-vpc | TestSg | CREATE_COMPLETE | AWS::EC2::SecurityGroup | 2020-10-29 00:00:00 | 31 | | 1 | 1 | myapp-vpc | myapp-vpc | CREATE_COMPLETE | AWS::CloudFormation::Stack | 2020-10-29 00:00:00 | 32 | When I run `stack_master events us-east-1 myapp-vpc --trace` 33 | Then the output should match /2020-10-29 00:00:00 (\+|\-)[0-9]{4} myapp-vpc AWS::CloudFormation::Stack CREATE_COMPLETE/ 34 | And the exit status should be 0 35 | -------------------------------------------------------------------------------- /features/init.feature: -------------------------------------------------------------------------------- 1 | Feature: init project 2 | 3 | Scenario: Run init 4 | When I run `stack_master init us-east-1 my-app` 5 | # TODO flesh this out 6 | Then the exit status should be 0 7 | -------------------------------------------------------------------------------- /features/outputs.feature: -------------------------------------------------------------------------------- 1 | Feature: Outputs command 2 | 3 | Background: 4 | Given a file named "stack_master.yml" with: 5 | """ 6 | stacks: 7 | us_east_1: 8 | myapp_vpc: 9 | template: myapp_vpc.rb 10 | """ 11 | And a directory named "templates" 12 | And a file named "templates/myapp_vpc.rb" with: 13 | """ 14 | SparkleFormation.new(:myapp_vpc) do 15 | description "Test template" 16 | set!('AWSTemplateFormatVersion', '2010-09-09') 17 | resources.vpc do 18 | type 'AWS::EC2::VPC' 19 | properties do 20 | cidr_block '10.200.0.0/16' 21 | end 22 | end 23 | end 24 | """ 25 | 26 | Scenario: Output stack resources 27 | Given I stub the following stacks: 28 | | stack_id | stack_name | parameters | region | outputs | 29 | | 1 | myapp-vpc | KeyName=my-key | us-east-1 | VpcId=vpc-123456 | 30 | And I stub a template for the stack "myapp-vpc": 31 | """ 32 | { 33 | } 34 | """ 35 | When I run `stack_master outputs us-east-1 myapp-vpc --trace` 36 | Then the output should contain all of these lines: 37 | | VpcId | 38 | | vpc-123456 | 39 | And the exit status should be 0 40 | 41 | Scenario: Fails when the stack doesn't exist 42 | When I run `stack_master outputs us-east-1 myapp-vpc --trace` 43 | Then the output should not contain all of these lines: 44 | | VpcId | 45 | | vpc-123456 | 46 | And the output should contain "Stack doesn't exist" 47 | And the exit status should be 1 48 | -------------------------------------------------------------------------------- /features/region_aliases.feature: -------------------------------------------------------------------------------- 1 | Feature: Region aliases 2 | 3 | Background: 4 | Given a file named "stack_master.yml" with: 5 | """ 6 | region_aliases: 7 | staging: ap-southeast-2 8 | production: us_east_1 9 | stacks: 10 | staging: 11 | myapp_vpc: 12 | template: myapp_vpc.rb 13 | production: 14 | myapp_vpc: 15 | template: myapp_vpc.rb 16 | """ 17 | And a directory named "templates" 18 | And a directory named "parameters" 19 | And a file named "templates/myapp_vpc.rb" with: 20 | """ 21 | SparkleFormation.new(:myapp_vpc) do 22 | description "Test template" 23 | set!('AWSTemplateFormatVersion', '2010-09-09') 24 | 25 | parameters.key_name do 26 | description 'Key name' 27 | type 'String' 28 | end 29 | 30 | resources.vpc do 31 | type 'AWS::EC2::VPC' 32 | properties do 33 | cidr_block '10.200.0.0/16' 34 | end 35 | end 36 | 37 | outputs do 38 | vpc_id do 39 | description 'A VPC ID' 40 | value ref!(:vpc) 41 | end 42 | end 43 | end 44 | """ 45 | And a file named "parameters/myapp_vpc.yml" with: 46 | """ 47 | key_name: my-key 48 | """ 49 | And I stub the following stack events: 50 | | stack_id | event_id | stack_name | logical_resource_id | resource_status | resource_type | timestamp | 51 | | 1 | 1 | myapp-vpc | TestSg | CREATE_COMPLETE | AWS::EC2::SecurityGroup | 2020-10-29 00:00:00 | 52 | | 1 | 1 | myapp-vpc | myapp-vpc | CREATE_COMPLETE | AWS::CloudFormation::Stack | 2020-10-29 00:00:00 | 53 | 54 | Scenario: Create a stack using region aliases 55 | When I run `stack_master apply staging myapp-vpc --trace` 56 | And the output should contain all of these lines: 57 | | Stack diff: | 58 | | + "Vpc": { | 59 | | Parameters diff: | 60 | | KeyName: my-key | 61 | And the output should match /2020-10-29 00:00:00 (\+|\-)[0-9]{4} myapp-vpc AWS::CloudFormation::Stack CREATE_COMPLETE/ 62 | Then the exit status should be 0 63 | -------------------------------------------------------------------------------- /features/resources.feature: -------------------------------------------------------------------------------- 1 | Feature: Resources command 2 | 3 | Background: 4 | Given a file named "stack_master.yml" with: 5 | """ 6 | stacks: 7 | us_east_1: 8 | myapp_vpc: 9 | template: myapp_vpc.rb 10 | """ 11 | And a directory named "templates" 12 | And a file named "templates/myapp_vpc.rb" with: 13 | """ 14 | SparkleFormation.new(:myapp_vpc) do 15 | description "Test template" 16 | set!('AWSTemplateFormatVersion', '2010-09-09') 17 | resources.vpc do 18 | type 'AWS::EC2::VPC' 19 | properties do 20 | cidr_block '10.200.0.0/16' 21 | end 22 | end 23 | end 24 | """ 25 | 26 | Scenario: Show resources 27 | Given I stub the following stacks: 28 | | stack_id | stack_name | parameters | region | 29 | | 1 | myapp-vpc | KeyName=my-key | us-east-1 | 30 | And I stub the following stack resources: 31 | | stack_name | logical_resource_id | resource_type | timestamp | resource_status | 32 | | myapp-vpc | Vpc | AWS::EC2::Vpc | 2015-11-02 06:41:58 | CREATE_COMPLETE | 33 | When I run `stack_master resources us-east-1 myapp-vpc --trace` 34 | And the output should contain all of these lines: 35 | | Vpc | 36 | | AWS::EC2::Vpc | 37 | | 2015-11-02 06:41:58 | 38 | | CREATE_COMPLETE | 39 | 40 | Scenario: Fails when the stack doesn't exist 41 | When I run `stack_master resources us-east-1 myapp-vpc --trace` 42 | Then the output should contain "Stack doesn't exist" 43 | And the exit status should be 1 44 | -------------------------------------------------------------------------------- /features/step_definitions/asume_role_steps.rb: -------------------------------------------------------------------------------- 1 | Then(/^I expect the role "([^"]*)" is assumed in account "([^"]*)"$/) do |role, account| 2 | expect(Aws::AssumeRoleCredentials).to receive(:new).with({ 3 | region: instance_of(String), 4 | role_arn: "arn:aws:iam::#{account}:role/#{role}", 5 | role_session_name: instance_of(String) 6 | }) 7 | end 8 | -------------------------------------------------------------------------------- /features/step_definitions/identity_steps.rb: -------------------------------------------------------------------------------- 1 | Given(/^I use the account "([^"]*)"(?: with alias "([^"]*)")?$/) do |account_id, account_alias| 2 | Aws.config[:sts] = { 3 | stub_responses: { 4 | get_caller_identity: { 5 | account: account_id, 6 | arn: 'an-arn', 7 | user_id: 'a-user-id' 8 | } 9 | } 10 | } 11 | 12 | if account_alias.present? 13 | Aws.config[:iam] = { 14 | stub_responses: { 15 | list_account_aliases: { 16 | account_aliases: [account_alias], 17 | is_truncated: false 18 | } 19 | } 20 | } 21 | else 22 | # ensure stubs don't leak between steps 23 | Aws.config[:iam]&.delete(:stub_responses) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /features/step_definitions/parameter_store_steps.rb: -------------------------------------------------------------------------------- 1 | Given(/^(?:a|the) SSM parameter(?: named)? "([^"]*)" with value "([^"]*)" in region "([^"]*)"$/) do |parameter_name, parameter_value, parameter_region| 2 | Aws.config[:ssm] = { 3 | stub_responses: { 4 | get_parameter: { 5 | parameter: { 6 | name: parameter_name, 7 | value: parameter_value, 8 | type: "SecureString", 9 | version: 1 10 | } 11 | } 12 | } 13 | } 14 | end 15 | -------------------------------------------------------------------------------- /features/support/env.rb: -------------------------------------------------------------------------------- 1 | require 'aruba/cucumber' 2 | require 'stack_master' 3 | require 'stack_master/testing' 4 | require 'aruba/processes/in_process' 5 | require 'pry' 6 | require 'cucumber/rspec/doubles' 7 | require 'timecop' 8 | 9 | Aruba.configure do |config| 10 | config.command_launcher = :in_process 11 | config.main_class = StackMaster::CLI 12 | end 13 | 14 | Before do 15 | StackMaster.cloud_formation_driver.reset 16 | StackMaster.s3_driver.reset 17 | StackMaster.reset_flags 18 | Timecop.travel(Time.local(2020, 10, 19)) 19 | end 20 | 21 | After do 22 | Timecop.return 23 | end 24 | 25 | lib = File.join(File.dirname(__FILE__), "../../spec/fixtures/sparkle_pack_integration/my_sparkle_pack/lib") 26 | $LOAD_PATH << lib 27 | -------------------------------------------------------------------------------- /features/tidy.feature: -------------------------------------------------------------------------------- 1 | Feature: Tidy command 2 | 3 | Background: 4 | Given a file named "stack_master.yml" with: 5 | """ 6 | stacks: 7 | us_east_1: 8 | stack1: 9 | template: stack1.json 10 | stack5: 11 | template: stack5.json 12 | """ 13 | And a directory named "parameters" 14 | And an empty file named "parameters/stack1.yml" 15 | And an empty file named "parameters/stack4.yml" 16 | And a directory named "templates" 17 | And an empty file named "templates/stack1.json" 18 | And an empty file named "templates/stack2.rb" 19 | And a directory named "templates/dynamics" 20 | And an empty file named "templates/dynamics/my_dynamic.rb" 21 | 22 | Scenario: Tidy identifies extra & missing files 23 | Given I run `stack_master tidy --trace` 24 | Then the output should contain all of these lines: 25 | | Stack "stack5" in "us-east-1" missing template "templates/stack5.json" | 26 | | templates/stack2.rb: no stack found for this template | 27 | | parameters/stack4.yml: no stack found for this parameter file | 28 | And the output should not contain "stack1" 29 | And the exit status should be 0 30 | -------------------------------------------------------------------------------- /features/validate.feature: -------------------------------------------------------------------------------- 1 | Feature: Validate command 2 | 3 | Background: 4 | Given a file named "stack_master.yml" with: 5 | """ 6 | stacks: 7 | us_east_1: 8 | stack1: 9 | template: stack1.json 10 | """ 11 | And a directory named "parameters" 12 | And a file named "parameters/stack1.yml" with: 13 | """ 14 | InstanceTypeParameter: my-type 15 | """ 16 | And a directory named "templates" 17 | And a file named "templates/stack1.json" with: 18 | """ 19 | { 20 | "AWSTemplateFormatVersion": "2010-09-09", 21 | "Description": "Test template", 22 | "Parameters": { 23 | "InstanceTypeParameter" : { "Type" : "String" } 24 | }, 25 | "Mappings": {}, 26 | "Resources": { 27 | "MyAwesomeQueue" : { 28 | "Type" : "AWS::SQS::Queue", 29 | "Properties" : { 30 | "VisibilityTimeout" : "1" 31 | } 32 | } 33 | }, 34 | "Outputs": {} 35 | } 36 | """ 37 | 38 | Scenario: Validate successfully 39 | Given I stub CloudFormation validate calls to pass validation 40 | And I run `stack_master validate us-east-1 stack1` 41 | Then the output should contain "stack1: valid" 42 | And the exit status should be 0 43 | 44 | Scenario: Validate unsuccessfully 45 | Given I stub CloudFormation validate calls to fail validation with message "Blah" 46 | And I run `stack_master validate us-east-1 stack1` 47 | Then the output should contain "stack1: invalid. Blah" 48 | And the exit status should be 1 49 | -------------------------------------------------------------------------------- /features/version.feature: -------------------------------------------------------------------------------- 1 | Feature: Check the StackMaster version 2 | Scenario: Use the --version option 3 | When I run `stack_master --version` 4 | Then the exit status should be 0 5 | -------------------------------------------------------------------------------- /lib/stack_master/aws_driver/cloud_formation.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | module AwsDriver 3 | class CloudFormation 4 | extend Forwardable 5 | 6 | def region 7 | @region ||= ENV['AWS_REGION'] || Aws.config[:region] || Aws.shared_config.region 8 | end 9 | 10 | def set_region(value) 11 | if region != value 12 | @region = value 13 | @cf = nil 14 | end 15 | end 16 | 17 | def_delegators :cf, :create_change_set, 18 | :describe_change_set, 19 | :execute_change_set, 20 | :delete_change_set, 21 | :delete_stack, 22 | :cancel_update_stack, 23 | :describe_stack_resources, 24 | :get_template, 25 | :get_stack_policy, 26 | :set_stack_policy, 27 | :describe_stack_events, 28 | :update_stack, 29 | :create_stack, 30 | :validate_template, 31 | :describe_stacks, 32 | :detect_stack_drift, 33 | :describe_stack_drift_detection_status, 34 | :describe_stack_resource_drifts 35 | 36 | private 37 | 38 | def cf 39 | @cf ||= Aws::CloudFormation::Client.new({ region: region, retry_limit: 10 }) 40 | end 41 | 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/stack_master/aws_driver/s3.rb: -------------------------------------------------------------------------------- 1 | require 'digest/md5' 2 | 3 | module StackMaster 4 | module AwsDriver 5 | class S3ConfigurationError < StandardError; end 6 | 7 | class S3 8 | def set_region(region) 9 | @region = region 10 | @s3 = nil 11 | end 12 | 13 | def upload_files(bucket: nil, prefix: nil, region: nil, files: {}) 14 | raise StackMaster::AwsDriver::S3ConfigurationError, 'A bucket must be specified in order to use S3' unless bucket 15 | 16 | return if files.empty? 17 | 18 | s3 = new_s3_client(region: region) 19 | 20 | current_objects = s3.list_objects({ 21 | prefix: prefix, 22 | bucket: bucket 23 | }).map(&:contents).flatten.inject({}){|h,obj| 24 | h.merge(obj.key => obj) 25 | } 26 | 27 | StackMaster.stdout.puts "Uploading files to S3:" 28 | 29 | files.each do |template, file| 30 | body = file.fetch(:body) 31 | path = file.fetch(:path) 32 | object_key = template.dup 33 | object_key.prepend("#{prefix}/") if prefix 34 | compiled_template_md5 = Digest::MD5.hexdigest(body).to_s 35 | s3_md5 = current_objects[object_key] ? current_objects[object_key].etag.gsub("\"", '') : nil 36 | 37 | next if compiled_template_md5 == s3_md5 38 | s3_uri = "s3://#{bucket}/#{object_key}" 39 | StackMaster.stdout.print "- #{File.basename(path)} => #{s3_uri} " 40 | 41 | s3.put_object({ 42 | bucket: bucket, 43 | key: object_key, 44 | body: body, 45 | metadata: { md5: compiled_template_md5 } 46 | }) 47 | StackMaster.stdout.puts "done." 48 | end 49 | end 50 | 51 | def url(bucket:, prefix:, region:, template:) 52 | if region == 'us-east-1' 53 | ["https://s3.amazonaws.com", bucket, prefix, template].compact.join('/') 54 | elsif region.start_with? "cn-" 55 | ["https://s3.#{region}.amazonaws.com.cn", bucket, prefix, template].compact.join('/') 56 | else 57 | ["https://s3-#{region}.amazonaws.com", bucket, prefix, template].compact.join('/') 58 | end 59 | end 60 | 61 | private 62 | 63 | def new_s3_client(region: nil) 64 | Aws::S3::Client.new({ region: region || @region }) 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/stack_master/cloudformation_interpolating_eruby.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'erubis' 4 | 5 | module StackMaster 6 | # This class is a modified version of `Erubis::Eruby`. It allows using 7 | # `<%= %>` ERB expressions to interpolate values into a source string. We use 8 | # this capability to enrich user data scripts with data and parameters pulled 9 | # from the AWS CloudFormation service. The evaluation produces an array of 10 | # objects ready for use in a CloudFormation `Fn::Join` intrinsic function. 11 | class CloudFormationInterpolatingEruby < Erubis::Eruby 12 | include Erubis::ArrayEnhancer 13 | 14 | # Load a template from a file at the specified path and evaluate it. 15 | def self.evaluate_file(source_path, context = Erubis::Context.new) 16 | template_contents = File.read(source_path) 17 | eruby = new(template_contents) 18 | eruby.filename = source_path 19 | eruby.evaluate(context) 20 | end 21 | 22 | # @return [Array] The result of evaluating the source: an array of strings 23 | # from the source intermindled with Hash objects from the ERB 24 | # expressions. To be included in a CloudFormation template, this 25 | # value needs to be used in a CloudFormation `Fn::Join` intrinsic 26 | # function. 27 | # @see Erubis::Eruby#evaluate 28 | # @example 29 | # CloudFormationInterpolatingEruby.new("my_variable=<%= { 'Ref' => 'Param1' } %>;").evaluate 30 | # #=> ['my_variable=', { 'Ref' => 'Param1' }, ';'] 31 | def evaluate(_context = Erubis::Context.new) 32 | format_lines_for_cloudformation(super) 33 | end 34 | 35 | # @see Erubis::Eruby#add_expr 36 | def add_expr(src, code, indicator) 37 | if indicator == '=' 38 | src << " #{@bufvar} << (" << code << ');' 39 | else 40 | super 41 | end 42 | end 43 | 44 | private 45 | 46 | # Split up long strings containing multiple lines. One string per line in the 47 | # CloudFormation array makes the compiled template and diffs more readable. 48 | def format_lines_for_cloudformation(source) 49 | source.flat_map do |lines| 50 | lines = lines.to_s if lines.is_a?(Symbol) 51 | next(lines) unless lines.is_a?(String) 52 | 53 | lines.scan(/[^\n]*\n?/).reject { |x| x == '' } 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/stack_master/cloudformation_template_eruby.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'erubis' 4 | require 'json' 5 | 6 | module StackMaster 7 | # This class is a modified version of `Erubis::Eruby`. It provides extra 8 | # helper methods to ease the dynamic creation of CloudFormation templates 9 | # with ERB. These helper methods are available within `<%= %>` expressions. 10 | class CloudFormationTemplateEruby < Erubis::Eruby 11 | # Adds the contents of an EC2 userdata script to the CloudFormation 12 | # template. Allows using the ERB `<%= %>` expressions within the user data 13 | # script to interpolate CloudFormation values. 14 | def user_data_file(filepath) 15 | JSON.pretty_generate({ 'Fn::Base64' => { 'Fn::Join' => ['', user_data_file_as_lines(filepath)] } }) 16 | end 17 | 18 | # Evaluate the ERB template at the specified filepath and return the result 19 | # as an array of lines. Allows using ERB `<%= %>` expressions to interpolate 20 | # CloudFormation objects into the result. 21 | def user_data_file_as_lines(filepath) 22 | StackMaster::CloudFormationInterpolatingEruby.evaluate_file(filepath, self) 23 | end 24 | 25 | # Add the contents of another file into the CloudFormation template as a 26 | # string. ERB `<%= %>` expressions within the referenced file are not 27 | # evaluated. 28 | def include_file(filepath) 29 | JSON.pretty_generate(File.read(filepath)) 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/stack_master/command.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | module Command 3 | def self.included(base) 4 | base.extend ClassMethods 5 | base.prepend Perform 6 | end 7 | 8 | module ClassMethods 9 | def perform(*args) 10 | new(*args).tap do |command| 11 | command.perform 12 | end 13 | end 14 | 15 | def command_name 16 | name.split('::').last.underscore 17 | end 18 | end 19 | 20 | module Perform 21 | def perform 22 | catch(:halt) do 23 | super 24 | end 25 | rescue Aws::CloudFormation::Errors::ServiceError, TemplateCompiler::TemplateCompilationFailed => e 26 | failed error_message(e) 27 | end 28 | end 29 | 30 | def initialize(config, stack_definition = nil, options = Commander::Command::Options.new) 31 | @config = config 32 | @stack_definition = stack_definition 33 | @options = options 34 | end 35 | 36 | def success? 37 | @failed != true 38 | end 39 | 40 | private 41 | 42 | def error_message(e) 43 | msg = "#{e.class} #{e.message}" 44 | msg << "\n Caused by: #{e.cause.class} #{e.cause.message}" if e.cause 45 | msg << "\n at #{e.cause.backtrace[0..3].join("\n ")}\n ..." if e.cause && !options.trace 46 | if options.trace 47 | msg << "\n#{backtrace(e)}" 48 | else 49 | msg << "\n Use --trace to view backtrace" 50 | end 51 | msg 52 | end 53 | 54 | def backtrace(error) 55 | if error.respond_to?(:full_message) 56 | error.full_message 57 | else 58 | # full_message was introduced in Ruby 2.5 59 | # remove this conditional when StackMaster no longer supports Ruby 2.4 60 | error.backtrace.join("\n") 61 | end 62 | end 63 | 64 | def failed(message = nil) 65 | StackMaster.stderr.puts(message) if message 66 | @failed = true 67 | end 68 | 69 | def failed!(message = nil) 70 | failed(message) 71 | halt! 72 | end 73 | 74 | def halt!(message = nil) 75 | StackMaster.stdout.puts(message) if message 76 | throw :halt 77 | end 78 | 79 | def options 80 | @options ||= Commander::Command::Options.new 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/stack_master/commands/compile.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | module Commands 3 | class Compile 4 | include Command 5 | include Commander::UI 6 | 7 | def perform 8 | StackMaster.stdout.puts(proposed_stack.template_body) 9 | end 10 | 11 | private 12 | 13 | def stack_definition 14 | @stack_definition ||= @config.find_stack(@region, @stack_name) 15 | end 16 | 17 | def proposed_stack 18 | @proposed_stack ||= Stack.generate(stack_definition, @config) 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/stack_master/commands/delete.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | module Commands 3 | class Delete 4 | include Command 5 | include StackMaster::Prompter 6 | 7 | def initialize(region, stack_name, options) 8 | super(nil, nil, options) 9 | @region = region 10 | @stack_name = stack_name 11 | @from_time = Time.now 12 | end 13 | 14 | def perform 15 | 16 | return unless check_exists 17 | 18 | unless ask?("Really delete stack #{@stack_name} (y/n)? ") 19 | StackMaster.stdout.puts "Stack update aborted" 20 | return 21 | end 22 | 23 | delete_stack 24 | tail_stack_events unless StackMaster.quiet? 25 | end 26 | 27 | private 28 | 29 | def delete_stack 30 | cf.delete_stack({stack_name: @stack_name}) 31 | end 32 | 33 | def check_exists 34 | cf.describe_stacks({stack_name: @stack_name}) 35 | true 36 | rescue Aws::CloudFormation::Errors::ValidationError 37 | failed("Stack does not exist") 38 | false 39 | end 40 | 41 | def cf 42 | StackMaster.cloud_formation_driver 43 | end 44 | 45 | def tail_stack_events 46 | StackEvents::Streamer.stream(@stack_name, @region, io: StackMaster.stdout, from: @from_time) 47 | StackMaster.stdout.puts "Stack deleted" 48 | rescue Aws::CloudFormation::Errors::ValidationError 49 | # Unfortunately the stack as a tendency of going away before we get the final delete event. 50 | StackMaster.stdout.puts "Stack deleted" 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/stack_master/commands/diff.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | module Commands 3 | class Diff 4 | include Command 5 | include Commander::UI 6 | 7 | def perform 8 | StackMaster::StackDiffer.new(proposed_stack, stack).output_diff 9 | end 10 | 11 | private 12 | 13 | def stack_definition 14 | @stack_definition ||= @config.find_stack(@region, @stack_name) 15 | end 16 | 17 | def stack 18 | @stack ||= Stack.find(@stack_definition.region, @stack_definition.stack_name) 19 | end 20 | 21 | def proposed_stack 22 | @proposed_stack ||= Stack.generate(stack_definition, @config) 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/stack_master/commands/events.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | module Commands 3 | class Events 4 | include Command 5 | include Commander::UI 6 | 7 | def perform 8 | events = StackEvents::Fetcher.fetch(@stack_definition.stack_name, @stack_definition.region) 9 | filter_events(events).each do |event| 10 | StackEvents::Presenter.print_event(StackMaster.stdout, event) 11 | end 12 | if @options.tail 13 | StackEvents::Streamer.stream(@stack_definition.stack_name, @stack_definition.region, io: StackMaster.stdout) 14 | end 15 | end 16 | 17 | private 18 | 19 | def filter_events(events) 20 | if @options.all 21 | events 22 | else 23 | n = @options.number || 25 24 | from = events.count - n 25 | if from < 0 26 | from = 0 27 | end 28 | events[from..-1] 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/stack_master/commands/lint.rb: -------------------------------------------------------------------------------- 1 | require 'tempfile' 2 | 3 | module StackMaster 4 | module Commands 5 | class Lint 6 | include Command 7 | include Commander::UI 8 | 9 | def perform 10 | unless cfn_lint_available 11 | failed! 'Failed to run cfn-lint. You may need to install it using'\ 12 | '`pip install cfn-lint`, or add it to $PATH.'\ 13 | "\n"\ 14 | '(See https://github.com/aws-cloudformation/cfn-python-lint'\ 15 | ' for package information)' 16 | end 17 | 18 | Tempfile.open(['stack', ".#{proposed_stack.template_format}"]) do |f| 19 | f.write(proposed_stack.template_body) 20 | f.flush 21 | system('cfn-lint', f.path) 22 | puts "cfn-lint run complete" 23 | end 24 | end 25 | 26 | private 27 | 28 | def stack_definition 29 | @stack_definition ||= @config.find_stack(@region, @stack_name) 30 | end 31 | 32 | def proposed_stack 33 | @proposed_stack ||= Stack.generate(stack_definition, @config) 34 | end 35 | 36 | def cfn_lint_available 37 | !system('cfn-lint', '--version').nil? 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/stack_master/commands/list_stacks.rb: -------------------------------------------------------------------------------- 1 | require 'table_print' 2 | 3 | module StackMaster 4 | module Commands 5 | class ListStacks 6 | include Command 7 | include Commander::UI 8 | include StackMaster::Commands::TerminalHelper 9 | 10 | def perform 11 | tp.set :max_width, self.window_size 12 | tp @config.stacks, :region, :stack_name 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/stack_master/commands/nag.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | module Commands 3 | class Nag 4 | include Command 5 | include Commander::UI 6 | 7 | def perform 8 | rv = Tempfile.open(['stack', "___#{stack_definition.stack_name}.#{proposed_stack.template_format}"]) do |f| 9 | f.write(proposed_stack.template_body) 10 | f.flush 11 | system('cfn_nag', f.path) 12 | $?.exitstatus 13 | end 14 | 15 | failed!("cfn_nag check failed with exit status #{rv}") if rv > 0 16 | end 17 | 18 | private 19 | 20 | def stack_definition 21 | @stack_definition ||= @config.find_stack(@region, @stack_name) 22 | end 23 | 24 | def proposed_stack 25 | @proposed_stack ||= Stack.generate(stack_definition, @config) 26 | end 27 | 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/stack_master/commands/outputs.rb: -------------------------------------------------------------------------------- 1 | require 'table_print' 2 | 3 | module StackMaster 4 | module Commands 5 | class Outputs 6 | include Command 7 | include Commander::UI 8 | include StackMaster::Commands::TerminalHelper 9 | 10 | def perform 11 | if stack 12 | tp.set :max_width, self.window_size 13 | tp stack.outputs, :output_key, :output_value, :description 14 | else 15 | failed("Stack doesn't exist") 16 | end 17 | end 18 | 19 | private 20 | 21 | def stack 22 | @stack ||= Stack.find(@stack_definition.region, @stack_definition.stack_name) 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/stack_master/commands/resources.rb: -------------------------------------------------------------------------------- 1 | require 'table_print' 2 | 3 | module StackMaster 4 | module Commands 5 | class Resources 6 | include Command 7 | include Commander::UI 8 | 9 | def perform 10 | if stack_resources 11 | tp stack_resources, :logical_resource_id, :resource_type, :timestamp, :resource_status, :resource_status_reason, :description 12 | else 13 | failed("Stack doesn't exist") 14 | end 15 | end 16 | 17 | private 18 | 19 | def stack_resources 20 | @stack_resources ||= cf.describe_stack_resources({ stack_name: @stack_definition.stack_name }).stack_resources 21 | rescue Aws::CloudFormation::Errors::ValidationError 22 | nil 23 | end 24 | 25 | def cf 26 | @cf ||= StackMaster.cloud_formation_driver 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/stack_master/commands/status.rb: -------------------------------------------------------------------------------- 1 | require 'table_print' 2 | require 'ruby-progressbar' 3 | 4 | module StackMaster 5 | module Commands 6 | class Status 7 | include Command 8 | include StackMaster::Commands::TerminalHelper 9 | 10 | def initialize(config, options, show_progress = true) 11 | super(config, nil, options) 12 | @show_progress = show_progress 13 | end 14 | 15 | def perform 16 | progress if @show_progress 17 | status = @config.stacks.map do |stack_definition| 18 | stack_status = StackStatus.new(@config, stack_definition) 19 | allowed_accounts = stack_definition.allowed_accounts 20 | progress.increment if @show_progress 21 | { 22 | region: stack_definition.region, 23 | stack_name: stack_definition.stack_name, 24 | stack_status: running_in_allowed_account?(allowed_accounts) ? stack_status.status : "Disallowed account", 25 | different: running_in_allowed_account?(allowed_accounts) ? stack_status.changed_message : "N/A", 26 | } 27 | end 28 | tp.set :max_width, self.window_size 29 | tp.set :io, StackMaster.stdout 30 | tp status 31 | StackMaster.stdout.puts " * No echo parameters can't be diffed" 32 | end 33 | 34 | private 35 | 36 | def progress 37 | @progress ||= ProgressBar.create(title: "Fetching stack information", 38 | total: @config.stacks.size, 39 | output: StackMaster.stdout) 40 | end 41 | 42 | def sort_params(hash) 43 | hash.sort.to_h 44 | end 45 | 46 | def running_in_allowed_account?(allowed_accounts) 47 | StackMaster.skip_account_check? || identity.running_in_account?(allowed_accounts) 48 | end 49 | 50 | def identity 51 | @identity ||= StackMaster::Identity.new 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/stack_master/commands/terminal_helper.rb: -------------------------------------------------------------------------------- 1 | require 'os' 2 | 3 | module StackMaster 4 | module Commands 5 | module TerminalHelper 6 | def window_size 7 | size = ENV.fetch("COLUMNS") { OS.windows? ? windows_window_size : unix_window_size } 8 | 9 | if size.nil? || size == "" 10 | 80 11 | else 12 | size.to_i 13 | end 14 | end 15 | 16 | def unix_window_size 17 | `tput cols`.chomp 18 | end 19 | 20 | def windows_window_size 21 | columns_regex = %r{^\s+Columns:\s+([0-9]+)$} 22 | output = `mode con` 23 | columns_line = output.split("\n").select { |line| line.match(columns_regex) }.last 24 | columns_line.match(columns_regex)[1] 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/stack_master/commands/tidy.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | module Commands 3 | class Tidy 4 | include Command 5 | include StackMaster::Commands::TerminalHelper 6 | 7 | def perform 8 | used_templates = [] 9 | used_parameter_files = [] 10 | 11 | templates = Set.new(find_templates()) 12 | parameter_files = Set.new(find_parameter_files()) 13 | 14 | status = @config.stacks.each do |stack_definition| 15 | parameter_files.subtract(stack_definition.parameter_files_from_globs) 16 | template = File.absolute_path(stack_definition.template_file_path) 17 | 18 | if template 19 | templates.delete(template) 20 | 21 | if !File.exist?(template) 22 | StackMaster.stdout.puts "Stack \"#{stack_definition.stack_name}\" in \"#{stack_definition.region}\" missing template \"#{rel_path(template)}\"" 23 | end 24 | end 25 | end 26 | 27 | templates.each do |path| 28 | StackMaster.stdout.puts "#{rel_path(path)}: no stack found for this template" 29 | end 30 | 31 | parameter_files.each do |path| 32 | StackMaster.stdout.puts "#{rel_path(path)}: no stack found for this parameter file" 33 | end 34 | end 35 | 36 | def rel_path(path) 37 | Pathname.new(path).relative_path_from(Pathname.new(@config.base_dir)) 38 | end 39 | 40 | def find_templates 41 | # TODO: Inferring default template directory based on the behaviour in 42 | # stack_definition.rb. For some configurations (eg, per-region 43 | # template directories) this won't find the right directory. 44 | template_dir = @config.template_dir || File.join(@config.base_dir, 'templates') 45 | 46 | templates = Dir.glob(File.absolute_path(File.join(template_dir, '**', "*.{rb,yaml,yml,json}"))) 47 | dynamics_dir = File.join(template_dir, 'dynamics') 48 | 49 | # Exclude sparkleformation dynamics 50 | # TODO: Should this filter out anything with 'dynamics', not just the first 51 | # subdirectory? 52 | templates = templates.select do |path| 53 | !path.start_with?(dynamics_dir) 54 | end 55 | 56 | templates 57 | end 58 | 59 | def find_parameter_files 60 | Dir.glob(File.absolute_path(File.join(@config.base_dir, "parameters", "*.{yml,yaml}"))) 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/stack_master/commands/validate.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | module Commands 3 | class Validate 4 | include Command 5 | include Commander::UI 6 | 7 | def perform 8 | failed unless Validator.valid?(@stack_definition, @config, @options) 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/stack_master/ctrl_c.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | class CtrlC < Exception 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /lib/stack_master/diff.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | class Diff 3 | def initialize(name: nil, before:, after:, context: 10_000) 4 | @name = name 5 | @before = before 6 | @after = after 7 | @context = context 8 | end 9 | 10 | def display 11 | stdout.print "#{@name} diff: " 12 | if diff == '' 13 | stdout.puts "No changes" 14 | else 15 | stdout.puts 16 | display_colorized_diff 17 | end 18 | end 19 | 20 | def display_colorized_diff 21 | diff.each_line do |line| 22 | if line.start_with?('+') 23 | stdout.print colorize(line, :green) 24 | elsif line.start_with?('-') 25 | stdout.print colorize(line, :red) 26 | else 27 | stdout.print line 28 | end 29 | end 30 | end 31 | 32 | def different? 33 | diff != '' 34 | end 35 | 36 | private 37 | 38 | def diff 39 | @diff ||= Diffy::Diff.new(@before, @after, context: @context).to_s 40 | end 41 | 42 | extend Forwardable 43 | def_delegators :StackMaster, :colorize, :stdout 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/stack_master/identity.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | class Identity 3 | AllowedAccountAliasesError = Class.new(StandardError) 4 | MissingIamPermissionsError = Class.new(StandardError) 5 | 6 | def running_in_account?(accounts) 7 | return true if accounts.nil? || accounts.empty? || contains_account_id?(accounts) 8 | 9 | # skip alias check (which makes an API call) if all values are account IDs 10 | return false if accounts.all? { |account| account_id?(account) } 11 | 12 | contains_account_alias?(accounts) 13 | rescue MissingIamPermissionsError 14 | raise AllowedAccountAliasesError, 'Failed to validate whether the current AWS account is allowed' 15 | end 16 | 17 | def account 18 | @account ||= sts.get_caller_identity.account 19 | end 20 | 21 | def account_aliases 22 | @aliases ||= iam.list_account_aliases.account_aliases 23 | rescue Aws::IAM::Errors::AccessDenied 24 | raise MissingIamPermissionsError, 'Failed to retrieve account aliases. Missing required IAM permission: iam:ListAccountAliases' 25 | end 26 | 27 | private 28 | 29 | def region 30 | @region ||= ENV['AWS_REGION'] || Aws.config[:region] || Aws.shared_config.region || 'us-east-1' 31 | end 32 | 33 | def sts 34 | @sts ||= Aws::STS::Client.new({ region: region }) 35 | end 36 | 37 | def iam 38 | @iam ||= Aws::IAM::Client.new({ region: region }) 39 | end 40 | 41 | def contains_account_id?(ids) 42 | ids.include?(account) 43 | end 44 | 45 | def contains_account_alias?(aliases) 46 | account_aliases.any? { |account_alias| aliases.include?(account_alias) } 47 | end 48 | 49 | def account_id?(id_or_alias) 50 | # While it's not explicitly documented as prohibited, it cannot (currently) be possible to set an account alias of 51 | # 12 digits, as that could cause one console sign-in URL to resolve to two separate accounts. 52 | /^[0-9]{12}$/.match?(id_or_alias) 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/stack_master/paged_response_accumulator.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | class PagedResponseAccumulator 3 | def self.call(*args) 4 | new(*args).call 5 | end 6 | 7 | def initialize(cf, method, arguments, accumulator_method) 8 | @cf = cf 9 | @method = method 10 | @arguments = arguments 11 | @accumulator_method = accumulator_method 12 | end 13 | 14 | def call 15 | book = [] 16 | next_token = nil 17 | first_response = nil 18 | begin 19 | response = @cf.public_send(@method, @arguments.merge(next_token: next_token)) 20 | first_response = response if first_response.nil? 21 | next_token = response.next_token 22 | book += response.public_send(@accumulator_method) 23 | end while !next_token.nil? 24 | first_response.send("#{@accumulator_method}=", book.reverse) 25 | first_response.send(:next_token=, book.reverse) 26 | first_response 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/stack_master/parameter_loader.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/core_ext/object/deep_dup' 2 | 3 | module StackMaster 4 | class ParameterLoader 5 | 6 | COMPILE_TIME_PARAMETERS_KEY = 'compile_time_parameters' 7 | 8 | def self.load(parameter_files: [], parameters: {}) 9 | StackMaster.debug 'Searching for parameter files...' 10 | all_parameters = parameter_files.map { |file_name| load_parameters(file_name) } + [parameters] 11 | all_parameters.reduce({template_parameters: {}, compile_time_parameters: {}}) do |hash, parameters| 12 | template_parameters = create_template_parameters(parameters) 13 | compile_time_parameters = create_compile_time_parameters(parameters) 14 | 15 | merge_and_camelize(hash[:template_parameters], template_parameters) 16 | merge_and_camelize(hash[:compile_time_parameters], compile_time_parameters) 17 | hash 18 | end 19 | end 20 | 21 | private 22 | 23 | def self.load_parameters(file_name) 24 | file_exists = File.exist?(file_name) 25 | StackMaster.debug file_exists ? " #{file_name} found" : " #{file_name} not found" 26 | file_exists ? load_file(file_name) : {} 27 | end 28 | 29 | def self.load_file(file_name) 30 | YAML.load(File.read(file_name)) || {} 31 | end 32 | 33 | def self.create_template_parameters(parameters) 34 | parameters.deep_dup.tap do |parameters_clone| 35 | parameters_clone.delete(COMPILE_TIME_PARAMETERS_KEY) || parameters_clone.delete(COMPILE_TIME_PARAMETERS_KEY.camelize) 36 | end 37 | end 38 | 39 | def self.create_compile_time_parameters(parameters) 40 | (parameters[COMPILE_TIME_PARAMETERS_KEY] || parameters[COMPILE_TIME_PARAMETERS_KEY.camelize] || {}).deep_dup 41 | end 42 | 43 | def self.merge_and_camelize(hash, parameters) 44 | parameters.each { |key, value| hash[key.camelize] = value } 45 | end 46 | 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/stack_master/parameter_resolvers/acm_certificate.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | module ParameterResolvers 3 | class AcmCertificate < Resolver 4 | CertificateNotFound = Class.new(StandardError) 5 | 6 | def initialize(config, stack_definition) 7 | @config = config 8 | @stack_definition = stack_definition 9 | end 10 | 11 | def resolve(domain_name) 12 | cert_arn = find_cert_arn_by_domain_name(domain_name) 13 | raise CertificateNotFound, "Could not find certificate #{domain_name} in #{@stack_definition.region}" unless cert_arn 14 | cert_arn 15 | end 16 | 17 | private 18 | 19 | def all_certs 20 | certs = [] 21 | next_token = nil 22 | client = Aws::ACM::Client.new({ region: @stack_definition.region }) 23 | loop do 24 | resp = client.list_certificates({ certificate_statuses: ['ISSUED'], next_token: next_token }) 25 | certs << resp.certificate_summary_list 26 | next_token = resp.next_token 27 | break if next_token.nil? 28 | end 29 | certs.flatten 30 | end 31 | 32 | def find_cert_arn_by_domain_name(domain_name) 33 | all_certs.map { |c| c.certificate_arn if c.domain_name == domain_name }.compact.first 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/stack_master/parameter_resolvers/ami_finder.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | module ParameterResolvers 3 | class AmiFinder 4 | def initialize(region) 5 | @region = region 6 | end 7 | 8 | def build_filters_from_string(value, prefix = nil) 9 | filters = value.split(',').map do |name_with_value| 10 | name, value = name_with_value.strip.split('=') 11 | name = prefix ? "#{prefix}:#{name}" : name 12 | { name: name, values: [value] } 13 | end 14 | filters 15 | end 16 | 17 | def build_filters_from_hash(hash) 18 | hash.map { |key, value| {name: key, values: Array(value.to_s)}} 19 | end 20 | 21 | def find_latest_ami(filters, owners = ['self']) 22 | images = ec2.describe_images({ owners: owners, filters: filters }).images 23 | sorted_images = images.sort do |a, b| 24 | Time.parse(a.creation_date) <=> Time.parse(b.creation_date) 25 | end 26 | sorted_images.last 27 | end 28 | 29 | private 30 | 31 | def ec2 32 | @ec2 ||= Aws::EC2::Client.new({ region: @region }) 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/stack_master/parameter_resolvers/ejson.rb: -------------------------------------------------------------------------------- 1 | require 'ejson_wrapper' 2 | 3 | module StackMaster 4 | module ParameterResolvers 5 | class Ejson < Resolver 6 | SecretNotFound = Class.new(StandardError) 7 | 8 | def initialize(config, stack_definition) 9 | @config = config 10 | @stack_definition = stack_definition 11 | @decrypted_ejson_files = {} 12 | end 13 | 14 | def resolve(secret_key) 15 | validate_ejson_file_specified 16 | secrets = decrypt_ejson_file 17 | secrets.fetch(secret_key.to_sym) do 18 | raise SecretNotFound, "Unable to find key #{secret_key} in file #{@stack_definition.ejson_file}" 19 | end 20 | end 21 | 22 | private 23 | 24 | def validate_ejson_file_specified 25 | if @stack_definition.ejson_file.nil? 26 | raise ArgumentError, "No ejson_file defined for stack definition #{@stack_definition.stack_name} in #{@stack_definition.region}" 27 | end 28 | end 29 | 30 | def decrypt_ejson_file 31 | ejson_file_key = credentials_key 32 | @decrypted_ejson_files.fetch(ejson_file_key) do 33 | @decrypted_ejson_files[ejson_file_key] = EJSONWrapper.decrypt(ejson_file_path, 34 | use_kms: @stack_definition.ejson_file_kms, 35 | region: ejson_file_region) 36 | end 37 | end 38 | 39 | def ejson_file_region 40 | @stack_definition.ejson_file_region || StackMaster.cloud_formation_driver.region 41 | end 42 | 43 | def ejson_file_path 44 | @ejson_file_path ||= File.join(@config.base_dir, secret_path_relative_to_base) 45 | end 46 | 47 | def secret_path_relative_to_base 48 | @secret_path_relative_to_base ||= File.join('secrets', @stack_definition.ejson_file) 49 | end 50 | 51 | def credentials_key 52 | Aws.config[:credentials]&.object_id 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/stack_master/parameter_resolvers/env.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | module ParameterResolvers 3 | class Env < Resolver 4 | 5 | def initialize(config, stack_definition) 6 | @config = config 7 | @stack_definition = stack_definition 8 | end 9 | 10 | def resolve(value) 11 | environment_variable = ENV[value] 12 | raise ArgumentError, "The environment variable #{value} is not set" if environment_variable.nil? 13 | environment_variable 14 | end 15 | 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/stack_master/parameter_resolvers/latest_ami.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | module ParameterResolvers 3 | class LatestAmi < Resolver 4 | array_resolver class_name: 'LatestAmis' 5 | 6 | def initialize(config, stack_definition) 7 | @config = config 8 | @stack_definition = stack_definition 9 | end 10 | 11 | def resolve(value) 12 | owners = Array(value.fetch('owners', 'self').to_s) 13 | ami_finder = AmiFinder.new(@stack_definition.region) 14 | filters = ami_finder.build_filters_from_hash(value.fetch('filters')) 15 | ami_finder.find_latest_ami(filters, owners)&.image_id 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/stack_master/parameter_resolvers/latest_ami_by_tags.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | module ParameterResolvers 3 | class LatestAmiByTags < Resolver 4 | array_resolver class_name: 'LatestAmisByTags' 5 | 6 | def initialize(config, stack_definition) 7 | @config = config 8 | @stack_definition = stack_definition 9 | @ami_finder = AmiFinder.new(@stack_definition.region) 10 | end 11 | 12 | def resolve(value) 13 | filters = @ami_finder.build_filters_from_string(value, prefix = "tag") 14 | @ami_finder.find_latest_ami(filters)&.image_id 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/stack_master/parameter_resolvers/latest_container.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | module ParameterResolvers 3 | class LatestContainer < Resolver 4 | array_resolver class_name: 'LatestContainers' 5 | 6 | def initialize(config, stack_definition) 7 | @config = config 8 | @stack_definition = stack_definition 9 | end 10 | 11 | def resolve(parameters) 12 | if parameters['repository_name'].nil? 13 | raise ArgumentError, "repository_name parameter is required but was not supplied" 14 | end 15 | 16 | @region = parameters['region'] || @stack_definition.region 17 | ecr_client = Aws::ECR::Client.new({ region: @region }) 18 | 19 | images = fetch_images(parameters['repository_name'], parameters['registry_id'], ecr_client) 20 | 21 | unless parameters['tag'].nil? 22 | images.select! { |image| image.image_tags.any? { |tag| tag == parameters['tag'] } } 23 | end 24 | images.sort! { |image_x, image_y| image_y.image_pushed_at <=> image_x.image_pushed_at } 25 | 26 | return nil if images.empty? 27 | 28 | latest_image = images.first 29 | 30 | # aws_account_id.dkr.ecr.region.amazonaws.com/repository@sha256:digest 31 | "#{latest_image.registry_id}.dkr.ecr.#{@region}.amazonaws.com/#{parameters['repository_name']}@#{latest_image.image_digest}" 32 | end 33 | 34 | private 35 | 36 | def fetch_images(repository_name, registry_id, ecr) 37 | images = [] 38 | next_token = nil 39 | while 40 | resp = ecr.describe_images({ 41 | repository_name: repository_name, 42 | registry_id: registry_id, 43 | next_token: next_token, 44 | filter: { 45 | tag_status: "TAGGED", 46 | }, 47 | }) 48 | 49 | images += resp.image_details 50 | next_token = resp.next_token 51 | break if next_token.nil? 52 | end 53 | images 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/stack_master/parameter_resolvers/parameter_store.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | module ParameterResolvers 3 | class ParameterStore < Resolver 4 | 5 | ParameterNotFound = Class.new(StandardError) 6 | 7 | def initialize(config, stack_definition) 8 | @config = config 9 | @stack_definition = stack_definition 10 | end 11 | 12 | def resolve(value) 13 | begin 14 | ssm = Aws::SSM::Client.new({ region: @stack_definition.region }) 15 | resp = ssm.get_parameter({ 16 | name: value, 17 | with_decryption: true 18 | }) 19 | rescue Aws::SSM::Errors::ParameterNotFound 20 | raise ParameterNotFound, "Unable to find #{value} in Parameter Store" 21 | end 22 | resp.parameter.value 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/stack_master/parameter_resolvers/security_group.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | module ParameterResolvers 3 | class SecurityGroup < Resolver 4 | array_resolver 5 | 6 | def initialize(config, stack_definition) 7 | @config = config 8 | @stack_definition = stack_definition 9 | end 10 | 11 | def resolve(value) 12 | security_group_finder.find(value) 13 | end 14 | 15 | private 16 | 17 | def security_group_finder 18 | StackMaster::SecurityGroupFinder.new(@stack_definition.region) 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/stack_master/parameter_resolvers/sns_topic_name.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | module ParameterResolvers 3 | class SnsTopicName < Resolver 4 | TopicNotFound = Class.new(StandardError) 5 | 6 | array_resolver 7 | 8 | def initialize(config, stack_definition) 9 | @config = config 10 | @stack_definition = stack_definition 11 | end 12 | 13 | def resolve(value) 14 | sns_topic_finder.find(value) 15 | rescue StackMaster::SnsTopicFinder::TopicNotFound => e 16 | raise TopicNotFound.new(e.message) 17 | end 18 | 19 | private 20 | 21 | def sns_topic_finder 22 | StackMaster::SnsTopicFinder.new(@stack_definition.region) 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/stack_master/parameter_resolvers/stack_output.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | module ParameterResolvers 3 | class StackOutput < Resolver 4 | StackNotFound = Class.new(StandardError) 5 | StackOutputNotFound = Class.new(StandardError) 6 | 7 | array_resolver 8 | 9 | def initialize(config, stack_definition) 10 | @config = config 11 | @stack_definition = stack_definition 12 | @stacks = {} 13 | @cf_drivers = {} 14 | @output_regex = %r{(?:(?[^:]+):)?(?[^:/]+)/(?.+)} 15 | end 16 | 17 | def resolve(value) 18 | region, stack_name, output_name = parse!(value) 19 | stack = find_stack(stack_name, region) 20 | if stack 21 | output = stack.outputs.find { |stack_output| stack_output.output_key == output_name.camelize || stack_output.output_key == output_name } 22 | if output 23 | output.output_value 24 | else 25 | raise StackOutputNotFound, "Stack exists (#{stack_name}), but output does not: #{output_name}" 26 | end 27 | else 28 | raise StackNotFound, "Stack in StackOutput not found: #{value}" 29 | end 30 | end 31 | 32 | private 33 | 34 | def cf 35 | StackMaster.cloud_formation_driver 36 | end 37 | 38 | def parse!(value) 39 | if !value.is_a?(String) || !(match = @output_regex.match(value)) 40 | raise ArgumentError, 'Stack output values must be in the form of [region:]stack-name/output_name' 41 | end 42 | 43 | [ 44 | match[:region] || cf.region, 45 | match[:stack_name], 46 | match[:output_name] 47 | ] 48 | end 49 | 50 | def find_stack(stack_name, region) 51 | unaliased_region = @config.unalias_region(region) 52 | stack_key = "#{unaliased_region}:#{stack_name}:#{credentials_key}" 53 | 54 | @stacks.fetch(stack_key) do 55 | regional_cf = cf_for_region(unaliased_region) 56 | cf_stack = regional_cf.describe_stacks({ stack_name: stack_name }).stacks.first 57 | @stacks[stack_key] = cf_stack 58 | end 59 | end 60 | 61 | def cf_for_region(region) 62 | driver_key = "#{region}:#{credentials_key}" 63 | 64 | @cf_drivers.fetch(driver_key) do 65 | cloud_formation_driver = cf.class.new 66 | cloud_formation_driver.set_region(region) 67 | @cf_drivers[driver_key] = cloud_formation_driver 68 | end 69 | end 70 | 71 | def credentials_key 72 | Aws.config[:credentials]&.object_id 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/stack_master/parameter_validator.rb: -------------------------------------------------------------------------------- 1 | require 'pathname' 2 | 3 | module StackMaster 4 | class ParameterValidator 5 | def initialize(stack:, stack_definition:) 6 | @stack = stack 7 | @stack_definition = stack_definition 8 | end 9 | 10 | def error_message 11 | return nil unless missing_parameters? 12 | message = "Empty/blank parameters detected. Please provide values for these parameters:\n" 13 | missing_parameters.each do |parameter_name| 14 | message << " - #{parameter_name}\n" 15 | end 16 | if @stack_definition.parameter_files.empty? 17 | message << message_for_parameter_globs 18 | else 19 | message << message_for_parameter_files 20 | end 21 | message 22 | end 23 | 24 | def missing_parameters? 25 | missing_parameters.any? 26 | end 27 | 28 | private 29 | 30 | def message_for_parameter_files 31 | "Parameters are configured to be read from the following files:\n".tap do |message| 32 | @stack_definition.parameter_files.each do |parameter_file| 33 | message << " - #{parameter_file}\n" 34 | end 35 | end 36 | end 37 | 38 | def message_for_parameter_globs 39 | "Parameters will be read from files matching the following globs:\n".tap do |message| 40 | base_dir = Pathname.new(@stack_definition.base_dir) 41 | @stack_definition.parameter_file_globs.each do |glob| 42 | parameter_file = Pathname.new(glob).relative_path_from(base_dir) 43 | message << " - #{parameter_file}\n" 44 | end 45 | end 46 | end 47 | 48 | def missing_parameters 49 | @missing_parameters ||= 50 | @stack.parameters_with_defaults.select { |_key, value| value.nil? }.keys 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/stack_master/prompter.rb: -------------------------------------------------------------------------------- 1 | require 'io/console' 2 | 3 | module StackMaster 4 | module Prompter 5 | def ask?(question) 6 | StackMaster.stdout.print question 7 | answer = if StackMaster.interactive? 8 | if StackMaster.stdin.tty? && StackMaster.stdout.tty? 9 | StackMaster.stdin.getch.chomp 10 | else 11 | StackMaster.stdout.puts 12 | StackMaster.stdout.puts "STDOUT or STDIN was not a TTY. Defaulting to no. To force yes use -y" 13 | 'n' 14 | end 15 | else 16 | print StackMaster.non_interactive_answer 17 | StackMaster.non_interactive_answer 18 | end 19 | StackMaster.stdout.puts 20 | answer == 'y' 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/stack_master/resolver_array.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | module ParameterResolvers 3 | class ResolverArray 4 | def initialize(config, stack_definition) 5 | @config = config 6 | @stack_definition = stack_definition 7 | end 8 | 9 | def resolve(values) 10 | Array(values).map do |value| 11 | resolver_class.new(@config, @stack_definition).resolve(value) 12 | end.join(',') 13 | end 14 | 15 | def resolver_class 16 | fail "Method resolver_class not implemented on #{self.class}" 17 | end 18 | end 19 | 20 | class Resolver 21 | def self.array_resolver(options = {}) 22 | resolver_class ||= Object.const_get(self.name) 23 | array_resolver_class_name = options[:class_name] || resolver_class.to_s.demodulize.pluralize 24 | 25 | klass = Class.new(ResolverArray) do 26 | define_method('resolver_class') do 27 | resolver_class 28 | end 29 | end 30 | StackMaster::ParameterResolvers.const_set("#{array_resolver_class_name}", klass) 31 | end 32 | end 33 | end 34 | end 35 | 36 | -------------------------------------------------------------------------------- /lib/stack_master/role_assumer.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/core_ext/object/deep_dup' 2 | 3 | module StackMaster 4 | class RoleAssumer 5 | BlockNotSpecified = Class.new(StandardError) 6 | 7 | def initialize 8 | @credentials = {} 9 | end 10 | 11 | def assume_role(account, role, &block) 12 | raise BlockNotSpecified unless block_given? 13 | raise ArgumentError, "Both 'account' and 'role' are required to assume a role" if account.nil? || role.nil? 14 | 15 | role_credentials = assume_role_credentials(account, role) 16 | with_temporary_credentials(role_credentials) do 17 | with_temporary_cf_driver do 18 | block.call 19 | end 20 | end 21 | end 22 | 23 | private 24 | 25 | def with_temporary_credentials(credentials, &block) 26 | original_aws_config = Aws.config 27 | Aws.config = original_aws_config.deep_dup 28 | Aws.config[:credentials] = credentials 29 | block.call 30 | ensure 31 | Aws.config = original_aws_config 32 | end 33 | 34 | def with_temporary_cf_driver(&block) 35 | original_driver = StackMaster.cloud_formation_driver 36 | new_driver = original_driver.class.new 37 | new_driver.set_region(original_driver.region) 38 | StackMaster.cloud_formation_driver = new_driver 39 | block.call 40 | ensure 41 | StackMaster.cloud_formation_driver = original_driver 42 | end 43 | 44 | def assume_role_credentials(account, role) 45 | credentials_key = "#{account}:#{role}" 46 | @credentials.fetch(credentials_key) do 47 | @credentials[credentials_key] = Aws::AssumeRoleCredentials.new({ 48 | region: StackMaster.cloud_formation_driver.region, 49 | role_arn: "arn:aws:iam::#{account}:role/#{role}", 50 | role_session_name: "stack-master-role-assumer" 51 | }) 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/stack_master/security_group_finder.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | class SecurityGroupFinder 3 | SecurityGroupNotFound = Class.new(StandardError) 4 | MultipleSecurityGroupsFound = Class.new(StandardError) 5 | 6 | def initialize(region) 7 | @resource = Aws::EC2::Resource.new({ region: region }) 8 | end 9 | 10 | def find(reference) 11 | raise ArgumentError, 'Security group references must be non-empty strings' unless reference.is_a?(String) && !reference.empty? 12 | 13 | groups = @resource.security_groups({ 14 | filters: [ 15 | { 16 | name: "group-name", 17 | values: [reference], 18 | }, 19 | ], 20 | }) 21 | 22 | raise SecurityGroupNotFound, "No security group with name #{reference} found" unless groups.any? 23 | raise MultipleSecurityGroupsFound, "More than one security group with name #{reference} found" if groups.count > 1 24 | 25 | groups.first.id 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/stack_master/sns_topic_finder.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | class SnsTopicFinder 3 | TopicNotFound = Class.new(StandardError) 4 | 5 | def initialize(region) 6 | @resource = Aws::SNS::Resource.new({ region: region }) 7 | end 8 | 9 | def find(reference) 10 | raise ArgumentError, 'SNS topic references must be non-empty strings' unless reference.is_a?(String) && !reference.empty? 11 | 12 | topic = @resource.topics.detect { |t| topic_name_from_arn(t.arn) == reference } 13 | 14 | raise TopicNotFound, "No topic with name #{reference} found" unless topic 15 | 16 | topic.arn 17 | end 18 | 19 | private 20 | 21 | def topic_name_from_arn(arn) 22 | arn.split(":")[5] 23 | end 24 | 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/stack_master/sparkle_formation/compile_time/allowed_pattern_validator.rb: -------------------------------------------------------------------------------- 1 | require_relative 'value_validator' 2 | 3 | module StackMaster 4 | module SparkleFormation 5 | module CompileTime 6 | class AllowedPatternValidator < ValueValidator 7 | 8 | KEY = :allowed_pattern 9 | 10 | def initialize(name, definition, parameter) 11 | @name = name 12 | @definition = definition 13 | @parameter = parameter 14 | end 15 | 16 | private 17 | 18 | def check_is_valid 19 | return true unless @definition.key?(KEY) 20 | invalid_values.empty? 21 | end 22 | 23 | def invalid_values 24 | values = build_values(@definition, @parameter) 25 | values.reject { |value| value.to_s.match(%r{#{@definition[KEY]}}) } 26 | end 27 | 28 | def create_error 29 | "#{@name}:#{invalid_values} does not match #{KEY}:#{@definition[KEY]}" 30 | end 31 | 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/stack_master/sparkle_formation/compile_time/allowed_values_validator.rb: -------------------------------------------------------------------------------- 1 | require_relative 'value_validator' 2 | 3 | module StackMaster 4 | module SparkleFormation 5 | module CompileTime 6 | class AllowedValuesValidator < ValueValidator 7 | 8 | KEY = :allowed_values 9 | 10 | def initialize(name, definition, parameter) 11 | @name = name 12 | @definition = definition 13 | @parameter = parameter 14 | end 15 | 16 | private 17 | 18 | def check_is_valid 19 | return true unless @definition.key?(KEY) 20 | invalid_values.empty? 21 | end 22 | 23 | def invalid_values 24 | values = build_values(@definition, @parameter) 25 | values.reject do |value| 26 | @definition[KEY].any? { |allowed_value| allowed_value.to_s == value.to_s} 27 | end 28 | end 29 | 30 | def create_error 31 | "#{@name}:#{invalid_values} is not in #{KEY}:#{@definition[KEY]}" 32 | end 33 | 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/stack_master/sparkle_formation/compile_time/definitions_validator.rb: -------------------------------------------------------------------------------- 1 | require_relative 'value_validator' 2 | 3 | module StackMaster 4 | module SparkleFormation 5 | module CompileTime 6 | class DefinitionsValidator 7 | 8 | VALID_TYPES = [:string, :number] 9 | def initialize(definitions) 10 | @definitions = definitions 11 | end 12 | 13 | def validate 14 | @definitions.each do|name, definition| 15 | type = definition[:type] 16 | raise ArgumentError.new "Unknown compile time parameter type: #{create_error(name, type)}" unless is_valid(type) 17 | end 18 | end 19 | 20 | private 21 | 22 | def is_valid(type) 23 | VALID_TYPES.include? type 24 | end 25 | 26 | def create_error(name, type) 27 | "#{name}:#{type} valid types are #{VALID_TYPES}" 28 | end 29 | 30 | end 31 | end 32 | end 33 | end -------------------------------------------------------------------------------- /lib/stack_master/sparkle_formation/compile_time/empty_validator.rb: -------------------------------------------------------------------------------- 1 | require_relative 'value_validator' 2 | 3 | module StackMaster 4 | module SparkleFormation 5 | module CompileTime 6 | class EmptyValidator < ValueValidator 7 | 8 | def initialize(name, definition, parameter) 9 | @name = name 10 | @definition = definition 11 | @parameter = parameter 12 | end 13 | 14 | private 15 | 16 | def check_is_valid 17 | !has_invalid_values? 18 | end 19 | 20 | def has_invalid_values? 21 | values = build_values(@definition, @parameter) 22 | values.include?(nil) 23 | end 24 | 25 | def create_error 26 | "#{@name} cannot contain empty parameters:#{@parameter.inspect}" 27 | end 28 | 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/stack_master/sparkle_formation/compile_time/max_length_validator.rb: -------------------------------------------------------------------------------- 1 | require_relative 'value_validator' 2 | 3 | module StackMaster 4 | module SparkleFormation 5 | module CompileTime 6 | class MaxLengthValidator < ValueValidator 7 | 8 | KEY = :max_length 9 | 10 | def initialize(name, definition, parameter) 11 | @name = name 12 | @definition = definition 13 | @parameter = parameter 14 | end 15 | 16 | private 17 | 18 | def check_is_valid 19 | return true unless @definition[:type] == :string 20 | return true unless @definition.key?(KEY) 21 | invalid_values.empty? 22 | end 23 | 24 | def invalid_values 25 | values = build_values(@definition, @parameter) 26 | values.select { |values| values.length > @definition[KEY].to_i } 27 | end 28 | 29 | def create_error 30 | "#{@name}:#{invalid_values} must not exceed #{KEY}:#{@definition[KEY]} characters" 31 | end 32 | 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/stack_master/sparkle_formation/compile_time/max_size_validator.rb: -------------------------------------------------------------------------------- 1 | require_relative 'value_validator' 2 | 3 | module StackMaster 4 | module SparkleFormation 5 | module CompileTime 6 | class MaxSizeValidator < ValueValidator 7 | 8 | KEY = :max_size 9 | 10 | def initialize(name, definition, parameter) 11 | @name = name 12 | @definition = definition 13 | @parameter = parameter 14 | end 15 | 16 | private 17 | 18 | def check_is_valid 19 | return true unless @definition[:type] == :number 20 | return true unless @definition.key?(KEY) 21 | invalid_values.empty? 22 | end 23 | 24 | def invalid_values 25 | values = build_values(@definition, @parameter) 26 | values.select { |value| value.to_f > @definition[KEY].to_f } 27 | end 28 | 29 | def create_error 30 | "#{@name}:#{invalid_values} must not be greater than #{KEY}:#{@definition[KEY]}" 31 | end 32 | 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/stack_master/sparkle_formation/compile_time/min_length_validator.rb: -------------------------------------------------------------------------------- 1 | require_relative 'value_validator' 2 | 3 | module StackMaster 4 | module SparkleFormation 5 | module CompileTime 6 | class MinLengthValidator < ValueValidator 7 | 8 | KEY = :min_length 9 | 10 | def initialize(name, definition, parameter) 11 | @name = name 12 | @definition = definition 13 | @parameter = parameter 14 | end 15 | 16 | private 17 | 18 | def check_is_valid 19 | return true unless @definition[:type] == :string 20 | return true unless @definition.key?(KEY) 21 | invalid_values.empty? 22 | end 23 | 24 | def invalid_values 25 | values = build_values(@definition, @parameter) 26 | values.select { |value| value.length < @definition[KEY].to_i} 27 | end 28 | 29 | def create_error 30 | "#{@name}:#{invalid_values} must be at least #{KEY}:#{@definition[KEY]} characters" 31 | end 32 | 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/stack_master/sparkle_formation/compile_time/min_size_validator.rb: -------------------------------------------------------------------------------- 1 | require_relative 'value_validator' 2 | 3 | module StackMaster 4 | module SparkleFormation 5 | module CompileTime 6 | class MinSizeValidator < ValueValidator 7 | 8 | KEY = :min_size 9 | 10 | def initialize(name, definition, parameter) 11 | @name = name 12 | @definition = definition 13 | @parameter = parameter 14 | end 15 | 16 | private 17 | 18 | def check_is_valid 19 | return true unless @definition[:type] == :number 20 | return true unless @definition.key?(KEY) 21 | invalid_values.empty? 22 | end 23 | 24 | def invalid_values 25 | values = build_values(@definition, @parameter) 26 | values.select { |value| value.to_f < @definition[KEY].to_f} 27 | end 28 | 29 | def create_error 30 | "#{@name}:#{invalid_values} must not be lesser than #{KEY}:#{@definition[KEY]}" 31 | end 32 | 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/stack_master/sparkle_formation/compile_time/number_validator.rb: -------------------------------------------------------------------------------- 1 | require_relative 'value_validator' 2 | 3 | module StackMaster 4 | module SparkleFormation 5 | module CompileTime 6 | class NumberValidator < ValueValidator 7 | 8 | def initialize(name, definition, parameter) 9 | @name = name 10 | @definition = definition 11 | @parameter = parameter 12 | end 13 | 14 | private 15 | 16 | def check_is_valid 17 | return true unless @definition[:type] == :number 18 | invalid_values.empty? 19 | end 20 | 21 | def invalid_values 22 | values = build_values(@definition, @parameter) 23 | values.reject do |value| 24 | value.is_a?(Numeric) || value.is_a?(String) && value.to_s =~ /^(\d+)(\.?(\d+))?$/ 25 | end 26 | end 27 | 28 | def create_error 29 | "#{@name}:#{invalid_values} are not Numbers" 30 | end 31 | 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/stack_master/sparkle_formation/compile_time/parameters_validator.rb: -------------------------------------------------------------------------------- 1 | require_relative 'value_validator_factory' 2 | 3 | module StackMaster 4 | module SparkleFormation 5 | module CompileTime 6 | class ParametersValidator 7 | 8 | def initialize(definitions, parameters) 9 | @definitions = definitions 10 | @parameters = parameters 11 | end 12 | 13 | def validate 14 | @definitions.each do |name, definition| 15 | parameter = @parameters[name.to_s.camelize] 16 | factory = ValueValidatorFactory.new(name, definition, parameter) 17 | value_validators = factory.build 18 | value_validators.each do |validator| 19 | validator.validate 20 | raise ArgumentError.new "Invalid compile time parameter: #{validator.error}" unless validator.is_valid 21 | end 22 | end 23 | end 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/stack_master/sparkle_formation/compile_time/state_builder.rb: -------------------------------------------------------------------------------- 1 | require_relative 'value_builder' 2 | 3 | module StackMaster 4 | module SparkleFormation 5 | module CompileTime 6 | class StateBuilder 7 | 8 | def initialize(definitions, parameters) 9 | @definitions = definitions 10 | @parameters = parameters 11 | end 12 | 13 | def build 14 | state = {} 15 | @definitions.each do |name, definition| 16 | parameter_key = name.to_s.camelize 17 | parameter = @parameters[parameter_key] 18 | state[name] = create_value(definition, parameter) 19 | end 20 | state 21 | end 22 | 23 | private 24 | 25 | def create_value(definition, parameter) 26 | ValueBuilder.new(definition, parameter).build 27 | end 28 | 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/stack_master/sparkle_formation/compile_time/string_validator.rb: -------------------------------------------------------------------------------- 1 | require_relative 'value_validator' 2 | 3 | module StackMaster 4 | module SparkleFormation 5 | module CompileTime 6 | class StringValidator < ValueValidator 7 | 8 | def initialize(name, definition, parameter) 9 | @name = name 10 | @definition = definition 11 | @parameter = parameter 12 | end 13 | 14 | private 15 | 16 | def check_is_valid 17 | return true unless @definition[:type] == :string 18 | invalid_values.empty? 19 | end 20 | 21 | def invalid_values 22 | values = build_values(@definition, @parameter) 23 | values.reject { |value| value.is_a?(String)} 24 | end 25 | 26 | def create_error 27 | "#{@name}:#{invalid_values} are not Strings" 28 | end 29 | 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/stack_master/sparkle_formation/compile_time/value_builder.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | module SparkleFormation 3 | module CompileTime 4 | class ValueBuilder 5 | 6 | def initialize(definition, parameter) 7 | @definition = definition 8 | @parameter = parameter 9 | end 10 | 11 | def build 12 | parameter_or_default 13 | convert_strings_to_array 14 | convert_strings_to_numbers 15 | @value 16 | end 17 | 18 | private 19 | 20 | def parameter_or_default 21 | @value = @parameter.nil? ? @definition[:default] : @parameter 22 | end 23 | 24 | def convert_strings_to_array 25 | if @definition[:multiple] && @value.is_a?(String) 26 | @value = @value.split(',').map(&:strip) 27 | end 28 | end 29 | 30 | def convert_strings_to_numbers 31 | if @definition[:type] == :number 32 | @value = @value.to_f if @value.is_a?(String) 33 | @value = @value.map { |item| item.is_a?(String) ? item.to_f : item } if @value.is_a?(Array) 34 | end 35 | end 36 | 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/stack_master/sparkle_formation/compile_time/value_validator.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | module SparkleFormation 3 | module CompileTime 4 | class ValueValidator 5 | 6 | attr_reader :is_valid, :error 7 | 8 | def validate 9 | @is_valid = check_is_valid 10 | @error = create_error unless @is_valid 11 | end 12 | 13 | protected 14 | 15 | def check_is_valid 16 | raise NotImplementedError 17 | end 18 | 19 | def create_error 20 | raise NotImplementedError 21 | end 22 | 23 | def build_values(definition, parameter) 24 | parameter_or_default = parameter.nil? ? definition[:default] : parameter 25 | convert_to_array(definition, parameter_or_default) 26 | end 27 | 28 | private 29 | 30 | def convert_to_array(definition, parameter) 31 | if definition[:multiple] && parameter.is_a?(String) 32 | return parameter.split(',').map(&:strip) 33 | end 34 | parameter.is_a?(Array) ? parameter : [parameter] 35 | end 36 | 37 | end 38 | end 39 | end 40 | end -------------------------------------------------------------------------------- /lib/stack_master/sparkle_formation/compile_time/value_validator_factory.rb: -------------------------------------------------------------------------------- 1 | require_relative 'empty_validator' 2 | require_relative 'string_validator' 3 | require_relative 'number_validator' 4 | require_relative 'allowed_values_validator' 5 | require_relative 'allowed_pattern_validator' 6 | require_relative 'max_length_validator' 7 | require_relative 'min_length_validator' 8 | require_relative 'max_size_validator' 9 | require_relative 'min_size_validator' 10 | 11 | module StackMaster 12 | module SparkleFormation 13 | module CompileTime 14 | class ValueValidatorFactory 15 | 16 | VALIDATORS_TYPES = [ 17 | EmptyValidator, 18 | StringValidator, 19 | NumberValidator, 20 | AllowedValuesValidator, 21 | AllowedPatternValidator, 22 | MaxLengthValidator, 23 | MinLengthValidator, 24 | MaxSizeValidator, 25 | MinSizeValidator 26 | ] 27 | 28 | def initialize(name, definition, parameter) 29 | @name = name 30 | @definition = definition 31 | @parameter = parameter 32 | end 33 | 34 | def build 35 | VALIDATORS_TYPES.map { |validator| validator.new(@name, @definition, @parameter)} 36 | end 37 | 38 | end 39 | end 40 | end 41 | end -------------------------------------------------------------------------------- /lib/stack_master/sparkle_formation/template_file.rb: -------------------------------------------------------------------------------- 1 | require 'sparkle_formation' 2 | require 'erubis' 3 | 4 | module StackMaster 5 | module SparkleFormation 6 | TemplateFileNotFound = ::Class.new(StandardError) 7 | 8 | class TemplateContext < AttributeStruct 9 | include ::SparkleFormation::SparkleAttribute 10 | include ::SparkleFormation::SparkleAttribute::Aws 11 | include ::SparkleFormation::Utils::TypeCheckers 12 | 13 | def self.build(vars, prefix) 14 | ::Class.new(self).tap do |klass| 15 | vars.each do |key, value| 16 | klass.send(:define_method, key) do 17 | value 18 | end 19 | end 20 | 21 | end.new(vars, prefix) 22 | end 23 | 24 | def initialize(vars, prefix) 25 | self._camel_keys = true 26 | @vars = vars 27 | @prefix = prefix 28 | end 29 | 30 | def has_var?(var_key) 31 | @vars.include?(var_key) 32 | end 33 | 34 | def render(file_name, vars = {}) 35 | Template.render(@prefix, file_name, vars) 36 | end 37 | end 38 | 39 | module Template 40 | def self.render(prefix, file_name, vars) 41 | file_path = File.join(::SparkleFormation.sparkle_path, prefix, file_name) 42 | template_context = TemplateContext.build(vars, prefix) 43 | CloudFormationInterpolatingEruby.evaluate_file(file_path, template_context) 44 | rescue Errno::ENOENT 45 | Kernel.raise TemplateFileNotFound, "Could not find template file at path: #{file_path}" 46 | end 47 | end 48 | 49 | module JoinedFile 50 | def _joined_file(file_name, vars = {}) 51 | join!(Template.render('joined_file', file_name, vars)) 52 | end 53 | alias_method :joined_file!, :_joined_file 54 | end 55 | 56 | module UserDataFile 57 | def _user_data_file(file_name, vars = {}) 58 | base64!(join!(Template.render('user_data', file_name, vars))) 59 | end 60 | alias_method :user_data_file!, :_user_data_file 61 | end 62 | end 63 | end 64 | 65 | SparkleFormation::SparkleAttribute::Aws.send(:include, StackMaster::SparkleFormation::UserDataFile) 66 | SparkleFormation::SparkleAttribute::Aws.send(:include, StackMaster::SparkleFormation::JoinedFile) 67 | 68 | -------------------------------------------------------------------------------- /lib/stack_master/stack_events/fetcher.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | module StackEvents 3 | class Fetcher 4 | def self.fetch(stack_name, region, **args) 5 | new(stack_name, region, **args).fetch 6 | end 7 | 8 | def initialize(stack_name, region, from: nil) 9 | @stack_name = stack_name 10 | @region = region 11 | @from = from 12 | end 13 | 14 | def fetch 15 | events = fetch_events 16 | if @from 17 | filter_old_events(events) 18 | else 19 | events 20 | end 21 | end 22 | 23 | private 24 | 25 | def cf 26 | @cf ||= StackMaster.cloud_formation_driver 27 | end 28 | 29 | def filter_old_events(events) 30 | events.select { |event| event.timestamp > @from } 31 | end 32 | 33 | def fetch_events 34 | PagedResponseAccumulator.call(cf, :describe_stack_events, { stack_name: @stack_name }, :stack_events).stack_events 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/stack_master/stack_events/presenter.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | module StackEvents 3 | class Presenter 4 | def self.print_event(io, event) 5 | new(io).print_event(event) 6 | end 7 | 8 | def initialize(io) 9 | @io = io 10 | end 11 | 12 | def print_event(event) 13 | @io.puts Rainbow("#{event.timestamp.localtime} #{event.logical_resource_id} #{event.resource_type} #{event.resource_status} #{event.resource_status_reason}").color(event_colour(event)) 14 | end 15 | 16 | def event_colour(event) 17 | if StackStates.failure_state?(event.resource_status) 18 | :red 19 | elsif StackStates.success_state?(event.resource_status) 20 | :green 21 | else 22 | :yellow 23 | end 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/stack_master/stack_events/streamer.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | module StackEvents 3 | class Streamer 4 | StackFailed = Class.new(StandardError) 5 | 6 | def self.stream(stack_name, region, **args, &block) 7 | new(stack_name, region, **args, &block).stream 8 | end 9 | 10 | def initialize(stack_name, region, from: Time.now, break_on_finish_state: true, sleep_between_fetches: 1, io: nil, &block) 11 | @stack_name = stack_name 12 | @region = region 13 | @block = block 14 | @seen_events = Set.new 15 | @from = from 16 | @break_on_finish_state = break_on_finish_state 17 | @sleep_between_fetches = sleep_between_fetches 18 | @io = io 19 | end 20 | 21 | def stream 22 | catch(:halt) do 23 | loop do 24 | events = Fetcher.fetch(@stack_name, @region, from: @from) 25 | unseen_events(events).each do |event| 26 | @block.call(event) if @block 27 | Presenter.print_event(@io, event) if @io 28 | if @break_on_finish_state && finish_state?(event) 29 | exit_with_error(event) if failure_state?(event) 30 | throw :halt 31 | end 32 | end 33 | sleep @sleep_between_fetches 34 | end 35 | end 36 | rescue Interrupt 37 | end 38 | 39 | private 40 | 41 | def unseen_events(events) 42 | [].tap do |unseen_events| 43 | events.each do |event| 44 | next if @seen_events.include?(event.event_id) 45 | @seen_events << event.event_id 46 | unseen_events << event 47 | end 48 | end 49 | end 50 | 51 | def finish_state?(event) 52 | StackStates.finish_state?(event.resource_status) && 53 | event.resource_type == 'AWS::CloudFormation::Stack' && 54 | event.logical_resource_id == @stack_name 55 | end 56 | 57 | def failure_state?(event) 58 | StackStates.failure_state?(event.resource_status) && 59 | event.resource_type == 'AWS::CloudFormation::Stack' && 60 | event.logical_resource_id == @stack_name 61 | end 62 | 63 | def exit_with_error(event) 64 | raise StackFailed, "#{event.logical_resource_id} did not succeed (last state was #{event.resource_status})" 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/stack_master/stack_states.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | module StackStates 3 | SUCCESS_STATES = %w[ 4 | CREATE_COMPLETE 5 | UPDATE_COMPLETE 6 | DELETE_COMPLETE 7 | ].freeze 8 | FAILURE_STATES = %w[ 9 | CREATE_FAILED 10 | DELETE_FAILED 11 | UPDATE_ROLLBACK_FAILED 12 | ROLLBACK_FAILED 13 | ROLLBACK_COMPLETE 14 | ROLLBACK_FAILED 15 | UPDATE_ROLLBACK_COMPLETE 16 | UPDATE_ROLLBACK_FAILED 17 | ].freeze 18 | FINISH_STATES = (SUCCESS_STATES + FAILURE_STATES).freeze 19 | 20 | extend self 21 | 22 | def finish_state?(state) 23 | FINISH_STATES.include?(state) 24 | end 25 | 26 | def failure_state?(state) 27 | FAILURE_STATES.include?(state) 28 | end 29 | 30 | def success_state?(state) 31 | SUCCESS_STATES.include?(state) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/stack_master/stack_status.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | class StackStatus 3 | def initialize(config, stack_definition) 4 | @config = config 5 | @stack_definition = stack_definition 6 | end 7 | 8 | def changed_message 9 | if changed? 10 | 'Yes' 11 | elsif no_echo_params? 12 | 'No *' 13 | else 14 | 'No' 15 | end 16 | end 17 | 18 | def changed? 19 | stack.nil? || body_changed? || parameters_changed? 20 | end 21 | 22 | def status 23 | stack ? stack.stack_status : nil 24 | end 25 | 26 | def body_changed? 27 | stack.nil? || differ.body_different? 28 | end 29 | 30 | def parameters_changed? 31 | stack.nil? || differ.params_different? 32 | end 33 | 34 | def no_echo_params? 35 | !differ.noecho_keys.empty? 36 | end 37 | 38 | private 39 | 40 | def stack 41 | return @stack if defined?(@stack) 42 | StackMaster.cloud_formation_driver.set_region(stack_definition.region) 43 | @stack = find_stack 44 | end 45 | 46 | def find_stack 47 | Stack.find(stack_definition.region, stack_definition.stack_name) 48 | rescue Aws::CloudFormation::Errors::ValidationError 49 | end 50 | 51 | def differ 52 | @differ ||= StackMaster::StackDiffer.new(proposed_stack, stack) 53 | end 54 | 55 | def proposed_stack 56 | @proposed_stack ||= Stack.generate(stack_definition, @config) 57 | end 58 | 59 | attr_reader :stack_definition 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/stack_master/template_compiler.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | class TemplateCompiler 3 | TemplateCompilationFailed = Class.new(RuntimeError) 4 | 5 | def self.compile(config, template_compiler, template_dir, template, compile_time_parameters, compiler_options = {}) 6 | compiler = if template_compiler 7 | find_compiler(template_compiler) 8 | else 9 | template_compiler_for_stack(template, config) 10 | end 11 | compiler.require_dependencies 12 | compiler.compile(template_dir, template, compile_time_parameters, compiler_options) 13 | rescue StandardError => e 14 | raise TemplateCompilationFailed, "Failed to compile #{template}" 15 | end 16 | 17 | def self.register(name, klass) 18 | @compilers ||= {} 19 | @compilers[name] = klass 20 | end 21 | 22 | # private 23 | def self.template_compiler_for_stack(template, config) 24 | ext = file_ext(template) 25 | compiler_name = config.template_compilers.fetch(ext) 26 | find_compiler(compiler_name) 27 | end 28 | private_class_method :template_compiler_for_stack 29 | 30 | def self.file_ext(template) 31 | File.extname(template).gsub('.', '').to_sym 32 | end 33 | private_class_method :file_ext 34 | 35 | def self.find_compiler(name) 36 | @compilers.fetch(name.to_sym) do 37 | raise "Unknown compiler #{name.inspect}" 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/stack_master/template_compilers/cfndsl.rb: -------------------------------------------------------------------------------- 1 | module StackMaster::TemplateCompilers 2 | class Cfndsl 3 | def self.require_dependencies 4 | require 'cfndsl' 5 | require 'json' 6 | end 7 | 8 | def self.compile(template_dir, template, compile_time_parameters, _compiler_options = {}) 9 | CfnDsl::ExternalParameters.defaults.clear # Ensure there's no leakage across invocations 10 | CfnDsl::ExternalParameters.defaults(compile_time_parameters.symbolize_keys) 11 | template_file_path = File.join(template_dir, template) 12 | json_hash = ::CfnDsl.eval_file_with_extras(template_file_path).as_json 13 | JSON.pretty_generate(json_hash) 14 | end 15 | 16 | StackMaster::TemplateCompiler.register(:cfndsl, self) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/stack_master/template_compilers/json.rb: -------------------------------------------------------------------------------- 1 | module StackMaster::TemplateCompilers 2 | class Json 3 | MAX_TEMPLATE_SIZE = 51200 4 | private_constant :MAX_TEMPLATE_SIZE 5 | 6 | def self.require_dependencies 7 | require 'json' 8 | end 9 | 10 | def self.compile(template_dir, template, _compile_time_parameters, _compiler_options = {}) 11 | template_file_path = File.join(template_dir, template) 12 | template_body = File.read(template_file_path) 13 | if template_body.size > MAX_TEMPLATE_SIZE 14 | # Parse the json and rewrite compressed 15 | JSON.dump(JSON.parse(template_body)) 16 | else 17 | template_body 18 | end 19 | end 20 | 21 | StackMaster::TemplateCompiler.register(:json, self) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/stack_master/template_compilers/yaml.rb: -------------------------------------------------------------------------------- 1 | module StackMaster::TemplateCompilers 2 | class Yaml 3 | def self.require_dependencies 4 | require 'yaml' 5 | require 'json' 6 | end 7 | 8 | def self.compile(template_dir, template, _compile_time_parameters, _compiler_options = {}) 9 | template_file_path = File.join(template_dir, template) 10 | File.read(template_file_path) 11 | end 12 | 13 | StackMaster::TemplateCompiler.register(:yaml, self) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/stack_master/template_compilers/yaml_erb.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module StackMaster::TemplateCompilers 4 | class YamlErb 5 | def self.require_dependencies 6 | require 'yaml' 7 | end 8 | 9 | def self.compile(template_dir, template, compile_time_parameters, _compiler_options = {}) 10 | template_file_path = File.join(template_dir, template) 11 | template = StackMaster::CloudFormationTemplateEruby.new(File.read(template_file_path)) 12 | template.filename = template_file_path 13 | 14 | template.result(params: compile_time_parameters) 15 | end 16 | 17 | StackMaster::TemplateCompiler.register(:yaml_erb, self) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/stack_master/template_utils.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | module TemplateUtils 3 | MAX_TEMPLATE_SIZE = 51200 4 | MAX_S3_TEMPLATE_SIZE = 460800 5 | # Matches if the first non-whitespace character is a '{', handling cases 6 | # with leading whitespace and extra (whitespace-only) lines. 7 | JSON_IDENTIFICATION_PATTERN = Regexp.new('\A\s*{', Regexp::MULTILINE) 8 | 9 | extend self 10 | 11 | def identify_template_format(template_body) 12 | if template_body =~ JSON_IDENTIFICATION_PATTERN 13 | :json 14 | else 15 | :yaml 16 | end 17 | end 18 | 19 | def template_hash(template_body=nil) 20 | return unless template_body 21 | template_format = identify_template_format(template_body) 22 | case template_format 23 | when :json 24 | JSON.parse(template_body) 25 | when :yaml 26 | YAML.load(template_body) 27 | end 28 | end 29 | 30 | def maybe_compressed_template_body(template_body) 31 | # Do not compress the template if it's not JSON because parsing YAML as a hash ignores 32 | # CloudFormation-specific tags such as !Ref 33 | return template_body if template_body.size <= MAX_TEMPLATE_SIZE || identify_template_format(template_body) != :json 34 | JSON.dump(template_hash(template_body)) 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/stack_master/test_driver/s3.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | module TestDriver 3 | class S3 4 | def initialize 5 | reset 6 | end 7 | 8 | def set_region(_) 9 | end 10 | 11 | def reset 12 | @files = Hash.new { |hash, key| hash[key] = Hash.new } 13 | end 14 | 15 | def upload_files(bucket: nil, prefix: nil, region: nil, files: {}) 16 | return if files.empty? 17 | 18 | files.each do |template, file| 19 | object_key = [prefix, template].compact.join('/') 20 | @files[bucket][object_key] = file[:body] 21 | end 22 | end 23 | 24 | def url(bucket:, prefix:, region:, template:) 25 | ["https://s3-#{region}.amazonaws.com", bucket, prefix, template].compact.join('/') 26 | end 27 | 28 | # test only method 29 | def find_file(bucket:, object_key:) 30 | @files[bucket][object_key] 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/stack_master/testing.rb: -------------------------------------------------------------------------------- 1 | require 'stack_master' 2 | 3 | Aws.config[:stub_responses] = true 4 | 5 | require 'stack_master/test_driver/cloud_formation' 6 | 7 | StackMaster.cloud_formation_driver = StackMaster::TestDriver::CloudFormation.new 8 | StackMaster.s3_driver = StackMaster::TestDriver::S3.new 9 | StackMaster.non_interactive! 10 | -------------------------------------------------------------------------------- /lib/stack_master/utils.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | module Utils 3 | module Initializable 4 | def initialize(attributes = {}) 5 | self.attributes = attributes 6 | end 7 | 8 | def attributes=(attributes) 9 | attributes.each do |k, v| 10 | instance_variable_set("@#{k}", v) 11 | end 12 | end 13 | end 14 | 15 | extend self 16 | 17 | def change_extension(file_name, extension) 18 | [ 19 | File.basename(file_name, '.*'), 20 | extension 21 | ].join('.') 22 | end 23 | 24 | def hash_to_aws_parameters(params) 25 | params.inject([]) do |params, (key, value)| 26 | params << { parameter_key: key, parameter_value: value } 27 | params 28 | end 29 | end 30 | 31 | def hash_to_aws_tags(tags) 32 | return [] if tags.nil? 33 | tags.inject([]) do |aws_tags, (key, value)| 34 | aws_tags << { key: key, value: value } 35 | aws_tags 36 | end 37 | end 38 | 39 | def underscore_to_hyphen(string) 40 | string.to_s.gsub('_', '-') 41 | end 42 | 43 | def underscore_keys_to_hyphen(hash) 44 | hash.inject({}) do |hash, (key, value)| 45 | hash[underscore_to_hyphen(key)] = value 46 | hash 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/stack_master/validator.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | class Validator 3 | def self.valid?(stack_definition, config, options) 4 | new(stack_definition, config, options).perform 5 | end 6 | 7 | def initialize(stack_definition, config, options) 8 | @stack_definition = stack_definition 9 | @config = config 10 | @options = options 11 | end 12 | 13 | def perform 14 | StackMaster.stdout.print "#{@stack_definition.stack_name}: " 15 | if validate_template_parameters? && parameter_validator.missing_parameters? 16 | StackMaster.stdout.puts "invalid\n#{parameter_validator.error_message}" 17 | return false 18 | end 19 | cf.validate_template(template_body: TemplateUtils.maybe_compressed_template_body(stack.template_body)) 20 | StackMaster.stdout.puts "valid" 21 | true 22 | rescue Aws::CloudFormation::Errors::ValidationError => e 23 | StackMaster.stdout.puts "invalid. #{e.message}" 24 | false 25 | end 26 | 27 | private 28 | 29 | def validate_template_parameters? 30 | @options.validate_template_parameters 31 | end 32 | 33 | def cf 34 | @cf ||= StackMaster.cloud_formation_driver 35 | end 36 | 37 | def stack 38 | @stack ||= if validate_template_parameters? 39 | Stack.generate(@stack_definition, @config) 40 | else 41 | Stack.generate_without_parameters(@stack_definition, @config) 42 | end 43 | end 44 | 45 | def parameter_validator 46 | @parameter_validator ||= ParameterValidator.new(stack: stack, stack_definition: @stack_definition) 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/stack_master/version.rb: -------------------------------------------------------------------------------- 1 | module StackMaster 2 | VERSION = "2.16.0" 3 | end 4 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/envato/stack_master/c4f4379758e875a6cbf413cd103633eced39c4c3/logo.png -------------------------------------------------------------------------------- /spec/fixtures/parameters/myapp_vpc.yml: -------------------------------------------------------------------------------- 1 | param_1: 'hello' 2 | -------------------------------------------------------------------------------- /spec/fixtures/parameters/myapp_vpc_with_secrets.yml: -------------------------------------------------------------------------------- 1 | param_1: 'hello' 2 | param_2: 3 | secret: world -------------------------------------------------------------------------------- /spec/fixtures/sparkle_pack_integration/my_sparkle_pack/lib/my_sparkle_pack.rb: -------------------------------------------------------------------------------- 1 | ::SparkleFormation::SparklePack.register! 2 | -------------------------------------------------------------------------------- /spec/fixtures/sparkle_pack_integration/my_sparkle_pack/lib/sparkleformation/dynamics/my_dynamic.rb: -------------------------------------------------------------------------------- 1 | SparkleFormation.dynamic(:my_dynamic) do 2 | outputs.foo do 3 | value "bar" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/fixtures/sparkle_pack_integration/my_sparkle_pack/lib/sparkleformation/templates/dynamics/local_dynamic.rb: -------------------------------------------------------------------------------- 1 | SparkleFormation.dynamic(:local_dynamic) do 2 | outputs.bar do 3 | value "local_dynamic" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/fixtures/sparkle_pack_integration/my_sparkle_pack/lib/sparkleformation/templates/template_with_dynamic.rb: -------------------------------------------------------------------------------- 1 | SparkleFormation.new(:template_with_dynamic) do 2 | dynamic!(:local_dynamic) 3 | end 4 | -------------------------------------------------------------------------------- /spec/fixtures/sparkle_pack_integration/my_sparkle_pack/lib/sparkleformation/templates/template_with_dynamic_from_pack.rb: -------------------------------------------------------------------------------- 1 | SparkleFormation.new(:template_with_dynamic_from_pack) do 2 | dynamic!(:my_dynamic) 3 | end 4 | -------------------------------------------------------------------------------- /spec/fixtures/stack_master.yml: -------------------------------------------------------------------------------- 1 | region_aliases: 2 | production: us-east-1 3 | staging: ap-southeast-2 4 | stack_defaults: 5 | allowed_accounts: 6 | - '555555555' 7 | tags: 8 | application: my-awesome-blog 9 | s3: 10 | bucket: my-bucket 11 | region: us-east-1 12 | template_compilers: 13 | rb: ruby_dsl 14 | region_defaults: 15 | us_east_1: 16 | tags: 17 | environment: production 18 | notification_arns: 19 | - test_arn 20 | role_arn: test_service_role_arn 21 | stack_policy_file: my_policy.json 22 | staging: 23 | tags: 24 | environment: staging 25 | test_override: 1 26 | notification_arns: 27 | - test_arn_3 28 | role_arn: test_service_role_arn3 29 | stacks: 30 | us-east-1: 31 | myapp_vpc: 32 | template: myapp_vpc.json 33 | notification_arns: 34 | - test_arn_2 35 | role_arn: test_service_role_arn2 36 | myapp_web: 37 | template: myapp_web.rb 38 | allowed_accounts: '1234567890' 39 | myapp_vpc_with_secrets: 40 | template: myapp_vpc.json 41 | ap-southeast-2: 42 | myapp_vpc: 43 | template: myapp_vpc.rb 44 | notification_arns: 45 | - test_arn_4 46 | role_arn: test_service_role_arn4 47 | myapp_web: 48 | template: myapp_web 49 | allowed_accounts: 50 | - '1234567890' 51 | - '9876543210' 52 | tags: 53 | test_override: 2 54 | -------------------------------------------------------------------------------- /spec/fixtures/stack_master_empty_default.yml: -------------------------------------------------------------------------------- 1 | stack_defaults: 2 | stacks: 3 | us-east-1: 4 | myapp_vpc: 5 | template: myapp_vpc.json 6 | -------------------------------------------------------------------------------- /spec/fixtures/stack_master_wrong_indent.yml: -------------------------------------------------------------------------------- 1 | stacks: 2 | us-east-1: 3 | myapp_vpc: 4 | template: myapp_vpc.json 5 | -------------------------------------------------------------------------------- /spec/fixtures/templates/erb/compile_time_parameters_loop.yml.erb: -------------------------------------------------------------------------------- 1 | --- 2 | <% cidr_az_pairs = params['SubnetCidrs'].map { |pair| pair.split(":") }%> 3 | Description: "A test case for generating subnet resources in a loop" 4 | Parameters: 5 | VpcCidr: 6 | type: String 7 | 8 | Resources: 9 | Vpc: 10 | Type: AWS::EC2::VPC 11 | Properties: 12 | CidrBlock: !Ref VpcCidr 13 | <% cidr_az_pairs.each_with_index do |pair, index| %> 14 | SubnetPrivate<%= index %>: 15 | Type: AWS::EC2::Subnet 16 | Properties: 17 | VpcId: !Ref Vpc 18 | CidrBlock: <%= pair[0] %> 19 | AvailabilityZone: <%= pair[1] %> 20 | <% end %> 21 | -------------------------------------------------------------------------------- /spec/fixtures/templates/erb/user_data.sh.erb: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo 'Hello, World!' 4 | REGION=<%= { 'Ref' => 'AWS::Region' } %> 5 | echo $REGION 6 | -------------------------------------------------------------------------------- /spec/fixtures/templates/erb/user_data.yml.erb: -------------------------------------------------------------------------------- 1 | Description: A test case for storing the userdata script in a dedicated file 2 | 3 | Resources: 4 | LaunchConfig: 5 | Type: 'AWS::AutoScaling::LaunchConfiguration' 6 | Properties: 7 | UserData: <%= user_data_file(File.join(__dir__, 'user_data.sh.erb')) %> 8 | -------------------------------------------------------------------------------- /spec/fixtures/templates/json/valid_myapp_vpc.json: -------------------------------------------------------------------------------- 1 | { 2 | "Description": "A test VPC template", 3 | "Resources": { 4 | "Vpc": { 5 | "Type": "AWS::EC2::VPC", 6 | "Properties": { 7 | "CidrBlock": "10.200.0.0/16" 8 | } 9 | }, 10 | "PublicSubnet": { 11 | "Type": "AWS::EC2::Subnet", 12 | "Properties": { 13 | "VpcId": { 14 | "Ref": "Vpc" 15 | }, 16 | "CidrBlock": "10.200.1.0/24", 17 | "AvailabilityZone": { 18 | "Ref": "VpcAz1" 19 | }, 20 | "Tags": [ 21 | { 22 | "Key": "Name", 23 | "Value": "PublicSubnet" 24 | }, 25 | { 26 | "Key": "network", 27 | "Value": "public" 28 | } 29 | ] 30 | } 31 | } 32 | }, 33 | "Parameters": { 34 | "VpcAz1": { 35 | "Description": "VPC AZ 1", 36 | "Type": "AWS::EC2::AvailabilityZone::Name" 37 | } 38 | }, 39 | "Outputs": { 40 | "VpcId": { 41 | "Description": "VPC ID", 42 | "Value": { 43 | "Ref": "Vpc" 44 | } 45 | }, 46 | "PublicSubnet": { 47 | "Description": "Public subnet", 48 | "Value": { 49 | "Ref": "PublicSubnet" 50 | } 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /spec/fixtures/templates/myapp_vpc.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /spec/fixtures/templates/mystack-with-parameters.yaml: -------------------------------------------------------------------------------- 1 | Parameters: 2 | ParamOne: 3 | Type: string 4 | ParamTwo: 5 | Type: string 6 | 7 | -------------------------------------------------------------------------------- /spec/fixtures/templates/rb/cfndsl/sample-ctp-repeated.rb: -------------------------------------------------------------------------------- 1 | CloudFormation { 2 | Description "Test" 3 | 4 | Parameter("One") { 5 | String 6 | Default "Test" 7 | MaxLength 15 8 | } 9 | 10 | Output(:One,FnBase64( Ref("One"))) 11 | 12 | EC2_Instance(:MyInstance) { 13 | DisableApiTermination external_parameters.fetch(:DisableApiTermination, "false") 14 | InstanceType external_parameters["InstanceType"] 15 | ImageId "ami-12345678" 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /spec/fixtures/templates/rb/cfndsl/sample-ctp.json: -------------------------------------------------------------------------------- 1 | { 2 | "Parameters" : { 3 | "One" : { 4 | "Type" : "String", 5 | "Default" : "Test", 6 | "MaxLength" : 15 7 | } 8 | }, 9 | "Resources" : { 10 | "MyInstance" : { 11 | "Type" : "AWS::EC2::Instance", 12 | "Properties" : { 13 | "InstanceType": "t2.medium", 14 | "ImageId" : "ami-12345678" 15 | } 16 | } 17 | }, 18 | "AWSTemplateFormatVersion" : "2010-09-09", 19 | "Outputs" : { 20 | "One" : { 21 | "Value" : { 22 | "Fn::Base64" : { 23 | "Ref" : "One" 24 | } 25 | } 26 | } 27 | }, 28 | "Description" : "Test" 29 | } 30 | -------------------------------------------------------------------------------- /spec/fixtures/templates/rb/cfndsl/sample-ctp.rb: -------------------------------------------------------------------------------- 1 | CloudFormation { 2 | Description "Test" 3 | 4 | Parameter("One") { 5 | String 6 | Default "Test" 7 | MaxLength 15 8 | } 9 | 10 | Output(:One,FnBase64( Ref("One"))) 11 | 12 | EC2_Instance(:MyInstance) { 13 | InstanceType external_parameters["InstanceType"] 14 | ImageId "ami-12345678" 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /spec/fixtures/templates/rb/cfndsl/sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "Parameters" : { 3 | "One" : { 4 | "Type" : "String", 5 | "Default" : "Test", 6 | "MaxLength" : 15 7 | } 8 | }, 9 | "Resources" : { 10 | "MyInstance" : { 11 | "Type" : "AWS::EC2::Instance", 12 | "Properties" : { 13 | "ImageId" : "ami-12345678" 14 | } 15 | } 16 | }, 17 | "AWSTemplateFormatVersion" : "2010-09-09", 18 | "Outputs" : { 19 | "One" : { 20 | "Value" : { 21 | "Fn::Base64" : { 22 | "Ref" : "One" 23 | } 24 | } 25 | } 26 | }, 27 | "Description" : "Test" 28 | } 29 | -------------------------------------------------------------------------------- /spec/fixtures/templates/rb/cfndsl/sample.rb: -------------------------------------------------------------------------------- 1 | CloudFormation { 2 | Description "Test" 3 | 4 | Parameter("One") { 5 | String 6 | Default "Test" 7 | MaxLength 15 8 | } 9 | 10 | Output(:One,FnBase64( Ref("One"))) 11 | 12 | EC2_Instance(:MyInstance) { 13 | ImageId "ami-12345678" 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /spec/fixtures/templates/rb/sparkle_formation/templates/template.rb: -------------------------------------------------------------------------------- 1 | SparkleFormation.new(:myapp_vpc_2) do 2 | description "A test VPC template" 3 | 4 | resources.vpc do 5 | type 'AWS::EC2::VPC' 6 | properties do 7 | cidr_block '10.200.0.0/16' 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/fixtures/templates/yml/valid_myapp_vpc.yml: -------------------------------------------------------------------------------- 1 | --- 2 | Description: "A test VPC template" 3 | Resources: 4 | Vpc: 5 | Type: "AWS::EC2::VPC" 6 | Properties: 7 | CidrBlock: "10.200.0.0/16" 8 | PublicSubnet: 9 | Type: "AWS::EC2::Subnet" 10 | Properties: 11 | VpcId: 12 | Ref: "Vpc" 13 | CidrBlock: "10.200.1.0/24" 14 | AvailabilityZone: 15 | Ref: "VpcAz1" 16 | Tags: 17 | - 18 | Key: "Name" 19 | Value: "PublicSubnet" 20 | - 21 | Key: "network" 22 | Value: "public" 23 | Parameters: 24 | VpcAz1: 25 | Description: "VPC AZ 1" 26 | Type: "AWS::EC2::AvailabilityZone::Name" 27 | Outputs: 28 | VpcId: 29 | Description: "VPC ID" 30 | Value: 31 | Ref: "Vpc" 32 | PublicSubnet: 33 | Description: "Public subnet" 34 | Value: 35 | Ref: "PublicSubnet" -------------------------------------------------------------------------------- /spec/fixtures/test/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/envato/stack_master/c4f4379758e875a6cbf413cd103633eced39c4c3/spec/fixtures/test/.gitkeep -------------------------------------------------------------------------------- /spec/stack_master/cloudformation_interpolating_eruby_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe(StackMaster::CloudFormationInterpolatingEruby) do 2 | describe('#evaluate') do 3 | subject(:evaluate) { described_class.new(user_data).evaluate } 4 | 5 | context('given a simple user data script') do 6 | let(:user_data) { <<~SHELL } 7 | #!/bin/bash 8 | 9 | REGION=ap-southeast-2 10 | echo $REGION 11 | SHELL 12 | 13 | it 'returns an array of lines' do 14 | expect(evaluate).to eq([ 15 | "#!/bin/bash\n", 16 | "\n", 17 | "REGION=ap-southeast-2\n", 18 | "echo $REGION\n", 19 | ]) 20 | end 21 | end 22 | 23 | context('given a user data script referring parameters') do 24 | let(:user_data) { <<~SHELL } 25 | #!/bin/bash 26 | <%= { 'Ref' => 'Param1' } %> <%= { 'Ref' => 'Param2' } %> 27 | SHELL 28 | 29 | it 'includes CloudFormation objects in the array' do 30 | expect(evaluate).to eq([ 31 | "#!/bin/bash\n", 32 | { 'Ref' => 'Param1' }, 33 | ' ', 34 | { 'Ref' => 'Param2' }, 35 | "\n", 36 | ]) 37 | end 38 | end 39 | end 40 | 41 | describe('.evaluate_file') do 42 | subject(:evaluate_file) { described_class.evaluate_file('my/userdata.sh') } 43 | 44 | context('given a simple user data script file') do 45 | before { allow(File).to receive(:read).with('my/userdata.sh').and_return(<<~SHELL) } 46 | #!/bin/bash 47 | 48 | REGION=ap-southeast-2 49 | echo $REGION 50 | SHELL 51 | 52 | it 'returns an array of lines' do 53 | expect(evaluate_file).to eq([ 54 | "#!/bin/bash\n", 55 | "\n", 56 | "REGION=ap-southeast-2\n", 57 | "echo $REGION\n", 58 | ]) 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/stack_master/commands/compile_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::Commands::Compile do 2 | let(:region) { 'us-east-1' } 3 | let(:stack_name) { 'myapp-vpc' } 4 | let(:stack_definition) { StackMaster::StackDefinition.new(base_dir: '/base_dir', region: region, stack_name: stack_name) } 5 | let(:config) { double(find_stack: stack_definition) } 6 | let(:parameters) { {} } 7 | let(:proposed_stack) { 8 | StackMaster::Stack.new( 9 | template_body: template_body, 10 | template_format: template_format, 11 | parameters: parameters) 12 | } 13 | 14 | let(:template_body) { '{}' } 15 | let(:template_format) { :json } 16 | 17 | before do 18 | allow(StackMaster::Stack).to receive(:generate).with(stack_definition, config).and_return(proposed_stack) 19 | end 20 | 21 | def run 22 | described_class.perform(config, stack_definition) 23 | end 24 | 25 | context "with a json stack" do 26 | it 'outputs the template' do 27 | expect { run }.to output(template_body + "\n").to_stdout 28 | end 29 | end 30 | 31 | context "with a yaml stack" do 32 | let(:template_body) { '---' } 33 | let(:template_format) { :yaml } 34 | 35 | it 'outputs the template' do 36 | expect { run }.to output(template_body + "\n").to_stdout 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/stack_master/commands/delete_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::Commands::Delete do 2 | 3 | subject(:delete) { described_class.new(stack_name, region, options) } 4 | let(:cf) { spy(Aws::CloudFormation::Client.new) } 5 | let(:region) { 'us-east-1' } 6 | let(:stack_name) { 'mystack' } 7 | let(:options) { Commander::Command::Options.new } 8 | 9 | before do 10 | StackMaster.cloud_formation_driver.set_region(region) 11 | allow(Aws::CloudFormation::Client).to receive(:new).with({ region: region, retry_limit: 10 }).and_return(cf) 12 | allow(delete).to receive(:ask?).and_return('y') 13 | allow(StackMaster::StackEvents::Streamer).to receive(:stream) 14 | end 15 | 16 | describe "#perform" do 17 | context "The stack exists" do 18 | before do 19 | allow(cf).to receive(:describe_stacks).and_return( 20 | {stacks: [{ stack_id: "ABC", stack_name: stack_name, creation_time: Time.now, stack_status: 'UPDATE_COMPLETE', parameters: []}]} 21 | ) 22 | end 23 | it "deletes the stack and tails the events" do 24 | delete.perform 25 | expect(cf).to have_received(:delete_stack).with({:stack_name => region}) 26 | expect(StackMaster::StackEvents::Streamer).to have_received(:stream) 27 | end 28 | end 29 | 30 | context "The stack does not exist" do 31 | before do 32 | allow(cf).to receive(:describe_stacks).and_raise(Aws::CloudFormation::Errors::ValidationError.new("x", "y")) 33 | end 34 | it "is not successful" do 35 | delete.perform 36 | expect(StackMaster::StackEvents::Streamer).not_to have_received(:stream) 37 | expect(cf).not_to have_received(:delete_stack) 38 | expect(delete.success?).to be false 39 | end 40 | end 41 | end 42 | 43 | end 44 | -------------------------------------------------------------------------------- /spec/stack_master/commands/init_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::Commands::Init do 2 | 3 | subject(:init_command) { described_class.new(options, region, stack_name) } 4 | let(:region) { "us-east-1" } 5 | let(:stack_name) { "test-stack" } 6 | let(:options) { double(overwrite: false)} 7 | 8 | describe "#perform" do 9 | it "creates all the expected files" do 10 | expect(IO).to receive(:write).with("stack_master.yml", "stacks:\n us-east-1:\n test-stack:\n template: test-stack.json\n tags:\n environment: production\n") 11 | expect(IO).to receive(:write).with("parameters/test-stack.yml", "# Add parameters here:\n# param1: value1\n# param2: value2\n") 12 | expect(IO).to receive(:write).with("parameters/us-east-1/test-stack.yml", "# Add parameters here:\n# param1: value1\n# param2: value2\n") 13 | expect(IO).to receive(:write).with("templates/test-stack.json", "{\n \"AWSTemplateFormatVersion\" : \"2010-09-09\",\n \"Description\" : \"Cloudformation stack for test-stack\",\n\n \"Parameters\" : {\n \"InstanceType\" : {\n \"Description\" : \"EC2 instance type\",\n \"Type\" : \"String\"\n }\n },\n\n \"Mappings\" : {\n },\n\n \"Resources\" : {\n },\n\n \"Outputs\" : {\n }\n}\n") 14 | init_command.perform() 15 | end 16 | end 17 | 18 | end 19 | -------------------------------------------------------------------------------- /spec/stack_master/commands/lint_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::Commands::Lint do 2 | let(:region) { 'us-east-1' } 3 | let(:stack_name) { 'myapp-vpc' } 4 | let(:stack_definition) { StackMaster::StackDefinition.new(base_dir: '/base_dir', region: region, stack_name: stack_name) } 5 | let(:config) { double(find_stack: stack_definition) } 6 | let(:parameters) { {} } 7 | let(:proposed_stack) { 8 | StackMaster::Stack.new( 9 | template_body: template_body, 10 | template_format: template_format, 11 | parameters: parameters) 12 | } 13 | let(:tempfile) { double(:tempfile) } 14 | let(:path) { double(:path) } 15 | 16 | before do 17 | allow(StackMaster::Stack).to receive(:generate).with(stack_definition, config).and_return(proposed_stack) 18 | end 19 | 20 | def run 21 | described_class.perform(config, stack_definition) 22 | end 23 | 24 | context "when cfn-lint is installed" do 25 | before do 26 | expect_any_instance_of(described_class).to receive(:system).once.with('cfn-lint', '--version').and_return(0) 27 | end 28 | 29 | context "with a json stack" do 30 | let(:template_body) { '{}' } 31 | let(:template_format) { :json } 32 | 33 | it 'outputs the template' do 34 | expect_any_instance_of(described_class).to receive(:system).once.with('cfn-lint', /.*\.json/) 35 | run 36 | end 37 | end 38 | 39 | context "with a yaml stack" do 40 | let(:template_body) { '---' } 41 | let(:template_format) { :yaml } 42 | 43 | it 'outputs the template' do 44 | expect_any_instance_of(described_class).to receive(:system).once.with('cfn-lint', /.*\.yaml/) 45 | run 46 | end 47 | end 48 | end 49 | 50 | context "when cfn-lint is missing" do 51 | let(:template_body) { '' } 52 | let(:template_format) { :json} 53 | 54 | it 'outputs a warning' do 55 | expect_any_instance_of(described_class).to receive(:system).once.with('cfn-lint', '--version').and_return(nil) 56 | expect { run }.to output(/Failed to run cfn-lint/).to_stderr 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /spec/stack_master/commands/nag_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::Commands::Nag do 2 | let(:region) { 'us-east-1' } 3 | let(:stack_name) { 'myapp-vpc' } 4 | let(:stack_definition) { StackMaster::StackDefinition.new(base_dir: '/base_dir', region: region, stack_name: stack_name) } 5 | let(:config) { instance_double(StackMaster::Config, find_stack: stack_definition) } 6 | let(:parameters) { {} } 7 | let(:proposed_stack) { 8 | StackMaster::Stack.new( 9 | template_body: template_body, 10 | template_format: template_format, 11 | parameters: parameters) 12 | } 13 | let(:tempfile) { double(Tempfile) } 14 | let(:path) { double(String) } 15 | let(:template_body) { '{}' } 16 | let(:template_format) { :json } 17 | let(:exitstatus) { 0 } 18 | 19 | before do 20 | allow(StackMaster::Stack).to receive(:generate).with(stack_definition, config).and_return(proposed_stack) 21 | end 22 | 23 | def run 24 | `(exit #{exitstatus})` # Makes calling $?.exitstatus work 25 | described_class.perform(config, stack_definition) 26 | end 27 | 28 | context "with a json stack" do 29 | it 'calls the nag gem' do 30 | expect_any_instance_of(File).to receive(:write).once 31 | expect_any_instance_of(File).to receive(:flush).once 32 | expect_any_instance_of(described_class).to receive(:system).once.with('cfn_nag', /.*\.json/) 33 | run 34 | end 35 | end 36 | 37 | context "with a yaml stack" do 38 | let(:template_body) { '---' } 39 | let(:template_format) { :yaml } 40 | 41 | it 'calls the nag gem' do 42 | expect_any_instance_of(File).to receive(:write).once 43 | expect_any_instance_of(File).to receive(:flush).once 44 | expect_any_instance_of(described_class).to receive(:system).once.with('cfn_nag', /.*\.yaml/) 45 | run 46 | end 47 | end 48 | 49 | context "when check is successful" do 50 | it 'exits with a zero exit status' do 51 | expect_any_instance_of(described_class).to receive(:system).once.with('cfn_nag', /.*\.json/) 52 | result = run 53 | expect(result.success?).to eq true 54 | end 55 | end 56 | 57 | context "when check fails" do 58 | let(:exitstatus) { 1 } 59 | it 'exits with non-zero exit status' do 60 | expect_any_instance_of(described_class).to receive(:system).once.with('cfn_nag', /.*\.json/) 61 | result = run 62 | expect(result.success?).to eq false 63 | end 64 | end 65 | 66 | end 67 | -------------------------------------------------------------------------------- /spec/stack_master/commands/outputs_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::Commands::Outputs do 2 | subject(:outputs) { described_class.new(config, stack_definition) } 3 | 4 | let(:config) { spy(StackMaster::Config) } 5 | let(:stack_definition) { spy(StackMaster::StackDefinition, stack_name: 'mystack', region: 'us-east-1') } 6 | let(:stack) { spy(StackMaster::Stack, outputs: stack_outputs) } 7 | let(:stack_outputs) { double(:outputs) } 8 | 9 | before do 10 | allow(StackMaster::Stack).to receive(:find).and_return(stack) 11 | allow(outputs).to receive(:tp).and_return(spy) 12 | end 13 | 14 | describe '#perform' do 15 | subject(:perform) { outputs.perform } 16 | 17 | context 'given the stack exists' do 18 | it 'prints the details in a table form' do 19 | perform 20 | expect(outputs).to have_received(:tp).with(stack_outputs, :output_key, :output_value, :description) 21 | end 22 | 23 | specify 'the command is successful' do 24 | perform 25 | expect(outputs.success?).to be(true) 26 | end 27 | 28 | it 'makes the API call only once' do 29 | perform 30 | expect(StackMaster::Stack).to have_received(:find).with('us-east-1', 'mystack').once 31 | end 32 | end 33 | 34 | context 'given the stack does not exist' do 35 | before do 36 | allow(StackMaster::Stack).to receive(:find).and_return(nil) 37 | end 38 | 39 | specify 'the command is not successful' do 40 | perform 41 | expect(outputs.success?).to be(false) 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/stack_master/commands/resources_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::Commands::Resources do 2 | subject(:resources) { described_class.new(config, stack_definition) } 3 | 4 | let(:config) { spy(StackMaster::Config) } 5 | let(:stack_definition) { spy(StackMaster::StackDefinition, stack_name: 'mystack', region: 'us-east-1') } 6 | let(:cf) { spy(Aws::CloudFormation::Client, describe_stack_resources: stack_resources) } 7 | let(:stack_resources) { double(stack_resources: [stack_resource]) } 8 | let(:stack_resource) { double('stack_resource') } 9 | 10 | before do 11 | allow(Aws::CloudFormation::Client).to receive(:new).and_return(cf) 12 | allow(resources).to receive(:tp) 13 | end 14 | 15 | describe '#perform' do 16 | subject(:perform) { resources.perform } 17 | 18 | context 'given the stack exists' do 19 | it 'prints the details in a table form' do 20 | perform 21 | expect(resources).to have_received(:tp).with( 22 | [stack_resource], :logical_resource_id, :resource_type, :timestamp, 23 | :resource_status, :resource_status_reason, :description 24 | ) 25 | end 26 | 27 | specify 'the command is successful' do 28 | perform 29 | expect(resources.success?).to be(true) 30 | end 31 | 32 | it 'makes the API call only once' do 33 | perform 34 | expect(cf).to have_received(:describe_stack_resources).with(stack_name: 'mystack').once 35 | end 36 | end 37 | 38 | context 'given the stack does not exist' do 39 | before do 40 | allow(cf).to receive(:describe_stack_resources).and_raise(Aws::CloudFormation::Errors::ValidationError.new('x', 'y')) 41 | end 42 | 43 | specify 'the command is not successful' do 44 | perform 45 | expect(resources.success?).to be(false) 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/stack_master/commands/validate_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::Commands::Validate do 2 | 3 | subject(:validate) { described_class.new(config, stack_definition, options) } 4 | let(:config) { instance_double(StackMaster::Config) } 5 | let(:region) { "us-east-1" } 6 | let(:stack_name) { "mystack" } 7 | let(:options) { Commander::Command::Options.new } 8 | let(:stack_definition) do 9 | StackMaster::StackDefinition.new( 10 | region: region, 11 | stack_name: stack_name, 12 | template: 'myapp_vpc.json', 13 | tags: { 'environment' => 'production' }, 14 | base_dir: File.expand_path('spec/fixtures') 15 | ) 16 | end 17 | 18 | describe "#perform" do 19 | context "can find stack" do 20 | it "calls the validator to validate the stack definition" do 21 | expect(StackMaster::Validator).to receive(:valid?).with(stack_definition, config, options) 22 | validate.perform 23 | end 24 | end 25 | end 26 | 27 | end 28 | -------------------------------------------------------------------------------- /spec/stack_master/paged_response_accumulator_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::PagedResponseAccumulator do 2 | let(:cf) { Aws::CloudFormation::Client.new } 3 | subject(:accumulator) { described_class.new(cf, :describe_stack_events, { stack_name: 'blah' }, :stack_events) } 4 | 5 | context 'with one page' do 6 | let(:page_one_events) { [ 7 | { event_id: '1', stack_id: '1', stack_name: 'blah', timestamp: Time.now}, 8 | { event_id: '2', stack_id: '1', stack_name: 'blah', timestamp: Time.now} 9 | ] } 10 | 11 | before do 12 | cf.stub_responses(:describe_stack_events, { stack_events: page_one_events, next_token: nil }) 13 | end 14 | 15 | it 'returns the first page' do 16 | events = accumulator.call 17 | expect(events.stack_events.count).to eq 2 18 | end 19 | end 20 | 21 | context 'with two pages' do 22 | let(:page_one_events) { [ 23 | { event_id: '1', stack_id: '1', stack_name: 'blah', timestamp: Time.now}, 24 | { event_id: '2', stack_id: '1', stack_name: 'blah', timestamp: Time.now} 25 | ] } 26 | let(:page_two_events) { [ 27 | { event_id: '3', stack_id: '1', stack_name: 'blah', timestamp: Time.now} 28 | ] } 29 | 30 | before do 31 | cf.stub_responses(:describe_stack_events, { stack_events: page_one_events, next_token: 'blah' }, { stack_events: page_two_events } ) 32 | end 33 | 34 | it 'returns all the stack events combined' do 35 | events = accumulator.call 36 | expect(events.stack_events.count).to eq 3 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/stack_master/parameter_resolvers/acm_certificate_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::ParameterResolvers::AcmCertificate do 2 | let(:config) { double(base_dir: '/base') } 3 | let(:stack_definition) { double(stack_name: 'mystack', region: 'us-east-1') } 4 | subject(:resolver) { described_class.new(config, stack_definition) } 5 | let(:acm) { Aws::ACM::Client.new } 6 | 7 | before do 8 | allow(Aws::ACM::Client).to receive(:new).and_return(acm) 9 | end 10 | 11 | context 'when a certificate is found' do 12 | before do 13 | acm.stub_responses( 14 | :list_certificates, 15 | { 16 | certificate_summary_list: [ 17 | { certificate_arn: 'arn:aws:acm:us-east-1:12345:certificate/abc', domain_name: 'abc' }, 18 | { certificate_arn: 'arn:aws:acm:us-east-1:12345:certificate/def', domain_name: 'def' } 19 | ] 20 | } 21 | ) 22 | end 23 | 24 | it 'returns the certificate' do 25 | expect(resolver.resolve('def')).to eq 'arn:aws:acm:us-east-1:12345:certificate/def' 26 | end 27 | end 28 | 29 | context 'when no certificate is found' do 30 | before do 31 | acm.stub_responses(:list_certificates, { certificate_summary_list: [] }) 32 | end 33 | 34 | it 'raises an error' do 35 | expect { resolver.resolve('def') }.to raise_error( 36 | StackMaster::ParameterResolvers::AcmCertificate::CertificateNotFound, 37 | 'Could not find certificate def in us-east-1' 38 | ) 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/stack_master/parameter_resolvers/ami_finder_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::ParameterResolvers::AmiFinder do 2 | subject(:resolver) { described_class.new('us-east-1') } 3 | let(:ec2) { Aws::EC2::Client.new } 4 | 5 | before do 6 | allow(Aws::EC2::Client).to receive(:new).and_return(ec2) 7 | end 8 | 9 | describe '#build_filters_from_string' do 10 | context 'when a single key-value pair is specified' do 11 | it 'returns an array with a single hash' do 12 | expect(resolver.build_filters_from_string('my-attr=my-value', nil)).to eq [ 13 | { name: 'my-attr', values: ['my-value']} 14 | ] 15 | end 16 | end 17 | 18 | context 'when multiple key-value pairs are specified' do 19 | it 'returns an array with multiple hashes' do 20 | expect(resolver.build_filters_from_string('my-attr=my-value,foo=bar', nil)).to eq [ 21 | { name: 'my-attr', values: ['my-value']}, 22 | { name: 'foo', values: ['bar']} 23 | ] 24 | end 25 | end 26 | 27 | context 'when a prefix is supplied' do 28 | it 'adds the prefix to the filter' do 29 | expect(resolver.build_filters_from_string('my-tag=my-value', 'tag')).to eq [ 30 | { name: 'tag:my-tag', values: ['my-value']} 31 | ] 32 | end 33 | end 34 | end 35 | 36 | describe '#build_filters_from_hash' do 37 | it 'outputs a hash of values in the format expected by the AWS API' do 38 | expect(resolver.build_filters_from_hash({'foo' => 'bacon'})).to eq([{name: 'foo', values: ['bacon']}]) 39 | end 40 | end 41 | 42 | describe '#find_latest_ami' do 43 | let(:filter) { [{ name: "String", values: ["String"]}] } 44 | 45 | context 'when matches are found' do 46 | before do 47 | ec2.stub_responses( 48 | :describe_images, 49 | { 50 | images: [ 51 | { image_id: '1', creation_date: '2015-01-02 00:00:00', tags: [{ key: 'my-tag', value: 'my-value' }] }, 52 | { image_id: '2', creation_date: '2015-01-03 00:00:00', tags: [{ key: 'my-tag', value: 'my-value' }] } 53 | ] 54 | } 55 | ) 56 | end 57 | 58 | it 'returns the latest one' do 59 | expect(resolver.find_latest_ami(filter).image_id).to eq '2' 60 | end 61 | end 62 | 63 | context 'when no matches are found' do 64 | before do 65 | ec2.stub_responses(:describe_images, { images: [] }) 66 | end 67 | 68 | it 'returns nil' do 69 | expect(resolver.find_latest_ami(filter)).to be_nil 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /spec/stack_master/parameter_resolvers/env_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::ParameterResolvers::Env do 2 | 3 | describe '#resolve' do 4 | 5 | subject(:resolver) { described_class.new(nil, double(region: 'us-east-1')) } 6 | let(:environment_variable_name) { 'TEST' } 7 | let(:error) { "The environment variable #{environment_variable_name} is not set" } 8 | 9 | before(:each) do 10 | ENV.delete(environment_variable_name) 11 | end 12 | 13 | context 'the environment variable is defined' do 14 | it 'should return the environment variable value' do 15 | ENV[environment_variable_name] = 'a' 16 | expect(resolver.resolve(environment_variable_name)).to eq 'a' 17 | end 18 | end 19 | 20 | context 'the environment variable is undefined' do 21 | it 'should raise and error' do 22 | expect { resolver.resolve(environment_variable_name) } 23 | .to raise_error(ArgumentError, error) 24 | end 25 | end 26 | 27 | context 'the environment variable is defined but empty' do 28 | it 'should return the empty string' do 29 | ENV[environment_variable_name] = '' 30 | expect(resolver.resolve(environment_variable_name)).to eq '' 31 | end 32 | end 33 | 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/stack_master/parameter_resolvers/latest_ami_by_tags_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::ParameterResolvers::LatestAmiByTags do 2 | let(:config) { double(base_dir: '/base') } 3 | let(:stack_definition) { double(stack_name: 'mystack', region: 'us-east-1') } 4 | subject(:resolver) { described_class.new(config, stack_definition) } 5 | let(:ec2) { Aws::EC2::Client.new } 6 | 7 | before do 8 | allow(Aws::EC2::Client).to receive(:new).and_return(ec2) 9 | end 10 | 11 | context 'when matches are found' do 12 | before do 13 | ec2.stub_responses( 14 | :describe_images, 15 | { 16 | images: [ 17 | { image_id: '1', creation_date: '2015-01-02 00:00:00', tags: [{ key: 'my-tag', value: 'my-value' }] }, 18 | { image_id: '2', creation_date: '2015-01-03 00:00:00', tags: [{ key: 'my-tag', value: 'my-value' }] } 19 | ] 20 | } 21 | ) 22 | end 23 | 24 | it 'returns the latest one' do 25 | expect(resolver.resolve('my-tag=my-value')).to eq '2' 26 | end 27 | end 28 | 29 | context 'when no matches are found' do 30 | before do 31 | ec2.stub_responses(:describe_images, { images: [] }) 32 | end 33 | 34 | it 'returns nil' do 35 | expect(resolver.resolve('my-tag=my-value')).to be_nil 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/stack_master/parameter_resolvers/latest_ami_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::ParameterResolvers::LatestAmi do 2 | let(:config) { double(base_dir: '/base') } 3 | let(:stack_definition) { double(stack_name: 'mystack', region: 'us-east-1') } 4 | subject(:resolver) { described_class.new(config, stack_definition) } 5 | let(:ec2) { Aws::EC2::Client.new } 6 | 7 | before do 8 | allow(Aws::EC2::Client).to receive(:new).and_return(ec2) 9 | end 10 | 11 | context 'when matches are found' do 12 | before do 13 | ec2.stub_responses( 14 | :describe_images, 15 | { 16 | images: [ 17 | { image_id: '1', creation_date: '2015-01-02 00:00:00', name: 'foo' }, 18 | { image_id: '2', creation_date: '2015-01-03 00:00:00', name: 'foo' } 19 | ] 20 | } 21 | ) 22 | end 23 | 24 | it 'returns the latest one' do 25 | expect(resolver.resolve('filters' => {'name' => 'foo'})).to eq '2' 26 | end 27 | end 28 | 29 | context 'when no matches are found' do 30 | before do 31 | ec2.stub_responses(:describe_images, { images: [] }) 32 | end 33 | 34 | it 'returns nil' do 35 | expect(resolver.resolve('filters' => {'name' => 'foo'})).to be_nil 36 | end 37 | end 38 | 39 | context 'when an owner_id is passed' do 40 | let(:ami_finder) { StackMaster::ParameterResolvers::AmiFinder.new('us-east-1') } 41 | before do 42 | expect(StackMaster::ParameterResolvers::AmiFinder).to receive(:new).and_return(ami_finder) 43 | allow(ami_finder).to receive(:build_filters_from_hash).and_call_original 44 | end 45 | 46 | it 'calls find_latest_ami with the owner and filters' do 47 | expect(ami_finder).to receive(:find_latest_ami).with([{name: 'foo', values: ['bacon']}], ['123456']) 48 | resolver.resolve({'owners' => 123456, 'filters' => {'foo' => 'bacon'} }) 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/stack_master/parameter_resolvers/parameter_store_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::ParameterResolvers::ParameterStore do 2 | 3 | describe '#resolve' do 4 | 5 | let(:config) { double(base_dir: '/base') } 6 | let(:stack_definition) { double(stack_name: 'mystack', region: 'us-east-1') } 7 | subject(:resolver) { described_class.new(config, stack_definition) } 8 | let(:parameter_name) { 'TEST' } 9 | let(:parameter_value) { 'TEST' } 10 | let(:unknown_parameter_name) { 'NOTEST' } 11 | let(:unencryptable_parameter_name) { 'SECRETTEST' } 12 | 13 | 14 | context 'the parameter is defined' do 15 | before do 16 | Aws.config[:ssm] = { 17 | stub_responses: { 18 | get_parameter: { 19 | parameter: { 20 | name: parameter_name, 21 | value: parameter_value, 22 | type: "SecureString", 23 | version: 1 24 | } 25 | } 26 | } 27 | } 28 | end 29 | 30 | it 'should return the parameter value' do 31 | expect(resolver.resolve(parameter_name)).to eq parameter_value 32 | end 33 | end 34 | 35 | context 'the parameter is undefined' do 36 | before do 37 | Aws.config[:ssm] = { 38 | stub_responses: { 39 | get_parameter: 40 | Aws::SSM::Errors::ParameterNotFound.new(unknown_parameter_name, "Parameter #{unknown_parameter_name} not found") 41 | } 42 | } 43 | end 44 | it 'should raise and error' do 45 | expect { resolver.resolve(unknown_parameter_name) } 46 | .to raise_error(StackMaster::ParameterResolvers::ParameterStore::ParameterNotFound, "Unable to find #{unknown_parameter_name} in Parameter Store") 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/stack_master/parameter_resolvers/security_group_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::ParameterResolvers::SecurityGroup do 2 | describe "#resolve" do 3 | subject(:resolver) { described_class.new(nil, double(region: 'us-east-1')) } 4 | let(:finder) { instance_double(StackMaster::SecurityGroupFinder) } 5 | let(:sg_id) { 'sg-id' } 6 | let(:sg_name) { 'sg-name' } 7 | 8 | before do 9 | allow(StackMaster::SecurityGroupFinder).to receive(:new).with('us-east-1').and_return finder 10 | expect(finder).to receive(:find).once.with(sg_name).and_return sg_id 11 | end 12 | 13 | context 'when given a single SG name' do 14 | it "resolves the security group" do 15 | expect(resolver.resolve(sg_name)).to eq sg_id 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/stack_master/parameter_resolvers/security_groups_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::ParameterResolvers::SecurityGroups do 2 | describe "#resolve" do 3 | subject(:resolver) { described_class.new(nil, double(region: 'us-east-1')) } 4 | let(:finder) { instance_double(StackMaster::SecurityGroupFinder) } 5 | let(:sg_id) { 'sg-id' } 6 | let(:sg_name) { 'sg-name' } 7 | 8 | before do 9 | allow(StackMaster::SecurityGroupFinder).to receive(:new).with('us-east-1').and_return finder 10 | expect(finder).to receive(:find).once.with(sg_name).and_return sg_id 11 | end 12 | 13 | context 'when given a single SG name' do 14 | it "resolves the security group" do 15 | expect(resolver.resolve(sg_name)).to eq sg_id 16 | end 17 | end 18 | 19 | context 'when given a an array of SG names' do 20 | let(:sg_id2) { 'sg-id2' } 21 | let(:sg_name2) { 'sg-name2' } 22 | 23 | before do 24 | expect(finder).to receive(:find).once.with(sg_name2).and_return sg_id2 25 | end 26 | 27 | it "resolves the security groups" do 28 | expect(resolver.resolve([sg_name, sg_name2])).to eq "#{sg_id},#{sg_id2}" 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/stack_master/parameter_resolvers/sns_topic_name_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::ParameterResolvers::SnsTopicName do 2 | let(:region) { 'us-east-1' } 3 | let(:stack_name) { 'my-stack' } 4 | let(:config) { double } 5 | 6 | def resolve(value) 7 | described_class.new(config, double(region: 'us-east-1')).resolve(value) 8 | end 9 | 10 | subject(:resolved_value) { resolve(value) } 11 | 12 | context 'when given a hash' do 13 | let(:value) { { not_expected: 1} } 14 | 15 | it 'raises an error' do 16 | expect { 17 | resolved_value 18 | }.to raise_error(ArgumentError) 19 | end 20 | end 21 | 22 | context 'when given a string value' do 23 | let(:value) { 'my-topic-name' } 24 | 25 | context 'the stack and sns topic name exist' do 26 | before do 27 | allow_any_instance_of(StackMaster::SnsTopicFinder).to receive(:find).with(value).and_return('myresolvedvalue') 28 | end 29 | 30 | it 'resolves the value' do 31 | expect(resolved_value).to eq 'myresolvedvalue' 32 | end 33 | end 34 | 35 | context "the topic doesn't exist" do 36 | it 'raises topic not found' do 37 | expect { 38 | resolved_value 39 | }.to raise_error(StackMaster::ParameterResolvers::SnsTopicName::TopicNotFound) 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/stack_master/parameter_validator_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::ParameterValidator do 2 | subject(:parameter_validator) { described_class.new(stack: stack, stack_definition: stack_definition) } 3 | 4 | let(:stack) { StackMaster::Stack.new(parameters: parameters, template_body: '{}', template_format: :json) } 5 | let(:parameter_files) { nil } 6 | let(:stack_definition) { StackMaster::StackDefinition.new(base_dir: '/base_dir', region: 'ap-southeast-2', stack_name: 'stack_name', parameter_files: parameter_files) } 7 | 8 | describe '#missing_parameters?' do 9 | subject { parameter_validator.missing_parameters? } 10 | 11 | context 'when a parameter has a nil value' do 12 | let(:parameters) { {'my_param' => nil} } 13 | 14 | it { should eq true } 15 | end 16 | 17 | context 'when no parameers have a nil value' do 18 | let(:parameters) { {'my_param' => '1'} } 19 | 20 | it { should eq false } 21 | end 22 | end 23 | 24 | describe '#error_message' do 25 | subject(:error_message) { parameter_validator.error_message } 26 | 27 | context 'when a parameter has a nil value' do 28 | let(:parameters) { {'Param1' => true, 'Param2' => nil, 'Param3' => 'string', 'Param4' => nil} } 29 | 30 | it 'returns a descriptive message' do 31 | expect(error_message).to eq(<<~MESSAGE) 32 | Empty/blank parameters detected. Please provide values for these parameters: 33 | - Param2 34 | - Param4 35 | Parameters will be read from files matching the following globs: 36 | - parameters/stack_name.y*ml 37 | - parameters/ap-southeast-2/stack_name.y*ml 38 | MESSAGE 39 | end 40 | end 41 | 42 | context 'when the stack definition is using explicit parameter files' do 43 | let(:parameters) { {'Param1' => true, 'Param2' => nil, 'Param3' => 'string', 'Param4' => nil} } 44 | let(:parameter_files) { ["params.yml"] } 45 | 46 | it 'returns a descriptive message' do 47 | expect(error_message).to eq(<<~MESSAGE) 48 | Empty/blank parameters detected. Please provide values for these parameters: 49 | - Param2 50 | - Param4 51 | Parameters are configured to be read from the following files: 52 | - /base_dir/parameters/params.yml 53 | MESSAGE 54 | end 55 | end 56 | 57 | context 'when no parameers have a nil value' do 58 | let(:parameters) { {'Param' => '1'} } 59 | 60 | it { should eq nil } 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/stack_master/prompter_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::Prompter do 2 | include StackMaster::Prompter 3 | 4 | context 'when STDIN is not a TTY' do 5 | before do 6 | allow(StackMaster.stdin).to receive(:tty?).and_return(false) 7 | end 8 | 9 | it 'defaults to no and outputs info about -y' do 10 | expect { ask?('blah') }.to output(/To force yes use -y/).to_stdout 11 | end 12 | end 13 | 14 | context 'when STDOUT is not a TTY' do 15 | before do 16 | allow(StackMaster.stdout).to receive(:tty?).and_return(false) 17 | end 18 | 19 | it 'defaults to no and outputs info about -y' do 20 | expect { ask?('blah') }.to output(/To force yes use -y/).to_stdout 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/stack_master/resolver_array_spec.rb: -------------------------------------------------------------------------------- 1 | require 'stack_master/resolver_array' 2 | 3 | RSpec.shared_examples_for 'a resolver' do 4 | it 'should create a TestResolvers class' do 5 | expect(array_resolver_class).to be_a Class 6 | end 7 | 8 | it 'should have TestResolver as a resolver class' do 9 | expect(array_resolver_instance).to respond_to :resolver_class 10 | expect(array_resolver_instance.resolver_class).to be TestResolver 11 | end 12 | end 13 | 14 | RSpec.describe 'StackMaster::ParameterResolvers::Resolver' do 15 | let(:array_resolver_instance) { array_resolver_class.new(nil, nil) } 16 | 17 | describe '.array_resolver' do 18 | context 'when using a default name' do 19 | before do 20 | class TestResolver < StackMaster::ParameterResolvers::Resolver 21 | array_resolver 22 | end 23 | end 24 | 25 | let(:array_resolver_class) { StackMaster::ParameterResolvers::TestResolvers } 26 | 27 | it_behaves_like 'a resolver' 28 | end 29 | 30 | context 'when using a specific name' do 31 | before do 32 | class TestResolver < StackMaster::ParameterResolvers::Resolver 33 | array_resolver class_name: 'SpecificResolver' 34 | end 35 | end 36 | 37 | let(:array_resolver_class) { StackMaster::ParameterResolvers::SpecificResolver } 38 | 39 | it_behaves_like 'a resolver' 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/stack_master/security_group_finder_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::SecurityGroupFinder do 2 | subject(:finder) { described_class.new(region) } 3 | let(:region) { 'us-east-1' } 4 | let(:group_name) { "our-api-BeanstalkSg-T4RKD99YOY2F" } 5 | let(:filter) do 6 | { 7 | filters: [ 8 | { 9 | name: "group-name", 10 | values: [group_name], 11 | }, 12 | ], 13 | } 14 | end 15 | 16 | describe "#find" do 17 | before do 18 | allow_any_instance_of(Aws::EC2::Resource).to receive(:security_groups).with(filter).and_return(security_groups) 19 | end 20 | 21 | context "one sg match" do 22 | let(:security_groups) { [ 23 | double(id: 'sg-a7d2ccc0') 24 | ] } 25 | it "returns the id" do 26 | expect(finder.find(group_name)).to eq 'sg-a7d2ccc0' 27 | end 28 | end 29 | 30 | context "more than one sg matches" do 31 | let(:security_groups) { [ 32 | double(id: 'sg-a7d2ccc0'), 33 | double(id: 'sg-a7d2ccc2'), 34 | ] } 35 | it "returns the id" do 36 | err = StackMaster::SecurityGroupFinder::MultipleSecurityGroupsFound 37 | expect { finder.find(group_name) }.to raise_error(err) 38 | end 39 | end 40 | 41 | context "no matches" do 42 | let(:security_groups) { [] } 43 | it "returns the id" do 44 | err = StackMaster::SecurityGroupFinder::SecurityGroupNotFound 45 | expect { finder.find(group_name) }.to raise_error(err) 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/stack_master/sns_topic_finder_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::SnsTopicFinder do 2 | 3 | subject(:finder) { described_class.new(region) } 4 | let(:region) { 'us-east-1' } 5 | let(:topics) do 6 | [ 7 | double(arn: 'arn:aws:sns:us-east-1:581634149801:topic1name'), 8 | double(arn: 'arn:aws:sns:us-east-1:581634149801:topic2name'), 9 | ] 10 | end 11 | before do 12 | allow_any_instance_of(Aws::SNS::Resource).to receive(:topics).and_return topics 13 | end 14 | 15 | describe '#find' do 16 | it 'finds the topics that exist' do 17 | expect(finder.find('topic1name')).to eq 'arn:aws:sns:us-east-1:581634149801:topic1name' 18 | expect(finder.find('topic2name')).to eq 'arn:aws:sns:us-east-1:581634149801:topic2name' 19 | end 20 | 21 | it 'raises an exception for topics that do not exist' do 22 | expect { finder.find('unknowntopics') }.to raise_error StackMaster::SnsTopicFinder::TopicNotFound 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/stack_master/sparkle_formation/compile_time/allowed_pattern_validator_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::SparkleFormation::CompileTime::AllowedPatternValidator do 2 | 3 | describe '#validate' do 4 | let(:name) { 'name' } 5 | let(:error_message) { -> (error, definition) { "#{name}:#{error} does not match allowed_pattern:#{definition[:allowed_pattern]}" } } 6 | 7 | context 'string validation' do 8 | let(:definition) { {type: :string, allowed_pattern: '^a'} } 9 | validate_valid_parameter('a') 10 | validate_valid_parameter(['a']) 11 | validate_invalid_parameter('b', ['b']) 12 | validate_invalid_parameter(['b'], ['b']) 13 | end 14 | 15 | context 'string validation with default' do 16 | let(:definition) { {type: :string, allowed_pattern: '^a', default: 'a'} } 17 | validate_valid_parameter(nil) 18 | end 19 | 20 | context 'string validation with multiple' do 21 | let(:definition) { {type: :string, allowed_pattern: '^a', multiple: true} } 22 | validate_valid_parameter('a,ab') 23 | validate_invalid_parameter('a,,ab', ['']) 24 | validate_invalid_parameter('a, ,ab', ['']) 25 | end 26 | 27 | context 'string validation with multiple default values' do 28 | let(:definition) { {type: :string, allowed_pattern: '^a', multiple: true, default:'a,a'} } 29 | validate_valid_parameter(nil) 30 | end 31 | 32 | context 'numerical validation' do 33 | let(:definition) { {type: :number, allowed_pattern: '^1'} } 34 | validate_valid_parameter(1) 35 | validate_valid_parameter('1') 36 | validate_valid_parameter([1]) 37 | validate_valid_parameter(['1']) 38 | validate_invalid_parameter(2, [2]) 39 | validate_invalid_parameter('2', ['2']) 40 | end 41 | 42 | context 'validation with default value' do 43 | let(:definition) { {type: :number, allowed_pattern: '^1', default: '1'} } 44 | validate_valid_parameter(nil) 45 | end 46 | end 47 | end -------------------------------------------------------------------------------- /spec/stack_master/sparkle_formation/compile_time/allowed_values_validator_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::SparkleFormation::CompileTime::AllowedValuesValidator do 2 | 3 | describe '#validate' do 4 | let(:name) { 'name' } 5 | let(:error_message) { -> (error, definition) { "#{name}:#{error} is not in allowed_values:#{definition[:allowed_values]}" } } 6 | 7 | context 'string validation' do 8 | let(:definition) { {type: :string, allowed_values: ['a']} } 9 | validate_valid_parameter('a') 10 | validate_valid_parameter(['a']) 11 | validate_invalid_parameter('b', ['b']) 12 | validate_invalid_parameter(['b'], ['b']) 13 | end 14 | 15 | context 'string validation with default' do 16 | let(:definition) { {type: :string, allowed_values: ['a'], default:'a'} } 17 | validate_valid_parameter(nil) 18 | end 19 | 20 | context 'string validation with multiple' do 21 | let(:definition) { {type: :string, allowed_values: ['a'], multiple: true} } 22 | validate_valid_parameter('a,a') 23 | validate_invalid_parameter( 'a,, a', ['']) 24 | validate_invalid_parameter( 'a,,b', ['', 'b']) 25 | end 26 | 27 | context 'string validation with multiple default values' do 28 | let(:definition) { {type: :string, allowed_values: ['a'], multiple: true, default: 'a,a'} } 29 | validate_valid_parameter(nil) 30 | end 31 | 32 | context 'numerical validation' do 33 | let(:definition) { {type: :number, allowed_values: [1]} } 34 | validate_valid_parameter(1) 35 | validate_valid_parameter('1') 36 | validate_valid_parameter([1]) 37 | validate_valid_parameter(['1']) 38 | validate_invalid_parameter(2, [2]) 39 | validate_invalid_parameter('2', ['2']) 40 | end 41 | 42 | context 'numerical validation with default value' do 43 | let(:definition) { {type: :number, allowed_values: [1], default: 1} } 44 | validate_valid_parameter(nil) 45 | end 46 | end 47 | end -------------------------------------------------------------------------------- /spec/stack_master/sparkle_formation/compile_time/definitions_validator_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::SparkleFormation::CompileTime::DefinitionsValidator do 2 | 3 | describe '#validate' do 4 | 5 | let(:key) {:key} 6 | let(:definition){ {key: {type: type}} } 7 | 8 | subject {described_class.new(definition)} 9 | 10 | [:string, :number].each do |type| 11 | 12 | context "with :#{type} type definition" do 13 | 14 | let(:type) {type} 15 | 16 | it 'should not raise an exception' do 17 | expect {subject.validate}.to_not raise_error 18 | end 19 | 20 | end 21 | 22 | end 23 | 24 | context 'with other type definition' do 25 | 26 | let(:type) {:other} 27 | 28 | it 'should not raise an exception' do 29 | expect {subject.validate}.to raise_error(ArgumentError, "Unknown compile time parameter type: #{key}:#{type} valid types are #{[:string, :number]}") 30 | end 31 | 32 | end 33 | 34 | end 35 | 36 | end -------------------------------------------------------------------------------- /spec/stack_master/sparkle_formation/compile_time/empty_validator_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::SparkleFormation::CompileTime::EmptyValidator do 2 | 3 | describe '#validate' do 4 | let(:name) { 'name' } 5 | let(:error_message) { -> (error, _definition) { "#{name} cannot contain empty parameters:#{error.inspect}" } } 6 | 7 | context 'string validation' do 8 | let(:definition) { {type: :string} } 9 | validate_valid_parameter('a') 10 | validate_valid_parameter(['a']) 11 | validate_invalid_parameter(nil, nil) 12 | validate_invalid_parameter(['a', nil], ['a', nil]) 13 | end 14 | 15 | context 'string validation with default' do 16 | let(:definition) { {type: :string, default: 'a'} } 17 | validate_valid_parameter(nil) 18 | end 19 | 20 | context 'string validation with multiples' do 21 | let(:definition) { {type: :string, multiple: true} } 22 | validate_valid_parameter('a,b') 23 | validate_valid_parameter('a,,b') 24 | end 25 | 26 | context 'string validation with multiples and defaults' do 27 | let(:definition) { {type: :string, multiple: true, default: 'a,b'} } 28 | validate_valid_parameter(nil) 29 | end 30 | 31 | context 'numerical validation' do 32 | let(:definition) { {type: :number} } 33 | validate_valid_parameter(1) 34 | validate_valid_parameter('1') 35 | validate_valid_parameter([1]) 36 | validate_valid_parameter(['1']) 37 | validate_invalid_parameter(nil, nil) 38 | validate_invalid_parameter([1, nil], [1, nil]) 39 | validate_invalid_parameter(['1', nil], ['1', nil]) 40 | end 41 | 42 | context 'numerical validation with default' do 43 | let(:definition) { {type: :number, default: '1'} } 44 | validate_valid_parameter(nil) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/stack_master/sparkle_formation/compile_time/max_length_validator_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::SparkleFormation::CompileTime::MaxLengthValidator do 2 | 3 | describe '#validate' do 4 | let(:name) { 'name' } 5 | let(:error_message) { -> (error, definition) { "#{name}:#{error} must not exceed max_length:#{definition[:max_length]} characters" } } 6 | 7 | context 'string validation' do 8 | let(:definition) { {type: :string, max_length: 1} } 9 | validate_valid_parameter('a') 10 | validate_valid_parameter(['a']) 11 | validate_invalid_parameter('ab', ['ab']) 12 | validate_invalid_parameter(['ab'], ['ab']) 13 | end 14 | 15 | context 'string validation with default value' do 16 | let(:definition) { {type: :string, max_length: 1, default: 'a'} } 17 | validate_valid_parameter(nil) 18 | end 19 | 20 | context 'string validation with multiples' do 21 | let(:definition) { {type: :string, max_length: 1, multiple: true} } 22 | validate_valid_parameter('a,a') 23 | validate_valid_parameter('a,,a') 24 | validate_invalid_parameter('a,, ab', ['ab']) 25 | end 26 | 27 | context 'string validation wtih multiples and default' do 28 | let(:definition) { {type: :string, max_length: 1, multiple: true, default: 'a,a'} } 29 | validate_valid_parameter(nil) 30 | end 31 | 32 | context 'numerical validation' do 33 | let(:definition) { {type: :number, max_length: 1} } 34 | validate_valid_parameter('ab') 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/stack_master/sparkle_formation/compile_time/max_size_validator_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::SparkleFormation::CompileTime::MaxSizeValidator do 2 | 3 | describe '#validate' do 4 | let(:name) { 'name' } 5 | let(:error_message) { -> (error, definition) { "#{name}:#{error} must not be greater than max_size:#{definition[:max_size]}" } } 6 | 7 | context 'numerical validation' do 8 | let(:definition) { {type: :number, max_size: 1} } 9 | validate_valid_parameter(1) 10 | validate_valid_parameter('1') 11 | validate_valid_parameter([1]) 12 | validate_valid_parameter(['1']) 13 | validate_invalid_parameter(2, [2]) 14 | validate_invalid_parameter('2', ['2']) 15 | end 16 | 17 | context 'numerical validation with default' do 18 | let(:definition) { {type: :number, max_size: 1, default: 1} } 19 | validate_valid_parameter(nil) 20 | end 21 | 22 | context 'string validation' do 23 | let(:definition) { {type: :string, max_size: 1} } 24 | validate_valid_parameter(2) 25 | end 26 | end 27 | end -------------------------------------------------------------------------------- /spec/stack_master/sparkle_formation/compile_time/min_length_validator_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::SparkleFormation::CompileTime::MinLengthValidator do 2 | 3 | describe '#validate' do 4 | let(:name) { 'name' } 5 | let(:error_message) { -> (error, definition) { "#{name}:#{error} must be at least min_length:#{definition[:min_length]} characters" } } 6 | 7 | context 'string validation' do 8 | let(:definition) { {type: :string, min_length: 2} } 9 | validate_valid_parameter('ab') 10 | validate_valid_parameter(['ab']) 11 | validate_invalid_parameter('a', ['a']) 12 | validate_invalid_parameter(['a'], ['a']) 13 | end 14 | 15 | context 'string validation with default value' do 16 | let(:definition) { {type: :string, min_length: 2, default: 'ab'} } 17 | validate_valid_parameter(nil) 18 | end 19 | 20 | context 'string validation with multiples' do 21 | let(:definition) { {type: :string, min_length: 2, multiple: true} } 22 | validate_valid_parameter('ab,cd') 23 | validate_invalid_parameter('a,, cd', ['a', '']) 24 | end 25 | 26 | context 'string validation with multiples and default' do 27 | let(:definition) { {type: :string, min_length: 2, multiple: true, default: 'ab,cd'} } 28 | validate_valid_parameter(nil) 29 | end 30 | 31 | context 'numerical validation' do 32 | let(:definition) { {type: :number, min_length: 2} } 33 | validate_valid_parameter('a') 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/stack_master/sparkle_formation/compile_time/min_size_validator_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::SparkleFormation::CompileTime::MinSizeValidator do 2 | 3 | describe '#validate' do 4 | let(:name) { 'name' } 5 | let(:error_message) { -> (error, definition) { "#{name}:#{error} must not be lesser than min_size:#{definition[:min_size]}" } } 6 | 7 | context 'numerical validation' do 8 | let(:definition) { {type: :number, min_size: 1} } 9 | validate_valid_parameter(1) 10 | validate_valid_parameter('1') 11 | validate_valid_parameter([1]) 12 | validate_valid_parameter(['1']) 13 | validate_invalid_parameter(0, [0]) 14 | validate_invalid_parameter('0', ['0']) 15 | end 16 | 17 | context 'numerical validation with default value' do 18 | let(:definition) { {type: :number, min_size: 1, default: 1} } 19 | validate_valid_parameter(nil) 20 | end 21 | 22 | context 'string validation' do 23 | let(:definition) { {type: :string, min_size: 1} } 24 | validate_valid_parameter(0) 25 | end 26 | 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/stack_master/sparkle_formation/compile_time/number_validator_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::SparkleFormation::CompileTime::NumberValidator do 2 | 3 | describe '#validate' do 4 | let(:name) { 'name' } 5 | let(:error_message) { -> (error, _definition) { "#{name}:#{error} are not Numbers" } } 6 | 7 | context 'numerical validation' do 8 | let(:definition) { {type: :number} } 9 | validate_valid_parameter(1) 10 | validate_valid_parameter(['1']) 11 | validate_invalid_parameter(['1.'], ['1.']) 12 | validate_invalid_parameter(['.1'], ['.1']) 13 | validate_invalid_parameter(['1.1.1'], ['1.1.1']) 14 | validate_invalid_parameter(['1a1'], ['1a1']) 15 | end 16 | 17 | context 'numerical validation with default' do 18 | let(:definition) { {type: :number, default: 1} } 19 | validate_valid_parameter(nil) 20 | end 21 | 22 | context 'numerical validation with multiples' do 23 | let(:definition) { {type: :number, multiple: true} } 24 | validate_valid_parameter('1,2') 25 | validate_valid_parameter([1, 2]) 26 | validate_invalid_parameter('1,1.', ['1.']) 27 | validate_invalid_parameter({}, [{}]) 28 | end 29 | 30 | context 'numerical validation with multiples and default' do 31 | let(:definition) { {type: :number, multiple: true, default: '1,2'} } 32 | validate_valid_parameter(nil) 33 | end 34 | 35 | context 'string validation' do 36 | let(:definition) { {type: :string} } 37 | validate_valid_parameter('a') 38 | end 39 | 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/stack_master/sparkle_formation/compile_time/parameters_validator_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::SparkleFormation::CompileTime::ParametersValidator do 2 | 3 | let(:definitions) {{ip: {type: :string}}} 4 | let(:parameters) {{'Ip' => '10.0.0.0'}} 5 | let(:value_validator_factory) {instance_double(StackMaster::SparkleFormation::CompileTime::ValueValidatorFactory)} 6 | let(:value_validator) {instance_double(StackMaster::SparkleFormation::CompileTime::ValueValidator)} 7 | 8 | subject {described_class.new(definitions, parameters)} 9 | 10 | before(:each) do 11 | allow(StackMaster::SparkleFormation::CompileTime::ValueValidatorFactory) 12 | .to receive(:new).and_return(value_validator_factory) 13 | allow(value_validator_factory) 14 | .to receive(:build).and_return([value_validator]) 15 | allow(value_validator).to receive(:validate) 16 | allow(value_validator).to receive(:is_valid).and_return(true) 17 | end 18 | 19 | describe '#validate' do 20 | 21 | it('should initialise the ValueValidatorFactory') do 22 | expect(StackMaster::SparkleFormation::CompileTime::ValueValidatorFactory).to receive(:new).with(:ip, {type: :string}, '10.0.0.0') 23 | subject.validate 24 | end 25 | 26 | it('should build validators') do 27 | expect(value_validator_factory).to receive(:build) 28 | subject.validate 29 | end 30 | 31 | it('should call validate on all validators') do 32 | expect(value_validator).to receive(:validate) 33 | subject.validate 34 | end 35 | 36 | context 'when the validators are valid' do 37 | 38 | before(:each) do 39 | allow(value_validator).to receive(:is_valid).and_return(true) 40 | end 41 | 42 | it('should not raise any error') do 43 | expect{subject.validate}.to_not raise_error 44 | end 45 | 46 | end 47 | 48 | context 'when the validators are invalid' do 49 | 50 | let(:error){'error'} 51 | 52 | before(:each) do 53 | allow(value_validator).to receive(:is_valid).and_return(false) 54 | allow(value_validator).to receive(:error).and_return(error) 55 | end 56 | 57 | it('should raise an error') do 58 | expect {subject.validate}.to raise_error(ArgumentError, "Invalid compile time parameter: #{error}") 59 | end 60 | 61 | end 62 | 63 | end 64 | 65 | end -------------------------------------------------------------------------------- /spec/stack_master/sparkle_formation/compile_time/state_builder_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::SparkleFormation::CompileTime::StateBuilder do 2 | 3 | let(:definitions) {{ip: {type: :string}, size: {type: :number}}} 4 | let(:ip) {'10.0.0.0'} 5 | let(:size) {nil} 6 | let(:parameters) {{'Ip' => ip}} 7 | let(:ip_builder) {instance_double(StackMaster::SparkleFormation::CompileTime::ValueBuilder)} 8 | let(:size_builder) {instance_double(StackMaster::SparkleFormation::CompileTime::ValueBuilder)} 9 | 10 | subject {described_class.new(definitions, parameters)} 11 | 12 | before(:each) do 13 | allow(StackMaster::SparkleFormation::CompileTime::ValueBuilder).to receive(:new).with({type: :string}, ip).and_return(ip_builder) 14 | allow(StackMaster::SparkleFormation::CompileTime::ValueBuilder).to receive(:new).with({type: :number}, size).and_return(size_builder) 15 | allow(ip_builder).to receive(:build).and_return(ip) 16 | allow(size_builder).to receive(:build).and_return(size) 17 | end 18 | 19 | describe '#build' do 20 | 21 | it 'should create state' do 22 | expected = {ip: '10.0.0.0', size: nil} 23 | expect(subject.build).to eq(expected) 24 | end 25 | 26 | end 27 | 28 | end 29 | -------------------------------------------------------------------------------- /spec/stack_master/sparkle_formation/compile_time/string_validator_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::SparkleFormation::CompileTime::StringValidator do 2 | 3 | describe '#validate' do 4 | let(:name) { 'name' } 5 | let(:error_message) { -> (error, _definition) { "#{name}:#{error} are not Strings" } } 6 | 7 | context 'string validation' do 8 | let(:definition) { {type: :string} } 9 | validate_valid_parameter('a') 10 | validate_valid_parameter(['']) 11 | validate_invalid_parameter({}, [{}]) 12 | end 13 | 14 | context 'string validation default' do 15 | let(:definition) { {type: :string, default: 'a'} } 16 | validate_valid_parameter(nil) 17 | end 18 | 19 | context 'string validation with multiples' do 20 | let(:definition) { {type: :string, multiple: true} } 21 | validate_valid_parameter('a,b') 22 | validate_invalid_parameter([{}], [{}]) 23 | end 24 | 25 | context 'string validation with multiples and default' do 26 | let(:definition) { {type: :string, multiple: true, default: 'a,a'} } 27 | validate_valid_parameter(nil) 28 | end 29 | 30 | context 'numerical validation' do 31 | let(:definition) { {type: :number} } 32 | validate_valid_parameter(1) 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/stack_master/sparkle_formation/compile_time/value_build_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::SparkleFormation::CompileTime::ValueBuilder do 2 | scenarios= [ 3 | {definition: {type: :string}, parameter: nil, expected: nil}, 4 | {definition: {type: :string}, parameter: 'a', expected: 'a'}, 5 | {definition: {type: :string}, parameter: ['a'], expected: ['a']}, 6 | 7 | {definition: {type: :string, default: 'a'}, parameter: nil, expected: 'a'}, 8 | 9 | {definition: {type: :string, multiple: true}, parameter: 'a', expected: ['a']}, 10 | {definition: {type: :string, multiple: true}, parameter: 'a,b', expected: ['a', 'b']}, 11 | {definition: {type: :string, multiple: true}, parameter: 'a, b', expected: ['a', 'b']}, 12 | 13 | {definition: {type: :string, multiple: true, default: 'a'}, parameter: nil, expected: ['a']}, 14 | 15 | {definition: {type: :number}, parameter: nil, expected: nil}, 16 | {definition: {type: :number}, parameter: 1, expected: 1}, 17 | {definition: {type: :number}, parameter: '1', expected: 1}, 18 | {definition: {type: :number}, parameter: [1], expected: [1]}, 19 | {definition: {type: :number}, parameter: ['1'], expected: [1]}, 20 | 21 | {definition: {type: :number, default: '1'}, parameter: nil, expected: 1}, 22 | 23 | {definition: {type: :number, multiple: true}, parameter: 1, expected: 1}, 24 | {definition: {type: :number, multiple: true}, parameter: '1', expected: [1]}, 25 | {definition: {type: :number, multiple: true}, parameter: '1,2', expected: [1,2]}, 26 | {definition: {type: :number, multiple: true}, parameter: '1, 2', expected: [1,2]}, 27 | 28 | {definition: {type: :number, multiple: true, default: '1'}, parameter: nil, expected: [1]} 29 | ] 30 | 31 | describe '#build' do 32 | 33 | scenarios.each do |scenario| 34 | 35 | description = scenario.clone.tap {|clone| clone.delete(:expected)} 36 | context "when #{description}" do 37 | 38 | definition = scenario[:definition] 39 | parameter = scenario[:parameter] 40 | expected = scenario[:expected] 41 | 42 | it("should have a value of #{expected}") do 43 | expect(described_class.new(definition, parameter).build).to eq expected 44 | end 45 | 46 | end 47 | 48 | end 49 | 50 | end 51 | 52 | end -------------------------------------------------------------------------------- /spec/stack_master/sparkle_formation/compile_time/value_validator_factory_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::SparkleFormation::CompileTime::ValueValidatorFactory do 2 | 3 | let(:name) {:ip} 4 | let(:definition) {{type: :string}} 5 | let(:parameter) {{'Ip' => '10.0.0.0'}} 6 | 7 | subject {described_class.new(name, definition, parameter)} 8 | 9 | describe '#build' do 10 | 11 | validators = [ 12 | StackMaster::SparkleFormation::CompileTime::EmptyValidator, 13 | StackMaster::SparkleFormation::CompileTime::StringValidator, 14 | StackMaster::SparkleFormation::CompileTime::NumberValidator, 15 | StackMaster::SparkleFormation::CompileTime::AllowedValuesValidator, 16 | StackMaster::SparkleFormation::CompileTime::AllowedPatternValidator, 17 | StackMaster::SparkleFormation::CompileTime::MaxLengthValidator, 18 | StackMaster::SparkleFormation::CompileTime::MinLengthValidator, 19 | StackMaster::SparkleFormation::CompileTime::MaxSizeValidator, 20 | StackMaster::SparkleFormation::CompileTime::MinSizeValidator] 21 | 22 | after(:each){subject.build} 23 | 24 | validators.each do |validator| 25 | 26 | it "should build a #{validator} with correct parameters" do 27 | expect(validator).to receive(:new).with(name, definition, parameter) 28 | end 29 | 30 | end 31 | 32 | it 'should build in the correct order' do 33 | validators.each do |validator| 34 | expect(validator).to receive(:new).ordered 35 | end 36 | end 37 | 38 | end 39 | 40 | end -------------------------------------------------------------------------------- /spec/stack_master/stack_events/fetcher_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::StackEvents::Fetcher do 2 | let(:cf) { Aws::CloudFormation::Client.new } 3 | let(:stack_name) { 'blah' } 4 | 5 | before do 6 | allow(Aws::CloudFormation::Client).to receive(:new).and_return(cf) 7 | allow(StackMaster::StackEvents::Streamer).to receive(:stream) 8 | allow(StackMaster::PagedResponseAccumulator).to receive(:call).with(StackMaster.cloud_formation_driver, :describe_stack_events, { stack_name: stack_name }, :stack_events).and_return(OpenStruct.new(stack_events: events)) 9 | end 10 | 11 | context 'with 2 stack events' do 12 | let(:events) { [ 13 | OpenStruct.new(event_id: '1', stack_id: '1', stack_name: 'blah', timestamp: Time.now), 14 | OpenStruct.new(event_id: '2', stack_id: '1', stack_name: 'blah', timestamp: Time.now) 15 | ] } 16 | 17 | it 'returns stack events' do 18 | events = StackMaster::StackEvents::Fetcher.fetch(stack_name, 'us-east-1') 19 | expect(events.count).to eq 2 20 | end 21 | end 22 | 23 | context 'filtering with a from timestamp' do 24 | let(:two_pm) { Time.parse('2015-10-27 14:00') } 25 | let(:three_pm) { Time.parse('2015-10-27 15:00') } 26 | let(:four_pm) { Time.parse('2015-10-27 16:00') } 27 | 28 | let(:events) { 29 | [ 30 | OpenStruct.new(event_id: '1', stack_id: '1', stack_name: 'blah', timestamp: two_pm), 31 | OpenStruct.new(event_id: '2', stack_id: '1', stack_name: 'blah', timestamp: four_pm), 32 | ] 33 | } 34 | 35 | it 'only returns events after the timestamp' do 36 | events = StackMaster::StackEvents::Fetcher.fetch(stack_name, 'us-east-1', from: three_pm) 37 | expect(events.map(&:event_id)).to eq ['2'] 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/stack_master/stack_events/presenter_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::StackEvents::Presenter do 2 | describe "#print_event" do 3 | let(:time) { Time.new(2001,1,1,2,2,2) } 4 | let(:event) do 5 | double(:event, 6 | timestamp: time, 7 | logical_resource_id: 'MyAwesomeQueue', 8 | resource_type: 'AWS::SQS::Queue', 9 | resource_status: 'CREATE_IN_PROGRESS', 10 | resource_status_reason: 'Resource creation Initiated') 11 | end 12 | subject(:print_event) { described_class.print_event($stdout, event) } 13 | 14 | it "nicely presents event data" do 15 | expect { print_event }.to output("\e[33m2001-01-01 02:02:02 #{time.strftime('%z')} MyAwesomeQueue AWS::SQS::Queue CREATE_IN_PROGRESS Resource creation Initiated\e[0m\n").to_stdout 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/stack_master/stack_events/streamer_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::StackEvents::Streamer do 2 | let(:events_first_call) { [ 3 | OpenStruct.new(event_id: '1', resource_status: 'BLAH', timestamp: Time.now), 4 | OpenStruct.new(event_id: '2', resource_status: 'BLAH', timestamp: Time.now), 5 | OpenStruct.new(event_id: '3', resource_status: 'BLAH', timestamp: Time.now), 6 | ] } 7 | let(:events_second_call) { 8 | events_first_call + [ 9 | OpenStruct.new(event_id: '4', resource_status: 'UPDATE_COMPLETE', resource_type: 'AWS::CloudFormation::Stack', logical_resource_id: stack_name, timestamp: Time.now) 10 | ] 11 | } 12 | let(:stack_name) { 'stack-name' } 13 | let(:region) { 'us-east-1' } 14 | let(:now) { Time.now } 15 | 16 | before do 17 | allow(StackMaster::StackEvents::Fetcher).to receive(:fetch).with(stack_name, region, from: now).and_return(events_first_call, events_second_call) 18 | allow(Time).to receive(:now).and_return(now) 19 | end 20 | 21 | it 'returns after seeing a finish state' do 22 | events = [] 23 | StackMaster::StackEvents::Streamer.stream(stack_name, region, sleep_between_fetches: 0) do |event| 24 | events << event 25 | end 26 | end 27 | 28 | it 'streams events to an io object' do 29 | io = StringIO.new 30 | StackMaster::StackEvents::Streamer.stream(stack_name, region, sleep_between_fetches: 0, io: io) 31 | expect(io.string).to include('UPDATE_COMPLETE') 32 | end 33 | 34 | context "the stack is in a failed state" do 35 | let(:events_second_call) { 36 | events_first_call + [ 37 | OpenStruct.new(event_id: '4', resource_status: 'ROLLBACK_FAILED', resource_type: 'AWS::CloudFormation::Stack', logical_resource_id: stack_name, timestamp: Time.now) 38 | ] 39 | } 40 | 41 | it 'raises an error on failure' do 42 | expect { 43 | StackMaster::StackEvents::Streamer.stream(stack_name, region, sleep_between_fetches: 0) 44 | }.to raise_error(StackMaster::StackEvents::Streamer::StackFailed) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/stack_master/template_compiler_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::TemplateCompiler do 2 | describe '.compile' do 3 | let(:config) { double(template_compilers: { fab: :test_template_compiler, rb: :test_template_compiler }) } 4 | let(:template) { 'template.fab' } 5 | let(:template_dir) { '/base_dir/templates' } 6 | let(:compile_time_parameters) { { 'InstanceType' => 't2.medium' } } 7 | 8 | class TestTemplateCompiler 9 | def self.require_dependencies; end 10 | def self.compile(template_dir, template, compile_time_parameters, compile_options); end 11 | end 12 | 13 | context 'when a template compiler is explicitly specified' do 14 | it 'uses it' do 15 | expect(StackMaster::TemplateCompilers::SparkleFormation).to receive(:compile).with('/base_dir/templates', 'template', compile_time_parameters, anything) 16 | StackMaster::TemplateCompiler.compile(config, :sparkle_formation, '/base_dir/templates', 'template', compile_time_parameters, compile_time_parameters) 17 | end 18 | end 19 | 20 | context 'when a template compiler is registered for the given file type' do 21 | before { 22 | StackMaster::TemplateCompiler.register(:test_template_compiler, TestTemplateCompiler) 23 | } 24 | 25 | it 'compiles the template using the relevant template compiler' do 26 | expect(TestTemplateCompiler).to receive(:compile).with(nil, template, compile_time_parameters, anything) 27 | StackMaster::TemplateCompiler.compile(config, nil, nil, template, compile_time_parameters, compile_time_parameters) 28 | end 29 | 30 | it 'passes compile_options to the template compiler' do 31 | opts = {foo: 1, bar: true, baz: "meh"} 32 | expect(TestTemplateCompiler).to receive(:compile).with(nil, template, compile_time_parameters, opts) 33 | StackMaster::TemplateCompiler.compile(config, nil, nil, template, compile_time_parameters,opts) 34 | end 35 | 36 | context 'when template compilation fails' do 37 | before { allow(TestTemplateCompiler).to receive(:compile).and_raise(RuntimeError) } 38 | 39 | it 'raise TemplateCompilationFailed exception' do 40 | expect{ StackMaster::TemplateCompiler.compile(config, nil, template_dir, template, compile_time_parameters, compile_time_parameters) 41 | }.to raise_error( 42 | StackMaster::TemplateCompiler::TemplateCompilationFailed, /^Failed to compile/) 43 | end 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/stack_master/template_compilers/cfndsl_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::TemplateCompilers::Cfndsl do 2 | 3 | let(:compile_time_parameters) { {'InstanceType' => 't2.medium'} } 4 | 5 | before(:all) { described_class.require_dependencies } 6 | let(:template_dir) { 'spec/fixtures/templates/rb/cfndsl/' } 7 | 8 | describe '.compile' do 9 | def compile 10 | described_class.compile(template_dir, template, compile_time_parameters) 11 | end 12 | 13 | context 'valid cfndsl template' do 14 | let(:template) { 'sample.rb' } 15 | let(:valid_compiled_json_path) { 'spec/fixtures/templates/rb/cfndsl/sample.json' } 16 | 17 | it 'produces valid JSON' do 18 | valid_compiled_json = File.read(valid_compiled_json_path) 19 | expect(JSON.parse(compile)).to eq(JSON.parse(valid_compiled_json)) 20 | end 21 | end 22 | 23 | context 'with compile time parameters' do 24 | let(:template) { 'sample-ctp.rb' } 25 | let(:valid_compiled_json_path) { 'spec/fixtures/templates/rb/cfndsl/sample-ctp.json' } 26 | 27 | it 'produces valid JSON' do 28 | valid_compiled_json = File.read(valid_compiled_json_path) 29 | expect(JSON.parse(compile)).to eq(JSON.parse(valid_compiled_json)) 30 | end 31 | 32 | context 'compiling multiple times' do 33 | let(:compile_time_parameters) { {'InstanceType' => 't2.medium', 'DisableApiTermination' => 'true'} } 34 | let(:template) { 'sample-ctp-repeated.rb' } 35 | 36 | it 'does not leak compile time params across invocations' do 37 | expect { 38 | compile_time_parameters.delete("DisableApiTermination") 39 | }.to change { JSON.parse(compile)["Resources"]["MyInstance"]["Properties"]["DisableApiTermination"] }.from('true').to('false') 40 | end 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/stack_master/template_compilers/json_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::TemplateCompilers::Json do 2 | 3 | let(:compile_time_parameters) { { 'InstanceType' => 't2.medium' } } 4 | 5 | describe '.compile' do 6 | def compile 7 | described_class.compile(stack_definition.template_dir, stack_definition.template, compile_time_parameters) 8 | end 9 | 10 | let(:stack_definition) { StackMaster::StackDefinition.new(template_dir: File.dirname(template_file_path), 11 | template: File.basename(template_file_path)) } 12 | let(:template_file_path) { '/base_dir/templates/template.json' } 13 | 14 | context "small json template" do 15 | before do 16 | allow(File).to receive(:read).with(template_file_path).and_return('{ }') 17 | end 18 | 19 | it "reads from the template file path" do 20 | expect(compile).to eq('{ }') 21 | end 22 | end 23 | 24 | context 'extra big json template' do 25 | before do 26 | allow(File).to receive(:read).with(template_file_path).and_return("{ #{' ' * 60000} }") 27 | end 28 | 29 | it "reads from the template file path" do 30 | expect(compile).to eq('{}') 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/stack_master/template_compilers/yaml_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::TemplateCompilers::Yaml do 2 | describe '.compile' do 3 | 4 | let(:compile_time_parameters) { {'InstanceType' => 't2.medium'} } 5 | 6 | def compile 7 | described_class.compile(stack_definition.template_dir, stack_definition.template, compile_time_parameters) 8 | end 9 | 10 | context 'valid YAML template' do 11 | let(:stack_definition) { StackMaster::StackDefinition.new(template_dir: 'spec/fixtures/templates/yml', 12 | template: 'valid_myapp_vpc.yml') } 13 | 14 | it 'produces valid YAML' do 15 | valid_myapp_vpc_yaml = File.read(stack_definition.template_file_path) 16 | 17 | expect(compile).to eq(valid_myapp_vpc_yaml) 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/stack_master/template_utils_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::TemplateUtils do 2 | describe "#identify_template_format" do 3 | subject { described_class.identify_template_format(template_body) } 4 | 5 | context "with a json template body" do 6 | let(:template_body) { '{"AWSTemplateFormatVersion": "2010-09-09"}' } 7 | 8 | it { is_expected.to eq(:json) } 9 | 10 | context "starting with a blank line with whitespace" do 11 | let(:template_body) { "\n " + '{"AWSTemplateFormatVersion" : "2010-09-09"}' } 12 | 13 | it { is_expected.to eq(:json) } 14 | end 15 | end 16 | 17 | context "with a non-json template body" do 18 | let(:template_body) { 'AWSTemplateFormatVersion: 2010-09-09' } 19 | 20 | it { is_expected.to eq(:yaml) } 21 | end 22 | end 23 | 24 | describe "#maybe_compressed_template_body" do 25 | subject(:maybe_compressed_template_body) do 26 | described_class.maybe_compressed_template_body(template_body) 27 | end 28 | context "undersized json" do 29 | let(:template_body) { '{ }' } 30 | 31 | it "leaves the json alone if it's not too large" do 32 | expect(maybe_compressed_template_body).to eq('{ }') 33 | end 34 | end 35 | 36 | context "oversized json" do 37 | let(:template_body) { "{#{' ' * 60000}}" } 38 | it "compresses the json when it's overly bulbous" do 39 | expect(maybe_compressed_template_body).to eq('{}') 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/stack_master/test_driver/s3_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::TestDriver::S3 do 2 | subject(:s3_driver) { described_class.new } 3 | let(:bucket) { 'test-bucket' } 4 | let(:prefix) { 'test-prefix' } 5 | let(:files) { { 'test-file' => { path: 'path', body: 'body' } } } 6 | let(:region) { 'us-east-1' } 7 | 8 | it 'uploads and finds files' do 9 | s3_driver.upload_files(bucket: bucket, 10 | prefix: prefix, 11 | region: region, 12 | files: files) 13 | file = s3_driver.find_file(bucket: bucket, 14 | object_key: [prefix, 'test-file'].compact.join('/')) 15 | expect(file).to be 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/stack_master/utils_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster::Utils do 2 | describe ".hash_to_aws_tags" do 3 | let(:tags) { {'environment' => 'production'} } 4 | subject(:aws_tags) { StackMaster::Utils.hash_to_aws_tags(tags) } 5 | 6 | it "converts the tags attribute to aws format" do 7 | expect(aws_tags).to eq([{key: 'environment', value: 'production'}]) 8 | end 9 | 10 | context "tags is nil" do 11 | let(:tags) { nil } 12 | 13 | it "returns nil" do 14 | expect(aws_tags).to eq([]) 15 | end 16 | end 17 | end 18 | 19 | describe ".hash_to_aws_parameters" do 20 | let(:params) { { 'param1' => 'value1', 'param2' => 'value2' } } 21 | subject(:aws_params) { StackMaster::Utils.hash_to_aws_parameters(params) } 22 | 23 | it "converts to aws parameters" do 24 | expect(aws_params).to eq([ 25 | { parameter_key: 'param1', parameter_value: 'value1' }, 26 | { parameter_key: 'param2', parameter_value: 'value2' } 27 | ]) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/stack_master_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StackMaster do 2 | describe '.debug' do 3 | let(:message) { "Doing some stuff" } 4 | 5 | context 'when debugging' do 6 | before { allow(StackMaster).to receive(:debug?).and_return(true) } 7 | 8 | it 'outputs the message to STDERR' do 9 | expect { StackMaster.debug(message) }.to output(/\[DEBUG\] #{message}/).to_stderr 10 | end 11 | end 12 | 13 | context 'when not debugging' do 14 | before { allow(StackMaster).to receive(:debug?).and_return(false) } 15 | 16 | it "doesn't output the message to STDERR" do 17 | expect { StackMaster.debug(message) }.to output("").to_stderr 18 | end 19 | end 20 | end 21 | 22 | describe '.debug?' do 23 | subject { StackMaster.debug? } 24 | 25 | context "when debug! isn't called" do 26 | it { should eq false } 27 | end 28 | 29 | context 'when debug! is called' do 30 | before { StackMaster.debug! } 31 | 32 | it { should eq true } 33 | 34 | after { StackMaster.instance_variable_set('@debug', false) } 35 | end 36 | end 37 | 38 | describe '.interactive?' do 39 | subject { StackMaster.interactive? } 40 | 41 | context "when non_interactive! isn't called" do 42 | it { should eq true } 43 | end 44 | 45 | context 'when non_interactive! is called' do 46 | before { StackMaster.non_interactive! } 47 | 48 | it { should eq false } 49 | 50 | after { StackMaster.instance_variable_set('@non_interactive', false) } 51 | end 52 | end 53 | 54 | describe '.non_interactive?' do 55 | subject { StackMaster.non_interactive? } 56 | 57 | context "when non_interactive! isn't called" do 58 | it { should eq false } 59 | end 60 | 61 | context 'when non_interactive! is called' do 62 | before { StackMaster.non_interactive! } 63 | 64 | it { should eq true } 65 | 66 | after { StackMaster.instance_variable_set('@non_interactive', false) } 67 | end 68 | end 69 | 70 | describe '.non_interactive_answer' do 71 | it 'defaults to y' do 72 | expect(StackMaster.non_interactive_answer).to eq 'y' 73 | end 74 | 75 | it 'can be overridden' do 76 | StackMaster.non_interactive_answer = 'n' 77 | expect(StackMaster.non_interactive_answer).to eq 'n' 78 | StackMaster.instance_variable_set('@non_interactive_answer', 'y') 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /spec/support/aruba.rb: -------------------------------------------------------------------------------- 1 | require 'aruba/rspec' 2 | require 'aruba/processes/in_process' 3 | 4 | Aruba.configure do |config| 5 | config.command_launcher = :in_process 6 | config.main_class = StackMaster::CLI 7 | end 8 | -------------------------------------------------------------------------------- /spec/support/aws_stubs.rb: -------------------------------------------------------------------------------- 1 | Aws.config[:stub_responses] = true 2 | 3 | module AwsHelpers 4 | def stub_drift_detection(stack_drift_detection_id: "1", stack_drift_status: "IN_SYNC") 5 | cfn.stub_responses(:detect_stack_drift, { stack_drift_detection_id: stack_drift_detection_id }) 6 | cfn.stub_responses( 7 | :describe_stack_drift_detection_status, 8 | { 9 | stack_id: "1", 10 | timestamp: Time.now, 11 | stack_drift_detection_id: stack_drift_detection_id, 12 | stack_drift_status: stack_drift_status, 13 | detection_status: "DETECTION_COMPLETE" 14 | } 15 | ) 16 | end 17 | 18 | def stub_stack_resource_drift(stack_name:, stack_resource_drifts:) 19 | cfn.stub_responses(:describe_stack_resource_drifts, { stack_resource_drifts: stack_resource_drifts }) 20 | end 21 | end 22 | 23 | RSpec.configure do |config| 24 | config.include(AwsHelpers) 25 | end 26 | -------------------------------------------------------------------------------- /spec/support/validator_spec.rb: -------------------------------------------------------------------------------- 1 | def validate_valid_parameter(parameter) 2 | context "with parameter #{parameter}" do 3 | subject { described_class.new('name', definition, parameter).tap { |validator| validator.validate } } 4 | 5 | it 'is valid' do 6 | expect(subject.is_valid).to be_truthy 7 | end 8 | end 9 | end 10 | 11 | def validate_invalid_parameter(parameter, errors) 12 | context "with parameter #{parameter}" do 13 | subject { described_class.new(name, definition, parameter).tap { |validator| validator.validate } } 14 | 15 | it 'is not valid' do 16 | expect(subject.is_valid).to be_falsey 17 | end 18 | 19 | it 'has an error' do 20 | expect(subject.error).to eql error_message.call(errors, definition) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /stacktemplates/parameter_region.yml: -------------------------------------------------------------------------------- 1 | # Add parameters here: 2 | # param1: value1 3 | # param2: value2 4 | -------------------------------------------------------------------------------- /stacktemplates/parameter_stack_name.yml: -------------------------------------------------------------------------------- 1 | # Add parameters here: 2 | # param1: value1 3 | # param2: value2 4 | -------------------------------------------------------------------------------- /stacktemplates/stack.json.erb: -------------------------------------------------------------------------------- 1 | { 2 | "AWSTemplateFormatVersion" : "2010-09-09", 3 | "Description" : "Cloudformation stack for <%= stack_name %>", 4 | 5 | "Parameters" : { 6 | "InstanceType" : { 7 | "Description" : "EC2 instance type", 8 | "Type" : "String" 9 | } 10 | }, 11 | 12 | "Mappings" : { 13 | }, 14 | 15 | "Resources" : { 16 | }, 17 | 18 | "Outputs" : { 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /stacktemplates/stack_master.yml.erb: -------------------------------------------------------------------------------- 1 | stacks: 2 | <%= region %>: 3 | <%= stack_name %>: 4 | template: <%= stack_name %>.json 5 | tags: 6 | environment: production 7 | --------------------------------------------------------------------------------