├── .gitignore ├── .rspec ├── .travis.yml ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin └── piculet ├── lib ├── piculet.rb └── piculet │ ├── client.rb │ ├── dsl.rb │ ├── dsl │ ├── converter.rb │ ├── ec2.rb │ ├── permission.rb │ ├── permissions.rb │ └── security-group.rb │ ├── exporter.rb │ ├── ext │ ├── ec2-owner-id-ext.rb │ ├── ip-permission-collection-ext.rb │ ├── security-group.rb │ └── string-ext.rb │ ├── logger.rb │ ├── template-helper.rb │ ├── utils.rb │ ├── version.rb │ └── wrapper │ ├── ec2-wrapper.rb │ ├── permission-collection.rb │ ├── permission.rb │ ├── security-group-collection.rb │ └── security-group.rb ├── piculet.gemspec └── spec ├── piculet_create_permission_spec.rb ├── piculet_delete_permission_spec.rb ├── piculet_merge_spec.rb ├── piculet_spec.rb ├── piculet_update_permission_spec.rb ├── piculet_update_tags_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | Groupfile 19 | *.group 20 | *~ 21 | *.sw? 22 | /_site/ 23 | *.json 24 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --colour 2 | --require spec_helper 3 | --require rspec/instafail 4 | --format RSpec::Instafail 5 | --format progress 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: ruby 3 | rvm: 4 | - 2.0.0 5 | script: 6 | - bundle exec rake 7 | env: 8 | global: 9 | - TEST_AWS_REGION=ap-northeast-1 10 | - TEST_VPC_ID=vpc-4f803c27 11 | - TEST_OWNER_ID=003530583122 12 | - TEST_EXPORT_DELAY=1 13 | - secure: FN+yewcF3x4QWI59Uszj2mCxcz59Yq7Vlt+R+YNPtqMYkQ7Iqg5Sxdb/fGslHrLwu9L48StLBTMQ3PAmPcZ+PNykZPHdrn0TENeomcIP/wskJZ+gI2R0YqDphpCOPZ9wBXtjuprRZFg+50P+b7+v25kG9ikn+lC+D7LJjGnuVxM= 14 | - secure: E28xS2ILCfdjjBdML0wKNtv2uCWFXQuvx6Fdc77Q27GulaH7DAsTAh2UEbT7IHO2cvjnD9x8KcsxGOibxsvG/JYOw9qpv/GyBCqLJsqG6SE4p6uPTmrmH+lcTVdCH/VfAMiqHApeUQR3OY6z3cfKwmIfXXO2bPUD3CQ6aX7rmxI= 15 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in piculet.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 winebarrel 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Piculet 2 | 3 | Piculet is a tool to manage EC2 Security Group. 4 | 5 | It defines the state of EC2 Security Group using DSL, and updates EC2 Security Group according to DSL. 6 | 7 | [![Gem Version](https://badge.fury.io/rb/piculet.svg)](http://badge.fury.io/rb/piculet) 8 | [![Build Status](https://travis-ci.org/winebarrel/piculet.svg?branch=master)](https://travis-ci.org/winebarrel/piculet) 9 | 10 | ## Notice 11 | 12 | * `>= 0.2.9` 13 | * Add ip/group duplicate check [PR#16](https://github.com/winebarrel/piculet/pull/16) 14 | * Add `--exclude-tags` option [PR#17](https://github.com/winebarrel/piculet/pull/18) 15 | * Support Template 16 | * Add `--split-more` option 17 | * Single port support: `permission :tcp, 80` 18 | 19 | ## Installation 20 | 21 | Add this line to your application's Gemfile: 22 | 23 | gem 'piculet' 24 | 25 | And then execute: 26 | 27 | $ bundle 28 | 29 | Or install it yourself as: 30 | 31 | $ gem install piculet 32 | 33 | ## Usage 34 | 35 | ```sh 36 | export AWS_ACCESS_KEY_ID='...' 37 | export AWS_SECRET_ACCESS_KEY='...' 38 | export AWS_REGION='ap-northeast-1' 39 | #export AWS_OWNER_ID='123456789012' 40 | # Note: If you do not set the OWNER_ID, 41 | # Piculet get the OWNER_ID from GetUser(IAM) or CreateSecurityGroup(EC2) 42 | piculet -e -o Groupfile # export EC2 SecurityGroup 43 | vi Groupfile 44 | piculet -a --dry-run 45 | piculet -a # apply `Groupfile` to EC2 SecurityGroup 46 | ``` 47 | 48 | ## Help 49 | ``` 50 | Usage: piculet [options] 51 | -p, --profile PROFILE_NAME 52 | --credentials-path PATH 53 | -k, --access-key ACCESS_KEY 54 | -s, --secret-key SECRET_KEY 55 | -r, --region REGION 56 | -a, --apply 57 | -f, --file FILE 58 | -n, --names SG_LIST 59 | -x, --exclude SG_LIST 60 | -t, --exclude-tags TAG_LIST 61 | --ec2s VPC_IDS 62 | --dry-run 63 | -e, --export 64 | -o, --output FILE 65 | --split 66 | --split-more 67 | --format=FORMAT 68 | --no-color 69 | --debug 70 | ``` 71 | 72 | ## Groupfile example 73 | 74 | ```ruby 75 | require 'other/groupfile' 76 | 77 | ec2 do 78 | security_group "default" do 79 | description "default group for EC2 Classic" 80 | 81 | tags( 82 | "key1" => "value1", 83 | "key2" => "value2" 84 | ) 85 | 86 | ingress do 87 | permission :tcp, 0..65535 do 88 | groups( 89 | "default" 90 | ) 91 | end 92 | permission :udp, 0..65535 do 93 | groups( 94 | "default" 95 | ) 96 | end 97 | permission :icmp, -1..-1 do 98 | groups( 99 | "default" 100 | ) 101 | end 102 | permission :tcp, 22..22 do 103 | ip_ranges( 104 | "0.0.0.0/0" 105 | ) 106 | end 107 | permission :udp, 60000..61000 do 108 | ip_ranges( 109 | "0.0.0.0/0", 110 | ) 111 | end 112 | end 113 | end 114 | end 115 | 116 | ec2 "vpc-XXXXXXXX" do 117 | security_group "default" do 118 | description "default VPC security group" 119 | 120 | tags( 121 | "key1" => "value1", 122 | "key2" => "value2" 123 | ) 124 | 125 | ingress do 126 | permission :tcp, 22..22 do 127 | ip_ranges( 128 | "0.0.0.0/0", 129 | ) 130 | end 131 | permission :tcp, 80..80 do 132 | ip_ranges( 133 | "0.0.0.0/0" 134 | ) 135 | end 136 | permission :udp, 60000..61000 do 137 | ip_ranges( 138 | "0.0.0.0/0" 139 | ) 140 | end 141 | # ESP (IP Protocol number: 50) 142 | permission :"50" do 143 | ip_ranges( 144 | "0.0.0.0/0" 145 | ) 146 | end 147 | permission :any do 148 | groups( 149 | "any_other_group", 150 | "default" 151 | ) 152 | end 153 | end 154 | 155 | egress do 156 | permission :any do 157 | ip_ranges( 158 | "0.0.0.0/0" 159 | ) 160 | end 161 | end 162 | end 163 | 164 | security_group "any_other_group" do 165 | description "any_other_group" 166 | 167 | tags( 168 | "key1" => "value1", 169 | "key2" => "value2" 170 | ) 171 | 172 | egress do 173 | permission :any do 174 | ip_ranges( 175 | "0.0.0.0/0" 176 | ) 177 | end 178 | end 179 | end 180 | end 181 | ``` 182 | 183 | ## Use Template 184 | 185 | ```ruby 186 | template "basic" do 187 | permission :tcp, 22..22 do 188 | ip_ranges( 189 | "0.0.0.0/0", 190 | ) 191 | end 192 | end 193 | 194 | template "egress" do 195 | egress do 196 | permission :any do 197 | ip_ranges( 198 | context.ip_addr || "0.0.0.0/0" 199 | ) 200 | end 201 | end 202 | end 203 | 204 | ec2 "vpc-XXXXXXXX" do 205 | security_group "default" do 206 | description "default VPC security group" 207 | 208 | ingress do 209 | include_template "basic" 210 | end 211 | 212 | include_template "egress", :ip_addr => "192.168.0.0/24" 213 | end 214 | end 215 | ``` 216 | 217 | ## JSON Groupfile 218 | 219 | ```json 220 | { 221 | "vpc-12345678": { 222 | "sg-12345678": { 223 | "name": "default", 224 | "description": "default VPC security group", 225 | "tags": { 226 | "key": "val" 227 | }, 228 | "owner_id": "123456789012", 229 | "ingress": [ 230 | { 231 | "protocol": "any", 232 | "port_range": null, 233 | "ip_ranges": [ 234 | 235 | ], 236 | "groups": [ 237 | { 238 | "id": "sg-12345678", 239 | "name": "default", 240 | "owner_id": "123456789012" 241 | } 242 | ] 243 | }, 244 | { 245 | "protocol": "tcp", 246 | "port_range": "22..22", 247 | "ip_ranges": [ 248 | "0.0.0.0/0" 249 | ], 250 | "groups": [ 251 | 252 | ] 253 | }, 254 | { 255 | "protocol": "tcp", 256 | "port_range": "80..80", 257 | "ip_ranges": [ 258 | "0.0.0.0/0" 259 | ], 260 | "groups": [ 261 | 262 | ] 263 | } 264 | ], 265 | "egress": [ 266 | { 267 | "protocol": "any", 268 | "port_range": null, 269 | "ip_ranges": [ 270 | "0.0.0.0/0" 271 | ], 272 | "groups": [ 273 | 274 | ] 275 | } 276 | ] 277 | } 278 | } 279 | } 280 | ``` 281 | 282 | ### Export 283 | 284 | $ piculet --export --format=json -o Groupfile.json 285 | 286 | ### Apply 287 | 288 | $ piculet --apply --format=json -f Groupfile.json 289 | 290 | ## Similar tools 291 | * [Codenize.tools](http://codenize.tools/) 292 | 293 | ## For piculet developers 294 | ### Minimum required IAM policy to run tests 295 | 296 | ```ruby 297 | user 'piculet', path: '/' do 298 | policy 'piculet' do 299 | { 300 | 'Version' => '2012-10-17', 301 | 'Statement' => [ 302 | { 303 | 'Effect' => 'Allow', 304 | 'Action' => [ 305 | 'ec2:CreateSecurityGroup', 306 | 'ec2:CreateTags', 307 | 'ec2:DeleteTags', 308 | 'ec2:DescribeSecurityGroups', 309 | 'ec2:DescribeTags', 310 | 'iam:GetUser', 311 | ], 312 | 'Resource' => '*', 313 | }, 314 | { 315 | 'Effect' => 'Allow', 316 | 'Action' => [ 317 | 'ec2:AuthorizeSecurityGroupEgress', 318 | 'ec2:AuthorizeSecurityGroupIngress', 319 | 'ec2:DeleteSecurityGroup', 320 | 'ec2:RevokeSecurityGroupEgress', 321 | 'ec2:RevokeSecurityGroupIngress', 322 | ], 323 | 'Resource' => '*', 324 | 'Condition' => { 325 | 'StringEquals' => { 326 | 'ec2:Vpc' => "arn:aws:ec2:#{ENV['TEST_AWS_REGION']}:#{ENV['TEST_OWNER_ID']}:vpc/#{ENV['TEST_VPC_ID']}", 327 | }, 328 | }, 329 | }, 330 | ], 331 | } 332 | end 333 | end 334 | ``` 335 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require 'rspec/core/rake_task' 3 | 4 | RSpec::Core::RakeTask.new('spec') 5 | task :default => :spec 6 | -------------------------------------------------------------------------------- /bin/piculet: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | $: << File.expand_path("#{File.dirname __FILE__}/../lib") 3 | require 'rubygems' 4 | require 'piculet' 5 | require 'optparse' 6 | 7 | Version = Piculet::VERSION 8 | 9 | mode = nil 10 | file = 'Groupfile' 11 | output_file = '-' 12 | split = false 13 | MAGIC_COMMENT = <<-EOS 14 | # -*- mode: ruby -*- 15 | # vi: set ft=ruby : 16 | EOS 17 | 18 | options = { 19 | :dry_run => false, 20 | :format => :ruby, 21 | :color => true, 22 | :debug => false, 23 | } 24 | 25 | ARGV.options do |opt| 26 | begin 27 | access_key = nil 28 | secret_key = nil 29 | region = nil 30 | profile_name = nil 31 | credentials_path = nil 32 | format_passed = false 33 | 34 | opt.on('-p', '--profile PROFILE_NAME') {|v| profile_name = v } 35 | opt.on('' , '--credentials-path PATH') {|v| credentials_path = v } 36 | opt.on('-k', '--access-key ACCESS_KEY') {|v| access_key = v } 37 | opt.on('-s', '--secret-key SECRET_KEY') {|v| secret_key = v } 38 | opt.on('-r', '--region REGION') {|v| region = v } 39 | opt.on('-a', '--apply') {|v| mode = :apply } 40 | opt.on('-f', '--file FILE') {|v| file = v } 41 | opt.on('-n', '--names SG_LIST', Array) {|v| options[:sg_names] = v } 42 | opt.on('-x', '--exclude SG_LIST', Array) {|v| options[:exclude_sgs] = v } 43 | opt.on('-t', '--exclude-tags TAGS_LIST', Array) {|v| options[:exclude_tags] = v } 44 | opt.on('', '--ec2s VPC_IDS', Array) {|v| options[:ec2s] = v } 45 | opt.on('', '--dry-run') {|v| options[:dry_run] = true } 46 | opt.on('-e', '--export') {|v| mode = :export } 47 | opt.on('-o', '--output FILE') {|v| output_file = v } 48 | opt.on('', '--split') {|v| split = true } 49 | opt.on('', '--split-more') {|v| split = :more } 50 | opt.on('', '--format=FORMAT', [:ruby, :json]) {|v| format_passed = true; options[:format] = v } 51 | opt.on('' , '--no-color') { options[:color] = false } 52 | opt.on('' , '--debug') { options[:debug] = true } 53 | opt.parse! 54 | 55 | credentials_path ||= ENV['AWS_CONFIG_FILE'] 56 | profile_name ||= ENV['AWS_DEFAULT_PROFILE'] || ENV['AWS_PROFILE'] 57 | region ||= ENV['AWS_DEFAULT_REGION'] 58 | 59 | aws_opts = {} 60 | if access_key and secret_key 61 | aws_opts = { 62 | :access_key_id => access_key, 63 | :secret_access_key => secret_key, 64 | } 65 | elsif profile_name or credentials_path 66 | credentials_opts = {} 67 | if credentials_path 68 | credentials_opts[:path] = credentials_path 69 | AWSConfig.credentials_file = credentials_path 70 | end 71 | if profile_name 72 | credentials_opts[:profile_name] = profile_name 73 | role_arn = AWSConfig[profile_name][:role_arn] 74 | end 75 | if role_arn 76 | session_name = "piculet-session-#{Time.now.to_i}" 77 | sts = AWS::STS.new(AWSConfig[profile_name].config_hash) 78 | provider = AWS::Core::CredentialProviders::AssumeRoleProvider.new( 79 | sts: sts, 80 | role_arn: role_arn, 81 | role_session_name: session_name 82 | ) 83 | else 84 | provider = AWS::Core::CredentialProviders::SharedCredentialFileProvider.new(credentials_opts) 85 | end 86 | aws_opts[:credential_provider] = provider 87 | elsif (access_key and !secret_key) or (!access_key and secret_key) or mode.nil? 88 | puts opt.help 89 | exit 1 90 | end 91 | 92 | aws_opts[:region] = region if region 93 | AWS.config(aws_opts) 94 | 95 | # Remap groups to exclude to regular expressions (if they're surrounded by '/') 96 | if options[:exclude_sgs] 97 | options[:exclude_sgs].map! do |name| 98 | name =~ /\A\/(.*)\/\z/ ? Regexp.new($1) : Regexp.new("\A#{Regexp.escape(name)}\z") 99 | end 100 | end 101 | 102 | if not format_passed and [file, output_file].any? {|i| i =~ /\.json\z/ } 103 | options[:format] = :json 104 | end 105 | rescue => e 106 | $stderr.puts("[ERROR] #{e.message}") 107 | exit 1 108 | end 109 | end 110 | 111 | String.colorize = options[:color] 112 | 113 | if options[:debug] 114 | AWS.config({ 115 | :http_wire_trace => true, 116 | :logger => Piculet::Logger.instance, 117 | }) 118 | end 119 | 120 | begin 121 | logger = Piculet::Logger.instance 122 | logger.set_debug(options[:debug]) 123 | client = Piculet::Client.new(options) 124 | 125 | case mode 126 | when :export 127 | if split 128 | logger.info('Export SecurityGroup') 129 | 130 | output_file = 'Groupfile' if output_file == '-' 131 | requires = [] 132 | base_dir = File.dirname(output_file) 133 | 134 | client.export(options.merge(:without_convert => options[:format] != :ruby)) do |exported, converter| 135 | write_group_file = proc do |group_file, vpc, security_groups| 136 | if options[:format] == :json 137 | group_file << '.json' 138 | end 139 | 140 | requires << group_file 141 | 142 | logger.info(" write `#{group_file}`") 143 | FileUtils.mkdir_p(File.dirname(group_file)) 144 | 145 | open(group_file, 'wb') do |f| 146 | if options[:format] == :json 147 | f.puts JSON.pretty_generate(vpc => security_groups) 148 | else 149 | f.puts MAGIC_COMMENT 150 | f.puts converter.call(vpc => security_groups) 151 | end 152 | end 153 | end 154 | 155 | exported.each do |vpc, security_groups| 156 | if split == :more 157 | security_groups.each do |sg_id, sg_attrs| 158 | sg_name = sg_attrs.fetch(:name) 159 | group_file = File.join(base_dir, vpc || 'classic', "#{sg_name}.group") 160 | write_group_file.call(group_file, vpc, sg_id => sg_attrs) 161 | end 162 | else 163 | group_file = File.join(base_dir, "#{vpc || :classic}.group") 164 | write_group_file.call(group_file, vpc, security_groups) 165 | end 166 | end 167 | end 168 | 169 | if options[:format] == :ruby 170 | logger.info(" write `#{output_file}`") 171 | 172 | open(output_file, 'wb') do |f| 173 | f.puts MAGIC_COMMENT 174 | 175 | requires.each do |group_file| 176 | group_file.sub!(%r|\A#{Regexp.escape base_dir}/?|, '') 177 | f.puts "require '#{group_file}'" 178 | end 179 | end 180 | end 181 | else 182 | exported = client.export(options.merge(:without_convert => options[:format] != :ruby)) 183 | 184 | if options[:format] == :json 185 | exported = JSON.pretty_generate(exported) 186 | end 187 | 188 | if output_file == '-' 189 | logger.info('# Export SecurityGroup') if options[:format] == :ruby 190 | puts exported 191 | else 192 | logger.info("Export SecurityGroup to `#{output_file}`") 193 | 194 | open(output_file, 'wb') do |f| 195 | f.puts MAGIC_COMMENT if options[:format] == :ruby 196 | f.puts exported 197 | end 198 | end 199 | end 200 | when :apply 201 | unless File.exist?(file) 202 | raise "No Groupfile found (looking for: #{file})" 203 | end 204 | 205 | msg = "Apply `#{file}` to SecurityGroup" 206 | msg << ' (dry-run)' if options[:dry_run] 207 | logger.info(msg) 208 | 209 | updated = client.apply(file) 210 | 211 | logger.info('No change'.intense_blue) unless updated 212 | else 213 | raise 'must not happen' 214 | end 215 | rescue => e 216 | if options[:debug] 217 | raise e 218 | else 219 | $stderr.puts("[ERROR] #{e.message}".red) 220 | exit 1 221 | end 222 | end 223 | -------------------------------------------------------------------------------- /lib/piculet.rb: -------------------------------------------------------------------------------- 1 | require 'forwardable' 2 | require 'logger' 3 | require 'ostruct' 4 | require 'set' 5 | require 'singleton' 6 | require 'term/ansicolor' 7 | require 'diffy' 8 | require 'pp' 9 | require 'hashie' 10 | require 'ipaddr' 11 | 12 | require 'aws-sdk-v1' 13 | require 'aws_config' 14 | 15 | require 'piculet/ext/ec2-owner-id-ext' 16 | require 'piculet/ext/security-group' 17 | require 'piculet/ext/ip-permission-collection-ext' 18 | require 'piculet/ext/string-ext' 19 | 20 | require 'piculet/logger' 21 | require 'piculet/template-helper' 22 | require 'piculet/utils' 23 | require 'piculet/client' 24 | require 'piculet/dsl' 25 | require 'piculet/dsl/converter' 26 | require 'piculet/dsl/ec2' 27 | require 'piculet/dsl/permission' 28 | require 'piculet/dsl/permissions' 29 | require 'piculet/dsl/security-group' 30 | require 'piculet/exporter' 31 | require 'piculet/version' 32 | require 'piculet/wrapper/ec2-wrapper' 33 | require 'piculet/wrapper/permission' 34 | require 'piculet/wrapper/permission-collection' 35 | require 'piculet/wrapper/security-group' 36 | require 'piculet/wrapper/security-group-collection' 37 | -------------------------------------------------------------------------------- /lib/piculet/client.rb: -------------------------------------------------------------------------------- 1 | module Piculet 2 | class Client 3 | include Logger::ClientHelper 4 | 5 | def initialize(options = {}) 6 | @options = OpenStruct.new(options) 7 | @options_hash = options 8 | @options.ec2 = AWS::EC2.new 9 | end 10 | 11 | def apply(file) 12 | @options.ec2.owner_id 13 | AWS.memoize { walk(file) } 14 | end 15 | 16 | def should_skip(sg_name, sg) 17 | # Name 18 | if @options.sg_names 19 | if not @options.sg_names.include?(sg_name) 20 | return true 21 | end 22 | end 23 | 24 | if @options.exclude_sgs 25 | if @options.exclude_sgs.any? {|regex| sg_name =~ regex} 26 | return true 27 | end 28 | end 29 | 30 | # Tag 31 | if @options.exclude_tags 32 | if sg and (@options.exclude_tags & sg.tags.keys).any? 33 | return true 34 | end 35 | end 36 | 37 | false 38 | end 39 | 40 | def export(options = {}) 41 | exported = AWS.memoize do 42 | Exporter.export(@options.ec2, @options_hash.merge(options)) 43 | end 44 | 45 | converter = proc do |src| 46 | if options[:without_convert] 47 | exported 48 | else 49 | DSL.convert(src, @options.ec2.owner_id) 50 | end 51 | end 52 | 53 | if block_given? 54 | yield(exported, converter) 55 | else 56 | converter.call(exported) 57 | end 58 | end 59 | 60 | private 61 | def load_file(file) 62 | if file.kind_of?(String) 63 | open(file) do |f| 64 | load_by_format(f.read, file) 65 | end 66 | elsif file.respond_to?(:read) 67 | load_by_format(file.read, file.path) 68 | else 69 | raise TypeError, "can't convert #{file} into File" 70 | end 71 | end 72 | 73 | def load_by_format(src, path) 74 | if @options.format == :json 75 | src = load_json(src, path) 76 | end 77 | 78 | DSL.define(src, path).result 79 | end 80 | 81 | def load_json(json, path) 82 | json = JSON.parse(json, :symbolize_names => true) 83 | 84 | if json.has_key?(:'') 85 | json[nil] = json.delete(:'') 86 | end 87 | 88 | DSL.convert(json, @options.ec2.owner_id) 89 | end 90 | 91 | def walk(file) 92 | dsl = load_file(file) 93 | 94 | dsl_ec2s = dsl.ec2s 95 | ec2 = EC2Wrapper.new(@options.ec2, @options) 96 | 97 | aws_ec2s = collect_to_hash(ec2.security_groups, :has_many => true) do |item| 98 | item.vpc? ? item.vpc_id : nil 99 | end 100 | 101 | dsl_ec2s.each do |vpc, ec2_dsl| 102 | if @options.ec2s 103 | next unless @options.ec2s.any? {|i| (i == 'classic' and vpc.nil?) or i == vpc } 104 | end 105 | 106 | ec2_aws = aws_ec2s[vpc] 107 | 108 | if ec2_aws 109 | walk_ec2(vpc, ec2_dsl, ec2_aws, ec2.security_groups) 110 | else 111 | log(:warn, "EC2 `#{vpc || :classic}` is not found", :yellow) 112 | end 113 | end 114 | 115 | ec2.updated? 116 | end 117 | 118 | def walk_ec2(vpc, ec2_dsl, ec2_aws, collection_api) 119 | sg_list_dsl = collect_to_hash(ec2_dsl.security_groups, :name) 120 | sg_list_aws = collect_to_hash(ec2_aws, :name) 121 | 122 | sg_list_dsl.each do |key, sg_dsl| 123 | name = key[0] 124 | sg_aws = sg_list_aws[key] 125 | 126 | next if should_skip(name, sg_aws) 127 | 128 | unless sg_aws 129 | sg_aws = collection_api.create(name, :vpc => vpc, :description => sg_dsl.description) 130 | 131 | if vpc and sg_dsl.egress.empty? 132 | log(:warn, '`egress any 0.0.0.0/0` is implicitly defined', :yellow) 133 | end 134 | 135 | sg_list_aws[key] = sg_aws 136 | end 137 | end 138 | 139 | sg_list_dsl.each do |key, sg_dsl| 140 | name = key[0] 141 | sg_aws = sg_list_aws.delete(key) 142 | 143 | next if should_skip(name, sg_aws) 144 | 145 | walk_security_group(sg_dsl, sg_aws) 146 | end 147 | 148 | sg_list_aws.each do |key, sg_aws| 149 | name = key[0] 150 | 151 | next if should_skip(name, sg_aws) 152 | 153 | sg_aws.ingress_ip_permissions.each {|i| i.delete } 154 | sg_aws.egress_ip_permissions.each {|i| i.delete } if vpc 155 | end 156 | 157 | sg_list_aws.each do |key, sg_aws| 158 | name = key[0] 159 | 160 | next if should_skip(name, sg_aws) 161 | 162 | sg_aws.delete 163 | end 164 | end 165 | 166 | def walk_security_group(security_group_dsl, security_group_aws) 167 | unless security_group_aws.eql?(security_group_dsl) 168 | security_group_aws.update(security_group_dsl) 169 | end 170 | 171 | walk_permissions( 172 | security_group_dsl.ingress, 173 | security_group_aws.ingress_ip_permissions) 174 | 175 | if security_group_aws.vpc? 176 | walk_permissions( 177 | security_group_dsl.egress, 178 | security_group_aws.egress_ip_permissions) 179 | end 180 | end 181 | 182 | def walk_permissions(permissions_dsl, permissions_aws) 183 | perm_list_dsl = collect_to_hash(permissions_dsl, :protocol, :port_range) 184 | perm_list_aws = collect_to_hash(permissions_aws, :protocol, :port_range) 185 | 186 | perm_list_aws.each do |key, perm_aws| 187 | perm_dsl = perm_list_dsl.delete(key) 188 | 189 | if perm_dsl 190 | unless perm_aws.eql?(perm_dsl) 191 | perm_aws.update(perm_dsl) 192 | end 193 | else 194 | perm_aws.delete 195 | end 196 | end 197 | 198 | perm_list_dsl.each do |key, perm_dsl| 199 | protocol, port_range = key 200 | permissions_aws.create(protocol, port_range, perm_dsl) 201 | end 202 | end 203 | 204 | def collect_to_hash(collection, *key_attrs) 205 | options = key_attrs.last.kind_of?(Hash) ? key_attrs.pop : {} 206 | hash = {} 207 | 208 | collection.each do |item| 209 | key = block_given? ? yield(item) : key_attrs.map {|k| item.send(k) } 210 | 211 | if options[:has_many] 212 | hash[key] ||= [] 213 | hash[key] << item 214 | else 215 | hash[key] = item 216 | end 217 | end 218 | 219 | return hash 220 | end 221 | end # Client 222 | end # Piculet 223 | -------------------------------------------------------------------------------- /lib/piculet/dsl.rb: -------------------------------------------------------------------------------- 1 | module Piculet 2 | class DSL 3 | include Piculet::TemplateHelper 4 | 5 | class << self 6 | def define(source, path) 7 | self.new(path) do 8 | eval(source, binding, path) 9 | end 10 | end 11 | 12 | def convert(exported, owner_id) 13 | Converter.convert(exported, owner_id) 14 | end 15 | end # of class methods 16 | 17 | attr_reader :result 18 | 19 | def initialize(path, &block) 20 | @path = path 21 | @result = OpenStruct.new(:ec2s => {}) 22 | 23 | @context = Hashie::Mash.new( 24 | :path => path, 25 | :templates => {} 26 | ) 27 | 28 | instance_eval(&block) 29 | end 30 | 31 | private 32 | 33 | def template(name, &block) 34 | @context.templates[name.to_s] = block 35 | end 36 | 37 | def require(file) 38 | groupfile = (file =~ %r|\A/|) ? file : File.expand_path(File.join(File.dirname(@path), file)) 39 | 40 | if File.exist?(groupfile) 41 | instance_eval(File.read(groupfile), groupfile) 42 | elsif File.exist?(groupfile + '.rb') 43 | instance_eval(File.read(groupfile + '.rb'), groupfile + '.rb') 44 | else 45 | Kernel.require(file) 46 | end 47 | end 48 | 49 | def ec2(vpc = nil, &block) 50 | if (ec2_result = @result.ec2s[vpc]) 51 | @result.ec2s[vpc] = EC2.new(@context, vpc, ec2_result.security_groups, &block).result 52 | else 53 | @result.ec2s[vpc] = EC2.new(@context, vpc, [], &block).result 54 | end 55 | end 56 | end # DSL 57 | end # Piculet 58 | -------------------------------------------------------------------------------- /lib/piculet/dsl/converter.rb: -------------------------------------------------------------------------------- 1 | module Piculet 2 | class DSL 3 | class Converter 4 | class << self 5 | def convert(exported, owner_id) 6 | self.new(exported, owner_id).convert 7 | end 8 | end # of class methods 9 | 10 | def initialize(exported, owner_id) 11 | @exported = exported 12 | @owner_id = owner_id 13 | end 14 | 15 | def convert 16 | @exported.each.map {|vpc, security_groups| 17 | output_ec2(vpc, security_groups) 18 | }.join("\n") 19 | end 20 | 21 | private 22 | def output_ec2(vpc, security_groups) 23 | vpc = vpc ? vpc.to_s.inspect + ' ' : '' 24 | security_groups = security_groups.map {|sg_id, sg| 25 | output_security_group(sg_id, sg) 26 | }.join("\n").strip 27 | 28 | <<-EOS 29 | ec2 #{vpc}do 30 | #{security_groups} 31 | end 32 | EOS 33 | end 34 | 35 | def output_security_group(security_group_id, security_group) 36 | name = security_group[:name].inspect 37 | description = security_group[:description].inspect 38 | tags = '' 39 | 40 | unless security_group[:tags].empty? 41 | tags = "\n\n tags(\n " + 42 | security_group[:tags].map {|k, v| 43 | k.inspect + ' => ' + v.inspect 44 | }.join(",\n ") + 45 | "\n )" 46 | end 47 | 48 | ingress = security_group.fetch(:ingress, []) 49 | egress = security_group.fetch(:egress, []) 50 | 51 | ingress_egress = [ 52 | output_permissions(:ingress, ingress), 53 | output_permissions(:egress, egress), 54 | ].select {|i| i } 55 | 56 | ingress_egress = ingress_egress.empty? ? '' : "\n\n " + ingress_egress.join("\n").strip 57 | 58 | <<-EOS 59 | security_group #{name} do 60 | description #{description}#{ 61 | tags}#{ 62 | ingress_egress} 63 | end 64 | EOS 65 | end 66 | 67 | def output_permissions(direction, permissions) 68 | return nil if permissions.empty? 69 | permissions = permissions.map {|i| output_perm(i) }.join.strip 70 | 71 | <<-EOS 72 | #{direction} do 73 | #{permissions} 74 | end 75 | EOS 76 | end 77 | 78 | def output_perm(permission) 79 | protocol = permission[:protocol].to_sym 80 | port_range = permission[:port_range] 81 | port_range = eval(port_range) if port_range.kind_of?(String) 82 | args = [protocol, port_range].select {|i| i }.map {|i| i.inspect }.join(', ') + ' ' 83 | 84 | ip_ranges = permission.fetch(:ip_ranges, []) 85 | groups = permission.fetch(:groups, []) 86 | 87 | ip_ranges_groups = [ 88 | output_ip_ranges(ip_ranges), 89 | output_groups(groups), 90 | ].select {|i| i }.join.strip 91 | 92 | ip_ranges_groups.insert(0, "\n ") unless ip_ranges_groups.empty? 93 | 94 | <<-EOS 95 | permission #{args}do#{ 96 | ip_ranges_groups} 97 | end 98 | EOS 99 | end 100 | 101 | def output_ip_ranges(ip_ranges) 102 | return nil if ip_ranges.empty? 103 | ip_ranges = ip_ranges.map {|i| i.inspect }.join(",\n ") 104 | 105 | <<-EOS 106 | ip_ranges( 107 | #{ip_ranges} 108 | ) 109 | EOS 110 | end 111 | 112 | def output_groups(groups) 113 | return nil if groups.empty? 114 | 115 | groups = groups.map {|i| 116 | name_or_id = i[:name] || i[:id] 117 | owner_id = i[:owner_id] 118 | 119 | if AWS::EC2::SecurityGroup.elb?(owner_id) 120 | arg = AWS::EC2::SecurityGroup.elb_sg 121 | elsif @owner_id == owner_id 122 | arg = name_or_id 123 | else 124 | arg = [owner_id, i[:id]] 125 | end 126 | 127 | arg.inspect 128 | }.join(",\n ") 129 | 130 | <<-EOS 131 | groups( 132 | #{groups} 133 | ) 134 | EOS 135 | end 136 | end # Converter 137 | end # DSL 138 | end # Piculet 139 | -------------------------------------------------------------------------------- /lib/piculet/dsl/ec2.rb: -------------------------------------------------------------------------------- 1 | module Piculet 2 | class DSL 3 | class EC2 4 | include Piculet::TemplateHelper 5 | 6 | attr_reader :result 7 | 8 | def initialize(context, vpc, security_groups = [], &block) 9 | @names = Set.new 10 | @context = context.merge(:vpc => vpc) 11 | 12 | @result = OpenStruct.new({ 13 | :vpc => vpc, 14 | :security_groups => security_groups, 15 | }) 16 | 17 | instance_eval(&block) 18 | end 19 | 20 | private 21 | def security_group(name, &block) 22 | if @names.include?(name) 23 | raise "EC2 `#{@result.vpc || :classic}`: `#{name}` is already defined" 24 | end 25 | 26 | @result.security_groups << SecurityGroup.new(@context, name, @result.vpc, &block).result 27 | @names << name 28 | end 29 | end # EC2 30 | end # DSL 31 | end # Piculet 32 | -------------------------------------------------------------------------------- /lib/piculet/dsl/permission.rb: -------------------------------------------------------------------------------- 1 | module Piculet 2 | class DSL 3 | class EC2 4 | class SecurityGroup 5 | class Permissions 6 | class Permission 7 | include Piculet::TemplateHelper 8 | 9 | def initialize(context, security_group, direction, protocol_prot_range, &block) 10 | @security_group = security_group 11 | @direction = direction 12 | @protocol_prot_range = protocol_prot_range 13 | 14 | @context = context.merge( 15 | :protocol => protocol_prot_range[0], 16 | :port_range => protocol_prot_range[1] 17 | ) 18 | 19 | @result = OpenStruct.new 20 | instance_eval(&block) 21 | end 22 | 23 | def result 24 | unless @result.ip_ranges or @result.groups 25 | raise "SecurityGroup `#{@security_group}`: #{@direction}: #{@protocol_prot_range}: `ip_ranges` or `groups` is required" 26 | end 27 | 28 | @result 29 | end 30 | 31 | private 32 | def ip_ranges(*values) 33 | if values.empty? 34 | raise ArgumentError, "SecurityGroup `#{@security_group}`: #{@direction}: #{@protocol_prot_range}: `ip_ranges`: wrong number of arguments (0 for 1..)" 35 | end 36 | 37 | values.each do |ip_range| 38 | unless ip_range =~ %r|\d{1,3}\.\d{1,3}\.\d{1,3}/\d{1,2}| 39 | raise "SecurityGroup `#{@security_group}`: #{@direction}: #{@protocol_prot_range}: `ip_ranges`: invalid ip range: #{ip_range}" 40 | end 41 | 42 | ip, range = ip_range.split('/', 2) 43 | 44 | unless ip.split('.').all? {|i| (0..255).include?(i.to_i) } and (0..32).include?(range.to_i) 45 | raise "SecurityGroup `#{@security_group}`: #{@direction}: #{@protocol_prot_range}: `ip_ranges`: invalid ip range: #{ip_range}" 46 | end 47 | 48 | begin 49 | parsed_ipaddr = IPAddr.new(ip_range) 50 | 51 | if ip != parsed_ipaddr.to_s 52 | raise "SecurityGroup `#{@security_group}`: #{@direction}: #{@protocol_prot_range}: `ip_ranges`: invalid ip range: #{ip_range} correct #{parsed_ipaddr.to_s}/#{range}" 53 | end 54 | rescue => e 55 | raise "SecurityGroup `#{@security_group}`: #{@direction}: #{@protocol_prot_range}: `ip_ranges`: #{ip_range}: #{e.message}" 56 | end 57 | end 58 | 59 | if values.size != values.uniq.size 60 | raise "SecurityGroup `#{@security_group}\: #{@direction}: #{@protocol_prot_range}: `ip_ranges`: duplicate ip ranges" 61 | end 62 | 63 | @result.ip_ranges = values 64 | end 65 | 66 | def groups(*values) 67 | if values.empty? 68 | raise ArgumentError, "SecurityGroup `#{@security_group}`: #{@direction}: #{@protocol_prot_range}: `groups`: wrong number of arguments (0 for 1..)" 69 | end 70 | 71 | values.each do |group| 72 | unless [String, Array].any? {|i| group.kind_of?(i) } 73 | raise "SecurityGroup `#{@security_group}`: #{@direction}: #{@protocol_prot_range}: `groups`: invalid type: #{group}" 74 | end 75 | end 76 | 77 | if values.size != values.uniq.size 78 | raise "SecurityGroup `#{@security_group}\: #{@direction}: #{@protocol_prot_range}: `groups`: duplicate groups" 79 | end 80 | 81 | @result.groups = values 82 | end 83 | end # Permission 84 | end # Permissions 85 | end # SecurityGroup 86 | end # EC2 87 | end # DSL 88 | end # Piculet 89 | -------------------------------------------------------------------------------- /lib/piculet/dsl/permissions.rb: -------------------------------------------------------------------------------- 1 | module Piculet 2 | class DSL 3 | class EC2 4 | class SecurityGroup 5 | class Permissions 6 | include Logger::ClientHelper 7 | include Piculet::TemplateHelper 8 | 9 | def initialize(context, security_group, direction, &block) 10 | @security_group = security_group 11 | @direction = direction 12 | @context = context.merge(:direction => direction) 13 | @result = {} 14 | instance_eval(&block) 15 | end 16 | 17 | def result 18 | @result.map do |key, perm| 19 | protocol, port_range = key 20 | 21 | OpenStruct.new({ 22 | :protocol => protocol, 23 | :port_range => port_range, 24 | :ip_ranges => perm.ip_ranges, 25 | :groups => perm.groups, 26 | }) 27 | end 28 | end 29 | 30 | private 31 | def permission(protocol, port_range = nil, &block) 32 | if port_range 33 | if port_range.kind_of?(Integer) 34 | port_range = port_range..port_range 35 | elsif not port_range.kind_of?(Range) 36 | raise TypeError, "SecurityGroup `#{@security_group}`: #{@direction}: can't convert #{port_range} into Range" 37 | end 38 | end 39 | 40 | key = [protocol, port_range] 41 | res = Permission.new(@context, @security_group, @direction, key, &block).result 42 | 43 | if @result.has_key?(key) 44 | @result[key] = OpenStruct.new(@result[key].marshal_dump.merge(res.marshal_dump) {|hash_key, old_val, new_val| 45 | if (duplicated = old_val & new_val).any? 46 | log(:warn, "SecurityGroup `#{@security_group}`: #{@direction}: #{key}: #{hash_key}: #{duplicated} are duplicated", :yellow) 47 | end 48 | 49 | old_val | new_val 50 | }) 51 | else 52 | @result[key] = res 53 | end 54 | end 55 | end # Permissions 56 | end # SecurityGroup 57 | end # EC2 58 | end # DSL 59 | end # Piculet 60 | -------------------------------------------------------------------------------- /lib/piculet/dsl/security-group.rb: -------------------------------------------------------------------------------- 1 | module Piculet 2 | class DSL 3 | class EC2 4 | class SecurityGroup 5 | include Piculet::TemplateHelper 6 | 7 | def initialize(context, name, vpc, &block) 8 | @name = name 9 | @vpc = vpc 10 | @context = context.merge(:security_group_name => name) 11 | 12 | @result = OpenStruct.new({ 13 | :name => name, 14 | :tags => {}, 15 | :ingress => [], 16 | :egress => [], 17 | }) 18 | 19 | instance_eval(&block) 20 | end 21 | 22 | def result 23 | unless @result.description 24 | raise "SecurityGroup `#{@name}`: `description` is required" 25 | end 26 | 27 | @result 28 | end 29 | 30 | private 31 | def description(value) 32 | @result.description = value 33 | end 34 | 35 | def tags(values) 36 | if @tags_is_defined 37 | raise "SecurityGroup `#{@name}`: `tags` is already defined" 38 | end 39 | 40 | unless values.kind_of?(Hash) 41 | raise "SecurityGroup `#{@name}`: argument of `tags` is wrong (expected Hash)" 42 | end 43 | 44 | @result.tags = values 45 | @tags_is_defined = true 46 | end 47 | 48 | def ingress(&block) 49 | if @ingress_is_defined 50 | raise "SecurityGroup `#{@name}`: `ingress` is already defined" 51 | end 52 | 53 | @result.ingress = Permissions.new(@context, @name, :ingress, &block).result 54 | @ingress_is_defined = true 55 | end 56 | 57 | def egress(&block) 58 | if @egress_is_defined 59 | raise "SecurityGroup `#{@name}`: `egress` is already defined" 60 | end 61 | 62 | unless @vpc 63 | raise "SecurityGroup `#{@name}`: Cannot define `egress` in classic" 64 | end 65 | 66 | @result.egress = Permissions.new(@context, @name, :egress, &block).result 67 | 68 | @egress_is_defined = true 69 | end 70 | end # SecurityGroup 71 | end # EC2 72 | end # DSL 73 | end # Piculet 74 | -------------------------------------------------------------------------------- /lib/piculet/exporter.rb: -------------------------------------------------------------------------------- 1 | module Piculet 2 | class Exporter 3 | class << self 4 | def export(ec2, options = {}) 5 | self.new(ec2, options).export 6 | end 7 | end # of class methods 8 | 9 | def initialize(ec2, options = {}) 10 | @ec2 = ec2 11 | @options = options 12 | end 13 | 14 | def export 15 | result = {} 16 | ec2s = @options[:ec2s] 17 | sg_names = @options[:sg_names] 18 | sgs = @ec2.security_groups 19 | sgs = sgs.filter('group-name', *sg_names) if sg_names 20 | sgs = sgs.sort_by {|sg| sg.name } 21 | 22 | sgs.each do |sg| 23 | vpc = sg.vpc 24 | vpc = vpc.id if vpc 25 | 26 | if ec2s 27 | next unless ec2s.any? {|i| (i == 'classic' and vpc.nil?) or i == vpc } 28 | end 29 | 30 | result[vpc] ||= {} 31 | result[vpc][sg.id] = export_security_group(sg) 32 | end 33 | 34 | return result 35 | end 36 | 37 | private 38 | def export_security_group(security_group) 39 | { 40 | :name => security_group.name, 41 | :description => security_group.description, 42 | :tags => tags_to_hash(security_group.tags), 43 | :owner_id => security_group.owner_id, 44 | :ingress => export_ip_permissions(security_group.ingress_ip_permissions), 45 | :egress => export_ip_permissions(security_group.egress_ip_permissions), 46 | } 47 | end 48 | 49 | def export_ip_permissions(ip_permissions) 50 | ip_permissions = ip_permissions ? ip_permissions.aggregate : [] 51 | 52 | ip_permissions = ip_permissions.map do |ip_perm| 53 | { 54 | :protocol => ip_perm.protocol, 55 | :port_range => ip_perm.port_range, 56 | :ip_ranges => ip_perm.ip_ranges.sort, 57 | :groups => ip_perm.groups.map {|group| 58 | { 59 | :id => group.id, 60 | :name => group.name, 61 | :owner_id => group.owner_id, 62 | } 63 | }.sort_by {|g| g[:name] }, 64 | } 65 | end 66 | 67 | ip_permissions.sort_by do |ip_perm| 68 | port_range = ip_perm[:port_range] || (0..0) 69 | [ip_perm[:protocol], port_range.first, port_range.last] 70 | end 71 | end 72 | 73 | def tags_to_hash(tags) 74 | h = {} 75 | tags.map {|k, v| h[k] = v } 76 | h 77 | end 78 | end # Exporter 79 | end # Piculet 80 | -------------------------------------------------------------------------------- /lib/piculet/ext/ec2-owner-id-ext.rb: -------------------------------------------------------------------------------- 1 | module AWS 2 | class EC2 3 | DESC_OWNER_ID_RETRY_TIMES = 3 4 | DESC_OWNER_ID_RETRY_WAIT = 3 5 | SECURITY_GROUP_NAME_MAX_LEN = 255 6 | 7 | def owner_id 8 | return ENV['AWS_OWNER_ID'] if ENV['AWS_OWNER_ID'] 9 | return @owner_id if @owner_id 10 | 11 | @owner_id = get_owner_id_from_iam || get_owner_id_from_security_group 12 | 13 | return @owner_id 14 | end 15 | 16 | def own?(other) 17 | other == owner_id 18 | end 19 | 20 | private 21 | def get_owner_id_from_iam 22 | credentials = self.config.credential_provider.credentials 23 | iam = AWS::IAM.new(credentials) 24 | user = iam.client.get_user rescue nil 25 | return nil unless user 26 | arn = user[:user][:arn] 27 | arn.split(':')[4] 28 | end 29 | 30 | def get_owner_id_from_security_group 31 | security_group = create_random_security_group 32 | return nil unless security_group 33 | owner_id = random_security_group_owner_id(security_group) 34 | delete_random_security_group(security_group) 35 | return owner_id 36 | end 37 | 38 | def create_random_security_group 39 | security_group = nil 40 | 41 | DESC_OWNER_ID_RETRY_TIMES.times do 42 | name = random_security_group_name 43 | security_group = self.security_groups.create(name) rescue nil 44 | break if security_group 45 | sleep DESC_OWNER_ID_RETRY_WAIT 46 | end 47 | 48 | return security_group 49 | end 50 | 51 | def random_security_group_owner_id(security_group) 52 | owner_id = nil 53 | 54 | (1..DESC_OWNER_ID_RETRY_TIMES).each do |i| 55 | begin 56 | owner_id = security_group.owner_id 57 | break 58 | rescue => e 59 | raise e unless i < DESC_OWNER_ID_RETRY_TIMES 60 | end 61 | 62 | sleep DESC_OWNER_ID_RETRY_WAIT 63 | end 64 | 65 | return owner_id 66 | end 67 | 68 | def delete_random_security_group(security_group) 69 | (1..DESC_OWNER_ID_RETRY_TIMES).each do |i| 70 | begin 71 | security_group.delete 72 | break 73 | rescue => e 74 | raise e unless i < DESC_OWNER_ID_RETRY_TIMES 75 | end 76 | 77 | sleep DESC_OWNER_ID_RETRY_WAIT 78 | end 79 | end 80 | 81 | def random_security_group_name 82 | name = [] 83 | len = SECURITY_GROUP_NAME_MAX_LEN 84 | 85 | while name.length < len 86 | name.concat(('a'..'z').to_a + ('A'..'Z').to_a + (0..9).to_a) 87 | end 88 | 89 | name.shuffle[0...len].join 90 | end 91 | end # EC2 92 | end # AWS 93 | -------------------------------------------------------------------------------- /lib/piculet/ext/ip-permission-collection-ext.rb: -------------------------------------------------------------------------------- 1 | module AWS 2 | class EC2 3 | class SecurityGroup 4 | DESC_SECURITY_GROUP_RETRY_TIMES = 3 5 | DESC_SECURITY_GROUP_RETRY_WAIT = 3 6 | 7 | class IpPermissionCollection 8 | def aggregate 9 | aggregated = nil 10 | 11 | (1..DESC_SECURITY_GROUP_RETRY_TIMES).each do |i| 12 | begin 13 | aggregated = {} 14 | 15 | self.each do |perm| 16 | key = [perm.protocol, perm.port_range] 17 | aggregated[key] ||= {:ip_ranges => [], :groups => []} 18 | aggregated[key][:ip_ranges].concat(perm.ip_ranges || []) 19 | aggregated[key][:groups].concat(perm.groups || []) 20 | end 21 | 22 | break 23 | rescue AWS::EC2::Errors::InvalidGroup::NotFound => e 24 | raise e unless i < DESC_SECURITY_GROUP_RETRY_TIMES 25 | sleep DESC_SECURITY_GROUP_RETRY_WAIT 26 | end 27 | end 28 | 29 | aggregated.map do |key, attrs| 30 | protocol, port_range = key 31 | 32 | OpenStruct.new({ 33 | :protocol => protocol, 34 | :port_range => port_range, 35 | }.merge(attrs)) 36 | end 37 | end 38 | end # IpPermissionCollection 39 | end # SecurityGroup 40 | end # EC2 41 | end # AWS 42 | -------------------------------------------------------------------------------- /lib/piculet/ext/security-group.rb: -------------------------------------------------------------------------------- 1 | module AWS 2 | class EC2 3 | class SecurityGroup 4 | ELB_OWNER = 'amazon-elb' 5 | ELB_NAME = 'amazon-elb-sg' 6 | 7 | def elb? 8 | self.class.elb?(self.owner_id) 9 | end 10 | 11 | alias name_orig name 12 | 13 | def name 14 | self.elb? ? ELB_NAME : name_orig 15 | rescue AWS::EC2::Errors::InvalidGroup::NotFound 16 | self.id 17 | end 18 | 19 | class << self 20 | def elb_sg 21 | "#{ELB_OWNER}/#{ELB_NAME}" 22 | end 23 | 24 | def elb?(owner_or_name) 25 | [ELB_OWNER, self.elb_sg].include?(owner_or_name) 26 | end 27 | end # of class methods 28 | end # SecurityGroup 29 | end # EC2 30 | end # AWS 31 | -------------------------------------------------------------------------------- /lib/piculet/ext/string-ext.rb: -------------------------------------------------------------------------------- 1 | class String 2 | @@colorize = false 3 | 4 | class << self 5 | def colorize=(value) 6 | @@colorize = value 7 | end 8 | 9 | def colorize 10 | @@colorize 11 | end 12 | end # of class methods 13 | 14 | Term::ANSIColor::Attribute.named_attributes.map do |attribute| 15 | class_eval(<<-EOS, __FILE__, __LINE__ + 1) 16 | def #{attribute.name} 17 | if @@colorize 18 | Term::ANSIColor.send(#{attribute.name.inspect}, self) 19 | else 20 | self 21 | end 22 | end 23 | EOS 24 | end 25 | end # String 26 | -------------------------------------------------------------------------------- /lib/piculet/logger.rb: -------------------------------------------------------------------------------- 1 | module Piculet 2 | class Logger < ::Logger 3 | include Singleton 4 | 5 | def initialize 6 | super($stdout) 7 | 8 | self.formatter = proc do |severity, datetime, progname, msg| 9 | "#{msg}\n" 10 | end 11 | 12 | self.level = Logger::INFO 13 | end 14 | 15 | def set_debug(value) 16 | self.level = value ? Logger::DEBUG : Logger::INFO 17 | end 18 | 19 | module ClientHelper 20 | def log(level, message, color, log_id = nil) 21 | message = "[#{level.to_s.upcase}] #{message}" unless level == :info 22 | message << ": #{log_id}" if log_id 23 | message << ' (dry-run)' if @options && @options.dry_run 24 | logger = (@options && @options.logger) || Piculet::Logger.instance 25 | message = message.send(color) if color 26 | logger.send(level, message) 27 | end 28 | end # ClientHelper 29 | end # Logger 30 | end # Piculet 31 | -------------------------------------------------------------------------------- /lib/piculet/template-helper.rb: -------------------------------------------------------------------------------- 1 | module Piculet 2 | module TemplateHelper 3 | def include_template(template_name, context = {}) 4 | tmplt = @context.templates[template_name.to_s] 5 | 6 | unless tmplt 7 | raise "Template `#{template_name}` is not defined" 8 | end 9 | 10 | context_orig = @context 11 | @context = @context.merge(context) 12 | instance_eval(&tmplt) 13 | @context = context_orig 14 | end 15 | 16 | def context 17 | @context 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/piculet/utils.rb: -------------------------------------------------------------------------------- 1 | module Piculet 2 | class Utils 3 | class << self 4 | def diff(obj1, obj2, options = {}) 5 | diffy = Diffy::Diff.new( 6 | obj1.pretty_inspect, 7 | obj2.pretty_inspect, 8 | :diff => '-u' 9 | ) 10 | 11 | out = diffy.to_s(options[:color] ? :color : :text).gsub(/\s+\z/m, '') 12 | out.gsub!(/^/, options[:indent]) if options[:indent] 13 | out 14 | end 15 | end # of class methods 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/piculet/version.rb: -------------------------------------------------------------------------------- 1 | module Piculet 2 | VERSION = "0.3.0" 3 | end 4 | -------------------------------------------------------------------------------- /lib/piculet/wrapper/ec2-wrapper.rb: -------------------------------------------------------------------------------- 1 | module Piculet 2 | class EC2Wrapper 3 | def initialize(ec2, options) 4 | @ec2 = ec2 5 | @options = options.dup 6 | end 7 | 8 | def security_groups 9 | SecurityGroupCollection.new(@ec2.security_groups, @options) 10 | end 11 | 12 | def updated? 13 | !!@options.updated 14 | end 15 | end # EC2Wrapper 16 | end # Piculet 17 | -------------------------------------------------------------------------------- /lib/piculet/wrapper/permission-collection.rb: -------------------------------------------------------------------------------- 1 | module Piculet 2 | class EC2Wrapper 3 | class SecurityGroupCollection 4 | class SecurityGroup 5 | class PermissionCollection 6 | include Logger::ClientHelper 7 | 8 | def initialize(security_group, direction, options) 9 | @security_group = security_group 10 | @permissions = security_group.send("#{direction}_ip_permissions") 11 | @direction = direction 12 | @options = options 13 | end 14 | 15 | def each 16 | perm_list = @permissions ? @permissions.aggregate : [] 17 | 18 | perm_list.each do |perm| 19 | yield(Permission.new(perm, self, @options)) 20 | end 21 | end 22 | 23 | def authorize(protocol, ports, sources, opts = {}) 24 | log(:info, " authorize #{format_sources(sources)}", opts.fetch(:log_color, :green)) 25 | 26 | unless @options.dry_run 27 | sources = normalize_sources(sources) 28 | 29 | case @direction 30 | when :ingress 31 | @security_group.authorize_ingress(protocol, ports, *sources) 32 | @options.updated = true 33 | when :egress 34 | sources.push(:protocol => protocol, :ports => ports) 35 | @security_group.authorize_egress(*sources) 36 | @options.updated = true 37 | end 38 | end 39 | end 40 | 41 | def revoke(protocol, ports, sources, opts = {}) 42 | log(:info, " revoke #{format_sources(sources)}", opts.fetch(:log_color, :green)) 43 | 44 | unless @options.dry_run 45 | sources = normalize_sources(sources) 46 | 47 | case @direction 48 | when :ingress 49 | @security_group.revoke_ingress(protocol, ports, *sources) 50 | @options.updated = true 51 | when :egress 52 | sources.push(:protocol => protocol, :ports => ports) 53 | @security_group.revoke_egress(*sources) 54 | @options.updated = true 55 | end 56 | end 57 | end 58 | 59 | def create(protocol, port_range, dsl) 60 | dsl_ip_ranges = dsl.ip_ranges || [] 61 | dsl_groups = (dsl.groups || []).map do |i| 62 | i.kind_of?(Array) ? i : [@options.ec2.owner_id, i] 63 | end 64 | 65 | sources = dsl_ip_ranges + dsl_groups 66 | 67 | unless sources.empty? 68 | log(:info, 'Create Permission', :cyan, "#{log_id} > #{protocol} #{port_range}") 69 | authorize(protocol, port_range, sources, :log_color => :cyan) 70 | end 71 | end 72 | 73 | def log_id 74 | vpc = @security_group.vpc_id || :classic 75 | name = @security_group.name 76 | 77 | if @security_group.owner_id and not @options.ec2.own?(@security_group.owner_id) 78 | name = "#{@security_group.owner_id}/#{name}" 79 | end 80 | 81 | "#{vpc} > #{name}(#{@direction})" 82 | end 83 | 84 | private 85 | def normalize_sources(sources) 86 | normalized = [] 87 | 88 | sources.each do |src| 89 | case src 90 | when String 91 | normalized << src 92 | when Array 93 | owner_id, group = src 94 | 95 | if src.any? {|i| AWS::EC2::SecurityGroup.elb?(i) } 96 | normalized << { 97 | :user_id => AWS::EC2::SecurityGroup::ELB_OWNER, 98 | :group_name => AWS::EC2::SecurityGroup::ELB_NAME 99 | } 100 | else 101 | unless group =~ /\Asg-[0-9a-f]+\Z/ 102 | sg_coll = @options.ec2.security_groups.filter('group-name', group) 103 | 104 | if @options.ec2.own?(owner_id) 105 | sg_coll = sg_coll.filter('vpc-id', @security_group.vpc_id) if @security_group.vpc? 106 | else 107 | sg_coll = sg_coll.filter('owner-id', owner_id) 108 | end 109 | 110 | unless (sg = sg_coll.first) 111 | raise "Can't find SecurityGroup: #{owner_id}/#{group} in #{@security_group.vpc_id || :classic}" 112 | end 113 | 114 | group = sg.id 115 | end 116 | 117 | normalized << {:user_id => owner_id, :group_id => group} 118 | end 119 | end 120 | end 121 | 122 | return normalized 123 | end 124 | 125 | def format_sources(sources) 126 | sources.map {|src| 127 | if src.kind_of?(Array) 128 | owner_id, group = src 129 | dst = [group] 130 | dst.unshift(owner_id) unless @options.ec2.own?(owner_id) 131 | dst.join('/') 132 | else 133 | src 134 | end 135 | }.join(', ') 136 | end 137 | end # PermissionCollection 138 | end # SecurityGroup 139 | end # SecurityGroupCollection 140 | end # EC2Wrapper 141 | end # Piculet 142 | -------------------------------------------------------------------------------- /lib/piculet/wrapper/permission.rb: -------------------------------------------------------------------------------- 1 | module Piculet 2 | class EC2Wrapper 3 | class SecurityGroupCollection 4 | class SecurityGroup 5 | class PermissionCollection 6 | class Permission 7 | extend Forwardable 8 | include Logger::ClientHelper 9 | 10 | def_delegators( 11 | :@permission, 12 | :protocol, :port_range, :ip_ranges, :groups) 13 | 14 | def initialize(permission, collection, options) 15 | @permission = permission 16 | @collection = collection 17 | @options = options 18 | end 19 | 20 | def eql?(dsl) 21 | dsl_ip_ranges, dsl_groups, self_ip_ranges, self_groups = normalize_attrs(dsl) 22 | (self_ip_ranges == dsl_ip_ranges) and (self_groups == dsl_groups) 23 | end 24 | 25 | def update(dsl) 26 | log(:info, 'Update Permission', :green, log_id) 27 | 28 | plus_ip_ranges, minus_ip_ranges, plus_groups, minus_groups = diff(dsl) 29 | 30 | unless (plus_ip_ranges + plus_groups).empty? 31 | @collection.authorize(protocol, port_range, (plus_ip_ranges + plus_groups), :log_color => :green) 32 | end 33 | 34 | unless (minus_ip_ranges + minus_groups).empty? 35 | @collection.revoke(protocol, port_range, (minus_ip_ranges + minus_groups), :log_color => :green) 36 | end 37 | end 38 | 39 | def delete 40 | log(:info, 'Delete Permission', :red, log_id) 41 | 42 | self_ip_ranges, self_groups = normalize_self_attrs([]) 43 | 44 | unless (self_ip_ranges + self_groups).empty? 45 | @collection.revoke(protocol, port_range, (self_ip_ranges + self_groups), :log_color => :red) 46 | end 47 | end 48 | 49 | private 50 | def log_id 51 | "#{@collection.log_id} > #{protocol} #{port_range}" 52 | end 53 | 54 | def diff(dsl) 55 | dsl_ip_ranges, dsl_groups, self_ip_ranges, self_groups = normalize_attrs(dsl) 56 | 57 | [ 58 | dsl_ip_ranges - self_ip_ranges, 59 | self_ip_ranges - dsl_ip_ranges, 60 | dsl_groups - self_groups, 61 | self_groups - dsl_groups, 62 | ] 63 | end 64 | 65 | def normalize_attrs(dsl) 66 | dsl_ip_ranges = (dsl.ip_ranges || []).sort 67 | dsl_groups = (dsl.groups || []).map {|i| 68 | if i.kind_of?(Array) 69 | i 70 | elsif AWS::EC2::SecurityGroup.elb?(i) 71 | [AWS::EC2::SecurityGroup::ELB_OWNER, AWS::EC2::SecurityGroup::ELB_NAME] 72 | else 73 | [@options.ec2.owner_id, i] 74 | end 75 | }.sort 76 | 77 | self_ip_ranges, self_groups = normalize_self_attrs(dsl_groups.map { |g| g[1] }) 78 | 79 | [dsl_ip_ranges, dsl_groups, self_ip_ranges, self_groups] 80 | end 81 | 82 | def normalize_self_attrs(dsl_group_names) 83 | self_ip_ranges = (@permission.ip_ranges || []).sort 84 | self_groups = (@permission.groups || []).map {|i| 85 | if dsl_group_names.include?(i.security_group_id) 86 | [i.owner_id, i.security_group_id] 87 | else 88 | [i.owner_id, i.name] 89 | end 90 | }.sort 91 | 92 | [self_ip_ranges, self_groups] 93 | end 94 | end # Permission 95 | end # PermissionCollection 96 | end # SecurityGroup 97 | end # SecurityGroupCollection 98 | end # EC2Wrapper 99 | end # Piculet 100 | -------------------------------------------------------------------------------- /lib/piculet/wrapper/security-group-collection.rb: -------------------------------------------------------------------------------- 1 | module Piculet 2 | class EC2Wrapper 3 | class SecurityGroupCollection 4 | include Logger::ClientHelper 5 | 6 | def initialize(security_groups, options) 7 | @security_groups = security_groups 8 | @options = options 9 | end 10 | 11 | def each 12 | @security_groups.each do |sg| 13 | yield(SecurityGroup.new(sg, @options)) 14 | end 15 | end 16 | 17 | def create(name, opts = {}) 18 | log(:info, 'Create SecurityGroup', :cyan, "#{opts[:vpc] || :classic} > #{name}") 19 | 20 | if @options.dry_run 21 | sg = OpenStruct.new({:id => '', :name => name, :vpc_id => opts[:vpc], :tags => {}}.merge(opts)) 22 | else 23 | sg = @security_groups.create(name, opts) 24 | @options.updated = true 25 | end 26 | 27 | SecurityGroup.new(sg, @options) 28 | end 29 | end # SecurityGroupCollection 30 | end # EC2Wrapper 31 | end # Piculet 32 | -------------------------------------------------------------------------------- /lib/piculet/wrapper/security-group.rb: -------------------------------------------------------------------------------- 1 | module Piculet 2 | class EC2Wrapper 3 | class SecurityGroupCollection 4 | class SecurityGroup 5 | extend Forwardable 6 | include Logger::ClientHelper 7 | 8 | def_delegators( 9 | :@security_group, 10 | :vpc_id, :name) 11 | 12 | def initialize(security_group, options) 13 | @security_group = security_group 14 | @options = options 15 | end 16 | 17 | def eql?(dsl) 18 | description_eql?(dsl) and tags_eql?(dsl) 19 | end 20 | 21 | def update(dsl) 22 | unless description_eql?(dsl) 23 | log(:warn, '`description` cannot be updated', :yellow, "#{vpc_id || :classic} > #{name}") 24 | end 25 | 26 | unless tags_eql?(dsl) 27 | log(:info, 'Update SecurityGroup', :green, "#{vpc_id || :classic} > #{name}") 28 | update_tags(dsl) 29 | end 30 | end 31 | 32 | def delete 33 | log(:info, 'Delete SecurityGroup', :red, "#{vpc_id || :classic} > #{name}") 34 | 35 | if name == 'default' 36 | log(:warn, 'SecurityGroup `default` is reserved', :yellow) 37 | else 38 | unless @options.dry_run 39 | @security_group.delete 40 | @options.updated = true 41 | end 42 | end 43 | end 44 | 45 | def vpc? 46 | !!@security_group 47 | end 48 | 49 | def tags 50 | h = {} 51 | @security_group.tags.map {|k, v| h[k] = v } 52 | h 53 | end 54 | 55 | def ingress_ip_permissions 56 | PermissionCollection.new(@security_group, :ingress, @options) 57 | end 58 | 59 | def egress_ip_permissions 60 | PermissionCollection.new(@security_group, :egress, @options) 61 | end 62 | 63 | private 64 | def description_eql?(dsl) 65 | @security_group.description == dsl.description 66 | end 67 | 68 | def tags_eql?(dsl) 69 | self_tags = normalize_tags(self.tags) 70 | dsl_tags = normalize_tags(dsl.tags) 71 | self_tags == dsl_tags 72 | end 73 | 74 | def update_tags(dsl) 75 | self_tags = normalize_tags(self.tags) 76 | dsl_tags = normalize_tags(dsl.tags) 77 | 78 | log(:info, " tags:\n".green + Piculet::Utils.diff(self_tags, dsl_tags, :color => @options.color, :indent => ' '), false) 79 | 80 | unless @options.dry_run 81 | if dsl_tags.empty? 82 | @security_group.tags.clear 83 | else 84 | delete_keys = self_tags.keys - dsl_tags.keys 85 | # XXX: `delete` method does not remove the tag. It's seems a bug in the API 86 | #@security_group.tags.delete(delete_keys) unless delete_keys.empty? 87 | @security_group.tags.clear unless delete_keys.empty? 88 | @security_group.tags.set(dsl_tags) 89 | end 90 | 91 | @options.updated = true 92 | end 93 | end 94 | 95 | def normalize_tags(src) 96 | normalized = {} 97 | src.map {|k, v| normalized[k.to_s] = v.to_s } 98 | normalized 99 | end 100 | end # SecurityGroup 101 | end # SecurityGroupCollection 102 | end # EC2Wrapper 103 | end # Piculet 104 | -------------------------------------------------------------------------------- /piculet.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'piculet/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "piculet" 8 | spec.version = Piculet::VERSION 9 | spec.authors = ["winebarrel"] 10 | spec.email = ["sgwr_dts@yahoo.co.jp"] 11 | spec.description = "Piculet is a tool to manage EC2 Security Group. It defines the state of EC2 Security Group using DSL, and updates EC2 Security Group according to DSL." 12 | spec.summary = "Piculet is a tool to manage EC2 Security Group." 13 | spec.homepage = "http://piculet.codenize.tools/" 14 | spec.license = "MIT" 15 | spec.files = %w(README.md) + Dir.glob('bin/**/*') + Dir.glob('lib/**/*') 16 | 17 | spec.add_dependency "aws-sdk-v1", ">= 1.48.0" 18 | spec.add_dependency "term-ansicolor", ">= 1.2.2" 19 | spec.add_dependency "diffy" 20 | spec.add_dependency "hashie" 21 | spec.add_dependency "nokogiri", "~> 1.8.2" 22 | spec.add_dependency "aws_config", "0.1.0" 23 | 24 | #spec.files = `git ls-files`.split($/) 25 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 26 | #spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 27 | spec.require_paths = ["lib"] 28 | 29 | spec.add_development_dependency "bundler", "~> 1.3" 30 | spec.add_development_dependency "rake" 31 | spec.add_development_dependency "rspec", ">= 3.4.0" 32 | spec.add_development_dependency "rspec-instafail" 33 | end 34 | -------------------------------------------------------------------------------- /spec/piculet_create_permission_spec.rb: -------------------------------------------------------------------------------- 1 | describe Piculet::Client do 2 | before(:each) { 3 | groupfile { (< true)[TEST_VPC_ID][0][0] 164 | 165 | groupfile { (< true) { dsl } 71 | 72 | exported = export_security_groups 73 | expect(exported.keys).to eq([TEST_VPC_ID]) 74 | 75 | expect(exported[TEST_VPC_ID]).to eq([[ 76 | [:description , "any other security group"], 77 | [:egress , [[ 78 | [:groups , EMPTY_ARRAY], 79 | [:ip_ranges , ["0.0.0.0/0", "127.0.0.2/32"]], 80 | [:port_range , nil], 81 | [:protocol , :any], 82 | ]]], 83 | [:ingress , [[ 84 | [:groups , EMPTY_ARRAY], 85 | [:ip_ranges , ["0.0.0.0/0", "127.0.0.1/32"]], 86 | [:port_range , nil], 87 | [:protocol , :any], 88 | ]]], 89 | [:name , "any_other_security_group"], 90 | [:owner_id , TEST_OWNER_ID], 91 | [:tags , {}], 92 | ],[ 93 | [:description , "default VPC security group"], 94 | [:egress , EMPTY_ARRAY], 95 | [:ingress , EMPTY_ARRAY], 96 | [:name , "default"], 97 | [:owner_id , TEST_OWNER_ID], 98 | [:tags , {}], 99 | ]]) 100 | end # it 101 | end # context ###################################################### 102 | end # describe 103 | -------------------------------------------------------------------------------- /spec/piculet_spec.rb: -------------------------------------------------------------------------------- 1 | describe Piculet::Client do 2 | before(:each) { 3 | groupfile { (< "value1", 35 | "key2" => "value2", 36 | ) 37 | 38 | ingress do 39 | permission :any do 40 | ip_ranges( 41 | "0.0.0.0/0" 42 | ) 43 | end 44 | end 45 | 46 | egress do 47 | permission :any do 48 | ip_ranges( 49 | "0.0.0.0/0" 50 | ) 51 | end 52 | end 53 | end 54 | 55 | security_group "default" do 56 | description "default VPC security group" 57 | end # security_group 58 | end # ec2 59 | EOS 60 | } 61 | end 62 | 63 | it do 64 | groupfile { (< "value1", 71 | "key2" => "value2", 72 | ) 73 | 74 | ingress do 75 | permission :any do 76 | ip_ranges( 77 | "0.0.0.0/0" 78 | ) 79 | end 80 | end 81 | 82 | egress do 83 | permission :any do 84 | ip_ranges( 85 | "0.0.0.0/0" 86 | ) 87 | end 88 | end 89 | end 90 | 91 | security_group "default" do 92 | description "default VPC security group" 93 | end # security_group 94 | end # ec2 95 | EOS 96 | } 97 | 98 | exported = export_security_groups 99 | expect(exported.keys).to eq([TEST_VPC_ID]) 100 | 101 | expect(exported[TEST_VPC_ID]).to eq([[ 102 | [:description , "any other security group"], 103 | [:egress , [[ 104 | [:groups , EMPTY_ARRAY], 105 | [:ip_ranges , ["0.0.0.0/0"]], 106 | [:port_range , nil], 107 | [:protocol , :any], 108 | ]]], 109 | [:ingress , [[ 110 | [:groups , EMPTY_ARRAY], 111 | [:ip_ranges , ["0.0.0.0/0"]], 112 | [:port_range , nil], 113 | [:protocol , :any], 114 | ]]], 115 | [:name , "any_other_security_group"], 116 | [:owner_id , TEST_OWNER_ID], 117 | [:tags , {"key1"=>"value1", "key2"=>"value2"}], 118 | ],[ 119 | [:description , "default VPC security group"], 120 | [:egress , EMPTY_ARRAY], 121 | [:ingress , EMPTY_ARRAY], 122 | [:name , "default"], 123 | [:owner_id , TEST_OWNER_ID], 124 | [:tags , {}], 125 | ]]) 126 | end # it 127 | end # context ###################################################### 128 | 129 | context 'add' do ################################# 130 | before do 131 | groupfile { (< "value1", 138 | "key2" => "value2", 139 | ) 140 | 141 | ingress do 142 | permission :any do 143 | ip_ranges( 144 | "0.0.0.0/0" 145 | ) 146 | end 147 | end 148 | 149 | egress do 150 | permission :any do 151 | ip_ranges( 152 | "0.0.0.0/0" 153 | ) 154 | end 155 | end 156 | end 157 | 158 | security_group "default" do 159 | description "default VPC security group" 160 | end # security_group 161 | end # ec2 162 | EOS 163 | } 164 | end 165 | 166 | it do 167 | groupfile { (< "value1", 174 | "key2" => "value2", 175 | "key3" => "value3", 176 | ) 177 | 178 | ingress do 179 | permission :any do 180 | ip_ranges( 181 | "0.0.0.0/0" 182 | ) 183 | end 184 | end 185 | 186 | egress do 187 | permission :any do 188 | ip_ranges( 189 | "0.0.0.0/0" 190 | ) 191 | end 192 | end 193 | end 194 | 195 | security_group "default" do 196 | description "default VPC security group" 197 | end # security_group 198 | end # ec2 199 | EOS 200 | } 201 | 202 | exported = export_security_groups 203 | expect(exported.keys).to eq([TEST_VPC_ID]) 204 | 205 | expect(exported[TEST_VPC_ID]).to eq([[ 206 | [:description , "any other security group"], 207 | [:egress , [[ 208 | [:groups , EMPTY_ARRAY], 209 | [:ip_ranges , ["0.0.0.0/0"]], 210 | [:port_range , nil], 211 | [:protocol , :any], 212 | ]]], 213 | [:ingress , [[ 214 | [:groups , EMPTY_ARRAY], 215 | [:ip_ranges , ["0.0.0.0/0"]], 216 | [:port_range , nil], 217 | [:protocol , :any], 218 | ]]], 219 | [:name , "any_other_security_group"], 220 | [:owner_id , TEST_OWNER_ID], 221 | [:tags , {"key1"=>"value1", "key2"=>"value2", "key3"=>"value3"}], 222 | ],[ 223 | [:description , "default VPC security group"], 224 | [:egress , EMPTY_ARRAY], 225 | [:ingress , EMPTY_ARRAY], 226 | [:name , "default"], 227 | [:owner_id , TEST_OWNER_ID], 228 | [:tags , {}], 229 | ]]) 230 | end # it 231 | end # context ###################################################### 232 | 233 | context 'delete' do ################################# 234 | before do 235 | groupfile { (< "value1", 242 | "key2" => "value2", 243 | ) 244 | 245 | ingress do 246 | permission :any do 247 | ip_ranges( 248 | "0.0.0.0/0" 249 | ) 250 | end 251 | end 252 | 253 | egress do 254 | permission :any do 255 | ip_ranges( 256 | "0.0.0.0/0" 257 | ) 258 | end 259 | end 260 | end 261 | 262 | security_group "default" do 263 | description "default VPC security group" 264 | end # security_group 265 | end # ec2 266 | EOS 267 | } 268 | end 269 | 270 | it do 271 | groupfile { (< "value1", 278 | ) 279 | 280 | ingress do 281 | permission :any do 282 | ip_ranges( 283 | "0.0.0.0/0" 284 | ) 285 | end 286 | end 287 | 288 | egress do 289 | permission :any do 290 | ip_ranges( 291 | "0.0.0.0/0" 292 | ) 293 | end 294 | end 295 | end 296 | 297 | security_group "default" do 298 | description "default VPC security group" 299 | end # security_group 300 | end # ec2 301 | EOS 302 | } 303 | 304 | exported = export_security_groups 305 | expect(exported.keys).to eq([TEST_VPC_ID]) 306 | 307 | expect(exported[TEST_VPC_ID]).to eq([[ 308 | [:description , "any other security group"], 309 | [:egress , [[ 310 | [:groups , EMPTY_ARRAY], 311 | [:ip_ranges , ["0.0.0.0/0"]], 312 | [:port_range , nil], 313 | [:protocol , :any], 314 | ]]], 315 | [:ingress , [[ 316 | [:groups , EMPTY_ARRAY], 317 | [:ip_ranges , ["0.0.0.0/0"]], 318 | [:port_range , nil], 319 | [:protocol , :any], 320 | ]]], 321 | [:name , "any_other_security_group"], 322 | [:owner_id , TEST_OWNER_ID], 323 | [:tags , {"key1"=>"value1"}], 324 | ],[ 325 | [:description , "default VPC security group"], 326 | [:egress , EMPTY_ARRAY], 327 | [:ingress , EMPTY_ARRAY], 328 | [:name , "default"], 329 | [:owner_id , TEST_OWNER_ID], 330 | [:tags , {}], 331 | ]]) 332 | end # it 333 | end # context ###################################################### 334 | 335 | context 'update key' do ################################# 336 | before do 337 | groupfile { (< "value1", 344 | "key2" => "value2", 345 | ) 346 | 347 | ingress do 348 | permission :any do 349 | ip_ranges( 350 | "0.0.0.0/0" 351 | ) 352 | end 353 | end 354 | 355 | egress do 356 | permission :any do 357 | ip_ranges( 358 | "0.0.0.0/0" 359 | ) 360 | end 361 | end 362 | end 363 | 364 | security_group "default" do 365 | description "default VPC security group" 366 | end # security_group 367 | end # ec2 368 | EOS 369 | } 370 | end 371 | 372 | it do 373 | groupfile { (< "value1", 380 | "key22" => "value2", 381 | ) 382 | 383 | ingress do 384 | permission :any do 385 | ip_ranges( 386 | "0.0.0.0/0" 387 | ) 388 | end 389 | end 390 | 391 | egress do 392 | permission :any do 393 | ip_ranges( 394 | "0.0.0.0/0" 395 | ) 396 | end 397 | end 398 | end 399 | 400 | security_group "default" do 401 | description "default VPC security group" 402 | end # security_group 403 | end # ec2 404 | EOS 405 | } 406 | 407 | exported = export_security_groups 408 | expect(exported.keys).to eq([TEST_VPC_ID]) 409 | 410 | expect(exported[TEST_VPC_ID]).to eq([[ 411 | [:description , "any other security group"], 412 | [:egress , [[ 413 | [:groups , EMPTY_ARRAY], 414 | [:ip_ranges , ["0.0.0.0/0"]], 415 | [:port_range , nil], 416 | [:protocol , :any], 417 | ]]], 418 | [:ingress , [[ 419 | [:groups , EMPTY_ARRAY], 420 | [:ip_ranges , ["0.0.0.0/0"]], 421 | [:port_range , nil], 422 | [:protocol , :any], 423 | ]]], 424 | [:name , "any_other_security_group"], 425 | [:owner_id , TEST_OWNER_ID], 426 | [:tags , {"key1"=>"value1", "key22"=>"value2"}], 427 | ],[ 428 | [:description , "default VPC security group"], 429 | [:egress , EMPTY_ARRAY], 430 | [:ingress , EMPTY_ARRAY], 431 | [:name , "default"], 432 | [:owner_id , TEST_OWNER_ID], 433 | [:tags , {}], 434 | ]]) 435 | end # it 436 | end # context ###################################################### 437 | 438 | context 'update value' do ################################# 439 | before do 440 | groupfile { (< "value1", 447 | "key2" => "value2", 448 | ) 449 | 450 | ingress do 451 | permission :any do 452 | ip_ranges( 453 | "0.0.0.0/0" 454 | ) 455 | end 456 | end 457 | 458 | egress do 459 | permission :any do 460 | ip_ranges( 461 | "0.0.0.0/0" 462 | ) 463 | end 464 | end 465 | end 466 | 467 | security_group "default" do 468 | description "default VPC security group" 469 | end # security_group 470 | end # ec2 471 | EOS 472 | } 473 | end 474 | 475 | it do 476 | groupfile { (< "value1", 483 | "key2" => "value22", 484 | ) 485 | 486 | ingress do 487 | permission :any do 488 | ip_ranges( 489 | "0.0.0.0/0" 490 | ) 491 | end 492 | end 493 | 494 | egress do 495 | permission :any do 496 | ip_ranges( 497 | "0.0.0.0/0" 498 | ) 499 | end 500 | end 501 | end 502 | 503 | security_group "default" do 504 | description "default VPC security group" 505 | end # security_group 506 | end # ec2 507 | EOS 508 | } 509 | 510 | exported = export_security_groups 511 | expect(exported.keys).to eq([TEST_VPC_ID]) 512 | 513 | expect(exported[TEST_VPC_ID]).to eq([[ 514 | [:description , "any other security group"], 515 | [:egress , [[ 516 | [:groups , EMPTY_ARRAY], 517 | [:ip_ranges , ["0.0.0.0/0"]], 518 | [:port_range , nil], 519 | [:protocol , :any], 520 | ]]], 521 | [:ingress , [[ 522 | [:groups , EMPTY_ARRAY], 523 | [:ip_ranges , ["0.0.0.0/0"]], 524 | [:port_range , nil], 525 | [:protocol , :any], 526 | ]]], 527 | [:name , "any_other_security_group"], 528 | [:owner_id , TEST_OWNER_ID], 529 | [:tags , {"key1"=>"value1", "key2"=>"value22"}], 530 | ],[ 531 | [:description , "default VPC security group"], 532 | [:egress , EMPTY_ARRAY], 533 | [:ingress , EMPTY_ARRAY], 534 | [:name , "default"], 535 | [:owner_id , TEST_OWNER_ID], 536 | [:tags , {}], 537 | ]]) 538 | end # it 539 | end # context ###################################################### 540 | end # describe 541 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'aws-sdk-v1' 3 | require 'piculet' 4 | 5 | TEST_VPC_ID = ENV['TEST_VPC_ID'] 6 | TEST_OWNER_ID = ENV['TEST_OWNER_ID'] 7 | RETRY_TIMES = 10 8 | EMPTY_ARRAY = [] 9 | 10 | AWS.config({ 11 | :access_key_id => (ENV['TEST_AWS_ACCESS_KEY_ID'] || 'scott'), 12 | :secret_access_key => (ENV['TEST_AWS_SECRET_ACCESS_KEY'] || 'tiger'), 13 | :region => ENV['TEST_AWS_REGION'], 14 | }) 15 | 16 | def groupfile(options = {}) 17 | updated = false 18 | tempfile = `mktemp /tmp/#{File.basename(__FILE__)}.XXXXXX`.strip 19 | 20 | begin 21 | open(tempfile, 'wb') {|f| f.puts(yield) } 22 | options = { 23 | :logger => Logger.new('/dev/null'), 24 | :ec2s => [TEST_VPC_ID], 25 | }.merge(options) 26 | 27 | if options[:debug] 28 | AWS.config({ 29 | :http_wire_trace => true, 30 | :logger => (options[:logger] || Piculet::Logger.instance), 31 | }) 32 | end 33 | 34 | client = Piculet::Client.new(options) 35 | 36 | (1..RETRY_TIMES).each do |i| 37 | begin 38 | updated = client.apply(tempfile) 39 | break 40 | rescue AWS::EC2::Errors::InvalidGroup::NotFound => e 41 | raise e unless i < RETRY_TIMES 42 | end 43 | end 44 | ensure 45 | FileUtils.rm_f(tempfile) 46 | end 47 | 48 | return updated 49 | end 50 | 51 | def export_security_groups(options = {}) 52 | options = { 53 | :logger => Logger.new('/dev/null'), 54 | :ec2s => [TEST_VPC_ID], 55 | }.merge(options) 56 | 57 | if options[:debug] 58 | AWS.config({ 59 | :http_wire_trace => true, 60 | :logger => (options[:logger] || Piculet::Logger.instance), 61 | }) 62 | end 63 | 64 | sleep ENV['TEST_EXPORT_DELAY'].to_f 65 | 66 | client = Piculet::Client.new(options) 67 | exported = client.export {|e, c| e } 68 | 69 | exported.keys.each do |vpc| 70 | security_groups = exported[vpc] 71 | 72 | security_groups.each do |sg_id, sg| 73 | [:ingress, :egress].each do |direction| 74 | if (perm_list = sg[direction]) 75 | perm_list.each do |perm| 76 | if (ip_ranges = perm[:ip_ranges]) 77 | perm[:ip_ranges] = ip_ranges.sort 78 | end 79 | 80 | if (groups = perm[:groups]) 81 | groups.each {|g| g.delete(:id) } 82 | perm[:groups] = groups.sort_by {|g| g[:name] }.map {|g| g.sort_by {|k, v| k } } 83 | end 84 | end 85 | 86 | sg[direction] = perm_list.map {|perm| perm.sort_by {|k, v| k } } 87 | end 88 | end 89 | end 90 | 91 | exported[vpc] = security_groups.sort_by {|sg_id, sg| sg[:name] }.map {|sg_id, sg| 92 | sg = sg.sort_by {|k, v| k } 93 | 94 | if options[:include_security_group_id] 95 | [sg_id, sg] 96 | else 97 | sg 98 | end 99 | } 100 | end 101 | 102 | return exported 103 | end 104 | --------------------------------------------------------------------------------