package-data-downloader 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. #!/usr/bin/python3
  2. # -*- coding: utf-8 -*-
  3. """Process new requests to download per-package data"""
  4. # Copyright (C) 2012 Canonical Ltd
  5. #
  6. # This program is free software; you can redistribute it and/or modify
  7. # it under the terms of version 3 of the GNU General Public License as
  8. # published by the Free Software Foundation.
  9. #
  10. # This program is distributed in the hope that it will be useful,
  11. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. # GNU General Public License for more details.
  14. #
  15. # You should have received a copy of the GNU General Public License along
  16. # with this program; if not, write to the Free Software Foundation, Inc.,
  17. # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
  18. import glob
  19. import os
  20. import sys
  21. import subprocess
  22. import traceback
  23. import debian.deb822
  24. import string
  25. import debconf
  26. from datetime import datetime
  27. import apt_pkg
  28. # avoid hanging forever (LP: #1243090)
  29. import socket
  30. socket.setdefaulttimeout(60)
  31. DATADIR = "/usr/share/package-data-downloads/"
  32. STAMPDIR = "/var/lib/update-notifier/package-data-downloads/"
  33. NOTIFIER_SOURCE_FILE = \
  34. "/usr/share/update-notifier/package-data-downloads-failed"
  35. NOTIFIER_FILE = "/var/lib/update-notifier/user.d/data-downloads-failed"
  36. NOTIFIER_PERMANENT_SOURCE_FILE = NOTIFIER_SOURCE_FILE + '-permanently'
  37. NOTIFIER_PERMANENT_FILE = NOTIFIER_FILE + '-permanently'
  38. failures = []
  39. permanent_failures = []
  40. def create_or_update_stampfile(file):
  41. """Create or update the indicated stampfile, and remove failure flags"""
  42. try:
  43. with open(file, 'w'):
  44. pass
  45. # Ignore errors
  46. except Exception:
  47. traceback.print_exc(file=sys.stderr)
  48. os.utime(file, None)
  49. for ext in ('.failed', '.permanent-failure'):
  50. if os.path.exists(file + ext):
  51. os.unlink(file + ext)
  52. def mark_hook_failed(hook_name, permanent=False):
  53. """Create a stampfile recording that a hook failed
  54. We create separate stampfiles for failed hooks so we can
  55. keep track of how long the hook has been failing and if the failure
  56. should be considered permanent."""
  57. if permanent:
  58. filename = hook_name + '.permanent-failure'
  59. else:
  60. filename = hook_name + '.failed'
  61. failure_file = os.path.join(STAMPDIR, filename)
  62. try:
  63. with open(failure_file, 'w'):
  64. pass
  65. # Ignore errors
  66. except Exception:
  67. traceback.print_exc(file=sys.stderr)
  68. for ext in ('', '.failed', '.permanent-failure'):
  69. stampfile = hook_name + ext
  70. if filename != stampfile \
  71. and os.path.exists(os.path.join(STAMPDIR, stampfile)):
  72. os.unlink(os.path.join(STAMPDIR, stampfile))
  73. def hook_is_permanently_failed(hook_name):
  74. """Check if this hook has been marked as permanently failing.
  75. If so, don't raise any more errors about it."""
  76. failure_file = os.path.join(STAMPDIR, hook_name + '.permanent-failure')
  77. return os.path.exists(failure_file)
  78. def hook_aged_out(hook_name):
  79. """Check if this hook has been failing consistently for >= 3 days"""
  80. failure_file = os.path.join(STAMPDIR, hook_name + '.failed')
  81. try:
  82. hook_date = datetime.fromtimestamp(os.stat(failure_file).st_ctime)
  83. cur_time = datetime.now()
  84. d = cur_time - hook_date
  85. if d.days >= 3:
  86. return True
  87. except OSError:
  88. pass
  89. except Exception:
  90. traceback.print_exc(file=sys.stderr)
  91. return False
  92. def record_failure(hook):
  93. """Record that the named hook has failed"""
  94. if hook_aged_out(hook):
  95. permanent_failures.append(hook)
  96. else:
  97. failures.append(hook)
  98. def existing_permanent_failures():
  99. """Return the list of all previously recorded permanent failures"""
  100. files = glob.glob(os.path.join(STAMPDIR, "*.permanent-failure"))
  101. return [os.path.splitext(os.path.basename(path))[0] for path in files]
  102. def trigger_update_notifier(failures, permanent=False):
  103. """Tell update-notifier that there were failed packages"""
  104. try:
  105. if permanent:
  106. with open(NOTIFIER_PERMANENT_SOURCE_FILE, 'r',
  107. encoding='utf-8') as f:
  108. input = f.read()
  109. output_file = open(NOTIFIER_PERMANENT_FILE, 'w', encoding='utf-8')
  110. else:
  111. with open(NOTIFIER_SOURCE_FILE, 'r', encoding='utf-8') as f:
  112. input = f.read()
  113. output_file = open(NOTIFIER_FILE, 'w', encoding='utf-8')
  114. except Exception:
  115. # Things failed and we can't even notify about it. Break the
  116. # trigger so that there's some error propagation, even if not
  117. # the most pleasant sort.
  118. traceback.print_exc(file=sys.stderr)
  119. sys.exit(1)
  120. packages = [os.path.basename(failure) for failure in failures]
  121. output_file.write(
  122. string.Template(input).substitute(
  123. {'packages': ", ".join(packages)}))
  124. output_file.close()
  125. def get_hook_file_names():
  126. res = []
  127. for relfile in os.listdir(DATADIR):
  128. # ignore files ending in .dpkg-*
  129. if (os.path.splitext(relfile)[1]
  130. and os.path.splitext(relfile)[1].startswith(".dpkg")):
  131. continue
  132. res.append(relfile)
  133. return res
  134. # we use apt-helper here as this gives us the exact same proxy behavior
  135. # as apt-get itself (environment/apt-config proxy settings/autodiscover)
  136. def download_file(uri, sha256_hashsum):
  137. """Download a URI and checks the given hashsum using apt-helper
  138. Returns: path to the downloaded file or None
  139. """
  140. download_dir = os.path.join(STAMPDIR, "partial")
  141. dest_file = os.path.join(download_dir, os.path.basename(uri))
  142. try:
  143. with open(dest_file, 'rb') as dest_file_obj:
  144. # apt_pkg can directly hash from file obj, let's use that instead
  145. # of hashlib
  146. real_sha256 = apt_pkg.sha256sum(dest_file_obj)
  147. if real_sha256 == sha256_hashsum:
  148. return dest_file
  149. else:
  150. os.remove(dest_file)
  151. except FileNotFoundError:
  152. pass
  153. ret = subprocess.call(
  154. ["/usr/lib/apt/apt-helper",
  155. "download-file", uri, dest_file, "SHA256:" + sha256_hashsum])
  156. if ret != 0:
  157. if os.path.exists(dest_file):
  158. os.remove(dest_file)
  159. return None
  160. return dest_file
  161. def print_maybe(*args, **kwargs):
  162. """Version of print() that ignores failure"""
  163. try:
  164. print(*args, **kwargs)
  165. except OSError:
  166. pass
  167. def process_download_requests():
  168. """Process requests to download package data files
  169. Iterate over /usr/share/package-data-downloads and download any
  170. package data specified in the contained file, then hand off to
  171. the indicated handler for further processing.
  172. Successful downloads are recorded in
  173. /var/lib/update-notifier/package-data-downloads to avoid unnecessary
  174. repeat handling.
  175. Failed downloads are reported to the user via the
  176. update-notifier interface."""
  177. # Iterate through all the available hooks. If we get a failure
  178. # processing any of them (download failure, checksum failure, or
  179. # failure to run the hook script), record it but continue processing
  180. # the rest of the hooks since some of them may succeed.
  181. for relfile in get_hook_file_names():
  182. stampfile = os.path.join(STAMPDIR, relfile)
  183. file = os.path.join(DATADIR, relfile)
  184. try:
  185. if not os.path.exists(NOTIFIER_FILE) and \
  186. not os.path.exists(NOTIFIER_PERMANENT_FILE):
  187. hook_date = os.stat(file).st_mtime
  188. stamp_date = os.stat(stampfile).st_mtime
  189. if hook_date < stamp_date:
  190. continue
  191. elif os.path.exists(stampfile):
  192. continue
  193. except Exception as e:
  194. if not isinstance(e, OSError):
  195. traceback.print_exc(file=sys.stderr)
  196. hook = debian.deb822.Deb822()
  197. files = []
  198. sums = []
  199. for para in hook.iter_paragraphs(open(file)):
  200. if 'Script' in para:
  201. if not files:
  202. record_failure(relfile)
  203. break
  204. command = [para['Script']]
  205. if 'Should-Download' in para:
  206. db = debconf.DebconfCommunicator('update-notifier')
  207. try:
  208. should = db.get(para['Should-Download'])
  209. if should == "false":
  210. # Do nothing with this file.
  211. break
  212. except (DebconfError, KeyError):
  213. pass
  214. finally:
  215. db.shutdown()
  216. print_maybe("%s: processing..." % (relfile))
  217. # Download each file and verify the sum
  218. try:
  219. downloaded = set()
  220. for i in range(len(files)):
  221. print_maybe("%s: downloading %s" % (relfile, files[i]))
  222. dest_file = download_file(files[i], sums[i])
  223. if dest_file:
  224. command.append(dest_file)
  225. downloaded.add(dest_file)
  226. else:
  227. record_failure(relfile)
  228. break
  229. if relfile in failures + permanent_failures:
  230. break
  231. sys.stdout.flush()
  232. result = subprocess.call(command)
  233. if result:
  234. # There's no sense redownloading if the script fails
  235. permanent_failures.append(relfile)
  236. else:
  237. create_or_update_stampfile(stampfile)
  238. # cleanup
  239. for f in downloaded:
  240. os.remove(f)
  241. break
  242. except Exception:
  243. traceback.print_exc(file=sys.stderr)
  244. record_failure(relfile)
  245. # The 'script' is always the last stanza
  246. break
  247. # Not in a 'script' stanza, so we should have some urls
  248. try:
  249. files.append(para['Url'])
  250. sums.append(para['Sha256'])
  251. except Exception as e:
  252. print_maybe("%s: Error processing!" % (relfile))
  253. if not isinstance(e, KeyError):
  254. traceback.print_exc(file=sys.stderr)
  255. record_failure(relfile)
  256. break
  257. previous_failures = existing_permanent_failures()
  258. # We only report about "permanent" failures when there are new ones,
  259. # but we want the whole list of permanently-failing hooks so when
  260. # we clobber the update-notifier file we don't lose information the
  261. # user may not have seen yet
  262. if permanent_failures:
  263. new_failures = False
  264. for failure in permanent_failures:
  265. if failure not in previous_failures:
  266. mark_hook_failed(failure, permanent=True)
  267. previous_failures.append(failure)
  268. new_failures = True
  269. if new_failures:
  270. trigger_update_notifier(previous_failures, permanent=True)
  271. # 2016-09-19 14:36 reset the list of permanent_failures as it caused
  272. # tests not to be idempotent
  273. permanent_failures.clear()
  274. if not previous_failures and os.path.exists(NOTIFIER_PERMANENT_FILE):
  275. os.unlink(NOTIFIER_PERMANENT_FILE)
  276. # Filter out new failure reports for permanently-failed packages
  277. our_failures = [x for x in failures if x not in previous_failures]
  278. # 2016-09-19 14:36 reset the list of permanent_failures as it caused
  279. # tests not to be idempotent
  280. failures.clear()
  281. if our_failures:
  282. for failure in our_failures:
  283. mark_hook_failed(failure)
  284. trigger_update_notifier(our_failures)
  285. elif os.path.exists(NOTIFIER_FILE):
  286. os.unlink(NOTIFIER_FILE)
  287. if __name__ == "__main__":
  288. process_download_requests()