eece8414a69af841d57a5ce042cb555e65ef2eb9
[~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 footer {
57     margin-top: 3em;
58     border-top: 1px solid;
59 }
60   </style>
61  </head>
62  <body>
63   <header>
64    <h1>
65     <a href="https://tracker.debian.org/pkg/{{ sourcepackage|e }}">
66      {{- sourcepackage|e -}}
67     </a>
68    </h1>
69   </header>
70   <section>
71    <h3>Cross build dependency satisfiability</h3>
72    <table>
73     <thead>
74      <tr>
75       <th>state</th>
76       <th>architectures</th>
77      </tr>
78     </thead>
79     <tbody>
80      {%- set okarchs = depresult.pop(None, None) -%}
81      {%- for reason, archs in depresult.items()|sort -%}
82       <tr class="dep {{ "tempbad" if reason.startswith("skew") else "bad" }}">
83        <td>{{ reason|e }}</td>
84        <td>{{ archs|arch_format }}</td>
85       </tr>
86      {%- endfor -%}
87      {%- if okarchs -%}
88       <tr class="dep good">
89        <td>ok</td>
90        <td>{{ okarchs|arch_format }}</td>
91       </tr>
92      {%- endif -%}
93     </tbody>
94    </table>
95    <h5>See also</h5>
96    <ul>
97     {%- if show_bootstrapdn -%}
98      <li>
99       <a href="https://bootstrap.debian.net/cross_all/{{ sourcepackage|e }}.html">bootstrap.debian.net</a>
100      </li>
101     {%- endif -%}
102     <li>
103      <a href="https://qa.debian.org/dose/debcheck/cross_unstable_main_amd64/">debcheck</a>
104     </li>
105    </ul>
106   </section>
107   {%- if builds -%}
108    <section>
109     <h3>Cross builds</h3>
110     <table>
111      <thead>
112       <tr>
113        <th>started</th>
114        <th>version</th>
115        <th>architecture</th>
116        <th>result log</th>
117       </tr>
118      </thead>
119      <tbody>
120       {%- for build in builds|sort(attribute='starttime', reverse=true) -%}
121        <tr class="build {{ "good" if build.success else "bad" }}">
122         <td>
123          {{- build.starttime|sqltimestamp|formatts -}}
124         </td>
125         <td>{{ build.version|e }}</td>
126         <td>{{ build.architecture|e }}</td>
127         <td>
128          <a href="{{ url_for("show_log", filename=build.filename[:-3]) }}">
129           {{- "ok" if build.success else "failed" -}}
130          </a>
131          <a href="{{ url_for("show_log", filename=build.filename) }}">xz</a>
132         </td>
133        </tr>
134       {%- endfor -%}
135      </tbody>
136     </table>
137    </section>
138   {%- endif -%}
139  </body>
140  <footer>
141   <h3>Details about this service</h3>
142   <ul>
143    <li>Maintainer: Helmut Grohne &lt;helmut@subdivi.de&gt;</li>
144    <li>Source: git://git.subdivi.de/~helmut/crossqa.git</li>
145   </ul>
146  </footer>
147 </html>
148 """
149
150 @app.template_filter("sqltimestamp")
151 def sqltimestamp_filter(s):
152     strptime = datetime.datetime.strptime
153     try:
154         return strptime(s, "%Y-%m-%d %H:%M:%S.%f").replace(microsecond=0)
155     except ValueError:
156         return strptime(s, "%Y-%m-%d %H:%M:%S")
157
158
159 def formatts(ts):
160     assert isinstance(ts, datetime.datetime)
161     dt = datetime.datetime.utcnow() - ts
162     if dt < datetime.timedelta(seconds=1):
163         return "now"
164     if dt < datetime.timedelta(seconds=100):
165         return "%d s" % dt.seconds
166     if dt < datetime.timedelta(minutes=100):
167         return "%d m" % (dt.seconds // 60)
168     if dt < datetime.timedelta(days=1):
169         return "%d h" % (dt.seconds // (60 * 60))
170     return "%d d" % dt.days
171
172
173 @app.template_filter("formatts")
174 def formatts_filter(ts):
175     return jinja2.Markup('<time title="%s" datetime="%s">%s</time>' %
176                          (ts, ts, formatts(ts)))
177
178 @app.template_filter('arch_format')
179 @jinja2.contextfilter
180 def arch_format_filter(context, some_archs):
181     if context["architectures"] == some_archs:
182         return "any"
183     return ", ".join(sorted(some_archs))
184
185 def collect_depstate(conn, source):
186     version = None
187     depstate = None
188     query = sqlalchemy.text("""
189         SELECT version, architecture, satisfiable, reason
190             FROM depstate WHERE source = :source;""")
191     for row in conn.execute(query, source=source):
192         if version is None or version_compare(version, row.version) > 0:
193             version = row.version
194             depstate = {}
195         depstate[row.architecture] = None if row.satisfiable else row.reason
196     if version is None:
197         raise werkzeug.exceptions.NotFound()
198     depresult = {}
199     for arch, reason in depstate.items():
200         depresult.setdefault(reason, set()).add(arch)
201     return version, depresult
202
203 @app.route("/src/<source>")
204 def show_source(source):
205     context = dict(sourcepackage=source)
206     with db.engine.connect() as conn:
207         query = sqlalchemy.text("SELECT architecture FROM depcheck;")
208         context["architectures"] = set(row[0] for row in conn.execute(query))
209         context["version"], context["depresult"] = collect_depstate(conn,
210                                                                     source)
211         query = sqlalchemy.text("""
212             SELECT version, architecture, success, starttime, filename
213                 FROM builds WHERE source = :source;""")
214         context["builds"] = list(conn.execute(query, source=source))
215         context["show_bootstrapdn"] = \
216                 any(reason and not reason.startswith("skew ")
217                     for reason in context["depresult"].keys())
218     return flask.render_template_string(src_template, **context)
219
220 @app.route("/build/<path:filename>")
221 def show_log(filename):
222     if filename.endswith(".xz"):
223         return flask.send_from_directory("logs", filename,
224                                          mimetype="application/octet-stream")
225     filename = flask.safe_join("logs", filename + ".xz")
226     try:
227         return flask.send_file(lzma.open(filename, "rb"),
228                                mimetype="text/plain")
229     except FileNotFoundError:
230         raise werkzeug.exceptions.NotFound()