diff options
| -rw-r--r-- | flake.nix | 110 | ||||
| -rw-r--r-- | pyproject.toml | 4 | ||||
| -rw-r--r-- | uv.lock | 29 | ||||
| -rw-r--r-- | web.py | 45 |
4 files changed, 147 insertions, 41 deletions
@@ -3,18 +3,28 @@ inputs = { nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; + pyproject-nix = { + url = "github:nix-community/pyproject.nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; }; outputs = { self, nixpkgs, + pyproject-nix, }: let system = "x86_64-linux"; pkgs = import nixpkgs {inherit system;}; + python = pkgs.python3; msci = (pkgs.writeScriptBin "msci" (builtins.readFile ./msci)).overrideAttrs (old: { buildCommand = "${old.buildCommand}\n patchShebangs $out"; }); + msci-web = pyproject-nix.lib.project.loadPyproject { + projectRoot = ./.; + }; + msci-web-attrs = msci-web.renderers.buildPythonPackage {inherit python;}; in { packages."${system}".msci = pkgs.symlinkJoin { name = "msci"; @@ -22,6 +32,8 @@ buildInputs = [pkgs.makeWrapper]; postBuild = "wrapProgram $out/bin/msci --prefix PATH : $out/bin"; }; + packages."${system}".msci-web = + python.pkgs.buildPythonPackage msci-web-attrs; nixosModules.default = { lib, config, @@ -40,40 +52,86 @@ description = "data directory of makeshiftci"; }; createUser = mkEnableOption "create a non-root user"; + webUI = mkOption { + type = types.submodule { + options = { + enable = mkEnableOption "enable makeshiftci web UI"; + port = mkOption { + type = types.int; + default = 5000; + description = "port to run the web UI on"; + }; + timeout = mkOption { + type = types.int; + default = 3600; + description = "gunicorn timeout"; + }; + workers = mkOption { + type = types.int; + default = 4; + description = "number of gunicorn workers"; + }; + }; + }; + default = {}; + }; }; }; default = {}; }; }; - config = lib.mkIf cfg.enable { - environment = { - variables.MSCI_HOME = cfg.dataDir; - systemPackages = [self.packages."${system}".msci]; - }; - systemd.tmpfiles.settings."makeshiftci" = { - "${cfg.dataDir}" = { - d = { - user = - if cfg.createUser - then "makeshiftci" - else "root"; - group = - if cfg.createUser - then "makeshiftci" - else "root"; - mode = "0750"; + config = lib.mkIf cfg.enable ({ + environment = { + variables.MSCI_HOME = cfg.dataDir; + systemPackages = [self.packages."${system}".msci]; + }; + systemd.tmpfiles.settings."makeshiftci" = { + "${cfg.dataDir}" = { + d = { + user = + if cfg.createUser + then "makeshiftci" + else "root"; + group = + if cfg.createUser + then "makeshiftci" + else "root"; + mode = "0750"; + }; }; }; - }; - services.cron.enable = true; - users = lib.mkIf cfg.createUser { - users."makeshiftci" = { - group = "makeshiftci"; - home = cfg.dataDir; - useDefaultShell = true; + services.cron.enable = true; + users = lib.mkIf cfg.createUser { + users."makeshiftci" = { + group = "makeshiftci"; + home = cfg.dataDir; + useDefaultShell = true; + }; }; - }; - }; + } + // lib.mkIf cfg.webUI.enable { + systemd.services.makeshiftci-web = { + wantedBy = ["multi-user.target"]; + unitConfig.ConditionUser = + if cfg.createUser + then "makeshiftci" + else "root"; + serviceConfig = let + webui = pkgs.python3.withPackages (p: + with p; [ + gunicorn + (callPackage self.packages."${system}".msci-web {}) + ]); + in { + ExecStart = + "${webui}/bin/gunicorn " + + "-w ${cfg.webUI.workers} " + + "--timeout ${cfg.webUI.timeout} " + + "-b 127.0.0.1:${cfg.webUI.port} " + + "'web:create_app()'"; + }; + }; + }); }; }; } diff --git a/pyproject.toml b/pyproject.toml index 68655cf..08f07c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,3 +7,7 @@ requires-python = ">=3.12" dependencies = [ "flask>=3.1.3", ] +[dependency-groups] +dev = [ + "gunicorn>=26.0.0", +] @@ -50,6 +50,18 @@ wheels = [ ] [[package]] +name = "gunicorn" +version = "26.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009, upload-time = "2026-05-05T06:38:23.007Z" }, +] + +[[package]] name = "itsdangerous" version = "2.2.0" source = { registry = "https://pypi.org/simple" } @@ -78,9 +90,17 @@ dependencies = [ { name = "flask" }, ] +[package.dev-dependencies] +dev = [ + { name = "gunicorn" }, +] + [package.metadata] requires-dist = [{ name = "flask", specifier = ">=3.1.3" }] +[package.metadata.requires-dev] +dev = [{ name = "gunicorn", specifier = ">=26.0.0" }] + [[package]] name = "markupsafe" version = "3.0.3" @@ -145,6 +165,15 @@ wheels = [ ] [[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] name = "werkzeug" version = "3.1.8" source = { registry = "https://pypi.org/simple" } @@ -13,20 +13,22 @@ from flask import ( stream_template, ) +MSCI_HOME = os.environ.get("MSCI_HOME") + def get_project(project: str) -> dict[str, Any]: - with open(f"./projects/{project}.json", "r") as p: + with open(f"{MSCI_HOME}/projects/{project}.json", "r") as p: return json.loads(p.read()) def get_run(project: str, run: int) -> str: - with open(f"./stdout/public/{project}/{run}", "r") as r: + with open(f"{MSCI_HOME}/stdout/public/{project}/{run}", "r") as r: return r.read() def stream_run(project: str, run: int): while True: - with open(f"./stdout/public/{project}/{run}", "r") as r: + with open(f"{MSCI_HOME}/stdout/public/{project}/{run}", "r") as r: txt = r.read() yield txt if "--MSCI_EXIT_" in txt: @@ -35,14 +37,14 @@ def stream_run(project: str, run: int): def _projects(q: queue.Queue[tuple[str, dict[str, Any]] | None]): - if os.path.isdir("./projects"): - _projects = os.listdir("./projects") + if os.path.isdir(f"{MSCI_HOME}/projects"): + _projects = os.listdir(f"{MSCI_HOME}/projects") for _p in _projects: loaded = get_project(_p.replace(".json", "")) if not loaded["hidden"]: q.put((_p.replace(".json", ""), loaded)) else: - os.mkdir("./projects") + os.mkdir(f"{MSCI_HOME}/projects") q.put(None) @@ -57,7 +59,7 @@ def projects(): def _project_runs(project: str, q: queue.Queue[dict[str, Any] | None]): - pr_stdout = f"./stdout/public/{project}" + pr_stdout = f"{MSCI_HOME}/stdout/public/{project}" if os.path.isdir(pr_stdout): _runs = sorted(os.listdir(pr_stdout), key=int) for _r in _runs: @@ -96,37 +98,43 @@ def project_runs(project: str): def project_exists(name: str): return ( - os.path.isfile(f"./projects/{name}.json") + os.path.isfile(f"{MSCI_HOME}/projects/{name}.json") and get_project(name)["hidden"] == False ) def run_exists(project: str, run: int): if project_exists(project): - if not os.path.isfile(f"./stdout/public/{project}/{run}"): + if not os.path.isfile(f"{MSCI_HOME}/stdout/public/{project}/{run}"): return False return True return False -app = Flask(__name__) +_app = Flask(__name__) +_empty = Flask(__name__) + + +@_empty.route("/", methods=["GET"]) +def ey(): + return "MSCI_HOME not set" -@app.route("/favicon.ico") +@_app.route("/favicon.ico") def favicon(): return send_from_directory( - app.root_path, + _app.root_path, "favicon.ico", mimetype="image/vnd.microsoft.icon", ) -@app.route("/", methods=["GET"]) +@_app.route("/", methods=["GET"]) def home(): return stream_template("home.html", projects=projects()) -@app.route("/<string:project>", methods=["GET"]) +@_app.route("/<string:project>", methods=["GET"]) def project(project: str): if project_exists(project): return stream_template( @@ -138,7 +146,7 @@ def project(project: str): abort(404) -@app.route("/<string:project>/<int:run>", methods=["GET"]) +@_app.route("/<string:project>/<int:run>", methods=["GET"]) def run(project: str, run: int): if run_exists(project, run): return stream_template( @@ -149,3 +157,10 @@ def run(project: str, run: int): run_number=run, ) abort(404) + + +def create_app(): + if not MSCI_HOME: + return _empty + else: + return _app |
