cloudformation-tool 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/bin/cftool ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'cloud_formation_tool'
4
+
5
+ begin
6
+ CloudFormationTool::CLI::Main.run
7
+ rescue CloudFormationTool::Errors::AppError => e
8
+ warn e.message
9
+ end
@@ -0,0 +1,94 @@
1
+ require 'logger'
2
+ require 'autoloaded'
3
+ require 'aws-sdk'
4
+
5
+ def log(message = nil, &block)
6
+ ($__logger ||= Logger.new(STDERR)).info(if message.nil?
7
+ yield
8
+ else
9
+ message
10
+ end)
11
+ end
12
+
13
+ module CloudFormationTool
14
+
15
+ Autoloaded.module do |autoloaded|
16
+ autoloaded.with :CLI
17
+ end
18
+
19
+ def find_profile(dir = nil)
20
+ dir ||= Dir.pwd
21
+ return profile if (dir == "/")
22
+ begin
23
+ return File.read("#{dir}/.awsprofile").chomp
24
+ rescue Errno::ENOENT
25
+ return find_profile(File.dirname(dir))
26
+ end
27
+ end
28
+
29
+ def region
30
+ $__region ||= (ENV['AWS_DEFAULT_REGION'] || 'us-west-1')
31
+ end
32
+
33
+ def profile
34
+ $__profile ||= (ENV['AWS_DEFAULT_PROFILE'] || 'default')
35
+ end
36
+
37
+ def awscreds
38
+ $__aws_creds ||= Aws::SharedCredentials.new(profile_name: find_profile)
39
+ end
40
+
41
+ def aws_config
42
+ {
43
+ credentials: awscreds,
44
+ region: region,
45
+ http_read_timeout: 5
46
+ }
47
+ end
48
+
49
+ def awsec2
50
+ $__aws_ec2 ||= Aws::EC2::Client.new aws_config
51
+ end
52
+
53
+ def awss3(s3reg = nil)
54
+ s3reg ||= region
55
+ ($__aws_s3 ||= {})[region] ||= Aws::S3::Client.new aws_config.merge(region: s3reg)
56
+ end
57
+
58
+ def awscf
59
+ $__aws_cf ||= Aws::CloudFormation::Client.new aws_config
60
+ end
61
+
62
+ def awsas
63
+ $__aws_as ||= Aws::AutoScaling::Client.new aws_config
64
+ end
65
+
66
+ def s3_bucket_name(region)
67
+ name = nil
68
+ # see if we already have a cf-templates bucket for this region
69
+ bucket = awss3.list_buckets.buckets.select do |b|
70
+ b.name =~ /cf-templates-(\w+)-#{region}/
71
+ end.first
72
+
73
+ # otherwise try to create one
74
+ if bucket.nil?
75
+ name = cf_bucket_name(region)
76
+ log("Creating CF template bucket #{name}")
77
+ awss3.create_bucket({
78
+ acl: "private",
79
+ bucket: name,
80
+ create_bucket_configuration: { location_constraint: region }
81
+ })
82
+ name
83
+ else
84
+ bucket[:name]
85
+ end
86
+ end
87
+
88
+ def cf_bucket_name(region, key = nil)
89
+ # generate random key if one wasn't given
90
+ key ||= ((0...12).map { [*'a'..'z',*'0'..'9'][rand(36)] }.join)
91
+ "cf-templates-#{key}-#{region}"
92
+ end
93
+
94
+ end
@@ -0,0 +1,5 @@
1
+ module CloudFormationTool
2
+ module CLI
3
+ Autoloaded.module { }
4
+ end
5
+ end
@@ -0,0 +1,19 @@
1
+ module CloudFormationTool
2
+ module CLI
3
+
4
+ class Compile < Clamp::Command
5
+
6
+ parameter 'FILE', 'Template main file'
7
+
8
+ def execute
9
+ if file.end_with? '.init'
10
+ puts CloudInit.new(file).compile
11
+ else
12
+ puts CloudFormation.parse(file).to_yaml
13
+ # raise AppError.new("not a valid template file. Only .init and .yaml are supported")
14
+ end
15
+ end
16
+
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,50 @@
1
+ module CloudFormationTool
2
+ module CLI
3
+ class Create < Clamp::Command
4
+
5
+ parameter 'FILE', 'Template main file'
6
+ parameter '[STACK_NAME]', 'Name of the stack to create. Defaults to directory name'
7
+
8
+ option [ "-p", "--param" ], "PARAM", [
9
+ "Parameter to use with the cloudformation, of the format Mame=Tag",
10
+ "Use multiple times to set multiple parameters.",
11
+ "See 'parameters' command to list the paramaters supported by the tempalte."
12
+ ].join("\n"), multivalued: true
13
+ option [ "-i", "--import" ], "FILE", "Import parameters from YAML file.", :attribute_name => :param_file
14
+ option [ "-k", "--import-key" ], "KEY", [
15
+ "When loading parameters from a YAML file, use the specified key to load a named",
16
+ "map from the file, instead of using just the file itself as the parameter map"
17
+ ].join("\n"), :attribute_name => :param_key
18
+
19
+ def get_params
20
+ params = if param_file
21
+ yaml = YAML.load(File.read(param_file)).to_h
22
+ if param_key
23
+ raise "Missing parameter section '#{param_key}' in '#{param_file}'!" unless yaml[param_key].is_a? Hash
24
+ yaml[param_key]
25
+ else
26
+ yaml
27
+ end
28
+ else
29
+ Hash.new
30
+ end
31
+ # allow param_list to override parameters from the param file
32
+ param_list.inject(params) do |h, param|
33
+ k,v = param.split /\s*[=:]\s*/
34
+ h[k] = v
35
+ h
36
+ end
37
+ end
38
+
39
+ def execute
40
+ name = stack_name || File.basename(File.dirname(File.expand_path(file)))
41
+ st = CloudFormation::Stack.new(name)
42
+ log "Creating stack #{name}"
43
+ start = Time.now
44
+ log "Created " + st.create(file, get_params).to_s
45
+ st.monitor(start)
46
+ end
47
+
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,18 @@
1
+ module CloudFormationTool
2
+ module CLI
3
+
4
+ class Delete < Clamp::Command
5
+
6
+ parameter "STACK_NAME", "Name of the stack to delete"
7
+
8
+ def execute
9
+ st = CloudFormation::Stack.new(stack_name)
10
+ start = Time.now
11
+ st.delete
12
+ log "Deleted stack #{stack_name}"
13
+ st.monitor(start)
14
+ end
15
+
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,15 @@
1
+ module CloudFormationTool
2
+ module CLI
3
+
4
+ class ListStacks < Clamp::Command
5
+ include CloudFormationTool
6
+
7
+ def execute
8
+ awscf.describe_stacks.stacks.each do |stack|
9
+ puts stack.stack_name.ljust(30,' ') + stack.stack_status
10
+ end
11
+ end
12
+
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,31 @@
1
+ require 'clamp'
2
+ require 'io/console'
3
+
4
+
5
+ module CloudFormationTool
6
+ module CLI
7
+ class Main < Clamp::Command
8
+
9
+ cftool = Class.new.include(CloudFormationTool).new
10
+
11
+ banner "Compile and deploy CloudFormation templates"
12
+
13
+ option [ "-r", "--region" ], "REGION", "AWS Region to use", default: cftool.region do |s|
14
+ $__region = s
15
+ end
16
+
17
+ option [ "-p", "--profile" ], "PROFILE", "AWS credentials profile to use", default: cftool.profile do |s|
18
+ $__profile = s
19
+ end
20
+
21
+ subcommand 'list', "List CloudFormation stacks", ListStacks
22
+ subcommand 'parameters', "List template parameters and their default values", Parameters
23
+ subcommand 'compile', "Compile the specified template", Compile
24
+ subcommand 'monitor', "Monitor recent and upcoming events on the stack", Monitor
25
+ subcommand 'create', "Create a stack from the template or update an existing stack", Create
26
+ subcommand 'status', "Check the current status of a stack", Status
27
+ subcommand 'delete', "Delete an existing stack", Delete
28
+ subcommand 'servers', 'List stack resources', Servers
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,28 @@
1
+ module CloudFormationTool
2
+ module CLI
3
+
4
+ class Monitor < Clamp::Command
5
+
6
+ parameter 'STACK_NAME', "Name of the stack to monitor"
7
+
8
+ option [ "-a", "--all" ], :flag, "Don't skip old events"
9
+
10
+ def execute
11
+ begin
12
+ st = CloudFormation::Stack.new(stack_name)
13
+ st.see_events unless all?
14
+ while true
15
+ st.monitor
16
+ sleep 1
17
+ end
18
+ rescue CloudFormationTool::Errors::StackDoesNotExistError => e
19
+ log "Stack #{stack_name} does not exist"
20
+ rescue SystemExit, Interrupt => e
21
+ # CTRL-C out of the loop
22
+ puts "\n"
23
+ end
24
+ end
25
+
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,20 @@
1
+ module CloudFormationTool
2
+ module CLI
3
+
4
+ class Parameters < Clamp::Command
5
+
6
+ parameter 'FILE', 'Template main file'
7
+ def execute
8
+ donefirst = false
9
+ CloudFormation.parse(file).each do |name, value|
10
+ unless donefirst
11
+ donefirst = true
12
+ puts "---\n"
13
+ end
14
+ puts "#{name}: #{value}"
15
+ end
16
+ end
17
+
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,30 @@
1
+ require 'set'
2
+
3
+ module CloudFormationTool
4
+ module CLI
5
+
6
+ class Servers < Clamp::Command
7
+ include CloudFormationTool
8
+
9
+ parameter "STACK_NAME", "Name of the stack to delete"
10
+
11
+ def execute
12
+ st = CloudFormation::Stack.new(stack_name)
13
+ ts = st.asgroups.collect do |res|
14
+ Thread.new do
15
+ awsas.describe_auto_scaling_groups({
16
+ auto_scaling_group_names: [ res.physical_resource_id ]
17
+ }).auto_scaling_groups.first.instances.collect do |i|
18
+ Aws::EC2::Instance.new i.instance_id, client: awsec2
19
+ end.collect do |i|
20
+ "#{res.logical_resource_id.ljust(30, ' ')} #{i.public_dns_name}"
21
+ end
22
+ end
23
+ end
24
+ ts.each(&:join)
25
+ puts ts.collect { |t| t.value.join "\n" }
26
+ end
27
+
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,18 @@
1
+ module CloudFormationTool
2
+ module CLI
3
+
4
+ class Status < Clamp::Command
5
+
6
+ parameter "STACK_NAME", "Name of the stack to delete"
7
+
8
+ def execute
9
+ if CloudFormation::Stack.new(stack_name).exist?
10
+ log "OK"
11
+ else
12
+ log "Stack #{stack_name} does not exist"
13
+ end
14
+ end
15
+
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,153 @@
1
+ require 'yaml'
2
+ require 'base64'
3
+
4
+ module CloudFormationTool
5
+ class CloudFormation
6
+
7
+ Autoloaded.class { }
8
+
9
+ def self.parse(path)
10
+ CloudFormation.new(path)
11
+ end
12
+
13
+ def initialize(path)
14
+ log "Loading #{path}"
15
+ @path = path
16
+ @path = "#{@path}/cloud-formation.yaml" if File.directory? @path
17
+ @path = "#{@path}.yaml" if !File.exist? @path and File.exist? "#{@path}.yaml"
18
+ @basedir = File.dirname(@path)
19
+ @compiled = false
20
+ text = File.read(@path)
21
+ # remove comments because white space seen between comments can seriously psych Psych
22
+ text.gsub!(/^#.*\n/s,'')
23
+ text = fixShorthand(text)
24
+ begin
25
+ @data = YAML.load(text).to_h
26
+ rescue Psych::SyntaxError => e
27
+ e.message =~ /line (\d+) column (\d+)/
28
+ lines = text.split "\n"
29
+ raise AppError, "Error parsing #{path} at line #{e.line} column #{e.column}:\n" +
30
+ "#{lines[e.line-1]}\n" +
31
+ "#{(' ' * (e.column - 1 ))}^- #{e.problem} #{e.context}"
32
+ rescue Errno::ENOENT => e
33
+ raise AppError, "Error reading #{path}: #{e.message}"
34
+ end
35
+ end
36
+
37
+ def compile
38
+ return @data if @compiled
39
+ @compiled = true
40
+ embed_includes
41
+ @data = load_files(@data)
42
+ end
43
+
44
+ def to_yaml
45
+ compile.to_yaml
46
+ end
47
+
48
+ def fixShorthand(text)
49
+ text.gsub(/(?:(\s*)([^![:space:]]+))?(\s+)!(\w+)/) do |match|
50
+ case $4
51
+ when *%w(Base64 FindInMap GetAtt GetAZs ImportValue Join Select Sub
52
+ And Equals If Not Or)
53
+ ($2.nil? ? "" : "#{$1}#{$2}\n#{$1} ") + "#{$3}\"Fn::#{$4}\":"
54
+ when 'Ref'
55
+ "#{$1}#{$2}\n#{$1} #{$3}#{$4}:"
56
+ else
57
+ match
58
+ end
59
+ end
60
+ end
61
+
62
+ def fixrefs(data, rmap)
63
+ case data
64
+ when Hash
65
+ data.inject({}) do |h,(k,v)|
66
+ h[k] = if k == "Ref"
67
+ rmap[v] || v
68
+ else
69
+ fixrefs(v,rmap)
70
+ end
71
+ h
72
+ end
73
+ when Array
74
+ data.collect do |item|
75
+ fixrefs(item, rmap)
76
+ end
77
+ else
78
+ return data
79
+ end
80
+ end
81
+
82
+ def embed_includes
83
+ (@data.delete(@data.keys.find{|k| k.start_with? 'Include'}) || []).each do |path|
84
+ realpath = "#{@basedir}/#{path}"
85
+ cfile_key = File.dirname(realpath).gsub(%r{/(.)}){|m| $1.upcase }.gsub(/\W+/,'')
86
+ rewrites = Hash.new
87
+ CloudFormation.new(realpath).compile.each do |category, catdata|
88
+ # some categories are meta-data that we can ignore from includes
89
+ next if %w(AWSTemplateFormatVersion Description).include? category
90
+
91
+ case category
92
+ when "Parameters"
93
+ @data[category].each do |name, param|
94
+ if catdata.has_key? name
95
+ next if param['Default'] == catdata[name]['Default']
96
+
97
+ if catdata[name].has_key?('Override') and catdata[name]['Override'] == false
98
+ catdata.delete(name)
99
+ else
100
+ newname = "#{cfile_key}z#{name}"
101
+ log "Rewriting conflicting parameter #{name} (='#{catdata[name]['Default']}') to #{newname}"
102
+ catdata[newname] = catdata.delete name
103
+ rewrites[name] = newname
104
+ end
105
+ else
106
+ @data[category][name] = param
107
+ end
108
+ end
109
+ else
110
+ # warn against duplicate entities, resources or outputs
111
+ (@data[category] ||= {}).keys.each do |key|
112
+ if catdata.has_key? key
113
+ raise AppError.new("Error compiling #{path} - duplicate '#{category}' item: #{key}")
114
+ end
115
+ end
116
+ catdata = fixrefs(catdata, rewrites)
117
+ end
118
+
119
+ # add included properties
120
+ @data[category].merge! catdata
121
+ end
122
+ end
123
+ end
124
+
125
+ def load_files(data)
126
+ case data
127
+ when Array
128
+ data.collect { |data| load_files(data) }
129
+ when Hash
130
+ data.inject({}) do |dict, (key, val)|
131
+ dict[key] = if (key == "UserData") and (val["File"])
132
+ # Support LaunchConfiguration UserData from file
133
+ CloudInit.new("#{@basedir}/#{val["File"]}").to_base64
134
+ elsif (key == "Code") and (val["URL"])
135
+ # Support Lambda Code from arbitrary URLs
136
+ LambdaCode.new(val["URL"]).to_cloudformation
137
+ else
138
+ load_files(val)
139
+ end
140
+ dict
141
+ end
142
+ else
143
+ data
144
+ end
145
+ end
146
+
147
+ def each
148
+ compile['Parameters'].each do |name, param|
149
+ yield name, param['Default']
150
+ end
151
+ end
152
+ end
153
+ end