require "miasma" module Miasma module Models class Orchestration # AWS Orchestration API class Aws < Orchestration include Bogo::Logger::Helpers logger_name("aws.orchestration") # Extended stack model to provide AWS specific stack options class Stack < Orchestration::Stack attribute :stack_policy_body, Hash, :coerce => lambda { |v| MultiJson.load(v).to_smash } attribute :stack_policy_url, String attribute :last_event_token, String end # Service name of the API API_SERVICE = "cloudformation".freeze # Service name of the eucalyptus API EUCA_API_SERVICE = "CloudFormation".freeze # Supported version of the AutoScaling API API_VERSION = "2010-05-15".freeze # Valid stack lookup states STACK_STATES = [ "CREATE_COMPLETE", "CREATE_FAILED", "CREATE_IN_PROGRESS", "DELETE_FAILED", "DELETE_IN_PROGRESS", "ROLLBACK_COMPLETE", "ROLLBACK_FAILED", "ROLLBACK_IN_PROGRESS", "UPDATE_COMPLETE", "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", "UPDATE_IN_PROGRESS", "UPDATE_ROLLBACK_COMPLETE", "UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS", "UPDATE_ROLLBACK_FAILED", "UPDATE_ROLLBACK_IN_PROGRESS", "REVIEW_IN_PROGRESS", ].map(&:freeze).freeze include Contrib::AwsApiCore::ApiCommon include Contrib::AwsApiCore::RequestUtils # @return [Smash] external to internal resource mapping RESOURCE_MAPPING = Smash.new( "AWS::EC2::Instance" => Smash.new( :api => :compute, :collection => :servers, ), "AWS::ElasticLoadBalancing::LoadBalancer" => Smash.new( :api => :load_balancer, :collection => :balancers, ), "AWS::AutoScaling::AutoScalingGroup" => Smash.new( :api => :auto_scale, :collection => :groups, ), "AWS::CloudFormation::Stack" => Smash.new( :api => :orchestration, :collection => :stacks, ), ).to_smash(:freeze) # Fetch stacks or update provided stack data # # @param stack [Models::Orchestration::Stack] # @return [Array<Models::Orchestration::Stack>] def load_stack_data(stack = nil) d_params = Smash.new("Action" => "DescribeStacks") l_params = Smash.new("Action" => "ListStacks") # TODO: Disable state filtering so we get entire list of defined # stacks. Allowing filtering would be ideal but need a generic # way to pass it through. This current filter setup imposes # list restrictions when new states are added that is less than # ideal # STACK_STATES.each_with_index do |state, idx| # l_params["StackStatusFilter.member.#{idx + 1}"] = state.to_s.upcase # end if stack logger.debug("loading stack information for `#{stack.id}`") d_params["StackName"] = stack.id descriptions = all_result_pages(nil, :body, "DescribeStacksResponse", "DescribeStacksResult", "Stacks", "member") do |options| request( :method => :post, :path => "/", :form => options.merge(d_params), ) end else logger.debug("loading stack listing information") lists = all_result_pages(nil, :body, "ListStacksResponse", "ListStacksResult", "StackSummaries", "member") do |options| request( :method => :post, :path => "/", :form => options.merge(l_params), ) end descriptions = [] end (lists || descriptions).map do |stk| if lists desc = descriptions.detect do |d_stk| d_stk["StackId"] == stk["StackId"] end || Smash.new stk.merge!(desc) end if stack next if stack.id != stk["StackId"] && stk["StackId"].split("/")[1] != stack.id end state = stk["StackStatus"].downcase.to_sym unless Miasma::Models::Orchestration::VALID_RESOURCE_STATES.include?(state) parts = state.to_s.split("_") state = [parts.first, *parts.slice(-2, parts.size)].join("_").to_sym unless Miasma::Models::Orchestration::VALID_RESOURCE_STATES.include?(parts) state = :unknown end end new_stack = stack || Stack.new(self) new_stack.load_data( :id => stk["StackId"], :name => stk["StackName"], :capabilities => [stk.get("Capabilities", "member")].flatten(1).compact, :description => stk["Description"], :created => stk["CreationTime"], :updated => stk["LastUpdatedTime"], :notification_topics => [stk.get("NotificationARNs", "member")].flatten(1).compact, :timeout_in_minutes => stk["TimeoutInMinutes"] ? stk["TimeoutInMinutes"].to_i : nil, :status => stk["StackStatus"], :status_reason => stk["StackStatusReason"], :state => state, :template_description => stk["TemplateDescription"], :disable_rollback => !!stk["DisableRollback"], :outputs => [stk.get("Outputs", "member")].flatten(1).compact.map { |o| Smash.new( :key => o["OutputKey"], :value => o["OutputValue"], :description => o["Description"], ) }, :tags => Smash[ [stk.fetch("Tags", "member", [])].flatten(1).map { |param| [param["Key"], param["Value"]] } ], :parameters => Smash[ [stk.fetch("Parameters", "member", [])].flatten(1).map { |param| [param["ParameterKey"], param["ParameterValue"]] } ], :custom => Smash.new( :stack_policy => stk["StackPolicyBody"], :stack_policy_url => stk["StackPolicyURL"], ), ).valid_state logger.debug("loaded stack information `#{new_stack.inspect}`") new_stack end end # Save the stack # # @param stack [Models::Orchestration::Stack] # @return [Models::Orchestration::Stack] def stack_save(stack) logger.debug("saving stack information `#{stack.inspect}`") params = common_stack_params(stack) if stack.custom[:stack_policy_body] params["StackPolicyBody"] = MultiJson.dump(stack.custom[:stack_policy_body]) end if stack.custom[:stack_policy_url] params["StackPolicyURL"] = stack.custom[:stack_policy_url] end unless stack.disable_rollback.nil? params["OnFailure"] = stack.disable_rollback ? "DO_NOTHING" : "ROLLBACK" end if stack.on_failure params["OnFailure"] = stack.on_failure == "nothing" ? "DO_NOTHING" : stack.on_failure.upcase end if stack.persisted? result = request( :path => "/", :method => :post, :form => Smash.new( "Action" => "UpdateStack", ).merge(params), ) stack else if stack.timeout_in_minutes params["TimeoutInMinutes"] = stack.timeout_in_minutes end result = request( :path => "/", :method => :post, :form => Smash.new( "Action" => "CreateStack", ).merge(params), ) stack.id = result.get(:body, "CreateStackResponse", "CreateStackResult", "StackId") stack.valid_state end end # Generate a new stack plan from the API # # @param stack [Models::Orchestration::Stack] # @return [Models::Orchestration::Stack] # @todo Needs to include the rolearn and resourcetypes # at some point but more thought on how to integrate def stack_plan(stack) logger.debug("generating plan for stack `#{stack.id}`") params = common_stack_params(stack) plan_name = changeset_name(stack) if stack.persisted? && stack.state != :unknown logger.debug("plan will update stack") changeset_type = "UPDATE" else logger.debug("plan will create stack") changeset_type = "CREATE" end result = request( :path => "/", :method => :post, :form => params.merge(Smash.new( "Action" => "CreateChangeSet", "ChangeSetName" => plan_name, "StackName" => stack.name, "ChangeSetType" => changeset_type, )), ) stack.reload # Ensure we have the same plan name in use after reload stack.custom = stack.custom.dup stack.custom[:plan_name] = plan_name stack.plan end # Load the plan for the stack # # @param stack [Models::Orchestration::Stack] # @return [Models::Orchestration::Stack::Plan] def stack_plan_load(stack) if stack.attributes[:plan] plan = stack.attributes[:plan] else plan = Stack::Plan.new(stack, name: changeset_name(stack)) end if stack.custom[:plan_name] if stack.custom[:plan_name] != plan.name plan.name = stack.custom[:plan_name] else plan.name = changeset_name(stack) end end logger.debug("loading plan `#{plan.name}` for stack `#{stack.id}`") result = nil Bogo::Retry.build(:linear, max_attempts: 10, wait_interval: 5, ui: Bogo::Ui.new) do begin result = request( :path => "/", :method => :post, :form => Smash.new( "Action" => "DescribeChangeSet", "ChangeSetName" => plan.name, "StackName" => stack.name, ), ) rescue Error::ApiError::RequestError => e # Plan does not exist if e.response.code == 404 logger.warn("plan `#{plan.name}` does not exist for stack `#{stack.id}`") return nil end # Stack does not exist if e.response.code == 400 && e.message.include?("ValidationError: Stack") logger.warn("stack `#{stack.id}` does not exist") return nil end raise end status = result.get(:body, "DescribeChangeSetResponse", "DescribeChangeSetResult", "ExecutionStatus") if status != "AVAILABLE" logger.debug("plan `#{plan.name}` is not available (status: `#{status}`)") raise "Plan execution is not yet available" end end.run! res = result.get(:body, "DescribeChangeSetResponse", "DescribeChangeSetResult") plan.id = res["ChangeSetId"] plan.name = res["ChangeSetName"] plan.custom = { :execution_status => res["ExecutionStatus"], :stack_name => res["StackName"], :stack_id => res["StackId"], :status => res["Status"], } plan.state = res["ExecutionStatus"].downcase.to_sym plan.parameters = Smash[ [res.get("Parameters", "member")].compact.flatten.map { |param| [param["ParameterKey"], param["ParameterValue"]] } ] plan.created_at = res["CreationTime"] plan.template = stack_plan_template(plan, :processed) items = {:add => [], :replace => [], :remove => [], :unknown => [], :interrupt => []} [res.get("Changes", "member")].compact.flatten.each do |chng| if chng["Type"] == "Resource" item_diffs = [] [chng.get("ResourceChange", "Details", "member")].compact.flatten.each do |d| item_path = [ d.get("Target", "Attribute"), d.get("Target", "Name"), ].compact original_value = stack.template.get("Resources", chng.get("ResourceChange", "LogicalResourceId"), *item_path) if original_value.is_a?(Hash) && (stack.parameters || {}).key?(original_value["Ref"]) original_value = stack.parameters[original_value["Ref"]] end new_value = plan.template.get("Resources", chng.get("ResourceChange", "LogicalResourceId"), *item_path) if new_value.is_a?(Hash) && plan.parameters.key?(new_value["Ref"]) new_value = plan.parameters[new_value["Ref"]] end diff = Stack::Plan::Diff.new( :name => item_path.join("."), :current => original_value.inspect, :proposed => new_value.inspect, ) unless item_diffs.detect { |d| d.name == diff.name && d.current == diff.current && d.proposed == diff.proposed } item_diffs << diff end end type = case chng.get("ResourceChange", "Action").to_s.downcase when "add" :add when "modify" chng.get("ResourceChange", "Replacement") == "True" ? :replace : :interrupt when "remove" :remove else :unknown end items[type] << Stack::Plan::Item.new( :name => chng.get("ResourceChange", "LogicalResourceId"), :type => chng.get("ResourceChange", "ResourceType"), :diffs => item_diffs.sort_by(&:name), ) end end.compact items.each do |type, list| plan.send("#{type}=", list.sort_by(&:name)) end if plan.custom[:stack_id] stack.id = plan.custom[:stack_id] stack.valid_state end logger.debug("plan `#{plan.name}` loaded for stack `#{stack.id}` - `#{plan.inspect}`") stack.plan = plan.valid_state end def stack_plan_template(plan, state) logger.debug("loading template for plan `#{plan.name}`") result = request( :path => "/", :method => :post, :form => Smash.new( "Action" => "GetTemplate", "ChangeSetName" => plan.id, "TemplateStage" => state.to_s.capitalize, ), ) MultiJson.load(result.get(:body, "GetTemplateResponse", "GetTemplateResult", "TemplateBody")).to_smash end # Delete the plan attached to the stack # # @param stack [Models::Orchestration::Stack] # @return [Models::Orchestration::Stack] def stack_plan_destroy(stack) logger.debug("deleting plan `#{stack.plan.id}` for stack `#{stack.id}`") request( :path => "/", :method => :post, :form => Smash.new( "Action" => "DeleteChangeSet", "ChangeSetName" => stack.plan.id, "StackName" => stack.name, ), ) stack.plan = nil stack.valid_state end # Apply the plan attached to the stack # # @param stack [Model::Orchestration::Stack] # @return [Model::Orchestration::Stack] def stack_plan_execute(stack) logger.debug("applying plan `#{stack.plan.id}` to stack `#{stack.id}`") request( :path => "/", :method => :post, :form => Smash.new( "Action" => "ExecuteChangeSet", "ChangeSetName" => stack.plan.id, "StackName" => stack.name, ), ) stack.reload end # Reload the plan # # @param plan [Model::Orchestration::Stack::Plan] # @return [Model::Orchestration::Stack::Plan] def stack_plan_reload(plan) logger.debug("reloading plan `#{plan.id}`") if plan.stack.plan == plan stack_plan_load(plan.stack) else stack = Stack.new(self, id: plan.custom[:stack_id], name: plan.custom[:stack_name]) stack.dirty[:plan] = plan stack_plan_load(stack) end end # Load all plans associated to given stack # # @param stack [Models::Orchestration::Stack] # @return [Array<Models::Orchestration::Stack::Plan>] def stack_plan_all(stack) logger.debug("loading all plans for stack `#{stack.id}`") all_result_pages(nil, :body, "ListChangeSetsResponse", "ListChangeSetsResult", "Summaries", "member") do |options| request( :method => :post, :path => "/", :form => options.merge( Smash.new( "Action" => "ListChangeSets", "StackName" => stack.id || stack.name, ) ), ) end.map do |res| stack = Stack.new(self, id: res["StackId"], name: res["StackName"]) stack.custom = {:plan_name => res["ChangeSetName"], :plan_id => res["ChangeSetId"]} stack.plan end end # Generate changeset name given stack. This # is a unique name for miasma and ensures only # one changeset is used/persisted for miasma # interactions. # # @param stack [Models::Orchestration::Stack] # @return [String] def changeset_name(stack) stack.custom.fetch(:plan_name, "miasma-changeset-#{stack.name}") end # Reload the stack data from the API # # @param stack [Models::Orchestration::Stack] # @return [Models::Orchestration::Stack] def stack_reload(stack) logger.debug("reloading stack `#{stack.id}`") if stack.persisted? ustack = Stack.new(self) ustack.id = stack.id load_stack_data(ustack) if ustack.data[:name] stack.load_data(ustack.attributes).valid_state else stack.status = "DELETE_COMPLETE" stack.state = :delete_complete stack.valid_state end end stack end # Delete the stack # # @param stack [Models::Orchestration::Stack] # @return [TrueClass, FalseClass] def stack_destroy(stack) if stack.persisted? logger.debug("deleting stack `#{stack.id}`") request( :method => :post, :path => "/", :form => Smash.new( "Action" => "DeleteStack", "StackName" => stack.id, ), ) true else logger.debug("stack not persisted. delete is no-op `#{stack.name}`") false end end # Fetch stack template # # @param stack [Stack] # @return [Smash] stack template def stack_template_load(stack) if stack.persisted? logger.debug("loading template for stack `#{stack.id}`") result = request( :method => :post, :path => "/", :form => Smash.new( "Action" => "GetTemplate", "StackName" => stack.id, ), ) template = result.get(:body, "GetTemplateResponse", "GetTemplateResult", "TemplateBody") template.nil? ? Smash.new : MultiJson.load(template) else logger.debug("no template for non-persisted stack `#{stack.name}`") Smash.new end end # Validate stack template # # @param stack [Stack] # @return [NilClass, String] nil if valid, string error message if invalid def stack_template_validate(stack) begin if stack.template_url params = Smash.new("TemplateURL" => stack.template_url) else params = Smash.new("TemplateBody" => MultiJson.dump(stack.template)) end result = request( :method => :post, :path => "/", :form => params.merge( "Action" => "ValidateTemplate", ), ) nil rescue Error::ApiError::RequestError => e logger.error("template validate error - #{e.response.body}") MultiXml.parse(e.response.body.to_s).to_smash.get( "ErrorResponse", "Error", "Message" ) end end # Return single stack # # @param ident [String] name or ID # @return [Stack] def stack_get(ident) i = Stack.new(self) i.id = ident i.reload i.name ? i : nil end # Return all stacks # # @param options [Hash] filter # @return [Array<Models::Orchestration::Stack>] # @todo check if we need any mappings on state set def stack_all load_stack_data end # Return all resources for stack # # @param stack [Models::Orchestration::Stack] # @return [Array<Models::Orchestration::Stack::Resource>] def resource_all(stack) all_result_pages(nil, :body, "ListStackResourcesResponse", "ListStackResourcesResult", "StackResourceSummaries", "member") do |options| request( :method => :post, :path => "/", :form => options.merge( Smash.new( "Action" => "ListStackResources", "StackName" => stack.id, ) ), ) end.map do |res| Stack::Resource.new( stack, :id => res["PhysicalResourceId"], :name => res["LogicalResourceId"], :logical_id => res["LogicalResourceId"], :type => res["ResourceType"], :state => res["ResourceStatus"].downcase.to_sym, :status => res["ResourceStatus"], :updated => res["LastUpdatedTimestamp"], ).valid_state end end # Reload the stack resource data from the API # # @param resource [Models::Orchestration::Stack::Resource] # @return [Models::Orchestration::Resource] def resource_reload(resource) result = request( :method => :post, :path => "/", :form => Smash.new( "LogicalResourceId" => resource.logical_id, "StackName" => resource.stack.name, ), ).get(:body, "DescribeStackResourceResponse", "DescribeStackResourceResult", "StackResourceDetail") resource.updated = result["LastUpdatedTimestamp"] resource.type = result["ResourceType"] resource.state = result["ResourceStatus"].downcase.to_sym resource.status = result["ResourceStatus"] resource.status_reason = result["ResourceStatusReason"] resource.valid_state resource end # Return all events for stack # # @param stack [Models::Orchestration::Stack] # @return [Array<Models::Orchestration::Stack::Event>] def event_all(stack, evt_id = nil) evt_id = stack.last_event_token if evt_id results = all_result_pages(evt_id, :body, "DescribeStackEventsResponse", "DescribeStackEventsResult", "StackEvents", "member") do |options| request( :method => :post, :path => "/", :form => options.merge( "Action" => "DescribeStackEvents", "StackName" => stack.id, ), ) end events = results.map do |event| stack.last_event_token = event["NextToken"] if event["NextToken"] Stack::Event.new( stack, :id => event["EventId"], :resource_id => event["PhysicalResourceId"], :resource_name => event["LogicalResourceId"], :resource_logical_id => event["LogicalResourceId"], :resource_state => event["ResourceStatus"].downcase.to_sym, :resource_status => event["ResourceStatus"], :resource_status_reason => event["ResourceStatusReason"], :time => Time.parse(event["Timestamp"]), ).valid_state end if evt_id idx = events.index { |d| d.id == evt_id } idx ? events.slice(0, idx) : events else events end end # Return all new events for event collection # # @param events [Models::Orchestration::Stack::Events] # @return [Array<Models::Orchestration::Stack::Event>] def event_all_new(events) event_all(events.stack, events.all.first.id) end # Reload the stack event data from the API # # @param resource [Models::Orchestration::Stack::Event] # @return [Models::Orchestration::Event] def event_reload(event) event.stack.events.reload event.stack.events.get(event.id) end # Common parameters used for stack creation/update # requests. This is currently shared between stack # creation and plan creation # # @param stack [Model::Orchestration::Stack] # @return [Smash] def common_stack_params(stack) params = Smash.new("StackName" => stack.name) if stack.dirty?(:parameters) initial_parameters = stack.data[:parameters] || {} else initial_parameters = {} end (stack.parameters || {}).each_with_index do |pair, idx| params["Parameters.member.#{idx + 1}.ParameterKey"] = pair.first if initial_parameters[pair.first] == pair.last params["Parameters.member.#{idx + 1}.UsePreviousValue"] = true else params["Parameters.member.#{idx + 1}.ParameterValue"] = pair.last end end (stack.capabilities || []).each_with_index do |cap, idx| params["Capabilities.member.#{idx + 1}"] = cap end (stack.notification_topics || []).each_with_index do |topic, idx| params["NotificationARNs.member.#{idx + 1}"] = topic end (stack.tags || {}).each_with_index do |tag, idx| params["Tags.member.#{idx + 1}.Key"] = tag.first params["Tags.member.#{idx + 1}.Value"] = tag.last end if stack.template_url params["TemplateURL"] = stack.template_url elsif !stack.dirty?(:template) && stack.persisted? params["UsePreviousTemplate"] = true else params["TemplateBody"] = MultiJson.dump(stack.template) end params end end end end end