晋太元中,武陵人捕鱼为业。缘溪行,忘路之远近。忽逢桃花林,夹岸数百步,中无杂树,芳草鲜美,落英缤纷。渔人甚异之,复前行,欲穷其林。   林尽水源,便得一山,山有小口,仿佛若有光。便舍船,从口入。初极狭,才通人。复行数十步,豁然开朗。土地平旷,屋舍俨然,有良田、美池、桑竹之属。阡陌交通,鸡犬相闻。其中往来种作,男女衣着,悉如外人。黄发垂髫,并怡然自乐。   见渔人,乃大惊,问所从来。具答之。便要还家,设酒杀鸡作食。村中闻有此人,咸来问讯。自云先世避秦时乱,率妻子邑人来此绝境,不复出焉,遂与外人间隔。问今是何世,乃不知有汉,无论魏晋。此人一一为具言所闻,皆叹惋。余人各复延至其家,皆出酒食。停数日,辞去。此中人语云:“不足为外人道也。”(间隔 一作:隔绝)   既出,得其船,便扶向路,处处志之。及郡下,诣太守,说如此。太守即遣人随其往,寻向所志,遂迷,不复得路。   南阳刘子骥,高尚士也,闻之,欣然规往。未果,寻病终。后遂无问津者。 sh-3ll

HOME


sh-3ll 1.0
DIR:/proc/thread-self/root/opt/imunify360/venv/lib/python3.11/site-packages/defence360agent/
Upload File :
Current File : //proc/thread-self/root/opt/imunify360/venv/lib/python3.11/site-packages/defence360agent/migrate.py
#!/opt/imunify360/venv/bin/python3
"""This module import peewee_migrate and apply migrations, for Imunify-AV
it's entrypoint for service"""

import contextlib
import os
import sys
import signal
import subprocess
import threading
import time

from collections.abc import Iterable
from logging import getLogger
from sqlite3 import Error as SqliteError, connect

from peewee_migrate import migrator
from playhouse.sqlite_ext import SqliteExtDatabase

import defence360agent.internals.logger
from defence360agent.application import app
from defence360agent.application.settings import configure
from defence360agent.contracts.config import Core
from defence360agent.contracts.config import Model
from defence360agent.router import Router
from defence360agent.subsys.svcctl import AGENT_SERVICE_NAME
from defence360agent.subsys import systemd_notifier
from defence360agent.model.instance import db as db_instance
from defence360agent.model import tls_check
from defence360agent.utils import (
    write_pid_file,
    IM360_RESIDENT_PID_PATH,
    cleanup_pid_file,
)
from defence360agent.utils.check_db import (
    make_backup,
    mark_with_timestamp,
    recreate_schema_models,
)

logger = getLogger(__name__)

GO_SERVICE_NAME = "/usr/bin/imunify-resident"
# Shipped with the interpreter this agent runs on; .recover needs >= 3.29.
SQLITE_CLI = "/opt/alt/sqlite/usr/bin/sqlite3"
# Measured at ~5 MB/s for recover plus reload, so this covers a database far
# larger than any seen; three of them still fit the unit's start timeout.
SALVAGE_TIMEOUT_SECS = 180
SYSTEMCTL_TIMEOUT_SECS = 30
# The primary sqlite result codes that prove the file itself is unreadable.
SQLITE_ERROR = 1  # a header this library cannot parse: unsupported file format
SQLITE_CORRUPT = 11  # database disk image is malformed
SQLITE_NOTADB = 26  # file is not a database
# Any other failure - a lock, an I/O error, a full disk - means the check could
# not be made rather than that the file is damaged, and acting on that verdict
# would delete a healthy database, so leaving it alone is the safe default.
# Compared on the primary code because Python surfaces extended ones:
# SQLITE_BUSY_SNAPSHOT arrives as 517 and matches no primary name.
DAMAGED_SQLITE_CODES = frozenset((SQLITE_ERROR, SQLITE_CORRUPT, SQLITE_NOTADB))


