diff --git a/module.nix b/module.nix index 06981d1..d2ce235 100644 --- a/module.nix +++ b/module.nix @@ -48,6 +48,20 @@ in The user the service will use. ''; }; + + globalCacheTTL = lib.mkOption { + type = types.nullOr types.int; + default = null; + description = '' + How long should nix store narinfo files. + + If not defined, the module will not reconfigure the entry. + If it is defined, this will define how many seconds a cache entry will + be stored. + + By default not given, as it affects the UX of the nix installation. + ''; + } }; }; @@ -120,6 +134,11 @@ in binaryCachePublicKeys = lib.mkIf (cfg.publicKeyFile != null) [ (builtins.readFile cfg.publicKeyFile) ]; + + extraOptions = lib.mkIf (cfg.globalCacheTTL != null) '' + narinfo-cache-negative-ttl ${cfg.globalCacheTTL} + narinfo-cache-positive-ttl ${cfg.globalCacheTTL} + ''; }; networking.firewall = lib.mkIf (cfg.openFirewall) { diff --git a/peerix/__main__.py b/peerix/__main__.py index 49355f9..603bc3f 100644 --- a/peerix/__main__.py +++ b/peerix/__main__.py @@ -1,19 +1,36 @@ +import os import logging import asyncio +import argparse import uvloop from hypercorn import Config from hypercorn.asyncio import serve -from peerix.app import app +from peerix.app import app, setup_stores +parser = argparse.ArgumentParser(description="Peerix nix binary cache.") +parser.add_argument("--verbose", action="store_const", const=logging.DEBUG, default=logging.INFO, dest="loglevel") +parser.add_argument("--port", default=12304, type=int) +parser.add_argument("--private-key", required=False) + def run(): - logging.basicConfig() + args = parser.parse_args() + os.environ["NIX_SECRET_KEY_FILE"] = os.path.abspath(os.path.expanduser(args.private_key)) + + logging.basicConfig(level=args.loglevel) uvloop.install() + + asyncio.run(main(args.port)) + + +async def main(port: int): config = Config() - config.bind = ["0.0.0.0:12304"] - asyncio.run(serve(app, config)) + config.bind = [f"0.0.0.0:{port}"] + + async with setup_stores(port): + await serve(app, config) if __name__ == "__main__": diff --git a/peerix/app.py b/peerix/app.py index 7475af9..5182978 100644 --- a/peerix/app.py +++ b/peerix/app.py @@ -1,4 +1,5 @@ import logging +import datetime import contextlib from starlette.requests import Request @@ -12,27 +13,18 @@ from peerix.prefix import PrefixStore @contextlib.asynccontextmanager -async def _setup_stores(local_port: int): +async def setup_stores(local_port: int): global l_access, r_access async with local() as l: l_access = PrefixStore("local/nar", l) lp = PrefixStore("local", l) async with remote(lp, local_port, "0.0.0.0", lp.prefix) as r: - r_access = PrefixStore("remote", r) + r_access = PrefixStore("v2/remote", r) yield -setup_store = _setup_stores(12304) app = Starlette() -@app.on_event("startup") -async def _setup_stores_init(): - await setup_store.__aenter__() - -@app.on_event("shutdown") -async def _setup_stores_deinit(): - await setup_store.__aexit__(None, None, None) - @app.route("/nix-cache-info") async def cache_info(_: Request) -> Response: @@ -43,10 +35,14 @@ async def cache_info(_: Request) -> Response: @app.route("/{hash:str}.narinfo") async def narinfo(req: Request) -> Response: + if req.client.host != "127.0.0.1": return Response(content="Permission denied.", status_code=403) + # We do not cache nar-infos. + # Therefore, dynamically recompute expires at. ni = await r_access.narinfo(req.path_params["hash"]) + if ni is None: return Response(content="Not found", status_code=404) @@ -62,9 +58,18 @@ async def access_narinfo(req: Request) -> Response: @app.route("/local/nar/{path:str}") async def push_nar(req: Request) -> Response: - return StreamingResponse(l_access.nar(f"local/nar/{req.path_params['path']}"), media_type="text/plain") + try: + return StreamingResponse( + await l_access.nar(f"local/nar/{req.path_params['path']}"), + media_type="text/plain" + ) + except FileNotFoundError: + return Response(content="Gone", status_code=404) - -@app.route("/remote/{path:path}") +# Paths must be versioned as nix is caching the NAR urls. +@app.route("/v2/remote/{path:path}") async def pull_nar(req: Request) -> Response: - return StreamingResponse(r_access.nar(f"remote/{req.path_params['path']}"), media_type="text/plain") + try: + return StreamingResponse(await r_access.nar(f"remote/{req.path_params['path']}"), media_type="text/plain") + except FileNotFoundError: + return Response(content="Gone", status_code=404) diff --git a/peerix/local.py b/peerix/local.py index 76ae016..10ec788 100644 --- a/peerix/local.py +++ b/peerix/local.py @@ -67,13 +67,20 @@ class LocalStore(Store): info = NarInfo.parse(await resp.text()) return info._replace(url=base64.b64encode(info.storePath.encode("utf-8")).replace(b"/", b"_").decode("ascii")+".nar") - async def nar(self, sp: str) -> t.AsyncIterable[bytes]: + async def nar(self, sp: str) -> t.Awaitable[t.AsyncIterable[bytes]]: if sp.endswith(".nar"): sp = sp[:-4] path = base64.b64decode(sp.replace("_", "/")).decode("utf-8") if not path.startswith((await self.cache_info()).storeDir): raise FileNotFoundError() + if not os.path.exists(path): + raise FileNotFoundError() + + return self._nar_pull(path) + + async def _nar_pull(self, path: str) -> t.AsyncIterable[bytes]: + logger.info(f"Serving {path}") process = await asyncio.create_subprocess_exec( nix, "dump-path", "--", path, stdout=subprocess.PIPE, @@ -85,11 +92,19 @@ class LocalStore(Store): while not process.stdout.at_eof(): yield await process.stdout.read(10*1024*1024) + logger.debug(f"Served {path}") + try: + process.terminate() + except ProcessLookupError: + pass + @contextlib.asynccontextmanager async def local(): with tempfile.TemporaryDirectory() as tmpdir: sock = f"{tmpdir}/server.sock" + + logger.info("Launching nix-serve.") process = await asyncio.create_subprocess_exec( nix_serve, "--listen", sock, stdin=subprocess.DEVNULL, @@ -108,5 +123,10 @@ async def local(): async with aiohttp.ClientSession(connector_owner=True, connector=connector) as session: yield LocalStore(session) finally: - process.terminate() + try: + process.terminate() + except ProcessLookupError: + pass + + logger.info("nix-serve exited.") diff --git a/peerix/prefix.py b/peerix/prefix.py index 9f0359d..64b8243 100644 --- a/peerix/prefix.py +++ b/peerix/prefix.py @@ -16,10 +16,9 @@ class PrefixStore(Store): return None return info._replace(url=f"{self.prefix}/{info.url}") - async def nar(self, path: str) -> t.AsyncIterable[bytes]: + def nar(self, path: str) -> t.Awaitable[t.AsyncIterable[bytes]]: if not path.startswith(self.prefix + "/"): raise FileNotFoundError("Not found.") - async for chunk in self.backend.nar(path[len(self.prefix)+1:]): - yield chunk + return self.backend.nar(path[len(self.prefix)+1:]) diff --git a/peerix/remote.py b/peerix/remote.py index f7483e0..b64c905 100644 --- a/peerix/remote.py +++ b/peerix/remote.py @@ -126,17 +126,41 @@ class DiscoveryProtocol(asyncio.DatagramProtocol, Store): return info = NarInfo.parse(await resp.text()) - return info._replace(url = f"{addr[0]}/{port}/{info.url}") + return info._replace(url = f"{addr[0]}/{port}/{hsh}/{info.url}") - async def nar(self, sp: str) -> t.AsyncIterable[bytes]: - addr1, addr2, p = sp.split("/", 2) - async with self.session.get(f"http://{addr1}:{addr2}/{p}") as resp: - if resp.status != 200: - raise FileNotFoundError("Not found.") + async def nar(self, sp: str) -> t.Awaitable[t.AsyncIterable[bytes]]: + try: + return await self._nar_req(sp) + except FileNotFoundError: + addr1, addr2, hsh, _ = sp.split("/", 2) + logging.warn(f"Remote({addr1}:{addr2})-store path is dead: {sp}") + pass + + _, _, hsh, _ = sp.split("/", 2) + narinfo = await self.narinfo(hsh) + if narinfo is None: + logging.warn(f"All sources are gone.") + raise FileNotFoundError() + + return await self._nar_req(narinfo.url) + + async def _nar_req(self, url: str) -> t.Awaitable[t.AsyncIterable[bytes]]: + addr1, addr2, _, p = url.split("/", 2) + resp = await self.session.get(f"http://{addr1}:{addr2}/{p}") + if resp.status == 200: + return self._nar_direct(resp) + else: + raise FileNotFoundError() + + + async def _nar_direct(self, resp: aiohttp.ClientResponse) -> t.AsyncIterable[bytes]: + try: content = resp.content while not content.at_eof(): yield await content.readany() - + finally: + resp.close() + await resp.wait_for_close() @contextlib.asynccontextmanager diff --git a/peerix/store.py b/peerix/store.py index 2c2f268..2a4a8ce 100644 --- a/peerix/store.py +++ b/peerix/store.py @@ -86,6 +86,6 @@ class Store: async def narinfo(self, hsh: str) -> t.Optional[NarInfo]: raise NotImplementedError() - async def nar(self, url: str) -> t.AsyncIterable[bytes]: + def nar(self, url: str) -> t.Awaitable[t.AsyncIterable[bytes]]: raise NotImplementedError()