graph_new.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  1. #!/usr/bin/env python3
  2. """
  3. Generate an HTML page and PNG charts wih the age of packages in NEW
  4. To be run once a day.
  5. The tool stores persistent status in the RRD database and the pickeled queue
  6. in order to speed up daily runs.
  7. Directories and database files are created as needed.
  8. If the RRD directory is deleted the script rebuilds past years from the historical
  9. logs, and that can take a while, otherwise it just parses fresh data.
  10. """
  11. # Copyright 2020-2024 Federico Ceratto <federico@debian.org>
  12. # License: GPL-2+
  13. # Tests are in tests/test_graph_new.py
  14. from datetime import datetime, timedelta
  15. from glob import glob
  16. from textwrap import dedent
  17. from typing import Optional, Iterator
  18. from os import makedirs
  19. import pickle
  20. import os.path
  21. import sys
  22. import subprocess
  23. import rrdtool # debdeps: python3-rrdtool
  24. import apt_pkg
  25. from daklib import utils
  26. SECONDS_IN_DAY = 86400
  27. debug_mode = False
  28. def debug(msg: str) -> None:
  29. """Verbose messages for debugging"""
  30. if debug_mode:
  31. print(msg)
  32. def write_index(out_dir: str) -> None:
  33. """Generates index.html page in the output directory"""
  34. index_html = """
  35. <!DOCTYPE html>
  36. <html lang="en">
  37. <head>
  38. <meta charset="utf-8" />
  39. <link rel="shortcut icon" href="https://www.debian.org/favicon.ico" />
  40. <title>Debian NEW queue</title>
  41. </head>
  42. <body>
  43. <p>How much time a new package spends in the NEW queue.</p>
  44. <p>Divided in percentiles: 0 (aka age of the oldest package), 50 (aka median) and 99</p>
  45. <img src="new_queue_wait_time_all.png">
  46. <p></p>
  47. <img src="new_queue_wait_time_year.png">
  48. <p></p>
  49. <img src="new_queue_wait_time_month.png">
  50. <p></p>
  51. <p>Generated on {}</p>
  52. </body>
  53. </html>
  54. """
  55. fname = os.path.join(out_dir, "index.html")
  56. with open(fname, "w") as f:
  57. f.write(index_html.format(datetime.utcnow()))
  58. def init_rrd(rrdfn: str, t: datetime) -> None:
  59. """Initialize empty RRD"""
  60. print(f"RRD file {rrdfn} not found. Initializing it now.")
  61. rrdtool.create(
  62. rrdfn,
  63. "--start",
  64. t.strftime("%s"),
  65. "--step",
  66. str(SECONDS_IN_DAY), # 1 update per day
  67. "DS:p0:GAUGE:172800:U:U", # 0 percentile
  68. "DS:p50:GAUGE:172800:U:U", # 50 percentile
  69. "DS:p99:GAUGE:172800:U:U", # 99 percentile
  70. "RRA:AVERAGE:0.5:1:3650",
  71. )
  72. def gen_stats(t: datetime, q: dict) -> tuple:
  73. """Extract percentiles of package ages"""
  74. s = sorted(q.values())
  75. p99 = t - s[int(len(s) * 0.50)]
  76. p50 = t - s[int(len(s) * 0.05)]
  77. p0 = t - s[int(len(s) * 0.02)]
  78. return (p0, p50, p99)
  79. def plot_graph(
  80. out_dir: str, fname: str, rrdfn: str, start, end: datetime, title: str
  81. ) -> None:
  82. fname = os.path.join(out_dir, fname)
  83. print(f"Writing {fname}")
  84. args = (
  85. "--start",
  86. start.strftime("%s"),
  87. "--end",
  88. end.strftime("%s"),
  89. "--width",
  90. "900",
  91. "--height",
  92. "200",
  93. "--title",
  94. "Days spent in NEW over {}".format(title),
  95. "-v",
  96. "days",
  97. f"DEF:p0={rrdfn}:p0:AVERAGE",
  98. f"DEF:p50={rrdfn}:p50:AVERAGE",
  99. f"DEF:p99={rrdfn}:p99:AVERAGE",
  100. "LINE1:p0#FF8080:0 percentile",
  101. "LINE2:p50#8F8080:50 percentile",
  102. "LINE3:p99#8FFF80:99 percentile",
  103. )
  104. # print(" ".join(args))
  105. rrdtool.graph(fname, *args)
  106. def _handle_conf() -> tuple:
  107. """Load configuration parameters from Cnf"""
  108. Cnf = utils.get_conf()
  109. Arguments = [
  110. ("h", "help", "GraphNew::Options::Help"),
  111. ("r", "rrd", "GraphNew::Options::Rrd", "HasArg"),
  112. ("o", "outdir", "GraphNew::Options::Outdir", "HasArg"),
  113. ("b", "dak_base_dir", "Dir::Base", "HasArg"),
  114. ]
  115. apt_pkg.parse_commandline(Cnf, Arguments, sys.argv)
  116. try:
  117. Options = Cnf.subtree("GraphNew::Options")
  118. except KeyError:
  119. msg = dedent(
  120. """
  121. The GraphNew::Options subtree is missing from the config file
  122. Please use --rrd ... --outdir ... (see --help) or add:
  123. GraphNew
  124. {
  125. Options
  126. {
  127. Rrd "<RRD directory>";
  128. Outdir "<HTML output directory>";
  129. }
  130. }
  131. """
  132. )
  133. print(msg, file=sys.stderr)
  134. sys.exit(1)
  135. if "Help" in Options:
  136. usage()
  137. if "GraphNew::Options::Rrd" in Cnf:
  138. rrd_dir = Cnf["GraphNew::Options::Rrd"]
  139. elif "Dir::Rrd" in Cnf:
  140. rrd_dir = Cnf["Dir::Rrd"]
  141. else:
  142. print("No RRD directory configured.\n", file=sys.stderr)
  143. sys.exit(1)
  144. try:
  145. outdir = Cnf["GraphNew::Options::Outdir"]
  146. except Exception:
  147. print("No output directory configured\n", file=sys.stderr)
  148. sys.exit(1)
  149. log_dir = Cnf.get("Dir::Log")
  150. assert log_dir, "Dir::Log is missing from dak.conf or empty"
  151. return rrd_dir, outdir, log_dir
  152. def skip_file(fn: str, last_update) -> bool:
  153. """Skip files that are already processed"""
  154. if last_update is None:
  155. return False
  156. fn = os.path.split(fn)[1]
  157. basename, ext = os.path.splitext(fn)
  158. if ext == "":
  159. return False # always process last, uncompressed file
  160. filetime = datetime.strptime(basename, "%Y-%m")
  161. return filetime < last_update
  162. def extract_queue_events(
  163. log_dir: str, last_update: Optional[datetime]
  164. ) -> Iterator[tuple]:
  165. """Extracts NEW queue events from log files"""
  166. compressed_glob = os.path.join(log_dir, "20*xz")
  167. debug(f"Scanning for compressed logfiles using glob '{compressed_glob}'")
  168. compressed_fns = sorted(glob(compressed_glob))
  169. fns = compressed_fns[5:] # 5 oldest logfiles have no events
  170. current_fn = os.path.join(log_dir, datetime.utcnow().strftime("%Y-%m"))
  171. debug(f"Also adding uncompressed log {current_fn}")
  172. fns.append(current_fn)
  173. print("%d files to process" % len(fns))
  174. events_per_month_cnt = None
  175. for fn in fns:
  176. if skip_file(fn, last_update):
  177. debug(f"Skipping {fn}")
  178. continue
  179. if events_per_month_cnt is not None:
  180. debug(f"Events in the month: {events_per_month_cnt}")
  181. events_per_month_cnt = 0
  182. debug(f"Processing {fn}")
  183. cmd = (
  184. """xzgrep -h -e ACCEPT-TO-NEW -e "|NEW ACCEPT|" -e "|REJECT|" """
  185. f"""-e "|NEW REJECT|" -e "|Policy Queue " {fn}"""
  186. )
  187. data = subprocess.check_output(cmd, shell=True).decode()
  188. for line in data.splitlines():
  189. line = line.rstrip()
  190. if line.startswith("#"):
  191. continue
  192. try:
  193. # <timestamp> <stage> <actor> <event> ...
  194. ts, _, _, event, remaining = line.split("|", 4)
  195. except ValueError:
  196. continue
  197. if event.startswith("Policy Queue "):
  198. _, pname = remaining.split("|", 1)
  199. else:
  200. pname = remaining
  201. assert "|" not in pname, repr(line)
  202. assert len(ts) == 14
  203. event_time = datetime.strptime(ts, "%Y%m%d%H%M%S")
  204. events_per_month_cnt += 1
  205. yield pname, event, event_time
  206. def process_events(
  207. events, last_update: Optional[datetime], init_time: Optional[datetime], queue
  208. ) -> tuple:
  209. """Process logged events like ACCEPT-TO-NEW, ACCEPT, REJECT and
  210. update the RRD database accordingly"""
  211. glitch_cnt = 0
  212. processed_events_cnt = 0
  213. previous_day = None
  214. rrdtool_updates = []
  215. for pname, event, event_time in events:
  216. if last_update and last_update >= event_time:
  217. continue
  218. processed_events_cnt += 1
  219. if init_time is None:
  220. # Initialise RRD once
  221. i = event_time - timedelta(days=1)
  222. debug(f"Very first event at: {event_time}")
  223. init_time = i
  224. previous_day = event_time.date()
  225. pname = pname.rsplit(".", 1)[0]
  226. # Update queue dict
  227. exit_events = set(
  228. (
  229. "NEW ACCEPT",
  230. "NEW REJECT",
  231. "Policy Queue ACCEPT",
  232. "REJECT",
  233. "Policy Queue REJECT",
  234. )
  235. )
  236. if event == "ACCEPT-TO-NEW":
  237. queue[pname] = event_time
  238. elif event in exit_events:
  239. # Approved or rejected, exits NEW
  240. if pname in queue:
  241. queue.pop(pname)
  242. else:
  243. glitch_cnt += 1
  244. elif event == "ACCEPT":
  245. pass
  246. if event_time.date() != previous_day:
  247. # On day change run statistics, update RRD and queue.pickle file
  248. stats = gen_stats(event_time, queue)
  249. ux = event_time.strftime("%s")
  250. v = f"{ux}:{stats[0].days}:{stats[1].days}:{stats[2].days}"
  251. rrdtool_updates.append(v)
  252. previous_day = event_time.date()
  253. debug(f"glitches count: {glitch_cnt}")
  254. debug(f"processed events count: {processed_events_cnt}")
  255. debug(f"queue len: {len(queue)}")
  256. return rrdtool_updates, init_time
  257. def generate_output(rrdfn: str, out_dir: str, init_time) -> None:
  258. """Generate png charts and index.html in the output directory"""
  259. end_time = rrdtool.lastupdate(rrdfn)["date"]
  260. ofn = "new_queue_wait_time_all.png"
  261. plot_graph(out_dir, ofn, rrdfn, init_time, end_time, "all")
  262. start_time = end_time - timedelta(days=365)
  263. ofn = "new_queue_wait_time_year.png"
  264. plot_graph(out_dir, ofn, rrdfn, start_time, end_time, "one year")
  265. start_time = end_time - timedelta(days=30)
  266. ofn = "new_queue_wait_time_month.png"
  267. plot_graph(out_dir, ofn, rrdfn, start_time, end_time, "one month")
  268. write_index(out_dir)
  269. def main(verbose_debug=False) -> None:
  270. global debug_mode
  271. debug_mode = verbose_debug
  272. rrd_dir, out_dir, log_dir = _handle_conf()
  273. makedirs(rrd_dir, exist_ok=True)
  274. makedirs(out_dir, exist_ok=True)
  275. rrdfn = os.path.join(rrd_dir, "graph_new.rrd")
  276. queue_fn = os.path.join(rrd_dir, "graph_new.pickle")
  277. init_time: Optional[datetime] = None
  278. last_update: Optional[datetime] = None
  279. if os.path.isfile(rrdfn):
  280. init_time = datetime.fromtimestamp(rrdtool.first(rrdfn))
  281. last_update = rrdtool.lastupdate(rrdfn)["date"]
  282. with open(queue_fn, "rb") as f:
  283. queue = pickle.load(f)
  284. else:
  285. queue = {}
  286. events = tuple(extract_queue_events(log_dir, last_update))
  287. print(f"{len(events)} events to process")
  288. if events:
  289. debug(f"First event to process at: {events[0][2]}")
  290. rrdtool_updates, begin_time = process_events(events, last_update, init_time, queue)
  291. if rrdtool_updates:
  292. debug(f"First RRD update: {rrdtool_updates[0]}")
  293. with open(queue_fn, "wb") as f:
  294. pickle.dump(queue, f)
  295. if init_time is None:
  296. assert begin_time, "This happens only if no events are found at all"
  297. init_time = begin_time
  298. init_rrd(rrdfn, begin_time)
  299. for u in rrdtool_updates:
  300. rrdtool.update(rrdfn, u)
  301. generate_output(rrdfn, out_dir, init_time)
  302. def usage(exit_code=0) -> None:
  303. msg = dedent(
  304. """\
  305. Usage: dak graph-new
  306. Graphs the age of packages in the NEW queue.
  307. -h, --help show this help and exit.
  308. -r, --rrd=key Directory where rrd files to be updated are stored
  309. -o, --outdir=key Directory where the output is stored
  310. """
  311. )
  312. print(msg)
  313. sys.exit(exit_code)
  314. # Uncomment this for manual/debugging runs
  315. # if __name__ == "__main__":
  316. # main(verbose_debug=True)