@contextlib.contextmanager
def exc_handler(log_msg: str, reraise: bool):
    """
    Logs error in case of exception.
    Depending on `reraise`:
    - re-raise exception and don't include exception info in the log operation
    - do not re-raise exception and include exception info in the log operation
    """

    try:
        yield
    except Exception:
        logger.error(log_msg, exc_info=not reraise)
        if reraise:
            raise


def apply_migrations(db: SqliteExtDatabase, migrations_dirs: Iterable[str]):
    """Apply migrations: restructure db, config files, etc."""

    router = Router(
        db,
        migrations_dirs=migrations_dirs,
        logger=logger,
    )
    # HACK: Migrator uses global unconfigurable LOGGER,
    # overrride it, to use our logging settings
    migrator.LOGGER = logger
    router.run()


def _park_sidecars(db_path: str, backup: str) -> None:
    # -wal and -shm belong to the file that was just replaced, and leaving them
    # behind makes SQLite reopen the old content through them. They can also
    # hold committed rows that are in no other file, so they move next to the
    # backup rather than being deleted.
    for suffix in ("-wal", "-shm"):
        with contextlib.suppress(OSError):
            os.replace(db_path + suffix, backup + suffix)


def _is_corrupted(db_path: str) -> bool:
    """True only on positive evidence that the file itself is damaged."""
    # Deliberately stricter than check_db.is_db_corrupted, which reports any
    # DatabaseError as corruption. That is fine for the interactive checkdb
    # path; here the verdict deletes a file, so "could not check" must not
    # become "damaged".
    try:
        with contextlib.closing(connect(db_path)) as conn:
            # Opening and closing a database in WAL mode checkpoints its -wal
            # into the file, so this check is not read-only.
            verdict = conn.execute("PRAGMA integrity_check").fetchone()
        return verdict is not None and "ok" not in verdict
    except SqliteError as e:
        if getattr(e, "sqlite_errorcode", 0) & 0xFF in DAMAGED_SQLITE_CODES:
            # A destructive verdict has to say what drove it: these codes
            # cannot tell a damaged file from one written in a format this
            # library cannot read, so the code belongs in the record.
            logger.warning(
                "Database %s reported %s (%s); treating it as damaged",
                db_path,
                getattr(e, "sqlite_errorname", "an unnamed sqlite error"),
                e,
            )
            return True
        logger.warning(
            "Cannot check %s for corruption (%s); leaving it alone",
            db_path,
            e,
        )
        return False


def _count_rows(db_path: str) -> int:
    # Counted on the rebuilt file rather than on the .recover output: a load
    # that only partly succeeded must not be reported as fully recovered.
    # lost_and_found holds rows .recover could not attribute to a table, which
    # is what it produces when the schema page itself is unreadable. Nothing
    # reads them, so they must not be counted as recovered either.
    with contextlib.closing(connect(db_path)) as conn:
        tables = conn.execute(
            "SELECT name FROM sqlite_master WHERE type = 'table'"
            " AND name NOT LIKE 'lost_and_found%'"
            " AND name NOT LIKE 'sqlite_%'"
        ).fetchall()
        return sum(
            conn.execute(
                'SELECT count(*) FROM "%s"' % name.replace('"', '""')
            ).fetchone()[0]
            for (name,) in tables
        )


