#!/usr/bin/ruby

# Services activated by DBus service activation mechanism do not have
# all necessary components in PATH
ENV["PATH"] = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

require 'dbus'
require 'fileutils'
require 'json'
require 'open3'
require 'puavo/etc'
require 'puavo/rest-client'
require 'socket'
require 'syslog'

Encoding.default_external = 'utf-8'

def syslog(channel, priority, *args)
  Syslog.log(priority, *args)
  channel.printf(*args)
end

def log(*args)   ; syslog(STDOUT, *args); end
def logerr(*args); syslog(STDERR, *args); end


$configuration_update_thread  = nil
$delete_image_overlays_thread = nil
$image_update_thread          = nil

$puavoetc = PuavoEtc.new

class CancelledUpdate < RuntimeError; end

class OverlayHandler < DBus::Object
  dbus_interface "org.puavo.client.overlayhandler" do
    dbus_signal :DeleteImageOverlaysFailed, 'in errormsg:s'
    dbus_signal :DeleteImageOverlaysCompleted

    dbus_method :DeleteImageOverlays do
      return if $delete_image_overlays_thread

      $delete_image_overlays_thread = Thread.new do
        begin
          # Under "/imageoverlays" is image-specific directories, under
          # that is "default", remove directories under that but do not
          # remove "default" (it might be currently mounted as part of
          # overlay-mount).
          Dir.glob("/imageoverlays/*/*/*").each do |dir|
            FileUtils.remove_entry_secure(dir)
          end
          self.DeleteImageOverlaysCompleted

          # We will reboot after this operation, because we know that
          # deleting an overlay that is in use will likely result in a system
          # that does not work correctly (give some time show the "completed"
          # message on desktop, though).
          system('sync')
          sleep(8)
          system('sync')
          system('reboot', '-f')

        rescue StandardError => e
          logerr(Syslog::LOG_ERR,
                 "Error in deleting imageoverlays: %s\n",
                 e.message)
          self.DeleteImageOverlaysFailed(e.message)
        ensure
          $delete_image_overlays_thread = nil
        end
      end
    end

    dbus_method :ImageOverlaysState, 'out i' do
      mntpath = '/imageoverlays'

      begin
        # ruby-sys-filesystem module might help here instead of this

        if !system('mountpoint', '-q', mntpath)
          logerr(Syslog::LOG_ERR,
                 "Checking %s but it is not a mountpoint\n",
                 imageoverlays_path)
          return -1
        end

        free_blocks, fb_status \
          = Open3.capture2("stat", "--file-system", "--format", "%f", mntpath)
        if !fb_status.success?
          logerr(Syslog::LOG_ERR,
                 "Could not get free block count for %s\n",
                 mntpath)
          return -1
        end

        total_blocks, tb_status \
          = Open3.capture2("stat", "--file-system", "--format", "%b", mntpath)
        if !tb_status.success?
          logerr(Syslog::LOG_ERR,
                 "Could not get total block count for %s\n",
                 mntpath)
          return -1
        end

        free_percentage = 100.0 * Integer(free_blocks) / Integer(total_blocks)
        return [ (100-free_percentage).round, 0 ].max

      rescue StandardError => e
        logerr(Syslog::LOG_ERR,
               "Could not calculate %s utilization percentage: %s\n",
               mntpath,
               e.message)
        return -1
      end
    end
  end
end

class PkgUpdater < DBus::Object
  def initialize(subpath)
    super
    @puavopkg_update_thread = nil
  end

  dbus_interface 'org.puavo.client.pkgupdater' do
    dbus_method :StartUpdate, '' do
      return if @puavopkg_update_thread
      puavopkg_update()
    end
  end

  def puavopkg_update
    @puavopkg_update_thread = Thread.new do
      begin
        log(Syslog::LOG_INFO, "starting puavo-pkg update\n")
        pid = spawn('/usr/sbin/puavo-pkg-update', :in     => '/dev/null',
                                                  :out    => '/dev/null',
                                                  :err    => '/dev/null',
                                                  :pgroup => true)
        pid, status = Process.waitpid2(pid) rescue nil
        if status.success? then
          logerr(Syslog::LOG_INFO, "puavo-pkg-update returned with success\n")
        elsif status.exited?
          logerr(Syslog::LOG_WARNING,
                 "puavo-pkg-update returned error with code %d\n",
                 status.exitstatus)
        elsif status.signaled? then
          logerr(Syslog::LOG_WARNING,
                 "puavo-pkg-update was terminated by signal %d\n",
                 status.termsig)
        end
      rescue StandardError => e
        logerr(Syslog::LOG_WARNING,
               "Error in running puavo-pkg-update: %s\n",
               e.message)
      ensure
        @puavopkg_update_thread = nil
      end
    end
  end
