mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 04:20:45 +08:00
Add opt-in per-NE CLI exec_policy for MCP/API exec.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
bfac0b53c8
commit
b9be545a75
20 changed files with 463 additions and 24 deletions
|
|
@ -43,6 +43,10 @@ def run_api_startup() -> None:
|
|||
"""Full API boot sequence previously inlined in ``main.on_startup``."""
|
||||
assert_secure_defaults_or_exit()
|
||||
_configure_ume_diag_logging()
|
||||
_log.info(
|
||||
"startup: ne_exec_policy_enabled=%s",
|
||||
bool(getattr(settings, "ne_exec_policy_enabled", False)),
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
alembic_ok = True
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
|
|||
from .device_types import SUPPORTED_DEVICE_TYPES
|
||||
from .models import CliConnectProfile, ManagedNE, UmeCliOverride, UmeInventoryNE
|
||||
from .ne_crypto import decrypt_secret
|
||||
from .ne_exec_guard import EXEC_POLICY_READONLY, normalize_exec_policy
|
||||
from .ne_service import get_device_credentials, row_to_out
|
||||
|
||||
_BUILTIN_NE_TYPE_RULES: list[tuple[re.Pattern[str], str, str]] = [
|
||||
|
|
@ -141,6 +142,7 @@ def resolve_cli_target(
|
|||
"port": meta["port"],
|
||||
"protocol": meta["protocol"],
|
||||
"connect_status": meta["connect_status"],
|
||||
"exec_policy": normalize_exec_policy(meta.get("exec_policy")),
|
||||
"hop_enabled": meta["hop_enabled"],
|
||||
"hop_vendor": meta["hop_vendor"],
|
||||
}
|
||||
|
|
@ -198,6 +200,7 @@ def resolve_cli_target(
|
|||
"port": int(profile.port or 22),
|
||||
"protocol": str(profile.protocol or "ssh"),
|
||||
"connect_status": connect_status,
|
||||
"exec_policy": EXEC_POLICY_READONLY,
|
||||
"hop_enabled": bool(profile.hop_enabled),
|
||||
"hop_vendor": str(profile.hop_vendor or ""),
|
||||
"cli_profile_id": str(profile.id),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,20 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
# Always load repo-root `.env` (cwd-independent). Optional cwd `.env` overrides last.
|
||||
_NETX_ROOT = Path(__file__).resolve().parents[1]
|
||||
_ENV_FILES = (str(_NETX_ROOT / ".env"), ".env")
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_prefix="NETX_", extra="ignore")
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=_ENV_FILES,
|
||||
env_prefix="NETX_",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
database_url: str = "postgresql+psycopg://netx:netx@127.0.0.1:5432/netx"
|
||||
host: str = "127.0.0.1"
|
||||
|
|
@ -114,6 +124,8 @@ class Settings(BaseSettings):
|
|||
biz_state_heavy_workers: int = 4
|
||||
# Managed NE exec: max CLI commands per request (lab can raise; hard-capped in ne_exec).
|
||||
ne_exec_max_commands: int = 5
|
||||
# Opt-in: allow per-NE exec_policy (linux_shell/unrestricted). Default off — UI hidden.
|
||||
ne_exec_policy_enabled: bool = False
|
||||
# WebCRT interactive terminal sessions (multi-operator concurrent terminals).
|
||||
webcrt_max_sessions: int = 40
|
||||
# Per-user cap (0 = unlimited beyond global max).
|
||||
|
|
|
|||
|
|
@ -75,9 +75,14 @@ def api_list_managed_ne(
|
|||
@router.get("/meta/device-types")
|
||||
def api_device_types():
|
||||
# Include generic/linux so LLDP/WebCRT placeholders can be edited without a bogus select value.
|
||||
from .config import settings
|
||||
from .device_types import WEBCRT_DEVICE_TYPES
|
||||
|
||||
return {"device_types": list(WEBCRT_DEVICE_TYPES), "vendors": list(SUPPORTED_VENDORS)}
|
||||
return {
|
||||
"device_types": list(WEBCRT_DEVICE_TYPES),
|
||||
"vendors": list(SUPPORTED_VENDORS),
|
||||
"exec_policy_enabled": bool(getattr(settings, "ne_exec_policy_enabled", False)),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/meta/credentials-configured")
|
||||
|
|
@ -150,7 +155,7 @@ def api_exec_managed_ne(
|
|||
ctx: Annotated[AuthContext, Depends(require_user)],
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Login to a managed NE or UME inventory NE and run read-only CLI (show/display/ping/traceroute)."""
|
||||
"""Login to a managed NE or UME inventory NE and run CLI (policy from managed NE exec_policy)."""
|
||||
uid, uname = _actor(ctx)
|
||||
out = execute_managed_ne_commands(
|
||||
db,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ class ManagedNE(Base):
|
|||
name: Mapped[str] = mapped_column(String(256), default="", index=True)
|
||||
vendor: Mapped[str] = mapped_column(String(64), default="Other", index=True)
|
||||
device_type: Mapped[str] = mapped_column(String(128), default="")
|
||||
# MCP/API execManagedNe command gate: readonly | linux_shell | unrestricted
|
||||
exec_policy: Mapped[str] = mapped_column(String(32), default="readonly")
|
||||
# Not unique: WebCRT sessions may share a host IP with distinct session names.
|
||||
# Inventory create/update still enforces uniqueness in ne_service.
|
||||
ip_address: Mapped[str] = mapped_column(String(128), index=True)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,12 @@ from .config import settings
|
|||
from .db import SessionLocal
|
||||
from .ne_collect_runner import _collect_on_device
|
||||
from .ne_crypto import credentials_configured
|
||||
from .ne_exec_guard import _validate_command, validate_ne_exec_command
|
||||
from .ne_exec_guard import (
|
||||
_validate_command,
|
||||
effective_exec_policy,
|
||||
normalize_exec_policy,
|
||||
validate_ne_exec_command,
|
||||
)
|
||||
|
||||
_EXEC_MAX_COMMANDS_CAP = 50
|
||||
_EXEC_MAX_OUTPUT = 32_000
|
||||
|
|
@ -28,6 +33,8 @@ __all__ = [
|
|||
"_validate_command",
|
||||
"execute_managed_ne_commands",
|
||||
"execute_managed_ne_commands_batch",
|
||||
"effective_exec_policy",
|
||||
"normalize_exec_policy",
|
||||
"validate_ne_exec_command",
|
||||
]
|
||||
|
||||
|
|
@ -62,10 +69,16 @@ def execute_managed_ne_commands(
|
|||
max_cmds = _exec_max_commands()
|
||||
if len(cmds) > max_cmds:
|
||||
raise HTTPException(status_code=400, detail=f"too_many_commands (max {max_cmds})")
|
||||
for c in cmds:
|
||||
validate_ne_exec_command(c)
|
||||
|
||||
creds, device = resolve_cli_target(db, managed_ne_id=mid or None, ume_ne_id=uid or None)
|
||||
exec_policy = effective_exec_policy(
|
||||
(device or {}).get("exec_policy") or (creds or {}).get("exec_policy"),
|
||||
device_type=(device or {}).get("device_type") or (creds or {}).get("device_type"),
|
||||
)
|
||||
if isinstance(device, dict):
|
||||
device["exec_policy"] = exec_policy
|
||||
for c in cmds:
|
||||
validate_ne_exec_command(c, policy=exec_policy)
|
||||
skip = cli_creds_skip_reason(creds, interactive=False)
|
||||
if skip:
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
"""NE CLI command allow/deny gates (read-only exec for ops tools)."""
|
||||
"""NE CLI command allow/deny gates (execManagedNe / ops tools).
|
||||
|
||||
Policies (per managed NE ``exec_policy``):
|
||||
|
||||
- ``readonly`` (default): network CLI only — show/display/ping/traceroute.
|
||||
- ``linux_shell``: single-line shell; no network prefix/pipe rules; no write-deny list.
|
||||
- ``unrestricted``: same as linux_shell (lab open); kept distinct for audit/UI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -6,6 +13,13 @@ import re
|
|||
|
||||
from fastapi import HTTPException
|
||||
|
||||
EXEC_POLICY_READONLY = "readonly"
|
||||
EXEC_POLICY_LINUX_SHELL = "linux_shell"
|
||||
EXEC_POLICY_UNRESTRICTED = "unrestricted"
|
||||
EXEC_POLICIES = frozenset(
|
||||
{EXEC_POLICY_READONLY, EXEC_POLICY_LINUX_SHELL, EXEC_POLICY_UNRESTRICTED}
|
||||
)
|
||||
|
||||
# Block obvious config-change / destructive patterns (case-insensitive).
|
||||
_BLOCKED_RE = re.compile(
|
||||
r"(?i)("
|
||||
|
|
@ -39,6 +53,49 @@ _ALLOWED_PIPE_SEGMENT_RE = re.compile(
|
|||
_BLOCKED_PIPE_SEGMENT_RE = re.compile(r"(?i)\b(redirect|append|tee|send)\b")
|
||||
|
||||
|
||||
def normalize_exec_policy(raw: str | None) -> str:
|
||||
p = str(raw or "").strip().lower()
|
||||
return p if p in EXEC_POLICIES else EXEC_POLICY_READONLY
|
||||
|
||||
|
||||
def is_linux_device_type(device_type: str | None) -> bool:
|
||||
low = str(device_type or "").strip().lower()
|
||||
return low in ("linux", "linux_ssh", "linux_telnet") or low.startswith("linux_")
|
||||
|
||||
|
||||
def exec_policy_feature_enabled() -> bool:
|
||||
"""Global kill-switch: off → always readonly (UI hidden, API rejects open policies)."""
|
||||
from .config import settings
|
||||
|
||||
return bool(getattr(settings, "ne_exec_policy_enabled", False))
|
||||
|
||||
|
||||
def effective_exec_policy(raw: str | None, *, device_type: str | None = None) -> str:
|
||||
"""Policy used at exec time (forces readonly when feature off or non-linux)."""
|
||||
if not exec_policy_feature_enabled():
|
||||
return EXEC_POLICY_READONLY
|
||||
pol = normalize_exec_policy(raw)
|
||||
if pol != EXEC_POLICY_READONLY and not is_linux_device_type(device_type):
|
||||
return EXEC_POLICY_READONLY
|
||||
return pol
|
||||
|
||||
|
||||
def require_exec_policy_writable(
|
||||
raw: str | None,
|
||||
*,
|
||||
device_type: str | None = None,
|
||||
) -> str:
|
||||
"""Normalize for create/update; reject open policies when feature off or non-linux."""
|
||||
pol = normalize_exec_policy(raw)
|
||||
if pol == EXEC_POLICY_READONLY:
|
||||
return pol
|
||||
if not exec_policy_feature_enabled():
|
||||
raise HTTPException(status_code=400, detail="exec_policy_feature_disabled")
|
||||
if not is_linux_device_type(device_type):
|
||||
raise HTTPException(status_code=400, detail="exec_policy_requires_linux_device_type")
|
||||
return pol
|
||||
|
||||
|
||||
def _validate_pipe_segments(cmd: str) -> None:
|
||||
if "|" not in cmd:
|
||||
return
|
||||
|
|
@ -52,13 +109,14 @@ def _validate_pipe_segments(cmd: str) -> None:
|
|||
raise HTTPException(status_code=400, detail="command_pipe_not_allowed")
|
||||
|
||||
|
||||
def validate_ne_exec_command(command: str) -> None:
|
||||
"""Raise HTTPException if command is empty, smuggled, blocked, or not allowlisted."""
|
||||
cmd = str(command or "").strip()
|
||||
if not cmd:
|
||||
raise HTTPException(status_code=400, detail="empty_command")
|
||||
if len(cmd) > 500:
|
||||
raise HTTPException(status_code=400, detail="command_too_long")
|
||||
def _validate_single_line(cmd: str) -> None:
|
||||
if any(ch in cmd for ch in ("\n", "\r")):
|
||||
raise HTTPException(status_code=400, detail="command_chars_not_allowed")
|
||||
if any(sep in cmd for sep in _FORBIDDEN_LINE_SEPARATORS):
|
||||
raise HTTPException(status_code=400, detail="command_chars_not_allowed")
|
||||
|
||||
|
||||
def _validate_readonly_command(cmd: str) -> None:
|
||||
if any(ch in cmd for ch in (";", "\n", "\r", "`")):
|
||||
raise HTTPException(status_code=400, detail="command_chars_not_allowed")
|
||||
if any(sep in cmd for sep in _FORBIDDEN_LINE_SEPARATORS):
|
||||
|
|
@ -70,5 +128,20 @@ def validate_ne_exec_command(command: str) -> None:
|
|||
_validate_pipe_segments(cmd)
|
||||
|
||||
|
||||
def validate_ne_exec_command(command: str, *, policy: str = EXEC_POLICY_READONLY) -> None:
|
||||
"""Raise HTTPException if command is empty, smuggled, blocked, or not allowlisted."""
|
||||
cmd = str(command or "").strip()
|
||||
if not cmd:
|
||||
raise HTTPException(status_code=400, detail="empty_command")
|
||||
if len(cmd) > 500:
|
||||
raise HTTPException(status_code=400, detail="command_too_long")
|
||||
pol = normalize_exec_policy(policy)
|
||||
if pol in (EXEC_POLICY_LINUX_SHELL, EXEC_POLICY_UNRESTRICTED):
|
||||
# One command string per slot; shell metacharacters (|;&&`$) allowed.
|
||||
_validate_single_line(cmd)
|
||||
return
|
||||
_validate_readonly_command(cmd)
|
||||
|
||||
|
||||
# Back-compat alias used by tests / callers.
|
||||
_validate_command = validate_ne_exec_command
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ from typing import Literal
|
|||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from .device_types import SUPPORTED_VENDORS
|
||||
from .ne_exec_guard import EXEC_POLICIES, EXEC_POLICY_READONLY
|
||||
|
||||
ConnectStatus = Literal["unknown", "testing", "pass", "fail"]
|
||||
ExecPolicy = Literal["readonly", "linux_shell", "unrestricted"]
|
||||
|
||||
|
||||
class ManagedNeCreate(BaseModel):
|
||||
|
|
@ -21,6 +23,7 @@ class ManagedNeCreate(BaseModel):
|
|||
password: str = ""
|
||||
tags: str = ""
|
||||
remark: str = ""
|
||||
exec_policy: ExecPolicy = "readonly"
|
||||
hop_enabled: bool = False
|
||||
hop_vendor: str = "zte"
|
||||
hop_host: str = ""
|
||||
|
|
@ -44,6 +47,16 @@ class ManagedNeCreate(BaseModel):
|
|||
return item
|
||||
return "Other"
|
||||
|
||||
@field_validator("exec_policy", mode="before")
|
||||
@classmethod
|
||||
def normalize_exec_policy_create(cls, v: object) -> str:
|
||||
if v is None or str(v).strip() == "":
|
||||
return EXEC_POLICY_READONLY
|
||||
raw = str(v).strip().lower()
|
||||
if raw not in EXEC_POLICIES:
|
||||
raise ValueError("unsupported_exec_policy")
|
||||
return raw
|
||||
|
||||
|
||||
class ManagedNeUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
|
|
@ -56,6 +69,7 @@ class ManagedNeUpdate(BaseModel):
|
|||
password: str | None = None
|
||||
tags: str | None = None
|
||||
remark: str | None = None
|
||||
exec_policy: ExecPolicy | None = None
|
||||
hop_enabled: bool | None = None
|
||||
hop_vendor: str | None = None
|
||||
hop_host: str | None = None
|
||||
|
|
@ -81,6 +95,18 @@ class ManagedNeUpdate(BaseModel):
|
|||
return item
|
||||
return "Other"
|
||||
|
||||
@field_validator("exec_policy", mode="before")
|
||||
@classmethod
|
||||
def normalize_exec_policy_update(cls, v: object) -> str | None:
|
||||
if v is None:
|
||||
return None
|
||||
raw = str(v).strip().lower()
|
||||
if not raw:
|
||||
return EXEC_POLICY_READONLY
|
||||
if raw not in EXEC_POLICIES:
|
||||
raise ValueError("unsupported_exec_policy")
|
||||
return raw
|
||||
|
||||
|
||||
class ManagedNeOut(BaseModel):
|
||||
id: str
|
||||
|
|
@ -102,6 +128,7 @@ class ManagedNeOut(BaseModel):
|
|||
# Provenance: "" | ume_sync | webcrt | lldp | …
|
||||
source: str = ""
|
||||
source_ref: str = ""
|
||||
exec_policy: ExecPolicy = "readonly"
|
||||
hop_enabled: bool = False
|
||||
hop_vendor: str = "zte"
|
||||
hop_host: str = ""
|
||||
|
|
@ -121,7 +148,7 @@ class ConnectTestRequest(BaseModel):
|
|||
|
||||
|
||||
class ManagedNeExecRequest(BaseModel):
|
||||
"""Run read-only show/display CLI on a managed NE or UME inventory NE (oclaw ops integration)."""
|
||||
"""Run CLI on a managed NE or UME inventory NE (gates follow managed NE exec_policy)."""
|
||||
|
||||
ne_id: str | None = None
|
||||
ume_ne_id: str | None = None
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from .ne_crypto import CredentialCryptoError, credentials_configured, decrypt_se
|
|||
from .ne_schemas import ManagedNeCreate, ManagedNeOut, ManagedNeUpdate
|
||||
from .ne_hop_templates import expand_bastion_hop_fields, normalize_hop_host
|
||||
from .ne_session_factory import default_bastion_username_template, default_hop_command_template
|
||||
from .ne_exec_guard import normalize_exec_policy
|
||||
from .timeutil import utcnow_naive
|
||||
|
||||
IMPORT_COLUMNS = (
|
||||
|
|
@ -241,6 +242,7 @@ def row_to_out(row: ManagedNE) -> ManagedNeOut:
|
|||
remark=str(row.remark or ""),
|
||||
source=str(row.source or ""),
|
||||
source_ref=str(row.source_ref or ""),
|
||||
exec_policy=normalize_exec_policy(getattr(row, "exec_policy", None)), # type: ignore[arg-type]
|
||||
hop_enabled=bool(row.hop_enabled),
|
||||
hop_vendor=str(row.hop_vendor or "zte"),
|
||||
hop_host=str(row.hop_host or ""),
|
||||
|
|
@ -273,6 +275,7 @@ def get_device_credentials(row: ManagedNE) -> dict[str, Any]:
|
|||
"password": decrypt_secret(row.password_enc),
|
||||
"enable_secret": decrypt_secret(row.enable_secret_enc),
|
||||
"name": str(row.name or ""),
|
||||
"exec_policy": normalize_exec_policy(getattr(row, "exec_policy", None)),
|
||||
"hop_enabled": hop_enabled,
|
||||
"hop_vendor": str(row.hop_vendor or "zte"),
|
||||
"hop_host": str(row.hop_host or ""),
|
||||
|
|
|
|||
|
|
@ -33,6 +33,11 @@ from .ne_service_common import (
|
|||
_validate_hop_on_create,
|
||||
row_to_out,
|
||||
)
|
||||
from .ne_exec_guard import (
|
||||
EXEC_POLICY_READONLY,
|
||||
is_linux_device_type,
|
||||
require_exec_policy_writable,
|
||||
)
|
||||
|
||||
# Inventory create stays strict; updates must also accept WebCRT/LLDP placeholder types
|
||||
# (generic/linux) so operators can open the form and promote them to zte_zxros etc.
|
||||
|
|
@ -116,6 +121,10 @@ def create_managed_ne(db: Session, body: ManagedNeCreate) -> ManagedNeOut:
|
|||
remark=str(body.remark or "").strip(),
|
||||
source="",
|
||||
source_ref="",
|
||||
exec_policy=require_exec_policy_writable(
|
||||
getattr(body, "exec_policy", None),
|
||||
device_type=body.device_type,
|
||||
),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
|
@ -159,6 +168,14 @@ def update_managed_ne(db: Session, ne_id: str, body: ManagedNeUpdate) -> Managed
|
|||
row.tags = str(data["tags"]).strip()
|
||||
if "remark" in data and data["remark"] is not None:
|
||||
row.remark = str(data["remark"]).strip()
|
||||
if "exec_policy" in data and data["exec_policy"] is not None:
|
||||
row.exec_policy = require_exec_policy_writable(
|
||||
str(data["exec_policy"]),
|
||||
device_type=row.device_type,
|
||||
)
|
||||
elif "device_type" in data and not is_linux_device_type(row.device_type):
|
||||
# Leaving linux clears any previously open policy.
|
||||
row.exec_policy = EXEC_POLICY_READONLY
|
||||
if "password" in data and data["password"]:
|
||||
_require_crypto()
|
||||
row.password_enc = encrypt_secret(str(data["password"]))
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ def apply_collection_schema_safety_net(conn: Connection) -> None:
|
|||
|
||||
|
||||
def apply_hop_schema_safety_net(conn: Connection) -> None:
|
||||
"""Always-on hop columns (Alembic head stamp skips legacy domain patches)."""
|
||||
"""Always-on hop / exec_policy columns (Alembic head stamp skips legacy domain patches)."""
|
||||
_run_sql(
|
||||
conn,
|
||||
"ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_enter_system_view BOOLEAN DEFAULT FALSE",
|
||||
|
|
@ -209,6 +209,10 @@ def apply_hop_schema_safety_net(conn: Connection) -> None:
|
|||
conn,
|
||||
"ALTER TABLE cli_connect_profile ADD COLUMN IF NOT EXISTS hop_enter_system_view BOOLEAN DEFAULT FALSE",
|
||||
)
|
||||
_run_sql(
|
||||
conn,
|
||||
"ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS exec_policy VARCHAR(32) DEFAULT 'readonly'",
|
||||
)
|
||||
|
||||
|
||||
def apply_key_alert_schema_patches(
|
||||
|
|
@ -539,14 +543,48 @@ def apply_all_legacy_startup_ddl(engine: Engine) -> None:
|
|||
|
||||
|
||||
def run_alembic_upgrade_to_head() -> None:
|
||||
"""Programmatic ``alembic upgrade head`` (optional on API start)."""
|
||||
"""Programmatic ``alembic upgrade head`` (optional on API start).
|
||||
|
||||
Skip the full Alembic command when already at head — avoids noisy
|
||||
``Context impl`` logs and lock waits when nothing to apply.
|
||||
"""
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
from .config import settings
|
||||
|
||||
t0 = time.monotonic()
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
cfg = Config(str(root / "alembic.ini"))
|
||||
# env.py reads settings.database_url; keep ini placeholder overwritten there.
|
||||
cfg.set_main_option("sqlalchemy.url", settings.database_url)
|
||||
|
||||
script = ScriptDirectory.from_config(cfg)
|
||||
head = script.get_current_head()
|
||||
engine = create_engine(settings.database_url, pool_pre_ping=True)
|
||||
current: str | None = None
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
try:
|
||||
row = conn.execute(text("SELECT version_num FROM alembic_version")).fetchone()
|
||||
current = str(row[0]) if row and row[0] is not None else None
|
||||
except Exception:
|
||||
current = None
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
if head and current == head:
|
||||
_log.info(
|
||||
"alembic already at head (%s), skip upgrade (%.2fs)",
|
||||
head,
|
||||
time.monotonic() - t0,
|
||||
)
|
||||
return
|
||||
|
||||
_log.info("alembic upgrading %s -> %s …", current, head)
|
||||
command.upgrade(cfg, "head")
|
||||
_log.info("alembic upgrade head completed")
|
||||
_log.info("alembic upgrade head completed (%.2fs)", time.monotonic() - t0)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue