py-app-runner 0.5.49.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- py_app_runner/__init__.py +11 -0
- py_app_runner/audit/__init__.py +29 -0
- py_app_runner/audit/_service.py +91 -0
- py_app_runner/audit/_service_args.py +44 -0
- py_app_runner/audit/audit.py +319 -0
- py_app_runner/audit/commands.py +151 -0
- py_app_runner/audit/diff.py +202 -0
- py_app_runner/audit/errors.py +8 -0
- py_app_runner/audit/event.py +130 -0
- py_app_runner/audit/store.py +134 -0
- py_app_runner/bridge/__init__.py +0 -0
- py_app_runner/bridge/_service.py +265 -0
- py_app_runner/bridge/_service_args.py +24 -0
- py_app_runner/bridge/api.py +138 -0
- py_app_runner/bridge/encoders/__init__.py +5 -0
- py_app_runner/bridge/encoders/base.py +24 -0
- py_app_runner/bridge/encoders/json_encoder.py +26 -0
- py_app_runner/bridge/encoders/msgpack_encoder.py +58 -0
- py_app_runner/bridge/web_app.py +31 -0
- py_app_runner/bridge/websocket.py +313 -0
- py_app_runner/colors.py +73 -0
- py_app_runner/config.py +132 -0
- py_app_runner/crypto/__init__.py +14 -0
- py_app_runner/crypto/_service.py +75 -0
- py_app_runner/crypto/_service_args.py +54 -0
- py_app_runner/crypto/commands.py +164 -0
- py_app_runner/crypto/envelope.py +144 -0
- py_app_runner/crypto/errors.py +8 -0
- py_app_runner/crypto/fields.py +300 -0
- py_app_runner/crypto/passwords.py +66 -0
- py_app_runner/db_pools.py +20 -0
- py_app_runner/http_exception.py +31 -0
- py_app_runner/logger_handlers.py +167 -0
- py_app_runner/migrations/__init__.py +5 -0
- py_app_runner/migrations/_service.py +296 -0
- py_app_runner/migrations/_service_args.py +91 -0
- py_app_runner/migrations/commands.py +386 -0
- py_app_runner/migrations/discovery.py +108 -0
- py_app_runner/migrations/states.py +63 -0
- py_app_runner/migrations/tracker.py +141 -0
- py_app_runner/py.typed +0 -0
- py_app_runner/pybridge.py +64 -0
- py_app_runner/queue/__init__.py +25 -0
- py_app_runner/queue/_service.py +231 -0
- py_app_runner/queue/_service_args.py +67 -0
- py_app_runner/queue/commands.py +180 -0
- py_app_runner/queue/driver_pg.py +464 -0
- py_app_runner/queue/driver_redis.py +613 -0
- py_app_runner/queue/handler.py +90 -0
- py_app_runner/queue/interface.py +63 -0
- py_app_runner/queue/job.py +46 -0
- py_app_runner/queue/worker.py +221 -0
- py_app_runner/registry.py +54 -0
- py_app_runner/request_handler/__init__.py +0 -0
- py_app_runner/request_handler/auth_service.py +123 -0
- py_app_runner/request_handler/decorators.py +304 -0
- py_app_runner/request_handler/handlers.py +604 -0
- py_app_runner/request_handler/pagination.py +24 -0
- py_app_runner/return_model.py +78 -0
- py_app_runner/runner.py +182 -0
- py_app_runner/throttle/__init__.py +5 -0
- py_app_runner/throttle/throttle.py +217 -0
- py_app_runner/tick_service.py +308 -0
- py_app_runner/timer.py +289 -0
- py_app_runner/utils.py +346 -0
- py_app_runner/wbcm/__init__.py +0 -0
- py_app_runner/wbcm/device_connections.py +89 -0
- py_app_runner/wbcm/factory.py +113 -0
- py_app_runner/wbcm/wb_connection_manager.py +333 -0
- py_app_runner/wbcm/ws_interface.py +56 -0
- py_app_runner-0.5.49.dev0.dist-info/METADATA +134 -0
- py_app_runner-0.5.49.dev0.dist-info/RECORD +75 -0
- py_app_runner-0.5.49.dev0.dist-info/WHEEL +5 -0
- py_app_runner-0.5.49.dev0.dist-info/licenses/LICENSE +21 -0
- py_app_runner-0.5.49.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from database_wrapper_pgsql import PgsqlWithPoolingAsync
|
|
2
|
+
from database_wrapper_redis import RedisDbWithPoolAsync
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class DbPools:
|
|
6
|
+
"""
|
|
7
|
+
Concrete class to hold database connection pools and wrappers for use
|
|
8
|
+
throughout the application
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
cache_db_pool: RedisDbWithPoolAsync
|
|
12
|
+
main_db_pool: PgsqlWithPoolingAsync
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
cache_db_pool: RedisDbWithPoolAsync,
|
|
17
|
+
main_db_pool: PgsqlWithPoolingAsync,
|
|
18
|
+
):
|
|
19
|
+
self.cache_db_pool = cache_db_pool
|
|
20
|
+
self.main_db_pool = main_db_pool
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class HTTPException(Exception):
|
|
5
|
+
message: str
|
|
6
|
+
code: int
|
|
7
|
+
description: str | None
|
|
8
|
+
http_status: int
|
|
9
|
+
logged: bool
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
message: str,
|
|
14
|
+
code: int = 1,
|
|
15
|
+
description: str | None = None,
|
|
16
|
+
http_status: int = 400,
|
|
17
|
+
logged: bool = False,
|
|
18
|
+
) -> None:
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
|
|
21
|
+
self.message = message
|
|
22
|
+
self.code = code
|
|
23
|
+
self.description = description
|
|
24
|
+
self.http_status = http_status
|
|
25
|
+
self.logged = logged
|
|
26
|
+
|
|
27
|
+
def to_dict(self) -> dict[str, Any]:
|
|
28
|
+
result: dict[str, Any] = {"msg": self.message, "code": self.code}
|
|
29
|
+
if self.description:
|
|
30
|
+
result["description"] = self.description
|
|
31
|
+
return result
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import socket
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
from argparse import Namespace
|
|
6
|
+
from difflib import SequenceMatcher
|
|
7
|
+
from typing import TypedDict
|
|
8
|
+
|
|
9
|
+
import sentry_sdk
|
|
10
|
+
from sentry_sdk.integrations.redis import RedisIntegration
|
|
11
|
+
from sentry_sdk.integrations.tornado import TornadoIntegration
|
|
12
|
+
from sentry_sdk.types import Event, Hint
|
|
13
|
+
|
|
14
|
+
from py_app_runner.registry import AppRegistry
|
|
15
|
+
|
|
16
|
+
from .colors import Colors
|
|
17
|
+
from .http_exception import HTTPException
|
|
18
|
+
|
|
19
|
+
# Constants
|
|
20
|
+
ERROR_RATE = 60 # Minute
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Types
|
|
24
|
+
class LastError(TypedDict):
|
|
25
|
+
count: int
|
|
26
|
+
time: int
|
|
27
|
+
msg: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Globals
|
|
31
|
+
last_error: LastError = {"count": 0, "time": 0, "msg": ""}
|
|
32
|
+
logger = logging.getLogger("meta")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
#################
|
|
36
|
+
### Functions ###
|
|
37
|
+
#################
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def RateControl(event: Event, hint: Hint) -> Event | None:
|
|
41
|
+
if "exc_info" in hint:
|
|
42
|
+
_exc_type, exc_value, _tb = hint["exc_info"]
|
|
43
|
+
if isinstance(exc_value, HTTPException):
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
if "extra" in event and "dispatch" in event["extra"] and not event["extra"]["dispatch"]:
|
|
47
|
+
sys.stderr.write("#### Manually discarded error message meant to be sent via Sentry\n")
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
now = int(time.time())
|
|
51
|
+
current_event = str(event)
|
|
52
|
+
test = SequenceMatcher(None, last_error["msg"], current_event).ratio()
|
|
53
|
+
if last_error["time"] == 0 or now - int(last_error["time"]) >= ERROR_RATE or test < 0.4:
|
|
54
|
+
sys.stderr.write("#### Allowing Sentry to send error message\n")
|
|
55
|
+
last_error["count"] = 0
|
|
56
|
+
last_error["time"] = now
|
|
57
|
+
last_error["msg"] = current_event
|
|
58
|
+
return event
|
|
59
|
+
|
|
60
|
+
sys.stderr.write("#### Discarded error message meant to be sent to Sentry\n")
|
|
61
|
+
last_error["count"] += 1
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def InitSentry(
|
|
66
|
+
args: Namespace,
|
|
67
|
+
sentry_dns: str,
|
|
68
|
+
release: str,
|
|
69
|
+
environment: str,
|
|
70
|
+
server_name: str | None = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
config = AppRegistry.config()
|
|
73
|
+
if config.get("environment") != "prod":
|
|
74
|
+
logger.debug(f"Sentry logging disabled in {config['environment']}")
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
logger.debug("Enabling Sentry logging")
|
|
78
|
+
server_ip = socket.gethostbyname(socket.gethostname())
|
|
79
|
+
with sentry_sdk.configure_scope() as scope:
|
|
80
|
+
scope.set_tag("server_ip", server_ip)
|
|
81
|
+
scope.set_level(args.sv)
|
|
82
|
+
|
|
83
|
+
# Tracing/profiling rates come from config when a project sets them; 0 disables.
|
|
84
|
+
rates = (config.get("sentry") or {}).get("rate") or {}
|
|
85
|
+
|
|
86
|
+
sentry_sdk.init(
|
|
87
|
+
sentry_dns,
|
|
88
|
+
release=release,
|
|
89
|
+
environment=environment,
|
|
90
|
+
server_name=server_name,
|
|
91
|
+
attach_stacktrace=True,
|
|
92
|
+
before_send=RateControl,
|
|
93
|
+
integrations=[TornadoIntegration(), RedisIntegration()],
|
|
94
|
+
traces_sample_rate=float(rates.get("performance", 0) or 0),
|
|
95
|
+
profiles_sample_rate=float(rates.get("profiles", 0) or 0),
|
|
96
|
+
ignore_errors=[KeyboardInterrupt, HTTPException],
|
|
97
|
+
# debug=True,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
###############
|
|
102
|
+
### Classes ###
|
|
103
|
+
###############
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class ConsoleHandler(logging.Handler):
|
|
107
|
+
name_filter: str | None
|
|
108
|
+
|
|
109
|
+
should_buffer: bool
|
|
110
|
+
the_buffer: list[logging.LogRecord]
|
|
111
|
+
|
|
112
|
+
def __init__(self, filter: str | None = None, level: int = logging.NOTSET):
|
|
113
|
+
super().__init__(level)
|
|
114
|
+
|
|
115
|
+
self.name_filter = filter
|
|
116
|
+
self.should_buffer = False
|
|
117
|
+
self.the_buffer = []
|
|
118
|
+
|
|
119
|
+
def emit_buffer(self) -> None:
|
|
120
|
+
self.should_buffer = False
|
|
121
|
+
|
|
122
|
+
for record in self.the_buffer:
|
|
123
|
+
self.emit(record)
|
|
124
|
+
self.the_buffer = []
|
|
125
|
+
|
|
126
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
127
|
+
if self.name_filter is not None and not record.name.startswith(self.name_filter):
|
|
128
|
+
return # Skip logging for this module
|
|
129
|
+
|
|
130
|
+
if self.should_buffer:
|
|
131
|
+
self.the_buffer.append(record)
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
# Colors
|
|
135
|
+
prepend = ""
|
|
136
|
+
append = ""
|
|
137
|
+
if record.levelno == logging.INFO:
|
|
138
|
+
prepend = Colors.Yellow
|
|
139
|
+
append = Colors.ColorOff
|
|
140
|
+
elif record.levelno == logging.DEBUG:
|
|
141
|
+
prepend = Colors.Cyan
|
|
142
|
+
append = Colors.ColorOff
|
|
143
|
+
elif record.levelno == logging.WARNING:
|
|
144
|
+
prepend = Colors.Purple
|
|
145
|
+
append = Colors.ColorOff
|
|
146
|
+
elif record.levelno == logging.ERROR:
|
|
147
|
+
prepend = Colors.Red
|
|
148
|
+
append = Colors.ColorOff
|
|
149
|
+
|
|
150
|
+
# End char for printing to console
|
|
151
|
+
end: str = getattr(record, "end", "\n")
|
|
152
|
+
prefix: str = getattr(record, "prefix", "")
|
|
153
|
+
|
|
154
|
+
# Date
|
|
155
|
+
date = ""
|
|
156
|
+
clean = getattr(record, "clean", False)
|
|
157
|
+
skip_date = getattr(record, "skip_date", False)
|
|
158
|
+
if not clean:
|
|
159
|
+
date = f"[{record.levelname}] [{record.name}] "
|
|
160
|
+
if not skip_date:
|
|
161
|
+
date = f"[{time.strftime('%d.%m.%Y %H:%M:%S')}] [{record.levelname}] [{record.name}] "
|
|
162
|
+
|
|
163
|
+
# Message
|
|
164
|
+
error_msg = f"{prepend}{date}{self.format(record)}{append}"
|
|
165
|
+
|
|
166
|
+
# Print to console
|
|
167
|
+
sys.stderr.write(f"{prefix}{error_msg}{end}")
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import pathlib
|
|
4
|
+
import socket
|
|
5
|
+
from argparse import Namespace
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import psycopg
|
|
11
|
+
|
|
12
|
+
from py_app_runner.migrations.commands import (
|
|
13
|
+
Out,
|
|
14
|
+
cmd_apply,
|
|
15
|
+
cmd_baseline,
|
|
16
|
+
cmd_new,
|
|
17
|
+
cmd_repair,
|
|
18
|
+
cmd_status,
|
|
19
|
+
)
|
|
20
|
+
from py_app_runner.migrations.discovery import MigrationError
|
|
21
|
+
from py_app_runner.pybridge import PyBridge
|
|
22
|
+
from py_app_runner.registry import AppRegistry
|
|
23
|
+
|
|
24
|
+
_DEFAULT_DIR = "data/migrations"
|
|
25
|
+
_DEFAULT_TABLE = "migrations"
|
|
26
|
+
_DEFAULT_TARGET_NAME = "main"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def connect_kwargs(db_config: dict[str, Any]) -> dict[str, Any]:
|
|
30
|
+
"""PgsqlConfig uses hostname/username/database; psycopg wants host/user/dbname.
|
|
31
|
+
|
|
32
|
+
autocommit is on because this service manages transactions per migration file
|
|
33
|
+
explicitly - an implicit outer transaction would swallow the per-file boundaries and
|
|
34
|
+
make the no-transaction directive impossible to honour.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
"host": db_config["hostname"],
|
|
39
|
+
"port": db_config.get("port") or 5432,
|
|
40
|
+
"user": db_config["username"],
|
|
41
|
+
"password": db_config["password"],
|
|
42
|
+
"dbname": db_config["database"],
|
|
43
|
+
"sslmode": db_config.get("ssl") or "prefer",
|
|
44
|
+
"autocommit": True,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class Target:
|
|
50
|
+
name: str
|
|
51
|
+
db: str
|
|
52
|
+
directory: pathlib.Path
|
|
53
|
+
table: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _resolve_directory(raw_dir: str | None, config: dict[str, Any]) -> pathlib.Path:
|
|
57
|
+
directory = pathlib.Path(raw_dir or _DEFAULT_DIR)
|
|
58
|
+
if directory.is_absolute():
|
|
59
|
+
return directory
|
|
60
|
+
|
|
61
|
+
current_path = config.get("current_path")
|
|
62
|
+
if current_path is None:
|
|
63
|
+
raise MigrationError(
|
|
64
|
+
f'migrations dir {str(directory)!r} is relative but config has no "current_path" to '
|
|
65
|
+
f"resolve it against; either set current_path or use an absolute dir."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
return pathlib.Path(current_path) / directory
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _database_identity(db_config: dict[str, Any]) -> tuple[Any, Any, Any]:
|
|
72
|
+
"""The physical database a `config["db"]` entry points at.
|
|
73
|
+
|
|
74
|
+
Two different db keys can name the same host/port/dbname - a dev box collapsing "main"
|
|
75
|
+
and "gis" onto one postgres database is the documented case - so the tracking-table
|
|
76
|
+
collision check below has to compare on this, not on the db key. The port default
|
|
77
|
+
mirrors connect_kwargs, so both agree on what an absent port means.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
return (db_config.get("hostname"), db_config.get("port") or 5432, db_config.get("database"))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _reject_shared_tracking_tables(targets: dict[str, Target], db_config: dict[str, Any]) -> None:
|
|
84
|
+
"""Two targets sharing a database is legal and supported; sharing a database *and* a
|
|
85
|
+
tracking table is not. They would each read the other's rows, see no matching file and
|
|
86
|
+
report MISSING - and the remediation `apply` prints for MISSING is a DELETE of the
|
|
87
|
+
tracking row, which de-registers a genuinely applied migration and re-runs it on the
|
|
88
|
+
next apply. Migrations are explicitly allowed to be non-idempotent, so that is a
|
|
89
|
+
data-corruption path reached by following the tool's own advice."""
|
|
90
|
+
|
|
91
|
+
seen: dict[tuple[Any, Any, Any, str], str] = {}
|
|
92
|
+
for target in targets.values():
|
|
93
|
+
identity = (*_database_identity(db_config[target.db]), target.table)
|
|
94
|
+
clash = seen.get(identity)
|
|
95
|
+
if clash is not None:
|
|
96
|
+
raise MigrationError(
|
|
97
|
+
f"migrations targets {clash!r} and {target.name!r} resolve to the same physical "
|
|
98
|
+
f"database and both track in table {target.table!r}; each would report the other's "
|
|
99
|
+
f'migrations as MISSING. Give at least one of them its own "table" under '
|
|
100
|
+
f'config["migrations"]["targets"]. Sharing a database is fine - sharing a database '
|
|
101
|
+
f"and a tracking table is not."
|
|
102
|
+
)
|
|
103
|
+
seen[identity] = target.name
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def resolve_targets(config: dict[str, Any]) -> dict[str, Target]:
|
|
107
|
+
"""Resolve `config["migrations"]` into an ordered mapping of target name -> Target.
|
|
108
|
+
|
|
109
|
+
Two shapes are supported and never mixed. The flat shape (today's shape, and what all
|
|
110
|
+
four consuming projects still ship) synthesises a single target named "main" against
|
|
111
|
+
db "main" - this is the backward-compatibility path and must keep working with zero
|
|
112
|
+
config changes. The "targets" shape opts a project into multiple named targets, each
|
|
113
|
+
with its own db/dir/table; presence of the "targets" key alone selects it.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
settings = config.get("migrations") or {}
|
|
117
|
+
|
|
118
|
+
if "targets" in settings:
|
|
119
|
+
flat_keys = {"dir", "table"} & settings.keys()
|
|
120
|
+
if flat_keys:
|
|
121
|
+
raise MigrationError(
|
|
122
|
+
f'config["migrations"] mixes "targets" with the flat key(s) '
|
|
123
|
+
f"{', '.join(sorted(flat_keys))}; that is ambiguous, not merged. Move dir/table "
|
|
124
|
+
f'into each entry under config["migrations"]["targets"] instead.'
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
raw_targets = settings["targets"]
|
|
128
|
+
if not raw_targets:
|
|
129
|
+
raise MigrationError(
|
|
130
|
+
'config["migrations"]["targets"] is present but empty; declare at least one target.'
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
db_config = config.get("db") or {}
|
|
134
|
+
targets: dict[str, Target] = {}
|
|
135
|
+
for name, raw_entry in raw_targets.items():
|
|
136
|
+
entry = raw_entry or {}
|
|
137
|
+
db_name = entry.get("db") or name
|
|
138
|
+
if db_name not in db_config:
|
|
139
|
+
raise MigrationError(
|
|
140
|
+
f"migrations target {name!r} names db {db_name!r}, which is not configured under "
|
|
141
|
+
f'config["db"]; configured db keys are: {", ".join(sorted(db_config)) or "none"}.'
|
|
142
|
+
)
|
|
143
|
+
targets[name] = Target(
|
|
144
|
+
name=name,
|
|
145
|
+
db=db_name,
|
|
146
|
+
directory=_resolve_directory(entry.get("dir"), config),
|
|
147
|
+
table=entry.get("table") or _DEFAULT_TABLE,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
_reject_shared_tracking_tables(targets, db_config)
|
|
151
|
+
return targets
|
|
152
|
+
|
|
153
|
+
directory = _resolve_directory(settings.get("dir"), config)
|
|
154
|
+
table = settings.get("table") or _DEFAULT_TABLE
|
|
155
|
+
target = Target(name=_DEFAULT_TARGET_NAME, db=_DEFAULT_TARGET_NAME, directory=directory, table=table)
|
|
156
|
+
return {_DEFAULT_TARGET_NAME: target}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# status and apply run every configured target unless narrowed with --target: status is a
|
|
160
|
+
# read-only report, and apply's own per-target failure handling (see below) is what keeps a
|
|
161
|
+
# fan-out safe. new/baseline/repair are single-item: baseline in particular writes tracking
|
|
162
|
+
# rows without ever running the SQL, so a mis-stamped baseline against the wrong target
|
|
163
|
+
# produces a permanently green `status` over a database that never got its tables - the
|
|
164
|
+
# worst failure this tool can produce, silently. Guessing which target that should be is not
|
|
165
|
+
# acceptable, so those three refuse outright when more than one target is configured and
|
|
166
|
+
# --target was not given.
|
|
167
|
+
_FAN_OUT_STEPS = frozenset({"status", "apply"})
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _out_for(name: str, multi: bool) -> Out:
|
|
171
|
+
"""Single-target output must stay byte-identical to today, so `print` is used directly
|
|
172
|
+
below whenever only one target is in play. In the multi-target case, each line is
|
|
173
|
+
prefixed with its target's name - except blank lines, which commands use as visual
|
|
174
|
+
separators; a `[name] ` prefix on those would just be noise."""
|
|
175
|
+
|
|
176
|
+
if not multi:
|
|
177
|
+
return print
|
|
178
|
+
|
|
179
|
+
def prefixed(line: str) -> None:
|
|
180
|
+
print(f"[{name}] {line}" if line else "")
|
|
181
|
+
|
|
182
|
+
return prefixed
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _select_targets(step: str, requested: str | None, targets: dict[str, Target]) -> list[Target]:
|
|
186
|
+
if requested is not None:
|
|
187
|
+
if requested not in targets:
|
|
188
|
+
print(
|
|
189
|
+
f"error: no migrations target {requested!r}; configured targets are: "
|
|
190
|
+
f"{', '.join(targets) or 'none'}."
|
|
191
|
+
)
|
|
192
|
+
raise SystemExit(1)
|
|
193
|
+
|
|
194
|
+
return [targets[requested]]
|
|
195
|
+
|
|
196
|
+
if step in _FAN_OUT_STEPS or len(targets) == 1:
|
|
197
|
+
return list(targets.values())
|
|
198
|
+
|
|
199
|
+
print(
|
|
200
|
+
f"error: migrations {step!r} needs --target since more than one target is configured; "
|
|
201
|
+
f"configured targets are: {', '.join(targets)}."
|
|
202
|
+
)
|
|
203
|
+
raise SystemExit(1)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
async def init_service(args: Namespace, _pybridge: PyBridge, logger: logging.Logger) -> None:
|
|
207
|
+
config = AppRegistry.config()
|
|
208
|
+
|
|
209
|
+
# runner.py catches Exception around init_service, logs it and returns normally - which
|
|
210
|
+
# exits 0. For a long-running service that is deliberate, but here it would tell an
|
|
211
|
+
# unattended playbook that migrations succeeded when the database was merely unreachable
|
|
212
|
+
# (or, for a multi-target config, mis-declared - an unknown `db` key under
|
|
213
|
+
# config["migrations"]["targets"] is exactly what resolve_targets raises on), and the
|
|
214
|
+
# playbook would go on to restart services against an unmigrated schema. Silent success
|
|
215
|
+
# is the one outcome this tool must never produce, so every non-SystemExit failure -
|
|
216
|
+
# config resolution included, not just the database connection - is converted into a
|
|
217
|
+
# non-zero exit here, inside the service, without touching runner.py. SystemExit derives
|
|
218
|
+
# from BaseException, so _select_targets's own refusals and the happy-path exit below are
|
|
219
|
+
# not re-wrapped.
|
|
220
|
+
code = 1
|
|
221
|
+
try:
|
|
222
|
+
targets = resolve_targets(config)
|
|
223
|
+
selected = _select_targets(args.step, getattr(args, "target", None), targets)
|
|
224
|
+
if not selected:
|
|
225
|
+
# resolve_targets always returns at least one target, and _select_targets never
|
|
226
|
+
# returns empty on a path that doesn't already SystemExit - this guards that
|
|
227
|
+
# invariant rather than trusting it. Falling through silently would exit 0 with
|
|
228
|
+
# nothing having run, which is exactly the failure mode this function exists to
|
|
229
|
+
# prevent.
|
|
230
|
+
raise RuntimeError("migrations: no targets selected; this should be unreachable")
|
|
231
|
+
|
|
232
|
+
multi = len(selected) > 1
|
|
233
|
+
|
|
234
|
+
if args.step == "new":
|
|
235
|
+
# Synchronous and needs no database - dispatched before any connection is
|
|
236
|
+
# opened. _select_targets already refused ambiguity above, so exactly one
|
|
237
|
+
# target here.
|
|
238
|
+
target = selected[0]
|
|
239
|
+
raise SystemExit(
|
|
240
|
+
cmd_new(target.directory, args.name, datetime.now(UTC), _out_for(target.name, multi))
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
applied_by = f"{config.get('app_version', 'unknown')} @ {socket.gethostname()}"
|
|
244
|
+
|
|
245
|
+
code = 0
|
|
246
|
+
# One connection per target, processed strictly in sequence: two targets can point at
|
|
247
|
+
# the same physical database (e.g. a dev box collapsing "main" and "gis"), and the
|
|
248
|
+
# advisory lock tracker.lock() takes would contend with itself under concurrency.
|
|
249
|
+
for target in selected:
|
|
250
|
+
logger.debug(f"migrations: target={target.name} dir={target.directory} table={target.table}")
|
|
251
|
+
out = _out_for(target.name, multi)
|
|
252
|
+
|
|
253
|
+
async with await psycopg.AsyncConnection.connect(**connect_kwargs(config["db"][target.db])) as conn:
|
|
254
|
+
if args.step == "status":
|
|
255
|
+
target_code = await cmd_status(conn, target.directory, target.table, args.check, out)
|
|
256
|
+
elif args.step == "apply":
|
|
257
|
+
dry_run = getattr(args, "dry_run", False)
|
|
258
|
+
target_code = await cmd_apply(
|
|
259
|
+
conn, target.directory, target.table, dry_run, args.to, applied_by, out
|
|
260
|
+
)
|
|
261
|
+
elif args.step == "baseline":
|
|
262
|
+
target_code = await cmd_baseline(
|
|
263
|
+
conn, target.directory, target.table, args.to, args.yes, applied_by, input, out
|
|
264
|
+
)
|
|
265
|
+
elif args.step == "repair":
|
|
266
|
+
target_code = await cmd_repair(conn, target.directory, target.table, args.name, out)
|
|
267
|
+
else:
|
|
268
|
+
out(f"error: unknown migrations command {args.step!r}")
|
|
269
|
+
target_code = 1
|
|
270
|
+
|
|
271
|
+
if args.step == "apply":
|
|
272
|
+
# Stop at the first failing target: a broken `main` must never leave a
|
|
273
|
+
# deploy half-migrated across two databases by ploughing on to the next one.
|
|
274
|
+
code = target_code
|
|
275
|
+
if code != 0:
|
|
276
|
+
break
|
|
277
|
+
elif args.step == "status":
|
|
278
|
+
# Every target is checked regardless of earlier results: `--check` must
|
|
279
|
+
# report on all of them, not just the first failure.
|
|
280
|
+
code = code or target_code
|
|
281
|
+
else:
|
|
282
|
+
code = target_code
|
|
283
|
+
|
|
284
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
285
|
+
# Both derive from BaseException, so the `except Exception` below never sees them:
|
|
286
|
+
# they would sail past init_service into runner.py, which swallows them and returns -
|
|
287
|
+
# exit 0, with no log line at all. `apply` runs one file at a time, so an interrupt
|
|
288
|
+
# between two files leaves a partially migrated database while telling the playbook
|
|
289
|
+
# it succeeded. Must stay above the `except Exception` clause to take effect.
|
|
290
|
+
logger.error("migrations: interrupted; the database may be partially migrated")
|
|
291
|
+
raise SystemExit(1) from None
|
|
292
|
+
except Exception:
|
|
293
|
+
logger.exception("migrations: unhandled failure")
|
|
294
|
+
raise SystemExit(1) from None
|
|
295
|
+
|
|
296
|
+
raise SystemExit(code)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""CLI subparsers for the built-in migrations service.
|
|
2
|
+
|
|
3
|
+
python3 src/app.py migrations status [--check] [--target NAME]
|
|
4
|
+
python3 src/app.py migrations apply [--dry-run] [--to PREFIX] [--target NAME]
|
|
5
|
+
python3 src/app.py migrations baseline [--to PREFIX] [--yes] [--target NAME]
|
|
6
|
+
python3 src/app.py migrations new <name> [--target NAME]
|
|
7
|
+
python3 src/app.py migrations repair <filename> [--target NAME]
|
|
8
|
+
|
|
9
|
+
`--target` has no top-level counterpart on `runner.py`'s parser (unlike `apply`'s
|
|
10
|
+
`--dry-run`, which shadows a real top-level flag and needs `default=SUPPRESS` to avoid
|
|
11
|
+
clobbering it - see the collision test in tests/test_migrations_service.py). A plain
|
|
12
|
+
`default=None` is enough here, and `None` is what lets `init_service` tell "no --target
|
|
13
|
+
given" apart from "given as main".
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from argparse import SUPPRESS, ArgumentParser, _SubParsersAction # type: ignore
|
|
18
|
+
|
|
19
|
+
from py_app_runner.pybridge import PyBridge
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _add_target(parser: ArgumentParser) -> None:
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"--target",
|
|
25
|
+
type=str,
|
|
26
|
+
default=None,
|
|
27
|
+
help='Named migrations target to operate on (see config["migrations"]["targets"])',
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def reg_subparsers(
|
|
32
|
+
subparsers: "_SubParsersAction[ArgumentParser]",
|
|
33
|
+
_pybridge: PyBridge,
|
|
34
|
+
_base_logger: logging.Logger,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Command line subparsers"""
|
|
37
|
+
|
|
38
|
+
parser = subparsers.add_parser(
|
|
39
|
+
"migrations",
|
|
40
|
+
description="Apply tracked SQL migrations to the configured database target(s)",
|
|
41
|
+
help="Database migrations",
|
|
42
|
+
)
|
|
43
|
+
group = parser.add_subparsers(title="command", dest="step", required=True)
|
|
44
|
+
|
|
45
|
+
status_parser = group.add_parser("status", help="Show which migrations are applied and which are pending")
|
|
46
|
+
status_parser.add_argument(
|
|
47
|
+
"--check",
|
|
48
|
+
action="store_true",
|
|
49
|
+
help="Exit 1 if anything is pending or drifted (for deploy assertions)",
|
|
50
|
+
)
|
|
51
|
+
_add_target(status_parser)
|
|
52
|
+
|
|
53
|
+
apply_parser = group.add_parser("apply", help="Apply every pending migration, in order")
|
|
54
|
+
apply_parser.add_argument(
|
|
55
|
+
"--dry-run",
|
|
56
|
+
action="store_true",
|
|
57
|
+
default=SUPPRESS,
|
|
58
|
+
help="List what would run; change nothing",
|
|
59
|
+
)
|
|
60
|
+
apply_parser.add_argument(
|
|
61
|
+
"--to",
|
|
62
|
+
type=str,
|
|
63
|
+
default=None,
|
|
64
|
+
help="Stop after the migration with this YYYY-MM-DD-HHMMSS prefix",
|
|
65
|
+
)
|
|
66
|
+
_add_target(apply_parser)
|
|
67
|
+
|
|
68
|
+
baseline_parser = group.add_parser(
|
|
69
|
+
"baseline",
|
|
70
|
+
help="Record migrations as applied WITHOUT running them (adopting an existing database)",
|
|
71
|
+
)
|
|
72
|
+
baseline_parser.add_argument(
|
|
73
|
+
"--to",
|
|
74
|
+
type=str,
|
|
75
|
+
default=None,
|
|
76
|
+
help="Only offer migrations up to and including this YYYY-MM-DD-HHMMSS prefix",
|
|
77
|
+
)
|
|
78
|
+
baseline_parser.add_argument(
|
|
79
|
+
"--yes",
|
|
80
|
+
action="store_true",
|
|
81
|
+
help="Do not ask; stamp every offered migration (for scripts)",
|
|
82
|
+
)
|
|
83
|
+
_add_target(baseline_parser)
|
|
84
|
+
|
|
85
|
+
new_parser = group.add_parser("new", help="Create an empty, timestamped migration file")
|
|
86
|
+
new_parser.add_argument("name", type=str, help="Short description, e.g. 'add widgets table'")
|
|
87
|
+
_add_target(new_parser)
|
|
88
|
+
|
|
89
|
+
repair_parser = group.add_parser("repair", help="Re-stamp one migration's checksum after a deliberate edit")
|
|
90
|
+
repair_parser.add_argument("name", type=str, help="Migration filename, e.g. 2026-08-04-091530-a.sql")
|
|
91
|
+
_add_target(repair_parser)
|