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.
@@ -0,0 +1,3 @@
1
+ """tunectl — VPS Performance Tuning Toolkit."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,6 @@
1
+ """Allow running tunectl as a module: python -m tunectl."""
2
+
3
+ from tunectl.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
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()