diff --git a/lib/puppet/provider/service/launchd.rb b/lib/puppet/provider/service/launchd.rb index bc4505d27..9c3768489 100644 --- a/lib/puppet/provider/service/launchd.rb +++ b/lib/puppet/provider/service/launchd.rb @@ -1,334 +1,340 @@ require 'facter/util/plist' Puppet::Type.type(:service).provide :launchd, :parent => :base do desc <<-'EOT' This provider manages jobs with `launchd`, which is the default service framework for Mac OS X (and may be available for use on other platforms). For `launchd` documentation, see: * * This provider reads plists out of the following directories: * `/System/Library/LaunchDaemons` * `/System/Library/LaunchAgents` * `/Library/LaunchDaemons` * `/Library/LaunchAgents` ...and builds up a list of services based upon each plist's "Label" entry. This provider supports: * ensure => running/stopped, * enable => true/false * status * restart Here is how the Puppet states correspond to `launchd` states: * stopped --- job unloaded * started --- job loaded * enabled --- 'Disable' removed from job plist file * disabled --- 'Disable' added to job plist file Note that this allows you to do something `launchctl` can't do, which is to be in a state of "stopped/enabled" or "running/disabled". Note that this provider does not support overriding 'restart' or 'status'. EOT include Puppet::Util::Warnings commands :launchctl => "/bin/launchctl" commands :sw_vers => "/usr/bin/sw_vers" commands :plutil => "/usr/bin/plutil" defaultfor :operatingsystem => :darwin confine :operatingsystem => :darwin has_feature :enableable mk_resource_methods # These are the paths in OS X where a launchd service plist could # exist. This is a helper method, versus a constant, for easy testing # and mocking def self.launchd_paths [ "/Library/LaunchAgents", "/Library/LaunchDaemons", "/System/Library/LaunchAgents", "/System/Library/LaunchDaemons" ] end private_class_method :launchd_paths # This is the path to the overrides plist file where service enabling # behavior is defined in 10.6 and greater def self.launchd_overrides "/var/db/launchd.db/com.apple.launchd/overrides.plist" end private_class_method :launchd_overrides # Caching is enabled through the following three methods. Self.prefetch will # call self.instances to create an instance for each service. Self.flush will # clear out our cache when we're done. def self.prefetch(resources) instances.each do |prov| if resource = resources[prov.name] resource.provider = prov end end end # Self.instances will return an array with each element being a hash # containing the name, provider, path, and status of each service on the # system. def self.instances jobs = self.jobsearch @job_list ||= self.job_list jobs.keys.collect do |job| job_status = @job_list.has_key?(job) ? :running : :stopped new(:name => job, :provider => :launchd, :path => jobs[job], :status => job_status) end end # This method will return a list of files in the passed directory. This method # does not go recursively down the tree and does not return directories + # + # @param path [String] The directory to glob + # + # @api private + # + # @return [Array] of String instances modeling file paths def self.return_globbed_list_of_file_paths(path) array_of_files = Dir.glob(File.join(path, '*')).collect do |filepath| File.file?(filepath) ? filepath : nil end array_of_files.compact end private_class_method :return_globbed_list_of_file_paths # Sets a class instance variable with a hash of all launchd plist files that # are found on the system. The key of the hash is the job id and the value # is the path to the file. If a label is passed, we return the job id and # path for that specific job. def self.jobsearch(label=nil) @label_to_path_map ||= {} if @label_to_path_map.empty? launchd_paths.each do |path| return_globbed_list_of_file_paths(path).each do |filepath| job = read_plist(filepath) next if job.nil? if job.has_key?("Label") if job["Label"] == label return { label => filepath } else @label_to_path_map[job["Label"]] = filepath end else Puppet.warning("The #{filepath} plist does not contain a 'label' key; " + "Puppet is skipping it") next end end end end if label if @label_to_path_map.has_key? label return { label => @label_to_path_map[label] } else raise Puppet::Error.new("Unable to find launchd plist for job: #{label}") end else @label_to_path_map end end # This status method lists out all currently running services. # This hash is returned at the end of the method. def self.job_list @job_list = Hash.new begin output = launchctl :list raise Puppet::Error.new("launchctl list failed to return any data.") if output.nil? output.split("\n").each do |line| @job_list[line.split(/\s/).last] = :running end rescue Puppet::ExecutionFailure raise Puppet::Error.new("Unable to determine status of #{resource[:name]}") end @job_list end # Launchd implemented plist overrides in version 10.6. # This method checks the major_version of OS X and returns true if # it is 10.6 or greater. This allows us to implement different plist # behavior for versions >= 10.6 def has_macosx_plist_overrides? @product_version ||= self.class.get_macosx_version_major # (#11593) Remove support for OS X 10.4 & earlier # leaving this as is because 10.5 still didn't have plist support return true unless /^10\.[0-5]/.match(@product_version) return false end # Read a plist, whether its format is XML or in Apple's "binary1" # format. def self.read_plist(path) begin Plist::parse_xml(plutil('-convert', 'xml1', '-o', '/dev/stdout', path)) rescue Puppet::ExecutionFailure => detail Puppet.warning("Cannot read file #{path}; Puppet is skipping it. \n" + "Details: #{detail}") return nil end end # Clean out the @property_hash variable containing the cached list of services def flush @property_hash.clear end def exists? Puppet.debug("Puppet::Provider::Launchd:Ensure for #{@property_hash[:name]}: #{@property_hash[:ensure]}") @property_hash[:ensure] != :absent end def self.get_macosx_version_major return @macosx_version_major if @macosx_version_major begin # Make sure we've loaded all of the facts Facter.loadfacts product_version_major = Facter.value(:macosx_productversion_major) fail("#{product_version_major} is not supported by the launchd provider") if %w{10.0 10.1 10.2 10.3 10.4}.include?(product_version_major) @macosx_version_major = product_version_major return @macosx_version_major rescue Puppet::ExecutionFailure => detail fail("Could not determine OS X version: #{detail}") end end # finds the path for a given label and returns the path and parsed plist # as an array of [path, plist]. Note plist is really a Hash here. def plist_from_label(label) job = self.class.jobsearch(label) job_path = job[label] if FileTest.file?(job_path) job_plist = self.class.read_plist(job_path) else raise Puppet::Error.new("Unable to parse launchd plist at path: #{job_path}") end [job_path, job_plist] end # start the service. To get to a state of running/enabled, we need to # conditionally enable at load, then disable by modifying the plist file # directly. def start return ucommand(:start) if resource[:start] job_path, job_plist = plist_from_label(resource[:name]) did_enable_job = false cmds = [] cmds << :launchctl << :load if self.enabled? == :false || self.status == :stopped # launchctl won't load disabled jobs cmds << "-w" did_enable_job = true end cmds << job_path begin execute(cmds) rescue Puppet::ExecutionFailure raise Puppet::Error.new("Unable to start service: #{resource[:name]} at path: #{job_path}") end # As load -w clears the Disabled flag, we need to add it in after self.disable if did_enable_job and resource[:enable] == :false end def stop return ucommand(:stop) if resource[:stop] job_path, job_plist = plist_from_label(resource[:name]) did_disable_job = false cmds = [] cmds << :launchctl << :unload if self.enabled? == :true # keepalive jobs can't be stopped without disabling cmds << "-w" did_disable_job = true end cmds << job_path begin execute(cmds) rescue Puppet::ExecutionFailure raise Puppet::Error.new("Unable to stop service: #{resource[:name]} at path: #{job_path}") end # As unload -w sets the Disabled flag, we need to add it in after self.enable if did_disable_job and resource[:enable] == :true end # launchd jobs are enabled by default. They are only disabled if the key # "Disabled" is set to true, but it can also be set to false to enable it. # Starting in 10.6, the Disabled key in the job plist is consulted, but only # if there is no entry in the global overrides plist. # We need to draw a distinction between undefined, true and false for both # locations where the Disabled flag can be defined. def enabled? job_plist_disabled = nil overrides_disabled = nil job_path, job_plist = plist_from_label(resource[:name]) job_plist_disabled = job_plist["Disabled"] if job_plist.has_key?("Disabled") if has_macosx_plist_overrides? if FileTest.file?(self.class.launchd_overrides) and overrides = self.class.read_plist(self.class.launchd_overrides) if overrides.has_key?(resource[:name]) overrides_disabled = overrides[resource[:name]]["Disabled"] if overrides[resource[:name]].has_key?("Disabled") end end end if overrides_disabled.nil? if job_plist_disabled.nil? or job_plist_disabled == false return :true end elsif overrides_disabled == false return :true end :false end # enable and disable are a bit hacky. We write out the plist with the appropriate value # rather than dealing with launchctl as it is unable to change the Disabled flag # without actually loading/unloading the job. # Starting in 10.6 we need to write out a disabled key to the global # overrides plist, in earlier versions this is stored in the job plist itself. def enable if has_macosx_plist_overrides? overrides = self.class.read_plist(self.class.launchd_overrides) overrides[resource[:name]] = { "Disabled" => false } Plist::Emit.save_plist(overrides, self.class.launchd_overrides) else job_path, job_plist = plist_from_label(resource[:name]) if self.enabled? == :false job_plist.delete("Disabled") Plist::Emit.save_plist(job_plist, job_path) end end end def disable if has_macosx_plist_overrides? overrides = self.class.read_plist(self.class.launchd_overrides) overrides[resource[:name]] = { "Disabled" => true } Plist::Emit.save_plist(overrides, self.class.launchd_overrides) else job_path, job_plist = plist_from_label(resource[:name]) job_plist["Disabled"] = true Plist::Emit.save_plist(job_plist, job_path) end end end diff --git a/spec/unit/provider/service/launchd_spec.rb b/spec/unit/provider/service/launchd_spec.rb index 2c54f1218..8b6fa9d5f 100755 --- a/spec/unit/provider/service/launchd_spec.rb +++ b/spec/unit/provider/service/launchd_spec.rb @@ -1,236 +1,236 @@ # Spec Tests for the Launchd provider # require 'spec_helper' describe Puppet::Type.type(:service).provider(:launchd) do let (:joblabel) { "com.foo.food" } let (:provider) { subject.class } let (:launchd_overrides) { '/var/db/launchd.db/com.apple.launchd/overrides.plist' } let(:resource) { Puppet::Type.type(:service).new(:name => joblabel) } subject { Puppet::Type.type(:service).provider(:launchd).new(resource) } describe "the type interface" do %w{ start stop enabled? enable disable status}.each do |method| it { should respond_to method.to_sym } end end describe 'the status of the services' do it "should call the external command 'launchctl list' once" do provider.expects(:launchctl).with(:list).returns(joblabel) provider.expects(:jobsearch).with(nil).returns({joblabel => "/Library/LaunchDaemons/#{joblabel}"}) provider.prefetch({}) end it "should return stopped if not listed in launchctl list output" do provider.expects(:launchctl).with(:list).returns('com.bar.is_running') provider.expects(:jobsearch).with(nil).returns({'com.bar.is_not_running' => "/Library/LaunchDaemons/com.bar.is_not_running"}) provider.prefetch({}).last.status.should eq :stopped end it "should return running if listed in launchctl list output" do provider.expects(:launchctl).with(:list).returns('com.bar.is_running') provider.expects(:jobsearch).with(nil).returns({'com.bar.is_running' => "/Library/LaunchDaemons/com.bar.is_running"}) provider.prefetch({}).last.status.should eq :running end after :each do provider.instance_variable_set(:@job_list, nil) end end describe "when checking whether the service is enabled on OS X 10.5" do it "should return true in if the job plist says disabled is false" do subject.expects(:has_macosx_plist_overrides?).returns(false) subject.expects(:plist_from_label).with(joblabel).returns(["foo", {"Disabled" => false}]) subject.enabled?.should == :true end it "should return true in if the job plist has no disabled key" do subject.expects(:has_macosx_plist_overrides?).returns(false) subject.expects(:plist_from_label).returns(["foo", {}]) subject.enabled?.should == :true end it "should return false in if the job plist says disabled is true" do subject.expects(:has_macosx_plist_overrides?).returns(false) subject.expects(:plist_from_label).returns(["foo", {"Disabled" => true}]) subject.enabled?.should == :false end end describe "when checking whether the service is enabled on OS X 10.6" do it "should return true if the job plist says disabled is true and the global overrides says disabled is false" do provider.expects(:get_macosx_version_major).returns("10.6") subject.expects(:plist_from_label).returns([joblabel, {"Disabled" => true}]) provider.expects(:read_plist).returns({joblabel => {"Disabled" => false}}) provider.stubs(:launchd_overrides).returns(launchd_overrides) FileTest.expects(:file?).with(launchd_overrides).returns(true) subject.enabled?.should == :true end it "should return false if the job plist says disabled is false and the global overrides says disabled is true" do provider.expects(:get_macosx_version_major).returns("10.6") subject.expects(:plist_from_label).returns([joblabel, {"Disabled" => false}]) provider.expects(:read_plist).returns({joblabel => {"Disabled" => true}}) provider.stubs(:launchd_overrides).returns(launchd_overrides) FileTest.expects(:file?).with(launchd_overrides).returns(true) subject.enabled?.should == :false end it "should return true if the job plist and the global overrides have no disabled keys" do provider.expects(:get_macosx_version_major).returns("10.6") subject.expects(:plist_from_label).returns([joblabel, {}]) provider.expects(:read_plist).returns({}) provider.stubs(:launchd_overrides).returns(launchd_overrides) FileTest.expects(:file?).with(launchd_overrides).returns(true) subject.enabled?.should == :true end end describe "when starting the service" do it "should call any explicit 'start' command" do resource[:start] = "/bin/false" subject.expects(:texecute).with(:start, ["/bin/false"], true) subject.start end it "should look for the relevant plist once" do subject.expects(:plist_from_label).returns([joblabel, {}]).once subject.expects(:enabled?).returns :true subject.expects(:execute).with([:launchctl, :load, joblabel]) subject.start end it "should execute 'launchctl load' once without writing to the plist if the job is enabled" do subject.expects(:plist_from_label).returns([joblabel, {}]) subject.expects(:enabled?).returns :true subject.expects(:execute).with([:launchctl, :load, joblabel]).once subject.start end it "should execute 'launchctl load' with writing to the plist once if the job is disabled" do subject.expects(:plist_from_label).returns([joblabel, {}]) subject.expects(:enabled?).returns(:false) subject.expects(:execute).with([:launchctl, :load, "-w", joblabel]).once subject.start end it "should disable the job once if the job is disabled and should be disabled at boot" do resource[:enable] = false subject.expects(:plist_from_label).returns([joblabel, {"Disabled" => true}]) subject.expects(:enabled?).returns :false subject.expects(:execute).with([:launchctl, :load, "-w", joblabel]) subject.expects(:disable).once subject.start end it "(#2773) should execute 'launchctl load -w' if the job is enabled but stopped" do subject.expects(:plist_from_label).returns([joblabel, {}]) subject.expects(:enabled?).returns(:true) subject.expects(:status).returns(:stopped) subject.expects(:execute).with([:launchctl, :load, '-w', joblabel]) subject.start end end describe "when stopping the service" do it "should call any explicit 'stop' command" do resource[:stop] = "/bin/false" subject.expects(:texecute).with(:stop, ["/bin/false"], true) subject.stop end it "should look for the relevant plist once" do subject.expects(:plist_from_label).returns([joblabel, {}]).once subject.expects(:enabled?).returns :true subject.expects(:execute).with([:launchctl, :unload, '-w', joblabel]) subject.stop end it "should execute 'launchctl unload' once without writing to the plist if the job is disabled" do subject.expects(:plist_from_label).returns([joblabel, {}]) subject.expects(:enabled?).returns :false subject.expects(:execute).with([:launchctl, :unload, joblabel]).once subject.stop end it "should execute 'launchctl unload' with writing to the plist once if the job is enabled" do subject.expects(:plist_from_label).returns([joblabel, {}]) subject.expects(:enabled?).returns :true subject.expects(:execute).with([:launchctl, :unload, '-w', joblabel]).once subject.stop end it "should enable the job once if the job is enabled and should be enabled at boot" do resource[:enable] = true subject.expects(:plist_from_label).returns([joblabel, {"Disabled" => false}]) subject.expects(:enabled?).returns :true subject.expects(:execute).with([:launchctl, :unload, "-w", joblabel]) subject.expects(:enable).once subject.stop end end describe "when enabling the service" do it "should look for the relevant plist once" do ### Do we need this test? Differentiating it? resource[:enable] = true subject.expects(:plist_from_label).returns([joblabel, {}]).once subject.expects(:enabled?).returns :false subject.expects(:execute).with([:launchctl, :unload, joblabel]) subject.stop end it "should check if the job is enabled once" do resource[:enable] = true subject.expects(:plist_from_label).returns([joblabel, {}]).once subject.expects(:enabled?).once subject.expects(:execute).with([:launchctl, :unload, joblabel]) subject.stop end end describe "when disabling the service" do it "should look for the relevant plist once" do resource[:enable] = false subject.expects(:plist_from_label).returns([joblabel, {}]).once subject.expects(:enabled?).returns :true subject.expects(:execute).with([:launchctl, :unload, '-w', joblabel]) subject.stop end end describe "when enabling the service on OS X 10.6" do it "should write to the global launchd overrides file once" do resource[:enable] = true provider.expects(:get_macosx_version_major).returns("10.6") provider.expects(:read_plist).returns({}) provider.stubs(:launchd_overrides).returns(launchd_overrides) Plist::Emit.expects(:save_plist).once subject.enable end end describe "when disabling the service on OS X 10.6" do it "should write to the global launchd overrides file once" do resource[:enable] = false provider.stubs(:get_macosx_version_major).returns("10.6") provider.stubs(:read_plist).returns({}) provider.stubs(:launchd_overrides).returns(launchd_overrides) Plist::Emit.expects(:save_plist).once subject.enable end end describe "when encountering malformed plists" do let(:plist_without_label) do { 'LimitLoadToSessionType' => 'Aqua' } end let(:busted_plist_path) { '/Library/LaunchAgents/org.busted.plist' } it "[17624] should warn that the plist in question is being skipped" do - provider.expects(:launchd_paths).returns('/Library/LaunchAgents') - provider.expects(:return_globbed_list_of_file_paths).with('/Library/LaunchAgents').returns(busted_plist_path) + provider.expects(:launchd_paths).returns(['/Library/LaunchAgents']) + provider.expects(:return_globbed_list_of_file_paths).with('/Library/LaunchAgents').returns([busted_plist_path]) provider.expects(:read_plist).with(busted_plist_path).returns(plist_without_label) Puppet.expects(:warning).with("The #{busted_plist_path} plist does not contain a 'label' key; Puppet is skipping it") provider.jobsearch end it "[15929] should skip plists that plutil cannot read" do provider.expects(:plutil).with('-convert', 'xml1', '-o', '/dev/stdout', busted_plist_path).raises(Puppet::ExecutionFailure, 'boom') Puppet.expects(:warning).with("Cannot read file #{busted_plist_path}; " + "Puppet is skipping it. \n" + "Details: boom") provider.read_plist(busted_plist_path) end end end