require 'set' require 'ostruct' require 'yaml' require 'pathname' require 'base64' require 'aws/with-stacco-patches' require 'stacco/base' require 'stacco/template/old' class Stacco::Stack def initialize(stack_bucket) @bucket = stack_bucket @bucket.cache_dir = Pathname.new(ENV['HOME']) + '.config' + 'stacco' + 'stack' + @bucket.name @config_object = @bucket.objects['stack.yml'] 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), rds: AWS::RDS.new(aws_creds), iam: AWS::IAM.new(aws_creds) } @aws_stack = @services[:cloudformation].stacks[self.name] @aws_stack.service_registry = @services end def connections connections = {} running_instances = @aws_stack.instances.find_all{ |i| i.status == :running } running_instances.each{ |i| connections[i.tags["aws:cloudformation:logical-id"]] = i } connections end def databases @aws_stack.rds_instances.inject({}) do |dbs, (k, v)| (dbs[k] = v) if v.status == "available" dbs end end def resource_summaries @aws_stack.resource_summaries end def must_be_up! unless self.up? $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 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_layer_names.include? layer_name end self.update_config do |c| c['layers'] = self.enabled_layer_names | 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_layer_names - layer_names end end def enabled_layer_names (self.available_layer_names & (self.config['layers'] || [])) end def enabled_layers self.enabled_layer_names.map{ |layer_name| Stacco::Layer.load(self, layer_name) } end def layer_enabled?(layer_name) self.enabled_layer_names.inlude?(layer_name.to_s) end def aws_credentials Hash[ *(self.config['aws'].map{ |k, v| [k.intern, v] }.flatten) ] end def description self.config['description'] end def name self.config['name'] end def name=(new_name) update_config do |c| c['name'] = new_name end end def up? @aws_stack.exists? end def bake_template(opts = {}) publish_first = opts.delete(:publish) baked_template_body = self.cloudformation_template_body env_lns = [ "cat >/etc/environment.local < self.iam_keypair_name, 'MainDBAdminUsernameVar' => self.secrets['db_admin_username'], 'MainDBAdminPasswordVar' => self.secrets['db_admin_password'], 'EnvironmentTypeVar' => self.config['environment'], 'UserDataEnvironmentVar' => env_lns.join } (self.config['permit_backoffice_access'] || []).each do |rule_name, (auth_type, auth_opts)| case auth_type when :ip_range parameters["#{rule_name.capitalize}IPRange"] = auth_opts end end scaling_groups = self.config['scale'] self.enabled_layer_names.each do |layer_name| next unless scaling_groups.has_key?(layer_name) camelized_layer_name = layer_name.split('-').map{ |w| w.capitalize.gsub(/api/i, 'API') }.join parameters["Min#{camelized_layer_name}Var"] = scaling_groups[layer_name].to_s parameters["Max#{camelized_layer_name}Var"] = (scaling_groups[layer_name] + 1).to_s end if instance_ami = self.config['base_image'] parameters['InstanceAMIVar'] = instance_ami end 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 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_layer_names Stacco::Resources::LayerTemplates.keys end def available_layers self.available_layer_names.map{ |layer_name| Stacco::Layer.load(self, layer_name) } 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! @services[:cloudfront].distributions.each do |dist| dist.update do next unless stack_dist_cert = @aws_stack.server_certificates(domain: dist.aliases).first dist.price_class = :"100" dist.certificate = stack_dist_cert.id end end end def invalidate_distributed_objects!(dist_cname, obj_keys) @aws_stack.distribution(dist_cname).invalidate(obj_keys) end def down! return false unless self.up? @aws_stack.buckets.each{ |bucket| bucket.delete! } @aws_stack.delete true end def cloudformation_template Stacco::Template.const_get(self.config['template']).new end def cloudformation_template_body self.cloudformation_template.to_json(stack: self) end def validate 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(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_object.read.split("\n")[line.to_i], column]] else [false, msg] end end end def iam_private_key @bucket.objects.with_prefix("ssh-key/").to_a.sort_by{ |obj| obj.key.split('/').last.to_i }.last end def iam_keypair_name "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 true added = 0 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 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(', ')}", detail: nil ) end end Kernel.sleep 2 end end end end