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/instance.py ADDED
@@ -0,0 +1,461 @@
1
+ # SPDX-FileCopyrightText: 2021 Dalibo
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ from collections.abc import Sequence
9
+ from functools import partial
10
+ from typing import IO, Any
11
+
12
+ import click
13
+ from pydantic.v1.utils import deep_update
14
+
15
+ from pglift import (
16
+ async_hooks,
17
+ hooks,
18
+ hookspecs,
19
+ instances,
20
+ postgresql,
21
+ privileges,
22
+ task,
23
+ )
24
+ from pglift.models import interface, system
25
+ from pglift.settings._postgresql import PostgreSQLVersion
26
+ from pglift.types import Status
27
+
28
+ from . import _site
29
+ from . import hookspecs as h
30
+ from . import model
31
+ from .util import (
32
+ Obj,
33
+ OutputFormat,
34
+ PluggableCommandGroup,
35
+ async_command,
36
+ dry_run_option,
37
+ foreground_option,
38
+ instance_identifier,
39
+ model_dump,
40
+ output_format_option,
41
+ print_argspec,
42
+ print_json_for,
43
+ print_schema,
44
+ print_table_for,
45
+ )
46
+
47
+
48
+ class InstanceCommands(PluggableCommandGroup):
49
+ """Group for 'instance' sub-commands, part of which come from registered
50
+ plugins.
51
+ """
52
+
53
+ def register_plugin_commands(self, obj: Obj) -> None:
54
+ hooks(_site.PLUGIN_MANAGER, h.add_instance_commands, group=self)
55
+
56
+
57
+ def print_instance_schema(
58
+ context: click.Context, param: click.Parameter, value: bool
59
+ ) -> None:
60
+ return print_schema(context, param, value, model=_site.INSTANCE_MODEL)
61
+
62
+
63
+ def print_instance_argspec(
64
+ context: click.Context, param: click.Parameter, value: bool
65
+ ) -> None:
66
+ print_argspec(context, param, value, model=_site.INSTANCE_MODEL)
67
+
68
+
69
+ @click.group(cls=InstanceCommands)
70
+ @click.option(
71
+ "--schema",
72
+ is_flag=True,
73
+ callback=print_instance_schema,
74
+ expose_value=False,
75
+ is_eager=True,
76
+ help="Print the JSON schema of instance model and exit.",
77
+ )
78
+ @click.option(
79
+ "--ansible-argspec",
80
+ is_flag=True,
81
+ callback=print_instance_argspec,
82
+ expose_value=False,
83
+ is_eager=True,
84
+ hidden=True,
85
+ help="Print the Ansible argspec of instance model and exit.",
86
+ )
87
+ def cli() -> None:
88
+ """Manage instances."""
89
+
90
+
91
+ @cli.command("create")
92
+ @model.as_parameters(_site.INSTANCE_MODEL, "create")
93
+ @click.option(
94
+ "--drop-on-error/--no-drop-on-error",
95
+ default=True,
96
+ help=(
97
+ "On error, drop partially initialized instance by possibly "
98
+ "rolling back operations (true by default)."
99
+ ),
100
+ )
101
+ @click.pass_obj
102
+ @async_command
103
+ async def create(obj: Obj, instance: interface.Instance, drop_on_error: bool) -> None:
104
+ """Initialize a PostgreSQL instance"""
105
+ with obj.lock:
106
+ if instances.exists(instance.name, instance.version, _site.SETTINGS):
107
+ raise click.ClickException("instance already exists")
108
+ async with task.async_transaction(drop_on_error):
109
+ await instances.apply(_site.SETTINGS, instance)
110
+
111
+
112
+ @cli.command("alter") # type: ignore[arg-type]
113
+ @instance_identifier(nargs=1)
114
+ @model.as_parameters(_site.INSTANCE_MODEL, "update", parse_model=False)
115
+ @click.pass_obj
116
+ @async_command
117
+ async def alter(obj: Obj, instance: system.Instance, **changes: Any) -> None:
118
+ """Alter PostgreSQL INSTANCE"""
119
+ with obj.lock:
120
+ status = await postgresql.status(instance)
121
+ manifest = await instances._get(instance, status)
122
+ values = manifest.model_dump(by_alias=True, exclude={"settings"})
123
+ values = deep_update(values, changes)
124
+ altered = _site.INSTANCE_MODEL.model_validate(values)
125
+ await instances.apply(
126
+ _site.SETTINGS, altered, _is_running=status == Status.running
127
+ )
128
+
129
+
130
+ @cli.command("apply", hidden=True)
131
+ @click.option("-f", "--file", type=click.File("r"), metavar="MANIFEST", required=True)
132
+ @output_format_option
133
+ @dry_run_option
134
+ @click.pass_obj
135
+ @async_command
136
+ async def apply(
137
+ obj: Obj, file: IO[str], output_format: OutputFormat, dry_run: bool
138
+ ) -> None:
139
+ """Apply manifest as a PostgreSQL instance"""
140
+ instance = _site.INSTANCE_MODEL.parse_yaml(file)
141
+ if dry_run:
142
+ ret = interface.InstanceApplyResult(change_state=None)
143
+ else:
144
+ with obj.lock:
145
+ ret = await instances.apply(_site.SETTINGS, instance)
146
+ if output_format == OutputFormat.json:
147
+ print_json_for(ret)
148
+
149
+
150
+ @cli.command("promote")
151
+ @instance_identifier(nargs=1)
152
+ @click.pass_obj
153
+ @async_command
154
+ async def promote(obj: Obj, instance: system.Instance) -> None:
155
+ """Promote standby PostgreSQL INSTANCE"""
156
+ with obj.lock:
157
+ await instances.promote(instance)
158
+
159
+
160
+ @cli.command("get")
161
+ @output_format_option
162
+ @instance_identifier(nargs=1)
163
+ @async_command
164
+ async def get(instance: system.Instance, output_format: OutputFormat) -> None:
165
+ """Get the description of PostgreSQL INSTANCE.
166
+
167
+ Unless --output-format is specified, 'settings' and 'state' fields are not
168
+ shown as well as 'standby' information if INSTANCE is not a standby.
169
+ """
170
+ i = await instances.get(instance)
171
+ if output_format == OutputFormat.json:
172
+ print_json_for(model_dump(i))
173
+ else:
174
+ exclude = {
175
+ "settings",
176
+ "state",
177
+ "data_directory",
178
+ "wal_directory",
179
+ "powa",
180
+ }
181
+ if not instance.standby:
182
+ exclude.add("standby")
183
+ print_table_for([i], partial(model_dump, exclude=exclude), box=None)
184
+
185
+
186
+ @cli.command("list")
187
+ @click.option(
188
+ "--version",
189
+ type=click.Choice(list(PostgreSQLVersion)),
190
+ help="Only list instances of specified version.",
191
+ )
192
+ @output_format_option
193
+ @async_command
194
+ async def ls(version: PostgreSQLVersion | None, output_format: OutputFormat) -> None:
195
+ """List the available instances"""
196
+ items = [i async for i in instances.ls(_site.SETTINGS, version=version)]
197
+ if output_format == OutputFormat.json:
198
+ print_json_for([model_dump(m) for m in items])
199
+ else:
200
+ print_table_for(items, model_dump)
201
+
202
+
203
+ @cli.command("drop")
204
+ @instance_identifier(nargs=-1)
205
+ @click.pass_obj
206
+ @async_command
207
+ async def drop(obj: Obj, instance: tuple[system.Instance, ...]) -> None:
208
+ """Drop PostgreSQL INSTANCE"""
209
+ with obj.lock:
210
+ for i in instance:
211
+ await instances.drop(i)
212
+
213
+
214
+ @cli.command("status")
215
+ @instance_identifier(nargs=1)
216
+ @click.pass_context
217
+ @async_command
218
+ async def status(context: click.Context, instance: system.Instance) -> None:
219
+ """Check the status of instance and all satellite components.
220
+
221
+ Output the status string value ('running', 'not running') for each component.
222
+ If not all services are running, the command exit code will be 3.
223
+ """
224
+ exit_code = Status.running.value
225
+ results = list(
226
+ filter(
227
+ None,
228
+ await async_hooks(
229
+ instance._settings, hookspecs.instance_status, instance=instance
230
+ ),
231
+ )
232
+ )
233
+ for status, component in reversed(results):
234
+ if status != Status.running:
235
+ exit_code = Status.not_running.value
236
+ click.echo(f"{component}: {status.name.replace('_', ' ')}")
237
+ context.exit(exit_code)
238
+
239
+
240
+ @cli.command("start")
241
+ @instance_identifier(nargs=-1)
242
+ @foreground_option
243
+ @click.option("--all", "all_instances", is_flag=True, help="Start all instances.")
244
+ @click.pass_obj
245
+ @async_command
246
+ async def start(
247
+ obj: Obj,
248
+ instance: tuple[system.Instance, ...],
249
+ foreground: bool,
250
+ all_instances: bool,
251
+ ) -> None:
252
+ """Start PostgreSQL INSTANCE"""
253
+ if foreground and len(instance) != 1:
254
+ raise click.UsageError(
255
+ "only one INSTANCE argument may be given with --foreground"
256
+ )
257
+ with obj.lock:
258
+ for i in instance:
259
+ await instances.start(i, foreground=foreground)
260
+
261
+
262
+ @cli.command("stop")
263
+ @instance_identifier(nargs=-1)
264
+ @click.option("--all", "all_instances", is_flag=True, help="Stop all instances.")
265
+ @click.pass_obj
266
+ @async_command
267
+ async def stop(
268
+ obj: Obj, instance: tuple[system.Instance, ...], all_instances: bool
269
+ ) -> None:
270
+ """Stop PostgreSQL INSTANCE"""
271
+ with obj.lock:
272
+ for i in instance:
273
+ await instances.stop(i)
274
+
275
+
276
+ @cli.command("reload")
277
+ @instance_identifier(nargs=-1)
278
+ @click.option("--all", "all_instances", is_flag=True, help="Reload all instances.")
279
+ @click.pass_obj
280
+ @async_command
281
+ async def reload(
282
+ obj: Obj, instance: tuple[system.Instance, ...], all_instances: bool
283
+ ) -> None:
284
+ """Reload PostgreSQL INSTANCE"""
285
+ with obj.lock:
286
+ for i in instance:
287
+ await instances.reload(i)
288
+
289
+
290
+ @cli.command("restart")
291
+ @instance_identifier(nargs=-1)
292
+ @click.option("--all", "all_instances", is_flag=True, help="Restart all instances.")
293
+ @click.pass_obj
294
+ @async_command
295
+ async def restart(
296
+ obj: Obj, instance: tuple[system.Instance, ...], all_instances: bool
297
+ ) -> None:
298
+ """Restart PostgreSQL INSTANCE"""
299
+ with obj.lock:
300
+ for i in instance:
301
+ await instances.restart(i)
302
+
303
+
304
+ @cli.command("exec")
305
+ @instance_identifier(nargs=1, required=True)
306
+ @click.argument("command", required=True, nargs=-1, type=click.UNPROCESSED)
307
+ def exec(instance: system.Instance, command: tuple[str, ...]) -> None:
308
+ """Execute command in the libpq environment for PostgreSQL INSTANCE.
309
+
310
+ COMMAND parts may need to be prefixed with -- to separate them from
311
+ options when confusion arises.
312
+ """
313
+ instances.exec(instance, command)
314
+
315
+
316
+ def get_shell(context: click.Context, param: click.Parameter, value: str | None) -> str:
317
+ if value is None:
318
+ try:
319
+ value = os.environ["SHELL"]
320
+ except KeyError:
321
+ raise click.UsageError(
322
+ f"SHELL environment variable not found; try to use {'/'.join(param.opts)} option"
323
+ ) from None
324
+ return value
325
+
326
+
327
+ @cli.command("shell")
328
+ @instance_identifier(nargs=1)
329
+ @click.option(
330
+ "--shell",
331
+ type=click.Path(exists=True, dir_okay=False),
332
+ callback=get_shell,
333
+ help="Path to shell executable",
334
+ )
335
+ def shell(instance: system.Instance, shell: str) -> None:
336
+ """Start a shell with instance environment.
337
+
338
+ Unless --shell option is specified, the $SHELL environment variable is
339
+ used to guess which shell executable to use.
340
+ """
341
+ env = instances.env_for(instance, path=True)
342
+ click.echo(f"Starting {shell!r} with {instance} environment")
343
+ os.execle(shell, shell, os.environ | env) # nosec
344
+
345
+
346
+ @cli.command("env")
347
+ @instance_identifier(nargs=1)
348
+ @output_format_option
349
+ def env(instance: system.Instance, output_format: OutputFormat) -> None:
350
+ """Output environment variables suitable to handle to PostgreSQL INSTANCE.
351
+
352
+ This can be injected in shell using:
353
+
354
+ export $(pglift instance env myinstance)
355
+ """
356
+ instance_env = instances.env_for(instance, path=True)
357
+ if output_format == OutputFormat.json:
358
+ print_json_for(instance_env)
359
+ else:
360
+ for key, value in sorted(instance_env.items()):
361
+ click.echo(f"{key}={value}")
362
+
363
+
364
+ @cli.command("logs")
365
+ @click.option("--follow/--no-follow", "-f/", default=False, help="Follow log output.")
366
+ @instance_identifier(nargs=1)
367
+ def logs(instance: system.Instance, follow: bool) -> None:
368
+ """Output PostgreSQL logs of INSTANCE.
369
+
370
+ This assumes that the PostgreSQL instance is configured to use file-based
371
+ logging (i.e. log_destination amongst 'stderr' or 'csvlog').
372
+ """
373
+ if follow:
374
+ logstream = postgresql.logs(instance)
375
+ else:
376
+ logstream = postgresql.logs(instance, timeout=0)
377
+ try:
378
+ for line in logstream:
379
+ click.echo(line, nl=False)
380
+ except TimeoutError:
381
+ pass
382
+ except FileNotFoundError as e:
383
+ raise click.ClickException(str(e)) from None
384
+
385
+
386
+ @cli.command("privileges")
387
+ @instance_identifier(nargs=1)
388
+ @click.option(
389
+ "-d",
390
+ "--database",
391
+ "databases",
392
+ multiple=True,
393
+ help="Database to inspect. When not provided, all databases are inspected.",
394
+ )
395
+ @click.option("-r", "--role", "roles", multiple=True, help="Role to inspect")
396
+ @click.option("--default", "defaults", is_flag=True, help="Display default privileges")
397
+ @output_format_option
398
+ @async_command
399
+ async def list_privileges(
400
+ instance: system.Instance,
401
+ databases: Sequence[str],
402
+ roles: Sequence[str],
403
+ defaults: bool,
404
+ output_format: OutputFormat,
405
+ ) -> None:
406
+ """List privileges on INSTANCE's databases."""
407
+ async with postgresql.running(instance):
408
+ try:
409
+ prvlgs = await privileges.get(
410
+ instance, databases=databases, roles=roles, defaults=defaults
411
+ )
412
+ except ValueError as e:
413
+ raise click.ClickException(str(e)) from None
414
+ if output_format == OutputFormat.json:
415
+ print_json_for([model_dump(p) for p in prvlgs])
416
+ else:
417
+ if defaults:
418
+ title = f"Default privileges on instance {instance}"
419
+ else:
420
+ title = f"Privileges on instance {instance}"
421
+ print_table_for(prvlgs, model_dump, title=title)
422
+
423
+
424
+ @cli.command("upgrade")
425
+ @instance_identifier(nargs=1)
426
+ @click.option(
427
+ "--version",
428
+ "newversion",
429
+ type=click.Choice(list(PostgreSQLVersion)),
430
+ help="PostgreSQL version of the new instance (default to site-configured value).",
431
+ )
432
+ @click.option(
433
+ "--name", "newname", help="Name of the new instance (default to old instance name)."
434
+ )
435
+ @click.option(
436
+ "--port", required=False, type=click.INT, help="Port of the new instance."
437
+ )
438
+ @click.option(
439
+ "--jobs",
440
+ required=False,
441
+ type=click.INT,
442
+ help="Number of simultaneous processes or threads to use (from pg_upgrade).",
443
+ )
444
+ @click.pass_obj
445
+ @async_command
446
+ async def upgrade(
447
+ obj: Obj,
448
+ instance: system.Instance,
449
+ newversion: PostgreSQLVersion | None,
450
+ newname: str | None,
451
+ port: int | None,
452
+ jobs: int | None,
453
+ ) -> None:
454
+ """Upgrade INSTANCE using pg_upgrade"""
455
+ with obj.lock:
456
+ await postgresql.check_status(instance, Status.not_running)
457
+ async with task.async_transaction():
458
+ new_instance = await instances.upgrade(
459
+ instance, version=newversion, name=newname, port=port, jobs=jobs
460
+ )
461
+ await instances.start(new_instance)