git_ops.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550
  1. # coding: utf-8
  2. from __future__ import print_function
  3. from __future__ import absolute_import
  4. from __future__ import division
  5. from __future__ import unicode_literals
  6. from backend import const
  7. import backend.models as bmodels
  8. import backend.ops as bops
  9. import process.models as pmodels
  10. import process.ops as pops
  11. import os
  12. import requests
  13. import re
  14. class ParseError(Exception):
  15. def __init__(self, log_entry, *args, **kw):
  16. super(ParseError, self).__init__(*args, **kw)
  17. self.log_entry = log_entry
  18. class OperationError(Exception):
  19. def __init__(self, log_entry, *args, **kw):
  20. super(OperationError, self).__init__(*args, **kw)
  21. self.log_entry = log_entry
  22. class Operation(object):
  23. """
  24. Base class for operations detected from git logs
  25. """
  26. # Available actions
  27. actions = {}
  28. email_map = {
  29. "gwolf@gwolf.org": "gwolf",
  30. "noodles@earth.li": "noodles",
  31. }
  32. @classmethod
  33. def action(cls, _class):
  34. """
  35. Register an action class
  36. """
  37. cls.actions[_class.__name__.lower()] = _class
  38. return _class
  39. @classmethod
  40. def from_log_entry(cls, log_entry):
  41. details = log_entry.parsed.get("details", None)
  42. if details is not None:
  43. return ProcessOperation.from_log_entry(log_entry)
  44. else:
  45. action_name = log_entry.parsed["action"].lower()
  46. Action = cls.actions.get(action_name, None)
  47. if Action is None:
  48. raise ParseError(log_entry, "Action {} not supported", action_name)
  49. return Action.from_log_entry(log_entry)
  50. def __init__(self, log_entry):
  51. self.log_entry = log_entry
  52. author_email = log_entry.commit.author.email
  53. author_uid = self.email_map.get(author_email, None)
  54. if author_uid is None:
  55. search = { "email": author_email }
  56. else:
  57. search = { "uid": author_uid }
  58. try:
  59. self.author = bmodels.Person.objects.get(**search)
  60. except bmodels.Person.DoesNotExist:
  61. raise ParseError(log_entry, "author {} not found in nm.debian.org".format(log_entry.commit.author.email))
  62. self.role = log_entry.parsed.get("role", None)
  63. if self.role is None: raise ParseError(log_entry, "Role not found in commit message")
  64. self.rt = log_entry.parsed.get("rt-ticket", None)
  65. def _get_consistent_person(self, persons):
  66. """
  67. Given a dict mapping workds to Person objects, make sure that all the
  68. Person objects are the same, and return the one Person object.
  69. If persons is empty, return None.
  70. """
  71. # Check if we are unambiguously referring to a record that we
  72. # can update
  73. person = None
  74. for v in persons.values():
  75. if person is None:
  76. person = v
  77. elif person != v:
  78. msg = []
  79. for k, v in persons.items():
  80. msg.append("{} by {}".format(k, v.lookup_key))
  81. raise OperationError(self.log_entry, "commit matches multiple people: {}".format(", ".join(msg)))
  82. return person
  83. class ProcessOperation(Operation):
  84. def __init__(self, log_entry):
  85. super(ProcessOperation, self).__init__(log_entry)
  86. for k in ("new-key", "key"):
  87. self.fpr = log_entry.parsed.get(k, None)
  88. if self.fpr is not None: break
  89. else:
  90. raise ParseError(log_entry, "commit message has no New-key or Key field")
  91. self.details = log_entry.parsed.get("details", None)
  92. try:
  93. process_id = int(os.path.basename(self.details))
  94. except:
  95. raise ParseError(log_entry, "cannot extract process ID from {}".format(self.details))
  96. try:
  97. self.process = pmodels.Process.objects.select_related("person").get(pk=process_id)
  98. except pmodels.Process.DoesNotExist:
  99. raise ParseError(log_entry, "process {} not found in the site".format(self.details))
  100. def __str__(self):
  101. return "Close process {}".format(self.process.pk)
  102. @classmethod
  103. def from_log_entry(cls, log_entry):
  104. return cls(log_entry)
  105. def ops(self):
  106. person = self.process.person
  107. if person.fpr != self.fpr:
  108. raise OperationError(self.log_entry, "{} in process {} has fingerprint {} but the commit has {}".format(
  109. person.lookup_key, self.details, person.fpr, self.fpr))
  110. if self.process.closed: return
  111. if self.rt:
  112. logtext = "Closed from keyring changelog {}, RT #{}".format(self.log_entry.shasum, self.rt)
  113. else:
  114. logtext = "Closed from keyring changelog {}, RT unknown".format(self.log_entry.shasum)
  115. yield pops.CloseProcess(
  116. process=self.process,
  117. logtext=logtext,
  118. logdate=self.log_entry.dt,
  119. audit_author=self.author,
  120. audit_notes=logtext,
  121. )
  122. class RoleOperation(Operation):
  123. @classmethod
  124. def from_log_entry(cls, log_entry):
  125. role = log_entry.parsed.get("role", None)
  126. if role == "role": return None
  127. if role is None: raise ParseError(log_entry, "role not found in commit message")
  128. Op = cls.by_role.get(role, None)
  129. if Op is None:
  130. raise ParseError(log_entry, "unsupported role {} in commit message".format(role))
  131. return Op(log_entry)
  132. @Operation.action
  133. class Add(RoleOperation):
  134. by_role = {}
  135. def __init__(self, log_entry):
  136. super(Add, self).__init__(log_entry)
  137. for k in ("new-key", "key"):
  138. self.fpr = log_entry.parsed.get(k, None)
  139. if self.fpr is not None: break
  140. else:
  141. raise ParseError(log_entry, "commit message has no New-key or Key field")
  142. fn = log_entry.parsed.get("subject", None)
  143. if fn is None:
  144. raise ParseError(log_entry, "commit message has no Subject field")
  145. self.cn, self.mn, self.sn = self._split_subject(fn)
  146. self.email = None
  147. self.uid = None
  148. def _split_subject(self, subject):
  149. """
  150. Arbitrary split a full name into cn, mn, sn
  151. This is better than nothing, but not a lot better than that.
  152. """
  153. # See http://www.kalzumeus.com/2010/06/17/falsehoods-programmers-believe-about-names/
  154. fn = subject.decode('utf8').split()
  155. if len(fn) == 1:
  156. return fn[0], "", ""
  157. elif len(fn) == 2:
  158. return fn[0], "", fn[1]
  159. elif len(fn) == 3:
  160. return fn
  161. else:
  162. middle = len(fn) // 2
  163. return " ".join(fn[:middle]), "", " ".join(fn[middle:])
  164. def _get_person(self):
  165. """
  166. Return the Person affected by this entry, or None if none exists in the
  167. database yet
  168. """
  169. # Check for existing records in the database
  170. persons = {}
  171. if self.fpr:
  172. try:
  173. persons["fpr"] = bmodels.Person.objects.get(fprs__fpr=self.fpr)
  174. except bmodels.Person.DoesNotExist:
  175. pass
  176. if self.email:
  177. try:
  178. persons["email"] = bmodels.Person.objects.get(email=self.email)
  179. except bmodels.Person.DoesNotExist:
  180. pass
  181. if self.uid:
  182. try:
  183. persons["uid"] = bmodels.Person.objects.get(uid=self.uid)
  184. except bmodels.Person.DoesNotExist:
  185. pass
  186. # Check if we are unambiguously referring to a record that we
  187. # can update
  188. return self._get_consistent_person(persons)
  189. class AddDM(Add):
  190. def __init__(self, log_entry):
  191. """
  192. Dig all information from a commit body that we can use to create a new
  193. DM
  194. """
  195. super(AddDM, self).__init__(log_entry)
  196. # To get the email, we need to go and scan the agreement post from the
  197. # list archives
  198. agreement_url = log_entry.parsed.get("agreement", None)
  199. if agreement_url is not None:
  200. r = self._fetch_url(agreement_url.strip())
  201. if r.status_code == 200:
  202. mo = re.search(r'<link rev="made" href="mailto:([^"]+)">', r.text)
  203. if mo:
  204. self.email = mo.group(1)
  205. if self.email is None:
  206. raise ParseError(log_entry, "agreement not found in commit, or email not found in agreement url")
  207. def _fetch_url(self, url):
  208. bundle="/etc/ssl/ca-debian/ca-certificates.crt"
  209. if os.path.exists(bundle):
  210. return requests.get(url, verify=bundle)
  211. else:
  212. return requests.get(url)
  213. def ops(self):
  214. # Check for existing records in the database
  215. person = self._get_person()
  216. # If it is all new, create and we are done
  217. if person is None:
  218. if self.rt:
  219. audit_notes = "Created DM entry, RT #{}".format(self.rt)
  220. else:
  221. audit_notes = "Created DM entry, RT unknown"
  222. yield bops.CreateUser(
  223. # Dummy username used to avoid unique entry conflicts
  224. username="{}@example.org".format(self.fpr),
  225. cn=self.cn,
  226. mn=self.mn,
  227. sn=self.sn,
  228. email=self.email,
  229. status=const.STATUS_DM,
  230. status_changed=self.log_entry.dt,
  231. audit_author=self.author,
  232. audit_notes=audit_notes,
  233. fpr=self.fpr,
  234. )
  235. return
  236. if person.status in (const.STATUS_DM, const.STATUS_DM_GA):
  237. # Already a DM, nothing to do
  238. #log.info("%s: %s is already a DM: skipping duplicate entry", self.logtag, self.person_link(person))
  239. return
  240. if person.status in (
  241. const.STATUS_DD_U, const.STATUS_DD_NU, const.STATUS_EMERITUS_DD, const.STATUS_REMOVED_DD,
  242. const.STATUS_EMERITUS_DM, const.STATUS_REMOVED_DM):
  243. raise OperationError(self.log_entry, "commit is for a new DM, but it corresponds to {} who has status {}".format(person.lookup_key, person.status))
  244. if person.status == const.STATUS_DC_GA:
  245. status = const.STATUS_DM_GA
  246. else:
  247. status = const.STATUS_DM
  248. if self.rt:
  249. audit_notes = "Set status to {}, RT #{}".format(const.ALL_STATUS_DESCS[status], self.rt)
  250. else:
  251. audit_notes = "Set status to {}, RT unknown".format(const.ALL_STATUS_DESCS[status])
  252. yield bops.ChangeStatus(
  253. person=person,
  254. status=status,
  255. status_changed=self.log_entry.dt,
  256. audit_author=self.author,
  257. audit_notes=audit_notes)
  258. #log.info("%s: %s: %s", self.logtag, self.person_link(person), audit_notes)
  259. def __str__(self):
  260. return "Add DM"
  261. Add.by_role["DM"] = AddDM
  262. class AddDD(Add):
  263. def __init__(self, log_entry):
  264. """
  265. Dig all information from a commit body that we can use to create a new
  266. DD
  267. """
  268. super(AddDD, self).__init__(log_entry)
  269. self.uid = log_entry.parsed.get("username", None)
  270. def __str__(self):
  271. return "Add DD"
  272. def ops(self):
  273. # Check for existing records in the database
  274. person = self._get_person()
  275. # If it is all new, keyring has a DD that DAM does not know about:
  276. # yell.
  277. if person is None:
  278. raise OperationError(self.log_entry, "commit has new DD {} {} that we do not know about".format(self.uid, self.fpr))
  279. if person.fpr != self.fpr:
  280. # Keyring-maint added a different key: sync with them
  281. if self.rt:
  282. audit_notes = "Set fingerprint to {}, RT #{}".format(self.fpr, self.rt)
  283. else:
  284. audit_notes = "Set fingerprint to {}, RT unknown".format(self.fpr)
  285. yield bops.ChangeFingerprint(
  286. person=person, fpr=self.fpr,
  287. audit_author=self.author, audit_notes=audit_notes)
  288. #person.save(audit_author=self.author, audit_notes=audit_notes)
  289. #log.info("%s: %s: %s", self.logtag, self.person_link(person), audit_notes)
  290. # Do not return yet, we still need to check the status
  291. role_status_map = {
  292. "DD": const.STATUS_DD_U,
  293. "DN": const.STATUS_DD_NU,
  294. }
  295. if person.status == role_status_map[self.role]:
  296. # Status already matches
  297. #log.info("%s: %s is already %s: skipping duplicate entry", self.logtag, self.person_link(person), const.ALL_STATUS_DESCS[person.status])
  298. return
  299. # Look for a process to close
  300. applying_for = role_status_map[self.role]
  301. found = False
  302. for p in person.active_processes:
  303. if p.applying_for != applying_for: continue
  304. if self.rt:
  305. logtext = "Added to {} keyring, RT #{}".format(self.role, self.rt)
  306. else:
  307. logtext = "Added to {} keyring, RT unknown".format(self.role)
  308. if not bmodels.Log.objects.filter(process=p, changed_by=self.author, logdate=self.log_entry.dt, logtext=logtext).exists():
  309. yield bops.CloseOldProcess(
  310. process=p,
  311. logtext=logtext,
  312. logdate=self.log_entry.dt,
  313. audit_author=self.author,
  314. audit_notes=logtext,
  315. )
  316. #log.info("%s: %s has an open process to become %s, keyring added them as %s",
  317. # self.logtag, self.person_link(person), const.ALL_STATUS_DESCS[p.applying_for], self.role)
  318. found = True
  319. for p in pmodels.Process.objects.filter(person=person, applying_for=applying_for, closed__isnull=True):
  320. if self.rt:
  321. logtext = "Added to {} keyring, RT #{}".format(self.role, self.rt)
  322. else:
  323. logtext = "Added to {} keyring, RT unknown".format(self.role)
  324. yield pops.CloseProcess(
  325. process=p,
  326. logtext=logtext,
  327. logdate=self.log_entry.dt,
  328. audit_author=self.author,
  329. audit_notes=logtext,
  330. )
  331. #log.info("%s: %s has an open process to become %s, keyring added them as %s",
  332. # self.logtag, self.person_link(person), const.ALL_STATUS_DESCS[p.applying_for], self.role)
  333. found = True
  334. if not found:
  335. # f3d1c1ee92bba3ebe05f584b7efea0cfd6e4ebe4 is an example commit
  336. # that triggers this
  337. raise OperationError(self.log_entry, "commit adds {} as {}, but we have no active process for it".format(
  338. person.lookup_key, self.role))
  339. Add.by_role["DD"] = AddDD
  340. Add.by_role["DN"] = AddDD
  341. @Operation.action
  342. class Remove(RoleOperation):
  343. by_role = {}
  344. class RemoveDD(Remove):
  345. def __init__(self, log_entry):
  346. super(RemoveDD, self).__init__(log_entry)
  347. self.uid = log_entry.parsed.get("username", None)
  348. self.fpr = log_entry.parsed.get("key", None)
  349. if self.fpr is None:
  350. raise ParseError(log_entry, "commit without Key field")
  351. def ops(self):
  352. persons = {}
  353. if self.uid:
  354. try:
  355. persons["uid"] = bmodels.Person.objects.get(uid=self.uid)
  356. except bmodels.Person.DoesNotExist:
  357. pass
  358. try:
  359. persons["fpr"] = bmodels.Person.objects.get(fprs__fpr=self.fpr)
  360. except bmodels.Person.DoesNotExist:
  361. pass
  362. person = self._get_consistent_person(persons)
  363. if not person:
  364. raise OperationError(self.log_entry, "commit references a person that is not known to the site")
  365. if person.status in (const.STATUS_DD_U, const.STATUS_DD_NU):
  366. if self.rt:
  367. audit_notes = "Moved to emeritus keyring, RT #{}".format(self.rt)
  368. else:
  369. audit_notes = "Moved to emeritus keyring, RT unknown"
  370. yield bops.ChangeStatus(
  371. person=person,
  372. status=const.STATUS_EMERITUS_DD,
  373. status_changed=self.log_entry.dt,
  374. audit_author=self.author,
  375. audit_notes=audit_notes)
  376. #log.info("%s: %s: %s", self.logtag, self.person_link(person), audit_notes)
  377. return
  378. if person.status == const.STATUS_EMERITUS_DD:
  379. # Already moved to DD
  380. #log.info("%s: %s is already emeritus: skipping key removal", self.logtag, self.person_link(person))
  381. return
  382. def __str__(self):
  383. return "Remove DD"
  384. Remove.by_role["DD"] = RemoveDD
  385. @Operation.action
  386. class Replace(Operation):
  387. def __init__(self, log_entry):
  388. super(Replace, self).__init__(log_entry)
  389. self.old_key = log_entry.parsed.get("old-key", None)
  390. if self.old_key is None:
  391. raise ParseError(log_entry, "commit without Old-Key field")
  392. self.new_key = log_entry.parsed.get("new-key", None)
  393. if self.new_key is None:
  394. raise ParseError(log_entry, "commit without New-Key field")
  395. self.uid = log_entry.parsed.get("username", None)
  396. def __str__(self):
  397. return "Replace"
  398. @classmethod
  399. def from_log_entry(cls, log_entry):
  400. return cls(log_entry)
  401. def ops(self):
  402. uid_person = None
  403. if self.uid is not None:
  404. try:
  405. uid_person = bmodels.Person.objects.get(uid=self.uid)
  406. except bmodels.Person.DoesNotExist:
  407. pass
  408. try:
  409. old_person = bmodels.Person.objects.get(fprs__fpr=self.old_key, fprs__is_active=True)
  410. except bmodels.Person.DoesNotExist:
  411. old_person = None
  412. try:
  413. new_person = bmodels.Person.objects.get(fprs__fpr=self.new_key, fprs__is_active=True)
  414. except bmodels.Person.DoesNotExist:
  415. new_person = None
  416. if old_person is None and new_person is None and uid_person is None:
  417. raise OperationError(self.log_entry, "cannot find existing person for key replace")
  418. if uid_person is not None:
  419. if old_person is not None and uid_person != old_person:
  420. raise OperationError(self.log_entry, "commit matches person {} by uid {} and person {} by old fingerprint {}".format(
  421. uid_person.lookup_key, uid_person.uid, old_person.lookup_key, old_person.fpr))
  422. if new_person is not None and uid_person != new_person:
  423. raise OperationError(self.log_entry, "commit matches person {} by uid {} and person {} by new fingerprint {}".format(
  424. uid_person.lookup_key, uid_person.uid, new_person.lookup_key, new_person.fpr))
  425. # Now, if uid_person is set, it can either:
  426. # - match old_person
  427. # - match new_person
  428. # - identify the old person when old_person is None and new_person is None
  429. if old_person is not None and new_person is not None:
  430. if old_person != new_person:
  431. raise OperationError(self.log_entry, "commit reports a key change from {} to {}, but the keys belong to two different people ({} and {})".format(
  432. self.old_key, self.new_key, old_person.lookup_key, new_person.lookup_key))
  433. else:
  434. raise OperationError(self.log_entry, "commit reports a key change from {} to {}, but both fingerprints match person {}".format(
  435. self.old_key, self.new_key, new_person.lookup_key))
  436. # Now either old_person is set or new_person is set, or both are unset
  437. # and uid_person is set
  438. if new_person is not None:
  439. # Already replaced
  440. #log.info("%s: %s already has the new key: skipping key replace", self.logtag, self.person_link(new_person))
  441. return
  442. # Perform replace
  443. person = old_person if old_person is not None else uid_person
  444. if self.rt:
  445. audit_notes = "GPG key changed, RT #{}".format(self.rt)
  446. else:
  447. audit_notes = "GPG key changed, RT unknown"
  448. #person.fprs.create(fpr=self.new_key, is_active=True, audit_author=self.author, audit_notes=audit_notes)
  449. yield bops.ChangeFingerprint(
  450. person=person, fpr=self.new_key,
  451. audit_author=self.author, audit_notes=audit_notes)
  452. #log.info("%s: %s: %s", self.logtag, self.person_link(person), audit_notes)