mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 20:15:10 +00:00
feat: identify the running code by source hash
This commit is contained in:
@@ -257,3 +257,6 @@ __marimo__/
|
|||||||
|
|
||||||
# Test fixtures with private/local content (services, IDs, credentials)
|
# Test fixtures with private/local content (services, IDs, credentials)
|
||||||
tests/remote/e2e/fixtures/fixtures.yaml
|
tests/remote/e2e/fixtures/fixtures.yaml
|
||||||
|
|
||||||
|
# Maintainer-side scripts, kept local
|
||||||
|
tools/
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ Health check. **This is the only route exempt from authentication**: you can cal
|
|||||||
{
|
{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"version": "5.3.0",
|
"version": "5.3.0",
|
||||||
"commit": "a24c9b9",
|
"code_hash": "1d22a1e",
|
||||||
"update_check": {
|
"update_check": {
|
||||||
"update_available": false,
|
"update_available": false,
|
||||||
"current_version": "5.3.0",
|
"current_version": "5.3.0",
|
||||||
@@ -52,7 +52,7 @@ Health check. **This is the only route exempt from authentication**: you can cal
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`commit` is the short git commit of the running checkout. It is `null` when the server cannot read one (for example, an installed package with no `.git` directory, or a host without git).
|
`code_hash` fingerprints the framework source the server is running, so it identifies the code itself rather than the release. It is the same value for a git checkout and for an installed package built from that checkout, and it changes if anything under `unshackle/core`, `unshackle/commands`, `unshackle/utils`, or `unshackle/vaults` is edited. Service modules are not included. The value is `null` only when the source files cannot be read.
|
||||||
|
|
||||||
If the update check fails (for example, no network), `update_available` and `latest_version` are `null` while `current_version` still reports the installed version.
|
If the update check fails (for example, no network), `update_available` and `latest_version` are `null` while `current_version` still reports the installed version.
|
||||||
|
|
||||||
|
|||||||
@@ -74,9 +74,11 @@ For example, `unshackle_debug_EXAMPLE_20260703-142530.jsonl`. The `<service>` ta
|
|||||||
Every line is a self-contained JSON object. The first line of a session records the environment:
|
Every line is a self-contained JSON object. The first line of a session records the environment:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"timestamp":"2026-07-03T14:25:30.123456+00:00","session_id":"a1b2c3d4","level":"INFO","operation":"session_start","message":"Debug logging session started","context":{"unshackle_version":"5.3.0","unshackle_commit":"a24c9b9","python_version":"3.12.4 ...","platform":"Linux-6.18.5-x86_64","platform_system":"Linux","platform_release":"6.18.5"}}
|
{"timestamp":"2026-07-03T14:25:30.123456+00:00","session_id":"a1b2c3d4","level":"INFO","operation":"session_start","message":"Debug logging session started","context":{"unshackle_version":"5.3.0","unshackle_code_hash":"1d22a1e","python_version":"3.12.4 ...","platform":"Linux-6.18.5-x86_64","platform_system":"Linux","platform_release":"6.18.5"}}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Quote `unshackle_code_hash` in a bug report. It fingerprints the framework source you are running, so it tells us the exact code state even when you did not install from git. A hash that matches no release means the source was edited locally.
|
||||||
|
|
||||||
Subsequent lines describe operations: service calls, DRM licence requests, vault lookups, downloader progress, and errors. Common fields include:
|
Subsequent lines describe operations: service calls, DRM licence requests, vault lookups, downloader progress, and errors. Common fields include:
|
||||||
|
|
||||||
| Field | Meaning |
|
| Field | Meaning |
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Unit tests for the code fingerprint behind the banner and the /api/health `code_hash` field.
|
||||||
|
It runs at import time, so it must always return a string and must never raise. It must change when
|
||||||
|
the framework source changes and stay unchanged when anything else does."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import unshackle.core
|
||||||
|
from unshackle.core import __code_hash__, code_files, code_hash
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_hex_and_stable() -> None:
|
||||||
|
first = code_hash()
|
||||||
|
assert len(first) == 32
|
||||||
|
assert int(first, 16) >= 0
|
||||||
|
assert first == code_hash()
|
||||||
|
|
||||||
|
|
||||||
|
def test_exported_value_is_the_short_form() -> None:
|
||||||
|
assert __code_hash__ == code_hash()[:7]
|
||||||
|
|
||||||
|
|
||||||
|
def test_covers_the_framework_dirs_only() -> None:
|
||||||
|
rels = code_files()
|
||||||
|
assert "__main__.py" in rels
|
||||||
|
assert any(rel.startswith("core/") for rel in rels)
|
||||||
|
assert not any(rel.startswith("services/") for rel in rels)
|
||||||
|
assert not any("__pycache__" in rel for rel in rels)
|
||||||
|
assert all(rel.endswith(".py") for rel in rels)
|
||||||
|
assert rels == sorted(rels) # order must not depend on the filesystem
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_changes_the_hash() -> None:
|
||||||
|
rels = ["core/__init__.py", "commands/dl.py"]
|
||||||
|
blobs = {"core/__init__.py": b"a", "commands/dl.py": b"b"}
|
||||||
|
before = code_hash(files=rels, read=blobs.__getitem__)
|
||||||
|
|
||||||
|
blobs["commands/dl.py"] = b"b "
|
||||||
|
assert code_hash(files=rels, read=blobs.__getitem__) != before
|
||||||
|
|
||||||
|
|
||||||
|
def test_path_is_part_of_the_hash() -> None:
|
||||||
|
"""A rename with identical bytes must not look like the same code."""
|
||||||
|
same_bytes = {"core/a.py": b"x", "core/b.py": b"x"}
|
||||||
|
assert code_hash(files=["core/a.py"], read=same_bytes.__getitem__) != code_hash(
|
||||||
|
files=["core/b.py"], read=same_bytes.__getitem__
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_order_does_not_matter_to_the_caller() -> None:
|
||||||
|
"""code_files() sorts, so two orderings of the same set must agree once sorted."""
|
||||||
|
blobs = {"core/a.py": b"x", "core/b.py": b"y"}
|
||||||
|
assert code_hash(files=sorted(blobs), read=blobs.__getitem__) == code_hash(
|
||||||
|
files=sorted(reversed(list(blobs))), read=blobs.__getitem__
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unreadable_source_yields_empty_string() -> None:
|
||||||
|
def unreadable(rel: str) -> bytes:
|
||||||
|
raise PermissionError(rel)
|
||||||
|
|
||||||
|
assert code_hash(files=["core/__init__.py"], read=unreadable) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_unwalkable_source_yields_empty_string(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""A dir os.walk cannot enter must yield "", not a valid-looking hash of fewer files."""
|
||||||
|
(tmp_path / "__main__.py").write_bytes(b"")
|
||||||
|
monkeypatch.setattr(unshackle.core, "_PKG", tmp_path)
|
||||||
|
assert code_hash() == ""
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
"""Unit tests for the git commit probe behind the banner and the /api/health `commit` field.
|
|
||||||
The probe runs at import time, so it must always return a string and must never raise, whatever
|
|
||||||
state git and the checkout are in."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
import unshackle.core
|
|
||||||
from unshackle.core import _git_commit
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.unit
|
|
||||||
|
|
||||||
# Without a .git the probe returns before it runs git, so the subprocess stubs below prove nothing.
|
|
||||||
needs_checkout = pytest.mark.skipif(
|
|
||||||
not (Path(unshackle.core.__file__).parents[2] / ".git").exists(), reason="not running from a git checkout"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_returns_str_in_this_checkout() -> None:
|
|
||||||
assert isinstance(_git_commit(), str)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"error",
|
|
||||||
[
|
|
||||||
FileNotFoundError("git"),
|
|
||||||
PermissionError("git"),
|
|
||||||
subprocess.TimeoutExpired(cmd="git", timeout=2),
|
|
||||||
subprocess.SubprocessError("boom"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@needs_checkout
|
|
||||||
def test_swallows_subprocess_errors(monkeypatch: pytest.MonkeyPatch, error: Exception) -> None:
|
|
||||||
def raise_it(*args: object, **kwargs: object) -> None:
|
|
||||||
raise error
|
|
||||||
|
|
||||||
monkeypatch.setattr(subprocess, "run", raise_it)
|
|
||||||
assert _git_commit() == ""
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("returncode", "stdout", "expected"),
|
|
||||||
[
|
|
||||||
(0, "a24c9b9\n", "a24c9b9"),
|
|
||||||
(128, "", ""), # empty repo, corrupt repo, or a stale worktree pointer
|
|
||||||
(0, "", ""), # no output despite success
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@needs_checkout
|
|
||||||
def test_maps_git_result(monkeypatch: pytest.MonkeyPatch, returncode: int, stdout: str, expected: str) -> None:
|
|
||||||
completed = subprocess.CompletedProcess(args=["git"], returncode=returncode, stdout=stdout, stderr="noise")
|
|
||||||
monkeypatch.setattr(subprocess, "run", lambda *a, **k: completed)
|
|
||||||
assert _git_commit() == expected
|
|
||||||
+44
-15
@@ -1,24 +1,53 @@
|
|||||||
import subprocess
|
import hashlib
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
__version__ = "5.3.0"
|
__version__ = "5.3.0"
|
||||||
|
|
||||||
|
_PKG = Path(__file__).parent.parent
|
||||||
|
# Framework code only. Services are user-swappable, so they are not part of the identity.
|
||||||
|
_CODE_DIRS = ("core", "commands", "utils", "vaults")
|
||||||
|
|
||||||
def _git_commit() -> str:
|
|
||||||
root = Path(__file__).parents[2]
|
def _raise(error: OSError) -> None:
|
||||||
if not (root / ".git").exists():
|
raise error
|
||||||
return ""
|
|
||||||
|
|
||||||
|
def code_files() -> list[str]:
|
||||||
|
"""Framework source paths relative to the package root, in a platform-stable order."""
|
||||||
|
rels = ["__main__.py"]
|
||||||
|
for name in _CODE_DIRS:
|
||||||
|
for root, dirs, files in os.walk(_PKG / name, onerror=_raise):
|
||||||
|
dirs[:] = [d for d in dirs if d != "__pycache__"]
|
||||||
|
rel = os.path.relpath(root, _PKG)
|
||||||
|
rels.extend(os.path.join(rel, f).replace(os.sep, "/") for f in files if f.endswith(".py"))
|
||||||
|
return sorted(rels)
|
||||||
|
|
||||||
|
|
||||||
|
def code_hash(
|
||||||
|
files: Optional[list[str]] = None,
|
||||||
|
read: Optional[Callable[[str], bytes]] = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
md5 of the framework source. Identifies the running code, not the release it claims to be.
|
||||||
|
|
||||||
|
Pass `files` and `read` to digest a different byte source, such as blobs from git history
|
||||||
|
(see tools/resolve_code_hash.py). Returns "" when the source cannot be read.
|
||||||
|
"""
|
||||||
|
if read is None:
|
||||||
|
|
||||||
|
def read(rel: str) -> bytes:
|
||||||
|
return (_PKG / rel).read_bytes()
|
||||||
|
|
||||||
|
digest = hashlib.md5(usedforsecurity=False)
|
||||||
try:
|
try:
|
||||||
proc = subprocess.run(
|
for rel in code_files() if files is None else files:
|
||||||
["git", "rev-parse", "--short", "HEAD"],
|
digest.update(rel.encode())
|
||||||
cwd=root,
|
digest.update(read(rel))
|
||||||
capture_output=True,
|
except OSError:
|
||||||
text=True,
|
|
||||||
timeout=2,
|
|
||||||
)
|
|
||||||
except (OSError, subprocess.SubprocessError):
|
|
||||||
return ""
|
return ""
|
||||||
return proc.stdout.strip() if proc.returncode == 0 else ""
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
__commit__ = _git_commit()
|
__code_hash__ = code_hash()[:7]
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from rich.padding import Padding
|
|||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
from urllib3.exceptions import InsecureRequestWarning
|
from urllib3.exceptions import InsecureRequestWarning
|
||||||
|
|
||||||
from unshackle.core import __commit__, __version__
|
from unshackle.core import __code_hash__, __version__
|
||||||
from unshackle.core.commands import Commands
|
from unshackle.core.commands import Commands
|
||||||
from unshackle.core.config import config
|
from unshackle.core.config import config
|
||||||
from unshackle.core.console import ComfyRichHandler, console
|
from unshackle.core.console import ComfyRichHandler, console
|
||||||
@@ -63,7 +63,7 @@ def main(version: bool, debug: bool) -> None:
|
|||||||
r" ▀▀▀ ▀▀ █▪ ▀▀▀▀ ▀▀▀ · ▀ ▀ ·▀▀▀ ·▀ ▀.▀▀▀ ▀▀▀ ",
|
r" ▀▀▀ ▀▀ █▪ ▀▀▀▀ ▀▀▀ · ▀ ▀ ·▀▀▀ ·▀ ▀.▀▀▀ ▀▀▀ ",
|
||||||
style="ascii.art",
|
style="ascii.art",
|
||||||
),
|
),
|
||||||
f"v [repr.number]{__version__}[/]{f' ({__commit__})' if __commit__ else ''}"
|
f"v [repr.number]{__version__}[/]{f' (code hash {__code_hash__})' if __code_hash__ else ''}"
|
||||||
f" - © 2025-{datetime.now().year} - github.com/unshackle-dl/unshackle",
|
f" - © 2025-{datetime.now().year} - github.com/unshackle-dl/unshackle",
|
||||||
),
|
),
|
||||||
(1, 11, 1, 10),
|
(1, 11, 1, 10),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import click
|
|||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from aiohttp_swagger3 import SwaggerDocs, SwaggerInfo, SwaggerUiSettings
|
from aiohttp_swagger3 import SwaggerDocs, SwaggerInfo, SwaggerUiSettings
|
||||||
|
|
||||||
from unshackle.core import __commit__, __version__
|
from unshackle.core import __code_hash__, __version__
|
||||||
from unshackle.core.api.errors import APIError, APIErrorCode, build_error_response, handle_api_exception
|
from unshackle.core.api.errors import APIError, APIErrorCode, build_error_response, handle_api_exception
|
||||||
from unshackle.core.api.handlers import (
|
from unshackle.core.api.handlers import (
|
||||||
cancel_download_job_handler,
|
cancel_download_job_handler,
|
||||||
@@ -105,10 +105,10 @@ async def health(request: web.Request) -> web.Response:
|
|||||||
version:
|
version:
|
||||||
type: string
|
type: string
|
||||||
example: "2.0.0"
|
example: "2.0.0"
|
||||||
commit:
|
code_hash:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
example: "a24c9b9"
|
example: "1d22a1e"
|
||||||
update_check:
|
update_check:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -133,7 +133,7 @@ async def health(request: web.Request) -> web.Response:
|
|||||||
update_info = {"update_available": None, "current_version": __version__, "latest_version": None}
|
update_info = {"update_available": None, "current_version": __version__, "latest_version": None}
|
||||||
|
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"status": "ok", "version": __version__, "commit": __commit__ or None, "update_check": update_info}
|
{"status": "ok", "version": __version__, "code_hash": __code_hash__ or None, "update_check": update_info}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -787,7 +787,7 @@ class DebugLogger:
|
|||||||
"""Log the start of a new session with environment information."""
|
"""Log the start of a new session with environment information."""
|
||||||
import platform
|
import platform
|
||||||
|
|
||||||
from unshackle.core import __commit__, __version__
|
from unshackle.core import __code_hash__, __version__
|
||||||
|
|
||||||
self.log(
|
self.log(
|
||||||
level="INFO",
|
level="INFO",
|
||||||
@@ -795,7 +795,7 @@ class DebugLogger:
|
|||||||
message="Debug logging session started",
|
message="Debug logging session started",
|
||||||
context={
|
context={
|
||||||
"unshackle_version": __version__,
|
"unshackle_version": __version__,
|
||||||
"unshackle_commit": __commit__ or None,
|
"unshackle_code_hash": __code_hash__ or None,
|
||||||
"python_version": sys.version,
|
"python_version": sys.version,
|
||||||
"platform": platform.platform(),
|
"platform": platform.platform(),
|
||||||
"platform_system": platform.system(),
|
"platform_system": platform.system(),
|
||||||
|
|||||||
Reference in New Issue
Block a user