#! /usr/bin/env python2
# -*- coding: utf-8 -*-

# opsi-makepackage is part of the desktop management solution opsi
# (open pc server integration) http://www.opsi.org
# Copyright (C) 2010-2020 uib GmbH <info@uib.de>

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License version 3
# as published by the Free Software Foundation.

# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.

# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
"""
opsi-makepackage - create opsi-packages for deployment.

:copyright:	uib GmbH <info@uib.de>
:author: Niko Wenselowski <n.wenselowski@uib.de>
:author: Jan Schneider <j.schneider@uib.de>
:author: Erol Ueluekmen <e.ueluekmen@uib.de>
:license: GNU Affero General Public License version 3
"""

import argparse
import fcntl
import gettext
import os
import struct
import sys
import termios
import tty

import OPSI.Util.File.Archive
from OPSI.Logger import LOG_DEBUG, LOG_ERROR, LOG_NONE, LOG_WARNING, Logger
from OPSI.System import execute
from OPSI.Types import forceFilename, forceUnicode
from OPSI.Util.Message import ProgressObserver, ProgressSubject
from OPSI.Util.Product import ProductPackageSource
from OPSI.Util.File.Opsi import PackageControlFile
from OPSI.Util.File import ZsyncFile
from OPSI.Util.Task.Rights import setRights
from OPSI.Util import md5sum

__version__ = '4.1.1.26'

logger = Logger()

try:
	t = gettext.translation('opsi-utils', '/usr/share/locale')
	_ = t.ugettext
except Exception as e:
	logger.error(u"Locale not found: %s" % e)

	def _(string):
		return string


class CancelledByUserError(Exception):
	pass


class ProgressNotifier(ProgressObserver):
	def __init__(self):
		self.usedWidth = 60
		try:
			tty = os.popen('tty').readline().strip()
			with open(tty) as fd:
				terminalWidth = struct.unpack('hh', fcntl.ioctl(fd, termios.TIOCGWINSZ, '1234'))[1]

			if self.usedWidth > terminalWidth:
				self.usedWidth = terminalWidth
		except Exception:
			pass

	def progressChanged(self, subject, state, percent, timeSpend, timeLeft, speed):
		if subject.getEnd() <= 0:
			return

		barlen = self.usedWidth - 10
		filledlen = int("%0.0f" % (barlen * percent / 100))
		bar = u'='*filledlen + u' ' * (barlen - filledlen)
		percent = '%0.2f%%' % percent
		sys.stderr.write('\r %8s [%s]\r' % (percent, bar))
		sys.stderr.flush()

	def messageChanged(self, subject, message):
		sys.stderr.write('\n%s\n' % message)
		sys.stderr.flush()