end

class ContentUpdates < DBus::Object
  EXTRA_CONTENT_SCRIPTS_PATH = '/etc/puavo-extra-contents/scripts'

  def initialize(subpath)
    super
    @puavo_extra_content_updates = {}
  end

  dbus_interface "org.puavo.client.contentupdates" do
    dbus_signal :UpdateStarted,   'in contentname:s, in version:s'
    dbus_signal :UpdateProgress,
                'in contentname:s, in version:s, in progress:i'
    dbus_signal :UpdateCompleted, 'in contentname:s, in version:s'
    dbus_signal :UpdateFailed,    'in contentname:s, in version:s, in errmsg:s'

    dbus_method :GetContents, 'out as' do
      extra_contents = {}
      begin
        extra_contents_json_path \
          = '/var/lib/puavo-desktop/extra_system_contents.json'
        extra_contents = JSON.parse( IO.read(extra_contents_json_path) )
      rescue Errno::ENOENT => e
        # pass
      rescue StandardError => e
        logerr(Syslog::LOG_ERR,
               "Could not read/parse #{ extra_contents_json_path }\n")
        raise e
      end

      [ extra_contents.to_a.flatten.map { |s| s.to_s } ]
    end

    dbus_method :StartUpdate, 'in contentname:s, in version:s' do
      |contentname, version|

      return unless contentname.match(/\A[A-Za-z0-9]+\z/)

      if @puavo_extra_content_updates.has_key?(contentname) then
	# check if we are already updating
	return if version == @puavo_extra_content_updates[contentname]['version']

	# cancel previous if we are going to update to a different version
	thread = @puavo_extra_content_updates[contentname]['thread']
	thread.raise(CancelledUpdate, "cancel #{ contentname } update")
	thread.join rescue nil
	@puavo_extra_content_updates.delete(contentname)
      end

      script_path = File.join(EXTRA_CONTENT_SCRIPTS_PATH, contentname)
      return unless File.executable?(script_path)

      @puavo_extra_content_updates[contentname] = {}
      @puavo_extra_content_updates[contentname]['version'] = version
      @puavo_extra_content_updates[contentname]['thread'] = Thread.new do
	pid = spawn(script_path, version, :in  => '/dev/null',
					  :out => '/dev/null',
					  :err => '/dev/null',
					  :pgroup => true)
	begin
	  Process.waitpid(pid)
	rescue CancelledUpdate => e
	  begin
	    Process.kill('-TERM', pid)
	    sleep(2)
	    Process.kill('-KILL', pid)
	  rescue
	  end
	  Process.waitpid(pid) rescue nil
	ensure
	  @puavo_extra_content_updates.delete(contentname)
	end
      end

      return true
    end

    dbus_method :CancelUpdate, 'in contentname:s' do |contentname|
      return unless contentname.match(/\A[A-Za-z0-9]+\z/)
      return unless @puavo_extra_content_updates.has_key?(contentname)

      thread = @puavo_extra_content_updates[contentname]['thread']
      thread.raise(CancelledUpdate, "cancel #{ contentname } update")
      thread.join rescue nil

      @puavo_extra_content_updates.delete(contentname)
    end

    dbus_method :SetUpdateStarted, 'in contentname:s, in version:s' do
      |contentname, version|
      self.UpdateStarted(contentname, version)
    end

    dbus_method :SetUpdateProgress,
                'in contentname:s, in version:s, in progress:i' do
      |contentname, version, progress|
      self.UpdateProgress(contentname, version, progress)
    end

    dbus_method :SetUpdateCompleted, 'in contentname:s, in version:s' do
      |contentname, version|
      self.UpdateCompleted(contentname, version)
    end

    dbus_method :SetUpdateFailed,
                'in contentname:s, in version:s, in errmsg:s' do
      |contentname, version, errmsg|
      self.UpdateFailed(contentname, version, errmsg)
    end
  end
end

