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.
- 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
|