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:
+4
-3
@@ -79,9 +79,10 @@ TRUSTED_PROXIES=127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7
|
|||||||
# Once a day the worker sends a single anonymous "heartbeat" so the project
|
# Once a day the worker sends a single anonymous "heartbeat" so the project
|
||||||
# maintainer can see how many installs are active and which versions/OSes are
|
# maintainer can see how many installs are active and which versions/OSes are
|
||||||
# used. It contains an opaque random install id and coarse facts only:
|
# used. It contains an opaque random install id and coarse facts only:
|
||||||
# version, OS/arch, Python version, language, enabled payment providers and a
|
# version, official/custom image provenance, OS/arch, Python version, language,
|
||||||
# user-count RANGE (e.g. "51-200"). No bot token, domain, user data or any
|
# enabled payment providers and a user-count RANGE (e.g. "51-200"). No bot
|
||||||
# personal information is ever sent. Full details: docs/configuration/telemetry.md
|
# token, domain, user data or any personal information is ever sent.
|
||||||
|
# Full details: docs/configuration/telemetry.md
|
||||||
#
|
#
|
||||||
# Set to False to disable, or toggle it any time in Admin -> System ->
|
# Set to False to disable, or toggle it any time in Admin -> System ->
|
||||||
# "Anonymous install analytics" (applies without a restart).
|
# "Anonymous install analytics" (applies without a restart).
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ jobs:
|
|||||||
# (the ref is the tag, not a branch) so release images stay un-suffixed.
|
# (the ref is the tag, not a branch) so release images stay un-suffixed.
|
||||||
build-args: |
|
build-args: |
|
||||||
GITHUB_REF_NAME=${{ inputs.tag_mode == 'release' && 'main' || github.ref_name }}
|
GITHUB_REF_NAME=${{ inputs.tag_mode == 'release' && 'main' || github.ref_name }}
|
||||||
|
REMNAWAVE_MINISHOP_BUILD_PROVENANCE=${{ github.repository == '3252a8/remnawave-minishop' && 'official' || 'custom' }}
|
||||||
cache-from: type=gha,scope=${{ matrix.target }}
|
cache-from: type=gha,scope=${{ matrix.target }}
|
||||||
cache-to: type=gha,mode=max,scope=${{ matrix.target }}
|
cache-to: type=gha,mode=max,scope=${{ matrix.target }}
|
||||||
provenance: false
|
provenance: false
|
||||||
|
|||||||
@@ -43,12 +43,22 @@ docker-dev:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
- |
|
- |
|
||||||
|
provenance="${REMNAWAVE_MINISHOP_BUILD_PROVENANCE:-}"
|
||||||
|
if [ -z "$provenance" ]; then
|
||||||
|
dockerhub_owner="$(printf '%s' "${DOCKERHUB_USERNAME:-}" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
if [ "${CI_PROJECT_PATH:-}" = "3252a8/remnawave-minishop" ] || [ "$dockerhub_owner" = "3252a8" ]; then
|
||||||
|
provenance="official"
|
||||||
|
else
|
||||||
|
provenance="custom"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
--load \
|
--load \
|
||||||
--platform linux/amd64 \
|
--platform linux/amd64 \
|
||||||
--file deploy/docker/Dockerfile \
|
--file deploy/docker/Dockerfile \
|
||||||
--target "$TARGET" \
|
--target "$TARGET" \
|
||||||
--build-arg "CI_COMMIT_REF_NAME=$CI_COMMIT_REF_NAME" \
|
--build-arg "CI_COMMIT_REF_NAME=$CI_COMMIT_REF_NAME" \
|
||||||
|
--build-arg "REMNAWAVE_MINISHOP_BUILD_PROVENANCE=$provenance" \
|
||||||
--build-arg "BUILDKIT_INLINE_CACHE=1" \
|
--build-arg "BUILDKIT_INLINE_CACHE=1" \
|
||||||
--cache-from "type=registry,ref=$DOCKERHUB_USERNAME/$IMAGE:dev" \
|
--cache-from "type=registry,ref=$DOCKERHUB_USERNAME/$IMAGE:dev" \
|
||||||
--tag "$DOCKERHUB_USERNAME/$IMAGE:dev" \
|
--tag "$DOCKERHUB_USERNAME/$IMAGE:dev" \
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ def prune_unused_appearance_assets(settings: Settings) -> None:
|
|||||||
except OSError:
|
except OSError:
|
||||||
logger.warning("Failed to remove unused webapp favicon set %s", path, exc_info=True)
|
logger.warning("Failed to remove unused webapp favicon set %s", path, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
async def _persist_appearance_upload(
|
async def _persist_appearance_upload(
|
||||||
request: web.Request,
|
request: web.Request,
|
||||||
updates: Dict[str, Any],
|
updates: Dict[str, Any],
|
||||||
|
|||||||
@@ -707,10 +707,11 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
"bool",
|
"bool",
|
||||||
"system",
|
"system",
|
||||||
"Анонимная статистика установки",
|
"Анонимная статистика установки",
|
||||||
"Раз в сутки отправляет обезличенный сигнал: версия, ОС, локаль и число "
|
"Раз в сутки отправляет обезличенный сигнал: версия, маркер образа "
|
||||||
"пользователей в виде диапазона. Без персональных данных, токенов и "
|
"official/custom, ОС, локаль и число пользователей в виде диапазона. Без персональных "
|
||||||
"доменов. Помогает понять число активных установок и какие версии "
|
"данных, токенов и доменов. Помогает понять число активных установок, какие "
|
||||||
"используются. Можно отключить здесь без перезапуска.",
|
"версии используются и долю изменённых сборок. Можно отключить здесь без "
|
||||||
|
"перезапуска.",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
from bot.infra.redis import redis_lock
|
from bot.infra.redis import redis_lock
|
||||||
from bot.utils.app_version import resolve_app_version, resolve_app_version_tag
|
from bot.utils.app_version import (
|
||||||
|
resolve_app_version,
|
||||||
|
resolve_app_version_tag,
|
||||||
|
resolve_build_provenance,
|
||||||
|
resolve_image_modified,
|
||||||
|
)
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from db.dal import app_settings_dal, user_dal
|
from db.dal import app_settings_dal, user_dal
|
||||||
|
|
||||||
@@ -169,11 +174,15 @@ class TelemetryWorker:
|
|||||||
|
|
||||||
version = resolve_app_version()
|
version = resolve_app_version()
|
||||||
version_tag = resolve_app_version_tag()
|
version_tag = resolve_app_version_tag()
|
||||||
|
build_provenance = resolve_build_provenance()
|
||||||
|
image_modified = resolve_image_modified()
|
||||||
# Person properties (``$set``) snapshot the latest state per install, so
|
# Person properties (``$set``) snapshot the latest state per install, so
|
||||||
# "version breakdown" in PostHog is a person-property breakdown.
|
# "version breakdown" in PostHog is a person-property breakdown.
|
||||||
person_props = {
|
person_props = {
|
||||||
"app_version": version,
|
"app_version": version,
|
||||||
"app_version_tag": version_tag,
|
"app_version_tag": version_tag,
|
||||||
|
"build_provenance": build_provenance,
|
||||||
|
"image_modified": image_modified,
|
||||||
"os": platform.system().lower() or "unknown",
|
"os": platform.system().lower() or "unknown",
|
||||||
"arch": platform.machine().lower() or "unknown",
|
"arch": platform.machine().lower() or "unknown",
|
||||||
"python_version": platform.python_version(),
|
"python_version": platform.python_version(),
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ the runtime container agree on the value:
|
|||||||
REMNAWAVE_MINISHOP_VERSION env > .build-version file > live ``git describe``
|
REMNAWAVE_MINISHOP_VERSION env > .build-version file > live ``git describe``
|
||||||
> ``dev+unknown``
|
> ``dev+unknown``
|
||||||
|
|
||||||
|
Build provenance is intentionally separate from the version: official release
|
||||||
|
automation stamps official images, while local/fork builds default to custom.
|
||||||
|
|
||||||
The same value powers the admin sidebar (web process) and the anonymous
|
The same value powers the admin sidebar (web process) and the anonymous
|
||||||
telemetry beacon (worker process), so "active installs" and version
|
telemetry beacon (worker process), so "active installs" and version
|
||||||
breakdowns line up across both.
|
breakdowns line up across both.
|
||||||
@@ -24,6 +27,16 @@ from typing import Optional
|
|||||||
APP_ROOT = Path(__file__).resolve().parents[3]
|
APP_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
|
||||||
_APP_VERSION_CACHE: Optional[str] = None
|
_APP_VERSION_CACHE: Optional[str] = None
|
||||||
|
_APP_BUILD_PROVENANCE_CACHE: Optional[str] = None
|
||||||
|
|
||||||
|
BUILD_PROVENANCE_OFFICIAL = "official"
|
||||||
|
BUILD_PROVENANCE_CUSTOM = "custom"
|
||||||
|
BUILD_PROVENANCE_UNKNOWN = "unknown"
|
||||||
|
_BUILD_PROVENANCE_VALUES = {
|
||||||
|
BUILD_PROVENANCE_OFFICIAL,
|
||||||
|
BUILD_PROVENANCE_CUSTOM,
|
||||||
|
BUILD_PROVENANCE_UNKNOWN,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _run_git_command(*args: str) -> str:
|
def _run_git_command(*args: str) -> str:
|
||||||
@@ -87,6 +100,29 @@ def _read_build_file(name: str) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_build_provenance(raw: str) -> str:
|
||||||
|
value = str(raw or "").strip().lower()
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
aliases = {
|
||||||
|
"true": BUILD_PROVENANCE_OFFICIAL,
|
||||||
|
"1": BUILD_PROVENANCE_OFFICIAL,
|
||||||
|
"yes": BUILD_PROVENANCE_OFFICIAL,
|
||||||
|
"upstream": BUILD_PROVENANCE_OFFICIAL,
|
||||||
|
"release": BUILD_PROVENANCE_OFFICIAL,
|
||||||
|
"false": BUILD_PROVENANCE_CUSTOM,
|
||||||
|
"0": BUILD_PROVENANCE_CUSTOM,
|
||||||
|
"no": BUILD_PROVENANCE_CUSTOM,
|
||||||
|
"fork": BUILD_PROVENANCE_CUSTOM,
|
||||||
|
"modified": BUILD_PROVENANCE_CUSTOM,
|
||||||
|
"local": BUILD_PROVENANCE_CUSTOM,
|
||||||
|
}
|
||||||
|
value = aliases.get(value, value)
|
||||||
|
if value in _BUILD_PROVENANCE_VALUES:
|
||||||
|
return value
|
||||||
|
return BUILD_PROVENANCE_CUSTOM
|
||||||
|
|
||||||
|
|
||||||
def resolve_app_version() -> str:
|
def resolve_app_version() -> str:
|
||||||
"""Full version string (cached), e.g. ``v3.4.6+gabc1234``."""
|
"""Full version string (cached), e.g. ``v3.4.6+gabc1234``."""
|
||||||
global _APP_VERSION_CACHE
|
global _APP_VERSION_CACHE
|
||||||
@@ -124,3 +160,28 @@ def resolve_app_version_tag() -> str:
|
|||||||
if tag:
|
if tag:
|
||||||
return tag
|
return tag
|
||||||
return resolve_app_version()
|
return resolve_app_version()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_build_provenance() -> str:
|
||||||
|
"""Low-cardinality image provenance: ``official``, ``custom`` or ``unknown``."""
|
||||||
|
global _APP_BUILD_PROVENANCE_CACHE
|
||||||
|
if _APP_BUILD_PROVENANCE_CACHE:
|
||||||
|
return _APP_BUILD_PROVENANCE_CACHE
|
||||||
|
|
||||||
|
env_value = _normalize_build_provenance(os.getenv("REMNAWAVE_MINISHOP_BUILD_PROVENANCE", ""))
|
||||||
|
if env_value:
|
||||||
|
_APP_BUILD_PROVENANCE_CACHE = env_value
|
||||||
|
return env_value
|
||||||
|
|
||||||
|
build_value = _normalize_build_provenance(_read_build_file(".build-provenance"))
|
||||||
|
if build_value:
|
||||||
|
_APP_BUILD_PROVENANCE_CACHE = build_value
|
||||||
|
return build_value
|
||||||
|
|
||||||
|
_APP_BUILD_PROVENANCE_CACHE = BUILD_PROVENANCE_CUSTOM
|
||||||
|
return _APP_BUILD_PROVENANCE_CACHE
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_image_modified() -> bool:
|
||||||
|
"""True for non-official builds, including forks and local rebuilds."""
|
||||||
|
return resolve_build_provenance() != BUILD_PROVENANCE_OFFICIAL
|
||||||
|
|||||||
@@ -1256,9 +1256,10 @@ class Settings(BaseSettings):
|
|||||||
TELEMETRY_ENABLED: bool = Field(
|
TELEMETRY_ENABLED: bool = Field(
|
||||||
default=True,
|
default=True,
|
||||||
description=(
|
description=(
|
||||||
"Send an anonymous daily install heartbeat (version, OS, locale, "
|
"Send an anonymous daily install heartbeat (version, official/custom "
|
||||||
"user-count range). No personal data. Opt out here, via the web "
|
"image provenance, OS, locale, user-count range). No personal data. "
|
||||||
"admin, or by clearing TELEMETRY_ENDPOINT/TELEMETRY_API_KEY."
|
"Opt out here, via the web admin, or by clearing "
|
||||||
|
"TELEMETRY_ENDPOINT/TELEMETRY_API_KEY."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
TELEMETRY_ENDPOINT: str = Field(
|
TELEMETRY_ENDPOINT: str = Field(
|
||||||
|
|||||||
@@ -139,9 +139,7 @@ async def find_recent_pending_provider_payment(
|
|||||||
conditions = [
|
conditions = [
|
||||||
Payment.user_id == user_id,
|
Payment.user_id == user_id,
|
||||||
Payment.provider == provider,
|
Payment.provider == provider,
|
||||||
func.lower(Payment.status).in_(
|
func.lower(Payment.status).in_(tuple({str(pending_status).lower(), "pending"})),
|
||||||
tuple({str(pending_status).lower(), "pending"})
|
|
||||||
),
|
|
||||||
or_(
|
or_(
|
||||||
Payment.provider_payment_id.isnot(None),
|
Payment.provider_payment_id.isnot(None),
|
||||||
Payment.yookassa_payment_id.isnot(None),
|
Payment.yookassa_payment_id.isnot(None),
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
# Resolve the application version from .git at build time and emit a tiny
|
# Resolve the application version from .git at build time and emit a tiny
|
||||||
# .build-version file. The .git tree is consumed in this throwaway stage and
|
# .build-version file. The .git tree is consumed in this throwaway stage and
|
||||||
# never copied into the runtime image; only the tag + commit version string
|
# never copied into the runtime image; only the tag + commit version string and
|
||||||
# ships. Non-main builds include the branch name so they are visibly distinct
|
# a low-cardinality build provenance marker ship. Non-main builds include the
|
||||||
# from release builds. This matches the runtime fallback chain in _resolve_app_version
|
# branch name so they are visibly distinct from release builds. This matches the
|
||||||
# (REMNAWAVE_MINISHOP_VERSION env > .build-version file > live git >
|
# runtime fallback chain in _resolve_app_version (REMNAWAVE_MINISHOP_VERSION env
|
||||||
# "dev+unknown") so the admin sidebar always shows a tag / sha even though the
|
# > .build-version file > live git > "dev+unknown") so the admin sidebar always
|
||||||
# runtime images have no git tooling and no .git tree.
|
# shows a tag / sha even though the runtime images have no git tooling and no
|
||||||
|
# .git tree.
|
||||||
FROM alpine:3.20 AS version-builder
|
FROM alpine:3.20 AS version-builder
|
||||||
RUN apk add --no-cache git
|
RUN apk add --no-cache git
|
||||||
WORKDIR /repo
|
WORKDIR /repo
|
||||||
@@ -14,6 +15,7 @@ ARG GIT_BRANCH=""
|
|||||||
ARG BRANCH_NAME=""
|
ARG BRANCH_NAME=""
|
||||||
ARG GITHUB_REF_NAME=""
|
ARG GITHUB_REF_NAME=""
|
||||||
ARG CI_COMMIT_REF_NAME=""
|
ARG CI_COMMIT_REF_NAME=""
|
||||||
|
ARG REMNAWAVE_MINISHOP_BUILD_PROVENANCE="custom"
|
||||||
COPY .git ./.git
|
COPY .git ./.git
|
||||||
RUN set -eu; \
|
RUN set -eu; \
|
||||||
git config --global --add safe.directory /repo; \
|
git config --global --add safe.directory /repo; \
|
||||||
@@ -40,9 +42,17 @@ RUN set -eu; \
|
|||||||
else \
|
else \
|
||||||
version="dev${branch_suffix}+unknown"; \
|
version="dev${branch_suffix}+unknown"; \
|
||||||
fi; \
|
fi; \
|
||||||
|
provenance=$(printf '%s' "$REMNAWAVE_MINISHOP_BUILD_PROVENANCE" | tr '[:upper:]' '[:lower:]'); \
|
||||||
|
case "$provenance" in \
|
||||||
|
official|custom|unknown) ;; \
|
||||||
|
true|1|yes|upstream|release) provenance="official" ;; \
|
||||||
|
false|0|no|fork|modified|local|"") provenance="custom" ;; \
|
||||||
|
*) provenance="custom" ;; \
|
||||||
|
esac; \
|
||||||
printf '%s' "$version" > /build-version; \
|
printf '%s' "$version" > /build-version; \
|
||||||
printf '%s' "${tag:-unknown}" > /build-tag; \
|
printf '%s' "${tag:-unknown}" > /build-tag; \
|
||||||
printf '%s' "${sha:-unknown}" > /build-commit
|
printf '%s' "${sha:-unknown}" > /build-commit; \
|
||||||
|
printf '%s' "$provenance" > /build-provenance
|
||||||
|
|
||||||
|
|
||||||
FROM python:3.12-slim AS python-base
|
FROM python:3.12-slim AS python-base
|
||||||
@@ -82,6 +92,7 @@ RUN mkdir -p /app/logs /app/data \
|
|||||||
COPY --from=version-builder /build-version /app/.build-version
|
COPY --from=version-builder /build-version /app/.build-version
|
||||||
COPY --from=version-builder /build-tag /app/.build-tag
|
COPY --from=version-builder /build-tag /app/.build-tag
|
||||||
COPY --from=version-builder /build-commit /app/.build-commit
|
COPY --from=version-builder /build-commit /app/.build-commit
|
||||||
|
COPY --from=version-builder /build-provenance /app/.build-provenance
|
||||||
|
|
||||||
USER appuser
|
USER appuser
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
| `installation_id` | `f47ac10b-...` (UUIDv4) | Случайный идентификатор установки. Генерируется один раз, хранится в БД. Не выводится из токена, домена или ID администраторов. |
|
| `installation_id` | `f47ac10b-...` (UUIDv4) | Случайный идентификатор установки. Генерируется один раз, хранится в БД. Не выводится из токена, домена или ID администраторов. |
|
||||||
| `app_version` | `v3.4.6+gabc1234` | Полная версия сборки. |
|
| `app_version` | `v3.4.6+gabc1234` | Полная версия сборки. |
|
||||||
| `app_version_tag` | `v3.4.6` | Релизный тег для разбивки по версиям. |
|
| `app_version_tag` | `v3.4.6` | Релизный тег для разбивки по версиям. |
|
||||||
|
| `build_provenance` | `official` / `custom` | Маркер происхождения образа. `official` ставится только официальной сборкой проекта; локальные сборки и форки считаются `custom`. |
|
||||||
|
| `image_modified` | `false` / `true` | Удобный булевый флаг для разбивки: `false` только у официальных образов, `true` у локальных/форк-сборок. |
|
||||||
| `os` / `arch` | `linux` / `x86_64` | Платформа. |
|
| `os` / `arch` | `linux` / `x86_64` | Платформа. |
|
||||||
| `python_version` | `3.12.7` | Версия рантайма. |
|
| `python_version` | `3.12.7` | Версия рантайма. |
|
||||||
| `locale` | `ru` | Язык по умолчанию. |
|
| `locale` | `ru` | Язык по умолчанию. |
|
||||||
@@ -22,7 +24,7 @@
|
|||||||
|
|
||||||
## Чего там нет
|
## Чего там нет
|
||||||
|
|
||||||
Никогда не отправляются: токен бота, домены, URL вебхуков, ключи платёжных систем и Remnawave, ID или данные пользователей, точное число пользователей, какой-либо контент.
|
Никогда не отправляются: токен бота, домены, URL вебхуков, ключи платёжных систем и Remnawave, ID или данные пользователей, точное число пользователей, какой-либо контент, registry образа или URL форка.
|
||||||
|
|
||||||
## Как выключить
|
## Как выключить
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,10 @@
|
|||||||
provider_webhook_needs_base_url:
|
provider_webhook_needs_base_url:
|
||||||
"Провайдеру {provider} нужен WEBHOOK_BASE_URL для приёма вебхуков, а он не задан.",
|
"Провайдеру {provider} нужен WEBHOOK_BASE_URL для приёма вебхуков, а он не задан.",
|
||||||
no_payment_methods: "Не включён ни один способ оплаты.",
|
no_payment_methods: "Не включён ни один способ оплаты.",
|
||||||
mini_app_url_missing: "SUBSCRIPTION_MINI_APP_URL не задан — кнопка Mini App в боте не появится.",
|
mini_app_url_missing:
|
||||||
mini_app_url_not_https: "SUBSCRIPTION_MINI_APP_URL должен начинаться с https:// (сейчас {url}).",
|
"SUBSCRIPTION_MINI_APP_URL не задан — кнопка Mini App в боте не появится.",
|
||||||
|
mini_app_url_not_https:
|
||||||
|
"SUBSCRIPTION_MINI_APP_URL должен начинаться с https:// (сейчас {url}).",
|
||||||
redis_not_configured:
|
redis_not_configured:
|
||||||
"REDIS_URL не задан — состояния диалогов бота и кэш не переживут перезапуск.",
|
"REDIS_URL не задан — состояния диалогов бота и кэш не переживут перезапуск.",
|
||||||
smtp_incomplete: "SMTP настроен не полностью — вход по email работать не будет.",
|
smtp_incomplete: "SMTP настроен не полностью — вход по email работать не будет.",
|
||||||
@@ -58,7 +60,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function alertText(alert) {
|
function alertText(alert) {
|
||||||
const fallback = interpolate(MESSAGE_FALLBACKS[alert.message_key] || alert.message_key, alert.params);
|
const fallback = interpolate(
|
||||||
|
MESSAGE_FALLBACKS[alert.message_key] || alert.message_key,
|
||||||
|
alert.params
|
||||||
|
);
|
||||||
return at(`health_${alert.message_key}`, alert.params || {}, fallback);
|
return at(`health_${alert.message_key}`, alert.params || {}, fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6057,7 +6057,7 @@
|
|||||||
"section_order": 12,
|
"section_order": 12,
|
||||||
"subsection": null,
|
"subsection": null,
|
||||||
"label": "Анонимная статистика установки",
|
"label": "Анонимная статистика установки",
|
||||||
"description": "Раз в сутки отправляет обезличенный сигнал: версия, ОС, локаль и число пользователей в виде диапазона. Без персональных данных, токенов и доменов. Помогает понять число активных установок и какие версии используются. Можно отключить здесь без перезапуска.",
|
"description": "Раз в сутки отправляет обезличенный сигнал: версия, маркер образа official/custom, ОС, локаль и число пользователей в виде диапазона. Без персональных данных, токенов и доменов. Помогает понять число активных установок, какие версии используются и долю изменённых сборок. Можно отключить здесь без перезапуска.",
|
||||||
"i18n_label_key": "admin_settings_field_telemetry_enabled_label",
|
"i18n_label_key": "admin_settings_field_telemetry_enabled_label",
|
||||||
"i18n_description_key": "admin_settings_field_telemetry_enabled_description",
|
"i18n_description_key": "admin_settings_field_telemetry_enabled_description",
|
||||||
"i18n_subsection_key": null,
|
"i18n_subsection_key": null,
|
||||||
|
|||||||
+1
-1
@@ -1178,7 +1178,7 @@
|
|||||||
"admin_settings_section_system": "System",
|
"admin_settings_section_system": "System",
|
||||||
"admin_settings_section_migrations": "Migrations",
|
"admin_settings_section_migrations": "Migrations",
|
||||||
"admin_settings_field_telemetry_enabled_label": "Anonymous install analytics",
|
"admin_settings_field_telemetry_enabled_label": "Anonymous install analytics",
|
||||||
"admin_settings_field_telemetry_enabled_description": "Sends one anonymous heartbeat per day (version, OS, locale, user-count range). No personal data, tokens or domains. Helps gauge how many installs are active and which versions are in use. Toggling this off takes effect without a restart.",
|
"admin_settings_field_telemetry_enabled_description": "Sends one anonymous heartbeat per day (version, official/custom image marker, OS, locale, user-count range). No personal data, tokens or domains. Helps gauge active installs, versions in use and the share of modified builds. Toggling this off takes effect without a restart.",
|
||||||
"admin_settings_subsection_common": "Common",
|
"admin_settings_subsection_common": "Common",
|
||||||
"admin_settings_subsection_checkout": "Checkout",
|
"admin_settings_subsection_checkout": "Checkout",
|
||||||
"admin_settings_subsection_trial": "Trial",
|
"admin_settings_subsection_trial": "Trial",
|
||||||
|
|||||||
+1
-1
@@ -1178,7 +1178,7 @@
|
|||||||
"admin_settings_section_system": "Система",
|
"admin_settings_section_system": "Система",
|
||||||
"admin_settings_section_migrations": "Миграции",
|
"admin_settings_section_migrations": "Миграции",
|
||||||
"admin_settings_field_telemetry_enabled_label": "Анонимная статистика установки",
|
"admin_settings_field_telemetry_enabled_label": "Анонимная статистика установки",
|
||||||
"admin_settings_field_telemetry_enabled_description": "Раз в сутки отправляет обезличенный сигнал: версия, ОС, локаль и число пользователей в виде диапазона. Без персональных данных, токенов и доменов. Помогает оценить число активных установок и используемые версии. Отключение применяется без перезапуска.",
|
"admin_settings_field_telemetry_enabled_description": "Раз в сутки отправляет обезличенный сигнал: версия, маркер образа official/custom, ОС, локаль и число пользователей в виде диапазона. Без персональных данных, токенов и доменов. Помогает оценить число активных установок, используемые версии и долю изменённых сборок. Отключение применяется без перезапуска.",
|
||||||
"admin_settings_subsection_common": "Общие",
|
"admin_settings_subsection_common": "Общие",
|
||||||
"admin_settings_subsection_checkout": "Оформление оплаты",
|
"admin_settings_subsection_checkout": "Оформление оплаты",
|
||||||
"admin_settings_subsection_trial": "Пробный период",
|
"admin_settings_subsection_trial": "Пробный период",
|
||||||
|
|||||||
@@ -5,12 +5,18 @@ $imageNamespace = if ($env:IMAGE_NAMESPACE) { $env:IMAGE_NAMESPACE } else { "325
|
|||||||
$imageTag = if ($env:IMAGE_TAG) { $env:IMAGE_TAG } else { "local" }
|
$imageTag = if ($env:IMAGE_TAG) { $env:IMAGE_TAG } else { "local" }
|
||||||
$imagePrefix = if ($env:IMAGE_PREFIX) { $env:IMAGE_PREFIX } else { "remnawave-minishop" }
|
$imagePrefix = if ($env:IMAGE_PREFIX) { $env:IMAGE_PREFIX } else { "remnawave-minishop" }
|
||||||
$dockerfile = if ($env:DOCKERFILE) { $env:DOCKERFILE } else { "deploy/docker/Dockerfile" }
|
$dockerfile = if ($env:DOCKERFILE) { $env:DOCKERFILE } else { "deploy/docker/Dockerfile" }
|
||||||
|
$buildProvenance = if ($env:REMNAWAVE_MINISHOP_BUILD_PROVENANCE) { $env:REMNAWAVE_MINISHOP_BUILD_PROVENANCE } else { "custom" }
|
||||||
|
|
||||||
function Build-Image {
|
function Build-Image {
|
||||||
param([string]$Target)
|
param([string]$Target)
|
||||||
$image = "$imageRegistry/$imageNamespace/$imagePrefix-$Target`:$imageTag"
|
$image = "$imageRegistry/$imageNamespace/$imagePrefix-$Target`:$imageTag"
|
||||||
Write-Host "Building $image" -ForegroundColor Cyan
|
Write-Host "Building $image" -ForegroundColor Cyan
|
||||||
docker build -f $dockerfile --target $Target -t $image .
|
docker build `
|
||||||
|
-f $dockerfile `
|
||||||
|
--target $Target `
|
||||||
|
--build-arg "REMNAWAVE_MINISHOP_BUILD_PROVENANCE=$buildProvenance" `
|
||||||
|
-t $image `
|
||||||
|
.
|
||||||
}
|
}
|
||||||
|
|
||||||
Build-Image backend
|
Build-Image backend
|
||||||
|
|||||||
@@ -6,12 +6,18 @@ IMAGE_NAMESPACE="${IMAGE_NAMESPACE:-3252a8}"
|
|||||||
IMAGE_TAG="${IMAGE_TAG:-local}"
|
IMAGE_TAG="${IMAGE_TAG:-local}"
|
||||||
IMAGE_PREFIX="${IMAGE_PREFIX:-remnawave-minishop}"
|
IMAGE_PREFIX="${IMAGE_PREFIX:-remnawave-minishop}"
|
||||||
DOCKERFILE="${DOCKERFILE:-deploy/docker/Dockerfile}"
|
DOCKERFILE="${DOCKERFILE:-deploy/docker/Dockerfile}"
|
||||||
|
REMNAWAVE_MINISHOP_BUILD_PROVENANCE="${REMNAWAVE_MINISHOP_BUILD_PROVENANCE:-custom}"
|
||||||
|
|
||||||
build_image() {
|
build_image() {
|
||||||
local target="$1"
|
local target="$1"
|
||||||
local image="$IMAGE_REGISTRY/$IMAGE_NAMESPACE/$IMAGE_PREFIX-$target:$IMAGE_TAG"
|
local image="$IMAGE_REGISTRY/$IMAGE_NAMESPACE/$IMAGE_PREFIX-$target:$IMAGE_TAG"
|
||||||
echo "Building $image"
|
echo "Building $image"
|
||||||
docker build -f "$DOCKERFILE" --target "$target" -t "$image" .
|
docker build \
|
||||||
|
-f "$DOCKERFILE" \
|
||||||
|
--target "$target" \
|
||||||
|
--build-arg "REMNAWAVE_MINISHOP_BUILD_PROVENANCE=$REMNAWAVE_MINISHOP_BUILD_PROVENANCE" \
|
||||||
|
-t "$image" \
|
||||||
|
.
|
||||||
}
|
}
|
||||||
|
|
||||||
build_image backend
|
build_image backend
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ $imagePrefix = if ($env:IMAGE_PREFIX) { $env:IMAGE_PREFIX } else { "remnawave-mi
|
|||||||
$dockerfile = if ($env:DOCKERFILE) { $env:DOCKERFILE } else { "deploy/docker/Dockerfile" }
|
$dockerfile = if ($env:DOCKERFILE) { $env:DOCKERFILE } else { "deploy/docker/Dockerfile" }
|
||||||
$targetsRaw = if ($env:TARGETS) { $env:TARGETS } else { "backend,worker,frontend" }
|
$targetsRaw = if ($env:TARGETS) { $env:TARGETS } else { "backend,worker,frontend" }
|
||||||
$targets = @($targetsRaw -split "[,;\s]+" | Where-Object { $_ })
|
$targets = @($targetsRaw -split "[,;\s]+" | Where-Object { $_ })
|
||||||
|
$buildProvenance = if ($env:REMNAWAVE_MINISHOP_BUILD_PROVENANCE) { $env:REMNAWAVE_MINISHOP_BUILD_PROVENANCE } else { "custom" }
|
||||||
|
|
||||||
function Get-ImageName {
|
function Get-ImageName {
|
||||||
param(
|
param(
|
||||||
@@ -31,7 +32,12 @@ function Build-Image {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "Building $Target for: $($imageRegistries -join ', ')" -ForegroundColor Cyan
|
Write-Host "Building $Target for: $($imageRegistries -join ', ')" -ForegroundColor Cyan
|
||||||
docker build -f $dockerfile --target $Target @tagArgs .
|
docker build `
|
||||||
|
-f $dockerfile `
|
||||||
|
--target $Target `
|
||||||
|
--build-arg "REMNAWAVE_MINISHOP_BUILD_PROVENANCE=$buildProvenance" `
|
||||||
|
@tagArgs `
|
||||||
|
.
|
||||||
}
|
}
|
||||||
|
|
||||||
function Push-Image {
|
function Push-Image {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ IMAGE_TAG="${IMAGE_TAG:?Set IMAGE_TAG to the release tag you want to build and p
|
|||||||
IMAGE_PREFIX="${IMAGE_PREFIX:-remnawave-minishop}"
|
IMAGE_PREFIX="${IMAGE_PREFIX:-remnawave-minishop}"
|
||||||
DOCKERFILE="${DOCKERFILE:-deploy/docker/Dockerfile}"
|
DOCKERFILE="${DOCKERFILE:-deploy/docker/Dockerfile}"
|
||||||
TARGETS="${TARGETS:-backend worker frontend}"
|
TARGETS="${TARGETS:-backend worker frontend}"
|
||||||
|
REMNAWAVE_MINISHOP_BUILD_PROVENANCE="${REMNAWAVE_MINISHOP_BUILD_PROVENANCE:-custom}"
|
||||||
|
|
||||||
normalize_list() {
|
normalize_list() {
|
||||||
local value="$1"
|
local value="$1"
|
||||||
@@ -34,7 +35,12 @@ build_image() {
|
|||||||
done
|
done
|
||||||
|
|
||||||
echo "Building $target for: ${registries[*]}"
|
echo "Building $target for: ${registries[*]}"
|
||||||
docker build -f "$DOCKERFILE" --target "$target" "${tags[@]}" .
|
docker build \
|
||||||
|
-f "$DOCKERFILE" \
|
||||||
|
--target "$target" \
|
||||||
|
--build-arg "REMNAWAVE_MINISHOP_BUILD_PROVENANCE=$REMNAWAVE_MINISHOP_BUILD_PROVENANCE" \
|
||||||
|
"${tags[@]}" \
|
||||||
|
.
|
||||||
}
|
}
|
||||||
|
|
||||||
push_image() {
|
push_image() {
|
||||||
|
|||||||
@@ -28,9 +28,11 @@ from unittest.mock import patch
|
|||||||
# Importing the webapp facade populates the runtime helpers we need.
|
# Importing the webapp facade populates the runtime helpers we need.
|
||||||
import bot.app.web.subscription_webapp # noqa: F401
|
import bot.app.web.subscription_webapp # noqa: F401
|
||||||
from bot.app.web.webapp import assets as assets_module
|
from bot.app.web.webapp import assets as assets_module
|
||||||
|
from bot.utils import app_version as app_version_module
|
||||||
|
|
||||||
_VERSION_ENV_NAMES = (
|
_VERSION_ENV_NAMES = (
|
||||||
"REMNAWAVE_MINISHOP_VERSION",
|
"REMNAWAVE_MINISHOP_VERSION",
|
||||||
|
"REMNAWAVE_MINISHOP_BUILD_PROVENANCE",
|
||||||
"REMNAWAVE_MINISHOP_BRANCH",
|
"REMNAWAVE_MINISHOP_BRANCH",
|
||||||
"GIT_BRANCH",
|
"GIT_BRANCH",
|
||||||
"BRANCH_NAME",
|
"BRANCH_NAME",
|
||||||
@@ -42,6 +44,8 @@ _VERSION_ENV_NAMES = (
|
|||||||
def _reset_cache() -> None:
|
def _reset_cache() -> None:
|
||||||
# The resolver memoizes the first result in a module-level global.
|
# The resolver memoizes the first result in a module-level global.
|
||||||
assets_module._APP_VERSION_CACHE = None # type: ignore[attr-defined]
|
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.
|
# Some callers reach through the facade re-export; clear that too.
|
||||||
runtime = importlib.import_module("bot.app.web.webapp._runtime")
|
runtime = importlib.import_module("bot.app.web.webapp._runtime")
|
||||||
runtime._APP_VERSION_CACHE = None # type: ignore[attr-defined]
|
runtime._APP_VERSION_CACHE = None # type: ignore[attr-defined]
|
||||||
@@ -251,5 +255,55 @@ class CacheBehaviourTests(unittest.TestCase):
|
|||||||
self.assertEqual(first_calls, second_calls)
|
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
|
if __name__ == "__main__": # pragma: no cover
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ def test_build_payload_shape(settings):
|
|||||||
for key in (
|
for key in (
|
||||||
"app_version",
|
"app_version",
|
||||||
"app_version_tag",
|
"app_version_tag",
|
||||||
|
"build_provenance",
|
||||||
|
"image_modified",
|
||||||
"os",
|
"os",
|
||||||
"arch",
|
"arch",
|
||||||
"python_version",
|
"python_version",
|
||||||
@@ -63,13 +65,44 @@ def test_build_payload_shape(settings):
|
|||||||
assert key in props, f"missing property: {key}"
|
assert key in props, f"missing property: {key}"
|
||||||
|
|
||||||
assert isinstance(props["payment_providers"], list)
|
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.
|
# No DB session -> user count degrades to the smallest bucket.
|
||||||
assert props["users_bucket"] == "0"
|
assert props["users_bucket"] == "0"
|
||||||
# Person properties mirror the event properties so PostHog breakdowns work.
|
# Person properties mirror the event properties so PostHog breakdowns work.
|
||||||
assert props["$set"]["app_version"] == props["app_version"]
|
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"
|
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):
|
def test_payload_contains_no_secrets_or_pii(settings):
|
||||||
worker = TelemetryWorker(settings, None)
|
worker = TelemetryWorker(settings, None)
|
||||||
payload = asyncio.run(worker._build_payload(None, "install-123"))
|
payload = asyncio.run(worker._build_payload(None, "install-123"))
|
||||||
|
|||||||
@@ -355,18 +355,23 @@ class WebAppPaymentStatusTests(IsolatedAsyncioTestCase):
|
|||||||
answer=AsyncMock(),
|
answer=AsyncMock(),
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch(
|
with (
|
||||||
"bot.payment_providers.shared.callbacks.safe_store_provider_payment_id",
|
patch(
|
||||||
AsyncMock(return_value=True),
|
"bot.payment_providers.shared.callbacks.safe_store_provider_payment_id",
|
||||||
) as store_id, patch(
|
AsyncMock(return_value=True),
|
||||||
"bot.payment_providers.shared.callbacks.render_payment_link",
|
) as store_id,
|
||||||
AsyncMock(),
|
patch(
|
||||||
) as render_link, patch(
|
"bot.payment_providers.shared.callbacks.render_payment_link",
|
||||||
"bot.payment_providers.shared.callbacks.safe_mark_failed_creation",
|
AsyncMock(),
|
||||||
AsyncMock(),
|
) as render_link,
|
||||||
) as mark_failed, patch(
|
patch(
|
||||||
"bot.payment_providers.shared.callbacks.notify_payment_gateway_failure",
|
"bot.payment_providers.shared.callbacks.safe_mark_failed_creation",
|
||||||
AsyncMock(),
|
AsyncMock(),
|
||||||
|
) as mark_failed,
|
||||||
|
patch(
|
||||||
|
"bot.payment_providers.shared.callbacks.notify_payment_gateway_failure",
|
||||||
|
AsyncMock(),
|
||||||
|
),
|
||||||
):
|
):
|
||||||
await render_link_or_fail(
|
await render_link_or_fail(
|
||||||
callback,
|
callback,
|
||||||
|
|||||||
Reference in New Issue
Block a user