tunectl 1.0.0
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.
- package/LICENSE +21 -0
- package/README.md +303 -0
- package/bin/tunectl +43 -0
- package/package.json +33 -0
- package/scripts/audit.sh +693 -0
- package/scripts/benchmark.sh +623 -0
- package/scripts/discover.sh +367 -0
- package/scripts/rollback.sh +267 -0
- package/scripts/tune.sh +1073 -0
- package/setup.py +5 -0
- package/tune-manifest.json +993 -0
- package/tunectl/__init__.py +3 -0
- package/tunectl/__main__.py +6 -0
- package/tunectl/cli.py +433 -0
package/tunectl/cli.py
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"""tunectl CLI — Python wrapper for the tunectl VPS tuning toolkit.
|
|
2
|
+
|
|
3
|
+
Provides 6 subcommands that delegate to the corresponding bash scripts:
|
|
4
|
+
discover — Detect system environment and output JSON profile
|
|
5
|
+
plan — Show dry-run tuning plan for a tier (no changes made)
|
|
6
|
+
apply — Apply tuning changes for a tier (requires root)
|
|
7
|
+
rollback — Restore system config from backup (requires root)
|
|
8
|
+
audit — Verify applied tuning against the manifest
|
|
9
|
+
benchmark — Run performance benchmarks (sysbench + fio)
|
|
10
|
+
|
|
11
|
+
Exit codes: 0=success, 1=operational failure, 2=usage error.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
from tunectl import __version__
|
|
21
|
+
|
|
22
|
+
# Valid tier choices for plan/apply
|
|
23
|
+
VALID_TIERS = ("conservative", "balanced", "aggressive")
|
|
24
|
+
|
|
25
|
+
# Resolve the scripts/ directory — supports both dev mode (repo checkout)
|
|
26
|
+
# and installed mode (pip install bundles scripts as package_data).
|
|
27
|
+
_PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
28
|
+
_PROJECT_ROOT = os.path.dirname(_PACKAGE_DIR)
|
|
29
|
+
|
|
30
|
+
# Installed mode: scripts live inside the package at tunectl/scripts/
|
|
31
|
+
_INSTALLED_SCRIPTS_DIR = os.path.join(_PACKAGE_DIR, "scripts")
|
|
32
|
+
# Dev mode: scripts live at the project root at scripts/
|
|
33
|
+
_DEV_SCRIPTS_DIR = os.path.join(_PROJECT_ROOT, "scripts")
|
|
34
|
+
|
|
35
|
+
# Pick whichever directory actually contains the scripts
|
|
36
|
+
_SCRIPTS_DIR = (
|
|
37
|
+
_INSTALLED_SCRIPTS_DIR
|
|
38
|
+
if os.path.isdir(_INSTALLED_SCRIPTS_DIR)
|
|
39
|
+
else _DEV_SCRIPTS_DIR
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _script_path(name: str) -> str:
|
|
44
|
+
"""Return the absolute path to a script in the scripts/ directory."""
|
|
45
|
+
return os.path.join(_SCRIPTS_DIR, name)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _run_script(script_name: str, args: list[str] | None = None,
|
|
49
|
+
capture: bool = False) -> int | tuple[int, str, str]:
|
|
50
|
+
"""Run a bash script and return its exit code.
|
|
51
|
+
|
|
52
|
+
When *capture* is False (default), stdout/stderr pass through to the
|
|
53
|
+
caller and only the exit code is returned.
|
|
54
|
+
|
|
55
|
+
When *capture* is True, stdout and stderr are captured and the return
|
|
56
|
+
value is a tuple ``(exit_code, stdout, stderr)``.
|
|
57
|
+
"""
|
|
58
|
+
script = _script_path(script_name)
|
|
59
|
+
if not os.path.isfile(script):
|
|
60
|
+
print(f"Error: Script not found: {script}", file=sys.stderr)
|
|
61
|
+
return (1, "", "") if capture else 1
|
|
62
|
+
|
|
63
|
+
cmd = ["bash", script]
|
|
64
|
+
if args:
|
|
65
|
+
cmd.extend(args)
|
|
66
|
+
|
|
67
|
+
if capture:
|
|
68
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
69
|
+
return result.returncode, result.stdout, result.stderr
|
|
70
|
+
|
|
71
|
+
result = subprocess.run(cmd)
|
|
72
|
+
return result.returncode
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# -------------------------------------------------------
|
|
76
|
+
# ANSI color helpers
|
|
77
|
+
# -------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
def _use_color() -> bool:
|
|
80
|
+
"""Return True when stdout is a TTY and colors should be used."""
|
|
81
|
+
return sys.stdout.isatty()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _c(code: str, text: str) -> str:
|
|
85
|
+
"""Wrap *text* in an ANSI color escape if colors are enabled."""
|
|
86
|
+
if not _use_color():
|
|
87
|
+
return text
|
|
88
|
+
return f"\033[{code}m{text}\033[0m"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _bold(text: str) -> str:
|
|
92
|
+
return _c("1", text)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _cyan(text: str) -> str:
|
|
96
|
+
return _c("36", text)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _green(text: str) -> str:
|
|
100
|
+
return _c("32", text)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _yellow(text: str) -> str:
|
|
104
|
+
return _c("33", text)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _dim(text: str) -> str:
|
|
108
|
+
return _c("2", text)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# -------------------------------------------------------
|
|
112
|
+
# Formatted discover output
|
|
113
|
+
# -------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def _format_discover_output(data: dict) -> str:
|
|
116
|
+
"""Render the discover JSON as a human-readable summary."""
|
|
117
|
+
lines: list[str] = []
|
|
118
|
+
|
|
119
|
+
# --- System Info header ---
|
|
120
|
+
lines.append(_bold("System Information"))
|
|
121
|
+
lines.append(_dim("─" * 40))
|
|
122
|
+
|
|
123
|
+
info_fields = [
|
|
124
|
+
("OS", data.get("os_version", "unknown")),
|
|
125
|
+
("Kernel", data.get("kernel_version", "unknown")),
|
|
126
|
+
("RAM", f"{data.get('ram_mb', 0)} MB"),
|
|
127
|
+
("CPUs", str(data.get("cpu_count", 0))),
|
|
128
|
+
("Disk type", data.get("disk_type", "unknown")),
|
|
129
|
+
("Virt type", data.get("virt_type", "unknown")),
|
|
130
|
+
]
|
|
131
|
+
for label, value in info_fields:
|
|
132
|
+
lines.append(f" {_cyan(label + ':'):>30s} {value}")
|
|
133
|
+
|
|
134
|
+
# --- Swap status ---
|
|
135
|
+
lines.append("")
|
|
136
|
+
lines.append(_bold("Swap Status"))
|
|
137
|
+
lines.append(_dim("─" * 40))
|
|
138
|
+
|
|
139
|
+
swap_configured = data.get("swap_configured", False)
|
|
140
|
+
swap_type = data.get("swap_type", "none")
|
|
141
|
+
swap_total = data.get("swap_total_mb", 0)
|
|
142
|
+
|
|
143
|
+
if swap_configured:
|
|
144
|
+
lines.append(f" {_cyan('Configured:'):>30s} {_green('yes')}")
|
|
145
|
+
lines.append(f" {_cyan('Type:'):>30s} {swap_type}")
|
|
146
|
+
lines.append(f" {_cyan('Total:'):>30s} {swap_total} MB")
|
|
147
|
+
else:
|
|
148
|
+
lines.append(f" {_cyan('Configured:'):>30s} {_yellow('no')}")
|
|
149
|
+
|
|
150
|
+
# --- Key sysctl values ---
|
|
151
|
+
sysctl = data.get("sysctl_values", {})
|
|
152
|
+
if sysctl:
|
|
153
|
+
lines.append("")
|
|
154
|
+
lines.append(_bold("Key sysctl Values"))
|
|
155
|
+
lines.append(_dim("─" * 40))
|
|
156
|
+
|
|
157
|
+
# Show a curated selection of the most important params
|
|
158
|
+
key_params = [
|
|
159
|
+
"vm.swappiness",
|
|
160
|
+
"vm.dirty_ratio",
|
|
161
|
+
"vm.dirty_background_ratio",
|
|
162
|
+
"vm.vfs_cache_pressure",
|
|
163
|
+
"vm.min_free_kbytes",
|
|
164
|
+
"vm.overcommit_memory",
|
|
165
|
+
"vm.max_map_count",
|
|
166
|
+
"net.core.rmem_max",
|
|
167
|
+
"net.core.wmem_max",
|
|
168
|
+
"net.core.somaxconn",
|
|
169
|
+
"net.core.default_qdisc",
|
|
170
|
+
"net.ipv4.tcp_congestion_control",
|
|
171
|
+
"net.ipv4.ip_local_port_range",
|
|
172
|
+
"fs.inotify.max_user_watches",
|
|
173
|
+
]
|
|
174
|
+
for param in key_params:
|
|
175
|
+
if param in sysctl:
|
|
176
|
+
lines.append(f" {_cyan(param + ':'):>48s} {sysctl[param]}")
|
|
177
|
+
|
|
178
|
+
# --- Mount options ---
|
|
179
|
+
mount_opts = data.get("mount_options", "")
|
|
180
|
+
if mount_opts and mount_opts != "unknown":
|
|
181
|
+
lines.append("")
|
|
182
|
+
lines.append(_bold("Root Mount Options"))
|
|
183
|
+
lines.append(_dim("─" * 40))
|
|
184
|
+
lines.append(f" {mount_opts}")
|
|
185
|
+
|
|
186
|
+
return "\n".join(lines)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# -------------------------------------------------------
|
|
190
|
+
# Subcommand handlers
|
|
191
|
+
# -------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
def cmd_discover(args: argparse.Namespace) -> int:
|
|
194
|
+
"""Run discover.sh and show formatted system info."""
|
|
195
|
+
rc, stdout, stderr = _run_script("discover.sh", capture=True)
|
|
196
|
+
|
|
197
|
+
if rc != 0:
|
|
198
|
+
# Pass through errors from the script
|
|
199
|
+
if stderr:
|
|
200
|
+
print(stderr, end="", file=sys.stderr)
|
|
201
|
+
if stdout:
|
|
202
|
+
print(stdout, end="")
|
|
203
|
+
return rc
|
|
204
|
+
|
|
205
|
+
# --json: emit raw JSON
|
|
206
|
+
if getattr(args, "json", False):
|
|
207
|
+
print(stdout, end="")
|
|
208
|
+
return 0
|
|
209
|
+
|
|
210
|
+
# Parse JSON and render formatted summary
|
|
211
|
+
try:
|
|
212
|
+
data = json.loads(stdout)
|
|
213
|
+
except json.JSONDecodeError as exc:
|
|
214
|
+
print(f"Error: Failed to parse discover output: {exc}",
|
|
215
|
+
file=sys.stderr)
|
|
216
|
+
# Fall back to raw output so the user still sees something
|
|
217
|
+
print(stdout, end="")
|
|
218
|
+
return 1
|
|
219
|
+
|
|
220
|
+
print(_format_discover_output(data))
|
|
221
|
+
return 0
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def cmd_plan(args: argparse.Namespace) -> int:
|
|
225
|
+
"""Run tune.sh in dry-run mode for the specified tier."""
|
|
226
|
+
return _run_script("tune.sh", ["--dry-run", "--tier", args.tier])
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def cmd_apply(args: argparse.Namespace) -> int:
|
|
230
|
+
"""Run tune.sh in apply mode for the specified tier."""
|
|
231
|
+
return _run_script("tune.sh", ["--apply", "--tier", args.tier])
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def cmd_rollback(args: argparse.Namespace) -> int:
|
|
235
|
+
"""Run rollback.sh to restore from backup."""
|
|
236
|
+
script_args: list[str] = []
|
|
237
|
+
if args.list:
|
|
238
|
+
script_args.append("--list")
|
|
239
|
+
elif args.backup:
|
|
240
|
+
script_args.extend(["--backup", args.backup])
|
|
241
|
+
return _run_script("rollback.sh", script_args)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def cmd_audit(args: argparse.Namespace) -> int:
|
|
245
|
+
"""Run audit.sh to verify applied tuning."""
|
|
246
|
+
return _run_script("audit.sh")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def cmd_benchmark(args: argparse.Namespace) -> int:
|
|
250
|
+
"""Run benchmark.sh for performance measurement."""
|
|
251
|
+
script_args: list[str] = []
|
|
252
|
+
if args.baseline:
|
|
253
|
+
script_args.append("--baseline")
|
|
254
|
+
elif args.compare:
|
|
255
|
+
script_args.append("--compare")
|
|
256
|
+
return _run_script("benchmark.sh", script_args)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# -------------------------------------------------------
|
|
260
|
+
# Tier validation helper
|
|
261
|
+
# -------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
def _validate_tier(value: str) -> str:
|
|
264
|
+
"""Validate the --tier argument against allowed values."""
|
|
265
|
+
if value not in VALID_TIERS:
|
|
266
|
+
raise argparse.ArgumentTypeError(
|
|
267
|
+
f"invalid tier '{value}'. Valid tiers: {', '.join(VALID_TIERS)}"
|
|
268
|
+
)
|
|
269
|
+
return value
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# -------------------------------------------------------
|
|
273
|
+
# Argument parser construction
|
|
274
|
+
# -------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
277
|
+
"""Build and return the argument parser with all subcommands."""
|
|
278
|
+
parser = argparse.ArgumentParser(
|
|
279
|
+
prog="tunectl",
|
|
280
|
+
description="tunectl — VPS Performance Tuning Toolkit",
|
|
281
|
+
epilog=(
|
|
282
|
+
"Workflow: discover → plan → apply → audit → benchmark\n"
|
|
283
|
+
"Run 'tunectl <command> --help' for command-specific options."
|
|
284
|
+
),
|
|
285
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
286
|
+
)
|
|
287
|
+
parser.add_argument(
|
|
288
|
+
"--version",
|
|
289
|
+
action="version",
|
|
290
|
+
version=f"tunectl {__version__}",
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
subparsers = parser.add_subparsers(
|
|
294
|
+
title="commands",
|
|
295
|
+
dest="command",
|
|
296
|
+
metavar="<command>",
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
# --- discover ---
|
|
300
|
+
sub_discover = subparsers.add_parser(
|
|
301
|
+
"discover",
|
|
302
|
+
help="Detect system environment and show formatted summary",
|
|
303
|
+
description=(
|
|
304
|
+
"Run environment discovery. Detects OS, kernel, RAM, CPU, disk type,\n"
|
|
305
|
+
"swap configuration, and current sysctl values. Shows a formatted,\n"
|
|
306
|
+
"colored summary by default. Use --json for machine-readable output.\n"
|
|
307
|
+
"Read-only — no system modifications. Works without root."
|
|
308
|
+
),
|
|
309
|
+
)
|
|
310
|
+
sub_discover.add_argument(
|
|
311
|
+
"--json",
|
|
312
|
+
action="store_true",
|
|
313
|
+
default=False,
|
|
314
|
+
help="Output raw JSON instead of formatted summary",
|
|
315
|
+
)
|
|
316
|
+
sub_discover.set_defaults(func=cmd_discover)
|
|
317
|
+
|
|
318
|
+
# --- plan ---
|
|
319
|
+
sub_plan = subparsers.add_parser(
|
|
320
|
+
"plan",
|
|
321
|
+
help="Show dry-run tuning plan for a tier (no changes made)",
|
|
322
|
+
description=(
|
|
323
|
+
"Display a read-only plan of all tuning changes for the selected tier.\n"
|
|
324
|
+
"No system files are modified. Works without root."
|
|
325
|
+
),
|
|
326
|
+
)
|
|
327
|
+
sub_plan.add_argument(
|
|
328
|
+
"--tier",
|
|
329
|
+
type=_validate_tier,
|
|
330
|
+
required=True,
|
|
331
|
+
metavar="TIER",
|
|
332
|
+
help="Tuning tier: conservative, balanced, or aggressive",
|
|
333
|
+
)
|
|
334
|
+
sub_plan.set_defaults(func=cmd_plan)
|
|
335
|
+
|
|
336
|
+
# --- apply ---
|
|
337
|
+
sub_apply = subparsers.add_parser(
|
|
338
|
+
"apply",
|
|
339
|
+
help="Apply tuning changes for a tier (requires root)",
|
|
340
|
+
description=(
|
|
341
|
+
"Apply tuning changes for the selected tier. Creates a timestamped\n"
|
|
342
|
+
"backup before making any modifications. Requires root privileges."
|
|
343
|
+
),
|
|
344
|
+
)
|
|
345
|
+
sub_apply.add_argument(
|
|
346
|
+
"--tier",
|
|
347
|
+
type=_validate_tier,
|
|
348
|
+
required=True,
|
|
349
|
+
metavar="TIER",
|
|
350
|
+
help="Tuning tier: conservative, balanced, or aggressive",
|
|
351
|
+
)
|
|
352
|
+
sub_apply.set_defaults(func=cmd_apply)
|
|
353
|
+
|
|
354
|
+
# --- rollback ---
|
|
355
|
+
sub_rollback = subparsers.add_parser(
|
|
356
|
+
"rollback",
|
|
357
|
+
help="Restore system config from backup (requires root)",
|
|
358
|
+
description=(
|
|
359
|
+
"Restore system configuration from a previous backup created by\n"
|
|
360
|
+
"'tunectl apply'. Uses the most recent backup by default.\n"
|
|
361
|
+
"Requires root for restore. Use --list to view backups without root."
|
|
362
|
+
),
|
|
363
|
+
)
|
|
364
|
+
rollback_group = sub_rollback.add_mutually_exclusive_group()
|
|
365
|
+
rollback_group.add_argument(
|
|
366
|
+
"--list",
|
|
367
|
+
action="store_true",
|
|
368
|
+
help="List available backups (does not require root)",
|
|
369
|
+
)
|
|
370
|
+
rollback_group.add_argument(
|
|
371
|
+
"--backup",
|
|
372
|
+
metavar="TIMESTAMP",
|
|
373
|
+
help="Restore from a specific backup timestamp",
|
|
374
|
+
)
|
|
375
|
+
sub_rollback.set_defaults(func=cmd_rollback)
|
|
376
|
+
|
|
377
|
+
# --- audit ---
|
|
378
|
+
sub_audit = subparsers.add_parser(
|
|
379
|
+
"audit",
|
|
380
|
+
help="Verify applied tuning against the manifest",
|
|
381
|
+
description=(
|
|
382
|
+
"Run verification checks for all 86 manifest entries against live\n"
|
|
383
|
+
"system state. Reports PASS/FAIL/SKIP per entry with a summary.\n"
|
|
384
|
+
"Read-only — no system modifications. Works without root."
|
|
385
|
+
),
|
|
386
|
+
)
|
|
387
|
+
sub_audit.set_defaults(func=cmd_audit)
|
|
388
|
+
|
|
389
|
+
# --- benchmark ---
|
|
390
|
+
sub_benchmark = subparsers.add_parser(
|
|
391
|
+
"benchmark",
|
|
392
|
+
help="Run performance benchmarks (sysbench + fio)",
|
|
393
|
+
description=(
|
|
394
|
+
"Run CPU, memory, and disk I/O benchmarks using sysbench and fio.\n"
|
|
395
|
+
"Supports baseline capture and before/after comparison.\n"
|
|
396
|
+
"Works without root."
|
|
397
|
+
),
|
|
398
|
+
)
|
|
399
|
+
bench_group = sub_benchmark.add_mutually_exclusive_group()
|
|
400
|
+
bench_group.add_argument(
|
|
401
|
+
"--baseline",
|
|
402
|
+
action="store_true",
|
|
403
|
+
help="Save results as baseline for later comparison",
|
|
404
|
+
)
|
|
405
|
+
bench_group.add_argument(
|
|
406
|
+
"--compare",
|
|
407
|
+
action="store_true",
|
|
408
|
+
help="Compare current results against saved baseline",
|
|
409
|
+
)
|
|
410
|
+
sub_benchmark.set_defaults(func=cmd_benchmark)
|
|
411
|
+
|
|
412
|
+
return parser
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# -------------------------------------------------------
|
|
416
|
+
# Entry point
|
|
417
|
+
# -------------------------------------------------------
|
|
418
|
+
|
|
419
|
+
def main() -> None:
|
|
420
|
+
"""Main entry point for the tunectl CLI."""
|
|
421
|
+
parser = build_parser()
|
|
422
|
+
args = parser.parse_args()
|
|
423
|
+
|
|
424
|
+
if args.command is None:
|
|
425
|
+
parser.print_help()
|
|
426
|
+
sys.exit(2)
|
|
427
|
+
|
|
428
|
+
exit_code = args.func(args)
|
|
429
|
+
sys.exit(exit_code)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
if __name__ == "__main__":
|
|
433
|
+
main()
|