diff --git a/lib/puppet/provider/zone/solaris.rb b/lib/puppet/provider/zone/solaris.rb index e681aca75..cbf07eec8 100644 --- a/lib/puppet/provider/zone/solaris.rb +++ b/lib/puppet/provider/zone/solaris.rb @@ -1,361 +1,361 @@ Puppet::Type.type(:zone).provide(:solaris) do desc "Provider for Solaris Zones." commands :adm => "/usr/sbin/zoneadm", :cfg => "/usr/sbin/zonecfg" defaultfor :osfamily => :solaris mk_resource_methods # Convert the output of a list into a hash def self.line2hash(line) fields = [:id, :name, :ensure, :path, :uuid, :brand, :iptype] properties = Hash[fields.zip(line.split(':'))] del_id = [:brand, :uuid] # Configured but not installed zones do not have IDs del_id << :id if properties[:id] == "-" del_id.each { |p| properties.delete(p) } properties[:ensure] = properties[:ensure].intern properties[:iptype] = 'exclusive' if properties[:iptype] == 'excl' properties end def self.instances adm(:list, "-cp").split("\n").collect do |line| new(line2hash(line)) end end def multi_conf(name, should, &action) has = properties[name] - has = [] if has == :absent + has = [] if !has || has == :absent rms = has - should adds = should - has (rms.map{|o| action.call(:rm,o)} + adds.map{|o| action.call(:add,o)}).join("\n") end def self.def_prop(var, str) define_method('%s_conf' % var.to_s) do |v| str % v end define_method('%s=' % var.to_s) do |v| setconfig self.send( ('%s_conf'% var).intern, v) end end def self.def_multiprop(var, &conf) define_method(var.to_s) do |v| o = properties[var] return '' if o.nil? or o == :absent o.join(' ') end define_method('%s=' % var.to_s) do |v| setconfig self.send( ('%s_conf'% var).intern, v) end define_method('%s_conf' % var.to_s) do |v| multi_conf(var, v, &conf) end end def_prop :iptype, "set ip-type=%s" def_prop :autoboot, "set autoboot=%s" def_prop :path, "set zonepath=%s" def_prop :pool, "set pool=%s" def_prop :shares, "add rctl\nset name=zone.cpu-shares\nadd value (priv=privileged,limit=%s,action=none)\nend" def_multiprop :ip do |action, str| interface, ip, defrouter = str.split(':') case action when :add cmd = ["add net"] cmd << "set physical=#{interface}" if interface cmd << "set address=#{ip}" if ip cmd << "set defrouter=#{defrouter}" if defrouter cmd << "end" cmd.join("\n") when :rm if ip "remove net address=#{ip}" elsif interface "remove net physical=#{interface}" else raise ArgumentError, "can not remove network based on default router" end else self.fail action end end def_multiprop :dataset do |action, str| case action when :add; ['add dataset',"set name=#{str}",'end'].join("\n") when :rm; "remove dataset name=#{str}" else self.fail action end end def_multiprop :inherit do |action, str| case action when :add; ['add inherit-pkg-dir', "set dir=#{str}",'end'].join("\n") when :rm; "remove inherit-pkg-dir dir=#{str}" else self.fail action end end def my_properties [:path, :iptype, :autoboot, :pool, :shares, :ip, :dataset, :inherit] end # Perform all of our configuration steps. def configure self.fail "Path is required" unless @resource[:path] arr = ["create -b #{@resource[:create_args]}"] # Then perform all of our configuration steps. It's annoying # that we need this much internal info on the resource. self.resource.properties.each do |property| next unless my_properties.include? property.name method = (property.name.to_s + '_conf').intern arr << self.send(method ,@resource[property.name]) unless property.safe_insync?(properties[property.name]) end setconfig(arr.join("\n")) end def destroy zonecfg :delete, "-F" end def add_cmd(cmd) @cmds = [] if @cmds.nil? @cmds << cmd end def exists? properties[:ensure] != :absent end # We cannot use the execpipe in util because the pipe is not opened in # read/write mode. def exec_cmd(var) # In bash, the exit value of the last command is the exit value of the # entire pipeline out = execute("echo \"#{var[:input]}\" | #{var[:cmd]}", :failonfail => false, :combine => true) st = $?.exitstatus {:out => out, :exit => st} end # Clear out the cached values. def flush return if @cmds.nil? || @cmds.empty? str = (@cmds << "commit" << "exit").join("\n") @cmds = [] @property_hash.clear command = "#{command(:cfg)} -z #{@resource[:name]} -f -" r = exec_cmd(:cmd => command, :input => str) if r[:exit] != 0 or r[:out] =~ /not allowed/ raise ArgumentError, "Failed to apply configuration" end end def install(dummy_argument=:work_arround_for_ruby_GC_bug) if @resource[:clone] # TODO: add support for "-s snapshot" zoneadm :clone, @resource[:clone] elsif @resource[:install_args] zoneadm :install, @resource[:install_args].split(" ") else zoneadm :install end end # Look up the current status. def properties if @property_hash.empty? @property_hash = status || {} if @property_hash.empty? @property_hash[:ensure] = :absent else @resource.class.validproperties.each do |name| @property_hash[name] ||= :absent end end end @property_hash.dup end # We need a way to test whether a zone is in process. Our 'ensure' # property models the static states, but we need to handle the temporary ones. def processing? hash = status return false unless hash ["incomplete", "ready", "shutting_down"].include? hash[:ensure] end # Collect the configuration of the zone. The output looks like: # zonename: z1 # zonepath: /export/z1 # brand: native # autoboot: true # bootargs: # pool: # limitpriv: # scheduling-class: # ip-type: shared # hostid: # net: # address: 192.168.1.1 # physical: eg0001 # defrouter not specified # net: # address: 192.168.1.3 # physical: eg0002 # defrouter not specified # def getconfig output = zonecfg :info name = nil current = nil hash = {} output.split("\n").each do |line| case line when /^(\S+):\s*$/ name = $1 current = nil # reset it when /^(\S+):\s*(\S+)$/ hash[$1.intern] = $2 when /^\s+(\S+):\s*(.+)$/ if name hash[name] ||= [] unless current current = {} hash[name] << current end current[$1.intern] = $2 else err "Ignoring '#{line}'" end else debug "Ignoring zone output '#{line}'" end end hash end # Execute a configuration string. Can't be private because it's called # by the properties. def setconfig(str) add_cmd str end def start # Check the sysidcfg stuff if cfg = @resource[:sysidcfg] self.fail "Path is required" unless @resource[:path] zoneetc = File.join(@resource[:path], "root", "etc") sysidcfg = File.join(zoneetc, "sysidcfg") # if the zone root isn't present "ready" the zone # which makes zoneadmd mount the zone root zoneadm :ready unless File.directory?(zoneetc) unless Puppet::FileSystem.exist?(sysidcfg) begin File.open(sysidcfg, "w", 0600) do |f| f.puts cfg end rescue => detail puts detail.stacktrace if Puppet[:debug] raise Puppet::Error, "Could not create sysidcfg: #{detail}", detail.backtrace end end end zoneadm :boot end # Return a hash of the current status of this zone. def status begin output = adm "-z", @resource[:name], :list, "-p" rescue Puppet::ExecutionFailure return nil end main = self.class.line2hash(output.chomp) # Now add in the configuration information config_status.each do |name, value| main[name] = value end main end def ready zoneadm :ready end def stop zoneadm :halt end def unconfigure zonecfg :delete, "-F" end def uninstall zoneadm :uninstall, "-F" end private # Turn the results of getconfig into status information. def config_status config = getconfig result = {} result[:autoboot] = config[:autoboot] ? config[:autoboot].intern : :true result[:pool] = config[:pool] result[:shares] = config[:shares] if dir = config["inherit-pkg-dir"] result[:inherit] = dir.collect { |dirs| dirs[:dir] } end if datasets = config["dataset"] result[:dataset] = datasets.collect { |dataset| dataset[:name] } end result[:iptype] = config[:'ip-type'] if config[:'ip-type'] if net = config["net"] result[:ip] = net.collect do |params| if params[:defrouter] "#{params[:physical]}:#{params[:address]}:#{params[:defrouter]}" elsif params[:address] "#{params[:physical]}:#{params[:address]}" else params[:physical] end end end result end def zoneadm(*cmd) adm("-z", @resource[:name], *cmd) rescue Puppet::ExecutionFailure => detail self.fail Puppet::Error, "Could not #{cmd[0]} zone: #{detail}", detail end def zonecfg(*cmd) # You apparently can't get the configuration of the global zone (strictly in solaris11) return "" if self.name == "global" begin cfg("-z", self.name, *cmd) rescue Puppet::ExecutionFailure => detail self.fail Puppet::Error, "Could not #{cmd[0]} zone: #{detail}", detail end end end diff --git a/lib/puppet/type/zone.rb b/lib/puppet/type/zone.rb index 09572bdb6..9094aaf6f 100644 --- a/lib/puppet/type/zone.rb +++ b/lib/puppet/type/zone.rb @@ -1,385 +1,382 @@ require 'puppet/property/list' Puppet::Type.newtype(:zone) do @doc = "Manages Solaris zones. **Autorequires:** If Puppet is managing the directory specified as the root of the zone's filesystem (with the `path` attribute), the zone resource will autorequire that directory." module Puppet::Zone class StateMachine # A silly little state machine. def initialize @state = {} @sequence = [] @state_aliases = {} @default = nil end # The order of calling insert_state is important def insert_state(name, transitions) @sequence << name @state[name] = transitions end def alias_state(state, salias) @state_aliases[state] = salias end def name(n) @state_aliases[n.to_sym] || n.to_sym end def index(state) @sequence.index(name(state)) end # return all states between fs and ss excluding fs def sequence(fs, ss) fi = index(fs) si= index(ss) (if fi > si then @sequence[si .. fi].map{|i| @state[i]}.reverse else @sequence[fi .. si].map{|i| @state[i]} end)[1..-1] end def cmp?(a,b) index(a) < index(b) end end end ensurable do desc "The running state of the zone. The valid states directly reflect the states that `zoneadm` provides. The states are linear, in that a zone must be `configured`, then `installed`, and only then can be `running`. Note also that `halt` is currently used to stop zones." def self.fsm return @fsm if @fsm @fsm = Puppet::Zone::StateMachine.new end def self.alias_state(values) values.each do |k,v| fsm.alias_state(k,v) end end def self.seqvalue(name, hash) fsm.insert_state(name, hash) self.newvalue name end # This is seq value because the order of declaration is important. # i.e we go linearly from :absent -> :configured -> :installed -> :running seqvalue :absent, :down => :destroy seqvalue :configured, :up => :configure, :down => :uninstall seqvalue :installed, :up => :install, :down => :stop seqvalue :running, :up => :start alias_state :incomplete => :installed, :ready => :installed, :shutting_down => :running defaultto :running def self.state_sequence(first, second) fsm.sequence(first, second) end # Why override it? because property/ensure.rb has a default retrieve method # that knows only about :present and :absent. That method just calls # provider.exists? and returns :present if a result was returned. def retrieve provider.properties[:ensure] end def provider_sync_send(method) warned = false while provider.processing? next if warned info "Waiting for zone to finish processing" warned = true sleep 1 end provider.send(method) provider.flush() end def sync method = nil direction = up? ? :up : :down # We need to get the state we're currently in and just call # everything between it and us. self.class.state_sequence(self.retrieve, self.should).each do |state| method = state[direction] raise Puppet::DevError, "Cannot move #{direction} from #{st[:name]}" unless method provider_sync_send(method) end ("zone_#{self.should}").intern end # Are we moving up the property tree? def up? self.class.fsm.cmp?(self.retrieve, self.should) end end newparam(:name) do desc "The name of the zone." isnamevar end newparam(:id) do desc "The numerical ID of the zone. This number is autogenerated and cannot be changed." end newparam(:clone) do desc "Instead of installing the zone, clone it from another zone. If the zone root resides on a zfs file system, a snapshot will be used to create the clone; if it resides on a ufs filesystem, a copy of the zone will be used. The zone from which you clone must not be running." end newproperty(:ip, :parent => Puppet::Property::List) do require 'ipaddr' desc "The IP address of the zone. IP addresses **must** be specified with an interface, and may optionally be specified with a default router (sometimes called a defrouter). The interface, IP address, and default router should be separated by colons to form a complete IP address string. For example: `bge0:192.168.178.200` would be a valid IP address string without a default router, and `bge0:192.168.178.200:192.168.178.1` adds a default router to it. For zones with multiple interfaces, the value of this attribute should be an array of IP address strings (each of which must include an interface and may include a default router)." # The default action of list should is to lst.join(' '). By specifying # @should, we ensure the should remains an array. If we override should, we # should also override insync?() -- property/list.rb def should @should end # overridden so that we match with self.should def insync?(is) - return true unless is - is = [] if is == :absent + is = [] if !is || is == :absent is.sort == self.should.sort end end newproperty(:iptype) do desc "The IP stack type of the zone." defaultto :shared newvalue :shared newvalue :exclusive end newproperty(:autoboot, :boolean => true) do desc "Whether the zone should automatically boot." defaultto true newvalues(:true, :false) end newproperty(:path) do desc "The root of the zone's filesystem. Must be a fully qualified file name. If you include `%s` in the path, then it will be replaced with the zone's name. Currently, you cannot use Puppet to move a zone. Consequently this is a readonly property." validate do |value| raise ArgumentError, "The zone base must be fully qualified" unless value =~ /^\// end munge do |value| if value =~ /%s/ value % @resource[:name] else value end end end newproperty(:pool) do desc "The resource pool for this zone." end newproperty(:shares) do desc "Number of FSS CPU shares allocated to the zone." end newproperty(:dataset, :parent => Puppet::Property::List ) do desc "The list of datasets delegated to the non-global zone from the global zone. All datasets must be zfs filesystem names which are different from the mountpoint." def should @should end # overridden so that we match with self.should def insync?(is) - return true unless is - is = [] if is == :absent + is = [] if !is || is == :absent is.sort == self.should.sort end validate do |value| unless value !~ /^\// raise ArgumentError, "Datasets must be the name of a zfs filesystem" end end end newproperty(:inherit, :parent => Puppet::Property::List) do desc "The list of directories that the zone inherits from the global zone. All directories must be fully qualified." def should @should end # overridden so that we match with self.should def insync?(is) - return true unless is - is = [] if is == :absent + is = [] if !is || is == :absent is.sort == self.should.sort end validate do |value| unless value =~ /^\// raise ArgumentError, "Inherited filesystems must be fully qualified" end end end # Specify the sysidcfg file. This is pretty hackish, because it's # only used to boot the zone the very first time. newparam(:sysidcfg) do desc %{The text to go into the `sysidcfg` file when the zone is first booted. The best way is to use a template: # $confdir/modules/site/templates/sysidcfg.erb system_locale=en_US timezone=GMT terminal=xterms security_policy=NONE root_password=<%= password %> timeserver=localhost name_service=DNS {domain_name=<%= domain %> name_server=<%= nameserver %>} network_interface=primary {hostname=<%= realhostname %> ip_address=<%= ip %> netmask=<%= netmask %> protocol_ipv6=no default_route=<%= defaultroute %>} nfs4_domain=dynamic And then call that: zone { myzone: ip => "bge0:192.168.0.23", sysidcfg => template("site/sysidcfg.erb"), path => "/opt/zones/myzone", realhostname => "fully.qualified.domain.name" } The `sysidcfg` only matters on the first booting of the zone, so Puppet only checks for it at that time.} end newparam(:create_args) do desc "Arguments to the `zonecfg` create command. This can be used to create branded zones." end newparam(:install_args) do desc "Arguments to the `zoneadm` install command. This can be used to create branded zones." end newparam(:realhostname) do desc "The actual hostname of the zone." end # If Puppet is also managing the base dir or its parent dir, list them # both as prerequisites. autorequire(:file) do if @parameters.include? :path [@parameters[:path].value, ::File.dirname(@parameters[:path].value)] else nil end end # If Puppet is also managing the zfs filesystem which is the zone dataset # then list it as a prerequisite. Zpool's get autorequired by the zfs # type. We just need to autorequire the dataset zfs itself as the zfs type # will autorequire all of the zfs parents and zpool. autorequire(:zfs) do # Check if we have datasets in our zone configuration and autorequire each dataset self[:dataset] if @parameters.include? :dataset end def validate_ip(ip, name) IPAddr.new(ip) if ip rescue ArgumentError self.fail Puppet::Error, "'#{ip}' is an invalid #{name}", $! end def validate_exclusive(interface, address, router) return if !interface.nil? and address.nil? self.fail "only interface may be specified when using exclusive IP stack: #{interface}:#{address}" end def validate_shared(interface, address, router) self.fail "ip must contain interface name and ip address separated by a \":\"" if interface.nil? or address.nil? [address, router].each do |ip| validate_ip(address, "IP address") unless ip.nil? end end validate do return unless self[:ip] # self[:ip] reflects the type passed from proeprty:ip.should. If we # override it and pass @should, then we get an array here back. self[:ip].each do |ip| interface, address, router = ip.split(':') if self[:iptype] == :shared validate_shared(interface, address, router) else validate_exclusive(interface, address, router) end end end def retrieve provider.flush hash = provider.properties return setstatus(hash) unless hash.nil? or hash[:ensure] == :absent # Return all properties as absent. return Hash[properties.map{|p| [p, :absent]} ] end # Take the results of a listing and set everything appropriately. def setstatus(hash) prophash = {} hash.each do |param, value| next if param == :name case self.class.attrtype(param) when :property # Only try to provide values for the properties we're managing prop = self.property(param) prophash[prop] = value if prop else self[param] = value end end prophash end end diff --git a/spec/unit/type/zone_spec.rb b/spec/unit/type/zone_spec.rb index e54902627..3497ae3a7 100755 --- a/spec/unit/type/zone_spec.rb +++ b/spec/unit/type/zone_spec.rb @@ -1,129 +1,172 @@ #! /usr/bin/env ruby require 'spec_helper' describe Puppet::Type.type(:zone) do - let(:zone) { described_class.new(:name => 'dummy', :path => '/dummy', :provider => :solaris) } + let(:zone) { described_class.new(:name => 'dummy', :path => '/dummy', :provider => :solaris, :ip=>'if:1.2.3.4:2.3.4.5', :inherit=>'/', :dataset=>'tank') } let(:provider) { zone.provider } + let(:ip) { zone.property(:ip) } + let(:inherit) { zone.property(:inherit) } + let(:dataset) { zone.property(:dataset) } parameters = [:create_args, :install_args, :sysidcfg, :realhostname] parameters.each do |parameter| it "should have a #{parameter} parameter" do described_class.attrclass(parameter).ancestors.should be_include(Puppet::Parameter) end end properties = [:ip, :iptype, :autoboot, :pool, :shares, :inherit, :path] properties.each do |property| it "should have a #{property} property" do described_class.attrclass(property).ancestors.should be_include(Puppet::Property) end end + describe "when trying to set a property that is empty" do + it "should verify that property.insync? of nil or :absent is true" do + [inherit, ip, dataset].each do |prop| + prop.stubs(:should).returns [] + end + [inherit, ip, dataset].each do |prop| + prop.insync?(nil).should be_true + end + [inherit, ip, dataset].each do |prop| + prop.insync?(:absent).should be_true + end + end + end + describe "when trying to set a property that is non empty" do + it "should verify that property.insync? of nil or :absent is false" do + [inherit, ip, dataset].each do |prop| + prop.stubs(:should).returns ['a','b'] + end + [inherit, ip, dataset].each do |prop| + prop.insync?(nil).should be_false + end + [inherit, ip, dataset].each do |prop| + prop.insync?(:absent).should be_false + end + end + end + describe "when trying to set a property that is non empty" do + it "insync? should return true or false depending on the current value, and new value" do + [inherit, ip, dataset].each do |prop| + prop.stubs(:should).returns ['a','b'] + end + [inherit, ip, dataset].each do |prop| + prop.insync?(['b', 'a']).should be_true + end + [inherit, ip, dataset].each do |prop| + prop.insync?(['a']).should be_false + end + end + end + it "should be valid when only :path is given" do described_class.new(:name => "dummy", :path => '/dummy', :provider => :solaris) end it "should be invalid when :ip is missing a \":\" and iptype is :shared" do expect { described_class.new(:name => "dummy", :ip => "if", :path => "/dummy", :provider => :solaris) }.to raise_error(Puppet::Error, /ip must contain interface name and ip address separated by a ":"/) end it "should be invalid when :ip has a \":\" and iptype is :exclusive" do expect { described_class.new(:name => "dummy", :ip => "if:1.2.3.4", :iptype => :exclusive, :provider => :solaris) }.to raise_error(Puppet::Error, /only interface may be specified when using exclusive IP stack/) end it "should be invalid when :ip has two \":\" and iptype is :exclusive" do expect { described_class.new(:name => "dummy", :ip => "if:1.2.3.4:2.3.4.5", :iptype => :exclusive, :provider => :solaris) }.to raise_error(Puppet::Error, /only interface may be specified when using exclusive IP stack/) end it "should be valid when :iptype is :shared and using interface and ip" do described_class.new(:name => "dummy", :path => "/dummy", :ip => "if:1.2.3.4", :provider => :solaris) end it "should be valid when :iptype is :shared and using interface, ip and default route" do described_class.new(:name => "dummy", :path => "/dummy", :ip => "if:1.2.3.4:2.3.4.5", :provider => :solaris) end it "should be valid when :iptype is :exclusive and using interface" do described_class.new(:name => "dummy", :path => "/dummy", :ip => "if", :iptype => :exclusive, :provider => :solaris) end it "should auto-require :dataset entries" do fs = 'random-pool/some-zfs' catalog = Puppet::Resource::Catalog.new relationship_graph = Puppet::Graph::RelationshipGraph.new(Puppet::Graph::RandomPrioritizer.new) zfs = Puppet::Type.type(:zfs).new(:name => fs) catalog.add_resource zfs zone = described_class.new(:name => "dummy", :path => "/foo", :ip => 'en1:1.0.0.0', :dataset => fs, :provider => :solaris) catalog.add_resource zone relationship_graph.populate_from(catalog) relationship_graph.dependencies(zone).should == [zfs] end describe Puppet::Zone::StateMachine do let (:sm) { Puppet::Zone::StateMachine.new } before :each do sm.insert_state :absent, :down => :destroy sm.insert_state :configured, :up => :configure, :down => :uninstall sm.insert_state :installed, :up => :install, :down => :stop sm.insert_state :running, :up => :start end context ":insert_state" do it "should insert state in correct order" do sm.insert_state :dummy, :left => :right sm.index(:dummy).should == 4 end end context ":alias_state" do it "should alias state" do sm.alias_state :dummy, :running sm.name(:dummy).should == :running end end context ":name" do it "should get an aliased state correctly" do sm.alias_state :dummy, :running sm.name(:dummy).should == :running end it "should get an un aliased state correctly" do sm.name(:dummy).should == :dummy end end context ":index" do it "should return the state index correctly" do sm.insert_state :dummy, :left => :right sm.index(:dummy).should == 4 end end context ":sequence" do it "should correctly return the actions to reach state specified" do sm.sequence(:absent, :running).map{|p|p[:up]}.should == [:configure,:install,:start] end it "should correctly return the actions to reach state specified(2)" do sm.sequence(:running, :absent).map{|p|p[:down]}.should == [:stop, :uninstall, :destroy] end end context ":cmp" do it "should correctly compare state sequence values" do sm.cmp?(:absent, :running).should == true sm.cmp?(:running, :running).should == false sm.cmp?(:running, :absent).should == false end end end end