#! /usr/bin/env ruby
require 'spec_helper'
require 'matchers/include_in_order'
require 'puppet_spec/compiler'

require 'puppet/transaction'
require 'fileutils'

describe Puppet::Transaction do
  include PuppetSpec::Files
  include PuppetSpec::Compiler

  def catalog_with_resource(resource)
    catalog = Puppet::Resource::Catalog.new
    catalog.add_resource(resource)
    catalog
  end

  def transaction_with_resource(resource)
    transaction = Puppet::Transaction.new(catalog_with_resource(resource), nil, Puppet::Graph::RandomPrioritizer.new)
    transaction
  end

  before do
    @basepath = make_absolute("/what/ever")
    @transaction = Puppet::Transaction.new(Puppet::Resource::Catalog.new, nil, Puppet::Graph::RandomPrioritizer.new)
  end

  it "should be able to look resource status up by resource reference" do
    resource = Puppet::Type.type(:notify).new :title => "foobar"
    transaction = transaction_with_resource(resource)
    transaction.evaluate

    expect(transaction.resource_status(resource.to_s)).to be_changed
  end

  # This will basically only ever be used during testing.
  it "should automatically create resource statuses if asked for a non-existent status" do
    resource = Puppet::Type.type(:notify).new :title => "foobar"
    transaction = transaction_with_resource(resource)
    expect(transaction.resource_status(resource)).to be_instance_of(Puppet::Resource::Status)
  end

  it "should add provided resource statuses to its report" do
    resource = Puppet::Type.type(:notify).new :title => "foobar"
    transaction = transaction_with_resource(resource)
    transaction.evaluate

    status = transaction.resource_status(resource)
    expect(transaction.report.resource_statuses[resource.to_s]).to equal(status)
  end

  it "should not consider there to be failed resources if no statuses are marked failed" do
    resource = Puppet::Type.type(:notify).new :title => "foobar"
    transaction = transaction_with_resource(resource)
    transaction.evaluate

    expect(transaction).not_to be_any_failed
  end

  it "should use the provided report object" do
    report = Puppet::Transaction::Report.new("apply")
    transaction = Puppet::Transaction.new(Puppet::Resource::Catalog.new, report, nil)

    expect(transaction.report).to eq(report)
  end

  it "should create a report if none is provided" do
    transaction = Puppet::Transaction.new(Puppet::Resource::Catalog.new, nil, nil)

    expect(transaction.report).to be_kind_of Puppet::Transaction::Report
  end

  describe "when initializing" do
    it "should create an event manager" do
      transaction = Puppet::Transaction.new(Puppet::Resource::Catalog.new, nil, nil)
      expect(transaction.event_manager).to be_instance_of(Puppet::Transaction::EventManager)
      expect(transaction.event_manager.transaction).to equal(transaction)
    end

    it "should create a resource harness" do
      transaction = Puppet::Transaction.new(Puppet::Resource::Catalog.new, nil, nil)
      expect(transaction.resource_harness).to be_instance_of(Puppet::Transaction::ResourceHarness)
      expect(transaction.resource_harness.transaction).to equal(transaction)
    end

    it "should set retrieval time on the report" do
      catalog = Puppet::Resource::Catalog.new
      report = Puppet::Transaction::Report.new("apply")
      catalog.retrieval_duration = 5

      report.expects(:add_times).with(:config_retrieval, 5)

      transaction = Puppet::Transaction.new(catalog, report, nil)
    end
  end

  describe "when evaluating a resource" do
    let(:resource) { Puppet::Type.type(:file).new :path => @basepath }

    it "should process events" do
      transaction = transaction_with_resource(resource)

      transaction.expects(:skip?).with(resource).returns false
      transaction.event_manager.expects(:process_events).with(resource)

      transaction.evaluate
    end

    describe "and the resource should be skipped" do
      it "should mark the resource's status as skipped" do
        transaction = transaction_with_resource(resource)

        transaction.expects(:skip?).with(resource).returns true

        transaction.evaluate
        expect(transaction.resource_status(resource)).to be_skipped
      end

      it "does not process any scheduled events" do
        transaction = transaction_with_resource(resource)
        transaction.expects(:skip?).with(resource).returns true
        transaction.event_manager.expects(:process_events).with(resource).never
        transaction.evaluate
      end

      it "dequeues all events scheduled on that resource" do
        transaction = transaction_with_resource(resource)
        transaction.expects(:skip?).with(resource).returns true
        transaction.event_manager.expects(:dequeue_all_events_for_resource).with(resource)
        transaction.evaluate
      end
    end
  end

  describe "when applying a resource" do
    before do
      @catalog = Puppet::Resource::Catalog.new
      @resource = Puppet::Type.type(:file).new :path => @basepath
      @catalog.add_resource(@resource)
      @status = Puppet::Resource::Status.new(@resource)

      @transaction = Puppet::Transaction.new(@catalog, nil, Puppet::Graph::RandomPrioritizer.new)
      @transaction.event_manager.stubs(:queue_events)
    end

    it "should use its resource harness to apply the resource" do
      @transaction.resource_harness.expects(:evaluate).with(@resource)
      @transaction.evaluate
    end

    it "should add the resulting resource status to its status list" do
      @transaction.resource_harness.stubs(:evaluate).returns(@status)
      @transaction.evaluate
      expect(@transaction.resource_status(@resource)).to be_instance_of(Puppet::Resource::Status)
    end

    it "should queue any events added to the resource status" do
      @transaction.resource_harness.stubs(:evaluate).returns(@status)
      @status.expects(:events).returns %w{a b}
      @transaction.event_manager.expects(:queue_events).with(@resource, ["a", "b"])
      @transaction.evaluate
    end

    it "should log and skip any resources that cannot be applied" do
      @resource.expects(:properties).raises ArgumentError
      @transaction.evaluate
      expect(@transaction.report.resource_statuses[@resource.to_s]).to be_failed
    end

    it "should report any_failed if any resources failed" do
      @resource.expects(:properties).raises ArgumentError
      @transaction.evaluate

      expect(@transaction).to be_any_failed
    end
  end

  describe "#unblock" do
    let(:graph) { @transaction.relationship_graph }
    let(:resource) { Puppet::Type.type(:notify).new(:name => 'foo') }

    it "should calculate the number of blockers if it's not known" do
      graph.add_vertex(resource)
      3.times do |i|
        other = Puppet::Type.type(:notify).new(:name => i.to_s)
        graph.add_vertex(other)
        graph.add_edge(other, resource)
      end

      graph.unblock(resource)

      expect(graph.blockers[resource]).to eq(2)
    end

    it "should decrement the number of blockers if there are any" do
      graph.blockers[resource] = 40

      graph.unblock(resource)

      expect(graph.blockers[resource]).to eq(39)
    end

    it "should warn if there are no blockers" do
      vertex = stub('vertex')
      vertex.expects(:warning).with "appears to have a negative number of dependencies"
      graph.blockers[vertex] = 0

      graph.unblock(vertex)
    end

    it "should return true if the resource is now unblocked" do
      graph.blockers[resource] = 1

      expect(graph.unblock(resource)).to eq(true)
    end

    it "should return false if the resource is still blocked" do
      graph.blockers[resource] = 2

      expect(graph.unblock(resource)).to eq(false)
    end
  end

  describe "when traversing" do
    let(:path) { tmpdir('eval_generate') }
    let(:resource) { Puppet::Type.type(:file).new(:path => path, :recurse => true) }

    before :each do
      @transaction.catalog.add_resource(resource)
    end

    it "should yield the resource even if eval_generate is called" do
      Puppet::Transaction::AdditionalResourceGenerator.any_instance.expects(:eval_generate).with(resource).returns true

      yielded = false
      @transaction.evaluate do |res|
        yielded = true if res == resource
      end

      expect(yielded).to eq(true)
    end

    it "should prefetch the provider if necessary" do
      @transaction.expects(:prefetch_if_necessary).with(resource)

      @transaction.evaluate {}
    end

    it "traverses independent resources before dependent resources" do
      dependent = Puppet::Type.type(:notify).new(:name => "hello", :require => resource)
      @transaction.catalog.add_resource(dependent)

      seen = []
      @transaction.evaluate do |res|
        seen << res
      end

      expect(seen).to include_in_order(resource, dependent)
    end

    it "traverses completely independent resources in the order they appear in the catalog" do
      independent = Puppet::Type.type(:notify).new(:name => "hello", :require => resource)
      @transaction.catalog.add_resource(independent)

      seen = []
      @transaction.evaluate do |res|
        seen << res
      end

      expect(seen).to include_in_order(resource, independent)
    end

    it "should fail unsuitable resources and go on if it gets blocked" do
      dependent = Puppet::Type.type(:notify).new(:name => "hello", :require => resource)
      @transaction.catalog.add_resource(dependent)

      resource.stubs(:suitable?).returns false

      evaluated = []
      @transaction.evaluate do |res|
        evaluated << res
      end

      # We should have gone on to evaluate the children
      expect(evaluated).to eq([dependent])
      expect(@transaction.resource_status(resource)).to be_failed
    end
  end

  describe "when generating resources before traversal" do
    let(:catalog) { Puppet::Resource::Catalog.new }
    let(:transaction) { Puppet::Transaction.new(catalog, nil, Puppet::Graph::RandomPrioritizer.new) }
    let(:generator) { Puppet::Type.type(:notify).new :title => "generator" }
    let(:generated) do
      %w[a b c].map { |name| Puppet::Type.type(:notify).new(:name => name) }
    end

    before :each do
      catalog.add_resource generator
      generator.stubs(:generate).returns generated
      # avoid crude failures because of nil resources that result
      # from implicit containment and lacking containers
      catalog.stubs(:container_of).returns generator
    end

    it "should call 'generate' on all created resources" do
      generated.each { |res| res.expects(:generate) }

      transaction.evaluate
    end

    it "should finish all resources" do
      generated.each { |res| res.expects(:finish) }

      transaction.evaluate
    end

    it "should copy all tags to the newly generated resources" do
      generator.tag('one', 'two')

      transaction.evaluate

      generated.each do |res|
        expect(res).to be_tagged(*generator.tags)
      end
    end
  end

  describe "after resource traversal" do
    let(:catalog) { Puppet::Resource::Catalog.new }
    let(:prioritizer) { Puppet::Graph::RandomPrioritizer.new }
    let(:report) { Puppet::Transaction::Report.new("apply") }
    let(:transaction) { Puppet::Transaction.new(catalog, report, prioritizer) }
    let(:generator) { Puppet::Transaction::AdditionalResourceGenerator.new(catalog, nil, prioritizer) }

    before :each do
      generator = Puppet::Transaction::AdditionalResourceGenerator.new(catalog, nil, prioritizer)
      Puppet::Transaction::AdditionalResourceGenerator.stubs(:new).returns(generator)
    end

    it "should should query the generator for whether resources failed to generate" do
      relationship_graph = Puppet::Graph::RelationshipGraph.new(prioritizer)
      catalog.stubs(:relationship_graph).returns(relationship_graph)

      sequence = sequence(:traverse_first)
      relationship_graph.expects(:traverse).in_sequence(sequence)
      generator.expects(:resources_failed_to_generate).in_sequence(sequence)

      transaction.evaluate
    end

    it "should report that resources failed to generate" do
      generator.expects(:resources_failed_to_generate).returns(true)
      report.expects(:resources_failed_to_generate=).with(true)

      transaction.evaluate
    end

    it "should not report that resources failed to generate if none did" do
      generator.expects(:resources_failed_to_generate).returns(false)
      report.expects(:resources_failed_to_generate=).never

      transaction.evaluate
    end
  end

  describe "when performing pre-run checks" do
    let(:resource) { Puppet::Type.type(:notify).new(:title => "spec") }
    let(:transaction) { transaction_with_resource(resource) }
    let(:spec_exception) { 'spec-exception' }

    it "should invoke each resource's hook and apply the catalog after no failures" do
      resource.expects(:pre_run_check)

      transaction.evaluate
    end

    it "should abort the transaction on failure" do
      resource.expects(:pre_run_check).raises(Puppet::Error, spec_exception)

      expect { transaction.evaluate }.to raise_error(Puppet::Error, /Some pre-run checks failed/)
    end

    it "should log the resource-specific exception" do
      resource.expects(:pre_run_check).raises(Puppet::Error, spec_exception)
      resource.expects(:log_exception).with(responds_with(:message, spec_exception))

      expect { transaction.evaluate }.to raise_error(Puppet::Error)
    end
  end

  describe "when skipping a resource" do
    before :each do
      @resource = Puppet::Type.type(:notify).new :name => "foo"
      @catalog = Puppet::Resource::Catalog.new
      @resource.catalog = @catalog
      @transaction = Puppet::Transaction.new(@catalog, nil, nil)
    end

    it "should skip resource with missing tags" do
      @transaction.stubs(:missing_tags?).returns(true)
      expect(@transaction).to be_skip(@resource)
    end

    it "should skip resources tagged with the skip tags" do
      @transaction.stubs(:skip_tags?).returns(true)
      expect(@transaction).to be_skip(@resource)
    end

    it "should skip unscheduled resources" do
      @transaction.stubs(:scheduled?).returns(false)
      expect(@transaction).to be_skip(@resource)
    end

    it "should skip resources with failed dependencies" do
      @transaction.stubs(:failed_dependencies?).returns(true)
      expect(@transaction).to be_skip(@resource)
    end

    it "should skip virtual resource" do
      @resource.stubs(:virtual?).returns true
      expect(@transaction).to be_skip(@resource)
    end

    it "should skip device only resouce on normal host" do
      @resource.stubs(:appliable_to_host?).returns false
      @resource.stubs(:appliable_to_device?).returns true
      @transaction.for_network_device = false
      expect(@transaction).to be_skip(@resource)
    end

    it "should not skip device only resouce on remote device" do
      @resource.stubs(:appliable_to_host?).returns false
      @resource.stubs(:appliable_to_device?).returns true
      @transaction.for_network_device = true
      expect(@transaction).not_to be_skip(@resource)
    end

    it "should skip host resouce on device" do
      @resource.stubs(:appliable_to_host?).returns true
      @resource.stubs(:appliable_to_device?).returns false
      @transaction.for_network_device = true
      expect(@transaction).to be_skip(@resource)
    end

    it "should not skip resouce available on both device and host when on device" do
      @resource.stubs(:appliable_to_host?).returns true
      @resource.stubs(:appliable_to_device?).returns true
      @transaction.for_network_device = true
      expect(@transaction).not_to be_skip(@resource)
    end

    it "should not skip resouce available on both device and host when on host" do
      @resource.stubs(:appliable_to_host?).returns true
      @resource.stubs(:appliable_to_device?).returns true
      @transaction.for_network_device = false
      expect(@transaction).not_to be_skip(@resource)
    end
  end

  describe "when determining if tags are missing" do
    before :each do
      @resource = Puppet::Type.type(:notify).new :name => "foo"
      @catalog = Puppet::Resource::Catalog.new
      @resource.catalog = @catalog
      @transaction = Puppet::Transaction.new(@catalog, nil, nil)

      @transaction.stubs(:ignore_tags?).returns false
    end

    it "should not be missing tags if tags are being ignored" do
      @transaction.expects(:ignore_tags?).returns true

      @resource.expects(:tagged?).never

      expect(@transaction).not_to be_missing_tags(@resource)
    end

    it "should not be missing tags if the transaction tags are empty" do
      @transaction.tags = []
      @resource.expects(:tagged?).never
      expect(@transaction).not_to be_missing_tags(@resource)
    end

    it "should otherwise let the resource determine if it is missing tags" do
      tags = ['one', 'two']
      @transaction.tags = tags
      expect(@transaction).to be_missing_tags(@resource)
    end
  end

  describe "when determining if a resource should be scheduled" do
    before :each do
      @resource = Puppet::Type.type(:notify).new :name => "foo"
      @catalog = Puppet::Resource::Catalog.new
      @catalog.add_resource(@resource)
      @transaction = Puppet::Transaction.new(@catalog, nil, Puppet::Graph::RandomPrioritizer.new)
    end

    it "should always schedule resources if 'ignoreschedules' is set" do
      @transaction.ignoreschedules = true
      @transaction.resource_harness.expects(:scheduled?).never

      @transaction.evaluate
      expect(@transaction.resource_status(@resource)).to be_changed
    end

    it "should let the resource harness determine whether the resource should be scheduled" do
      @transaction.resource_harness.expects(:scheduled?).with(@resource).returns "feh"

      @transaction.evaluate
    end
  end

  describe "when prefetching" do
    let(:catalog) { Puppet::Resource::Catalog.new }
    let(:transaction) { Puppet::Transaction.new(catalog, nil, nil) }
    let(:resource) { Puppet::Type.type(:sshkey).new :title => "foo", :name => "bar", :type => :dsa, :key => "eh", :provider => :parsed }
    let(:resource2) { Puppet::Type.type(:package).new :title => "blah", :provider => "apt" }

    before :each do
      catalog.add_resource resource
      catalog.add_resource resource2
    end

    it "should match resources by name, not title" do
      resource.provider.class.expects(:prefetch).with("bar" => resource)

      transaction.prefetch_if_necessary(resource)
    end

    it "should not prefetch a provider which has already been prefetched" do
      transaction.prefetched_providers[:sshkey][:parsed] = true

      resource.provider.class.expects(:prefetch).never

      transaction.prefetch_if_necessary(resource)
    end

    it "should mark the provider prefetched" do
      resource.provider.class.stubs(:prefetch)

      transaction.prefetch_if_necessary(resource)

      expect(transaction.prefetched_providers[:sshkey][:parsed]).to be_truthy
    end

    it "should prefetch resources without a provider if prefetching the default provider" do
      other = Puppet::Type.type(:sshkey).new :name => "other"

      other.instance_variable_set(:@provider, nil)

      catalog.add_resource other

      resource.provider.class.expects(:prefetch).with('bar' => resource, 'other' => other)

      transaction.prefetch_if_necessary(resource)
    end
  end

  describe "during teardown" do
    let(:catalog) { Puppet::Resource::Catalog.new }
    let(:transaction) do
      Puppet::Transaction.new(catalog, nil, Puppet::Graph::RandomPrioritizer.new)
    end

    let(:teardown_type) do
      Puppet::Type.newtype(:teardown_test) do
        newparam(:name) {}
      end
    end

    before :each do
      teardown_type.provide(:teardown_provider) do
        class << self
          attr_reader :result

          def post_resource_eval
            @result = 'passed'
          end
        end
      end
    end

    it "should call ::post_resource_eval on provider classes that support it" do
      resource = teardown_type.new(:title => "foo", :provider => :teardown_provider)

      transaction = transaction_with_resource(resource)
      transaction.evaluate

      expect(resource.provider.class.result).to eq('passed')
    end

    it "should call ::post_resource_eval even if other providers' ::post_resource_eval fails" do
      teardown_type.provide(:always_fails) do
        class << self
          attr_reader :result

          def post_resource_eval
            @result = 'failed'
            raise Puppet::Error, "This provider always fails"
          end
        end
      end

      good_resource = teardown_type.new(:title => "bloo", :provider => :teardown_provider)
      bad_resource  = teardown_type.new(:title => "blob", :provider => :always_fails)

      catalog.add_resource(bad_resource)
      catalog.add_resource(good_resource)

      transaction.evaluate

      expect(good_resource.provider.class.result).to eq('passed')
      expect(bad_resource.provider.class.result).to eq('failed')
    end

    it "should call ::post_resource_eval even if one of the resources fails" do
      resource = teardown_type.new(:title => "foo", :provider => :teardown_provider)
      resource.stubs(:retrieve_resource).raises
      catalog.add_resource resource

      resource.provider.class.expects(:post_resource_eval)

      transaction.evaluate
    end
  end

  describe 'when checking application run state' do
    before do
      @catalog = Puppet::Resource::Catalog.new
      @transaction = Puppet::Transaction.new(@catalog, nil, Puppet::Graph::RandomPrioritizer.new)
    end

    context "when stop is requested" do
      before :each do
        Puppet::Application.stubs(:stop_requested?).returns(true)
      end

      it 'should return true for :stop_processing?' do
        expect(@transaction).to be_stop_processing
      end

      it 'always evaluates non-host_config catalogs' do
        @catalog.host_config = false
        expect(@transaction).not_to be_stop_processing
      end
    end

    it 'should return false for :stop_processing? if Puppet::Application.stop_requested? is false' do
      Puppet::Application.stubs(:stop_requested?).returns(false)
      expect(@transaction.stop_processing?).to be_falsey
    end

    describe 'within an evaluate call' do
      before do
        @resource = Puppet::Type.type(:notify).new :title => "foobar"
        @catalog.add_resource @resource
        @transaction.stubs(:add_dynamically_generated_resources)
      end

      it 'should stop processing if :stop_processing? is true' do
        @transaction.stubs(:stop_processing?).returns(true)
        @transaction.expects(:eval_resource).never
        @transaction.evaluate
      end

      it 'should continue processing if :stop_processing? is false' do
        @transaction.stubs(:stop_processing?).returns(false)
        @transaction.expects(:eval_resource).returns(nil)
        @transaction.evaluate
      end
    end
  end

  it "errors with a dependency cycle for a resource that requires itself" do
    expect do
      apply_compiled_manifest(<<-MANIFEST)
        notify { cycle: require => Notify[cycle] }
      MANIFEST
    end.to raise_error(Puppet::Error, /Found 1 dependency cycle:.*\(Notify\[cycle\] => Notify\[cycle\]\)/m)
  end

  it "errors with a dependency cycle for a self-requiring resource also required by another resource" do
    expect do
      apply_compiled_manifest(<<-MANIFEST)
        notify { cycle: require => Notify[cycle] }
        notify { other: require => Notify[cycle] }
      MANIFEST
    end.to raise_error(Puppet::Error, /Found 1 dependency cycle:.*\(Notify\[cycle\] => Notify\[cycle\]\)/m)
  end

  it "errors with a dependency cycle for a resource that requires itself and another resource" do
    expect do
      apply_compiled_manifest(<<-MANIFEST)
        notify { cycle:
          require => [Notify[other], Notify[cycle]]
        }
        notify { other: }
      MANIFEST
    end.to raise_error(Puppet::Error, /Found 1 dependency cycle:.*\(Notify\[cycle\] => Notify\[cycle\]\)/m)
  end

  it "errors with a dependency cycle for a resource that is later modified to require itself" do
    expect do
      apply_compiled_manifest(<<-MANIFEST)
        notify { cycle: }
        Notify <| title == 'cycle' |> {
          require => Notify[cycle]
        }
      MANIFEST
    end.to raise_error(Puppet::Error, /Found 1 dependency cycle:.*\(Notify\[cycle\] => Notify\[cycle\]\)/m)
  end

  it "reports a changed resource with a successful run" do
    transaction = apply_compiled_manifest("notify { one: }")

    expect(transaction.report.status).to eq('changed')
    expect(transaction.report.resource_statuses['Notify[one]']).to be_changed
  end

  describe "when interrupted" do
    it "marks unprocessed resources as skipped" do
      Puppet::Application.stop!

      transaction = apply_compiled_manifest(<<-MANIFEST)
        notify { a: } ->
        notify { b: }
      MANIFEST

      expect(transaction.report.resource_statuses['Notify[a]']).to be_skipped
      expect(transaction.report.resource_statuses['Notify[b]']).to be_skipped
    end
  end