class Updater < DBus::Object
  def check_for_available_image_updates()
    system('/usr/lib/puavo-ltsp-install/is-update-available')
    code = $?.exitstatus
    status = {
               0 => :update_available,
               1 => :uptodate_no_reboot,
               2 => :uptodate_yes_reboot,
             }[code]

    return status if status

    logerr(Syslog::LOG_ERR,
           "Unknown exit code in is-update-available: %d\n",
           code)
    return :unknown
  end

  def configuration_update
    thread = $configuration_update_thread = Thread.new do
      log(Syslog::LOG_INFO, "Starting configuration update\n")

      begin
        # Notify Puavo on our current client state changes, mostly regarding
        # our image situation.  It is good to do this on every image update,
        # but also periodically, in case we have booted to a new image (there
        # might be a better place for this, but at least the configuration
        # update should be run periodically anyway).
        begin
          update_client_state_on_puavo()
        rescue StandardError => e
          logerr(Syslog::LOG_WARNING,
                 "Could not update client state information in Puavo: %s\n",
                 e.message)
        end

        # configuration update should be run before
        # check_for_available_image_updates() so that it gives a proper result
        command = '/usr/lib/puavo-ltsp-install/update-configuration'
        output, status = Open3.capture2e(command)
        if not status.success? then
          logerr(Syslog::LOG_WARNING,
                 "Failed to update device configuration: %s\n",
                 output)
          # (but carry on, because configuration update may fail due to
          # several reasons and maybe we have correct information
          # for check_for_available_image_updates() anyway)
        end

        version_state = :unknown

        if $image_update_thread then
          version_state = :updates_in_progress
        else
          available_image_updates = check_for_available_image_updates()
          case available_image_updates
            when :update_available
              log(Syslog::LOG_INFO, "There is an image update available\n")
              version_state = :update_available
            when :uptodate_no_reboot
              log(Syslog::LOG_INFO,
                  "Current image is up-to-date, no reboot required\n")
              version_state = :uptodate_no_reboot
            when :uptodate_yes_reboot
              log(Syslog::LOG_INFO,
                  "Current image is up-to-date, but reboot is required\n")
              version_state = :uptodate_yes_reboot
            else
              logerr(Syslog::LOG_WARNING,
                     "Could not determine image up-to-dateness\n")
          end
        end

        case version_state
          when :update_available
            log(Syslog::LOG_INFO, "Notifying user about available upgrades\n")
            self.UpdateAvailable
          when :updates_in_progress
            # no need to notify about anything
          when :uptodate_no_reboot
            log(Syslog::LOG_INFO, "Notifying user that we are up-to-date.\n")
            self.UpdateIsUpToDate(false) # false == no reboot required
          when :uptodate_yes_reboot
            log(Syslog::LOG_INFO,
                "Notifying user that we are up-to-date, but require reboot.\n")
            self.UpdateIsUpToDate(true)  # true == reboot *is* required
          else
            raise "Internal error, version_state is #{ version_state }"
        end

      rescue StandardError => e
        logerr(Syslog::LOG_ERR,
               "Could not do a configuration update: %s\n",
               e.message)
        # Internal error, but do something, anything, maybe updating helps?
        log(Syslog::LOG_WARNING,
            "Sending UpdateAvailable in confusion on what to do.\n")
        self.UpdateAvailable
        raise e
      ensure
        $configuration_update_thread = nil
      end
    end

    return thread
  end

  def image_update(use_rate_limit)
    thread = $image_update_thread = Thread.new do
      wait_thr, out_thr, err_thr = nil, nil, nil

      begin
        log(Syslog::LOG_INFO, "Starting system update\n")
        self.UpdateStarted

        stdin, stdout, stderr, wait_thr \
          = Open3.popen3('/usr/lib/puavo-ltsp-install/update-images',
                         use_rate_limit ? 'true' : 'false',
                         { :pgroup => true })

        stdin.close
        out_thr = Thread.new do
          loop { self.UpdateMessage('ok', stdout.readline) } \
            rescue EOFError
        end
        err_thr = Thread.new do
          loop { self.UpdateMessage('error', stderr.readline) } \
            rescue EOFError
        end

        status = wait_thr.value
        if not status.success? then
          raise 'Failed to update system, update-images returned ' \
                  + status.exitstatus.to_s
        end

        log(Syslog::LOG_INFO, "System update completed\n")

        # update device information in Puavo and in local disk as well
        confthread = $configuration_update_thread
        if confthread then
          log(Syslog::LOG_DEBUG,
              "Configuration update in progress, waiting for it to finish\n")
          confthread.join rescue nil
        end
        log(Syslog::LOG_INFO,
            "Starting post-system-update configuration update\n")
        configuration_update().join rescue nil

      rescue CancelledUpdate => e
        Process.kill('-TERM', wait_thr.pid) if wait_thr && wait_thr.alive?
        logerr(Syslog::LOG_WARNING, "System update was cancelled\n")
        raise e

      rescue StandardError => e
        logerr(Syslog::LOG_ERR,
               "Error occurred when doing system update: %s\n",
               e.message)
        raise e

      ensure
        [ wait_thr, out_thr, err_thr ].each { |t| t.join if t rescue nil }
        $image_update_thread = nil
      end
    end

    return thread
  end

  def update_client_state_on_puavo
    available_images \
      = (Dir.glob('/images/*.img').map { |p| File.basename(p, '.img') } \
           - %w(ltsp ltsp-backup)).sort
    current_image_with_img = IO.readlines('/etc/puavo-image/name') \
                               .first.chomp
    current_image = File.basename(current_image_with_img, '.img')

    deviceinfo = JSON.parse( File.read('/state/etc/puavo/device.json') )

    update_available_images \
      = ((deviceinfo['available_images'] || []).sort != available_images)
    update_current_image = (deviceinfo['current_image'] != current_image)

    reset_state = nil
    if $puavoetc.hosttype == 'laptop' then
      reset_state = JSON.parse( File.read('/state/etc/puavo/reset_override') ) \
                      rescue nil
    end

    if reset_state || update_available_images || update_current_image then
      if reset_state then
        log(Syslog::LOG_NOTICE, "Sending reset state\n")
      end
      if update_available_images then
        log(Syslog::LOG_NOTICE, "Updating available images in Puavo\n")
      end
      if update_current_image then
        log(Syslog::LOG_NOTICE, "Updating the current image in Puavo\n")
      end

      # Set :dns => :no because we write stuff to Puavo and we have much
      # better chances of finding the right server when not using DNS.
      client = PuavoRestClient.new(:auth => :etc, :dns => :no)

      restpath = ($puavoetc.hosttype == 'bootserver')            \
                    ? "/v3/boot_servers/#{ Socket.gethostname }" \
                    : "/v3/devices/#{ Socket.gethostname }/state_update"
      senddata = {
        'available_images' => available_images,
        'current_image'    => current_image,
      }
      senddata['reset'] = reset_state if reset_state
      client.post(restpath, :json => senddata)
    end
  end

  dbus_interface "org.puavo.client.update" do
    dbus_signal :UpdateAvailable
    dbus_signal :UpdateCancelled
    dbus_signal :UpdateCompleted, 'in image_update_done:b, reboot_required:b'
    dbus_signal :UpdateFailed
    dbus_signal :UpdateIsUpToDate,        'in reboot_required:b'
    dbus_signal :UpdateMessage,           'in msgtype:s, in content:s'
    dbus_signal :UpdateProgressIndicator, 'in phase:s, in progress:i'
    dbus_signal :UpdateStarted

    dbus_method :CancelImageUpdate, '' do
      image_update_thread = $image_update_thread

      if image_update_thread then
        image_update_thread.raise(CancelledUpdate, 'cancel image update')
        image_update_thread.join rescue nil
      end

      self.UpdateCancelled
    end

    dbus_method :Update, 'in use_rate_limit:b, out b' do |use_rate_limit|
      Thread.new do
        # Configuration update updates our information about what should be
        # our current image (may be the one we are running or another that
        # should be updated to).
        ($configuration_update_thread || self.configuration_update()) \
          .join rescue nil

        image_update_thread                                                 \
          = $image_update_thread                                            \
              ? $image_update_thread                                        \
              :                                                             \
            (self.check_for_available_image_updates() == :update_available) \
              ? self.image_update(use_rate_limit)                           \
              : nil

        begin
          if image_update_thread then
            image_update_thread.join
          end
        rescue CancelledUpdate => e
          # no problemos torremolinos!
          return
        rescue StandardError => e
          self.UpdateFailed
          raise e
        end

        # Update might result only in configuration update, but possibly
        # an image update was also done.   Send UpdateCompleted-dbus-signal
        # and tell also if an image update was done
        # and if a reboot is required.
        image_update_done = image_update_thread ? true : false
        reboot_required = (self.check_for_available_image_updates() \
                             == :uptodate_yes_reboot)
        self.UpdateCompleted(image_update_done, reboot_required)
      end
    end

    dbus_method :UpdateConfiguration, 'out b' do
      $configuration_update_thread          \
	? $configuration_update_thread.join \
	: self.configuration_update()
    end

    dbus_method :UpdateProgress,
                "in phase:s, in progress:i" do |phase, progress|
      self.UpdateProgressIndicator(phase, progress)
    end
  end

end

Syslog.open(File.basename($0), Syslog::LOG_CONS)

bus = DBus::SystemBus.instance

# Request a well-known name for the service. It can be denied if someone
# has reserved the name (very unlikely, only if someone is playing
# tricks with us) or we do not have permissions to own the name (missing
# conf file in /etc/dbus-1/system.d).
service = bus.request_service("org.puavo.client.Daemon")

# Export all dbus-accessible objects.
service.export(ContentUpdates.new('/contentupdates'))
service.export(OverlayHandler.new('/overlayhandler'))
service.export(PkgUpdater.new('/pkgupdater'))
service.export(Updater.new('/updater'))

# Run Forrest, run!
loop = DBus::Main.new
loop << bus
loop.run

Syslog.close()
