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
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:
@@ -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,
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user