feat: add telemetry build provenance
Stamp official Docker builds with a low-cardinality provenance marker and report build_provenance/image_modified in anonymous telemetry. Local and fork builds default to custom, while official GitHub/GitLab release paths mark images as official.
This commit is contained in:
@@ -28,9 +28,11 @@ from unittest.mock import patch
|
||||
# Importing the webapp facade populates the runtime helpers we need.
|
||||
import bot.app.web.subscription_webapp # noqa: F401
|
||||
from bot.app.web.webapp import assets as assets_module
|
||||
from bot.utils import app_version as app_version_module
|
||||
|
||||
_VERSION_ENV_NAMES = (
|
||||
"REMNAWAVE_MINISHOP_VERSION",
|
||||
"REMNAWAVE_MINISHOP_BUILD_PROVENANCE",
|
||||
"REMNAWAVE_MINISHOP_BRANCH",
|
||||
"GIT_BRANCH",
|
||||
"BRANCH_NAME",
|
||||
@@ -42,6 +44,8 @@ _VERSION_ENV_NAMES = (
|
||||
def _reset_cache() -> None:
|
||||
# The resolver memoizes the first result in a module-level global.
|
||||
assets_module._APP_VERSION_CACHE = None # type: ignore[attr-defined]
|
||||
app_version_module._APP_VERSION_CACHE = None # type: ignore[attr-defined]
|
||||
app_version_module._APP_BUILD_PROVENANCE_CACHE = None # type: ignore[attr-defined]
|
||||
# Some callers reach through the facade re-export; clear that too.
|
||||
runtime = importlib.import_module("bot.app.web.webapp._runtime")
|
||||
runtime._APP_VERSION_CACHE = None # type: ignore[attr-defined]
|
||||
@@ -251,5 +255,55 @@ class CacheBehaviourTests(unittest.TestCase):
|
||||
self.assertEqual(first_calls, second_calls)
|
||||
|
||||
|
||||
class BuildProvenanceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
_reset_cache()
|
||||
self.addCleanup(_reset_cache)
|
||||
|
||||
def test_env_var_short_circuits_build_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
(Path(tmp) / ".build-provenance").write_text("custom", encoding="utf-8")
|
||||
env = _clean_version_env()
|
||||
env["REMNAWAVE_MINISHOP_BUILD_PROVENANCE"] = "official"
|
||||
with (
|
||||
patch.dict(os.environ, env, clear=True),
|
||||
patch.object(app_version_module, "APP_ROOT", Path(tmp)),
|
||||
):
|
||||
self.assertEqual(app_version_module.resolve_build_provenance(), "official")
|
||||
self.assertFalse(app_version_module.resolve_image_modified())
|
||||
|
||||
def test_reads_baked_build_provenance_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
(Path(tmp) / ".build-provenance").write_text("custom\n", encoding="utf-8")
|
||||
env = _clean_version_env()
|
||||
with (
|
||||
patch.dict(os.environ, env, clear=True),
|
||||
patch.object(app_version_module, "APP_ROOT", Path(tmp)),
|
||||
):
|
||||
self.assertEqual(app_version_module.resolve_build_provenance(), "custom")
|
||||
self.assertTrue(app_version_module.resolve_image_modified())
|
||||
|
||||
def test_missing_marker_defaults_to_custom(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
env = _clean_version_env()
|
||||
with (
|
||||
patch.dict(os.environ, env, clear=True),
|
||||
patch.object(app_version_module, "APP_ROOT", Path(tmp)),
|
||||
):
|
||||
self.assertEqual(app_version_module.resolve_build_provenance(), "custom")
|
||||
self.assertTrue(app_version_module.resolve_image_modified())
|
||||
|
||||
def test_legacy_boolean_aliases_are_normalized(self):
|
||||
env = _clean_version_env()
|
||||
env["REMNAWAVE_MINISHOP_BUILD_PROVENANCE"] = "true"
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
self.assertEqual(app_version_module.resolve_build_provenance(), "official")
|
||||
|
||||
_reset_cache()
|
||||
env["REMNAWAVE_MINISHOP_BUILD_PROVENANCE"] = "fork"
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
self.assertEqual(app_version_module.resolve_build_provenance(), "custom")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
unittest.main()
|
||||
|
||||
@@ -51,6 +51,8 @@ def test_build_payload_shape(settings):
|
||||
for key in (
|
||||
"app_version",
|
||||
"app_version_tag",
|
||||
"build_provenance",
|
||||
"image_modified",
|
||||
"os",
|
||||
"arch",
|
||||
"python_version",
|
||||
@@ -63,13 +65,44 @@ def test_build_payload_shape(settings):
|
||||
assert key in props, f"missing property: {key}"
|
||||
|
||||
assert isinstance(props["payment_providers"], list)
|
||||
assert props["build_provenance"] in {"official", "custom", "unknown"}
|
||||
assert isinstance(props["image_modified"], bool)
|
||||
# No DB session -> user count degrades to the smallest bucket.
|
||||
assert props["users_bucket"] == "0"
|
||||
# Person properties mirror the event properties so PostHog breakdowns work.
|
||||
assert props["$set"]["app_version"] == props["app_version"]
|
||||
assert props["$set"]["build_provenance"] == props["build_provenance"]
|
||||
assert props["$set"]["image_modified"] == props["image_modified"]
|
||||
assert props["$lib"] == "remnawave-minishop"
|
||||
|
||||
|
||||
def test_payload_marks_official_images_not_modified(settings, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"bot.services.telemetry_worker.resolve_build_provenance",
|
||||
lambda: "official",
|
||||
)
|
||||
monkeypatch.setattr("bot.services.telemetry_worker.resolve_image_modified", lambda: False)
|
||||
|
||||
worker = TelemetryWorker(settings, None)
|
||||
payload = asyncio.run(worker._build_payload(None, "install-123"))
|
||||
props = payload["properties"]
|
||||
|
||||
assert props["build_provenance"] == "official"
|
||||
assert props["image_modified"] is False
|
||||
|
||||
|
||||
def test_payload_marks_custom_images_modified(settings, monkeypatch):
|
||||
monkeypatch.setattr("bot.services.telemetry_worker.resolve_build_provenance", lambda: "custom")
|
||||
monkeypatch.setattr("bot.services.telemetry_worker.resolve_image_modified", lambda: True)
|
||||
|
||||
worker = TelemetryWorker(settings, None)
|
||||
payload = asyncio.run(worker._build_payload(None, "install-123"))
|
||||
props = payload["properties"]
|
||||
|
||||
assert props["build_provenance"] == "custom"
|
||||
assert props["image_modified"] is True
|
||||
|
||||
|
||||
def test_payload_contains_no_secrets_or_pii(settings):
|
||||
worker = TelemetryWorker(settings, None)
|
||||
payload = asyncio.run(worker._build_payload(None, "install-123"))
|
||||
|
||||
@@ -355,18 +355,23 @@ class WebAppPaymentStatusTests(IsolatedAsyncioTestCase):
|
||||
answer=AsyncMock(),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"bot.payment_providers.shared.callbacks.safe_store_provider_payment_id",
|
||||
AsyncMock(return_value=True),
|
||||
) as store_id, patch(
|
||||
"bot.payment_providers.shared.callbacks.render_payment_link",
|
||||
AsyncMock(),
|
||||
) as render_link, patch(
|
||||
"bot.payment_providers.shared.callbacks.safe_mark_failed_creation",
|
||||
AsyncMock(),
|
||||
) as mark_failed, patch(
|
||||
"bot.payment_providers.shared.callbacks.notify_payment_gateway_failure",
|
||||
AsyncMock(),
|
||||
with (
|
||||
patch(
|
||||
"bot.payment_providers.shared.callbacks.safe_store_provider_payment_id",
|
||||
AsyncMock(return_value=True),
|
||||
) as store_id,
|
||||
patch(
|
||||
"bot.payment_providers.shared.callbacks.render_payment_link",
|
||||
AsyncMock(),
|
||||
) as render_link,
|
||||
patch(
|
||||
"bot.payment_providers.shared.callbacks.safe_mark_failed_creation",
|
||||
AsyncMock(),
|
||||
) as mark_failed,
|
||||
patch(
|
||||
"bot.payment_providers.shared.callbacks.notify_payment_gateway_failure",
|
||||
AsyncMock(),
|
||||
),
|
||||
):
|
||||
await render_link_or_fail(
|
||||
callback,
|
||||
|
||||
Reference in New Issue
Block a user