def _salvage_database(db_path: str) -> int | None:
    """Rebuild db_path from readable pages; rows kept, else None."""
    # iterdump() aborts on the first damaged page. The CLI's .recover walks the
    # b-tree directly and skips what it cannot read, so it is the only salvage
    # that gets anything off a malformed file.
    if not os.path.exists(SQLITE_CLI):
        return None
    recovered_sql = mark_with_timestamp(db_path, extension="recover.sql")
    rebuilt = db_path + ".rebuilt"
    # A rebuilt file left by a previous attempt that was killed mid-flight
    # would be loaded into, mixing stale content into the result.
    with contextlib.suppress(OSError):
        os.remove(rebuilt)
    try:
        with open(recovered_sql, "wb") as sql:
            subprocess.run(
                [SQLITE_CLI, db_path, ".recover"],
                stdout=sql,
                stderr=subprocess.DEVNULL,
                timeout=SALVAGE_TIMEOUT_SECS,
                check=True,
            )
        with open(recovered_sql, "rb") as sql:
            subprocess.run(
                [SQLITE_CLI, rebuilt],
                stdin=sql,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
                timeout=SALVAGE_TIMEOUT_SECS,
                check=True,
            )
        if _is_corrupted(rebuilt):
            return None
        rows = _count_rows(rebuilt)
        if rows == 0:
            return None
        os.replace(rebuilt, db_path)
        return rows
    except (OSError, subprocess.SubprocessError, SqliteError):
        return None
    finally:
        for path in (rebuilt, recovered_sql):
            with contextlib.suppress(OSError):
                os.remove(path)


def _restore_wal(db_path: str) -> None:
    # A repaired file is a new one, and new SQLite files default to the DELETE
    # journal. Both agents share these databases and only WAL keeps a reader
    # from being blocked by a writer. The resident re-applies WAL to its main
    # database on connect but not to the ones it attaches, so a repaired
    # attached file would stay in DELETE for the life of the installation.
    try:
        with contextlib.closing(connect(db_path)) as conn:
            conn.execute("PRAGMA journal_mode=WAL")
    except SqliteError as e:
        logger.error("Could not restore WAL mode on %s: %s", db_path, e)


def _restart_non_resident_agent() -> None:
    # A repair replaces the file's inode. The non-resident agent is a separate
    # long-lived process that attaches the same databases, and it is not
    # restarted with the resident: the units share only an ordering, so
    # nothing propagates. Left alone it keeps writing into the file it still
    # holds open, which no longer has a name, and those rows are lost while
    # the resident sees a database without them. try-restart leaves a stopped
    # unit stopped, and --no-block does not wait on a job that is ordered
    # after the startup issuing it.
    try:
        subprocess.run(
            [
                "systemctl",
                "try-restart",
                "--no-block",
                "%s.service" % AGENT_SERVICE_NAME,
            ],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            timeout=SYSTEMCTL_TIMEOUT_SECS,
            check=True,
        )
    except (OSError, subprocess.SubprocessError) as e:
        logger.warning(
            "Could not restart %s after repairing a database: %s",
            AGENT_SERVICE_NAME,
            e,
        )


def _repair_corrupted_attached_dbs(
    attached_dbs: tuple[tuple[str, str], ...],
) -> None:
    """Salvage damaged attached databases, else drop them for ATTACH."""
    # The recreation below cannot rewrite a damaged page, so without this the
    # file stays broken for the life of the installation.
    repaired = False
    for db_path, _ in attached_dbs:
        if not os.path.exists(db_path) or not _is_corrupted(db_path):
            continue
        backup = make_backup(db_path)
        if backup is None:
            logger.error(
                "Database %s is corrupted and its backup could not be"
                " written; leaving it untouched",
                db_path,
            )
            continue
        rows = _salvage_database(db_path)
        if rows is None:
            try:
                os.remove(db_path)
            except OSError as e:
                logger.error(
                    "Database %s is corrupted and could not be removed for"
                    " recreation (%s); leaving it as it is. Backup: %s",
                    db_path,
                    e,
                    backup,
                )
                continue
        _park_sidecars(db_path, backup)
        _restore_wal(db_path)
        repaired = True
        if rows is not None:
            # Rows on damaged pages are gone; only the backup still has them.
            logger.warning(
                "Database %s was corrupted, rebuilt with %d recovered rows."
                " Backup: %s",
                db_path,
                rows,
                backup,
            )
        elif not os.path.exists(SQLITE_CLI):
            logger.error(
                "Database %s is corrupted and %s is not present to salvage"
                " it; recreating it empty. Backup: %s",
                db_path,
                SQLITE_CLI,
                backup,
            )
        else:
            logger.error(
                "Database %s is corrupted and could not be salvaged;"
                " recreating it empty. Backup: %s",
                db_path,
                backup,
            )
    if repaired:
        _restart_non_resident_agent()


