./lib/stacco/stack.rb in stacco-0.1.18 vs ./lib/stacco/stack.rb in stacco-0.1.20

- old
+ new

@@ -1,9 +1,10 @@ require 'set' require 'ostruct' require 'yaml' require 'pathname' +require 'base64' require 'aws/with-stacco-patches' require 'stacco/base' @@ -18,10 +19,12 @@ aws_creds = self.aws_credentials @services = { ec2: AWS::EC2.new(aws_creds), s3: AWS::S3.new(aws_creds), + autoscaling: AWS::AutoScaling.new(aws_creds), + route53: AWS::Route53.new(aws_creds), cloudformation: AWS::CloudFormation.new(aws_creds), cloudfront: AWS::CloudFront.new(aws_creds), iam: AWS::IAM.new(aws_creds) } @@ -44,30 +47,89 @@ $stderr.puts "stack #{self.name} is down" Kernel.exit 1 end end + def operation_in_progress? + @aws_stack.exists? and @aws_stack.status =~ /_IN_PROGRESS$/ + end + + def cancel_operation + return unless self.operation_in_progress? + @aws_stack.cancel_update + end + + def cancel_operation! + self.cancel_operation + Kernel.sleep(2) while self.operation_in_progress? + end + def aws_status @aws_stack.status end def status self.up? ? self.aws_status : "DOWN" end + def domain + domain = Stacco::Domain.new(self, self.config['domain'].gsub(/\.$/, '').split('.').reverse) + domain.service_registry = @services + domain + end + + def subdomains + d = self.domain + + self.config['subdomains'].map do |logical_name, prefix_parts| + sd = prefix_parts.inject(d, &:+) + sd.logical_name = logical_name + sd + end + end + def config YAML.load(@config_object.read) end def config=(new_config) @config_object.write(new_config.to_yaml) end def update_config - # TODO + new_config = self.config + yield(new_config) + self.config = new_config end + def enable_layers(layer_names) + layer_names = layer_names.map(&:to_s) + layer_names.each do |layer_name| + raise ArgumentError, "Layer '#{layer_name}' is not provided by the template definition" unless self.available_layers.include? layer_name + end + + self.update_config do |c| + c['layers'] = self.enabled_layers | layer_names + end + end + + def disable_layers(layer_names) + layer_names = layer_names.map(&:to_s) + + self.update_config do |c| + c['layers'] = self.enabled_layers - layer_names + end + end + + def enabled_layers + self.available_layers & (self.config['layers'] || []) + end + + def layer_enabled?(layer_name) + self.enabled_layers.inlude?(layer_name.to_s) + end + def aws_credentials Hash[ *(self.config['aws'].map{ |k, v| [k.intern, v] }.flatten) ] end def description @@ -77,25 +139,102 @@ def name self.config['name'] end def name=(new_name) - update_config{ |config| config.merge("name" => new_name) } + update_config do |c| + c['name'] = new_name + end end def up? @aws_stack.exists? end - def up! - if @aws_stack.exists? - @aws_stack.update(template: self.cloudformation_template) + def bake_template(opts = {}) + publish_first = opts.delete(:publish) + + baked_template_body = self.cloudformation_template_body + + env_lns = [ + "cat >/etc/environment.local <<EOF", + self.config.find_all{ |k, v| v.kind_of?(String) }.map{ |(k, v)| "export #{k.to_s.upcase}=\"#{v}\"" }, + self.secrets.map{ |k, v| "export #{k.to_s.upcase}=\"#{v}\"" }, + "EOF", + "source /etc/environment.local" + ].flatten.map{ |ln| ln + "\n" } + + parameters = { + 'DockerLibrarySnapshotVar' => self.config['docker_library_snapshot'], + 'IAMKeypairNameVar' => self.iam_keypair_name, + 'UserDataEnvironmentVar' => env_lns.join + } + + Stacco::Resources::RoleScripts.each do |role_name, role_script| + parameters["#{role_name}RoleScriptVar"] = role_script + end + + + bake_id = '%d-%04x' % [Time.now.to_i, rand(36 ** 4)] + template_object = @bucket.objects["template/#{bake_id}"] + + if publish_first + template_object.write(baked_template_body, acl: :authenticated_read) + end + + return [template_object, parameters] unless block_given? + + if block_given? + new_template_body = yield(baked_template_body, parameters) else - @services[:cloudformation].stacks.create(self.name, self.cloudformation_template) + new_template_body = baked_template_body end + + unless publish_first and new_template_body == baked_template_body + if new_template_body + template_object.write(new_template_body, acl: :authenticated_read) + else + template_object.delete if template_object.exists? + end + end + + [template_object, parameters] end + def secrets + self.config['secrets'] + end + + def roles + Stacco::Resources::RoleScripts.keys + end + + def available_layers + Stacco::Resources::LayerTemplates.keys + end + + def up! + body_object, params = self.bake_template(publish: true) + + unless @aws_stack.exists? + return @services[:cloudformation].stacks.create( + self.name, + body_object.public_url, + parameters: params + ) + #disable_rollback: true + end + + begin + @aws_stack.update(template: body_object.public_url, parameters: params) + true + rescue AWS::CloudFormation::Errors::ValidationError => e + raise unless e.message =~ /no updates/i + false + end + end + def up_since @aws_stack.creation_time if @aws_stack.exists? end def initialize_distributions! @@ -115,75 +254,141 @@ true end def cloudformation_template - #Kernel.eval(Stacco::Resources::Templates[:cloudformation]) - tpl = Stacco::Template.const_get(self.config['template']).new - tpl.to_json(stack: self) + Stacco::Template.const_get(self.config['template']).new end def cloudformation_template_body - Stacco::Resources::Templates[:cloudformation] + self.cloudformation_template.to_json(stack: self) end def validate - baked_template = self.cloudformation_template - test_template = baked_template.gsub(/"[cm][123]\.(\dx)?(small|medium|large)"/, '"m1.small"') + body_object, _ = self.bake_template do |body, params| + tpl.gsub(/"[cm][123]\.(\dx)?(small|medium|large)"/, '"m1.small"') + end + Kernel.sleep 1 + begin - @services[:cloudformation].estimate_template_cost test_template + @services[:cloudformation].estimate_template_cost(body_object) [true] rescue AWS::CloudFormation::Errors::ValidationError => e msg = e.message match = msg.scan(/^Template format error: JSON not well-formed. \(line (\d+), column (\d+)\)$/) if match.length.nonzero? line, column = match.to_a.flatten.map{ |el| el.to_i } - [false, msg, [baked_template.split("\n")[line.to_i], column]] + [false, msg, [baked_template_object.read.split("\n")[line.to_i], column]] else [false, msg] end end end def iam_private_key - @config_object.bucket.objects.with_prefix("sshkey/#{self.name}-").to_a.sort_by{ |obj| obj.key.split('/').last.split('-').last.to_i }.last + @bucket.objects.with_prefix("ssh-key/").to_a.sort_by{ |obj| obj.key.split('/').last.to_i }.last end def iam_keypair_name - "stacco-" + self.iam_private_key.key.split('/').last + "stacco-%s-%s" % [self.name, self.iam_private_key.key.split('/').last] end def stream_events Enumerator.new do |out| known_events = Set.new ticks_without_add = 0 + current_tick = 0 + current_op = nil + tracked_resources = Set.new - while self.up? + while true added = 0 - @aws_stack.events.sort_by{ |ev| ev.timestamp }.each do |event| + stack_events = @aws_stack.events.to_a rescue [] + + current_resources = [@aws_stack.instances, @aws_stack.distributions].flatten + current_resources.each do |new_rs| + next if tracked_resources.member? new_rs + tracked_resources.add new_rs + new_rs.instance_variable_set('@prev_status', :nonexistent) + end + + tracked_resources.each do |rs| + resource_name = [rs.tags['aws:cloudformation:logical-id']] + if rs.tags['aws:autoscaling:groupName'] + resource_name.push(rs.id.split('-')[1]) + end + resource_name = resource_name.compact.join('.') + + resource_is_live = (current_tick > 0) + resource_status_delta = rs.change_in_status + + if resource_is_live and resource_status_delta + now = Time.now + evt = OpenStruct.new( + event_id: "#{rs.id}#{now.to_i}#{resource_status_delta.inspect}", + live: true, + logical_resource_id: resource_name, + status: "CHANGED", + operation: "UPDATE", + timestamp: now, + error: "#{resource_status_delta[:from]} -> #{resource_status_delta[:to]}", + detail: nil + ) + + if resource_status_delta[:to] == :terminated and rs.respond_to?(:console_output) and logs = rs.console_output + logs = logs.split("\r\n") + if cfn_signal_ln = logs.grep("CloudFormation signaled successfully with FAILURE.").last + logs = logs[0 ... logs.index(cfn_signal_ln)] + end + logs = logs[-30 .. -1] + evt.detail = logs.map{ |ln| ln } + end + + stack_events.push evt + end + end + + stack_events = stack_events.sort_by{ |ev| ev.timestamp } + + stack_events.each do |event| next if known_events.include? event.event_id + known_events.add event.event_id + + if event.resource_type == "AWS::CloudFormation::Stack" + current_op = event + end + + event.live = (current_tick > 0) + event.op = current_op + out.yield event - known_events.add event.event_id added += 1 ticks_without_add = 0 + end + if current_tick == 0 and stack_events.last.op + stack_events.last.op.live = true + stack_events.each{ |ev| out.yield(ev) if (ev.op and ev.op.live) } end + current_tick += 1 ticks_without_add += 1 if added == 0 if ticks_without_add >= 8 and (Math.log2(ticks_without_add) % 1) == 0.0 jobs = @aws_stack.resource_summaries active_jobs = jobs.find_all{ |job| job[:resource_status] =~ /IN_PROGRESS$/ }.map{ |job| job[:logical_resource_id] }.sort unless active_jobs.empty? out.yield OpenStruct.new( + live: true, logical_resource_id: "Scheduler", status: "WAIT", operation: "WAIT", timestamp: Time.now, - error: "waiting on #{active_jobs.join(', ')}" + error: "waiting on #{active_jobs.join(', ')}", + detail: nil ) end end Kernel.sleep 2