1# -*- coding: utf-8 -*-
2#
3# Copyright 2017 Michael Lotz
4# Distributed under the terms of the MIT License.
5
6# -- Modules ------------------------------------------------------------------
7
8import glob
9import hashlib
10import os
11import subprocess
12
13from .Configuration import Configuration
14from .DependencyResolver import DependencyResolver
15from .Options import getOption
16from .PackageInfo import PackageInfo
17from .Utils import info, prefixLines, sysExit, versionCompare, warn
18
19# -- PackageRepository class --------------------------------------------------
20
21class PackageRepository(object):
22
23	def __init__(self, packagesPath, repository, quiet, verbose):
24		self.packagesPath = packagesPath
25		if not os.path.exists(self.packagesPath):
26			os.mkdir(self.packagesPath)
27
28		self.obsoleteDir = os.path.join(self.packagesPath, '.obsolete')
29		if not os.path.exists(self.obsoleteDir):
30			os.mkdir(self.obsoleteDir)
31
32		self.architectures = [Configuration.getTargetArchitecture(),
33			'any', 'source']
34
35		self.repository = repository
36		self.quiet = quiet
37		self.verbose = verbose
38
39	def prune(self):
40		self.obsoletePackagesWithoutPort()
41		self.obsoletePackagesNewerThanActiveVersion()
42		self.obsoletePackagesWithNewerVersions()
43
44	def packageList(self, packageSpec=None):
45		if packageSpec is None:
46			packageSpec = ''
47		else:
48			packageSpec += '-'
49
50		packageSpec += '*.hpkg'
51		return glob.glob(os.path.join(self.packagesPath, packageSpec))
52
53	def packageInfoList(self, packageSpec=None):
54		result = []
55		for package in self.packageList(packageSpec):
56			try:
57				packageInfo = PackageInfo(package)
58			except Exception as exception:
59				warn('failed to get info of {}: {}'.format(package, exception))
60				continue
61
62			if packageInfo.architecture not in self.architectures:
63				continue
64
65			result.append(packageInfo)
66
67		return result
68
69	def obsoletePackage(self, path, reason=None):
70		packageFileName = os.path.basename(path)
71		if not self.quiet:
72			print('\tobsoleting package {}: {}'.format(packageFileName, reason))
73
74		os.rename(path, os.path.join(self.obsoleteDir, packageFileName))
75
76	def obsoletePackagesForSpec(self, packageSpec, reason=None):
77		"""remove all packages for the given packageSpec"""
78
79		for package in self.packageList(packageSpec):
80			self.obsoletePackage(package, reason)
81
82	def obsoletePackagesWithoutPort(self):
83		"""remove packages that have no corresponding port"""
84
85		for package in self.packageInfoList():
86			portName = self.repository.getPortNameForPackageName(package.name)
87			activePort = self.repository.getActivePort(portName)
88			if not activePort:
89				self.obsoletePackage(package.path, 'no port for it exists')
90
91	def obsoletePackagesNewerThanActiveVersion(self):
92		"""remove packages newer than what their active port version produces"""
93
94		for package in self.packageInfoList():
95			portName = self.repository.getPortNameForPackageName(package.name)
96			activePort = self.repository.getActivePort(portName)
97			if not activePort:
98				continue
99
100			if versionCompare(package.version, activePort.fullVersion) > 0:
101				self.obsoletePackage(package.path,
102					'newer than active {}'.format(activePort.fullVersion))
103
104	def obsoletePackagesWithNewerVersions(self):
105		"""remove all packages where newer version packages are available"""
106
107		newestPackages = dict()
108		reason = 'newer version {} available'
109		for package in self.packageInfoList():
110			if package.name in newestPackages:
111				newest = newestPackages[package.name]
112				if versionCompare(newest.version, package.version) > 0:
113					self.obsoletePackage(package.path,
114						reason.format(newest.version))
115					continue
116
117				self.obsoletePackage(newest.path,
118					reason.format(package.version))
119
120			newestPackages[package.name] = package
121
122	def createPackageRepository(self, outputPath):
123		packageRepoCommand = Configuration.getPackageRepoCommand()
124		if not packageRepoCommand:
125			sysExit('package repo command must be configured or specified')
126
127		repoFile = os.path.join(outputPath, 'repo')
128		repoInfoFile = repoFile + '.info'
129		if not os.path.exists(repoInfoFile):
130			sysExit('repository info file expected at {}'.format(repoInfoFile))
131
132		repoPackagesPath = os.path.join(outputPath, 'packages')
133		if not os.path.exists(repoPackagesPath):
134			os.mkdir(repoPackagesPath)
135		else:
136			for package in glob.glob(os.path.join(repoPackagesPath, '*.hpkg')):
137				os.unlink(package)
138
139		packageList = self.packageInfoList()
140		for package in packageList:
141			os.link(package.path,
142				os.path.join(repoPackagesPath, os.path.basename(package.path)))
143
144		packageListFile = os.path.join(outputPath, 'package.list')
145		with open(packageListFile, 'w') as outputFile:
146			fileList = '\n'.join(
147				[os.path.basename(package.path) for package in packageList])
148			outputFile.write(fileList)
149
150		if not os.path.exists(repoFile):
151			if not packageList:
152				sysExit('no repo file exists and no packages to create it')
153
154			output = subprocess.check_output([packageRepoCommand, 'create',
155					'-v', repoInfoFile, packageList[0].path],
156				stderr=subprocess.STDOUT).decode('utf-8')
157			info(output)
158
159		output = subprocess.check_output([packageRepoCommand, 'update', '-C',
160				repoPackagesPath, '-v', repoFile, repoFile, packageListFile],
161			stderr=subprocess.STDOUT).decode('utf-8')
162		info(output)
163		self._checksumPackageRepository(repoFile)
164		self._signPackageRepository(repoFile)
165
166	def _checksumPackageRepository(self, repoFile):
167		"""Create a checksum of the package repository"""
168		checksum = hashlib.sha256()
169		with open(repoFile, 'rb') as inputFile:
170			while True:
171				data = inputFile.read(1 * 1024 * 1024)
172				if not data:
173					break
174				checksum.update(data)
175		with open(repoFile + '.sha256', 'w') as outputFile:
176			outputFile.write(checksum.hexdigest())
177
178	def _signPackageRepository(self, repoFile):
179		"""Sign the package repository if a private key was provided"""
180		privateKeyFile = getOption('packageRepositorySignPrivateKeyFile')
181		privateKeyPass = getOption('packageRepositorySignPrivateKeyPass')
182		if not privateKeyFile and not privateKeyPass:
183			info("Warning: unsigned package repository")
184			return
185		if not os.path.exists(privateKeyFile):
186			sysExit('specified package repo private key file missing!')
187
188		if not os.path.exists(repoFile):
189			sysExit('no repo file was found to sign!')
190
191		minisignCommand = Configuration.getMinisignCommand()
192		if not minisignCommand:
193			sysExit('minisign command missing to sign repository!')
194
195		# minisign -s /tmp/minisign.key -Sm ${ARTIFACT}
196		info("signing repository")
197		output = subprocess.check_output([minisignCommand, '-s',
198			privateKeyFile, "-Sm", repoFile], input=privateKeyPass.encode('utf-8'),
199			stderr=subprocess.STDOUT).decode('utf-8')
200		info(output)
201
202	def checkPackageRepositoryConsistency(self):
203		"""Check consistency of package repository by dependency solving all
204			all packages."""
205
206		repositories = [self.packagesPath]
207		systemPackagesDirectory = getOption('systemPackagesDirectory')
208		if systemPackagesDirectory:
209			repositories.append(systemPackagesDirectory)
210
211		resolver = DependencyResolver(None, ['REQUIRES'], repositories,
212			quiet=True)
213
214		for package in self.packageInfoList():
215			if self.verbose:
216				print('checking package {}'.format(package.path))
217
218			try:
219				resolver.determineRequiredPackagesFor([package.path])
220			except LookupError as error:
221				print('{}:\n{}\n'.format(os.path.relpath(package.path,
222						self.packagesPath), prefixLines('\t', str(error))))
223