|
63 | 63 |
|
64 | 64 | from . import global_config |
65 | 65 | from .config import Config |
66 | | -from .dcs import AbstractDCS, Cluster, get_dcs as _get_dcs, Member |
| 66 | +from .dcs import AbstractDCS, Cluster, get_dcs as _get_dcs, Leader, Member |
67 | 67 | from .exceptions import PatroniException |
68 | 68 | from .postgresql.misc import postgres_version_to_int, PostgresqlRole, PostgresqlState |
69 | 69 | from .postgresql.mpp import get_mpp |
@@ -523,11 +523,14 @@ def watching(w: bool, watch: Optional[int], max_count: Optional[int] = None, cle |
523 | 523 | return |
524 | 524 |
|
525 | 525 | counter = 1 |
| 526 | + yield_time = time.time() |
526 | 527 | while watch and counter <= (max_count or counter): |
527 | | - time.sleep(watch) |
| 528 | + elapsed = time.time() - yield_time |
| 529 | + time.sleep(max(0, watch - elapsed)) |
528 | 530 | counter += 1 |
529 | 531 | if clear: |
530 | 532 | click.clear() |
| 533 | + yield_time = time.time() |
531 | 534 | yield 0 |
532 | 535 |
|
533 | 536 |
|
@@ -2512,3 +2515,115 @@ def format_pg_version(version: int) -> str: |
2512 | 2515 | return "{0}.{1}.{2}".format(version // 10000, version // 100 % 100, version % 100) |
2513 | 2516 | else: |
2514 | 2517 | return "{0}.{1}".format(version // 10000, version % 100) |
| 2518 | + |
| 2519 | + |
| 2520 | +def change_cluster_role(cluster_name: str, force: bool, standby_config: Optional[Dict[str, Any]]) -> None: |
| 2521 | + """Demote or promote cluster. |
| 2522 | +
|
| 2523 | + :param cluster_name: name of the Patroni cluster. |
| 2524 | + :param force: if ``True`` run cluster demotion without asking for confirmation. |
| 2525 | + :param standby_config: standby cluster configuration to be applied if demotion is requested. |
| 2526 | + """ |
| 2527 | + demote = bool(standby_config) |
| 2528 | + action_name = 'demot' if demote else 'promot' |
| 2529 | + target_role = PostgresqlRole.STANDBY_LEADER if demote else PostgresqlRole.PRIMARY |
| 2530 | + |
| 2531 | + dcs = get_dcs(cluster_name, None) |
| 2532 | + cluster = dcs.get_cluster() |
| 2533 | + leader_name = cluster.leader and cluster.leader.name |
| 2534 | + if not leader_name: |
| 2535 | + raise PatroniCtlException(f'Cluster has no leader, {action_name}ion is not possible') |
| 2536 | + if cluster.leader and cluster.leader.data.get('role') == target_role: |
| 2537 | + raise PatroniCtlException('Cluster is already in the required state') |
| 2538 | + |
| 2539 | + click.echo('Current cluster topology') |
| 2540 | + output_members(cluster, cluster_name) |
| 2541 | + if not force: |
| 2542 | + confirm = click.confirm(f'Are you sure you want to {action_name}e {cluster_name} cluster?') |
| 2543 | + if not confirm: |
| 2544 | + raise PatroniCtlException(f'Aborted cluster {action_name}ion') |
| 2545 | + |
| 2546 | + try: |
| 2547 | + if TYPE_CHECKING: # pragma: no cover |
| 2548 | + assert isinstance(cluster.leader, Leader) |
| 2549 | + r = request_patroni(cluster.leader.member, 'patch', 'config', {'standby_cluster': standby_config}) |
| 2550 | + |
| 2551 | + if r.status != 200: |
| 2552 | + raise PatroniCtlException( |
| 2553 | + f'Failed to {action_name}e {cluster_name} cluster: ' |
| 2554 | + f'/config PATCH status code={r.status}, ({r.data.decode("utf-8")})') |
| 2555 | + except Exception as err: |
| 2556 | + raise PatroniCtlException(f'Failed to {action_name}e {cluster_name} cluster: {err}') |
| 2557 | + |
| 2558 | + for _ in watching(True, 1, clear=False): |
| 2559 | + cluster = dcs.get_cluster() |
| 2560 | + is_unlocked = cluster.is_unlocked() |
| 2561 | + leader_role = cluster.leader and cluster.leader.data.get('role') |
| 2562 | + leader_state = cluster.leader and cluster.leader.data.get('state') |
| 2563 | + old_leader = cluster.get_member(leader_name, False) |
| 2564 | + old_leader_state = old_leader and old_leader.data.get('state') |
| 2565 | + |
| 2566 | + if not is_unlocked and leader_role == target_role and leader_state == PostgresqlState.RUNNING: |
| 2567 | + if not demote or old_leader_state == PostgresqlState.RUNNING: |
| 2568 | + click.echo( |
| 2569 | + f'{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")} cluster is successfully {action_name}ed') |
| 2570 | + break |
| 2571 | + |
| 2572 | + state_prts = [f'{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")} cluster is unlocked: {is_unlocked}', |
| 2573 | + f'leader role: {leader_role}', |
| 2574 | + f'leader state: {leader_state}'] |
| 2575 | + if demote and cluster.leader and leader_name != cluster.leader.name and old_leader_state: |
| 2576 | + state_prts.append(f'previous leader state: {repr(old_leader_state)}') |
| 2577 | + click.echo(", ".join(state_prts)) |
| 2578 | + output_members(cluster, cluster_name) |
| 2579 | + |
| 2580 | + |
| 2581 | +@ctl.command('demote-cluster', help="Demote cluster to a standby cluster") |
| 2582 | +@arg_cluster_name |
| 2583 | +@option_force |
| 2584 | +@click.option('--host', help='Address of the remote node', required=False) |
| 2585 | +@click.option('--port', help='Port of the remote node', type=int, required=False) |
| 2586 | +@click.option('--restore-command', help='Command to restore WAL records from the remote primary', required=False) |
| 2587 | +@click.option('--primary-slot-name', help='Name of the slot on the remote node to use for replication', required=False) |
| 2588 | +def demote_cluster(cluster_name: str, force: bool, host: Optional[str], port: Optional[int], |
| 2589 | + restore_command: Optional[str], primary_slot_name: Optional[str]) -> None: |
| 2590 | + """Process ``demote-cluster`` command of ``patronictl`` utility. |
| 2591 | +
|
| 2592 | + Demote cluster to a standby cluster. |
| 2593 | +
|
| 2594 | + :param cluster_name: name of the Patroni cluster. |
| 2595 | + :param force: if ``True`` run cluster demotion without asking for confirmation. |
| 2596 | + :param host: address of the remote node. |
| 2597 | + :param port: port of the remote node. |
| 2598 | + :param restore_command: command to restore WAL records from the remote primary'. |
| 2599 | + :param primary_slot_name: name of the slot on the remote node to use for replication. |
| 2600 | +
|
| 2601 | + :raises: |
| 2602 | + :class:`PatroniCtlException`: if: |
| 2603 | + * neither ``host`` nor ``port`` nor ``restore_command`` is provided; or |
| 2604 | + * cluster has no leader; or |
| 2605 | + * cluster is already in the required state; or |
| 2606 | + * operation is aborted. |
| 2607 | + """ |
| 2608 | + if not any((host, port, restore_command)): |
| 2609 | + raise PatroniCtlException('At least --host, --port or --restore-command should be specified') |
| 2610 | + |
| 2611 | + data = {k: v for k, v in {'host': host, |
| 2612 | + 'port': port, |
| 2613 | + 'primary_slot_name': primary_slot_name, |
| 2614 | + 'restore_command': restore_command}.items() if v} |
| 2615 | + change_cluster_role(cluster_name, force, data) |
| 2616 | + |
| 2617 | + |
| 2618 | +@ctl.command('promote-cluster', help="Promote cluster, make it run standalone") |
| 2619 | +@arg_cluster_name |
| 2620 | +@option_force |
| 2621 | +def promote_cluster(cluster_name: str, force: bool) -> None: |
| 2622 | + """Process ``promote-cluster`` command of ``patronictl`` utility. |
| 2623 | +
|
| 2624 | + Promote cluster, make it run standalone. |
| 2625 | +
|
| 2626 | + :param cluster_name: name of the Patroni cluster. |
| 2627 | + :param force: if ``True`` run cluster demotion without asking for confirmation. |
| 2628 | + """ |
| 2629 | + change_cluster_role(cluster_name, force, None) |
0 commit comments