cloudformation-tool 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE +339 -0
- data/README.md +204 -0
- data/bin/cftool +9 -0
- data/lib/cloud_formation_tool.rb +94 -0
- data/lib/cloud_formation_tool/cli.rb +5 -0
- data/lib/cloud_formation_tool/cli/compile.rb +19 -0
- data/lib/cloud_formation_tool/cli/create.rb +50 -0
- data/lib/cloud_formation_tool/cli/delete.rb +18 -0
- data/lib/cloud_formation_tool/cli/list_stacks.rb +15 -0
- data/lib/cloud_formation_tool/cli/main.rb +31 -0
- data/lib/cloud_formation_tool/cli/monitor.rb +28 -0
- data/lib/cloud_formation_tool/cli/parameters.rb +20 -0
- data/lib/cloud_formation_tool/cli/servers.rb +30 -0
- data/lib/cloud_formation_tool/cli/status.rb +18 -0
- data/lib/cloud_formation_tool/cloud_formation.rb +153 -0
- data/lib/cloud_formation_tool/cloud_formation/lambda_code.rb +41 -0
- data/lib/cloud_formation_tool/cloud_formation/stack.rb +152 -0
- data/lib/cloud_formation_tool/cloud_init.rb +86 -0
- data/lib/cloud_formation_tool/errors.rb +11 -0
- data/lib/cloud_formation_tool/storable.rb +41 -0
- data/lib/cloud_formation_tool/version.rb +3 -0
- metadata +109 -0
data/bin/cftool
ADDED
@@ -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,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
|