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
    # Zone state machine
    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)
        seq = if fi > si
                @sequence[si..fi].map { |i| @state[i] }.reverse
              else
                @sequence[fi..si].map { |i| @state[i] }
              end
        seq[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)
      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(retrieve, should).each do |state|
        method = state[direction]
        raise Puppet::DevError, _('Cannot move %{direction} from %{name}') % { direction: direction, name: st[:name] } unless method
        provider_sync_send(method)
      end

      "zone_#{should}".to_sym
    end

    # Are we moving up the property tree?
    def up?
      self.class.fsm.cmp?(retrieve, 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
    attr_reader :should

    # overridden so that we match with self.should
    def insync?(is)
      is = [] if !is || is == :absent
      is.sort == 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 =~ %r{^/}
    end

    munge do |value|
      if value =~ %r{%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."

    attr_reader :should

    # overridden so that we match with self.should
    def insync?(is)
      is = [] if !is || is == :absent
      is.sort == should.sort
    end

    validate do |value|
      unless value !~ %r{^/}
        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."

    attr_reader :should

    # overridden so that we match with self.should
    def insync?(is)
      is = [] if !is || is == :absent
      is.sort == should.sort
    end

    validate do |value|
      unless value =~ %r{^/}
        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
    raise Puppet::Error, _("'%{ip}' is an invalid %{name}") % { ip: ip, name: name }, $ERROR_INFO
  end

  def validate_exclusive(interface, address, _router)
    return if !interface.nil? && address.nil?
    fail _('only interface may be specified when using exclusive IP stack: %{interface}:%{address}') % { interface: interface, address: address }
  end

  def validate_shared(interface, address, router)
    fail _('ip must contain interface name and ip address separated by a ":"') if interface.nil? || 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 property: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? || hash[:ensure] == :absent
    # Return all properties as absent.
    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 = property(param)
        prophash[prop] = value if prop
      else
        self[param] = value
      end
    end
    prophash
  end
end