def prepare_databases(
    migrations_dirs: Iterable[str],
    attached_dbs: tuple[tuple[str, str], ...] = tuple(),
):
    """
    Apply migrations and recreate attached databases.

    The workflow:
    1. Apply migrations
    2. Regardless whether the migrations were applied - recreate attached databases
    3. If the recreation of the attached databases was successful - apply migrations again
        - this is done to verify that migrations will successfully apply in future for the recreated databases
        - the recreation + the migrations in this step are within the same transaction,
          so databases will only be recreated if the migrations can applied after the recreation.
    """

    # prepare database to operate in WAL journal_mode and run migrations
    tls_check.reset()
    _repair_corrupted_attached_dbs(attached_dbs)
    db_instance.init(Model.PATH)
    attached_schemas = []
    for db_path, schema_name in attached_dbs:
        db_instance.execute_sql("ATTACH ? AS ?", (db_path, schema_name))
        attached_schemas.append(schema_name)

    try:
        logger.info("Applying database migrations...")
        systemd_notifier.notify(systemd_notifier.AgentState.MIGRATING)
        with db_instance.atomic("EXCLUSIVE"), exc_handler(
            "Error applying migrations", reraise=False
        ):
            apply_migrations(db_instance, migrations_dirs)

        logger.info("Recreating attached databases...")
        with db_instance.atomic("EXCLUSIVE"), exc_handler(
            "Error recreating attached databases", reraise=True
        ):
            # Migration history is stored in main db, so to automatically recreate
            # attached dbs it is required to recreate schema for them from models
            recreate_schema_models(db_instance, attached_schemas)

            # verify migrations can be applied after the attached dbs recreation
            with exc_handler(
                "Error applying migrations after recreating attached"
                " databases",
                reraise=True,
            ):
                apply_migrations(db_instance, migrations_dirs)
    finally:
        # close connection immediately since later this process
        # will be replaced by execv
        db_instance.close()


# required in case package manager or user sends signals while migrations are still running
def signal_handler(sig, _):
    logger.warning("Received signal %s in signal_handler", sig)
    logger.warning(
        "waiting %d seconds so that migrations can finish",
        Core.SIGNAL_HANDLER_MIGRATION_TIMEOUT_SECS,
    )
    time.sleep(Core.SIGNAL_HANDLER_MIGRATION_TIMEOUT_SECS)
    logger.info("Exiting")
    sys.exit(0)


def run(*, start_pkg="defence360agent", configure=configure):
    """Entry point for Imunify-AV service. Apply migrations,
    and then replace process with {start_pkg}.run module."""

    for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
        signal.signal(sig, signal_handler)
    try:
        if start_pkg == "im360.run_resident":
            write_pid_file(IM360_RESIDENT_PID_PATH)
        os.umask(Core.FILE_UMASK)
        configure()
        defence360agent.internals.logger.reconfigure()
        migration_thread = threading.Thread(
            target=prepare_databases,
            args=(app.MIGRATIONS_DIRS, app.MIGRATIONS_ATTACHED_DBS),
        )
        migration_thread.start()
        migration_thread.join()

        systemd_notifier.notify(systemd_notifier.AgentState.READY)
        logger.info("Starting main process...")
        systemd_notifier.notify(systemd_notifier.AgentState.STARTING)

        if start_pkg == "im360.run_resident":
            Core.GO_FLAG_FILE.touch(exist_ok=True)
            logger.info("Run imunify-resident service")
            os.execv(
                GO_SERVICE_NAME,
                [
                    GO_SERVICE_NAME,
                ]
                + sys.argv[1:],
            )
        else:
            os.execv(
                sys.executable,
                [sys.executable, "-m", "{}".format(start_pkg)] + sys.argv[1:],
            )
    except Exception:
        if start_pkg == "im360.run_resident":
            cleanup_pid_file(IM360_RESIDENT_PID_PATH)


if __name__ == "__main__":
    run()