def main(argv):
	os.umask(0o022)

	logger.setConsoleLevel(LOG_WARNING)
	logger.setConsoleColor(True)
	logger.setConsoleFormat(u'%L: %M')

	parser = argparse.ArgumentParser(add_help=False,
		description=("Provides an opsi package from a package source directory.\n"
				"If no source directory is supplied, the current directory will be used.")
	)
	parser.add_argument('--help', action='store_true', default=False,
						help="Show help.")  # Manual implementation because of -h
	parser.add_argument('--version', '-V', action='version', version=__version__)
	parser.add_argument('--quiet', '-q', action='store_true', default=False,
						help="do not show progress")
	parser.add_argument('--verbose', '-v', default=False, action="store_true",
						help="verbose")
	parser.add_argument('--log-level', '-l', dest="logLevel",
						default=LOG_WARNING,
						type=int,
						choices=[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
						help="Set log-level (0..9)")
	parser.add_argument('--no-compression', '-n', dest="compression",
						default='gzip', action='store_const', const=None,
						help="Do not compress")
	parser.add_argument('--archive-format', '-F', dest="format", default='cpio', choices=['cpio', 'tar'],
						help="Archive format to use. Default: cpio")
	parser.add_argument('--no-pigz', dest="disablePigz",
					default=False, action='store_true',
					help="Disable the usage of pigz")
	parser.add_argument('--follow-symlinks', '-h',
						dest="dereference", help="follow symlinks",
						default=False, action='store_true')
	customGroup = parser.add_mutually_exclusive_group()
	customGroup.add_argument('--custom-name', '-i', metavar='custom name',
							dest="customName", default=u'',
							help="Add custom files and add custom name to the base package.")
	customGroup.add_argument('--custom-only', '-c', metavar='custom name',
							dest="customOnly", default=False,
							help="Only package custom files and add custom name to base package.")
	parser.add_argument('--temp-directory', '-t',
						dest="tempDir", help="temp dir", default=u'/tmp',
						metavar='directory')
	hashSumGroup = parser.add_mutually_exclusive_group()
	hashSumGroup.add_argument(
		'--md5', '-m',
		dest="createMd5SumFile", default=True, action='store_true',
		help="Create file with md5 checksum.")
	hashSumGroup.add_argument(
		'--no-md5', dest="createMd5SumFile", action='store_false',
		help="Do not create file with md5 checksum.")
	zsyncGroup = parser.add_mutually_exclusive_group()
	zsyncGroup.add_argument(
		'--zsync', '-z', dest="createZsyncFile",
		default=True, action='store_true',
		help="Create zsync file.")
	zsyncGroup.add_argument(
		'--no-zsync', dest="createZsyncFile", action='store_false',
		help="Do not create zsync file.")
	parser.add_argument('packageSourceDir', metavar="source directory",
						nargs='?', default=os.getcwd())

	vgroup = parser.add_argument_group('Versions',
		'Set versions for package. Combinations are possible.')
	vgroup.add_argument('--keep-versions', '-k', action='store_true',
				help="Keep versions and overwrite package", dest="keepVersions")
	vgroup.add_argument('--package-version', help="Set new package version ",
				default=u'', metavar='packageversion', dest="newPackageVersion")
	vgroup.add_argument('--product-version', default=u'',
				dest="newProductVersion", metavar='productversion',
				help="Set new product version for package")

	args = parser.parse_args()
	if args.help:
		parser.print_help()
		sys.exit(1)

	reload(sys)
	sys.setdefaultencoding("utf-8")

	keepVersions = args.keepVersions
	needOneVersion = False
	newProductVersion = args.newProductVersion
	newPackageVersion = args.newPackageVersion
	if newProductVersion or newPackageVersion:
		needOneVersion = True
	doNotUseTerminal = False
	if keepVersions:
		doNotUseTerminal = True
	if newPackageVersion and newProductVersion:
		doNotUseTerminal = True

	customName = args.customName
	customOnly = bool(args.customOnly)
	if customOnly:
		customName = args.customOnly
	dereference = args.dereference
	logLevel = args.logLevel
	compression = args.compression
	quiet = args.quiet
	tempDir = forceFilename(args.tempDir)
	format = forceUnicode(args.format)
	createMd5SumFile = args.createMd5SumFile
	createZsyncFile = args.createZsyncFile
	packageSourceDir = args.packageSourceDir
	disablePigz = args.disablePigz

	if args.verbose:
		logLevel = LOG_DEBUG

	if logLevel != LOG_WARNING:
		logger.setColor(True)

	if quiet:
		logLevel = LOG_NONE

	logger.setConsoleLevel(logLevel)

	logger.info(u"Source dir: %s" % packageSourceDir)
	logger.info(u"Temp dir: %s" % tempDir)
	logger.info(u"Custom name: %s" % customName)
	logger.info(u"Archive format: %s" % format)

	if format not in ['tar', 'cpio']:
		raise ValueError(u"Unsupported archive format: %s" % format)

	if not os.path.isdir(packageSourceDir):
		raise OSError(u"No such directory: %s" % packageSourceDir)

	if customName:
		packageControlFilePath = os.path.join(packageSourceDir, u'OPSI.%s' % customName, u'control')
	if not customName or not os.path.exists(packageControlFilePath):
		packageControlFilePath = os.path.join(packageSourceDir, u'OPSI', u'control')
		if not os.path.exists(packageControlFilePath):
			raise OSError(u"Control file '%s' not found" % packageControlFilePath)

	if not quiet:
		print ""
		print _(u"Locking package")
	pcf = PackageControlFile(packageControlFilePath)

	lockPackage(tempDir, pcf)
	pps = None
	try:
		while True:
			product = pcf.getProduct()

			if not quiet:
				print u""
				print _(u"Package info")
				print u"----------------------------------------------------------------------------"
				print u"   %-20s : %s" % (u'version', product.packageVersion)
				print u"   %-20s : %s" % (u'custom package name', customName)
				print u"   %-20s : %s" % (u'package dependencies', u', '.join(u'{package}({condition}{version})'.format(**dep) for dep in pcf.getPackageDependencies()))

				print ""
				print _(u"Product info")
				print u"----------------------------------------------------------------------------"
				print u"   %-20s : %s" % (u'product id', product.id)

				if product.getType() == 'LocalbootProduct':
					print u"   %-20s : %s" % (u'product type', u'localboot')
				elif product.getType() == 'NetbootProduct':
					print u"   %-20s : %s" % (u'product type', u'netboot')

				print u"   %-20s : %s" % (u'version', product.productVersion)
				print u"   %-20s : %s" % (u'name', product.name)
				print u"   %-20s : %s" % (u'description', product.description)
				print u"   %-20s : %s" % (u'advice', product.advice)
				print u"   %-20s : %s" % (u'priority', product.priority)
				print u"   %-20s : %s" % (u'licenseRequired', product.licenseRequired)
				print u"   %-20s : %s" % (u'product classes', u', '.join(product.productClassIds))
				print u"   %-20s : %s" % (u'windows software ids', u', '.join(product.windowsSoftwareIds))

				if product.getType() == 'NetbootProduct':
					print u"   %-20s : %s" % (u'pxe config template', product.pxeConfigTemplate)

				print ""
				print _(u"Product scripts")
				print u"----------------------------------------------------------------------------"
				print u"   %-20s : %s" % (u'setup', product.setupScript)
				print u"   %-20s : %s" % (u'uninstall', product.uninstallScript)
				print u"   %-20s : %s" % (u'update', product.updateScript)
				print u"   %-20s : %s" % (u'always', product.alwaysScript)
				print u"   %-20s : %s" % (u'once', product.onceScript)
				print u"   %-20s : %s" % (u'custom', product.customScript)
				if product.getType() == 'LocalbootProduct':
					print u"   %-20s : %s" % (u'user login', product.userLoginScript)
				print u""

			if disablePigz:
				logger.debug("Disabling pigz")
				OPSI.Util.File.Archive.PIGZ_ENABLED = False

			pps = ProductPackageSource(
				packageSourceDir=packageSourceDir,
				tempDir=tempDir,
				customName=customName,
				customOnly=customOnly,
				packageFileDestDir=os.getcwd(),
				format=format,
				compression=compression,
				dereference=dereference
			)

			if not quiet and os.path.exists(pps.getPackageFile()):
				print _(u"Package file '%s' already exists.") % pps.getPackageFile()
				print _(u"Press <O> to overwrite, <C> to abort or <N> to specify a new version:"),
				newVersion = False
				if keepVersions and needOneVersion:
					newVersion = True
				elif keepVersions:
					if os.path.exists(pps.packageFile):
						os.remove(pps.packageFile)
					if os.path.exists(pps.packageFile + u'.md5'):
						os.remove(pps.packageFile + u'.md5')
					if os.path.exists(pps.packageFile + u'.zsync'):
						os.remove(pps.packageFile + u'.zsync')
				elif needOneVersion:
					newVersion = True

				if not doNotUseTerminal:
					fd = sys.stdin.fileno()
					savedSettings = termios.tcgetattr(fd)
					tty.setraw(fd)
					try:
						while True:
							ch = sys.stdin.read(1)
							if ch in ('o', 'O'):
								if os.path.exists(pps.packageFile):
									os.remove(pps.packageFile)
								if os.path.exists(pps.packageFile + u'.md5'):
									os.remove(pps.packageFile + u'.md5')
								if os.path.exists(pps.packageFile + u'.zsync'):
									os.remove(pps.packageFile + u'.zsync')
								break
							elif ch in ('c', 'C'):
								raise Exception(_(u"Aborted"))
							elif ch in ('n', 'N'):
								newVersion = True
								break
					finally:
						termios.tcsetattr(fd, termios.TCSADRAIN, savedSettings)
						print '\r\033[0K'

				if newVersion:
					while True:
						print '\r%s' % _(u"Please specify new product version, press <ENTER> to keep current version (%s):") % product.productVersion,
						newVersion = newProductVersion
						if not keepVersions and not needOneVersion:
							newVersion = sys.stdin.readline().strip()
						else:
							if newProductVersion:
								newVersion = newProductVersion
							elif keepVersions:
								newVersion = product.productVersion
							else:
								newVersion = sys.stdin.readline().strip()

						try:
							if newVersion:
								product.setProductVersion(newVersion)
								pcf.generate()
							break
						except Exception:
							print _(u"Bad product version: %s") % newVersion

					while True:
						print '\r%s' % _(u"Please specify new package version, press <ENTER> to keep current version (%s):") % product.packageVersion,
						newVersion = newPackageVersion
						if not keepVersions and not needOneVersion:
							newVersion = sys.stdin.readline().strip()
						else:
							if newPackageVersion:
								newVersion = newPackageVersion
							elif keepVersions:
								newVersion = product.packageVersion
							else:
								newVersion = sys.stdin.readline().strip()

						try:
							if newVersion:
								product.setPackageVersion(newVersion)
								pcf.generate()
							break
						except Exception:
							print _(u"Bad package version: %s") % newVersion

					keepVersions = True
					needOneVersion = False
					newProductVersion = newPackageVersion = None
					newVersion = None
					continue

			# Regenerating to fix encoding
			pcf.generate()

			progressSubject = None
			if not quiet:
				progressSubject = ProgressSubject('packing')
				progressSubject.attachObserver(ProgressNotifier())
				print _(u"Creating package file '%s'") % pps.getPackageFile()
			pps.pack(progressSubject=progressSubject)
			setRights(pps.getPackageFile())

			if not quiet:
				print "\n"
			if createMd5SumFile:
				md5sumFile = u'%s.md5' % pps.getPackageFile()
				if not quiet:
					print _(u"Creating md5sum file '%s'") % md5sumFile
				md5 = md5sum(pps.getPackageFile())
				with open(md5sumFile, 'w') as f:
					f.write(md5)
				setRights(md5sumFile)

			if createZsyncFile:
				zsyncFilePath = u'%s.zsync' % pps.getPackageFile()
				if not quiet:
					print _(u"Creating zsync file '%s'") % zsyncFilePath
				zsyncFile = ZsyncFile(zsyncFilePath)
				zsyncFile.generate(pps.getPackageFile())
				setRights(zsyncFilePath)
			break
	finally:
		if pps:
			if not quiet:
				print _(u"Cleaning up")
			pps.cleanup()
		if not quiet:
			print _(u"Unlocking package")
		unlockPackage(tempDir, pcf)
		if not quiet:
			print ""


def lockPackage(tempDir, packageControlFile):
	lockFile = os.path.join(tempDir, u'.opsi-makepackage.lock.%s' % packageControlFile.getProduct().id)
	# Test if other processes are accessing same product
	try:
		with open(lockFile, 'r') as lf:
			p = lf.read().strip()

		if p:
			for line in execute(u"ps -A"):
				line = line.strip()
				if not line:
					continue
				if p == line.split()[0].strip():
					pName = line.split()[-1].strip()
					# process is running
					raise RuntimeError(u"Product '%s' is currently locked by process %s (%s)."
									% (packageControlFile.getProduct().id, pName, p))

	except IOError:
		pass

	# Write lock-file
	with open(lockFile, 'w') as lf:
		lf.write(str(os.getpid()))


def unlockPackage(tempDir, packageControlFile):
	lockFile = os.path.join(tempDir, u'.opsi-makepackage.lock.%s' % packageControlFile.getProduct().id)
	if os.path.isfile(lockFile):
		os.unlink(lockFile)


if __name__ == "__main__":
	try:
		main(sys.argv[1:])
	except SystemExit:
		pass
	except Exception as exception:
		logger.setConsoleLevel(LOG_ERROR)
		logger.logException(exception)
		print >> sys.stderr, u"ERROR: %s" % exception
		sys.exit(1)
