ODIAN Launcher: hub, installer, local stack and remembered sign-in (M2)

- Tauri 2 + Svelte 5 hub in ODIAN dark: patch notes and roadmap, install and
  update progress, local stack status, settings with DHP and MCP addresses.
- crates/odian-packs: Rust installer for pack format v1 (signed manifests,
  resumable verified downloads from a DHP or an ODIAN drive), byte-compatible
  with ODIAN_MCP_Server's Python odian_packs.
- Local stack on 127.0.0.1: Postgres -> Engine API -> tile server -> Odian.exe,
  one Job Object, clean shutdown; database snapshot restore on a fresh PC only.
- Keycloak sign-in through the DHP's MCP, remembered until Sign out with a
  launcher session that renews the Engine token silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kelorastus
2026-09-15 15:23:17 +02:00
commit b7f3061be3
102 changed files with 47611 additions and 0 deletions
+339
View File
@@ -0,0 +1,339 @@
"""Local stub of an ODIAN site for the odian-packs Rust end-to-end tests.
PYTHONPATH=<ODIAN_MCP_Server>/src python stub_site.py --root DIR [--no-empty-src]
Builds a small signed pack store under DIR with odian_packs itself. The layout is the
site store layout, which is also the ODIAN drive layout:
DIR/packs/versions/CURRENT
DIR/packs/versions/0.51.0/{manifest.json, notes.md, tiles/dark.test_ds.index.json}
DIR/packs/app/<sha256> DIR/packs/db/<snapshot>/...
DIR/tiles/dark/test_ds/<z>/<x>/<y><ext>, DIR/tiles/tiles.db
DIR/src/...
It then serves DIR on 127.0.0.1 (a free port) with ``odian_update.api.create_app`` and a
stub token validator (token ``good``), plus nginx-like Range routes for /packs/app,
/packs/db and /src that check the same Bearer token. It prints one JSON line (port, test
public key, token, layout) and serves until its stdin closes.
Test-only control routes (no token):
GET /_ctl/log every request seen: path, range
POST /_ctl/reset clear the log and all rules
POST /_ctl/fail?prefix=P&status=503&count=N&retry_after=0
POST /_ctl/cut?prefix=P&after=BYTES&count=N send BYTES of the body, then drop the connection
"""
import argparse
import hashlib
import json
import os
import re
import shutil
import socket
import sys
import threading
import urllib.parse
sys.dont_write_bytecode = True
VERSION = "0.51.0"
STYLE = "dark"
DATASET = "test_ds"
SNAPSHOT = "20260915T101500Z"
TOKEN = "good"
CHUNK = 1024 * 1024
TEST_KEY_RAW = bytes(range(32))
APP_FILES = {
"Odian/Odian.exe": 200 * 1024,
"Odian/assets/empty.conf": 0,
"Odian/data/big.bin": 3 * CHUNK + 17,
"Odian/données/carte été.png": 1000,
"api/odian_api.exe": 50 * 1024,
}
DB_FILES = [
("pre-data", None, "pre-data.dump", 5000),
("data", "public.t1", "data/public.t1.dump", 300 * 1024),
("post-data", None, "post-data.dump", 2048),
]
# (z, x, y, ext, size). Blocks (0,0,0), (3,0,0), (7,0,0), (7,1,0). The 8-byte tile is
# below MIN_TILE_BYTES and never journaled.
TILES = [
(0, 0, 0, ".png", 5000),
(3, 1, 2, ".png", 40),
(7, 1, 2, ".png", 700),
(7, 3, 2, ".jpg", 512),
(7, 3, 2, ".png", 9),
(7, 63, 63, ".pbf", 300 * 1024),
(7, 2, 2, ".png", 8),
(7, 64, 5, ".png", 20),
(7, 127, 63, ".png", 1000),
]
# Journaled AFTER the index is frozen, inside block (7,0,0): no install may contain it.
LATE_TILE = (7, 5, 5, ".png", 64)
SRC_FILES = {"osm_source/world_aeroway_v1.sqlite": 2 * CHUNK + 123, "osm_source/small.bin": 100}
EMPTY_SRC = "osm_source/empty.bin"
def blob(label, size):
return hashlib.shake_256(label.encode("utf-8")).digest(size) if size else b""
def write(path, data):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
f.write(data)
def write_json(path, obj):
"""Like odian_packs.cli._write_json: pretty, sorted, ASCII, trailing newline."""
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8", newline="\n") as f:
json.dump(obj, f, indent=2, sort_keys=True)
f.write("\n")
def join(root, rel):
return os.path.join(root, *rel.split("/"))
def build(root, empty_src):
from odian_packs import appstore, hashing, signing, tiles
from odian_packs import manifest as mf
packs = os.path.join(root, "packs")
tiles_root = os.path.join(root, "tiles")
src_root = os.path.join(root, "src")
stage = os.path.join(root, "stage")
for rel, size in APP_FILES.items():
write(join(os.path.join(stage, "app"), rel), blob("app:" + rel, size))
app = appstore.store_tree(os.path.join(stage, "app"), packs, workers=2)
shutil.rmtree(stage)
db_files = []
for section, table, rel, size in DB_FILES:
path_rel = f"db/{SNAPSHOT}/{rel}"
full = join(packs, path_rel)
write(full, blob("db:" + rel, size))
got_size, got_sha = hashing.sha256_file(full)
entry = {"section": section, "path": path_rel, "size": got_size, "sha256": got_sha}
if table:
entry["table"] = table
db_files.append(entry)
set_root = os.path.join(tiles_root, STYLE, DATASET)
journal = os.path.join(tiles_root, "tiles.db")
for z, x, y, ext, size in TILES:
write(os.path.join(set_root, str(z), str(x), f"{y}{ext}"), blob(f"tile:{z}/{x}/{y}{ext}", size))
tiles.import_tree(set_root, STYLE, DATASET, journal, workers=2)
index = tiles.build_index(journal, STYLE, DATASET)
version_dir = os.path.join(packs, "versions", VERSION)
index_name = mf.tile_index_name(STYLE, DATASET)
write_json(join(version_dir, index_name), index)
z, x, y, ext, size = LATE_TILE
write(os.path.join(set_root, str(z), str(x), f"{y}{ext}"), blob(f"tile:{z}/{x}/{y}{ext}", size))
tiles.import_tree(set_root, STYLE, DATASET, journal, workers=2)
src_items = dict(SRC_FILES)
if empty_src:
src_items[EMPTY_SRC] = 0
src_files = []
for rel, size in src_items.items():
full = join(src_root, rel)
write(full, blob("src:" + rel, size))
hashed = hashing.hash_chunks(full, CHUNK)
src_files.append({"path": rel, "size": hashed["size"], "chunks": hashed["chunks"],
"chunks_sha256": hashed["chunks_sha256"]})
manifest = {
"schema": 1, "product": "odian", "version": VERSION, "released_at": "2026-09-15T12:00:00Z",
"min_launcher": "0.1.0", "notes": "notes.md",
"app": app,
"db": {"schema_version": 0, "snapshot": SNAPSHOT, "files": db_files},
"tiles": {"block_shift": 6, "sets": [{
"style": STYLE, "dataset": DATASET, "journal_seq": index["journal_seq"],
"blocks": len(index["blocks"]), "files": index["files"], "bytes": index["bytes"],
"index": index_name, "index_sha256": tiles.index_sha256(index)}]},
"src": {"enabled": True, "chunk_bytes": CHUNK, "files": src_files},
}
signed = signing.sign(mf.validate(manifest), signing.private_key_from_raw(TEST_KEY_RAW))
write_json(os.path.join(version_dir, "manifest.json"), signed)
write(os.path.join(version_dir, "notes.md"), "# ODIAN 0.51.0\n\nPremier pack signé.\n".encode("utf-8"))
write(os.path.join(packs, "versions", "CURRENT"), f"{VERSION}\n".encode("ascii"))
def serve(root, info):
import uvicorn
from fastapi import Request
from fastapi.responses import Response
from odian_packs.manifest import ManifestError, safe_relpath
from odian_update.api import create_app
from odian_update.auth import AuthInvalid, Caller, bearer_token
from odian_update.config import Settings
class StubValidator:
async def validate(self, token):
if token != TOKEN:
raise AuthInvalid("invalid token")
return Caller(username="stub-operator", expires_at=0)
packs = os.path.join(root, "packs")
settings = Settings(packs_dir=packs, tiles_dir=os.path.join(root, "tiles"), max_streams=8)
app = create_app(settings, validator=StubValidator())
def serve_file(request: Request, base: str, rel: str) -> Response:
"""nginx `alias` + `auth_request`: token check, then the file, honouring one Range."""
if bearer_token(request.headers.get("authorization")) != TOKEN:
return Response(status_code=401, headers={"WWW-Authenticate": "Bearer"})
try:
safe_relpath(rel)
except ManifestError:
return Response(status_code=404)
path = join(base, rel)
if not os.path.isfile(path):
return Response(status_code=404)
with open(path, "rb") as f:
data = f.read()
size = len(data)
header = request.headers.get("range")
if not header:
return Response(data, media_type="application/octet-stream", headers={"Accept-Ranges": "bytes"})
match = re.fullmatch(r"bytes=(\d*)-(\d*)", header.strip())
unsatisfiable = Response(status_code=416, headers={"Content-Range": f"bytes */{size}"})
if not match or (not match.group(1) and not match.group(2)):
return unsatisfiable
if not match.group(1):
start, end = max(0, size - int(match.group(2))), size - 1
else:
start = int(match.group(1))
end = min(int(match.group(2)), size - 1) if match.group(2) else size - 1
if start >= size or start > end:
return unsatisfiable
return Response(data[start:end + 1], status_code=206, media_type="application/octet-stream",
headers={"Content-Range": f"bytes {start}-{end}/{size}", "Accept-Ranges": "bytes"})
@app.get("/packs/app/{sha}")
def app_file(sha: str, request: Request):
return serve_file(request, os.path.join(packs, "app"), sha)
@app.get("/packs/db/{rel:path}")
def db_file(rel: str, request: Request):
return serve_file(request, os.path.join(packs, "db"), rel)
@app.get("/src/{rel:path}")
def src_file(rel: str, request: Request):
return serve_file(request, os.path.join(root, "src"), rel)
lock = threading.Lock()
state = {"log": [], "fail": [], "cut": []}
async def reply(send, status, obj, headers=()):
body = json.dumps(obj).encode("utf-8")
await send({"type": "http.response.start", "status": status,
"headers": [(b"content-type", b"application/json"),
(b"content-length", str(len(body)).encode("ascii")), *headers]})
await send({"type": "http.response.body", "body": body})
async def control(scope, receive, send):
path = scope["path"]
query = {k: v[0] for k, v in urllib.parse.parse_qs(scope["query_string"].decode("latin-1")).items()}
with lock:
if path == "/_ctl/log":
return await reply(send, 200, {"requests": list(state["log"])})
if path == "/_ctl/reset":
state["log"].clear()
state["fail"].clear()
state["cut"].clear()
elif path == "/_ctl/fail":
state["fail"].append({"prefix": query["prefix"], "status": int(query.get("status", "503")),
"count": int(query.get("count", "1")), "retry_after": query.get("retry_after")})
elif path == "/_ctl/cut":
state["cut"].append({"prefix": query["prefix"], "after": int(query["after"]),
"count": int(query.get("count", "1"))})
else:
return await reply(send, 404, {"error": "unknown control"})
await reply(send, 200, {"ok": True})
def take_rule(kind, path):
for rule in state[kind]:
if rule["count"] > 0 and path.startswith(rule["prefix"]):
rule["count"] -= 1
return rule
return None
async def asgi(scope, receive, send):
if scope["type"] != "http":
return await app(scope, receive, send)
path = scope["path"]
if path.startswith("/_ctl/"):
return await control(scope, receive, send)
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope["headers"]}
with lock:
state["log"].append({"path": path, "range": headers.get("range")})
fail = take_rule("fail", path)
cut = None if fail else take_rule("cut", path)
if fail:
extra = [(b"retry-after", fail["retry_after"].encode("ascii"))] if fail["retry_after"] is not None else []
return await reply(send, fail["status"], {"error": "stub failure"}, extra)
if not cut:
return await app(scope, receive, send)
sent = 0
async def cutting_send(message):
nonlocal sent
if message["type"] != "http.response.body":
return await send(message)
body = message.get("body", b"")
room = cut["after"] - sent
if len(body) < room:
sent += len(body)
return await send(message)
await send({"type": "http.response.body", "body": body[:room], "more_body": True})
raise ConnectionAbortedError("stub cut the connection")
return await app(scope, receive, cutting_send)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
info.update(port=port, base_url=f"http://127.0.0.1:{port}")
print(json.dumps(info), flush=True)
def exit_when_stdin_closes():
try:
sys.stdin.buffer.read()
finally:
os._exit(0)
threading.Thread(target=exit_when_stdin_closes, daemon=True).start()
server = uvicorn.Server(uvicorn.Config(asgi, log_level="warning", lifespan="off", access_log=False))
server.run(sockets=[sock])
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--root", required=True)
parser.add_argument("--no-empty-src", action="store_true", help="leave out the zero-byte source file")
parser.add_argument("--build-only", action="store_true")
args = parser.parse_args()
root = os.path.abspath(args.root)
if not os.path.isfile(os.path.join(root, "packs", "versions", "CURRENT")):
build(root, empty_src=not args.no_empty_src)
from odian_packs import signing
info = {
"root": root, "token": TOKEN, "version": VERSION, "style": STYLE, "dataset": DATASET, "snapshot": SNAPSHOT,
"public_key": signing.public_key_text(signing.private_key_from_raw(TEST_KEY_RAW).public_key()),
}
if args.build_only:
print(json.dumps(info), flush=True)
return
serve(root, info)
if __name__ == "__main__":
main()