aidevops 3.12.0 → 3.13.1

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,386 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-License-Identifier: MIT
3
+ # SPDX-FileCopyrightText: 2025-2026 Marcus Quinn
4
+ # =============================================================================
5
+ # Schedulers Linux Sub-Library -- systemd/cron scheduler installation and
6
+ # uninstall functions for Linux (and macOS uninstall path).
7
+ # =============================================================================
8
+ # This sub-library is sourced by setup-modules/schedulers.sh (the orchestrator).
9
+ # It covers:
10
+ # - systemd user service availability check
11
+ # - systemd value escaping
12
+ # - Building systemd Environment= and cron env prefix lines
13
+ # - Generic systemd timer installation
14
+ # - Generic cron entry installation
15
+ # - Linux dispatcher (systemd preferred, cron fallback)
16
+ # - Generic scheduler uninstall (launchd/systemd/cron)
17
+ # - Supervisor pulse uninstall
18
+ #
19
+ # Usage: source "${SCRIPT_DIR}/schedulers-linux.sh"
20
+ #
21
+ # Dependencies:
22
+ # - shared-constants.sh (print_info, print_warning)
23
+ #
24
+ # Part of aidevops framework: https://aidevops.sh
25
+
26
+ # Apply strict mode only when executed directly (not when sourced)
27
+ [[ "${BASH_SOURCE[0]}" == "${0}" ]] && set -euo pipefail
28
+
29
+ # Include guard
30
+ [[ -n "${_SCHEDULERS_LINUX_LIB_LOADED:-}" ]] && return 0
31
+ _SCHEDULERS_LINUX_LIB_LOADED=1
32
+
33
+ # SCRIPT_DIR fallback — needed when sourced from test harnesses that don't set it.
34
+ if [[ -z "${SCRIPT_DIR:-}" ]]; then
35
+ _sched_linux_lib_path="${BASH_SOURCE[0]%/*}"
36
+ [[ "$_sched_linux_lib_path" == "${BASH_SOURCE[0]}" ]] && _sched_linux_lib_path="."
37
+ SCRIPT_DIR="$(cd "$_sched_linux_lib_path" && pwd)"
38
+ unset _sched_linux_lib_path
39
+ fi
40
+
41
+ # --- Functions ---
42
+
43
+ # Check if systemd user services are available on this Linux system.
44
+ # Returns 0 if systemd --user is functional, 1 otherwise.
45
+ _systemd_user_available() {
46
+ command -v systemctl >/dev/null 2>&1 || return 1
47
+ systemctl --user status >/dev/null 2>&1 || return 1
48
+ return 0
49
+ }
50
+
51
+ # Escape a value for safe embedding in a systemd unit Environment= or ExecStart=
52
+ # directive. systemd interprets % as specifiers (%h, %n, %t, etc.) and spaces
53
+ # as key-value separators. This helper:
54
+ # 1. Escapes \ → \\ (must be first to avoid double-escaping)
55
+ # 2. Doubles % → %% (escape specifiers)
56
+ # 3. Escapes embedded " → \"
57
+ # 4. Wraps the result in "..." (handles spaces and other shell metacharacters)
58
+ # Usage: escaped=$(_systemd_escape "$value")
59
+ #
60
+ # WARNING: Do NOT use for StandardOutput= or StandardError= directives.
61
+ # systemd does not strip outer quotes from those values — "append:/path" is
62
+ # treated as a literal filename with quote characters, failing silently.
63
+ # Use bare values for StandardOutput=/StandardError=:
64
+ # StandardOutput=append:${log_file} ← correct
65
+ # StandardOutput=$(_systemd_escape "append:${log_file}") ← WRONG
66
+ _systemd_escape() {
67
+ local _val="$1"
68
+ # Step 1: escape backslashes
69
+ _val="${_val//\\/\\\\}"
70
+ # Step 2: escape % specifiers
71
+ _val="${_val//%/%%}"
72
+ # Step 3: escape embedded double-quotes
73
+ _val="${_val//\"/\\\"}"
74
+ # Step 4: wrap in double-quotes
75
+ printf '"%s"' "$_val"
76
+ return 0
77
+ }
78
+
79
+ # Build systemd Environment= lines from newline-separated KEY=VALUE pairs.
80
+ # Always appends HOME and PATH for parity with launchd and cron execution.
81
+ _scheduler_systemd_env_lines() {
82
+ local env_vars="$1"
83
+ local _env_lines=""
84
+
85
+ if [[ -n "$env_vars" ]]; then
86
+ while IFS= read -r _kv; do
87
+ [[ -z "$_kv" ]] && continue
88
+ local _key="${_kv%%=*}"
89
+ local _raw_val="${_kv#*=}"
90
+ local _escaped_val
91
+ _escaped_val=$(_systemd_escape "$_raw_val")
92
+ _env_lines+="Environment=${_key}=${_escaped_val}"$'\n'
93
+ done <<<"$env_vars"
94
+ fi
95
+
96
+ _env_lines+="Environment=HOME=$(_systemd_escape "$HOME")"$'\n'
97
+ _env_lines+="Environment=PATH=$(_systemd_escape "$PATH")"$'\n'
98
+ printf '%s' "$_env_lines"
99
+ return 0
100
+ }
101
+
102
+ # Build inline cron environment assignments from newline-separated KEY=VALUE pairs.
103
+ _scheduler_cron_env_prefix() {
104
+ local env_vars="$1"
105
+ local _env_prefix=""
106
+
107
+ if [[ -n "$env_vars" ]]; then
108
+ while IFS= read -r _kv; do
109
+ [[ -z "$_kv" ]] && continue
110
+ local _key="${_kv%%=*}"
111
+ local _raw_val="${_kv#*=}"
112
+ local _escaped_val
113
+ _escaped_val=$(_cron_escape "$_raw_val")
114
+ _env_prefix+="${_key}=${_escaped_val} "
115
+ done <<<"$env_vars"
116
+ fi
117
+
118
+ printf '%s' "$_env_prefix"
119
+ return 0
120
+ }
121
+
122
+ # Install a generic scheduler via systemd user timer (Linux with systemd).
123
+ # Args:
124
+ # $1 = service_name (e.g. "aidevops-stats-wrapper")
125
+ # $2 = exec_command (shell command run via /bin/bash -lc)
126
+ # $3 = interval_sec (OnUnitActiveSec interval in seconds; may be empty for calendar-only)
127
+ # $4 = log_file (absolute path to log file)
128
+ # $5 = env_vars (newline-separated KEY=VALUE pairs, may be empty)
129
+ # $6 = run_at_load ("true" or "false")
130
+ # $7 = low_priority ("true" or "false")
131
+ # $8 = on_calendar (optional systemd OnCalendar spec)
132
+ # $9 = timeout_sec (optional TimeoutStartSec; defaults to interval_sec)
133
+ # Returns 0 on success, 1 if systemd enable fails (caller should fall back to cron).
134
+ _install_scheduler_systemd() {
135
+ local service_name="$1"
136
+ local exec_command="$2"
137
+ local interval_sec="$3"
138
+ local log_file="$4"
139
+ local env_vars="$5"
140
+ local run_at_load="$6"
141
+ local low_priority="$7"
142
+ local on_calendar="$8"
143
+ local timeout_sec="$9"
144
+ local service_dir="$HOME/.config/systemd/user"
145
+ local service_file="${service_dir}/${service_name}.service"
146
+ local timer_file="${service_dir}/${service_name}.timer"
147
+
148
+ mkdir -p "$service_dir"
149
+
150
+ # GH#18439 Bug 1: command substitution strips trailing newlines, which
151
+ # would run the final Environment=PATH=... into the following
152
+ # StandardOutput=... directive on the same line. Use a sentinel ('x')
153
+ # to preserve the trailing newline that _scheduler_systemd_env_lines
154
+ # always emits.
155
+ local _env_lines
156
+ _env_lines=$(
157
+ _scheduler_systemd_env_lines "$env_vars"
158
+ printf 'x'
159
+ )
160
+ _env_lines="${_env_lines%x}"
161
+
162
+ if [[ -z "$timeout_sec" ]]; then
163
+ timeout_sec="$interval_sec"
164
+ fi
165
+ if [[ -z "$timeout_sec" ]]; then
166
+ timeout_sec="3600"
167
+ fi
168
+
169
+ local _service_extra=""
170
+ if [[ "$low_priority" == "true" ]]; then
171
+ _service_extra+="Nice=10"$'\n'
172
+ _service_extra+="IOSchedulingClass=idle"$'\n'
173
+ fi
174
+
175
+ printf '%s' "[Unit]
176
+ Description=aidevops ${service_name}
177
+ After=network.target
178
+
179
+ [Service]
180
+ Type=oneshot
181
+ KillMode=process
182
+ ExecStart=/bin/bash -lc $(_systemd_escape "$exec_command")
183
+ TimeoutStartSec=${timeout_sec}
184
+ ${_service_extra}${_env_lines}StandardOutput=append:${log_file}
185
+ StandardError=append:${log_file}
186
+ " >"$service_file"
187
+
188
+ local _timer_lines=""
189
+ if [[ "$run_at_load" == "true" ]]; then
190
+ _timer_lines+="OnActiveSec=10s"$'\n'
191
+ fi
192
+ if [[ -n "$interval_sec" ]]; then
193
+ _timer_lines+="OnBootSec=${interval_sec}"$'\n'
194
+ _timer_lines+="OnUnitActiveSec=${interval_sec}"$'\n'
195
+ fi
196
+ if [[ -n "$on_calendar" ]]; then
197
+ _timer_lines+="OnCalendar=${on_calendar}"$'\n'
198
+ fi
199
+
200
+ printf '%s' "[Unit]
201
+ Description=aidevops ${service_name} Timer
202
+
203
+ [Timer]
204
+ ${_timer_lines}Persistent=true
205
+
206
+ [Install]
207
+ WantedBy=timers.target
208
+ " >"$timer_file"
209
+
210
+ systemctl --user daemon-reload 2>/dev/null || true
211
+ if systemctl --user enable --now "${service_name}.timer" 2>/dev/null; then
212
+ return 0
213
+ fi
214
+ return 1
215
+ }
216
+
217
+ # Install a generic cron entry.
218
+ # Args: $1=cron_tag, $2=cron_schedule, $3=exec_command, $4=log_file, $5=env_vars
219
+ _install_scheduler_cron() {
220
+ local cron_tag="$1"
221
+ local cron_schedule="$2"
222
+ local exec_command="$3"
223
+ local log_file="$4"
224
+ local env_vars="$5"
225
+ local _cron_exec
226
+ local _cron_log
227
+ local _env_prefix
228
+
229
+ _env_prefix=$(_scheduler_cron_env_prefix "$env_vars")
230
+ _cron_exec=$(_cron_escape "$exec_command")
231
+ _cron_log=$(_cron_escape "$log_file")
232
+
233
+ (
234
+ crontab -l 2>/dev/null | grep -vF "${cron_tag}" || true
235
+ echo "${cron_schedule} ${_env_prefix}/bin/bash -lc ${_cron_exec} >> ${_cron_log} 2>&1 # ${cron_tag}"
236
+ ) | crontab - 2>/dev/null || true
237
+ return 0
238
+ }
239
+
240
+ # Dispatcher: install a scheduler on Linux, preferring systemd over cron.
241
+ # Args:
242
+ # $1 = service_name (systemd service name, e.g. "aidevops-stats-wrapper")
243
+ # $2 = cron_tag (comment tag for cron line, e.g. "aidevops: stats-wrapper")
244
+ # $3 = cron_schedule (cron schedule expression, e.g. "*/15 * * * *")
245
+ # $4 = exec_command (shell command run via /bin/bash -lc)
246
+ # $5 = interval_sec (systemd OnUnitActiveSec in seconds; may be empty for calendar-only)
247
+ # $6 = log_file (absolute path to log file)
248
+ # $7 = env_vars (newline-separated KEY=VALUE pairs for systemd/cron, may be empty)
249
+ # $8 = success_msg (message to print on success)
250
+ # $9 = fail_msg (message to print on failure)
251
+ # $10 = run_at_load ("true" or "false")
252
+ # $11 = low_priority ("true" or "false")
253
+ # $12 = on_calendar (optional systemd OnCalendar spec)
254
+ # $13 = timeout_sec (optional TimeoutStartSec)
255
+ # Returns 0 always (failures are warnings, not fatal).
256
+ _install_scheduler_linux() {
257
+ local service_name="$1"
258
+ local cron_tag="$2"
259
+ local cron_schedule="$3"
260
+ local exec_command="$4"
261
+ local interval_sec="$5"
262
+ local log_file="$6"
263
+ local env_vars="$7"
264
+ local success_msg="$8"
265
+ local fail_msg="$9"
266
+ local run_at_load="${10}"
267
+ local low_priority="${11}"
268
+ local on_calendar="${12:-}"
269
+ local timeout_sec="${13:-}"
270
+
271
+ if _systemd_user_available; then
272
+ if _install_scheduler_systemd \
273
+ "$service_name" \
274
+ "$exec_command" \
275
+ "$interval_sec" \
276
+ "$log_file" \
277
+ "$env_vars" \
278
+ "$run_at_load" \
279
+ "$low_priority" \
280
+ "$on_calendar" \
281
+ "$timeout_sec"; then
282
+ print_info "${success_msg} (systemd user timer)"
283
+ # After systemd install succeeds, remove any pre-existing cron entry
284
+ # to prevent dual-execution (GH#17695 Finding A)
285
+ if command -v crontab >/dev/null 2>&1; then
286
+ local current_cron
287
+ current_cron=$(crontab -l 2>/dev/null) || current_cron=""
288
+ if [[ -n "$current_cron" ]] && echo "$current_cron" | grep -qF "$cron_tag"; then
289
+ echo "$current_cron" | grep -vF "$cron_tag" | crontab -
290
+ echo "[schedulers] Removed pre-existing cron entry for $cron_tag (migrated to systemd)"
291
+ fi
292
+ fi
293
+ else
294
+ print_warning "systemd enable failed for ${service_name} — falling back to cron"
295
+ _install_scheduler_cron "$cron_tag" "$cron_schedule" "$exec_command" "$log_file" "$env_vars"
296
+ if crontab -l 2>/dev/null | grep -qF "${cron_tag}" 2>/dev/null; then
297
+ print_info "${success_msg} (cron fallback)"
298
+ else
299
+ print_warning "${fail_msg}"
300
+ fi
301
+ fi
302
+ else
303
+ _install_scheduler_cron "$cron_tag" "$cron_schedule" "$exec_command" "$log_file" "$env_vars"
304
+ if crontab -l 2>/dev/null | grep -qF "${cron_tag}" 2>/dev/null; then
305
+ print_info "${success_msg} (cron)"
306
+ else
307
+ print_warning "${fail_msg}"
308
+ fi
309
+ fi
310
+ return 0
311
+ }
312
+
313
+ # Uninstall a scheduler across all backends (launchd/systemd/cron).
314
+ # Args:
315
+ # $1 = os (output of uname -s)
316
+ # $2 = launchd_label (e.g. "sh.aidevops.stats-wrapper")
317
+ # $3 = systemd_name (e.g. "aidevops-stats-wrapper")
318
+ # $4 = cron_tag (grep pattern for cron line, e.g. "aidevops: stats-wrapper")
319
+ # $5 = success_msg (message to print on removal)
320
+ # Returns 0 always.
321
+ _uninstall_scheduler() {
322
+ local _os="$1"
323
+ local launchd_label="$2"
324
+ local systemd_name="$3"
325
+ local cron_tag="$4"
326
+ local success_msg="$5"
327
+
328
+ if [[ "$_os" == "Darwin" ]]; then
329
+ local _plist="$HOME/Library/LaunchAgents/${launchd_label}.plist"
330
+ if _launchd_has_agent "$launchd_label"; then
331
+ launchctl unload "$_plist" 2>/dev/null || true
332
+ rm -f "$_plist"
333
+ print_info "${success_msg} (launchd agent removed)"
334
+ fi
335
+ else
336
+ # Check and remove from ALL backends sequentially, not just the first
337
+ # match. Prevents orphan entries when migrating between systemd and cron
338
+ # (GH#17695 Finding A).
339
+ if _systemd_user_available && systemctl --user is-enabled "${systemd_name}.timer" >/dev/null 2>&1; then
340
+ systemctl --user disable --now "${systemd_name}.timer" 2>/dev/null || true
341
+ rm -f "$HOME/.config/systemd/user/${systemd_name}.service"
342
+ rm -f "$HOME/.config/systemd/user/${systemd_name}.timer"
343
+ systemctl --user daemon-reload 2>/dev/null || true
344
+ print_info "${success_msg} (systemd timer removed)"
345
+ fi
346
+ if command -v crontab >/dev/null 2>&1; then
347
+ local current_cron
348
+ current_cron=$(crontab -l 2>/dev/null) || current_cron=""
349
+ if [[ -n "$current_cron" ]] && echo "$current_cron" | grep -qF "${cron_tag}"; then
350
+ echo "$current_cron" | grep -vF "${cron_tag}" | crontab - 2>/dev/null || true
351
+ print_info "${success_msg} (cron entry removed)"
352
+ fi
353
+ fi
354
+ fi
355
+ return 0
356
+ }
357
+
358
+ # Uninstall supervisor pulse (user explicitly disabled)
359
+ _uninstall_pulse() {
360
+ local _os="$1"
361
+ local pulse_label="$2"
362
+ if [[ "$_os" == "Darwin" ]]; then
363
+ local pulse_plist="$HOME/Library/LaunchAgents/${pulse_label}.plist"
364
+ if _launchd_has_agent "$pulse_label"; then
365
+ launchctl unload "$pulse_plist" || true
366
+ rm -f "$pulse_plist"
367
+ pkill -f 'Supervisor Pulse' 2>/dev/null || true
368
+ print_info "Supervisor pulse disabled (launchd agent removed per config)"
369
+ fi
370
+ elif _systemd_user_available; then
371
+ local service_name="aidevops-supervisor-pulse"
372
+ if systemctl --user is-enabled "${service_name}.timer" >/dev/null 2>&1; then
373
+ systemctl --user disable --now "${service_name}.timer" 2>/dev/null || true
374
+ rm -f "$HOME/.config/systemd/user/${service_name}.service"
375
+ rm -f "$HOME/.config/systemd/user/${service_name}.timer"
376
+ systemctl --user daemon-reload 2>/dev/null || true
377
+ print_info "Supervisor pulse disabled (systemd timer removed per config)"
378
+ fi
379
+ else
380
+ if crontab -l 2>/dev/null | grep -qF "pulse-wrapper"; then
381
+ crontab -l 2>/dev/null | grep -v 'aidevops: supervisor-pulse' | crontab - || true
382
+ print_info "Supervisor pulse disabled (cron entry removed per config)"
383
+ fi
384
+ fi
385
+ return 0
386
+ }