feat: add digest-gated internal main deployment
Some checks failed
Mostovik Backend CI/CD / Tests and lint (push) Failing after 5s
Mostovik Backend CI/CD / Build linux/amd64 release images (push) Has been skipped
Mostovik Backend CI/CD / Deploy dev (push) Has been skipped
Mostovik Backend CI/CD / Deploy and verify internal main (push) Has been skipped
Mostovik Backend CI/CD / Deploy customer main (push) Has been skipped

This commit is contained in:
2026-08-27 12:49:43 +03:00
parent 7a8e5765ad
commit c884b4e3fd
11 changed files with 728 additions and 648 deletions

View File

@@ -0,0 +1,97 @@
"""Deactivate environment-bound state after restoring a database clone."""
import json
from apps.core.management.commands.base import BaseAppCommand
from apps.core.models import BackgroundJob, JobStatus
from apps.exchange.models import ExchangeConnection
from apps.parsers.models import (
CheckoCollectionAttempt,
FinancialReport,
ParserLoadLog,
)
from django.db.models import Q
from django.utils import timezone
from django_celery_beat.models import PeriodicTask, PeriodicTasks
CLONE_TERMINATION_MESSAGE = "Closed after database clone"
class Command(BaseAppCommand):
"""Make a restored database safe to start in another environment."""
help = "Disable schedules and close environment-bound state after a database clone"
use_transaction = True
def execute_command(self, *args, **options) -> str:
now = timezone.now()
periodic_tasks = PeriodicTask.objects.filter(enabled=True).update(
enabled=False,
date_changed=now,
)
if periodic_tasks:
PeriodicTasks.update_changed()
exchange_connections = ExchangeConnection.objects.filter(
Q(is_active=True)
| ~Q(password="")
| Q(last_checked_at__isnull=False)
| ~Q(last_error="")
).update(
is_active=False,
password="",
last_checked_at=None,
last_error="",
updated_at=now,
)
background_jobs = BackgroundJob.objects.filter(
status__in=(JobStatus.PENDING, JobStatus.STARTED, JobStatus.RETRY)
).update(
status=JobStatus.REVOKED,
completed_at=now,
updated_at=now,
)
parser_load_logs = ParserLoadLog.objects.filter(
status__in=(
ParserLoadLog.Status.PENDING,
ParserLoadLog.Status.IN_PROGRESS,
)
).update(
status=ParserLoadLog.Status.FAILED,
error_message=CLONE_TERMINATION_MESSAGE,
updated_at=now,
)
checko_attempts = CheckoCollectionAttempt.objects.filter(
status=CheckoCollectionAttempt.Status.IN_PROGRESS
).update(
status=CheckoCollectionAttempt.Status.FAILED,
error_message=CLONE_TERMINATION_MESSAGE,
updated_at=now,
)
financial_reports = FinancialReport.objects.filter(
status__in=(
FinancialReport.Status.PENDING,
FinancialReport.Status.PROCESSING,
)
).update(
status=FinancialReport.Status.FAILED,
error_message=CLONE_TERMINATION_MESSAGE,
updated_at=now,
)
return json.dumps(
{
"background_jobs": background_jobs,
"checko_attempts": checko_attempts,
"exchange_connections": exchange_connections,
"financial_reports": financial_reports,
"parser_load_logs": parser_load_logs,
"periodic_tasks": periodic_tasks,
},
sort_keys=True,
)

View File

@@ -0,0 +1,40 @@
"""Validate that a cloned database contains only migrations known to this image."""
from django.core.management.base import BaseCommand, CommandError
from django.db import DEFAULT_DB_ALIAS, connections
from django.db.migrations.loader import MigrationLoader
from django.db.migrations.recorder import MigrationRecorder
class Command(BaseCommand):
"""Reject a clone produced by code newer than the candidate image."""
help = "Fail if the database has applied migrations absent from this image"
requires_migrations_checks = False
def add_arguments(self, parser) -> None:
parser.add_argument(
"--database",
default=DEFAULT_DB_ALIAS,
choices=tuple(connections),
help="Database to validate",
)
def handle(self, *args, **options) -> str:
database = options["database"]
connection = connections[database]
disk_migrations = set(MigrationLoader(connection).disk_migrations)
applied_migrations = set(MigrationRecorder(connection).applied_migrations())
unknown_migrations = sorted(applied_migrations - disk_migrations)
if unknown_migrations:
formatted = ", ".join(
f"{app_label}.{migration_name}"
for app_label, migration_name in unknown_migrations
)
raise CommandError(
"Cloned database has applied migrations absent from this image: "
f"{formatted}"
)
return "Cloned database migrations are compatible with this image"

View File

@@ -7,6 +7,8 @@ import warnings
from datetime import timedelta
from pathlib import Path
from django.core.exceptions import ImproperlyConfigured
BASE_DIR = Path(__file__).resolve().parent.parent
# Repository root (one level above src/)
PROJECT_ROOT = BASE_DIR.parent
@@ -17,6 +19,23 @@ STARTUP_CHECKS_ENABLED = True
STARTUP_DB_TIMEOUT_SECONDS = 3
STARTUP_REDIS_TIMEOUT_SECONDS = 3
def _env_bool(name: str, *, default: bool) -> bool:
"""Read a strict boolean environment flag with an explicit default."""
raw_value = os.getenv(name)
if raw_value is None:
return default
value = raw_value.strip().lower()
if value in {"1", "true", "yes", "on"}:
return True
if value in {"0", "false", "no", "off"}:
return False
raise ImproperlyConfigured(
f"{name} must be one of: 1, true, yes, on, 0, false, no, off"
)
# Suppress noisy warning from legacy coreapi used by drf-yasg.
warnings.filterwarnings(
"ignore",
@@ -269,7 +288,10 @@ CELERY_BROKER_CONNECTION_RETRY = True
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
# Celery: автообновление источников при старте worker.
CELERY_STARTUP_REFRESH_ENABLED = True
CELERY_STARTUP_REFRESH_ENABLED = _env_bool(
"CELERY_STARTUP_REFRESH_ENABLED",
default=True,
)
CELERY_STARTUP_REFRESH_DELAY_SECONDS = 30
CELERY_STARTUP_REFRESH_LOCK_KEY = "celery:startup:parse_all_sources:lock"
CELERY_STARTUP_REFRESH_LOCK_TTL_SECONDS = 3600