summaryrefslogtreecommitdiff
path: root/doc/invocations-http-server/server.py
diff options
context:
space:
mode:
authorKlaus Aehlig <klaus.aehlig@huawei.com>2025-04-22 12:26:20 +0200
committerKlaus Aehlig <klaus.aehlig@huawei.com>2025-04-23 17:33:42 +0200
commitbfdeb85104770527eb455712ab2ca65730d722fd (patch)
treec04e26cd358d4c2bd67708e83bb34ac115c78b0e /doc/invocations-http-server/server.py
parent1173ce9021c36629a6f4d7b86eef848295b074ab (diff)
downloadjustbuild-bfdeb85104770527eb455712ab2ca65730d722fd.tar.gz
Add simple http server allowing to browse an invocation-log directory
Being able to browse through past invocations of the build tool can actually be useful and doing so in the browser is a way many users prefer. Therefore, add a small WSGI application (written in python, using werkzeug and jinja) serving a directory of invocation logs via http.
Diffstat (limited to 'doc/invocations-http-server/server.py')
-rwxr-xr-xdoc/invocations-http-server/server.py309
1 files changed, 309 insertions, 0 deletions
diff --git a/doc/invocations-http-server/server.py b/doc/invocations-http-server/server.py
new file mode 100755
index 00000000..940e03a1
--- /dev/null
+++ b/doc/invocations-http-server/server.py
@@ -0,0 +1,309 @@
+#!/usr/bin/env python3
+# Copyright 2025 Huawei Cloud Computing Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import jinja2
+import json
+import os
+import subprocess
+import werkzeug.exceptions
+import werkzeug.routing
+import werkzeug.utils
+
+from werkzeug.wrappers import Request, Response
+
+def core_config(conf):
+ new_conf = {}
+ for k, v in conf.items():
+ if v is not None:
+ new_conf[k] = v
+ return new_conf
+
+class HashIdentifierConverter(werkzeug.routing.BaseConverter):
+ regex = '[a-zA-Z0-9]{40,51}'
+
+class InvocationIdentifierConverter(werkzeug.routing.BaseConverter):
+ regex = '[-:_a-zA-Z0-9]{1,200}'
+
+class InvocationServer:
+ def __init__(self, logsdir, *,
+ just_mr = None,
+ graph = "graph.json",
+ profile = "profile.json",
+ meta = "meta.json"):
+ self.logsdir = logsdir
+ if just_mr is None:
+ self.just_mr = ["just-mr"]
+ else:
+ self.just_mr = just_mr
+ self.profile = profile
+ self.graph = graph
+ self.meta = meta
+ self.templatepath = os.path.join(os.path.dirname(__file__), "templates")
+ self.jinjaenv = jinja2.Environment(
+ loader=jinja2.FileSystemLoader(self.templatepath))
+ rule = werkzeug.routing.Rule
+ self.routingmap = werkzeug.routing.Map([
+ rule("/", methods=("GET",), endpoint="list_invocations"),
+ rule("/blob/<hashidentifier:blob>",
+ methods=("GET",),
+ endpoint="get_blob"),
+ rule("/tree/<hashidentifier:tree>",
+ methods=("GET",),
+ endpoint="get_tree"),
+ rule("/invocations/<invocationidentifier:invocation>",
+ methods=("GET",),
+ endpoint="get_invocation"),
+ ], converters=dict(
+ invocationidentifier=InvocationIdentifierConverter,
+ hashidentifier=HashIdentifierConverter,
+ ))
+
+ @Request.application
+ def __call__(self, request):
+ mapadapter = self.routingmap.bind_to_environ(request.environ)
+ try:
+ endpoint, args = mapadapter.match()
+ return getattr(self, "do_%s" % (endpoint,))(**args)
+ except werkzeug.exceptions.HTTPException as e:
+ return e
+
+ def render(self, templatename, params):
+ response = Response()
+ response.content_type = "text/html; charset=utf8"
+ template = self.jinjaenv.get_template(templatename)
+ response.data = template.render(params).encode("utf8")
+ return response
+
+ def do_list_invocations(self):
+ invocations = []
+ count = 0
+ entries = sorted(os.listdir(self.logsdir), reverse=True)
+ for e in entries:
+ profile = os.path.join(self.logsdir, e, self.profile)
+ if not os.path.exists(profile):
+ # only consider invocations that provide a profile; unfinished
+ # invocations as well as not build related ones (like
+ # install-cas) are not relevant.
+ continue
+ try:
+ with open(profile) as f:
+ profile_data = json.load(f)
+ except:
+ profile_data = {}
+ count += 1
+ target = profile_data.get("target")
+ config = profile_data.get("config", {})
+ invocation = {
+ "name": e,
+ "subcommand": profile_data.get("subcommand"),
+ "target": json.dumps(target) if target else None,
+ "config": json.dumps(core_config(config)),
+ "exit_code": profile_data.get('exit code', 0),
+ }
+ invocations.append(invocation)
+ if count >= 50:
+ break
+ return self.render("invocations.html",
+ {"invocations": invocations})
+
+ def process_failure(self, cmd, procobj, *, failure_kind=None):
+ params = {"stdout": None, "stderr": None, "failure_kind": failure_kind}
+ params["cmd"] = json.dumps(cmd)
+ params["exit_code"] = procobj.returncode
+ try:
+ params["stdout"] = procobj.stdout.decode('utf-8')
+ except:
+ pass
+ try:
+ params["stderr"] = procobj.stderr.decode('utf-8')
+ except:
+ pass
+ return self.render("failure.html", params)
+
+ def do_get_blob(self, blob):
+ cmd = self.just_mr + ["install-cas", "--remember", blob]
+ blob_data = subprocess.run(cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE)
+ if blob_data.returncode !=0:
+ return self.process_failure(cmd, blob_data)
+ try:
+ blob_content = blob_data.stdout.decode('utf-8')
+ except:
+ # Not utf-8, so return as binary file
+ response = Response()
+ response.content_type = "application/octet-stream"
+ response.data = blob_data.stdout
+ response.headers['Content-Disposition'] = \
+ "attachement; filename=%s" %(blob,)
+ return response
+ return self.render("blob.html",
+ {"name": blob,
+ "data": blob_content})
+
+ def do_get_tree(self, tree):
+ cmd = self.just_mr + ["install-cas", "%s::t" % (tree,)]
+ tree_data = subprocess.run(cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE)
+ if tree_data.returncode !=0:
+ return self.process_failure(cmd, tree_data)
+ try:
+ tree_info = json.loads(tree_data.stdout.decode('utf-8'))
+ except Exception as e:
+ return self.process_failure(
+ cmd, tree_data,
+ failure_kind = "Malformed output: %s" % (e,))
+
+ entries = []
+ for k, v in tree_info.items():
+ h, s, t = v.strip('[]').split(':')
+ entries.append({"path": k,
+ "hash": h,
+ "type": "tree" if t == 't' else "blob"})
+ return self.render("tree.html", {"name": tree,
+ "entries": entries})
+
+ def do_get_invocation(self, invocation):
+ params = {"invocation": invocation}
+
+ try:
+ with open(os.path.join(
+ self.logsdir, invocation, self.profile)) as f:
+ profile = json.load(f)
+ except:
+ profile = {}
+
+ try:
+ with open(os.path.join(
+ self.logsdir, invocation, self.meta)) as f:
+ meta = json.load(f)
+ except:
+ meta = {}
+
+ try:
+ with open(os.path.join(
+ self.logsdir, invocation, self.graph)) as f:
+ graph = json.load(f)
+ except:
+ graph = {}
+
+ params["repo_config"] = meta.get('configuration')
+ params["exit_code"] = profile.get('exit code')
+ # For complex conditional data fill with None as default
+ for k in ["cmdline", "cmd", "target", "config"]:
+ params[k] = None
+ # Fill this data, if available
+ if meta.get('cmdline'):
+ params["cmdline"] = json.dumps(meta.get('cmdline'))
+ if profile.get('subcommand'):
+ params["cmd"] = json.dumps(
+ [profile.get('subcommand')] + profile.get('subcommand args', []))
+ if profile.get('target'):
+ params["target"] = json.dumps(profile.get('target'))
+ if profile.get('configuration') is not None:
+ params["config"] = json.dumps(core_config(
+ profile.get('configuration')))
+
+ def action_data(name, profile_value):
+ data = { "name_prefix": "",
+ "name": name,
+ "exit_code": profile_value.get('exit code', 0)}
+ desc = graph.get('actions', {}).get(name, {})
+ data["may_fail"] = desc.get("may_fail")
+ data["stdout"] = profile_value.get('stdout')
+ data["stderr"] = profile_value.get('stderr')
+ data["output"] = desc.get('output', [])
+ data["output_dirs"] = desc.get('output_dirs', [])
+ if len(data["output"]) == 1:
+ data["primary_output"] = data["output"][0]
+ elif len(data["output"]) == 0 and len(data["output_dirs"]) == 1:
+ data["primary_output"] = data["output_dirs"][0]
+ else:
+ data["primary_output"] = None
+ data["artifacts"] = profile_value.get('artifacts', {})
+ origins = []
+ for origin in desc.get('origins', []):
+ origins.append("%s#%d@%s" % (
+ json.dumps(origin.get('target', [])),
+ origin.get('subtask', 0),
+ core_config(origin.get('config', {})),))
+ data["origins"] = origins
+ return data
+
+ failed_build_actions = []
+ failed_test_actions = []
+ for k, v in profile.get('actions', {}).items():
+ if v.get('exit code', 0) != 0:
+ if graph.get('actions', {}).get(k, {}).get('may_fail') != None:
+ failed_test_actions.append(action_data(k, v))
+ else:
+ failed_build_actions.append(action_data(k, v))
+ params["failed_actions"] = failed_build_actions + failed_test_actions
+
+ # longest running non-cached non-failed actions
+ candidates = []
+ for k, v in profile.get('actions', {}).items():
+ if not v.get('cached'):
+ if v.get('exit code', 0) == 0:
+ candidates.append((v.get('duration', 0.0), k, v))
+ candidates.sort(reverse=True)
+ non_cached = []
+ params["more_noncached"] = None
+ if len(candidates) > 30:
+ params["more_non_cached"] = len(candidates) - 30
+ candidates = candidates[:30]
+ for t, k, v in candidates:
+ action = action_data(k, v)
+ action["name_prefix"] = "%5.1fs" % (t,)
+ non_cached.append(action)
+ params["non_cached"] = non_cached
+
+ return self.render("invocation.html", params)
+
+if __name__ == '__main__':
+ import sys
+ from argparse import ArgumentParser
+ from wsgiref.simple_server import make_server
+ parser = ArgumentParser(
+ description="Serve invocations of a given directory")
+ parser.add_argument("--meta", dest="meta", default="meta.json",
+ help="Name of the logged metadata file")
+ parser.add_argument("--graph", dest="graph", default="graph.json",
+ help="Name of the logged action-graph file")
+ parser.add_argument("--profile", dest="profile", default="profile.json",
+ help="Name of the logged profile file")
+ parser.add_argument(
+ "--just-mr", dest="just_mr", default='["just-mr"]',
+ help="The correct way of invoking just-mr, as JSON vector of strings")
+ parser.add_argument("-i", dest="interface", default='127.0.0.1',
+ help="The interface to listen on")
+ parser.add_argument("-p", dest="port", default=9000, type=int,
+ help="The port to listen on")
+ parser.add_argument("DIR",
+ help="Directory (for a single project id) to serve")
+ args = parser.parse_args()
+ try:
+ just_mr = json.loads(args.just_mr)
+ except Exception as e:
+ print("just-mr argument should be valid json, but %r is not: %s"
+ % (args.just_mr, e), file=sys.stderr)
+ sys.exit(1)
+ app = InvocationServer(args.DIR,
+ just_mr=just_mr,
+ graph=args.graph,
+ meta=args.meta,
+ profile=args.profile)
+ make_server(args.interface, args.port, app).serve_forever()