pglift-cli 1.3.0__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.
- pglift_cli/__init__.py +7 -0
- pglift_cli/__main__.py +10 -0
- pglift_cli/_settings.py +44 -0
- pglift_cli/_site.py +34 -0
- pglift_cli/base.py +53 -0
- pglift_cli/console.py +9 -0
- pglift_cli/database.py +260 -0
- pglift_cli/hookspecs.py +24 -0
- pglift_cli/instance.py +461 -0
- pglift_cli/main.py +277 -0
- pglift_cli/model.py +346 -0
- pglift_cli/patroni.py +37 -0
- pglift_cli/pgbackrest/__init__.py +101 -0
- pglift_cli/pgbackrest/repo_path.py +40 -0
- pglift_cli/pgconf.py +151 -0
- pglift_cli/pm.py +19 -0
- pglift_cli/postgres.py +30 -0
- pglift_cli/prometheus.py +138 -0
- pglift_cli/role.py +197 -0
- pglift_cli/util.py +576 -0
- pglift_cli-1.3.0.dist-info/METADATA +59 -0
- pglift_cli-1.3.0.dist-info/RECORD +24 -0
- pglift_cli-1.3.0.dist-info/WHEEL +4 -0
- pglift_cli-1.3.0.dist-info/entry_points.txt +8 -0
pglift_cli/util.py
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2021 Dalibo
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import abc
|
|
8
|
+
import asyncio
|
|
9
|
+
import enum
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import pathlib
|
|
14
|
+
import tempfile
|
|
15
|
+
import time
|
|
16
|
+
import typing
|
|
17
|
+
from collections.abc import Coroutine, Iterable, Iterator, Sequence
|
|
18
|
+
from contextlib import contextmanager
|
|
19
|
+
from functools import cache, cached_property, singledispatch, wraps
|
|
20
|
+
from typing import Any, Callable, TypeVar
|
|
21
|
+
|
|
22
|
+
import click
|
|
23
|
+
import filelock
|
|
24
|
+
import psycopg
|
|
25
|
+
import pydantic
|
|
26
|
+
import pydantic_core
|
|
27
|
+
import rich
|
|
28
|
+
import rich.prompt
|
|
29
|
+
from click.shell_completion import CompletionItem
|
|
30
|
+
from rich.console import Console
|
|
31
|
+
from rich.table import Table
|
|
32
|
+
|
|
33
|
+
from pglift import exceptions, install, instances, task
|
|
34
|
+
from pglift._compat import ParamSpec
|
|
35
|
+
from pglift.models import helpers, system
|
|
36
|
+
from pglift.settings import Settings
|
|
37
|
+
from pglift.settings._postgresql import PostgreSQLVersion
|
|
38
|
+
from pglift.task import Displayer
|
|
39
|
+
from pglift.types import AutoStrEnum, ByteSizeType
|
|
40
|
+
|
|
41
|
+
from . import _site, model
|
|
42
|
+
from .console import console
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger("pglift")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def model_dump(
|
|
48
|
+
m: pydantic.BaseModel, by_alias: bool = True, **kwargs: Any
|
|
49
|
+
) -> dict[str, Any]:
|
|
50
|
+
return m.model_dump(by_alias=by_alias, **kwargs)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@singledispatch
|
|
54
|
+
def prettify(value: Any, annotations: Sequence[Any] = ()) -> str:
|
|
55
|
+
"""Prettify a value."""
|
|
56
|
+
return str(value)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@prettify.register(int)
|
|
60
|
+
def _(value: int, annotations: Sequence[Any] = ()) -> str:
|
|
61
|
+
"""Prettify an integer value"""
|
|
62
|
+
for a in annotations:
|
|
63
|
+
if isinstance(a, ByteSizeType):
|
|
64
|
+
return a.human_readable(value)
|
|
65
|
+
return str(value)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@prettify.register(list)
|
|
69
|
+
def _(value: list[Any], annotations: Sequence[Any] = ()) -> str:
|
|
70
|
+
"""Prettify a List value"""
|
|
71
|
+
return ", ".join(str(x) for x in value)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@prettify.register(set)
|
|
75
|
+
def _(value: set[Any], annotations: Sequence[Any] = ()) -> str:
|
|
76
|
+
"""Prettify a Set value"""
|
|
77
|
+
return prettify(sorted(value), annotations)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@prettify.register(type(None))
|
|
81
|
+
def _(value: None, annotations: Sequence[Any] = ()) -> str:
|
|
82
|
+
"""Prettify a None value"""
|
|
83
|
+
return ""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@prettify.register(dict)
|
|
87
|
+
def _(value: dict[str, Any], annotations: Sequence[Any] = ()) -> str:
|
|
88
|
+
"""Prettify a Dict value"""
|
|
89
|
+
|
|
90
|
+
def prettify_dict(
|
|
91
|
+
d: dict[str, Any], level: int = 0, indent: str = " "
|
|
92
|
+
) -> Iterator[str]:
|
|
93
|
+
for key, value in d.items():
|
|
94
|
+
row = f"{indent * level}{key}:"
|
|
95
|
+
if isinstance(value, dict):
|
|
96
|
+
yield row
|
|
97
|
+
yield from prettify_dict(value, level + 1)
|
|
98
|
+
else:
|
|
99
|
+
yield row + " " + prettify(value)
|
|
100
|
+
|
|
101
|
+
return "\n".join(prettify_dict(value))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
_I = TypeVar("_I")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def print_table_for(
|
|
108
|
+
items: Iterable[_I],
|
|
109
|
+
asdict: Callable[[_I], dict[str, Any]],
|
|
110
|
+
title: str | None = None,
|
|
111
|
+
*,
|
|
112
|
+
console: Console = console,
|
|
113
|
+
**kwargs: Any,
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Render a list of items as a table."""
|
|
116
|
+
table = None
|
|
117
|
+
headers: list[str] = []
|
|
118
|
+
rows = []
|
|
119
|
+
for item in items:
|
|
120
|
+
row = []
|
|
121
|
+
hdr = []
|
|
122
|
+
annotations = typing.get_type_hints(item.__class__, include_extras=True)
|
|
123
|
+
for k, v in asdict(item).items():
|
|
124
|
+
f_annotations = []
|
|
125
|
+
try:
|
|
126
|
+
i_annotations = annotations[k]
|
|
127
|
+
except KeyError:
|
|
128
|
+
pass
|
|
129
|
+
else:
|
|
130
|
+
if args := typing.get_args(i_annotations):
|
|
131
|
+
_, *f_annotations = args
|
|
132
|
+
hdr.append(k)
|
|
133
|
+
row.append(prettify(v, f_annotations))
|
|
134
|
+
if not headers:
|
|
135
|
+
headers = hdr[:]
|
|
136
|
+
rows.append(row)
|
|
137
|
+
if not rows:
|
|
138
|
+
return
|
|
139
|
+
table = Table(*headers, title=title, **kwargs)
|
|
140
|
+
for row in rows:
|
|
141
|
+
table.add_row(*row)
|
|
142
|
+
console.print(table)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def print_json_for(data: Any, *, console: Console = console) -> None:
|
|
146
|
+
"""Render `data` as JSON."""
|
|
147
|
+
console.print_json(data=pydantic_core.to_jsonable_python(data))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
P = ParamSpec("P")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def print_schema(
|
|
154
|
+
context: click.Context,
|
|
155
|
+
param: click.Parameter,
|
|
156
|
+
value: bool,
|
|
157
|
+
*,
|
|
158
|
+
model: type[pydantic.BaseModel],
|
|
159
|
+
console: Console = console,
|
|
160
|
+
) -> None:
|
|
161
|
+
"""Callback for --schema flag."""
|
|
162
|
+
if value:
|
|
163
|
+
console.print_json(data=model.model_json_schema())
|
|
164
|
+
context.exit()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def print_argspec(
|
|
168
|
+
context: click.Context,
|
|
169
|
+
param: click.Parameter,
|
|
170
|
+
value: bool,
|
|
171
|
+
*,
|
|
172
|
+
model: type[pydantic.BaseModel],
|
|
173
|
+
) -> None:
|
|
174
|
+
"""Callback for --ansible-argspec flag."""
|
|
175
|
+
if value:
|
|
176
|
+
click.echo(
|
|
177
|
+
json.dumps(helpers.argspec_from_model(model), sort_keys=False, indent=2)
|
|
178
|
+
)
|
|
179
|
+
context.exit()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def pass_instance(f: Callable[P, None]) -> Callable[P, None]:
|
|
183
|
+
"""Command decorator passing 'instance' bound to click.Context's object."""
|
|
184
|
+
|
|
185
|
+
@wraps(f)
|
|
186
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> None:
|
|
187
|
+
context = click.get_current_context()
|
|
188
|
+
instance = context.obj.instance
|
|
189
|
+
assert isinstance(instance, system.Instance), instance
|
|
190
|
+
context.invoke(f, instance, *args, **kwargs)
|
|
191
|
+
|
|
192
|
+
return wrapper
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def get_instance(name: str, version: str | None, settings: Settings) -> system.Instance:
|
|
196
|
+
"""Return an Instance from name/version, possibly guessing version if unspecified."""
|
|
197
|
+
if version is None:
|
|
198
|
+
found = None
|
|
199
|
+
for version in PostgreSQLVersion:
|
|
200
|
+
try:
|
|
201
|
+
instance = system.Instance.system_lookup((name, version, settings))
|
|
202
|
+
except exceptions.InstanceNotFound:
|
|
203
|
+
logger.debug("instance '%s' not found in version %s", name, version)
|
|
204
|
+
else:
|
|
205
|
+
if found:
|
|
206
|
+
raise click.BadParameter(
|
|
207
|
+
f"instance {name!r} exists in several PostgreSQL versions;"
|
|
208
|
+
" please select version explicitly"
|
|
209
|
+
)
|
|
210
|
+
found = instance
|
|
211
|
+
|
|
212
|
+
if found:
|
|
213
|
+
return found
|
|
214
|
+
|
|
215
|
+
raise click.BadParameter(f"instance {name!r} not found")
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
return system.Instance.system_lookup((name, version, settings))
|
|
219
|
+
except Exception as e:
|
|
220
|
+
raise click.BadParameter(str(e)) from None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def nameversion_from_id(instance_id: str) -> tuple[str, str | None]:
|
|
224
|
+
version = None
|
|
225
|
+
try:
|
|
226
|
+
version, name = instance_id.split("/", 1)
|
|
227
|
+
except ValueError:
|
|
228
|
+
name = instance_id
|
|
229
|
+
return name, version
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def instance_lookup(
|
|
233
|
+
context: click.Context, param: click.Parameter, value: None | str | tuple[str]
|
|
234
|
+
) -> system.Instance | tuple[system.Instance, ...]:
|
|
235
|
+
"""Return one or more system.Instance, possibly guessed if there is only
|
|
236
|
+
one on system, depending on 'param' variadic flag (nargs).
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
settings = _site.SETTINGS
|
|
240
|
+
|
|
241
|
+
def guess() -> tuple[str, str | None]:
|
|
242
|
+
"""Return (name, version) of the instance found on system, if there's
|
|
243
|
+
only one, or fail.
|
|
244
|
+
"""
|
|
245
|
+
try:
|
|
246
|
+
(i,) = instances.system_list(settings)
|
|
247
|
+
except ValueError:
|
|
248
|
+
raise click.UsageError(
|
|
249
|
+
f"argument {param.get_error_hint(context)} is required."
|
|
250
|
+
) from None
|
|
251
|
+
return i.name, i.version
|
|
252
|
+
|
|
253
|
+
if context.params.get("all_instances"):
|
|
254
|
+
return tuple(
|
|
255
|
+
get_instance(i.name, i.version, i._settings)
|
|
256
|
+
for i in instances.system_list(settings)
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
if param.nargs == 1:
|
|
260
|
+
if value is None:
|
|
261
|
+
name, version = guess()
|
|
262
|
+
else:
|
|
263
|
+
assert isinstance(value, str)
|
|
264
|
+
name, version = nameversion_from_id(value)
|
|
265
|
+
return get_instance(name, version, settings)
|
|
266
|
+
|
|
267
|
+
elif param.nargs == -1:
|
|
268
|
+
assert isinstance(value, tuple)
|
|
269
|
+
if value:
|
|
270
|
+
return tuple(
|
|
271
|
+
get_instance(*nameversion_from_id(item), settings) for item in value
|
|
272
|
+
)
|
|
273
|
+
else:
|
|
274
|
+
name, version = guess()
|
|
275
|
+
return (get_instance(name, version, settings),)
|
|
276
|
+
|
|
277
|
+
else:
|
|
278
|
+
raise AssertionError(f"unexpected nargs={param.nargs}")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def instance_bind_context(
|
|
282
|
+
context: click.Context, param: click.Parameter, value: str | None
|
|
283
|
+
) -> None:
|
|
284
|
+
"""Bind instance specified as -i/--instance to context's object, possibly
|
|
285
|
+
guessing from available instance if there is only one.
|
|
286
|
+
"""
|
|
287
|
+
obj: Obj = context.obj
|
|
288
|
+
version: str | None
|
|
289
|
+
if value is None:
|
|
290
|
+
values = list(instances.system_list(_site.SETTINGS))
|
|
291
|
+
if not values:
|
|
292
|
+
obj._instance = "no instance found; create one first."
|
|
293
|
+
return
|
|
294
|
+
elif len(values) > 1:
|
|
295
|
+
option = param.get_error_hint(context)
|
|
296
|
+
obj._instance = f"several instances found; option {option} is required."
|
|
297
|
+
return
|
|
298
|
+
(i,) = values
|
|
299
|
+
name, version = i.name, i.version
|
|
300
|
+
else:
|
|
301
|
+
name, version = nameversion_from_id(value)
|
|
302
|
+
instance = get_instance(name, version, _site.SETTINGS)
|
|
303
|
+
obj._instance = instance
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _list_instances(
|
|
307
|
+
context: click.Context, param: click.Parameter, incomplete: str
|
|
308
|
+
) -> list[CompletionItem]:
|
|
309
|
+
"""Shell completion function for instance identifier <name> or <version>/<name>."""
|
|
310
|
+
out = []
|
|
311
|
+
iname, iversion = nameversion_from_id(incomplete)
|
|
312
|
+
for i in instances.system_list(_site.SETTINGS):
|
|
313
|
+
if iversion is not None and i.version.startswith(iversion):
|
|
314
|
+
if i.name.startswith(iname):
|
|
315
|
+
out.append(
|
|
316
|
+
CompletionItem(f"{i.version}/{i.name}", help=f"port={i.port}")
|
|
317
|
+
)
|
|
318
|
+
else:
|
|
319
|
+
out.append(CompletionItem(i.version))
|
|
320
|
+
else:
|
|
321
|
+
out.append(
|
|
322
|
+
CompletionItem(i.name, help=f"{i.version}/{i.name} port={i.port}")
|
|
323
|
+
)
|
|
324
|
+
return out
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def instance_identifier(nargs: int = 1, required: bool = False) -> Callable[[F], F]:
|
|
331
|
+
def decorator(fn: F) -> F:
|
|
332
|
+
command = click.argument(
|
|
333
|
+
"instance",
|
|
334
|
+
nargs=nargs,
|
|
335
|
+
required=required,
|
|
336
|
+
callback=instance_lookup,
|
|
337
|
+
shell_complete=_list_instances,
|
|
338
|
+
)(fn)
|
|
339
|
+
assert command.__doc__
|
|
340
|
+
command.__doc__ += (
|
|
341
|
+
"\n\n INSTANCE identifies target instance as <version>/<name> where the "
|
|
342
|
+
"<version>/ prefix may be omitted if there is only one instance "
|
|
343
|
+
"matching <name>."
|
|
344
|
+
)
|
|
345
|
+
if not required:
|
|
346
|
+
command.__doc__ += " Required if there is more than one instance on system."
|
|
347
|
+
return command
|
|
348
|
+
|
|
349
|
+
return decorator
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
instance_identifier_option = click.option(
|
|
353
|
+
"-i",
|
|
354
|
+
"--instance",
|
|
355
|
+
"instance",
|
|
356
|
+
metavar="<version>/<name>",
|
|
357
|
+
callback=instance_bind_context,
|
|
358
|
+
shell_complete=_list_instances,
|
|
359
|
+
help=(
|
|
360
|
+
"Instance identifier; the <version>/ prefix may be omitted if "
|
|
361
|
+
"there's only one instance matching <name>. "
|
|
362
|
+
"Required if there is more than one instance on system."
|
|
363
|
+
),
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
class OutputFormat(AutoStrEnum):
|
|
368
|
+
"""Output format"""
|
|
369
|
+
|
|
370
|
+
json = enum.auto()
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
output_format_option = click.option(
|
|
374
|
+
"-o",
|
|
375
|
+
"--output-format",
|
|
376
|
+
type=click.Choice(model.choices_from_enum(OutputFormat), case_sensitive=False),
|
|
377
|
+
help="Specify the output format.",
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
dry_run_option = click.option(
|
|
381
|
+
"--dry-run", is_flag=True, help="Only validate input data."
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def validate_foreground(
|
|
386
|
+
context: click.Context, param: click.Parameter, value: bool
|
|
387
|
+
) -> bool:
|
|
388
|
+
if _site.SETTINGS.service_manager == "systemd" and value:
|
|
389
|
+
raise click.BadParameter("cannot be used with systemd")
|
|
390
|
+
return value
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
foreground_option = click.option(
|
|
394
|
+
"--foreground",
|
|
395
|
+
is_flag=True,
|
|
396
|
+
help="Start the program in foreground.",
|
|
397
|
+
callback=validate_foreground,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@contextmanager
|
|
402
|
+
def command_logging(logdir: pathlib.Path) -> Iterator[None]:
|
|
403
|
+
logdir_created = False
|
|
404
|
+
logfilename = f"{time.time()}.log"
|
|
405
|
+
logfile = logdir / logfilename
|
|
406
|
+
try:
|
|
407
|
+
if not logdir.exists():
|
|
408
|
+
logdir.mkdir(parents=True)
|
|
409
|
+
logdir_created = True
|
|
410
|
+
except OSError:
|
|
411
|
+
# Might be, e.g. PermissionError, if log file path is not writable.
|
|
412
|
+
logfile = pathlib.Path(
|
|
413
|
+
tempfile.NamedTemporaryFile(prefix="pglift", suffix=logfilename).name
|
|
414
|
+
)
|
|
415
|
+
handler = logging.FileHandler(logfile)
|
|
416
|
+
formatter = logging.Formatter(
|
|
417
|
+
fmt="%(levelname)-8s - %(asctime)s - %(name)s:%(filename)s:%(lineno)d - %(message)s",
|
|
418
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
419
|
+
)
|
|
420
|
+
handler.setFormatter(formatter)
|
|
421
|
+
logger.addHandler(handler)
|
|
422
|
+
if logdir_created:
|
|
423
|
+
logger.debug("created log directory %s", logdir)
|
|
424
|
+
logger.debug("logging command at %s", logfile)
|
|
425
|
+
keep_logfile = False
|
|
426
|
+
try:
|
|
427
|
+
yield None
|
|
428
|
+
except (click.Abort, click.ClickException, click.exceptions.Exit):
|
|
429
|
+
raise
|
|
430
|
+
except Exception:
|
|
431
|
+
keep_logfile = True
|
|
432
|
+
logger.exception("an unexpected error occurred")
|
|
433
|
+
raise click.ClickException(
|
|
434
|
+
"an unexpected error occurred, this is probably a bug; "
|
|
435
|
+
f"details can be found at {logfile}"
|
|
436
|
+
) from None
|
|
437
|
+
finally:
|
|
438
|
+
if not keep_logfile:
|
|
439
|
+
os.unlink(logfile)
|
|
440
|
+
if logdir_created and next(logdir.iterdir(), None) is None:
|
|
441
|
+
logger.debug("removing log directory %s", logdir)
|
|
442
|
+
logdir.rmdir()
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
class LogDisplayer:
|
|
446
|
+
def handle(self, msg: str) -> None:
|
|
447
|
+
logger.info(msg)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
class InteractiveUserInterface:
|
|
451
|
+
"""An interactive UI that prompts for confirmation."""
|
|
452
|
+
|
|
453
|
+
def confirm(self, message: str, default: bool) -> bool:
|
|
454
|
+
return rich.prompt.Confirm().ask(
|
|
455
|
+
f"[yellow]>[/yellow] {message}", default=default
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
@cache
|
|
459
|
+
def prompt(self, message: str, hide_input: bool = False) -> str:
|
|
460
|
+
return rich.prompt.Prompt().ask(
|
|
461
|
+
f"[yellow]>[/yellow] {message}", password=hide_input
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
class Obj:
|
|
466
|
+
"""Object bound to click.Context"""
|
|
467
|
+
|
|
468
|
+
# Set in commands taking a -i/--instance option through
|
|
469
|
+
# instance_identifier_option decorator's callback.
|
|
470
|
+
_instance: str | system.Instance
|
|
471
|
+
|
|
472
|
+
def __init__(
|
|
473
|
+
self,
|
|
474
|
+
*,
|
|
475
|
+
displayer: Displayer | None = None,
|
|
476
|
+
debug: bool = False,
|
|
477
|
+
) -> None:
|
|
478
|
+
self.displayer = displayer
|
|
479
|
+
self.debug = debug
|
|
480
|
+
|
|
481
|
+
@cached_property
|
|
482
|
+
def lock(self) -> filelock.FileLock:
|
|
483
|
+
"""Lock to prevent concurrent execution."""
|
|
484
|
+
lockfile = _site.SETTINGS.cli.lock_file
|
|
485
|
+
lockfile.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
486
|
+
return filelock.FileLock(lockfile, timeout=0)
|
|
487
|
+
|
|
488
|
+
@cached_property
|
|
489
|
+
def instance(self) -> system.Instance:
|
|
490
|
+
if isinstance(self._instance, system.Instance):
|
|
491
|
+
return self._instance
|
|
492
|
+
else:
|
|
493
|
+
assert isinstance(self._instance, str)
|
|
494
|
+
raise click.UsageError(self._instance)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def async_command(
|
|
498
|
+
callback: Callable[P, Coroutine[None, None, None]]
|
|
499
|
+
) -> Callable[P, None]:
|
|
500
|
+
@wraps(callback)
|
|
501
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> None:
|
|
502
|
+
asyncio.run(callback(*args, **kwargs))
|
|
503
|
+
|
|
504
|
+
return wrapper
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
class Command(click.Command):
|
|
508
|
+
def invoke(self, context: click.Context) -> Any:
|
|
509
|
+
obj: Obj = context.obj
|
|
510
|
+
displayer = obj.displayer
|
|
511
|
+
with command_logging(_site.SETTINGS.cli.logpath):
|
|
512
|
+
try:
|
|
513
|
+
with task.displayer_installed(displayer):
|
|
514
|
+
return super().invoke(context)
|
|
515
|
+
except filelock.Timeout:
|
|
516
|
+
raise click.ClickException("another operation is in progress") from None
|
|
517
|
+
except exceptions.Cancelled as e:
|
|
518
|
+
logger.warning(str(e))
|
|
519
|
+
raise click.Abort from None
|
|
520
|
+
except (exceptions.ValidationError, pydantic.ValidationError) as e:
|
|
521
|
+
raise click.ClickException(str(e)) from None
|
|
522
|
+
except exceptions.Error as e:
|
|
523
|
+
logger.debug("an internal error occurred", exc_info=obj.debug)
|
|
524
|
+
msg = str(e)
|
|
525
|
+
if isinstance(e, exceptions.CommandError):
|
|
526
|
+
if e.stderr:
|
|
527
|
+
msg += f"\n{e.stderr}"
|
|
528
|
+
if e.stdout:
|
|
529
|
+
msg += f"\n{e.stdout}"
|
|
530
|
+
raise click.ClickException(msg) from None
|
|
531
|
+
except psycopg.DatabaseError as e:
|
|
532
|
+
logger.debug("a database error occurred", exc_info=True)
|
|
533
|
+
raise click.ClickException(str(e).strip()) from None
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
class Group(click.Group):
|
|
537
|
+
command_class = Command
|
|
538
|
+
group_class = type
|
|
539
|
+
|
|
540
|
+
def add_command(self, command: click.Command, name: str | None = None) -> None:
|
|
541
|
+
name = name or command.name
|
|
542
|
+
assert name not in self.commands, f"command {name!r} already registered"
|
|
543
|
+
super().add_command(command, name)
|
|
544
|
+
|
|
545
|
+
def invoke(self, ctx: click.Context) -> Any:
|
|
546
|
+
if not install.check(_site.SETTINGS):
|
|
547
|
+
raise click.ClickException(
|
|
548
|
+
"broken installation; did you run 'site-configure install'?",
|
|
549
|
+
)
|
|
550
|
+
return super().invoke(ctx)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
class PluggableCommandGroup(abc.ABC, Group):
|
|
554
|
+
_plugin_commands_loaded: bool = False
|
|
555
|
+
|
|
556
|
+
@abc.abstractmethod
|
|
557
|
+
def register_plugin_commands(self, obj: Obj) -> None: ...
|
|
558
|
+
|
|
559
|
+
def _load_plugins_commands(self, context: click.Context) -> None:
|
|
560
|
+
if self._plugin_commands_loaded:
|
|
561
|
+
return
|
|
562
|
+
obj: Obj | None = context.obj
|
|
563
|
+
if obj is None:
|
|
564
|
+
obj = context.ensure_object(Obj)
|
|
565
|
+
if obj is None:
|
|
566
|
+
return
|
|
567
|
+
self.register_plugin_commands(obj)
|
|
568
|
+
self._plugin_commands_loaded = True
|
|
569
|
+
|
|
570
|
+
def list_commands(self, context: click.Context) -> list[str]:
|
|
571
|
+
self._load_plugins_commands(context)
|
|
572
|
+
return super().list_commands(context)
|
|
573
|
+
|
|
574
|
+
def get_command(self, context: click.Context, name: str) -> click.Command | None:
|
|
575
|
+
self._load_plugins_commands(context)
|
|
576
|
+
return super().get_command(context, name)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: pglift_cli
|
|
3
|
+
Version: 1.3.0
|
|
4
|
+
Summary: Command-line interface for pglift
|
|
5
|
+
Project-URL: Documentation, https://pglift.readthedocs.io/
|
|
6
|
+
Project-URL: Source, https://gitlab.com/dalibo/pglift/
|
|
7
|
+
Project-URL: Tracker, https://gitlab.com/dalibo/pglift/-/issues/
|
|
8
|
+
Author-email: Dalibo SCOP <contact@dalibo.com>
|
|
9
|
+
License: GPLv3
|
|
10
|
+
Keywords: administration,command-line,deployment,postgresql
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
15
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Topic :: Database
|
|
22
|
+
Classifier: Topic :: System :: Systems Administration
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: <4,>=3.9
|
|
25
|
+
Requires-Dist: click!=8.1.0,!=8.1.4,>=8.0.0
|
|
26
|
+
Requires-Dist: filelock!=3.12.1,>=3.9.0
|
|
27
|
+
Requires-Dist: pluggy
|
|
28
|
+
Requires-Dist: psycopg>=3.1
|
|
29
|
+
Requires-Dist: pydantic>=2.5.0
|
|
30
|
+
Requires-Dist: pyyaml>=6.0.1
|
|
31
|
+
Requires-Dist: rich>=11.0.0
|
|
32
|
+
Provides-Extra: dev
|
|
33
|
+
Requires-Dist: pglift-cli[test,typing]; extra == 'dev'
|
|
34
|
+
Provides-Extra: test
|
|
35
|
+
Requires-Dist: anyio; extra == 'test'
|
|
36
|
+
Requires-Dist: patroni[etcd]>=2.1.5; extra == 'test'
|
|
37
|
+
Requires-Dist: port-for; extra == 'test'
|
|
38
|
+
Requires-Dist: prysk[pytest-plugin]>=0.14.0; extra == 'test'
|
|
39
|
+
Requires-Dist: pytest; extra == 'test'
|
|
40
|
+
Requires-Dist: pytest-cov; extra == 'test'
|
|
41
|
+
Requires-Dist: trustme; extra == 'test'
|
|
42
|
+
Provides-Extra: typing
|
|
43
|
+
Requires-Dist: mypy>=1.8.0; extra == 'typing'
|
|
44
|
+
Requires-Dist: types-pyyaml>=6.0.12.10; extra == 'typing'
|
|
45
|
+
Description-Content-Type: text/markdown
|
|
46
|
+
|
|
47
|
+
<!--
|
|
48
|
+
SPDX-FileCopyrightText: 2024 Dalibo
|
|
49
|
+
|
|
50
|
+
SPDX-License-Identifier: GPL-3.0-or-later
|
|
51
|
+
-->
|
|
52
|
+
|
|
53
|
+
This package provides the command-line interface for [pglift][]. It is a
|
|
54
|
+
dependency package and should not be installed directly but rather through the
|
|
55
|
+
``cli`` *optional dependency* (aka "extra") of the pglift package, e.g.:
|
|
56
|
+
|
|
57
|
+
$ pip install "pglift[cli]"
|
|
58
|
+
|
|
59
|
+
[pglift]: https://pglift.readthedocs.io/
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
pglift_cli/__init__.py,sha256=hgySzF5bjmvicFPVexksIJOMtm2Jlp3XKfZlNs11xs0,143
|
|
2
|
+
pglift_cli/__main__.py,sha256=BcvVDRUWCioA3XaySTcVAC9qP1dyT3nAigzmF4xuSiI,199
|
|
3
|
+
pglift_cli/_settings.py,sha256=ObWR7YyU45BMb6ahXLWbriGegguLND1K5v0QaE4YHLc,1281
|
|
4
|
+
pglift_cli/_site.py,sha256=vMjzi1syv18HDiKrHGrZjUk0Dvk2AB-Ti139SB28Akw,931
|
|
5
|
+
pglift_cli/base.py,sha256=SGawQThhNOmk2RnaCw3QRsusz8Mg0T-0JRjwch5saTQ,1628
|
|
6
|
+
pglift_cli/console.py,sha256=E7qUe4l4XmDSOSjn_0ximqu9vzHniQ6WNswPd0ZtV2w,172
|
|
7
|
+
pglift_cli/database.py,sha256=CEmhsx-K_SP28fZIijVpjLzufBfT91IOjcCXgjeF26M,7484
|
|
8
|
+
pglift_cli/hookspecs.py,sha256=KR3R7DQ5ZZ-9owcS2cGFpgyAXTjc0JZ1CCUI3E5L98o,563
|
|
9
|
+
pglift_cli/instance.py,sha256=lGoRfDtt_bvPPA1YA1jLAxA9ZvnQjA3oeHbwFLKWcb4,13822
|
|
10
|
+
pglift_cli/main.py,sha256=DpOEAJv5kOfhE2g3aBQX2pnsK0QpIRKnnVddvdSWWAo,7990
|
|
11
|
+
pglift_cli/model.py,sha256=al0Kx3b-Ilv9JPFE26vvhCGSR6BQ6KGl9oXgedyceWk,12344
|
|
12
|
+
pglift_cli/patroni.py,sha256=dra7cmlsR2_W2ku_KQXg0bdq5vsujX8vEH2baUAZXuI,878
|
|
13
|
+
pglift_cli/pgconf.py,sha256=_967q_fA5jh6w_hEEnyX3DvidhbNMoi2WUOzCu2WU0M,4879
|
|
14
|
+
pglift_cli/pm.py,sha256=Za8CYTMHHojojmfBUFjYZvC4eQVyxsgFE0zmv4d_Go4,389
|
|
15
|
+
pglift_cli/postgres.py,sha256=VUwjQkh-C1Zw3jg-Wt3J4oY_nGgoP-Q672bQvXB3Y3I,923
|
|
16
|
+
pglift_cli/prometheus.py,sha256=5fKtwwEjat87QDgVY5SzIHyHdDPbB-4VTN6BY1a0nRI,3922
|
|
17
|
+
pglift_cli/role.py,sha256=PtKxB_Sotwpkko7lgpD_pm4E-WrvnNu_-0z3QXZuCB8,5582
|
|
18
|
+
pglift_cli/util.py,sha256=uh-7axokFv1gLComjauqjTPbCjQYEi1lys3Y2Ctj8sA,17754
|
|
19
|
+
pglift_cli/pgbackrest/__init__.py,sha256=q9pSa4qChjo9VBDsRWNCKf9v7BsVgS-90BQcBvQSUl4,3016
|
|
20
|
+
pglift_cli/pgbackrest/repo_path.py,sha256=COoo_gvBQUNk7w23WF9Z5jb6bZQ-MOaY7m7AL03-lfc,1079
|
|
21
|
+
pglift_cli-1.3.0.dist-info/METADATA,sha256=hatNCFHx4DomAwn6AOVAPuy7F3dngyCn4wxjgIwtRxc,2249
|
|
22
|
+
pglift_cli-1.3.0.dist-info/WHEEL,sha256=TJPnKdtrSue7xZ_AVGkp9YXcvDrobsjBds1du3Nx6dc,87
|
|
23
|
+
pglift_cli-1.3.0.dist-info/entry_points.txt,sha256=jk1c4HAZ3oKjEEwxeBKjrZzqrOZD6_yEEasUvYfJDW8,255
|
|
24
|
+
pglift_cli-1.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
[console_scripts]
|
|
2
|
+
pglift = pglift_cli.main:cli
|
|
3
|
+
|
|
4
|
+
[pglift]
|
|
5
|
+
pglift_cli.patroni = pglift_cli.patroni
|
|
6
|
+
pglift_cli.pgbackrest = pglift_cli.pgbackrest
|
|
7
|
+
pglift_cli.pgbackrest.repo_path = pglift_cli.pgbackrest.repo_path
|
|
8
|
+
pglift_cli.prometheus = pglift_cli.prometheus
|