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,1073 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # tune.sh — Main tuning engine for tunectl
5
+ # Reads tune-manifest.json, applies tuning based on tier selection.
6
+ #
7
+ # Usage:
8
+ # tune.sh --tier <conservative|balanced|aggressive> [--dry-run|--apply]
9
+ #
10
+ # Exit codes: 0=success, 1=failure, 2=usage error
11
+
12
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
13
+ MANIFEST="${SCRIPT_DIR}/../tune-manifest.json"
14
+ BACKUP_ROOT="/var/lib/tunectl/backups"
15
+
16
+ # -------------------------------------------------------
17
+ # Usage / help
18
+ # -------------------------------------------------------
19
+ usage() {
20
+ cat >&2 <<EOF
21
+ Usage: tune.sh --tier <tier> [--dry-run|--apply]
22
+
23
+ Options:
24
+ --tier <tier> Required. One of: conservative, balanced, aggressive
25
+ --dry-run Show plan without making changes (default)
26
+ --apply Apply tuning changes (requires root)
27
+ --help Show this help message
28
+
29
+ Tiers:
30
+ conservative risk=none entries only (51 entries)
31
+ balanced risk=none + risk=low entries (80 entries)
32
+ aggressive all entries including risk=med (86 entries)
33
+
34
+ Exit codes:
35
+ 0 Success
36
+ 1 Operational failure
37
+ 2 Usage error
38
+ EOF
39
+ exit 2
40
+ }
41
+
42
+ # -------------------------------------------------------
43
+ # Globals
44
+ # -------------------------------------------------------
45
+ TIER=""
46
+ MODE="dry-run"
47
+
48
+ # -------------------------------------------------------
49
+ # Parse arguments
50
+ # -------------------------------------------------------
51
+ parse_args() {
52
+ while [[ $# -gt 0 ]]; do
53
+ case "$1" in
54
+ --tier)
55
+ [[ $# -lt 2 ]] && { echo "Error: --tier requires a value" >&2; usage; }
56
+ TIER="$2"
57
+ shift 2
58
+ ;;
59
+ --dry-run)
60
+ MODE="dry-run"
61
+ shift
62
+ ;;
63
+ --apply)
64
+ MODE="apply"
65
+ shift
66
+ ;;
67
+ --help|-h)
68
+ usage
69
+ ;;
70
+ *)
71
+ echo "Error: Unknown argument: $1" >&2
72
+ usage
73
+ ;;
74
+ esac
75
+ done
76
+
77
+ # Require --tier
78
+ if [[ -z "$TIER" ]]; then
79
+ echo "Error: --tier is required" >&2
80
+ usage
81
+ fi
82
+
83
+ # Validate tier
84
+ case "$TIER" in
85
+ conservative|balanced|aggressive) ;;
86
+ *)
87
+ echo "Error: Invalid tier '$TIER'. Valid tiers: conservative, balanced, aggressive" >&2
88
+ exit 2
89
+ ;;
90
+ esac
91
+
92
+ # Apply mode requires root
93
+ if [[ "$MODE" == "apply" && $EUID -ne 0 ]]; then
94
+ echo "Error: --apply requires root privileges. Run with sudo." >&2
95
+ exit 1
96
+ fi
97
+ }
98
+
99
+ # -------------------------------------------------------
100
+ # Validate manifest
101
+ # -------------------------------------------------------
102
+ validate_manifest() {
103
+ if [[ ! -f "$MANIFEST" ]]; then
104
+ echo "Error: Manifest not found at $MANIFEST" >&2
105
+ exit 1
106
+ fi
107
+
108
+ if ! jq empty "$MANIFEST" 2>/dev/null; then
109
+ echo "Error: Manifest is not valid JSON: $MANIFEST" >&2
110
+ exit 1
111
+ fi
112
+ }
113
+
114
+ # -------------------------------------------------------
115
+ # Detect system RAM in KB
116
+ # -------------------------------------------------------
117
+ detect_ram_kb() {
118
+ awk '/^MemTotal:/ {print $2}' /proc/meminfo 2>/dev/null || echo "0"
119
+ }
120
+
121
+ # -------------------------------------------------------
122
+ # Get tier jq filter expression
123
+ # -------------------------------------------------------
124
+ get_tier_filter() {
125
+ case "$TIER" in
126
+ conservative) echo 'select(.risk == "none")' ;;
127
+ balanced) echo 'select(.risk == "none" or .risk == "low")' ;;
128
+ aggressive) echo '.' ;;
129
+ esac
130
+ }
131
+
132
+ # -------------------------------------------------------
133
+ # Round up to the nearest power of 2 (for memory sizes)
134
+ # -------------------------------------------------------
135
+ round_to_power_of_2() {
136
+ local val="$1"
137
+ awk "BEGIN {
138
+ v = $val
139
+ if (v <= 0) { print 1; exit }
140
+ p = 1
141
+ while (p < v) p *= 2
142
+ # Pick the nearest power of 2 (up or down)
143
+ lower = p / 2
144
+ if ((v - lower) <= (p - v)) print lower
145
+ else print p
146
+ }"
147
+ }
148
+
149
+ # -------------------------------------------------------
150
+ # Scale a numeric value proportionally based on RAM
151
+ # Reference: 8GB
152
+ # Memory sizes are rounded to the nearest power of 2.
153
+ # -------------------------------------------------------
154
+ scale_value() {
155
+ local entry_id="$1"
156
+ local raw_value="$2"
157
+ local ram_kb="$3"
158
+ local ram_gb
159
+ ram_gb=$(awk "BEGIN {printf \"%.2f\", $ram_kb / 1048576}")
160
+
161
+ local ref_ram_gb=8
162
+
163
+ case "$entry_id" in
164
+ MEM-006)
165
+ local scaled
166
+ scaled=$(awk "BEGIN {v = 131072 * ($ram_gb / $ref_ram_gb); printf \"%.0f\", v}")
167
+ scaled=$(round_to_power_of_2 "$scaled")
168
+ [[ $scaled -lt 65536 ]] && scaled=65536
169
+ echo "$scaled"
170
+ ;;
171
+ SWAP-007)
172
+ local size_gb
173
+ size_gb=$(awk "BEGIN {v = $ram_gb * 1.5; printf \"%.0f\", v}")
174
+ [[ $size_gb -lt 1 ]] && size_gb=1
175
+ echo "${size_gb}G"
176
+ ;;
177
+ FS-003)
178
+ local size_gb
179
+ size_gb=$(awk "BEGIN {v = $ram_gb * 0.5; printf \"%.0f\", v}")
180
+ [[ $size_gb -lt 1 ]] && size_gb=1
181
+ echo "tmpfs /tmp tmpfs defaults,noatime,size=${size_gb}G 0 0"
182
+ ;;
183
+ FS-006)
184
+ local scaled
185
+ scaled=$(awk "BEGIN {v = 524288 * ($ram_gb / $ref_ram_gb); printf \"%.0f\", v}")
186
+ scaled=$(round_to_power_of_2 "$scaled")
187
+ [[ $scaled -lt 65536 ]] && scaled=65536
188
+ echo "$scaled"
189
+ ;;
190
+ CGRP-002)
191
+ local size_gb
192
+ size_gb=$(awk "BEGIN {v = $ram_gb * 0.6; printf \"%.0f\", v}")
193
+ [[ $size_gb -lt 1 ]] && size_gb=1
194
+ echo "${size_gb}G"
195
+ ;;
196
+ CGRP-008)
197
+ local size_gb
198
+ size_gb=$(awk "BEGIN {v = $ram_gb * 0.35; printf \"%.0f\", v}")
199
+ [[ $size_gb -lt 1 ]] && size_gb=1
200
+ echo "${size_gb}G"
201
+ ;;
202
+ *)
203
+ echo "$raw_value"
204
+ ;;
205
+ esac
206
+ }
207
+
208
+ # -------------------------------------------------------
209
+ # Check if jemalloc library exists
210
+ # -------------------------------------------------------
211
+ check_jemalloc() {
212
+ [[ -f "/usr/lib/x86_64-linux-gnu/libjemalloc.so.2" ]] && return 0
213
+ ldconfig -p 2>/dev/null | grep -q libjemalloc && return 0
214
+ return 1
215
+ }
216
+
217
+ # -------------------------------------------------------
218
+ # Check if a systemd service unit file exists
219
+ # -------------------------------------------------------
220
+ service_exists() {
221
+ local svc="$1"
222
+ systemctl list-unit-files "$svc" 2>/dev/null | grep -q "$svc"
223
+ }
224
+
225
+ # -------------------------------------------------------
226
+ # Get current value for a sysctl parameter
227
+ # -------------------------------------------------------
228
+ get_sysctl_value() {
229
+ sysctl -n "$1" 2>/dev/null || echo "not set"
230
+ }
231
+
232
+ # -------------------------------------------------------
233
+ # DRY-RUN: Display the tuning plan
234
+ # -------------------------------------------------------
235
+ do_dry_run() {
236
+ local ram_kb
237
+ ram_kb=$(detect_ram_kb)
238
+ local ram_mb=$((ram_kb / 1024))
239
+ local ram_gb
240
+ ram_gb=$(awk "BEGIN {printf \"%.1f\", $ram_kb / 1048576}")
241
+
242
+ local filter
243
+ filter=$(get_tier_filter)
244
+
245
+ echo "============================================"
246
+ echo " tunectl tune plan — Tier: $TIER (DRY RUN)"
247
+ echo "============================================"
248
+ echo ""
249
+ echo "System RAM: ${ram_mb} MB (${ram_gb} GB)"
250
+ echo ""
251
+
252
+ # Extract all filtered entries as tab-separated lines in a SINGLE jq call
253
+ local tsv_data
254
+ tsv_data=$(jq -r "[.tuning_entries[] | $filter] | .[] | [.id, .category, .parameter, .config_file, .before_value, .after_value, .risk, (.requires_reboot | tostring), (.scaling_note // \"\")] | @tsv" "$MANIFEST")
255
+
256
+ local count
257
+ count=$(echo "$tsv_data" | wc -l)
258
+
259
+ echo "Entries to apply: $count"
260
+ echo ""
261
+
262
+ # Category summary from the same data
263
+ echo "--- Category Summary ---"
264
+ echo "$tsv_data" | awk -F'\t' '{print $2}' | sort | uniq -c | sort -rn | while read -r cnt cat; do
265
+ echo " $cat: $cnt"
266
+ done
267
+ echo ""
268
+
269
+ # Reboot required count
270
+ local reboot_count
271
+ reboot_count=$(echo "$tsv_data" | awk -F'\t' '$8 == "true"' | wc -l)
272
+ if [[ $reboot_count -gt 0 ]]; then
273
+ echo "⚠ $reboot_count entries require reboot"
274
+ echo ""
275
+ fi
276
+
277
+ echo "--- Tuning Plan ---"
278
+ printf "%-12s %-42s %-40s %-20s → %-20s %s\n" "ID" "Parameter" "Config File" "Current" "Target" "Notes"
279
+ printf '%0.s-' {1..160}
280
+ echo ""
281
+
282
+ # Pre-fetch all sysctl values in one batch for speed
283
+ declare -A sysctl_cache
284
+ local sysctl_params
285
+ sysctl_params=$(echo "$tsv_data" | awk -F'\t' '$4 == "/etc/sysctl.d/99-performance.conf" {print $3}')
286
+ if [[ -n "$sysctl_params" ]]; then
287
+ while IFS= read -r param; do
288
+ sysctl_cache["$param"]=$(sysctl -n "$param" 2>/dev/null || echo "not set")
289
+ done <<< "$sysctl_params"
290
+ fi
291
+
292
+ # Pre-check which services exist (batch)
293
+ declare -A svc_exists_cache
294
+ local svc_names
295
+ svc_names=$(echo "$tsv_data" | awk -F'\t' '$4 == "systemctl disable/mask" {print $3}')
296
+ if [[ -n "$svc_names" ]]; then
297
+ # Get all unit files in one call (include both services and sockets)
298
+ local all_units
299
+ all_units=$( (systemctl list-unit-files --type=service --no-legend 2>/dev/null; systemctl list-unit-files --type=socket --no-legend 2>/dev/null) | awk '{print $1}' || echo "")
300
+ while IFS= read -r svc; do
301
+ [[ -z "$svc" ]] && continue
302
+ if echo "$all_units" | grep -qF "$svc"; then
303
+ svc_exists_cache["$svc"]="yes"
304
+ else
305
+ svc_exists_cache["$svc"]="no"
306
+ fi
307
+ done <<< "$svc_names"
308
+ fi
309
+
310
+ # Check jemalloc once
311
+ local has_jemalloc=false
312
+ check_jemalloc && has_jemalloc=true
313
+
314
+ # Iterate entries
315
+ while IFS=$'\t' read -r id category parameter config_file before_value after_value risk requires_reboot scaling_note; do
316
+ # Compute target value (with RAM scaling)
317
+ local target_value
318
+ target_value=$(scale_value "$id" "$after_value" "$ram_kb")
319
+
320
+ # Get current value
321
+ local current_value=""
322
+ case "$config_file" in
323
+ /etc/sysctl.d/99-performance.conf)
324
+ current_value="${sysctl_cache[$parameter]:-not set}"
325
+ ;;
326
+ "systemctl disable/mask")
327
+ if [[ "${svc_exists_cache[$parameter]:-no}" == "yes" ]]; then
328
+ current_value=$(systemctl is-enabled "$parameter" 2>/dev/null || echo "unknown")
329
+ else
330
+ current_value="not installed"
331
+ fi
332
+ ;;
333
+ "systemctl")
334
+ current_value="active"
335
+ ;;
336
+ /etc/environment)
337
+ case "$id" in
338
+ MEM-019) current_value=$(grep "^LD_PRELOAD=" /etc/environment 2>/dev/null | cut -d= -f2- || echo "not set") ;;
339
+ BUILD-001) current_value=$(grep "^RUSTC_WRAPPER=" /etc/environment 2>/dev/null | cut -d= -f2- || echo "not set") ;;
340
+ *) current_value="not set" ;;
341
+ esac
342
+ [[ -z "$current_value" ]] && current_value="not set"
343
+ ;;
344
+ /etc/default/grub)
345
+ # Extract specific boot parameter from GRUB_CMDLINE_LINUX_DEFAULT
346
+ current_value="not set"
347
+ if [[ -f "$config_file" ]]; then
348
+ local grub_cmdline
349
+ grub_cmdline=$(grep '^GRUB_CMDLINE_LINUX_DEFAULT=' "$config_file" 2>/dev/null | sed 's/^GRUB_CMDLINE_LINUX_DEFAULT=//' | tr -d '"' || echo "")
350
+ local grub_param_name=""
351
+ case "$id" in
352
+ SWAP-009) grub_param_name="zswap.enabled" ;;
353
+ BOOT-001) grub_param_name="mitigations" ;;
354
+ BOOT-002) grub_param_name="l1tf" ;;
355
+ BOOT-003) grub_param_name="tsx_async_abort" ;;
356
+ BOOT-004) grub_param_name="preempt" ;;
357
+ BOOT-005) grub_param_name="transparent_hugepage" ;;
358
+ esac
359
+ if [[ -n "$grub_param_name" && -n "$grub_cmdline" ]]; then
360
+ local grub_val
361
+ grub_val=$(echo "$grub_cmdline" | grep -oE "${grub_param_name}=[^ ]+" | head -1 | cut -d= -f2-)
362
+ if [[ -n "$grub_val" ]]; then
363
+ current_value="$grub_val"
364
+ fi
365
+ fi
366
+ fi
367
+ ;;
368
+ /etc/fstab)
369
+ # Extract fstab entry details
370
+ current_value="not configured"
371
+ if [[ -f "$config_file" ]]; then
372
+ case "$id" in
373
+ SWAP-005)
374
+ if grep -q '/dev/zram0' "$config_file" 2>/dev/null; then
375
+ current_value=$(grep '/dev/zram0' "$config_file" | head -1 | sed 's/\s\+/ /g')
376
+ fi
377
+ ;;
378
+ FS-001)
379
+ local root_opts
380
+ root_opts=$(awk '$2=="/" {print $4}' "$config_file" 2>/dev/null)
381
+ if [[ -n "$root_opts" ]]; then
382
+ if echo "$root_opts" | grep -q 'noatime'; then
383
+ current_value="noatime"
384
+ elif echo "$root_opts" | grep -q 'relatime'; then
385
+ current_value="relatime"
386
+ else
387
+ current_value="defaults"
388
+ fi
389
+ fi
390
+ ;;
391
+ FS-002)
392
+ local root_opts
393
+ root_opts=$(awk '$2=="/" {print $4}' "$config_file" 2>/dev/null)
394
+ if [[ -n "$root_opts" ]]; then
395
+ local commit_val
396
+ commit_val=$(echo "$root_opts" | grep -oE 'commit=[0-9]+' | cut -d= -f2)
397
+ if [[ -n "$commit_val" ]]; then
398
+ current_value="${commit_val} (seconds)"
399
+ else
400
+ current_value="5 (default)"
401
+ fi
402
+ fi
403
+ ;;
404
+ FS-003)
405
+ if grep -q 'tmpfs.*\/tmp' "$config_file" 2>/dev/null; then
406
+ current_value=$(grep 'tmpfs.*\/tmp' "$config_file" | head -1 | sed 's/\s\+/ /g')
407
+ fi
408
+ ;;
409
+ esac
410
+ fi
411
+ ;;
412
+ /etc/tmpfiles.d/thp.conf|/etc/tmpfiles.d/ksm.conf)
413
+ # Read live values from /sys/kernel/mm/
414
+ current_value="not set"
415
+ case "$id" in
416
+ MEM-012) current_value=$(cat /sys/kernel/mm/transparent_hugepage/enabled 2>/dev/null | grep -oE '\[([a-z]+)\]' | tr -d '[]' || echo "not set") ;;
417
+ MEM-013) current_value=$(cat /sys/kernel/mm/transparent_hugepage/defrag 2>/dev/null | grep -oE '\[([a-z+]+)\]' | tr -d '[]' || echo "not set") ;;
418
+ MEM-014) current_value=$(cat /sys/kernel/mm/transparent_hugepage/khugepaged/scan_sleep_millisecs 2>/dev/null || echo "not set") ;;
419
+ MEM-015) current_value=$(cat /sys/kernel/mm/ksm/run 2>/dev/null || echo "not set") ;;
420
+ MEM-016) current_value=$(cat /sys/kernel/mm/ksm/pages_to_scan 2>/dev/null || echo "not set") ;;
421
+ MEM-017) current_value=$(cat /sys/kernel/mm/ksm/sleep_millisecs 2>/dev/null || echo "not set") ;;
422
+ MEM-018) current_value=$(cat /sys/kernel/mm/ksm/use_zero_pages 2>/dev/null || echo "not set") ;;
423
+ esac
424
+ ;;
425
+ /etc/systemd/system/*.slice)
426
+ # Read from systemctl show or parse unit file
427
+ current_value="not present"
428
+ local slice_name
429
+ slice_name=$(basename "$config_file")
430
+ local prop_name=""
431
+ case "$parameter" in
432
+ *CPUWeight*) prop_name="CPUWeight" ;;
433
+ *MemoryHigh*) prop_name="MemoryHigh" ;;
434
+ *IOWeight*) prop_name="IOWeight" ;;
435
+ *ManagedOOMSwap) prop_name="ManagedOOMSwap" ;;
436
+ *ManagedOOMMemoryPressureLimit) prop_name="ManagedOOMMemoryPressureLimit" ;;
437
+ *ManagedOOMMemoryPressure) prop_name="ManagedOOMMemoryPressure" ;;
438
+ esac
439
+ if [[ -n "$prop_name" ]]; then
440
+ local prop_val
441
+ prop_val=$(systemctl show "$slice_name" -p "$prop_name" 2>/dev/null | cut -d= -f2-)
442
+ if [[ -n "$prop_val" && "$prop_val" != "infinity" && "$prop_val" != "[not set]" ]]; then
443
+ # Convert bytes to human-readable for MemoryHigh
444
+ if [[ "$prop_name" == "MemoryHigh" && "$prop_val" =~ ^[0-9]+$ ]]; then
445
+ local gb_val
446
+ gb_val=$(awk "BEGIN {printf \"%.0f\", $prop_val / 1073741824}")
447
+ current_value="${gb_val}G"
448
+ # Convert ManagedOOMMemoryPressureLimit from raw number to percentage
449
+ elif [[ "$prop_name" == "ManagedOOMMemoryPressureLimit" && "$prop_val" =~ ^[0-9]+$ ]]; then
450
+ # systemd reports as basis points (0-10000) or raw. Convert to %
451
+ local pct
452
+ pct=$(awk "BEGIN {v = $prop_val * 100 / 4294967295; printf \"%.0f\", v}")
453
+ current_value="${pct}%"
454
+ else
455
+ current_value="$prop_val"
456
+ fi
457
+ fi
458
+ fi
459
+ ;;
460
+ /etc/udev/rules.d/*)
461
+ # Read live values from /sys/block/ for I/O and zram
462
+ current_value="not set"
463
+ case "$id" in
464
+ FS-004)
465
+ local sched
466
+ sched=$(cat /sys/block/vda/queue/scheduler 2>/dev/null || cat /sys/block/sda/queue/scheduler 2>/dev/null || echo "")
467
+ if [[ -n "$sched" ]]; then
468
+ current_value=$(echo "$sched" | grep -oE '\[[a-z_-]+\]' | tr -d '[]' || echo "unknown")
469
+ fi
470
+ ;;
471
+ FS-005)
472
+ current_value=$(cat /sys/block/vda/queue/read_ahead_kb 2>/dev/null || cat /sys/block/sda/queue/read_ahead_kb 2>/dev/null || echo "not set")
473
+ ;;
474
+ SWAP-006)
475
+ local algo
476
+ algo=$(cat /sys/block/zram0/comp_algorithm 2>/dev/null || echo "")
477
+ if [[ -n "$algo" ]]; then
478
+ current_value=$(echo "$algo" | grep -oE '\[[a-z0-9_-]+\]' | tr -d '[]' || echo "not configured")
479
+ fi
480
+ ;;
481
+ SWAP-007)
482
+ local disksize_bytes
483
+ disksize_bytes=$(cat /sys/block/zram0/disksize 2>/dev/null || echo "0")
484
+ if [[ "$disksize_bytes" -gt 0 ]] 2>/dev/null; then
485
+ local disksize_gb
486
+ disksize_gb=$(awk "BEGIN {printf \"%.0f\", $disksize_bytes / 1073741824}")
487
+ current_value="${disksize_gb}G"
488
+ fi
489
+ ;;
490
+ esac
491
+ ;;
492
+ /etc/modules-load.d/*)
493
+ # Check if module is loaded
494
+ current_value="not loaded"
495
+ local mod_name="$after_value"
496
+ if lsmod 2>/dev/null | grep -q "^${mod_name} "; then
497
+ current_value="loaded"
498
+ fi
499
+ ;;
500
+ /usr/local/bin/run-in-*|/usr/local/bin/*)
501
+ # Extract OOMScoreAdjust from helper script
502
+ current_value="not present"
503
+ if [[ -f "$config_file" ]]; then
504
+ local oom_val
505
+ oom_val=$(grep -oE 'OOMScoreAdjust=-?[0-9]+' "$config_file" 2>/dev/null | head -1 | cut -d= -f2)
506
+ if [[ -n "$oom_val" ]]; then
507
+ current_value="$oom_val"
508
+ fi
509
+ fi
510
+ ;;
511
+ *)
512
+ if [[ -f "$config_file" ]]; then
513
+ current_value="(file exists)"
514
+ else
515
+ current_value="(file missing)"
516
+ fi
517
+ ;;
518
+ esac
519
+
520
+ # Notes column
521
+ local notes=""
522
+ if [[ "$requires_reboot" == "true" ]]; then
523
+ notes="[REBOOT]"
524
+ fi
525
+
526
+ # jemalloc skip check
527
+ if [[ "$id" == "MEM-019" ]] && ! $has_jemalloc; then
528
+ notes="${notes} [SKIP: library not found]"
529
+ fi
530
+
531
+ # Service existence check
532
+ if [[ "$config_file" == "systemctl disable/mask" && "${svc_exists_cache[$parameter]:-no}" == "no" ]]; then
533
+ notes="${notes} [SKIP: service not installed]"
534
+ fi
535
+
536
+ # CPU-003 is "keep active" — informational
537
+ if [[ "$config_file" == "systemctl" && "$after_value" == *"kept"* ]]; then
538
+ notes="${notes} [INFO: no change]"
539
+ fi
540
+
541
+ printf "%-12s %-42s %-40s %-20s → %-20s %s\n" \
542
+ "$id" "${parameter:0:42}" "${config_file:0:40}" "${current_value:0:20}" "${target_value:0:20}" "$notes"
543
+
544
+ done <<< "$tsv_data"
545
+
546
+ echo ""
547
+ echo "============================================"
548
+ echo " DRY RUN complete — no changes made"
549
+ echo " To apply: tune.sh --tier $TIER --apply"
550
+ echo "============================================"
551
+ }
552
+
553
+ # -------------------------------------------------------
554
+ # APPLY: Actually apply tuning changes
555
+ # -------------------------------------------------------
556
+ do_apply() {
557
+ local ram_kb
558
+ ram_kb=$(detect_ram_kb)
559
+
560
+ local filter
561
+ filter=$(get_tier_filter)
562
+
563
+ # Extract entries as TSV
564
+ local tsv_data
565
+ tsv_data=$(jq -r "[.tuning_entries[] | $filter] | .[] | [.id, .category, .parameter, .config_file, .before_value, .after_value, .risk, (.requires_reboot | tostring), (.scaling_note // \"\")] | @tsv" "$MANIFEST")
566
+
567
+ local count
568
+ count=$(echo "$tsv_data" | wc -l)
569
+
570
+ echo "============================================"
571
+ echo " tunectl tune apply — Tier: $TIER"
572
+ echo "============================================"
573
+ echo ""
574
+ echo "Applying $count entries..."
575
+ echo ""
576
+
577
+ # --- Step 1: Create timestamped backup ---
578
+ local timestamp
579
+ timestamp=$(date +%Y-%m-%dT%H-%M-%S)
580
+ local backup_dir="${BACKUP_ROOT}/${timestamp}"
581
+ mkdir -p "$backup_dir"
582
+ echo "Backup directory: $backup_dir"
583
+
584
+ # Collect unique config files that will be modified (exclude systemctl pseudo-files)
585
+ local config_files_list
586
+ config_files_list=$(echo "$tsv_data" | awk -F'\t' '{print $4}' | sort -u | grep -v '^systemctl')
587
+
588
+ while IFS= read -r cf; do
589
+ [[ -z "$cf" ]] && continue
590
+ if [[ -f "$cf" ]]; then
591
+ local backup_path="${backup_dir}${cf}"
592
+ mkdir -p "$(dirname "$backup_path")"
593
+ cp -p "$cf" "$backup_path"
594
+ echo " Backed up: $cf"
595
+ fi
596
+ done <<< "$config_files_list"
597
+ echo ""
598
+
599
+ # --- Step 2: Collect and write sysctl entries ---
600
+ local sysctl_lines
601
+ sysctl_lines=$(echo "$tsv_data" | awk -F'\t' '$4 == "/etc/sysctl.d/99-performance.conf"')
602
+ local sysctl_count
603
+ sysctl_count=$(echo "$sysctl_lines" | grep -c . || true)
604
+
605
+ if [[ $sysctl_count -gt 0 ]]; then
606
+ echo "Writing sysctl values to /etc/sysctl.d/99-performance.conf..."
607
+ local sysctl_file="/etc/sysctl.d/99-performance.conf"
608
+
609
+ {
610
+ echo "# tunectl performance tuning — managed by tunectl"
611
+ echo "# Tier: $TIER ($sysctl_count sysctl entries)"
612
+ echo ""
613
+
614
+ local prev_category=""
615
+ while IFS=$'\t' read -r id category parameter config_file before_value after_value risk requires_reboot scaling_note; do
616
+ local target_value
617
+ target_value=$(scale_value "$id" "$after_value" "$ram_kb")
618
+
619
+ if [[ "$category" != "$prev_category" ]]; then
620
+ [[ -n "$prev_category" ]] && echo ""
621
+ echo "# ${category} tuning"
622
+ prev_category="$category"
623
+ fi
624
+
625
+ echo "$parameter = $target_value"
626
+ done <<< "$sysctl_lines"
627
+ } > "$sysctl_file"
628
+
629
+ echo " Written $sysctl_count sysctl parameters"
630
+
631
+ if sysctl --system >/dev/null 2>&1; then
632
+ echo " Sysctl values reloaded"
633
+ else
634
+ echo " Warning: sysctl reload returned non-zero (some params may have failed)" >&2
635
+ fi
636
+ echo ""
637
+ fi
638
+
639
+ # --- Step 3: Write other config files ---
640
+ local grub_changed=false
641
+
642
+ # Pre-check services
643
+ local all_units
644
+ all_units=$( (systemctl list-unit-files --type=service --no-legend 2>/dev/null; systemctl list-unit-files --type=socket --no-legend 2>/dev/null) | awk '{print $1}' || echo "")
645
+
646
+ local has_jemalloc=false
647
+ check_jemalloc && has_jemalloc=true
648
+
649
+ while IFS=$'\t' read -r id category parameter config_file before_value after_value risk requires_reboot scaling_note; do
650
+ local target_value
651
+ target_value=$(scale_value "$id" "$after_value" "$ram_kb")
652
+
653
+ case "$config_file" in
654
+ /etc/sysctl.d/99-performance.conf)
655
+ # Already handled above
656
+ ;;
657
+
658
+ "systemctl disable/mask")
659
+ if echo "$all_units" | grep -qF "$parameter"; then
660
+ systemctl disable "$parameter" 2>/dev/null || true
661
+ systemctl mask "$parameter" 2>/dev/null || true
662
+ systemctl stop "$parameter" 2>/dev/null || true
663
+ echo " $id: disabled/masked $parameter"
664
+ else
665
+ echo " $id: SKIP — $parameter not installed"
666
+ fi
667
+ ;;
668
+
669
+ "systemctl")
670
+ echo " $id: $parameter — no change (keep current state)"
671
+ ;;
672
+
673
+ /etc/environment)
674
+ case "$id" in
675
+ MEM-019)
676
+ if $has_jemalloc; then
677
+ if [[ -f /etc/environment ]] && grep -q '^LD_PRELOAD=' /etc/environment; then
678
+ sed -i "s|^LD_PRELOAD=.*|LD_PRELOAD=$target_value|" /etc/environment
679
+ else
680
+ echo "LD_PRELOAD=$target_value" >> /etc/environment
681
+ fi
682
+ echo " $id: Set LD_PRELOAD=$target_value"
683
+ else
684
+ echo " $id: SKIP — jemalloc library not found"
685
+ fi
686
+ ;;
687
+ BUILD-001)
688
+ if command -v sccache &>/dev/null; then
689
+ if [[ -f /etc/environment ]] && grep -q '^RUSTC_WRAPPER=' /etc/environment; then
690
+ sed -i "s|^RUSTC_WRAPPER=.*|RUSTC_WRAPPER=$target_value|" /etc/environment
691
+ else
692
+ echo "RUSTC_WRAPPER=$target_value" >> /etc/environment
693
+ fi
694
+ echo " $id: Set RUSTC_WRAPPER=$target_value"
695
+ else
696
+ echo " $id: SKIP — sccache not found in PATH"
697
+ fi
698
+ ;;
699
+ esac
700
+ ;;
701
+
702
+ /etc/default/grub)
703
+ grub_changed=true
704
+ handle_grub_entry "$id" "$parameter" "$target_value"
705
+ ;;
706
+
707
+ /etc/fstab)
708
+ handle_fstab_entry "$id" "$parameter" "$target_value" "$ram_kb"
709
+ ;;
710
+
711
+ /etc/modules-load.d/*)
712
+ mkdir -p "$(dirname "$config_file")"
713
+ echo "$target_value" > "$config_file"
714
+ echo " $id: Written $config_file ($target_value)"
715
+ ;;
716
+
717
+ /etc/udev/rules.d/*)
718
+ handle_udev_entry "$id" "$config_file" "$parameter" "$target_value"
719
+ ;;
720
+
721
+ /etc/tmpfiles.d/*)
722
+ handle_tmpfiles_entry "$id" "$config_file" "$parameter" "$target_value"
723
+ ;;
724
+
725
+ /etc/systemd/system/*.slice)
726
+ handle_slice_entry "$id" "$config_file" "$parameter" "$target_value"
727
+ ;;
728
+
729
+ /usr/local/bin/*)
730
+ handle_helper_script "$id" "$config_file" "$parameter" "$target_value"
731
+ ;;
732
+
733
+ *)
734
+ echo " $id: Unknown config file type: $config_file (skipped)" >&2
735
+ ;;
736
+ esac
737
+ done <<< "$tsv_data"
738
+
739
+ # --- Step 4: Run update-grub if GRUB entries changed ---
740
+ if $grub_changed; then
741
+ echo ""
742
+ echo "GRUB entries changed — running update-grub..."
743
+ if command -v update-grub &>/dev/null; then
744
+ update-grub 2>/dev/null || echo " Warning: update-grub returned non-zero" >&2
745
+ else
746
+ echo " Warning: update-grub not found" >&2
747
+ fi
748
+ fi
749
+
750
+ # --- Step 5: Reload systemd if slice files changed ---
751
+ systemctl daemon-reload 2>/dev/null || true
752
+
753
+ echo ""
754
+ echo "============================================"
755
+ echo " Apply complete — $count entries processed"
756
+ echo " Backup saved: $backup_dir"
757
+ echo "============================================"
758
+ }
759
+
760
+ # -------------------------------------------------------
761
+ # Handle GRUB kernel command line entries
762
+ # -------------------------------------------------------
763
+ handle_grub_entry() {
764
+ local id="$1"
765
+ local parameter="$2"
766
+ local target_value="$3"
767
+ local grub_file="/etc/default/grub"
768
+
769
+ if [[ ! -f "$grub_file" ]]; then
770
+ echo " $id: SKIP — $grub_file not found" >&2
771
+ return
772
+ fi
773
+
774
+ local param_name="" param_str=""
775
+ case "$id" in
776
+ SWAP-009) param_name="zswap.enabled"; param_str="zswap.enabled=0" ;;
777
+ BOOT-001) param_name="mitigations"; param_str="mitigations=auto,nosmt" ;;
778
+ BOOT-002) param_name="l1tf"; param_str="l1tf=off" ;;
779
+ BOOT-003) param_name="tsx_async_abort"; param_str="tsx_async_abort=off" ;;
780
+ BOOT-004) param_name="preempt"; param_str="preempt=none" ;;
781
+ BOOT-005) param_name="transparent_hugepage"; param_str="transparent_hugepage=always" ;;
782
+ *)
783
+ echo " $id: Unknown GRUB parameter" >&2
784
+ return
785
+ ;;
786
+ esac
787
+
788
+ local current_cmdline
789
+ current_cmdline=$(grep '^GRUB_CMDLINE_LINUX_DEFAULT=' "$grub_file" | sed 's/^GRUB_CMDLINE_LINUX_DEFAULT=//' | tr -d '"' || echo "")
790
+
791
+ local new_cmdline
792
+ new_cmdline=$(echo "$current_cmdline" | sed -E "s/(^| )${param_name}=[^ ]*//" | sed 's/ */ /g' | sed 's/^ //;s/ $//')
793
+
794
+ if [[ -n "$new_cmdline" ]]; then
795
+ new_cmdline="$new_cmdline $param_str"
796
+ else
797
+ new_cmdline="$param_str"
798
+ fi
799
+
800
+ if grep -q '^GRUB_CMDLINE_LINUX_DEFAULT=' "$grub_file"; then
801
+ sed -i "s|^GRUB_CMDLINE_LINUX_DEFAULT=.*|GRUB_CMDLINE_LINUX_DEFAULT=\"$new_cmdline\"|" "$grub_file"
802
+ else
803
+ echo "GRUB_CMDLINE_LINUX_DEFAULT=\"$new_cmdline\"" >> "$grub_file"
804
+ fi
805
+
806
+ echo " $id: GRUB $param_name set ($param_str)"
807
+ }
808
+
809
+ # -------------------------------------------------------
810
+ # Handle fstab entries
811
+ # -------------------------------------------------------
812
+ handle_fstab_entry() {
813
+ local id="$1"
814
+ local parameter="$2"
815
+ local target_value="$3"
816
+ local ram_kb="$4"
817
+ local fstab="/etc/fstab"
818
+
819
+ case "$id" in
820
+ SWAP-005)
821
+ if ! grep -q '/dev/zram0' "$fstab" 2>/dev/null; then
822
+ echo "$target_value" >> "$fstab"
823
+ echo " $id: Added zram0 swap entry to fstab"
824
+ else
825
+ echo " $id: zram0 already in fstab (idempotent)"
826
+ fi
827
+ ;;
828
+ FS-001)
829
+ if grep -qE '^\S+\s+/\s' "$fstab"; then
830
+ if ! grep -E '^\S+\s+/\s' "$fstab" | grep -q 'noatime'; then
831
+ sed -i -E '/^\S+\s+\/\s/s/(defaults[^ ]*)/\1,noatime/' "$fstab"
832
+ echo " $id: Added noatime to root mount"
833
+ else
834
+ echo " $id: noatime already set (idempotent)"
835
+ fi
836
+ else
837
+ echo " $id: SKIP — root mount not found in fstab"
838
+ fi
839
+ ;;
840
+ FS-002)
841
+ if grep -qE '^\S+\s+/\s' "$fstab"; then
842
+ if ! grep -E '^\S+\s+/\s' "$fstab" | grep -q 'commit='; then
843
+ sed -i -E '/^\S+\s+\/\s/s/(defaults[^ ]*)/\1,commit=60/' "$fstab"
844
+ echo " $id: Added commit=60 to root mount"
845
+ else
846
+ echo " $id: commit interval already set (idempotent)"
847
+ fi
848
+ else
849
+ echo " $id: SKIP — root mount not found in fstab"
850
+ fi
851
+ ;;
852
+ FS-003)
853
+ if ! grep -q 'tmpfs\s\+/tmp' "$fstab" 2>/dev/null; then
854
+ echo "$target_value" >> "$fstab"
855
+ echo " $id: Added tmpfs /tmp entry"
856
+ else
857
+ echo " $id: tmpfs /tmp already in fstab (idempotent)"
858
+ fi
859
+ ;;
860
+ *)
861
+ echo " $id: Unknown fstab entry" >&2
862
+ ;;
863
+ esac
864
+ }
865
+
866
+ # -------------------------------------------------------
867
+ # Handle udev rule entries
868
+ # -------------------------------------------------------
869
+ handle_udev_entry() {
870
+ local id="$1"
871
+ local config_file="$2"
872
+ local parameter="$3"
873
+ local target_value="$4"
874
+
875
+ mkdir -p "$(dirname "$config_file")"
876
+
877
+ case "$id" in
878
+ FS-004)
879
+ echo 'ACTION=="add|change", KERNEL=="vd[a-z]|sd[a-z]|nvme[0-9]*", ATTR{queue/scheduler}="none"' > "$config_file"
880
+ echo " $id: Written I/O scheduler udev rule"
881
+ ;;
882
+ FS-005)
883
+ local rule='ACTION=="add|change", KERNEL=="vd[a-z]|sd[a-z]|nvme[0-9]*", ATTR{queue/read_ahead_kb}="1024"'
884
+ if [[ -f "$config_file" ]] && ! grep -q 'read_ahead_kb' "$config_file"; then
885
+ echo "$rule" >> "$config_file"
886
+ elif [[ ! -f "$config_file" ]]; then
887
+ echo "$rule" > "$config_file"
888
+ fi
889
+ echo " $id: Written read-ahead udev rule"
890
+ ;;
891
+ SWAP-006)
892
+ local rule="KERNEL==\"zram0\", ATTR{comp_algorithm}=\"$target_value\""
893
+ if [[ -f "$config_file" ]] && grep -q 'comp_algorithm' "$config_file"; then
894
+ sed -i "s|comp_algorithm.*|comp_algorithm}=\"$target_value\"|" "$config_file"
895
+ elif [[ -f "$config_file" ]]; then
896
+ echo "$rule" >> "$config_file"
897
+ else
898
+ echo "$rule" > "$config_file"
899
+ fi
900
+ echo " $id: Written zram comp_algorithm rule"
901
+ ;;
902
+ SWAP-007)
903
+ local rule="KERNEL==\"zram0\", ATTR{disksize}=\"$target_value\""
904
+ if [[ -f "$config_file" ]] && grep -q 'disksize' "$config_file"; then
905
+ sed -i "s|disksize.*|disksize}=\"$target_value\"|" "$config_file"
906
+ elif [[ -f "$config_file" ]]; then
907
+ echo "$rule" >> "$config_file"
908
+ else
909
+ echo "$rule" > "$config_file"
910
+ fi
911
+ echo " $id: Written zram disksize rule ($target_value)"
912
+ ;;
913
+ *)
914
+ echo " $id: Unhandled udev entry" >&2
915
+ ;;
916
+ esac
917
+ }
918
+
919
+ # -------------------------------------------------------
920
+ # Handle tmpfiles.d entries — manifest-driven, writes per-entry line
921
+ # Uses a tracking variable to write header + all entries on first call per file.
922
+ # -------------------------------------------------------
923
+ declare -A _tmpfiles_written=()
924
+
925
+ handle_tmpfiles_entry() {
926
+ local id="$1"
927
+ local config_file="$2"
928
+ local parameter="$3"
929
+ local target_value="$4"
930
+
931
+ mkdir -p "$(dirname "$config_file")"
932
+
933
+ # Write the complete file on first entry for this config_file.
934
+ # All values are read from the manifest via the filtered tsv_data.
935
+ if [[ -z "${_tmpfiles_written[$config_file]:-}" ]]; then
936
+ _tmpfiles_written["$config_file"]=1
937
+
938
+ # Determine sys path prefix and header based on file
939
+ local header=""
940
+ local sys_prefix=""
941
+ case "$config_file" in
942
+ /etc/tmpfiles.d/thp.conf)
943
+ header="# Transparent Huge Pages configuration"
944
+ sys_prefix="/sys/kernel/mm/"
945
+ ;;
946
+ /etc/tmpfiles.d/ksm.conf)
947
+ header="# KSM (Kernel Samepage Merging) configuration"
948
+ sys_prefix="/sys/kernel/mm/"
949
+ ;;
950
+ esac
951
+
952
+ {
953
+ echo "$header"
954
+ # Extract all entries for this config_file from the manifest, apply scaling
955
+ local file_entries
956
+ file_entries=$(jq -r --arg cf "$config_file" \
957
+ '[.tuning_entries[] | select(.config_file == $cf)] | .[] | [.id, .parameter, .after_value] | @tsv' \
958
+ "$MANIFEST")
959
+ local ram_kb
960
+ ram_kb=$(detect_ram_kb)
961
+ while IFS=$'\t' read -r eid eparam eafter; do
962
+ [[ -z "$eid" ]] && continue
963
+ local scaled_val
964
+ scaled_val=$(scale_value "$eid" "$eafter" "$ram_kb")
965
+ echo "w ${sys_prefix}${eparam} - - - - ${scaled_val}"
966
+ done <<< "$file_entries"
967
+ } > "$config_file"
968
+ echo " $id: Written ${config_file} (all entries from manifest)"
969
+ else
970
+ echo " $id: ${config_file} already written"
971
+ fi
972
+ }
973
+
974
+ # -------------------------------------------------------
975
+ # Handle systemd slice entries — manifest-driven, writes per-slice file
976
+ # Writes all properties for the slice from manifest on first call per file.
977
+ # -------------------------------------------------------
978
+ declare -A _slice_written=()
979
+
980
+ handle_slice_entry() {
981
+ local id="$1"
982
+ local config_file="$2"
983
+ local parameter="$3"
984
+ local target_value="$4"
985
+
986
+ mkdir -p "$(dirname "$config_file")"
987
+
988
+ # Write the complete slice file on first entry for this config_file.
989
+ if [[ -z "${_slice_written[$config_file]:-}" ]]; then
990
+ _slice_written["$config_file"]=1
991
+
992
+ local ram_kb
993
+ ram_kb=$(detect_ram_kb)
994
+
995
+ {
996
+ echo "[Slice]"
997
+ # Extract all entries for this config_file from the manifest
998
+ local file_entries
999
+ file_entries=$(jq -r --arg cf "$config_file" \
1000
+ '[.tuning_entries[] | select(.config_file == $cf)] | .[] | [.id, .parameter, .after_value] | @tsv' \
1001
+ "$MANIFEST")
1002
+ while IFS=$'\t' read -r eid eparam eafter; do
1003
+ [[ -z "$eid" ]] && continue
1004
+ local scaled_val
1005
+ scaled_val=$(scale_value "$eid" "$eafter" "$ram_kb")
1006
+ # Extract the property name from the parameter field (e.g., "droid.slice CPUWeight" -> "CPUWeight")
1007
+ local prop_name
1008
+ prop_name=$(echo "$eparam" | awk '{print $NF}')
1009
+ echo "${prop_name}=${scaled_val}"
1010
+ done <<< "$file_entries"
1011
+ } > "$config_file"
1012
+ echo " $id: Written ${config_file} (all entries from manifest)"
1013
+ else
1014
+ echo " $id: ${config_file} already written"
1015
+ fi
1016
+ }
1017
+
1018
+ # -------------------------------------------------------
1019
+ # Handle helper scripts (run-in-droid, run-in-bulk) — manifest-driven
1020
+ # OOMScoreAdjust value comes from the manifest's after_value field.
1021
+ # -------------------------------------------------------
1022
+ handle_helper_script() {
1023
+ local id="$1"
1024
+ local config_file="$2"
1025
+ local parameter="$3"
1026
+ local target_value="$4"
1027
+
1028
+ mkdir -p "$(dirname "$config_file")"
1029
+
1030
+ # Determine slice name from the config_file path
1031
+ local slice_name=""
1032
+ local script_desc=""
1033
+ case "$config_file" in
1034
+ */run-in-droid)
1035
+ slice_name="droid.slice"
1036
+ script_desc="Run a command in the droid.slice cgroup with OOM protection"
1037
+ ;;
1038
+ */run-in-bulk)
1039
+ slice_name="bulk.slice"
1040
+ script_desc="Run a command in the bulk.slice cgroup (OOM-expendable)"
1041
+ ;;
1042
+ *)
1043
+ echo " $id: Unknown helper script: $config_file" >&2
1044
+ return
1045
+ ;;
1046
+ esac
1047
+
1048
+ cat > "$config_file" <<SCRIPT
1049
+ #!/usr/bin/env bash
1050
+ # $(basename "$config_file"): ${script_desc}
1051
+ exec systemd-run --slice=${slice_name} --scope --property=OOMScoreAdjust=${target_value} -- "\$@"
1052
+ SCRIPT
1053
+ chmod +x "$config_file"
1054
+ echo " $id: Written $config_file (OOMScoreAdjust=$target_value)"
1055
+ }
1056
+
1057
+ # -------------------------------------------------------
1058
+ # Main
1059
+ # -------------------------------------------------------
1060
+ main() {
1061
+ parse_args "$@"
1062
+ validate_manifest
1063
+
1064
+ if [[ "$MODE" == "dry-run" ]]; then
1065
+ do_dry_run
1066
+ else
1067
+ do_apply
1068
+ fi
1069
+
1070
+ exit 0
1071
+ }
1072
+
1073
+ main "$@"