kctl-redis 0.8.4__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.
- kctl_redis/__init__.py +3 -0
- kctl_redis/__main__.py +5 -0
- kctl_redis/cli.py +154 -0
- kctl_redis/commands/__init__.py +0 -0
- kctl_redis/commands/backup.py +144 -0
- kctl_redis/commands/clients.py +71 -0
- kctl_redis/commands/config_cmd.py +242 -0
- kctl_redis/commands/dashboard.py +82 -0
- kctl_redis/commands/db.py +93 -0
- kctl_redis/commands/doctor_cmd.py +87 -0
- kctl_redis/commands/health.py +133 -0
- kctl_redis/commands/keys.py +216 -0
- kctl_redis/commands/maintenance.py +57 -0
- kctl_redis/commands/memory.py +124 -0
- kctl_redis/commands/performance.py +112 -0
- kctl_redis/commands/persistence.py +93 -0
- kctl_redis/commands/pubsub.py +70 -0
- kctl_redis/commands/query.py +42 -0
- kctl_redis/commands/replication.py +82 -0
- kctl_redis/commands/server.py +101 -0
- kctl_redis/commands/skill_cmd.py +76 -0
- kctl_redis/commands/streams.py +168 -0
- kctl_redis/core/__init__.py +0 -0
- kctl_redis/core/callbacks.py +46 -0
- kctl_redis/core/client.py +137 -0
- kctl_redis/core/config.py +160 -0
- kctl_redis/core/exceptions.py +41 -0
- kctl_redis/core/output.py +5 -0
- kctl_redis-0.8.4.dist-info/METADATA +19 -0
- kctl_redis-0.8.4.dist-info/RECORD +32 -0
- kctl_redis-0.8.4.dist-info/WHEEL +4 -0
- kctl_redis-0.8.4.dist-info/entry_points.txt +2 -0
kctl_redis/__init__.py
ADDED
kctl_redis/__main__.py
ADDED
kctl_redis/cli.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Main CLI entry point for kctl-redis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from kctl_lib import handle_cli_error
|
|
9
|
+
from kctl_lib import cli_entrypoint, register_introspection_commands
|
|
10
|
+
|
|
11
|
+
from kctl_redis import __version__
|
|
12
|
+
from kctl_redis.commands.backup import app as backup_app
|
|
13
|
+
from kctl_redis.commands.clients import app as clients_app
|
|
14
|
+
from kctl_redis.commands.config_cmd import app as config_app
|
|
15
|
+
from kctl_redis.commands.dashboard import app as dashboard_app
|
|
16
|
+
from kctl_redis.commands.db import app as db_app
|
|
17
|
+
from kctl_redis.commands.health import app as health_app
|
|
18
|
+
from kctl_redis.commands.keys import app as keys_app
|
|
19
|
+
from kctl_redis.commands.maintenance import app as maintenance_app
|
|
20
|
+
from kctl_redis.commands.memory import app as memory_app
|
|
21
|
+
from kctl_redis.commands.performance import app as performance_app
|
|
22
|
+
from kctl_redis.commands.persistence import app as persistence_app
|
|
23
|
+
from kctl_redis.commands.pubsub import app as pubsub_app
|
|
24
|
+
from kctl_redis.commands.query import app as query_app
|
|
25
|
+
from kctl_redis.commands.replication import app as replication_app
|
|
26
|
+
from kctl_redis.commands.server import app as server_app
|
|
27
|
+
from kctl_redis.commands.streams import app as streams_app
|
|
28
|
+
from kctl_redis.commands.skill_cmd import app as skill_app
|
|
29
|
+
from kctl_redis.commands.doctor_cmd import app as doctor_app
|
|
30
|
+
from kctl_redis.core.callbacks import AppContext
|
|
31
|
+
from kctl_redis.core.exceptions import KctlError
|
|
32
|
+
from kctl_lib.self_update import notify_if_outdated
|
|
33
|
+
from kctl_lib.tui import add_tui_command
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def version_callback(value: bool) -> None:
|
|
37
|
+
if value:
|
|
38
|
+
typer.echo(f"kctl-redis {__version__}")
|
|
39
|
+
raise typer.Exit()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
app = typer.Typer(
|
|
43
|
+
name="kctl-redis",
|
|
44
|
+
help="Kodemeio Redis CLI - manage Redis servers via SSH tunnel.",
|
|
45
|
+
no_args_is_help=True,
|
|
46
|
+
rich_markup_mode="rich",
|
|
47
|
+
pretty_exceptions_enable=False,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.callback()
|
|
52
|
+
def main(
|
|
53
|
+
ctx: typer.Context,
|
|
54
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
55
|
+
quiet: Annotated[bool, typer.Option("--quiet", "-q", help="Suppress info messages")] = False,
|
|
56
|
+
profile: Annotated[str | None, typer.Option("--profile", "-p", help="Config profile name")] = None,
|
|
57
|
+
host: Annotated[str | None, typer.Option("--host", "-H", help="Redis host override")] = None,
|
|
58
|
+
port: Annotated[int | None, typer.Option("--port", help="Redis port override")] = None,
|
|
59
|
+
user: Annotated[str | None, typer.Option("--user", "-U", help="Redis username override")] = None,
|
|
60
|
+
password: Annotated[str | None, typer.Option("--password", help="Redis password override")] = None,
|
|
61
|
+
db: Annotated[int | None, typer.Option("--db", help="Redis database number override")] = None,
|
|
62
|
+
version: Annotated[
|
|
63
|
+
bool, typer.Option("--version", "-V", callback=version_callback, is_eager=True, help="Show version")
|
|
64
|
+
] = False,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Kodemeio Redis CLI."""
|
|
67
|
+
ctx.ensure_object(dict)
|
|
68
|
+
ctx.obj = AppContext(
|
|
69
|
+
json_mode=json_output,
|
|
70
|
+
quiet=quiet,
|
|
71
|
+
profile=profile,
|
|
72
|
+
host_override=host,
|
|
73
|
+
port_override=port,
|
|
74
|
+
user_override=user,
|
|
75
|
+
password_override=password,
|
|
76
|
+
db_override=db,
|
|
77
|
+
)
|
|
78
|
+
notify_if_outdated(ctx.obj.output, "kctl-redis", __version__)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# Register all command groups
|
|
82
|
+
app.add_typer(config_app, name="config")
|
|
83
|
+
app.add_typer(health_app, name="health")
|
|
84
|
+
app.add_typer(dashboard_app, name="dashboard")
|
|
85
|
+
app.add_typer(query_app, name="query")
|
|
86
|
+
app.add_typer(keys_app, name="keys")
|
|
87
|
+
app.add_typer(db_app, name="db")
|
|
88
|
+
app.add_typer(memory_app, name="memory")
|
|
89
|
+
app.add_typer(clients_app, name="clients")
|
|
90
|
+
app.add_typer(server_app, name="server")
|
|
91
|
+
app.add_typer(persistence_app, name="persistence")
|
|
92
|
+
app.add_typer(replication_app, name="replication")
|
|
93
|
+
app.add_typer(pubsub_app, name="pubsub")
|
|
94
|
+
app.add_typer(streams_app, name="streams")
|
|
95
|
+
app.add_typer(performance_app, name="performance")
|
|
96
|
+
app.add_typer(backup_app, name="backup")
|
|
97
|
+
app.add_typer(maintenance_app, name="maintenance")
|
|
98
|
+
app.add_typer(skill_app, name="skill", hidden=True)
|
|
99
|
+
app.add_typer(doctor_app, name="doctor")
|
|
100
|
+
add_tui_command(app, service_key="redis", version=__version__)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.command("self-update")
|
|
104
|
+
def self_update_cmd(ctx: typer.Context) -> None:
|
|
105
|
+
"""Check for updates and upgrade kctl-redis."""
|
|
106
|
+
actx = ctx.obj
|
|
107
|
+
out = actx.output
|
|
108
|
+
|
|
109
|
+
from kctl_lib.self_update import check_update
|
|
110
|
+
from kctl_lib.self_update import update as do_update
|
|
111
|
+
|
|
112
|
+
latest = check_update("kctl-redis", __version__)
|
|
113
|
+
if latest:
|
|
114
|
+
out.info(f"Updating to {latest}...")
|
|
115
|
+
do_update("kctl-redis")
|
|
116
|
+
out.success(f"Updated to {latest}")
|
|
117
|
+
else:
|
|
118
|
+
out.success("Already up to date")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@app.command()
|
|
122
|
+
def completions(
|
|
123
|
+
shell: Annotated[str, typer.Argument(help="Shell type: zsh, bash, fish")] = "zsh",
|
|
124
|
+
install: Annotated[bool, typer.Option("--install", help="Install completions")] = False,
|
|
125
|
+
) -> None:
|
|
126
|
+
"""Generate or install shell completions."""
|
|
127
|
+
from kctl_lib.completions import get_completion_script, install_completions
|
|
128
|
+
|
|
129
|
+
if install:
|
|
130
|
+
path = install_completions("kctl-redis", shell)
|
|
131
|
+
if path:
|
|
132
|
+
typer.echo(f"Completions installed to {path}")
|
|
133
|
+
else:
|
|
134
|
+
typer.echo(f"Could not install completions for {shell}", err=True)
|
|
135
|
+
raise typer.Exit(code=1)
|
|
136
|
+
else:
|
|
137
|
+
script = get_completion_script("kctl-redis", shell)
|
|
138
|
+
typer.echo(script)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# Wrap app so KctlError subclasses surface as clean user-facing messages.
|
|
142
|
+
app = cli_entrypoint(app)
|
|
143
|
+
register_introspection_commands(app)
|
|
144
|
+
|
|
145
|
+
def _run() -> None:
|
|
146
|
+
"""Entry point with error handling."""
|
|
147
|
+
try:
|
|
148
|
+
app()
|
|
149
|
+
except KctlError as e:
|
|
150
|
+
handle_cli_error(e)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
if __name__ == "__main__":
|
|
154
|
+
_run()
|
|
File without changes
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Backup commands for kctl-redis (via SSH/SCP)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from kctl_lib.ssh import scp_download, scp_upload, ssh_run
|
|
13
|
+
from kctl_redis.core.callbacks import AppContext
|
|
14
|
+
from kctl_redis.core.config import ServiceConfig, resolve_connection
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(help="Redis backup operations via SSH.", no_args_is_help=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _resolve_ssh_config(app_ctx: AppContext) -> ServiceConfig:
|
|
20
|
+
"""Resolve connection config with all CLI overrides applied."""
|
|
21
|
+
return resolve_connection(
|
|
22
|
+
profile_name=app_ctx.profile,
|
|
23
|
+
host_override=app_ctx.host_override,
|
|
24
|
+
port_override=app_ctx.port_override,
|
|
25
|
+
user_override=app_ctx.user_override,
|
|
26
|
+
password_override=app_ctx.password_override,
|
|
27
|
+
db_override=app_ctx.db_override,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.command()
|
|
32
|
+
def dump(
|
|
33
|
+
ctx: typer.Context,
|
|
34
|
+
output_path: Annotated[str, typer.Option("--output", "-o", help="Local output path")] = "",
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Trigger BGSAVE and download the RDB file."""
|
|
37
|
+
app_ctx: AppContext = ctx.obj
|
|
38
|
+
out = app_ctx.output
|
|
39
|
+
c = app_ctx.client
|
|
40
|
+
|
|
41
|
+
out.info("Triggering BGSAVE...")
|
|
42
|
+
c.execute("BGSAVE")
|
|
43
|
+
|
|
44
|
+
deadline = time.monotonic() + 300
|
|
45
|
+
while True:
|
|
46
|
+
persist = c.info("persistence")
|
|
47
|
+
if not persist.get("rdb_bgsave_in_progress", 0):
|
|
48
|
+
break
|
|
49
|
+
if time.monotonic() >= deadline:
|
|
50
|
+
out.error("BGSAVE timed out after 300 seconds")
|
|
51
|
+
raise typer.Exit(1)
|
|
52
|
+
time.sleep(1)
|
|
53
|
+
|
|
54
|
+
status = c.info("persistence").get("rdb_last_bgsave_status", "?")
|
|
55
|
+
if status != "ok":
|
|
56
|
+
out.error(f"BGSAVE failed: {status}")
|
|
57
|
+
raise typer.Exit(1)
|
|
58
|
+
|
|
59
|
+
if not output_path:
|
|
60
|
+
timestamp = datetime.now(tz=timezone.utc).strftime("%Y%m%d-%H%M%S")
|
|
61
|
+
output_path = f"dump-{timestamp}.rdb"
|
|
62
|
+
|
|
63
|
+
cfg = _resolve_ssh_config(app_ctx)
|
|
64
|
+
remote_path = "/data/dump.rdb"
|
|
65
|
+
|
|
66
|
+
out.info(f"Downloading RDB from {cfg.ssh_host}...")
|
|
67
|
+
try:
|
|
68
|
+
scp_download(
|
|
69
|
+
cfg.ssh_host,
|
|
70
|
+
remote_path,
|
|
71
|
+
output_path,
|
|
72
|
+
user=cfg.ssh_user,
|
|
73
|
+
port=cfg.ssh_port,
|
|
74
|
+
ssh_key=cfg.ssh_key,
|
|
75
|
+
)
|
|
76
|
+
except Exception as e:
|
|
77
|
+
out.error(f"SCP failed: {e}")
|
|
78
|
+
raise typer.Exit(1) from e
|
|
79
|
+
|
|
80
|
+
out.success(f"Backup saved to {output_path}")
|
|
81
|
+
app_ctx.close()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@app.command()
|
|
85
|
+
def restore(
|
|
86
|
+
ctx: typer.Context,
|
|
87
|
+
file_path: Annotated[str, typer.Argument(help="Local RDB file to upload")],
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Upload an RDB file to the server."""
|
|
90
|
+
app_ctx: AppContext = ctx.obj
|
|
91
|
+
out = app_ctx.output
|
|
92
|
+
|
|
93
|
+
local = Path(file_path)
|
|
94
|
+
if not local.exists():
|
|
95
|
+
out.error(f"File not found: {file_path}")
|
|
96
|
+
raise typer.Exit(1)
|
|
97
|
+
|
|
98
|
+
cfg = _resolve_ssh_config(app_ctx)
|
|
99
|
+
remote_path = "/data/dump.rdb"
|
|
100
|
+
|
|
101
|
+
typer.confirm("This will overwrite the remote dump.rdb. Redis must be restarted to load it. Continue?", abort=True)
|
|
102
|
+
|
|
103
|
+
out.info(f"Uploading {file_path} to {cfg.ssh_host}...")
|
|
104
|
+
try:
|
|
105
|
+
scp_upload(
|
|
106
|
+
cfg.ssh_host,
|
|
107
|
+
str(local),
|
|
108
|
+
remote_path,
|
|
109
|
+
user=cfg.ssh_user,
|
|
110
|
+
port=cfg.ssh_port,
|
|
111
|
+
ssh_key=cfg.ssh_key,
|
|
112
|
+
)
|
|
113
|
+
except Exception as e:
|
|
114
|
+
out.error(f"SCP failed: {e}")
|
|
115
|
+
raise typer.Exit(1) from e
|
|
116
|
+
|
|
117
|
+
out.success(f"Uploaded {file_path}. Restart Redis to load the restored data.")
|
|
118
|
+
app_ctx.close()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@app.command(name="list")
|
|
122
|
+
def list_backups(
|
|
123
|
+
ctx: typer.Context,
|
|
124
|
+
remote_dir: Annotated[str, typer.Option(help="Remote backup directory")] = "/backups",
|
|
125
|
+
) -> None:
|
|
126
|
+
"""List backup files on the remote server."""
|
|
127
|
+
app_ctx: AppContext = ctx.obj
|
|
128
|
+
out = app_ctx.output
|
|
129
|
+
|
|
130
|
+
cfg = _resolve_ssh_config(app_ctx)
|
|
131
|
+
|
|
132
|
+
result = ssh_run(
|
|
133
|
+
cfg.ssh_host,
|
|
134
|
+
f"ls -lhtr {remote_dir}/*.rdb 2>/dev/null || echo '(no backups found)'",
|
|
135
|
+
user=cfg.ssh_user,
|
|
136
|
+
port=cfg.ssh_port,
|
|
137
|
+
ssh_key=cfg.ssh_key,
|
|
138
|
+
)
|
|
139
|
+
if not result.ok:
|
|
140
|
+
out.error(f"SSH failed: {result.stderr}")
|
|
141
|
+
raise typer.Exit(1)
|
|
142
|
+
|
|
143
|
+
out.text(result.stdout)
|
|
144
|
+
app_ctx.close()
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Client connection management commands for kctl-redis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from kctl_redis.core.callbacks import AppContext
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(help="Redis client management.", no_args_is_help=True)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command(name="list")
|
|
15
|
+
def list_clients(ctx: typer.Context) -> None:
|
|
16
|
+
"""List connected clients."""
|
|
17
|
+
app_ctx: AppContext = ctx.obj
|
|
18
|
+
out = app_ctx.output
|
|
19
|
+
r = app_ctx.client.r
|
|
20
|
+
|
|
21
|
+
clients = r.client_list()
|
|
22
|
+
if app_ctx.json_mode:
|
|
23
|
+
out.json({"clients": clients})
|
|
24
|
+
else:
|
|
25
|
+
rows = [
|
|
26
|
+
{
|
|
27
|
+
"id": c.get("id", "?"),
|
|
28
|
+
"addr": c.get("addr", "?"),
|
|
29
|
+
"name": c.get("name", ""),
|
|
30
|
+
"age": c.get("age", "?"),
|
|
31
|
+
"idle": c.get("idle", "?"),
|
|
32
|
+
"db": c.get("db", "?"),
|
|
33
|
+
"cmd": c.get("cmd", "?"),
|
|
34
|
+
}
|
|
35
|
+
for c in clients
|
|
36
|
+
]
|
|
37
|
+
out.table(rows, columns=["id", "addr", "name", "age", "idle", "db", "cmd"], title="Connected Clients")
|
|
38
|
+
|
|
39
|
+
app_ctx.close()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.command()
|
|
43
|
+
def kill(
|
|
44
|
+
ctx: typer.Context,
|
|
45
|
+
client_id: Annotated[int, typer.Argument(help="Client ID to kill")],
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Kill a client connection by ID."""
|
|
48
|
+
app_ctx: AppContext = ctx.obj
|
|
49
|
+
out = app_ctx.output
|
|
50
|
+
r = app_ctx.client.r
|
|
51
|
+
|
|
52
|
+
r.client_kill_filter(_id=client_id)
|
|
53
|
+
out.success(f"Killed client {client_id}")
|
|
54
|
+
app_ctx.close()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.command()
|
|
58
|
+
def info(ctx: typer.Context) -> None:
|
|
59
|
+
"""Show client connection info."""
|
|
60
|
+
app_ctx: AppContext = ctx.obj
|
|
61
|
+
out = app_ctx.output
|
|
62
|
+
c = app_ctx.client
|
|
63
|
+
|
|
64
|
+
clients_info = c.info("clients")
|
|
65
|
+
if app_ctx.json_mode:
|
|
66
|
+
out.json(clients_info)
|
|
67
|
+
else:
|
|
68
|
+
rows = [{"metric": k, "value": str(v)} for k, v in clients_info.items()]
|
|
69
|
+
out.table(rows, columns=["metric", "value"], title="Client Info")
|
|
70
|
+
|
|
71
|
+
app_ctx.close()
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Config profile management commands for kctl-redis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from kctl_redis.core.callbacks import AppContext
|
|
10
|
+
from kctl_redis.core.config import (
|
|
11
|
+
ServiceConfig,
|
|
12
|
+
get_profile_names,
|
|
13
|
+
get_service_config,
|
|
14
|
+
remove_profile,
|
|
15
|
+
resolve_active_profile_name,
|
|
16
|
+
set_default_profile,
|
|
17
|
+
set_service_config,
|
|
18
|
+
)
|
|
19
|
+
from kctl_redis.core.exceptions import KctlError
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(help="Manage kctl-redis configuration profiles.", no_args_is_help=True)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.command()
|
|
25
|
+
def init(
|
|
26
|
+
ctx: typer.Context,
|
|
27
|
+
profile: Annotated[str, typer.Option("--profile", "-p", help="Profile name")] = "production",
|
|
28
|
+
host: Annotated[str | None, typer.Option(help="Redis host (private IP)")] = None,
|
|
29
|
+
port: Annotated[int, typer.Option(help="Redis port")] = 6379,
|
|
30
|
+
username: Annotated[str, typer.Option(help="Redis username")] = "default",
|
|
31
|
+
password: Annotated[str | None, typer.Option(help="Redis password")] = None,
|
|
32
|
+
db: Annotated[int, typer.Option(help="Redis database number")] = 0,
|
|
33
|
+
ssh_host: Annotated[str | None, typer.Option(help="SSH jump host")] = None,
|
|
34
|
+
ssh_port: Annotated[int, typer.Option(help="SSH port")] = 22,
|
|
35
|
+
ssh_user: Annotated[str, typer.Option(help="SSH username")] = "root",
|
|
36
|
+
ssh_key: Annotated[str, typer.Option(help="SSH private key path")] = "~/.ssh/id_ed25519",
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Initialize kctl-redis configuration."""
|
|
39
|
+
app_ctx: AppContext = ctx.obj
|
|
40
|
+
out = app_ctx.output
|
|
41
|
+
|
|
42
|
+
if not host:
|
|
43
|
+
host = typer.prompt("Redis host (private IP)")
|
|
44
|
+
if not password:
|
|
45
|
+
password = typer.prompt("Redis password", hide_input=True)
|
|
46
|
+
if not ssh_host:
|
|
47
|
+
ssh_host = typer.prompt("SSH jump host (public IP)")
|
|
48
|
+
|
|
49
|
+
svc = ServiceConfig(
|
|
50
|
+
host=host,
|
|
51
|
+
port=port,
|
|
52
|
+
username=username,
|
|
53
|
+
password=password,
|
|
54
|
+
db=db,
|
|
55
|
+
ssh_host=ssh_host,
|
|
56
|
+
ssh_port=ssh_port,
|
|
57
|
+
ssh_user=ssh_user,
|
|
58
|
+
ssh_key=ssh_key,
|
|
59
|
+
)
|
|
60
|
+
set_service_config(profile, svc)
|
|
61
|
+
set_default_profile(profile)
|
|
62
|
+
out.success(f"Profile '{profile}' created and set as default.")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.command()
|
|
66
|
+
def add(
|
|
67
|
+
ctx: typer.Context,
|
|
68
|
+
profile: Annotated[str, typer.Argument(help="Profile name")],
|
|
69
|
+
host: Annotated[str, typer.Option(help="Redis host")] = "",
|
|
70
|
+
port: Annotated[int, typer.Option(help="Redis port")] = 6379,
|
|
71
|
+
username: Annotated[str, typer.Option(help="Redis username")] = "default",
|
|
72
|
+
password: Annotated[str, typer.Option(help="Redis password")] = "",
|
|
73
|
+
db: Annotated[int, typer.Option(help="Redis database number")] = 0,
|
|
74
|
+
ssh_host: Annotated[str, typer.Option(help="SSH jump host")] = "",
|
|
75
|
+
ssh_port: Annotated[int, typer.Option(help="SSH port")] = 22,
|
|
76
|
+
ssh_user: Annotated[str, typer.Option(help="SSH username")] = "root",
|
|
77
|
+
ssh_key: Annotated[str, typer.Option(help="SSH key path")] = "~/.ssh/id_ed25519",
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Add or update a profile."""
|
|
80
|
+
app_ctx: AppContext = ctx.obj
|
|
81
|
+
out = app_ctx.output
|
|
82
|
+
svc = ServiceConfig(
|
|
83
|
+
host=host,
|
|
84
|
+
port=port,
|
|
85
|
+
username=username,
|
|
86
|
+
password=password,
|
|
87
|
+
db=db,
|
|
88
|
+
ssh_host=ssh_host,
|
|
89
|
+
ssh_port=ssh_port,
|
|
90
|
+
ssh_user=ssh_user,
|
|
91
|
+
ssh_key=ssh_key,
|
|
92
|
+
)
|
|
93
|
+
set_service_config(profile, svc)
|
|
94
|
+
out.success(f"Profile '{profile}' saved.")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@app.command()
|
|
98
|
+
def use(
|
|
99
|
+
ctx: typer.Context,
|
|
100
|
+
profile: Annotated[str, typer.Argument(help="Profile to activate")],
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Switch the default profile."""
|
|
103
|
+
app_ctx: AppContext = ctx.obj
|
|
104
|
+
out = app_ctx.output
|
|
105
|
+
names = get_profile_names()
|
|
106
|
+
if profile not in names:
|
|
107
|
+
out.error(f"Profile '{profile}' not found. Available: {', '.join(names)}")
|
|
108
|
+
raise typer.Exit(1)
|
|
109
|
+
set_default_profile(profile)
|
|
110
|
+
out.success(f"Default profile set to '{profile}'.")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@app.command(name="remove")
|
|
114
|
+
def remove_(
|
|
115
|
+
ctx: typer.Context,
|
|
116
|
+
profile: Annotated[str, typer.Argument(help="Profile to remove")],
|
|
117
|
+
force: Annotated[bool, typer.Option("--force", "-f", help="Skip confirmation")] = False,
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Remove a profile."""
|
|
120
|
+
app_ctx: AppContext = ctx.obj
|
|
121
|
+
out = app_ctx.output
|
|
122
|
+
if not force:
|
|
123
|
+
typer.confirm(f"Remove profile '{profile}'?", abort=True)
|
|
124
|
+
remove_profile(profile)
|
|
125
|
+
out.success(f"Profile '{profile}' removed.")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@app.command()
|
|
129
|
+
def show(ctx: typer.Context) -> None:
|
|
130
|
+
"""Show current configuration (passwords masked)."""
|
|
131
|
+
app_ctx: AppContext = ctx.obj
|
|
132
|
+
out = app_ctx.output
|
|
133
|
+
try:
|
|
134
|
+
pname = resolve_active_profile_name(app_ctx.profile)
|
|
135
|
+
except KctlError:
|
|
136
|
+
out.error("No profile configured. Run: kctl-redis config init")
|
|
137
|
+
raise typer.Exit(1)
|
|
138
|
+
|
|
139
|
+
svc = get_service_config(pname)
|
|
140
|
+
masked_pass = _mask(svc.password)
|
|
141
|
+
rows = [
|
|
142
|
+
{"key": "profile", "value": pname},
|
|
143
|
+
{"key": "host", "value": svc.host or "(not set)"},
|
|
144
|
+
{"key": "port", "value": str(svc.port)},
|
|
145
|
+
{"key": "username", "value": svc.username},
|
|
146
|
+
{"key": "password", "value": masked_pass},
|
|
147
|
+
{"key": "db", "value": str(svc.db)},
|
|
148
|
+
{"key": "ssh_host", "value": svc.ssh_host or "(not set)"},
|
|
149
|
+
{"key": "ssh_port", "value": str(svc.ssh_port)},
|
|
150
|
+
{"key": "ssh_user", "value": svc.ssh_user},
|
|
151
|
+
{"key": "ssh_key", "value": svc.ssh_key},
|
|
152
|
+
]
|
|
153
|
+
out.table(rows, columns=["key", "value"], title="Redis Configuration")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@app.command(name="set")
|
|
157
|
+
def set_field(
|
|
158
|
+
ctx: typer.Context,
|
|
159
|
+
key: Annotated[str, typer.Argument(help="Config field name")],
|
|
160
|
+
value: Annotated[str, typer.Argument(help="Config field value")],
|
|
161
|
+
) -> None:
|
|
162
|
+
"""Set a configuration value."""
|
|
163
|
+
app_ctx: AppContext = ctx.obj
|
|
164
|
+
out = app_ctx.output
|
|
165
|
+
pname = resolve_active_profile_name(app_ctx.profile)
|
|
166
|
+
svc = get_service_config(pname)
|
|
167
|
+
valid_fields = set(ServiceConfig.model_fields.keys())
|
|
168
|
+
if key not in valid_fields:
|
|
169
|
+
out.error(f"Unknown field '{key}'. Valid: {', '.join(sorted(valid_fields))}")
|
|
170
|
+
raise typer.Exit(1)
|
|
171
|
+
field_info = ServiceConfig.model_fields[key]
|
|
172
|
+
if field_info.annotation is int:
|
|
173
|
+
setattr(svc, key, int(value))
|
|
174
|
+
else:
|
|
175
|
+
setattr(svc, key, value)
|
|
176
|
+
set_service_config(pname, svc)
|
|
177
|
+
out.success(f"Set {key} = {_mask(value) if 'pass' in key else value}")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@app.command()
|
|
181
|
+
def profiles(ctx: typer.Context) -> None:
|
|
182
|
+
"""List all profiles."""
|
|
183
|
+
app_ctx: AppContext = ctx.obj
|
|
184
|
+
out = app_ctx.output
|
|
185
|
+
names = get_profile_names()
|
|
186
|
+
if not names:
|
|
187
|
+
out.info("No profiles configured. Run: kctl-redis config init")
|
|
188
|
+
return
|
|
189
|
+
try:
|
|
190
|
+
active = resolve_active_profile_name(app_ctx.profile)
|
|
191
|
+
except KctlError:
|
|
192
|
+
active = ""
|
|
193
|
+
rows = []
|
|
194
|
+
for name in names:
|
|
195
|
+
svc = get_service_config(name)
|
|
196
|
+
rows.append(
|
|
197
|
+
{
|
|
198
|
+
"name": name,
|
|
199
|
+
"active": "*" if name == active else "",
|
|
200
|
+
"host": svc.host or "(not set)",
|
|
201
|
+
"ssh_host": svc.ssh_host or "(not set)",
|
|
202
|
+
}
|
|
203
|
+
)
|
|
204
|
+
out.table(rows, columns=["name", "active", "host", "ssh_host"], title="Profiles")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@app.command()
|
|
208
|
+
def current(ctx: typer.Context) -> None:
|
|
209
|
+
"""Show the active profile name."""
|
|
210
|
+
app_ctx: AppContext = ctx.obj
|
|
211
|
+
out = app_ctx.output
|
|
212
|
+
try:
|
|
213
|
+
pname = resolve_active_profile_name(app_ctx.profile)
|
|
214
|
+
out.text(pname)
|
|
215
|
+
except KctlError:
|
|
216
|
+
out.error("No profile configured.")
|
|
217
|
+
raise typer.Exit(1)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@app.command()
|
|
221
|
+
def test(ctx: typer.Context) -> None:
|
|
222
|
+
"""Test Redis connection."""
|
|
223
|
+
app_ctx: AppContext = ctx.obj
|
|
224
|
+
out = app_ctx.output
|
|
225
|
+
try:
|
|
226
|
+
client = app_ctx.client
|
|
227
|
+
version = client.server_version
|
|
228
|
+
info = client.info("server")
|
|
229
|
+
out.success(f"Connected to Redis {version}")
|
|
230
|
+
out.kv("Uptime", f"{info.get('uptime_in_days', '?')} days")
|
|
231
|
+
out.kv("Role", info.get("role", "unknown"))
|
|
232
|
+
app_ctx.close()
|
|
233
|
+
except KctlError as e:
|
|
234
|
+
out.error(f"Connection failed: {e}")
|
|
235
|
+
raise typer.Exit(1)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _mask(value: str) -> str:
|
|
239
|
+
"""Mask a password: first4****last4."""
|
|
240
|
+
if not value or len(value) <= 8:
|
|
241
|
+
return "****"
|
|
242
|
+
return f"{value[:4]}****{value[-4:]}"
|