diff --git a/pipeline-runner/pipeline-runner.conf b/pipeline-runner/pipeline-runner.conf index de4a447..d4acb69 100644 --- a/pipeline-runner/pipeline-runner.conf +++ b/pipeline-runner/pipeline-runner.conf @@ -1,5 +1,6 @@ { "port": 8000, "pipelines_dir": "./storage/pipelines/", - "logs_dir": "./storage/logs/" + "logs_dir": "./storage/logs/", + "web_root": "" } diff --git a/pipeline-runner/src/main.cpp b/pipeline-runner/src/main.cpp index 93823e0..a18bde1 100644 --- a/pipeline-runner/src/main.cpp +++ b/pipeline-runner/src/main.cpp @@ -41,10 +41,12 @@ int main(int argc, char * argv[]) { int port = getConfigInt(config, "port", 8000); PIString pipelinesDir = getConfigValue(config, "pipelines_dir", "./storage/pipelines/"); PIString logsDir = getConfigValue(config, "logs_dir", "./storage/logs/"); + PIString webRoot = getConfigValue(config, "web_root", ""); piCout << "Port: " << port; piCout << "Pipelines dir: " << pipelinesDir; piCout << "Logs dir: " << logsDir; + piCout << "Web root: " << (webRoot.isEmpty() ? "(not set)" : webRoot); // Handle signals PISignals::setSlot([](PISignals::Signal s) { @@ -55,7 +57,7 @@ int main(int argc, char * argv[]) { PISignals::grabSignals(PISignals::Interrupt); // Start server - Server server(static_cast(port), pipelinesDir, logsDir); + Server server(static_cast(port), pipelinesDir, logsDir, webRoot); server.start(); // Wait for exit diff --git a/pipeline-runner/src/server.cpp b/pipeline-runner/src/server.cpp index 5ad3080..7aacea2 100644 --- a/pipeline-runner/src/server.cpp +++ b/pipeline-runner/src/server.cpp @@ -2,14 +2,40 @@ #include "messageutils.h" +#include #include #include #include -Server::Server(ushort port, const PIString & pipelines_dir, const PIString & logs_dir) +namespace { +const PIMap StaticContentTypes{ + {"html", "text/html; charset=utf-8" }, + {"htm", "text/html; charset=utf-8" }, + {"css", "text/css; charset=utf-8" }, + {"js", "text/javascript; charset=utf-8" }, + {"mjs", "text/javascript; charset=utf-8" }, + {"json", "application/json; charset=utf-8"}, + {"png", "image/png" }, + {"gif", "image/gif" }, + {"jpeg", "image/jpeg" }, + {"jpg", "image/jpeg" }, + {"svg", "image/svg+xml; charset=utf-8" }, + {"ico", "image/x-icon" }, + {"webp", "image/webp" }, + {"txt", "text/plain; charset=utf-8" }, + {"pdf", "application/pdf" }, + {"woff", "font/woff" }, + {"woff2", "font/woff2" }, + {"ttf", "font/ttf" }, + {"otf", "font/otf" }, +}; +} // namespace + +Server::Server(ushort port, const PIString & pipelines_dir, const PIString & logs_dir, const PIString & web_root) : port_(port) , pipelines_dir_(pipelines_dir) , logs_dir_(logs_dir) + , web_root_(web_root) , runner_(pipelines_dir, logs_dir) { httpserver_ = new PIHTTPServer(); @@ -40,6 +66,11 @@ bool Server::start() { httpserver_->registerPath("/api/runs/{run_id}/status", PIHTTP::Method::Get, this, &Server::getRunStatus); httpserver_->registerPath("/api/runs/{run_id}/log", PIHTTP::Method::Get, this, &Server::getRunLog); + // Serve static files if web_root is configured + if (!web_root_.isEmpty()) { + serveStaticFiles(web_root_); + } + // Unhandled request handler httpserver_->registerUnhandled(this, &Server::unhandledRequest); @@ -48,6 +79,48 @@ bool Server::start() { return ok; } +void Server::serveStaticFiles(const PIString & webRoot) { + PIDir rootDir(webRoot); + piCout << "Serving static files from: " << rootDir.absolutePath(); + + const auto filelist = PIDir::allEntries(webRoot); + for (const auto & finfo: filelist) { + if (!finfo.isFile()) continue; + if (finfo.name().startsWith('.')) continue; + + PIString relPath = finfo.dir().removeAll(webRoot) + finfo.name(); + if (relPath.isEmpty() || relPath[0] != '/') { + relPath = "/" + relPath; + } + + PIString ext = finfo.extension().toLowerCase(); + PIString contentType = StaticContentTypes.value(ext, "application/octet-stream"); + + PIByteArray fileData = PIFile::readAll(finfo.path); + PIString etag = PIDigest::calculate(fileData, PIDigest::Type::BLAKE2s_128).toHex().quote(); + + piCout << " Registered: " << relPath << " (" << contentType << ", ETag: " << etag << ")"; + + auto handler = [fileData, contentType, etag](const PIHTTP::MessageConst & request) { + PIHTTP::MessageMutable msg; + msg.addHeader(PIHTTP::Header::ContentType, contentType); + msg.addHeader(PIHTTP::Header::CacheControl, "max-age=3600"); + msg.addHeader(PIHTTP::Header::ETag, etag); + + PIString clientEtag = request.headers().value(PIHTTP::Header::IfNoneMatch, PIString()); + if (clientEtag == etag) { + msg.setCode(PIHTTP::Code::NotModified); + return msg; + } + + msg.setBody(fileData); + return msg; + }; + + httpserver_->registerPath(relPath, PIHTTP::Method::Get, handler); + } +} + PIHTTP::MessageMutable Server::listPipelines(const PIHTTP::MessageConst & request) { piCout << "GET /api/pipelines"; PIVector pipelines = loadPipelines(pipelines_dir_); diff --git a/pipeline-runner/src/server.h b/pipeline-runner/src/server.h index fbf5bbe..76ef48a 100644 --- a/pipeline-runner/src/server.h +++ b/pipeline-runner/src/server.h @@ -13,12 +13,14 @@ class Server: public PIObject { PIOBJECT(Server) public: - Server(ushort port, const PIString & pipelines_dir, const PIString & logs_dir); + Server(ushort port, const PIString & pipelines_dir, const PIString & logs_dir, const PIString & web_root = PIString()); ~Server(); bool start(); private: + void serveStaticFiles(const PIString & web_root); + PIHTTP::MessageMutable listPipelines(const PIHTTP::MessageConst & request); PIHTTP::MessageMutable createPipeline(const PIHTTP::MessageConst & request); PIHTTP::MessageMutable deletePipeline(const PIHTTP::MessageConst & request); @@ -31,6 +33,7 @@ private: ushort port_; PIString pipelines_dir_; PIString logs_dir_; + PIString web_root_; PIHTTPServer * httpserver_; PipelineRunner runner_; }; diff --git a/pipeline-runner/tests/conftest.py b/pipeline-runner/tests/conftest.py index 3554eb4..f17c926 100644 --- a/pipeline-runner/tests/conftest.py +++ b/pipeline-runner/tests/conftest.py @@ -23,10 +23,11 @@ def get_free_port(): class PipelineServer: """Manages a pipeline-runner server instance for tests.""" - def __init__(self, port, work_dir, verbose=False): + def __init__(self, port, work_dir, verbose=False, web_root=None): self.port = port self.work_dir = work_dir self.verbose = verbose + self.web_root = web_root test_dir = os.path.dirname(os.path.abspath(__file__)) project_root = os.path.dirname(test_dir) @@ -37,6 +38,8 @@ class PipelineServer: "pipelines_dir": os.path.join(work_dir, "pipelines") + "/", "logs_dir": os.path.join(work_dir, "logs") + "/", } + if web_root: + config["web_root"] = web_root self.config_path = os.path.join(work_dir, "pipeline-runner.conf") with open(self.config_path, "w") as f: json.dump(config, f) diff --git a/pipeline-runner/tests/test_smoke.py b/pipeline-runner/tests/test_smoke.py index ff99458..cbe1db0 100644 --- a/pipeline-runner/tests/test_smoke.py +++ b/pipeline-runner/tests/test_smoke.py @@ -1,5 +1,13 @@ """Smoke tests for pipeline-runner REST API endpoints.""" +import json +import os +import shutil +import socket +import subprocess +import tempfile +import time + import pytest import requests @@ -24,3 +32,110 @@ def test_cors_headers(pipeline_server): """Check that CORS headers are present on responses.""" response = requests.get(f"{pipeline_server.base_url}/api/pipelines") assert response.headers.get("Access-Control-Allow-Origin") == "*" + + +def _get_free_port(): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.bind(("", 0)) + port = s.getsockname()[1] + s.close() + return port + + +@pytest.fixture(scope="module") +def static_file_server(): + """Fixture that starts a server with web_root configured for static file tests.""" + project_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + build_script = os.path.join(project_dir, "build.sh") + subprocess.check_call([build_script, "--fake"], cwd=project_dir) + + work_dir = tempfile.mkdtemp(prefix="pipeline-static-test-") + + web_root = os.path.join(work_dir, "web") + os.makedirs(web_root) + os.makedirs(os.path.join(web_root, "subdir")) + + html_content = "

Hello Static

" + with open(os.path.join(web_root, "index.html"), "w") as f: + f.write(html_content) + + css_content = "body { color: red; }" + with open(os.path.join(web_root, "style.css"), "w") as f: + f.write(css_content) + + js_content = "console.log('hello');" + with open(os.path.join(web_root, "app.js"), "w") as f: + f.write(js_content) + + nested_content = "nested file content" + with open(os.path.join(web_root, "subdir", "nested.txt"), "w") as f: + f.write(nested_content) + + port = _get_free_port() + + test_dir = os.path.dirname(os.path.abspath(__file__)) + from conftest import PipelineServer + server = PipelineServer(port, work_dir, web_root=web_root) + if not server.start(): + pytest.fail("Could not start pipeline-runner server with static files") + + yield server, web_root + + server.stop() + shutil.rmtree(work_dir, ignore_errors=True) + + +class TestStaticFiles: + def test_serve_html(self, static_file_server): + server, _ = static_file_server + resp = requests.get(f"{server.base_url}/index.html") + assert resp.status_code == 200 + assert "text/html" in resp.headers.get("Content-Type", "") + assert "

Hello Static

" in resp.text + assert "ETag" in resp.headers + + def test_serve_css(self, static_file_server): + server, _ = static_file_server + resp = requests.get(f"{server.base_url}/style.css") + assert resp.status_code == 200 + assert "text/css" in resp.headers.get("Content-Type", "") + assert resp.text == "body { color: red; }" + + def test_serve_js(self, static_file_server): + server, _ = static_file_server + resp = requests.get(f"{server.base_url}/app.js") + assert resp.status_code == 200 + assert "text/javascript" in resp.headers.get("Content-Type", "") + assert resp.text == "console.log('hello');" + + def test_serve_nested_file(self, static_file_server): + server, _ = static_file_server + resp = requests.get(f"{server.base_url}/subdir/nested.txt") + assert resp.status_code == 200 + assert "text/plain" in resp.headers.get("Content-Type", "") + assert resp.text == "nested file content" + + def test_etag_304(self, static_file_server): + server, _ = static_file_server + resp1 = requests.get(f"{server.base_url}/index.html") + assert resp1.status_code == 200 + etag = resp1.headers.get("ETag") + assert etag is not None + + resp2 = requests.get( + f"{server.base_url}/index.html", + headers={"If-None-Match": etag}, + ) + assert resp2.status_code == 304 + assert resp2.text == "" + + def test_cache_control_header(self, static_file_server): + server, _ = static_file_server + resp = requests.get(f"{server.base_url}/index.html") + assert resp.status_code == 200 + assert "max-age" in resp.headers.get("Cache-Control", "") + + def test_static_404(self, static_file_server): + server, _ = static_file_server + resp = requests.get(f"{server.base_url}/nonexistent.html") + assert resp.status_code == 404