pglift-cli 1.4.0__tar.gz → 1.6.0__tar.gz
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.
- {pglift_cli-1.4.0 → pglift_cli-1.6.0}/PKG-INFO +11 -3
- {pglift_cli-1.4.0 → pglift_cli-1.6.0}/pyproject.toml +1 -1
- pglift_cli-1.6.0/src/pglift_cli/__init__.py +15 -0
- pglift_cli-1.6.0/src/pglift_cli/_settings.py +75 -0
- {pglift_cli-1.4.0 → pglift_cli-1.6.0}/src/pglift_cli/database.py +50 -5
- {pglift_cli-1.4.0 → pglift_cli-1.6.0}/src/pglift_cli/instance.py +24 -15
- {pglift_cli-1.4.0 → pglift_cli-1.6.0}/src/pglift_cli/main.py +13 -15
- {pglift_cli-1.4.0 → pglift_cli-1.6.0}/src/pglift_cli/model.py +55 -29
- {pglift_cli-1.4.0 → pglift_cli-1.6.0}/src/pglift_cli/pgbackrest/__init__.py +2 -1
- {pglift_cli-1.4.0 → pglift_cli-1.6.0}/src/pglift_cli/pgbackrest/repo_path.py +2 -2
- {pglift_cli-1.4.0 → pglift_cli-1.6.0}/src/pglift_cli/pgconf.py +11 -4
- {pglift_cli-1.4.0 → pglift_cli-1.6.0}/src/pglift_cli/prometheus.py +8 -7
- {pglift_cli-1.4.0 → pglift_cli-1.6.0}/src/pglift_cli/role.py +4 -3
- {pglift_cli-1.4.0 → pglift_cli-1.6.0}/src/pglift_cli/util.py +102 -32
- pglift_cli-1.4.0/src/pglift_cli/__init__.py +0 -7
- pglift_cli-1.4.0/src/pglift_cli/_settings.py +0 -44
- {pglift_cli-1.4.0 → pglift_cli-1.6.0}/.gitignore +0 -0
- {pglift_cli-1.4.0 → pglift_cli-1.6.0}/README.md +0 -0
- {pglift_cli-1.4.0 → pglift_cli-1.6.0}/hatch.toml +0 -0
- {pglift_cli-1.4.0 → pglift_cli-1.6.0}/src/pglift_cli/__main__.py +0 -0
- {pglift_cli-1.4.0 → pglift_cli-1.6.0}/src/pglift_cli/_site.py +0 -0
- {pglift_cli-1.4.0 → pglift_cli-1.6.0}/src/pglift_cli/base.py +0 -0
- {pglift_cli-1.4.0 → pglift_cli-1.6.0}/src/pglift_cli/console.py +0 -0
- {pglift_cli-1.4.0 → pglift_cli-1.6.0}/src/pglift_cli/hookspecs.py +0 -0
- {pglift_cli-1.4.0 → pglift_cli-1.6.0}/src/pglift_cli/patroni.py +0 -0
- {pglift_cli-1.4.0 → pglift_cli-1.6.0}/src/pglift_cli/pm.py +0 -0
- {pglift_cli-1.4.0 → pglift_cli-1.6.0}/src/pglift_cli/postgres.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: pglift_cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.6.0
|
|
4
4
|
Summary: Command-line interface for pglift
|
|
5
5
|
Project-URL: Documentation, https://pglift.readthedocs.io/
|
|
6
6
|
Project-URL: Source, https://gitlab.com/dalibo/pglift/
|
|
@@ -30,7 +30,15 @@ Requires-Dist: pydantic>=2.5.0
|
|
|
30
30
|
Requires-Dist: pyyaml>=6.0.1
|
|
31
31
|
Requires-Dist: rich>=11.0.0
|
|
32
32
|
Provides-Extra: dev
|
|
33
|
-
Requires-Dist:
|
|
33
|
+
Requires-Dist: anyio; extra == 'dev'
|
|
34
|
+
Requires-Dist: mypy>=1.10.0; extra == 'dev'
|
|
35
|
+
Requires-Dist: patroni[etcd]>=2.1.5; extra == 'dev'
|
|
36
|
+
Requires-Dist: port-for; extra == 'dev'
|
|
37
|
+
Requires-Dist: prysk[pytest-plugin]>=0.14.0; extra == 'dev'
|
|
38
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
39
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
40
|
+
Requires-Dist: trustme; extra == 'dev'
|
|
41
|
+
Requires-Dist: types-pyyaml>=6.0.12.10; extra == 'dev'
|
|
34
42
|
Provides-Extra: test
|
|
35
43
|
Requires-Dist: anyio; extra == 'test'
|
|
36
44
|
Requires-Dist: patroni[etcd]>=2.1.5; extra == 'test'
|
|
@@ -40,7 +48,7 @@ Requires-Dist: pytest; extra == 'test'
|
|
|
40
48
|
Requires-Dist: pytest-cov; extra == 'test'
|
|
41
49
|
Requires-Dist: trustme; extra == 'test'
|
|
42
50
|
Provides-Extra: typing
|
|
43
|
-
Requires-Dist: mypy>=1.
|
|
51
|
+
Requires-Dist: mypy>=1.10.0; extra == 'typing'
|
|
44
52
|
Requires-Dist: types-pyyaml>=6.0.12.10; extra == 'typing'
|
|
45
53
|
Description-Content-Type: text/markdown
|
|
46
54
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2024 Dalibo
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
import pluggy
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
try:
|
|
12
|
+
loggers = list(os.environ["PGLIFT_LOGGERS"].split(","))
|
|
13
|
+
except KeyError:
|
|
14
|
+
loggers = [__name__, "pglift"]
|
|
15
|
+
hookimpl = pluggy.HookimplMarker(__name__)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2021 Dalibo
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated, Any, Optional
|
|
8
|
+
|
|
9
|
+
from pydantic import AfterValidator, Field, ValidationInfo
|
|
10
|
+
|
|
11
|
+
from pglift.settings import Settings as BaseSettings
|
|
12
|
+
from pglift.settings import SiteSettings as BaseSiteSettings
|
|
13
|
+
from pglift.settings.base import BaseModel, LogPath, RunPath
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def deprecated(value: Any, info: ValidationInfo) -> Any:
|
|
17
|
+
if value is not None:
|
|
18
|
+
warnings.warn(
|
|
19
|
+
f"{info.field_name!r} setting is deprecated", FutureWarning, stacklevel=2
|
|
20
|
+
)
|
|
21
|
+
return value
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AuditSettings(BaseModel):
|
|
25
|
+
"""Settings for change operations auditing."""
|
|
26
|
+
|
|
27
|
+
path: Annotated[
|
|
28
|
+
Annotated[Path, LogPath],
|
|
29
|
+
Field(description="Log file path"),
|
|
30
|
+
]
|
|
31
|
+
log_format: Annotated[
|
|
32
|
+
str,
|
|
33
|
+
Field(description="Format for log messages"),
|
|
34
|
+
] = "%(levelname)-8s - %(asctime)s - %(name)s - %(message)s"
|
|
35
|
+
date_format: Annotated[
|
|
36
|
+
str,
|
|
37
|
+
Field(description="Date format in log messages"),
|
|
38
|
+
] = "%Y-%m-%d %H:%M:%S"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class CLISettings(BaseModel):
|
|
42
|
+
"""Settings for pglift's command-line interface."""
|
|
43
|
+
|
|
44
|
+
logpath: Annotated[
|
|
45
|
+
Annotated[Optional[Path], LogPath],
|
|
46
|
+
Field(
|
|
47
|
+
description="Directory where temporary debug files from command executions will be stored (DEPRECATED).",
|
|
48
|
+
),
|
|
49
|
+
AfterValidator(deprecated),
|
|
50
|
+
] = None
|
|
51
|
+
|
|
52
|
+
log_format: Annotated[
|
|
53
|
+
str, Field(description="Format for log messages when written to a file")
|
|
54
|
+
] = "%(asctime)s %(levelname)-8s %(name)s - %(message)s"
|
|
55
|
+
|
|
56
|
+
date_format: Annotated[
|
|
57
|
+
str, Field(description="Date format in log messages when written to a file")
|
|
58
|
+
] = "%Y-%m-%d %H:%M:%S"
|
|
59
|
+
|
|
60
|
+
lock_file: Annotated[
|
|
61
|
+
Path, RunPath, Field(description="Path to lock file dedicated to pglift")
|
|
62
|
+
] = Path(".pglift.lock")
|
|
63
|
+
|
|
64
|
+
audit: Annotated[
|
|
65
|
+
Optional[AuditSettings],
|
|
66
|
+
Field(description="Settings for change operations auditing"),
|
|
67
|
+
] = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Settings(BaseSettings):
|
|
71
|
+
cli: Annotated[CLISettings, Field(default_factory=CLISettings)]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class SiteSettings(Settings, BaseSiteSettings):
|
|
75
|
+
pass
|
|
@@ -11,6 +11,7 @@ from typing import Any
|
|
|
11
11
|
|
|
12
12
|
import click
|
|
13
13
|
import psycopg
|
|
14
|
+
from attrs import asdict
|
|
14
15
|
from pydantic.v1.utils import deep_update
|
|
15
16
|
|
|
16
17
|
from pglift import databases, postgresql, privileges, task
|
|
@@ -23,6 +24,7 @@ from .util import (
|
|
|
23
24
|
Obj,
|
|
24
25
|
OutputFormat,
|
|
25
26
|
async_command,
|
|
27
|
+
audit,
|
|
26
28
|
dry_run_option,
|
|
27
29
|
instance_identifier_option,
|
|
28
30
|
manifest_option,
|
|
@@ -68,7 +70,7 @@ async def create(
|
|
|
68
70
|
obj: Obj, instance: system.Instance, database: interface.Database
|
|
69
71
|
) -> None:
|
|
70
72
|
"""Create a database in a PostgreSQL instance"""
|
|
71
|
-
with obj.lock:
|
|
73
|
+
with obj.lock, audit():
|
|
72
74
|
async with postgresql.running(instance):
|
|
73
75
|
if await databases.exists(instance, database.name):
|
|
74
76
|
raise click.ClickException("database already exists")
|
|
@@ -86,7 +88,7 @@ async def alter(
|
|
|
86
88
|
obj: Obj, instance: system.Instance, dbname: str, **changes: Any
|
|
87
89
|
) -> None:
|
|
88
90
|
"""Alter a database in a PostgreSQL instance"""
|
|
89
|
-
with obj.lock:
|
|
91
|
+
with obj.lock, audit():
|
|
90
92
|
async with postgresql.running(instance):
|
|
91
93
|
values = (await databases.get(instance, dbname)).model_dump(by_alias=True)
|
|
92
94
|
values = deep_update(values, changes)
|
|
@@ -113,7 +115,7 @@ async def apply(
|
|
|
113
115
|
if dry_run:
|
|
114
116
|
ret = interface.ApplyResult(change_state=None)
|
|
115
117
|
else:
|
|
116
|
-
with obj.lock:
|
|
118
|
+
with obj.lock, audit():
|
|
117
119
|
async with postgresql.running(instance):
|
|
118
120
|
ret = await databases.apply(instance, database)
|
|
119
121
|
if output_format == OutputFormat.json:
|
|
@@ -171,7 +173,7 @@ async def drop(
|
|
|
171
173
|
obj: Obj, instance: system.Instance, databasedropped: interface.DatabaseDropped
|
|
172
174
|
) -> None:
|
|
173
175
|
"""Drop a database"""
|
|
174
|
-
with obj.lock:
|
|
176
|
+
with obj.lock, audit():
|
|
175
177
|
async with postgresql.running(instance):
|
|
176
178
|
await databases.drop(instance, databasedropped)
|
|
177
179
|
|
|
@@ -251,7 +253,7 @@ async def run(
|
|
|
251
253
|
"--output",
|
|
252
254
|
metavar="DIRECTORY",
|
|
253
255
|
type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
|
|
254
|
-
help="Write dump file(s) to DIRECTORY instead of
|
|
256
|
+
help="Write dump file(s) to DIRECTORY instead of default dumps directory.",
|
|
255
257
|
)
|
|
256
258
|
@click.argument("dbname")
|
|
257
259
|
@pass_instance
|
|
@@ -260,3 +262,46 @@ async def dump(instance: system.Instance, dbname: str, output: Path | None) -> N
|
|
|
260
262
|
"""Dump a database"""
|
|
261
263
|
async with postgresql.running(instance):
|
|
262
264
|
await databases.dump(instance, dbname, output)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@cli.command("dumps")
|
|
268
|
+
@click.argument("dbname", nargs=-1)
|
|
269
|
+
@output_format_option
|
|
270
|
+
@pass_instance
|
|
271
|
+
@async_command
|
|
272
|
+
async def dumps(
|
|
273
|
+
instance: system.Instance, dbname: Sequence[str], output_format: OutputFormat
|
|
274
|
+
) -> None:
|
|
275
|
+
"""List the database dumps
|
|
276
|
+
|
|
277
|
+
Only dumps created in the default dumps directory are listed.
|
|
278
|
+
"""
|
|
279
|
+
values = [asdict(dump) async for dump in databases.dumps(instance, dbnames=dbname)]
|
|
280
|
+
if output_format == OutputFormat.json:
|
|
281
|
+
print_json_for(values)
|
|
282
|
+
else:
|
|
283
|
+
dbnames = ", ".join(dbname) if dbname else "all databases"
|
|
284
|
+
print_table_for(values, lambda d: d, title=f"Dumps for {dbnames}")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@cli.command("restore")
|
|
288
|
+
@click.argument("dump_id")
|
|
289
|
+
@click.argument("targetdbname", required=False)
|
|
290
|
+
@pass_instance
|
|
291
|
+
@async_command
|
|
292
|
+
async def restore(
|
|
293
|
+
instance: system.Instance, dump_id: str, targetdbname: str | None
|
|
294
|
+
) -> None:
|
|
295
|
+
"""Restore a database dump
|
|
296
|
+
|
|
297
|
+
DUMP_ID identifies the dump id.
|
|
298
|
+
|
|
299
|
+
TARGETDBNAME identifies the (optional) name of the database in which the
|
|
300
|
+
dump is reloaded. If provided, the database needs to be created beforehand.
|
|
301
|
+
|
|
302
|
+
If TARGETDBNAME is not provided, the dump is reloaded using the database
|
|
303
|
+
name that appears in the dump. In this case, the restore command will
|
|
304
|
+
create the database so it needs to be dropped before running the command.
|
|
305
|
+
"""
|
|
306
|
+
async with postgresql.running(instance):
|
|
307
|
+
await databases.restore(instance, dump_id, targetdbname)
|
|
@@ -22,8 +22,9 @@ from pglift import (
|
|
|
22
22
|
task,
|
|
23
23
|
)
|
|
24
24
|
from pglift.models import interface, system
|
|
25
|
+
from pglift.settings import default_postgresql_version
|
|
25
26
|
from pglift.settings._postgresql import PostgreSQLVersion
|
|
26
|
-
from pglift.types import Status, validation_context
|
|
27
|
+
from pglift.types import Operation, Status, validation_context
|
|
27
28
|
|
|
28
29
|
from . import _site
|
|
29
30
|
from . import hookspecs as h
|
|
@@ -34,6 +35,7 @@ from .util import (
|
|
|
34
35
|
OutputFormat,
|
|
35
36
|
PluggableCommandGroup,
|
|
36
37
|
async_command,
|
|
38
|
+
audit,
|
|
37
39
|
dry_run_option,
|
|
38
40
|
foreground_option,
|
|
39
41
|
instance_identifier,
|
|
@@ -104,7 +106,7 @@ def cli() -> None:
|
|
|
104
106
|
@async_command
|
|
105
107
|
async def create(obj: Obj, instance: interface.Instance, drop_on_error: bool) -> None:
|
|
106
108
|
"""Initialize a PostgreSQL instance"""
|
|
107
|
-
with obj.lock:
|
|
109
|
+
with obj.lock, audit():
|
|
108
110
|
if instances.exists(instance.name, instance.version, _site.SETTINGS):
|
|
109
111
|
raise click.ClickException("instance already exists")
|
|
110
112
|
async with task.async_transaction(drop_on_error):
|
|
@@ -118,12 +120,14 @@ async def create(obj: Obj, instance: interface.Instance, drop_on_error: bool) ->
|
|
|
118
120
|
@async_command
|
|
119
121
|
async def alter(obj: Obj, instance: system.Instance, **changes: Any) -> None:
|
|
120
122
|
"""Alter PostgreSQL INSTANCE"""
|
|
121
|
-
with obj.lock:
|
|
123
|
+
with obj.lock, audit():
|
|
122
124
|
status = await postgresql.status(instance)
|
|
123
125
|
manifest = await instances._get(instance, status)
|
|
124
126
|
values = manifest.model_dump(by_alias=True, exclude={"settings"})
|
|
125
127
|
values = deep_update(values, changes)
|
|
126
|
-
|
|
128
|
+
# No need for 'settings' in validation_context() as a 'version' key
|
|
129
|
+
# must be present in 'values' when altering.
|
|
130
|
+
with validation_context(operation="update"):
|
|
127
131
|
altered = _site.INSTANCE_MODEL.model_validate(values)
|
|
128
132
|
await instances.apply(
|
|
129
133
|
_site.SETTINGS, altered, _is_running=status == Status.running
|
|
@@ -141,14 +145,19 @@ async def apply(
|
|
|
141
145
|
) -> None:
|
|
142
146
|
"""Apply manifest as a PostgreSQL instance"""
|
|
143
147
|
name, version = data["name"], data.get("version")
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
148
|
+
if version is None:
|
|
149
|
+
version = default_postgresql_version(_site.SETTINGS.postgresql)
|
|
150
|
+
elif not isinstance(version, str):
|
|
151
|
+
version = str(version)
|
|
152
|
+
op: Operation = (
|
|
153
|
+
"update" if instances.exists(name, version, _site.SETTINGS) else "create"
|
|
154
|
+
)
|
|
155
|
+
with validation_context(operation=op, settings=_site.SETTINGS):
|
|
147
156
|
instance = _site.INSTANCE_MODEL.model_validate(data)
|
|
148
157
|
if dry_run:
|
|
149
158
|
ret = interface.InstanceApplyResult(change_state=None)
|
|
150
159
|
else:
|
|
151
|
-
with obj.lock:
|
|
160
|
+
with obj.lock, audit():
|
|
152
161
|
ret = await instances.apply(_site.SETTINGS, instance)
|
|
153
162
|
if output_format == OutputFormat.json:
|
|
154
163
|
print_json_for(ret)
|
|
@@ -160,7 +169,7 @@ async def apply(
|
|
|
160
169
|
@async_command
|
|
161
170
|
async def promote(obj: Obj, instance: system.Instance) -> None:
|
|
162
171
|
"""Promote standby PostgreSQL INSTANCE"""
|
|
163
|
-
with obj.lock:
|
|
172
|
+
with obj.lock, audit():
|
|
164
173
|
await instances.promote(instance)
|
|
165
174
|
|
|
166
175
|
|
|
@@ -213,7 +222,7 @@ async def ls(version: PostgreSQLVersion | None, output_format: OutputFormat) ->
|
|
|
213
222
|
@async_command
|
|
214
223
|
async def drop(obj: Obj, instance: tuple[system.Instance, ...]) -> None:
|
|
215
224
|
"""Drop PostgreSQL INSTANCE"""
|
|
216
|
-
with obj.lock:
|
|
225
|
+
with obj.lock, audit():
|
|
217
226
|
for i in instance:
|
|
218
227
|
await instances.drop(i)
|
|
219
228
|
|
|
@@ -261,7 +270,7 @@ async def start(
|
|
|
261
270
|
raise click.UsageError(
|
|
262
271
|
"only one INSTANCE argument may be given with --foreground"
|
|
263
272
|
)
|
|
264
|
-
with obj.lock:
|
|
273
|
+
with obj.lock, audit():
|
|
265
274
|
for i in instance:
|
|
266
275
|
await instances.start(i, foreground=foreground)
|
|
267
276
|
|
|
@@ -275,7 +284,7 @@ async def stop(
|
|
|
275
284
|
obj: Obj, instance: tuple[system.Instance, ...], all_instances: bool
|
|
276
285
|
) -> None:
|
|
277
286
|
"""Stop PostgreSQL INSTANCE"""
|
|
278
|
-
with obj.lock:
|
|
287
|
+
with obj.lock, audit():
|
|
279
288
|
for i in instance:
|
|
280
289
|
await instances.stop(i)
|
|
281
290
|
|
|
@@ -289,7 +298,7 @@ async def reload(
|
|
|
289
298
|
obj: Obj, instance: tuple[system.Instance, ...], all_instances: bool
|
|
290
299
|
) -> None:
|
|
291
300
|
"""Reload PostgreSQL INSTANCE"""
|
|
292
|
-
with obj.lock:
|
|
301
|
+
with obj.lock, audit():
|
|
293
302
|
for i in instance:
|
|
294
303
|
await instances.reload(i)
|
|
295
304
|
|
|
@@ -303,7 +312,7 @@ async def restart(
|
|
|
303
312
|
obj: Obj, instance: tuple[system.Instance, ...], all_instances: bool
|
|
304
313
|
) -> None:
|
|
305
314
|
"""Restart PostgreSQL INSTANCE"""
|
|
306
|
-
with obj.lock:
|
|
315
|
+
with obj.lock, audit():
|
|
307
316
|
for i in instance:
|
|
308
317
|
await instances.restart(i)
|
|
309
318
|
|
|
@@ -459,7 +468,7 @@ async def upgrade(
|
|
|
459
468
|
jobs: int | None,
|
|
460
469
|
) -> None:
|
|
461
470
|
"""Upgrade INSTANCE using pg_upgrade"""
|
|
462
|
-
with obj.lock:
|
|
471
|
+
with obj.lock, audit():
|
|
463
472
|
await postgresql.check_status(instance, Status.not_running)
|
|
464
473
|
async with task.async_transaction():
|
|
465
474
|
new_instance = await instances.upgrade(
|
|
@@ -26,16 +26,16 @@ from pglift import install, ui
|
|
|
26
26
|
from pglift._compat import assert_never
|
|
27
27
|
|
|
28
28
|
from . import __name__ as pkgname
|
|
29
|
-
from . import _site
|
|
29
|
+
from . import _site, loggers
|
|
30
30
|
from ._settings import Settings
|
|
31
31
|
from .base import CLIGroup
|
|
32
32
|
from .console import console as console
|
|
33
33
|
from .util import (
|
|
34
34
|
InteractiveUserInterface,
|
|
35
|
-
LogDisplayer,
|
|
36
35
|
Obj,
|
|
37
36
|
OutputFormat,
|
|
38
37
|
async_command,
|
|
38
|
+
audit,
|
|
39
39
|
output_format_option,
|
|
40
40
|
)
|
|
41
41
|
|
|
@@ -145,10 +145,7 @@ def cli(
|
|
|
145
145
|
stacklevel=1,
|
|
146
146
|
)
|
|
147
147
|
if not context.obj:
|
|
148
|
-
context.obj = Obj(
|
|
149
|
-
displayer=None if log_file else LogDisplayer(),
|
|
150
|
-
debug=debug,
|
|
151
|
-
)
|
|
148
|
+
context.obj = Obj(debug=debug)
|
|
152
149
|
else:
|
|
153
150
|
assert isinstance(context.obj, Obj), context.obj
|
|
154
151
|
|
|
@@ -156,12 +153,9 @@ def cli(
|
|
|
156
153
|
if interactive:
|
|
157
154
|
ui_token = ui.set(InteractiveUserInterface())
|
|
158
155
|
|
|
159
|
-
|
|
160
|
-
for logger in loggers:
|
|
161
|
-
logger.setLevel(logging.DEBUG)
|
|
156
|
+
handler: logging.Handler | rich.logging.RichHandler
|
|
162
157
|
if debug:
|
|
163
158
|
log_level = logging.DEBUG
|
|
164
|
-
handler: logging.Handler | rich.logging.RichHandler
|
|
165
159
|
if log_file or not sys.stderr.isatty():
|
|
166
160
|
if log_file:
|
|
167
161
|
handler = logging.FileHandler(log_file)
|
|
@@ -182,11 +176,15 @@ def cli(
|
|
|
182
176
|
show_path=False,
|
|
183
177
|
highlighter=NullHighlighter(),
|
|
184
178
|
)
|
|
185
|
-
|
|
179
|
+
|
|
180
|
+
for name in loggers:
|
|
181
|
+
logger = logging.getLogger(name)
|
|
182
|
+
logger.setLevel(logging.DEBUG)
|
|
186
183
|
logger.addHandler(handler)
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
184
|
+
# Remove rich handler on close since this would pollute all tests
|
|
185
|
+
# stderr otherwise.
|
|
186
|
+
context.call_on_close(partial(logger.removeHandler, handler))
|
|
187
|
+
|
|
190
188
|
# Reset contextvars
|
|
191
189
|
if ui_token is not None:
|
|
192
190
|
context.call_on_close(partial(ui.reset, ui_token))
|
|
@@ -264,7 +262,7 @@ async def site_configure(
|
|
|
264
262
|
|
|
265
263
|
This is an INTERNAL command.
|
|
266
264
|
"""
|
|
267
|
-
with obj.lock:
|
|
265
|
+
with obj.lock, audit():
|
|
268
266
|
if action == "install":
|
|
269
267
|
env = {"SETTINGS": f"@{settings_file}"} if settings_file else {}
|
|
270
268
|
await install.do(_site.SETTINGS, env=env)
|
|
@@ -7,7 +7,6 @@ from __future__ import annotations
|
|
|
7
7
|
import enum
|
|
8
8
|
import functools
|
|
9
9
|
import inspect
|
|
10
|
-
import logging
|
|
11
10
|
import typing
|
|
12
11
|
from abc import ABC, abstractmethod
|
|
13
12
|
from collections.abc import Callable, Iterator, Sequence
|
|
@@ -30,7 +29,7 @@ from pglift.types import (
|
|
|
30
29
|
validation_context,
|
|
31
30
|
)
|
|
32
31
|
|
|
33
|
-
|
|
32
|
+
from . import _site, logger
|
|
34
33
|
|
|
35
34
|
ModelType = type[pydantic.BaseModel]
|
|
36
35
|
T = TypeVar("T", bound=pydantic.BaseModel)
|
|
@@ -101,7 +100,7 @@ def as_parameters(
|
|
|
101
100
|
args = params_to_modelargs(kwargs)
|
|
102
101
|
with (
|
|
103
102
|
catch_validationerror(*paramspecs),
|
|
104
|
-
validation_context(
|
|
103
|
+
validation_context(operation=operation, settings=_site.SETTINGS),
|
|
105
104
|
):
|
|
106
105
|
model = parse_params_as(model_type, args)
|
|
107
106
|
kwargs[model_argname] = model
|
|
@@ -110,7 +109,7 @@ def as_parameters(
|
|
|
110
109
|
else:
|
|
111
110
|
|
|
112
111
|
@functools.wraps(f)
|
|
113
|
-
def callback(**kwargs: Any) -> Any:
|
|
112
|
+
def callback(**kwargs: Any) -> Any: # type: ignore[misc]
|
|
114
113
|
args = params_to_modelargs(kwargs)
|
|
115
114
|
values = unnest(model_type, args)
|
|
116
115
|
kwargs.update(values)
|
|
@@ -164,6 +163,7 @@ class ParamSpec(ABC):
|
|
|
164
163
|
"""Intermediate representation for a future click.Parameter."""
|
|
165
164
|
|
|
166
165
|
param_decls: Sequence[str]
|
|
166
|
+
field_info: FieldInfo
|
|
167
167
|
attrs: dict[str, Any]
|
|
168
168
|
loc: tuple[str, ...]
|
|
169
169
|
|
|
@@ -208,7 +208,15 @@ class OptionSpec(ParamSpec):
|
|
|
208
208
|
|
|
209
209
|
@property
|
|
210
210
|
def decorator(self) -> ClickDecorator:
|
|
211
|
-
return click.option(*self.param_decls, **self.attrs)
|
|
211
|
+
return click.option(*self.param_decls, help=self._help(), **self.attrs)
|
|
212
|
+
|
|
213
|
+
def _help(self) -> str | None:
|
|
214
|
+
if description := self.field_info.description:
|
|
215
|
+
description = description[0].upper() + description[1:]
|
|
216
|
+
if description[-1] not in ".?":
|
|
217
|
+
description += "."
|
|
218
|
+
return description
|
|
219
|
+
return None
|
|
212
220
|
|
|
213
221
|
|
|
214
222
|
@dataclass(frozen=True)
|
|
@@ -255,10 +263,31 @@ def _paramspecs_from_model(
|
|
|
255
263
|
assert ftype is not None
|
|
256
264
|
nested = lenient_issubclass(origin_type or ftype, pydantic.BaseModel)
|
|
257
265
|
required = field.is_required()
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
266
|
+
attrs: dict[str, Any]
|
|
267
|
+
|
|
268
|
+
if nested:
|
|
269
|
+
yield from _paramspecs_from_model(
|
|
270
|
+
ftype, operation, _parents=_parents + (_Parent(argname, required),)
|
|
261
271
|
)
|
|
272
|
+
|
|
273
|
+
elif not _parents and required:
|
|
274
|
+
attrs = {}
|
|
275
|
+
if origin_type is typing.Literal:
|
|
276
|
+
choices = list(typing.get_args(ftype))
|
|
277
|
+
if config is not None and config.choices is not None:
|
|
278
|
+
choices = config.choices
|
|
279
|
+
attrs["type"] = click.Choice(choices)
|
|
280
|
+
if config is not None and config.as_option:
|
|
281
|
+
attrs["required"] = True
|
|
282
|
+
yield (modelname, argname), OptionSpec(
|
|
283
|
+
(f"--{argname.replace('_', '-')}",), field, attrs, loc=(modelname,)
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
else:
|
|
287
|
+
yield (modelname, argname), ArgumentSpec(
|
|
288
|
+
(argname.replace("_", "-"),), field, attrs, loc=(modelname,)
|
|
289
|
+
)
|
|
290
|
+
|
|
262
291
|
else:
|
|
263
292
|
metavar: str | None
|
|
264
293
|
if config and config.metavar is not None:
|
|
@@ -268,12 +297,15 @@ def _paramspecs_from_model(
|
|
|
268
297
|
if metavar is not None:
|
|
269
298
|
metavar = metavar.upper()
|
|
270
299
|
argparts = tuple(p.argname for p in _parents) + tuple(argname.split("_"))
|
|
300
|
+
argname = "_".join(argparts)
|
|
301
|
+
loc = tuple(p.argname for p in _parents) + (modelname,)
|
|
302
|
+
modelname = "_".join(loc)
|
|
271
303
|
fname = f"--{'-'.join(argparts)}"
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
304
|
+
|
|
305
|
+
attrs = {}
|
|
306
|
+
if required and all(p.required for p in _parents):
|
|
307
|
+
attrs["required"] = True
|
|
308
|
+
|
|
277
309
|
if origin_type is typing.Literal:
|
|
278
310
|
choices = list(typing.get_args(ftype))
|
|
279
311
|
if len(choices) == 1: # const
|
|
@@ -282,17 +314,14 @@ def _paramspecs_from_model(
|
|
|
282
314
|
choices = config.choices
|
|
283
315
|
attrs["type"] = click.Choice(choices)
|
|
284
316
|
metavar = None
|
|
317
|
+
|
|
285
318
|
elif lenient_issubclass(ftype, enum.Enum):
|
|
286
319
|
if config and config.choices is not None:
|
|
287
320
|
choices = config.choices
|
|
288
321
|
else:
|
|
289
322
|
choices = choices_from_enum(ftype)
|
|
290
323
|
attrs["type"] = click.Choice(choices)
|
|
291
|
-
|
|
292
|
-
yield from _paramspecs_from_model(
|
|
293
|
-
ftype, operation, _parents=_parents + (_Parent(argname, required),)
|
|
294
|
-
)
|
|
295
|
-
continue
|
|
324
|
+
|
|
296
325
|
elif lenient_issubclass(origin_type or ftype, list):
|
|
297
326
|
if operation != "create":
|
|
298
327
|
continue
|
|
@@ -306,30 +335,27 @@ def _paramspecs_from_model(
|
|
|
306
335
|
attrs["type"] = click.Choice(choices_from_enum(itemtype))
|
|
307
336
|
else:
|
|
308
337
|
attrs["metavar"] = metavar
|
|
338
|
+
|
|
309
339
|
elif lenient_issubclass(ftype, pydantic.SecretStr):
|
|
310
340
|
attrs["prompt"] = (
|
|
311
|
-
description.rstrip(".")
|
|
341
|
+
field.description.rstrip(".")
|
|
342
|
+
if field.description is not None
|
|
343
|
+
else True
|
|
312
344
|
)
|
|
313
345
|
attrs["prompt_required"] = False
|
|
314
346
|
attrs["confirmation_prompt"] = True
|
|
315
347
|
attrs["hide_input"] = True
|
|
348
|
+
|
|
316
349
|
elif lenient_issubclass(ftype, bool):
|
|
317
350
|
fname = f"{fname}/--no-{fname[2:]}"
|
|
318
351
|
# Use None to distinguish unspecified option from the default value.
|
|
319
352
|
attrs["default"] = None
|
|
353
|
+
|
|
320
354
|
else:
|
|
321
355
|
attrs["metavar"] = metavar
|
|
322
|
-
|
|
323
|
-
if description[-1] not in ".?":
|
|
324
|
-
description += "."
|
|
325
|
-
attrs["help"] = description
|
|
326
|
-
if field.is_required() and all(p.required for p in _parents):
|
|
327
|
-
attrs["required"] = True
|
|
328
|
-
argname = "_".join(argparts)
|
|
329
|
-
loc = tuple(p.argname for p in _parents) + (modelname,)
|
|
330
|
-
modelname = "_".join(loc)
|
|
356
|
+
|
|
331
357
|
yield (modelname, argname), OptionSpec(
|
|
332
|
-
(fname,), {"callback": default, **attrs}, loc=loc
|
|
358
|
+
(fname,), field, {"callback": default, **attrs}, loc=loc
|
|
333
359
|
)
|
|
334
360
|
|
|
335
361
|
|
|
@@ -22,6 +22,7 @@ from ..util import (
|
|
|
22
22
|
Obj,
|
|
23
23
|
OutputFormat,
|
|
24
24
|
async_command,
|
|
25
|
+
audit,
|
|
25
26
|
instance_identifier,
|
|
26
27
|
instance_identifier_option,
|
|
27
28
|
model_dump,
|
|
@@ -69,7 +70,7 @@ async def instance_restore(
|
|
|
69
70
|
"--label and --date arguments are mutually exclusive"
|
|
70
71
|
) from None
|
|
71
72
|
settings = pgbackrest.get_settings(_site.SETTINGS)
|
|
72
|
-
with obj.lock:
|
|
73
|
+
with obj.lock, audit():
|
|
73
74
|
await base.restore(instance, settings, label=label, date=date)
|
|
74
75
|
|
|
75
76
|
|
|
@@ -12,7 +12,7 @@ from pglift.pgbackrest import repo_path
|
|
|
12
12
|
from pglift.pgbackrest.repo_path import register_if as register_if # noqa: F401
|
|
13
13
|
|
|
14
14
|
from .. import _site, hookimpl
|
|
15
|
-
from ..util import Command, Obj, async_command, instance_identifier
|
|
15
|
+
from ..util import Command, Obj, async_command, audit, instance_identifier
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
@click.command("backup", cls=Command)
|
|
@@ -31,7 +31,7 @@ async def instance_backup(
|
|
|
31
31
|
) -> None:
|
|
32
32
|
"""Back up PostgreSQL INSTANCE"""
|
|
33
33
|
settings = pgbackrest.get_settings(_site.SETTINGS)
|
|
34
|
-
with obj.lock:
|
|
34
|
+
with obj.lock, audit():
|
|
35
35
|
await repo_path.backup(instance, settings, type=backup_type)
|
|
36
36
|
|
|
37
37
|
|
|
@@ -14,7 +14,14 @@ from pglift import h, hook, instances, postgresql
|
|
|
14
14
|
from pglift.models import system
|
|
15
15
|
from pglift.types import ConfigChanges, Status
|
|
16
16
|
|
|
17
|
-
from .util import
|
|
17
|
+
from .util import (
|
|
18
|
+
Group,
|
|
19
|
+
Obj,
|
|
20
|
+
async_command,
|
|
21
|
+
audit,
|
|
22
|
+
instance_identifier_option,
|
|
23
|
+
pass_instance,
|
|
24
|
+
)
|
|
18
25
|
|
|
19
26
|
|
|
20
27
|
@click.group(cls=Group)
|
|
@@ -92,7 +99,7 @@ def validate_configuration_parameters(
|
|
|
92
99
|
@async_command
|
|
93
100
|
async def set_(obj: Obj, instance: system.Instance, parameters: dict[str, Any]) -> None:
|
|
94
101
|
"""Set configuration items."""
|
|
95
|
-
with obj.lock:
|
|
102
|
+
with obj.lock, audit():
|
|
96
103
|
status = await postgresql.status(instance)
|
|
97
104
|
manifest = await instances._get(instance, status)
|
|
98
105
|
manifest.settings.update(parameters)
|
|
@@ -109,7 +116,7 @@ async def set_(obj: Obj, instance: system.Instance, parameters: dict[str, Any])
|
|
|
109
116
|
@async_command
|
|
110
117
|
async def remove(obj: Obj, instance: system.Instance, parameters: tuple[str]) -> None:
|
|
111
118
|
"""Remove configuration items."""
|
|
112
|
-
with obj.lock:
|
|
119
|
+
with obj.lock, audit():
|
|
113
120
|
status = await postgresql.status(instance)
|
|
114
121
|
manifest = await instances._get(instance, status)
|
|
115
122
|
for p in parameters:
|
|
@@ -131,7 +138,7 @@ async def remove(obj: Obj, instance: system.Instance, parameters: tuple[str]) ->
|
|
|
131
138
|
@async_command
|
|
132
139
|
async def edit(obj: Obj, instance: system.Instance) -> None:
|
|
133
140
|
"""Edit managed configuration."""
|
|
134
|
-
with obj.lock:
|
|
141
|
+
with obj.lock, audit():
|
|
135
142
|
actual_config = hook(
|
|
136
143
|
instance._settings, h.postgresql_editable_conf, instance=instance
|
|
137
144
|
)
|
|
@@ -21,6 +21,7 @@ from .util import (
|
|
|
21
21
|
Obj,
|
|
22
22
|
OutputFormat,
|
|
23
23
|
async_command,
|
|
24
|
+
audit,
|
|
24
25
|
dry_run_option,
|
|
25
26
|
foreground_option,
|
|
26
27
|
manifest_option,
|
|
@@ -65,13 +66,13 @@ async def apply(
|
|
|
65
66
|
"""Apply manifest as a Prometheus postgres_exporter."""
|
|
66
67
|
settings = prometheus.get_settings(_site.SETTINGS)
|
|
67
68
|
name = data["name"]
|
|
68
|
-
op = "update" if impl.exists(name, settings) else "create"
|
|
69
|
-
with types.validation_context(
|
|
69
|
+
op: types.Operation = "update" if impl.exists(name, settings) else "create"
|
|
70
|
+
with types.validation_context(operation=op):
|
|
70
71
|
exporter = PostgresExporter.model_validate(data)
|
|
71
72
|
if dry_run:
|
|
72
73
|
ret = interface.ApplyResult(change_state=None)
|
|
73
74
|
else:
|
|
74
|
-
with obj.lock:
|
|
75
|
+
with obj.lock, audit():
|
|
75
76
|
ret = await impl.apply(exporter, _site.SETTINGS, settings)
|
|
76
77
|
if output_format == OutputFormat.json:
|
|
77
78
|
print_json_for(ret)
|
|
@@ -84,7 +85,7 @@ async def apply(
|
|
|
84
85
|
async def install(obj: Obj, postgresexporter: PostgresExporter) -> None:
|
|
85
86
|
"""Install the service for a (non-local) instance."""
|
|
86
87
|
settings = prometheus.get_settings(_site.SETTINGS)
|
|
87
|
-
with obj.lock:
|
|
88
|
+
with obj.lock, audit():
|
|
88
89
|
async with task.async_transaction():
|
|
89
90
|
await impl.apply(postgresexporter, _site.SETTINGS, settings)
|
|
90
91
|
|
|
@@ -95,7 +96,7 @@ async def install(obj: Obj, postgresexporter: PostgresExporter) -> None:
|
|
|
95
96
|
@async_command
|
|
96
97
|
async def uninstall(obj: Obj, name: str) -> None:
|
|
97
98
|
"""Uninstall the service."""
|
|
98
|
-
with obj.lock:
|
|
99
|
+
with obj.lock, audit():
|
|
99
100
|
await impl.drop(_site.SETTINGS, name)
|
|
100
101
|
|
|
101
102
|
|
|
@@ -112,7 +113,7 @@ async def start(obj: Obj, name: str, foreground: bool) -> None:
|
|
|
112
113
|
<version>-<name>.
|
|
113
114
|
"""
|
|
114
115
|
settings = prometheus.get_settings(_site.SETTINGS)
|
|
115
|
-
with obj.lock:
|
|
116
|
+
with obj.lock, audit():
|
|
116
117
|
service = impl.system_lookup(name, settings)
|
|
117
118
|
if service is None:
|
|
118
119
|
raise exceptions.InstanceNotFound(name)
|
|
@@ -131,7 +132,7 @@ async def stop(obj: Obj, name: str) -> None:
|
|
|
131
132
|
<version>-<name>.
|
|
132
133
|
"""
|
|
133
134
|
settings = prometheus.get_settings(_site.SETTINGS)
|
|
134
|
-
with obj.lock:
|
|
135
|
+
with obj.lock, audit():
|
|
135
136
|
service = impl.system_lookup(name, settings)
|
|
136
137
|
if service is None:
|
|
137
138
|
raise exceptions.InstanceNotFound(name)
|
|
@@ -21,6 +21,7 @@ from .util import (
|
|
|
21
21
|
Obj,
|
|
22
22
|
OutputFormat,
|
|
23
23
|
async_command,
|
|
24
|
+
audit,
|
|
24
25
|
dry_run_option,
|
|
25
26
|
instance_identifier_option,
|
|
26
27
|
manifest_option,
|
|
@@ -76,7 +77,7 @@ def cli(**kwargs: Any) -> None:
|
|
|
76
77
|
@async_command
|
|
77
78
|
async def create(obj: Obj, instance: system.Instance, role: interface.Role) -> None:
|
|
78
79
|
"""Create a role in a PostgreSQL instance"""
|
|
79
|
-
with obj.lock:
|
|
80
|
+
with obj.lock, audit():
|
|
80
81
|
async with postgresql.running(instance):
|
|
81
82
|
if await roles.exists(instance, role.name):
|
|
82
83
|
raise click.ClickException("role already exists")
|
|
@@ -93,7 +94,7 @@ async def alter(
|
|
|
93
94
|
obj: Obj, instance: system.Instance, rolname: str, **changes: Any
|
|
94
95
|
) -> None:
|
|
95
96
|
"""Alter a role in a PostgreSQL instance"""
|
|
96
|
-
with obj.lock:
|
|
97
|
+
with obj.lock, audit():
|
|
97
98
|
async with postgresql.running(instance):
|
|
98
99
|
values = (await roles.get(instance, rolname)).model_dump(by_alias=True)
|
|
99
100
|
values = deep_update(values, changes)
|
|
@@ -120,7 +121,7 @@ async def apply(
|
|
|
120
121
|
if dry_run:
|
|
121
122
|
ret = interface.ApplyResult(change_state=None)
|
|
122
123
|
else:
|
|
123
|
-
with obj.lock:
|
|
124
|
+
with obj.lock, audit():
|
|
124
125
|
async with postgresql.running(instance):
|
|
125
126
|
ret = await roles.apply(instance, role)
|
|
126
127
|
if output_format == OutputFormat.json:
|
|
@@ -9,18 +9,23 @@ import asyncio
|
|
|
9
9
|
import enum
|
|
10
10
|
import json
|
|
11
11
|
import logging
|
|
12
|
+
import logging.handlers
|
|
12
13
|
import os
|
|
13
14
|
import pathlib
|
|
15
|
+
import shlex
|
|
16
|
+
import sys
|
|
14
17
|
import tempfile
|
|
15
18
|
import time
|
|
16
19
|
import typing
|
|
17
20
|
from collections.abc import Coroutine, Iterable, Iterator, Sequence
|
|
18
21
|
from contextlib import contextmanager
|
|
22
|
+
from datetime import timedelta
|
|
19
23
|
from functools import cache, cached_property, singledispatch, wraps
|
|
20
24
|
from typing import IO, Any, Callable, TypedDict, TypeVar
|
|
21
25
|
|
|
22
26
|
import click
|
|
23
27
|
import filelock
|
|
28
|
+
import humanize
|
|
24
29
|
import psycopg
|
|
25
30
|
import pydantic
|
|
26
31
|
import pydantic_core
|
|
@@ -31,19 +36,18 @@ from click.shell_completion import CompletionItem
|
|
|
31
36
|
from rich.console import Console
|
|
32
37
|
from rich.table import Table
|
|
33
38
|
|
|
34
|
-
from pglift import exceptions, install, instances
|
|
39
|
+
from pglift import exceptions, install, instances
|
|
35
40
|
from pglift._compat import ParamSpec
|
|
36
41
|
from pglift.models import helpers, system
|
|
37
42
|
from pglift.settings import Settings
|
|
38
43
|
from pglift.settings._postgresql import PostgreSQLVersion
|
|
39
|
-
from pglift.task import Displayer
|
|
40
44
|
from pglift.types import AutoStrEnum, ByteSizeType
|
|
41
45
|
|
|
42
|
-
from . import
|
|
46
|
+
from . import __name__ as pkgname
|
|
47
|
+
from . import _site, logger, loggers, model
|
|
48
|
+
from ._settings import CLISettings
|
|
43
49
|
from .console import console
|
|
44
50
|
|
|
45
|
-
logger = logging.getLogger("pglift")
|
|
46
|
-
|
|
47
51
|
|
|
48
52
|
def model_dump(
|
|
49
53
|
m: pydantic.BaseModel, by_alias: bool = True, **kwargs: Any
|
|
@@ -429,18 +433,92 @@ foreground_option = click.option(
|
|
|
429
433
|
|
|
430
434
|
|
|
431
435
|
@contextmanager
|
|
432
|
-
def
|
|
436
|
+
def audit(
|
|
437
|
+
command: Sequence[str] = sys.argv, settings: CLISettings | None = None
|
|
438
|
+
) -> Iterator[None]:
|
|
439
|
+
"""Context manager handling log messages to the audit file, if configured
|
|
440
|
+
in site settings.
|
|
441
|
+
"""
|
|
442
|
+
if settings is None:
|
|
443
|
+
settings = _site.SETTINGS.cli
|
|
444
|
+
if (audit_settings := settings.audit) is None:
|
|
445
|
+
yield None
|
|
446
|
+
return
|
|
447
|
+
|
|
448
|
+
audit_file = audit_settings.path
|
|
449
|
+
if not audit_file.parent.exists():
|
|
450
|
+
logger.debug("creating audit file parent directory")
|
|
451
|
+
audit_file.parent.mkdir(parents=True, exist_ok=True)
|
|
452
|
+
logger.debug("using audit file %s", audit_file)
|
|
453
|
+
|
|
454
|
+
handler = logging.handlers.WatchedFileHandler(audit_file)
|
|
455
|
+
handler.setLevel(logging.DEBUG)
|
|
456
|
+
formatter = logging.Formatter(
|
|
457
|
+
fmt=audit_settings.log_format, datefmt=audit_settings.date_format
|
|
458
|
+
)
|
|
459
|
+
handler.setFormatter(formatter)
|
|
460
|
+
|
|
461
|
+
loggrs = [logging.getLogger(n) for n in loggers]
|
|
462
|
+
for loggr in loggrs:
|
|
463
|
+
loggr.addHandler(handler)
|
|
464
|
+
|
|
465
|
+
# The audit logger, as defined here, is only meant to emit start/end
|
|
466
|
+
# messages below; and we avoid them to get propagated to higher logger.
|
|
467
|
+
audit_logger = logging.getLogger(pkgname).getChild("audit")
|
|
468
|
+
audit_logger.propagate = False
|
|
469
|
+
audit_logger.addHandler(handler)
|
|
470
|
+
audit_logger.setLevel(logging.DEBUG)
|
|
471
|
+
|
|
472
|
+
audit_logger.info("command: %s", shlex.join(command))
|
|
473
|
+
started_at = time.monotonic()
|
|
474
|
+
|
|
475
|
+
try:
|
|
476
|
+
yield None
|
|
477
|
+
except Exception as exc:
|
|
478
|
+
if isinstance(exc, exceptions.Cancelled):
|
|
479
|
+
msg, level = "command cancelled (%s)", logging.WARNING
|
|
480
|
+
else:
|
|
481
|
+
msg, level = "command failed (%s)", logging.ERROR
|
|
482
|
+
raise
|
|
483
|
+
else:
|
|
484
|
+
msg, level = "command completed (%s)", logging.INFO
|
|
485
|
+
finally:
|
|
486
|
+
# Note: by removing the audit handler from loggers managed above, we
|
|
487
|
+
# avoid termination messages (typically those emitted in
|
|
488
|
+
# Command.invoke()) to be emitted in this handler.
|
|
489
|
+
# If this appears to be a bad idea, removeHandler() should be invoked
|
|
490
|
+
# through context.call_on_close().
|
|
491
|
+
for loggr in loggrs:
|
|
492
|
+
loggr.removeHandler(handler)
|
|
493
|
+
elapsed = humanize.precisedelta(
|
|
494
|
+
timedelta(seconds=time.monotonic() - started_at)
|
|
495
|
+
)
|
|
496
|
+
audit_logger.log(level, msg, elapsed)
|
|
497
|
+
audit_logger.removeHandler(handler)
|
|
498
|
+
handler.close()
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
@contextmanager
|
|
502
|
+
def command_logging(logdir: pathlib.Path | None) -> Iterator[None]:
|
|
433
503
|
logdir_created = False
|
|
434
504
|
logfilename = f"{time.time()}.log"
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
505
|
+
if logdir is not None: # pragma: nocover (DEPRECATED)
|
|
506
|
+
logdir_created = False
|
|
507
|
+
logfile = logdir / logfilename
|
|
508
|
+
try:
|
|
509
|
+
if not logdir.exists():
|
|
510
|
+
logdir.mkdir(parents=True)
|
|
511
|
+
logdir_created = True
|
|
512
|
+
except OSError:
|
|
513
|
+
# Might be, e.g. PermissionError, if log file path is not writable.
|
|
514
|
+
logfile = pathlib.Path(
|
|
515
|
+
tempfile.NamedTemporaryFile(
|
|
516
|
+
prefix="pglift-", suffix="-" + logfilename
|
|
517
|
+
).name
|
|
518
|
+
)
|
|
519
|
+
else:
|
|
442
520
|
logfile = pathlib.Path(
|
|
443
|
-
tempfile.NamedTemporaryFile(prefix="pglift", suffix=logfilename).name
|
|
521
|
+
tempfile.NamedTemporaryFile(prefix="pglift-", suffix="-" + logfilename).name
|
|
444
522
|
)
|
|
445
523
|
handler = logging.FileHandler(logfile)
|
|
446
524
|
formatter = logging.Formatter(
|
|
@@ -449,9 +527,9 @@ def command_logging(logdir: pathlib.Path) -> Iterator[None]:
|
|
|
449
527
|
)
|
|
450
528
|
handler.setFormatter(formatter)
|
|
451
529
|
logger.addHandler(handler)
|
|
452
|
-
if logdir_created:
|
|
530
|
+
if logdir is not None and logdir_created: # pragma: nocover (DEPRECATED)
|
|
453
531
|
logger.debug("created log directory %s", logdir)
|
|
454
|
-
logger.debug("logging
|
|
532
|
+
logger.debug("debug logging at %s", logfile)
|
|
455
533
|
keep_logfile = False
|
|
456
534
|
try:
|
|
457
535
|
yield None
|
|
@@ -467,16 +545,15 @@ def command_logging(logdir: pathlib.Path) -> Iterator[None]:
|
|
|
467
545
|
finally:
|
|
468
546
|
if not keep_logfile:
|
|
469
547
|
os.unlink(logfile)
|
|
470
|
-
if
|
|
548
|
+
if (
|
|
549
|
+
logdir is not None
|
|
550
|
+
and logdir_created
|
|
551
|
+
and next(logdir.iterdir(), None) is None
|
|
552
|
+
): # pragma: nocover (DEPRECATED)
|
|
471
553
|
logger.debug("removing log directory %s", logdir)
|
|
472
554
|
logdir.rmdir()
|
|
473
555
|
|
|
474
556
|
|
|
475
|
-
class LogDisplayer:
|
|
476
|
-
def handle(self, msg: str) -> None:
|
|
477
|
-
logger.info(msg)
|
|
478
|
-
|
|
479
|
-
|
|
480
557
|
class InteractiveUserInterface:
|
|
481
558
|
"""An interactive UI that prompts for confirmation."""
|
|
482
559
|
|
|
@@ -499,13 +576,7 @@ class Obj:
|
|
|
499
576
|
# instance_identifier_option decorator's callback.
|
|
500
577
|
_instance: str | system.Instance
|
|
501
578
|
|
|
502
|
-
def __init__(
|
|
503
|
-
self,
|
|
504
|
-
*,
|
|
505
|
-
displayer: Displayer | None = None,
|
|
506
|
-
debug: bool = False,
|
|
507
|
-
) -> None:
|
|
508
|
-
self.displayer = displayer
|
|
579
|
+
def __init__(self, *, debug: bool = False) -> None:
|
|
509
580
|
self.debug = debug
|
|
510
581
|
|
|
511
582
|
@cached_property
|
|
@@ -537,17 +608,16 @@ def async_command(
|
|
|
537
608
|
class Command(click.Command):
|
|
538
609
|
def invoke(self, context: click.Context) -> Any:
|
|
539
610
|
obj: Obj = context.obj
|
|
540
|
-
displayer = obj.displayer
|
|
541
611
|
with command_logging(_site.SETTINGS.cli.logpath):
|
|
542
612
|
try:
|
|
543
|
-
|
|
544
|
-
return super().invoke(context)
|
|
613
|
+
return super().invoke(context)
|
|
545
614
|
except filelock.Timeout:
|
|
546
615
|
raise click.ClickException("another operation is in progress") from None
|
|
547
616
|
except exceptions.Cancelled as e:
|
|
548
617
|
logger.warning(str(e))
|
|
549
618
|
raise click.Abort from None
|
|
550
619
|
except pydantic.ValidationError as e:
|
|
620
|
+
logger.debug("a validation error occurred", exc_info=obj.debug)
|
|
551
621
|
raise click.ClickException(str(e)) from None
|
|
552
622
|
except exceptions.Error as e:
|
|
553
623
|
logger.debug("an internal error occurred", exc_info=obj.debug)
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
# SPDX-FileCopyrightText: 2021 Dalibo
|
|
2
|
-
#
|
|
3
|
-
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
-
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
from typing import Annotated
|
|
7
|
-
|
|
8
|
-
from pydantic import Field
|
|
9
|
-
|
|
10
|
-
from pglift.settings import Settings as BaseSettings
|
|
11
|
-
from pglift.settings import SiteSettings as BaseSiteSettings
|
|
12
|
-
from pglift.settings.base import BaseModel, LogPath, RunPath
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
class CLISettings(BaseModel):
|
|
16
|
-
"""Settings for pglift's command-line interface."""
|
|
17
|
-
|
|
18
|
-
logpath: Annotated[
|
|
19
|
-
Annotated[Path, LogPath],
|
|
20
|
-
Field(
|
|
21
|
-
description="Directory where temporary log files from command executions will be stored",
|
|
22
|
-
title="CLI log directory",
|
|
23
|
-
),
|
|
24
|
-
] = Path()
|
|
25
|
-
|
|
26
|
-
log_format: Annotated[
|
|
27
|
-
str, Field(description="Format for log messages when written to a file")
|
|
28
|
-
] = "%(asctime)s %(levelname)-8s %(name)s - %(message)s"
|
|
29
|
-
|
|
30
|
-
date_format: Annotated[
|
|
31
|
-
str, Field(description="Date format in log messages when written to a file")
|
|
32
|
-
] = "%Y-%m-%d %H:%M:%S"
|
|
33
|
-
|
|
34
|
-
lock_file: Annotated[
|
|
35
|
-
Path, RunPath, Field(description="Path to lock file dedicated to pglift")
|
|
36
|
-
] = Path(".pglift.lock")
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
class Settings(BaseSettings):
|
|
40
|
-
cli: Annotated[CLISettings, Field(default_factory=CLISettings)]
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
class SiteSettings(Settings, BaseSiteSettings):
|
|
44
|
-
pass
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|