cloudformation-tool 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
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