This is an automated email from the ASF dual-hosted git repository.

juergbi pushed a commit to branch jbilleter/http-cache
in repository https://gitbox.apache.org/repos/asf/buildstream.git

commit 837da4ac151c3abf0e858bb8990ffe35c1b37591
Author: Jürg Billeter <[email protected]>
AuthorDate: Sat Sep 12 17:23:29 2026 +0200

    tests/testutils: Add HTTPCASServer
---
 tests/testutils/__init__.py        |  9 ++++-
 tests/testutils/artifactshare.py   | 18 +++++++++
 tests/testutils/http_cas_server.py | 78 ++++++++++++++++++++++++++++++++++++++
 3 files changed, 104 insertions(+), 1 deletion(-)

diff --git a/tests/testutils/__init__.py b/tests/testutils/__init__.py
index 03eb422ea..4885fefc6 100644
--- a/tests/testutils/__init__.py
+++ b/tests/testutils/__init__.py
@@ -19,7 +19,14 @@
 #           William Salmon <[email protected]>
 #
 
-from .artifactshare import create_artifact_share, create_split_share, 
assert_shared, assert_not_shared, ArtifactShare
+from .artifactshare import (
+    create_artifact_share,
+    create_artifact_and_http_share,
+    create_split_share,
+    assert_shared,
+    assert_not_shared,
+    ArtifactShare,
+)
 from .casd import casd_cache
 from .context import dummy_context
 from .element_generators import create_element_size
diff --git a/tests/testutils/artifactshare.py b/tests/testutils/artifactshare.py
index fcdbcb597..466ab8818 100644
--- a/tests/testutils/artifactshare.py
+++ b/tests/testutils/artifactshare.py
@@ -20,6 +20,8 @@ from contextlib import ExitStack, contextmanager
 from concurrent import futures
 from urllib.parse import urlparse
 
+from .http_cas_server import HTTPCASServer
+
 import grpc
 
 from buildstream._cas import CASCache
@@ -329,6 +331,22 @@ def create_split_share(directory1, directory2, *, 
quota=None):
         storage.close()
 
 
+@contextmanager
+def create_artifact_and_http_share(directory, *, quota=None):
+    share = ArtifactShare(directory, quota=quota)
+    try:
+        # Read-only access to the same CAS objects using a HTTP REST protocol
+        # https://github.com/buchgr/bazel-remote/
+        httpserver = HTTPCASServer(share.repodir)
+        httpserver.start()
+        try:
+            yield share, httpserver
+        finally:
+            httpserver.stop()
+    finally:
+        share.close()
+
+
 # create_dummy_artifact_share()
 #
 # Create a dummy artifact share that doesn't have any capabilities
diff --git a/tests/testutils/http_cas_server.py 
b/tests/testutils/http_cas_server.py
new file mode 100644
index 000000000..b5d77477e
--- /dev/null
+++ b/tests/testutils/http_cas_server.py
@@ -0,0 +1,78 @@
+#
+#  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 functools
+import os
+import re
+import threading
+from http.server import BaseHTTPRequestHandler, HTTPStatus, ThreadingHTTPServer
+
+
+class HTTPCASRequestHandler(BaseHTTPRequestHandler):
+    HASH_SIZE = 64  # SHA256 as hex digits
+
+    def __init__(self, directory, *args, **kwargs):
+        self.objdir = os.path.join(directory, "cas", "objects")
+        super().__init__(*args, **kwargs)
+
+    def do_GET(self):
+        self.process_get_or_head()
+
+    def do_HEAD(self):
+        self.process_get_or_head()
+
+    def process_get_or_head(self):
+        match = re.fullmatch(r"/cas/([0-9a-f]+)", self.path)
+        if match is None:
+            self.send_error(HTTPStatus.NOT_FOUND)
+            return
+        cas_hash = match.group(1)
+        if len(cas_hash) != self.HASH_SIZE:
+            self.send_error(HTTPStatus.NOT_FOUND)
+            return
+
+        try:
+            with open(os.path.join(self.objdir, cas_hash[:2], cas_hash[2:]), 
"rb") as f:
+                fs = os.fstat(f.fileno())
+
+                self.send_response(HTTPStatus.OK)
+                self.send_header("Content-Length", str(fs.st_size))
+                self.end_headers()
+
+                if self.command == "GET":
+                    shutil.copyfileobj(f, self.wfile)
+        except OSError:
+            self.send_error(HTTPStatus.NOT_FOUND)
+
+
+class HTTPCASServer(threading.Thread):
+    def __init__(self, directory):
+        super().__init__()
+        self.server = ThreadingHTTPServer(("127.0.0.1", 0), 
functools.partial(HTTPCASRequestHandler, directory))
+        self.started = False
+
+    def start(self):
+        self.started = True
+        super().start()
+
+    def run(self):
+        self.server.serve_forever()
+
+    def stop(self):
+        if not self.started:
+            return
+        self.server.shutdown()
+        self.join()
+
+    def base_url(self):
+        return "http://127.0.0.1:{}".format(self.server.server_port)

Reply via email to