b4021869aa08bba279a87fd7294fbe159425d35a
[~helmut/crossqa.git] / webapp.py
1 #!/usr/bin/python3
2 # SPDX-License-Identifier: GPL-2.0+
3
4 import datetime
5 import lzma
6
7 import apt_pkg
8 apt_pkg.init()
9 version_compare = apt_pkg.version_compare
10
11 import flask
12 import flask_sqlalchemy
13 import jinja2
14 import sqlalchemy
15 import werkzeug
16
17 app = flask.Flask("crossqa")
18 app.config["SQLALCHEMY_DATABASE_URI"] = 'sqlite:///db'
19 app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
20 db = flask_sqlalchemy.SQLAlchemy(app)
21
22 src_template = """<!DOCTYPE html>
23 <html>
24  <head>
25   <title>{{ sourcepackage|e }} - Debian cross build</title>
26   <style>
27 tr.dep.bad td:nth-child(1) {
28     background-color: #faa;
29 }
30 tr.dep.tempbad td:nth-child(1) {
31     background-color: #ffa;
32 }
33 tr.dep.good td:nth-child(1) {
34     background-color: #afa;
35 }
36 tr.build.bad td:nth-child(4) {
37     background-color: #faa;
38 }
39 tr.build.tempbad td:nth-child(4) {
40     background-color: #ffa;
41 }
42 tr.build.good td:nth-child(4) {
43     background-color: #afa;
44 }
45 th {
46     padding-left: 1em;
47     padding-right: 1em;
48 }
49 td {
50     padding-left: 1px;
51     padding-right: 1em;
52 }
53 td:last-child {
54     padding-right: 1px;
55 }
56   </style>
57  </head>
58  <body>
59   <header>
60    <h1>
61     <a href="https://tracker.debian.org/pkg/{{ sourcepackage|e }}">
62      {{- sourcepackage|e -}}
63     </a>
64    </h1>
65   </header>
66   <section>
67    <h3>Cross build dependency satisfiability</h3>
68    <table>
69     <thead>
70      <tr>
71       <th>state</th>
72       <th>architectures</th>
73      </tr>
74     </thead>
75     <tbody>
76      {%- set okarchs = depresult.pop(None, None) -%}
77      {%- for reason, archs in depresult.items()|sort -%}
78       <tr class="dep {{ "tempbad" if reason.startswith("skew") else "bad" }}">
79        <td>{{ reason|e }}</td>
80        <td>{{ archs|arch_format }}</td>
81       </tr>
82      {%- endfor -%}
83      {%- if okarchs -%}
84       <tr class="dep good">
85        <td>ok</td>
86        <td>{{ okarchs|arch_format }}</td>
87       </tr>
88      {%- endif -%}
89     </tbody>
90    </table>
91    <h5>See also</h5>
92    <ul>
93     {%- if show_bootstrapdn -%}
94      <li>
95       <a href="https://bootstrap.debian.net/cross_all/{{ sourcepackage|e }}.html">bootstrap.debian.net</a>
96      </li>
97     {%- endif -%}
98     <li>
99      <a href="https://qa.debian.org/dose/debcheck/cross_unstable_main_amd64/">debcheck</a>
100     </li>
101    </ul>
102   </section>
103   {%- if builds -%}
104    <section>
105     <h3>Cross builds</h3>
106     <table>
107      <thead>
108       <tr>
109        <th>started</th>
110        <th>version</th>
111        <th>architecture</th>
112        <th>result log</th>
113       </tr>
114      </thead>
115      <tbody>
116       {%- for build in builds|sort(attribute='starttime', reverse=true) -%}
117        <tr class="build {{ "good" if build.success else "bad" }}">
118         <td>
119          {{- build.starttime|sqltimestamp|formatts -}}
120         </td>
121         <td>{{ build.version|e }}</td>
122         <td>{{ build.architecture|e }}</td>
123         <td>
124          <a href="{{ url_for("show_log", filename=build.filename[:-3]) }}">
125           {{- "ok" if build.success else "failed" -}}
126          </a>
127          <a href="{{ url_for("show_log", filename=build.filename) }}">xz</a>
128         </td>
129        </tr>
130       {%- endfor -%}
131      </tbody>
132     </table>
133    </section>
134   {%- endif -%}
135  </body>
136 </html>
137 """
138
139 @app.template_filter("sqltimestamp")
140 def sqltimestamp_filter(s):
141     strptime = datetime.datetime.strptime
142     try:
143         return strptime(s, "%Y-%m-%d %H:%M:%S.%f").replace(microsecond=0)
144     except ValueError:
145         return strptime(s, "%Y-%m-%d %H:%M:%S")
146
147
148 def formatts(ts):
149     assert isinstance(ts, datetime.datetime)
150     dt = datetime.datetime.utcnow() - ts
151     if dt < datetime.timedelta(seconds=1):
152         return "now"
153     if dt < datetime.timedelta(seconds=100):
154         return "%d s" % dt.seconds
155     if dt < datetime.timedelta(minutes=100):
156         return "%d m" % (dt.seconds // 60)
157     if dt < datetime.timedelta(days=1):
158         return "%d h" % (dt.seconds // (60 * 60))
159     return "%d d" % dt.days
160
161
162 @app.template_filter("formatts")
163 def formatts_filter(ts):
164     return jinja2.Markup('<time title="%s" datetime="%s">%s</time>' %
165                          (ts, ts, formatts(ts)))
166
167 @app.template_filter('arch_format')
168 @jinja2.contextfilter
169 def arch_format_filter(context, some_archs):
170     if context["architectures"] == some_archs:
171         return "any"
172     return ", ".join(sorted(some_archs))
173
174 def collect_depstate(conn, source):
175     version = None
176     depstate = None
177     query = sqlalchemy.text("""
178         SELECT version, architecture, satisfiable, reason
179             FROM depstate WHERE source = :source;""")
180     for row in conn.execute(query, source=source):
181         if version is None or version_compare(version, row.version) > 0:
182             version = row.version
183             depstate = {}
184         depstate[row.architecture] = None if row.satisfiable else row.reason
185     if version is None:
186         raise werkzeug.exceptions.NotFound()
187     depresult = {}
188     for arch, reason in depstate.items():
189         depresult.setdefault(reason, set()).add(arch)
190     return version, depresult
191
192 @app.route("/src/<source>")
193 def show_source(source):
194     context = dict(sourcepackage=source)
195     with db.engine.connect() as conn:
196         query = sqlalchemy.text("SELECT architecture FROM depcheck;")
197         context["architectures"] = set(row[0] for row in conn.execute(query))
198         context["version"], context["depresult"] = collect_depstate(conn,
199                                                                     source)
200         query = sqlalchemy.text("""
201             SELECT version, architecture, success, starttime, filename
202                 FROM builds WHERE source = :source;""")
203         context["builds"] = list(conn.execute(query, source=source))
204         context["show_bootstrapdn"] = \
205                 any(reason and not reason.startswith("skew ")
206                     for reason in context["depresult"].keys())
207     return flask.render_template_string(src_template, **context)
208
209 @app.route("/build/<path:filename>")
210 def show_log(filename):
211     if filename.endswith(".xz"):
212         return flask.send_from_directory("logs", filename,
213                                          mimetype="application/octet-stream")
214     filename = flask.safe_join("logs", filename + ".xz")
215     try:
216         return flask.send_file(lzma.open(filename, "rb"),
217                                mimetype="text/plain")
218     except FileNotFoundError:
219         raise werkzeug.exceptions.NotFound()