end

describe Puppet::Transaction, " when determining tags" do
  before do
    @config = Puppet::Resource::Catalog.new
    @transaction = Puppet::Transaction.new(@config, nil, nil)
  end

  it "should default to the tags specified in the :tags setting" do
    Puppet[:tags] = "one"
    expect(@transaction).to be_tagged("one")
  end

  it "should split tags based on ','" do
    Puppet[:tags] = "one,two"
    expect(@transaction).to be_tagged("one")
    expect(@transaction).to be_tagged("two")
  end

  it "should use any tags set after creation" do
    Puppet[:tags] = ""
    @transaction.tags = %w{one two}
    expect(@transaction).to be_tagged("one")
    expect(@transaction).to be_tagged("two")
  end

  it "should always convert assigned tags to an array" do
    @transaction.tags = "one::two"
    expect(@transaction).to be_tagged("one::two")
  end

  it "should tag one::two only as 'one::two' and not 'one', 'two', and 'one::two'" do
    @transaction.tags = "one::two"
    expect(@transaction).to be_tagged("one::two")
    expect(@transaction).to_not be_tagged("one")
    expect(@transaction).to_not be_tagged("two")
  end

  it "should accept a comma-delimited string" do
    @transaction.tags = "one, two"
    expect(@transaction).to be_tagged("one")
    expect(@transaction).to be_tagged("two")
  end

  it "should accept an empty string" do
    @transaction.tags = "one, two"
    expect(@transaction).to be_tagged("one")
    @transaction.tags = ""
    expect(@transaction).not_to be_tagged("one")
  end
end
