@fastybird/smart-panel-backend 1.1.0-alpha.15 → 1.1.0-alpha.17

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.
Files changed (66) hide show
  1. package/dist/modules/devices/utils/schema.utils.js +1 -1
  2. package/dist/modules/devices/utils/schema.utils.js.map +1 -1
  3. package/dist/modules/system/scripts/update-worker.sh +463 -59
  4. package/dist/modules/system/services/update-executor.service.d.ts +9 -0
  5. package/dist/modules/system/services/update-executor.service.js +170 -25
  6. package/dist/modules/system/services/update-executor.service.js.map +1 -1
  7. package/dist/plugins/devices-homekit/listeners/homekit-event.listener.d.ts +2 -0
  8. package/dist/plugins/devices-homekit/listeners/homekit-event.listener.js +22 -0
  9. package/dist/plugins/devices-homekit/listeners/homekit-event.listener.js.map +1 -1
  10. package/dist/plugins/devices-homekit/mappers/button.mapper.d.ts +15 -0
  11. package/dist/plugins/devices-homekit/mappers/button.mapper.js +143 -0
  12. package/dist/plugins/devices-homekit/mappers/button.mapper.js.map +1 -0
  13. package/dist/plugins/devices-homekit/mappers/homekit-mapper.interface.d.ts +8 -0
  14. package/dist/plugins/devices-homekit/services/homekit-mapper-registry.service.d.ts +5 -1
  15. package/dist/plugins/devices-homekit/services/homekit-mapper-registry.service.js +42 -0
  16. package/dist/plugins/devices-homekit/services/homekit-mapper-registry.service.js.map +1 -1
  17. package/dist/plugins/devices-reterminal/services/reterminal-button.service.d.ts +7 -3
  18. package/dist/plugins/devices-reterminal/services/reterminal-button.service.js +63 -17
  19. package/dist/plugins/devices-reterminal/services/reterminal-button.service.js.map +1 -1
  20. package/dist/plugins/devices-third-party/controllers/third-party-inputs.controller.d.ts +17 -0
  21. package/dist/plugins/devices-third-party/controllers/third-party-inputs.controller.js +205 -0
  22. package/dist/plugins/devices-third-party/controllers/third-party-inputs.controller.js.map +1 -0
  23. package/dist/plugins/devices-third-party/devices-third-party.openapi.d.ts +1 -1
  24. package/dist/plugins/devices-third-party/devices-third-party.plugin.js +2 -1
  25. package/dist/plugins/devices-third-party/devices-third-party.plugin.js.map +1 -1
  26. package/dist/plugins/devices-third-party/dto/report-input-occurrence-response.dto.d.ts +5 -0
  27. package/dist/plugins/devices-third-party/dto/report-input-occurrence-response.dto.js +50 -0
  28. package/dist/plugins/devices-third-party/dto/report-input-occurrence-response.dto.js.map +1 -0
  29. package/dist/plugins/devices-third-party/dto/report-input-occurrence.dto.d.ts +8 -0
  30. package/dist/plugins/devices-third-party/dto/report-input-occurrence.dto.js +81 -0
  31. package/dist/plugins/devices-third-party/dto/report-input-occurrence.dto.js.map +1 -0
  32. package/dist/plugins/devices-third-party/platforms/third-party-device.platform.js +19 -4
  33. package/dist/plugins/devices-third-party/platforms/third-party-device.platform.js.map +1 -1
  34. package/dist/plugins/devices-virtual/listeners/virtual-projection.listener.d.ts +2 -0
  35. package/dist/plugins/devices-virtual/listeners/virtual-projection.listener.js +57 -0
  36. package/dist/plugins/devices-virtual/listeners/virtual-projection.listener.js.map +1 -1
  37. package/dist/plugins/simulator/controllers/simulator.controller.d.ts +8 -2
  38. package/dist/plugins/simulator/controllers/simulator.controller.js +102 -2
  39. package/dist/plugins/simulator/controllers/simulator.controller.js.map +1 -1
  40. package/dist/plugins/simulator/dto/simulate-occurrence.dto.d.ts +12 -0
  41. package/dist/plugins/simulator/dto/simulate-occurrence.dto.js +122 -0
  42. package/dist/plugins/simulator/dto/simulate-occurrence.dto.js.map +1 -0
  43. package/dist/plugins/simulator/models/simulator-response.model.d.ts +12 -0
  44. package/dist/plugins/simulator/models/simulator-response.model.js +84 -1
  45. package/dist/plugins/simulator/models/simulator-response.model.js.map +1 -1
  46. package/dist/plugins/simulator/scenarios/definitions/small-apartment.yaml +78 -0
  47. package/dist/plugins/simulator/services/scenario-executor.service.js +3 -1
  48. package/dist/plugins/simulator/services/scenario-executor.service.js.map +1 -1
  49. package/dist/plugins/simulator/services/simulation.service.js +10 -1
  50. package/dist/plugins/simulator/services/simulation.service.js.map +1 -1
  51. package/dist/plugins/simulator/simulator.openapi.d.ts +3 -2
  52. package/dist/plugins/simulator/simulator.openapi.js +5 -0
  53. package/dist/plugins/simulator/simulator.openapi.js.map +1 -1
  54. package/dist/plugins/simulator/simulators/device-simulator.interface.d.ts +2 -0
  55. package/dist/plugins/simulator/simulators/device-simulator.interface.js +83 -0
  56. package/dist/plugins/simulator/simulators/device-simulator.interface.js.map +1 -1
  57. package/dist/plugins/simulator/simulators/index.d.ts +1 -0
  58. package/dist/plugins/simulator/simulators/index.js +1 -0
  59. package/dist/plugins/simulator/simulators/index.js.map +1 -1
  60. package/dist/plugins/simulator/simulators/input-controller.simulator.d.ts +10 -0
  61. package/dist/plugins/simulator/simulators/input-controller.simulator.js +57 -0
  62. package/dist/plugins/simulator/simulators/input-controller.simulator.js.map +1 -0
  63. package/dist/plugins/simulator/simulators/lighting.simulator.js +1 -0
  64. package/dist/plugins/simulator/simulators/lighting.simulator.js.map +1 -1
  65. package/dist/tsconfig.build.tsbuildinfo +1 -1
  66. package/package.json +2 -2
@@ -7,6 +7,328 @@ INSTALL_TYPE="${INSTALL_TYPE:-npm}"
7
7
  IMAGE_BASE_DIR="${IMAGE_BASE_DIR:-/opt/smart-panel}"
8
8
  DOWNLOAD_URL="${DOWNLOAD_URL:-}"
9
9
 
10
+ # The attempt record and lock live beside the public status file, never inside a versioned image
11
+ # directory. This lets the record survive a backend restart, target-directory cleanup or a
12
+ # failed migration, while keeping it outside the release tarball and private to the service user.
13
+ ATTEMPT_DIR="${ATTEMPT_DIR:-$(dirname "$STATUS_FILE")/update-attempt}"
14
+ ATTEMPT_FILE="${ATTEMPT_DIR}/attempt.json"
15
+ ATTEMPT_LOCK="${ATTEMPT_DIR}/lock"
16
+ ATTEMPT_ID=""
17
+ MIGRATION_ENTERED="false"
18
+ FINALIZED="false"
19
+ MIGRATION_LOG=""
20
+ CURRENT_PHASE="preparing"
21
+ SERVICE_NAME="smart-panel"
22
+ QUIESCENCE_TIMEOUT_SECONDS="${QUIESCENCE_TIMEOUT_SECONDS:-30}"
23
+ START_TIMEOUT_SECONDS="${START_TIMEOUT_SECONDS:-60}"
24
+ HEALTH_URL="${HEALTH_URL:-}"
25
+ CAPTURED_SERVICE_CGROUP_PROCS=""
26
+ CAPTURED_SERVICE_MEMBER_IDENTITIES=""
27
+
28
+ json_escape() {
29
+ printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/[[:cntrl:]]//g'
30
+ }
31
+
32
+ write_attempt() {
33
+ local state="$1"
34
+ local phase="$2"
35
+ local error="${3:-}"
36
+ local recovery_required="${4:-false}"
37
+ local migration_entered="${5:-$MIGRATION_ENTERED}"
38
+ local tmp_file="${ATTEMPT_FILE}.tmp.$$"
39
+ CURRENT_PHASE="$phase"
40
+
41
+ mkdir -p "$ATTEMPT_DIR"
42
+ chmod 700 "$ATTEMPT_DIR"
43
+
44
+ {
45
+ printf '{\n'
46
+ printf '\t"attemptId": "%s",\n' "$(json_escape "$ATTEMPT_ID")"
47
+ printf '\t"ownerPid": %s,\n' "$$"
48
+ printf '\t"targetVersion": "%s",\n' "$(json_escape "$VERSION")"
49
+ printf '\t"state": "%s",\n' "$(json_escape "$state")"
50
+ printf '\t"phase": "%s",\n' "$(json_escape "$phase")"
51
+ printf '\t"migrationEntered": %s,\n' "$migration_entered"
52
+ printf '\t"recoveryRequired": %s,\n' "$recovery_required"
53
+ printf '\t"migrationLog": "%s",\n' "$(json_escape "$MIGRATION_LOG")"
54
+ printf '\t"updatedAt": "%s",\n' "$(date -Iseconds)"
55
+ printf '\t"error": "%s"\n' "$(json_escape "$error")"
56
+ printf '}\n'
57
+ } > "$tmp_file"
58
+
59
+ chmod 600 "$tmp_file"
60
+ mv -f "$tmp_file" "$ATTEMPT_FILE"
61
+ }
62
+
63
+ attempt_is_unresolved() {
64
+ if [ -f "$ATTEMPT_FILE" ] && grep -q '"state": "complete"' "$ATTEMPT_FILE"; then
65
+ # A crash after the durable completion record but before rmdir must not strand a clean
66
+ # attempt forever; completion is the evidence that makes this lock removable.
67
+ rmdir "$ATTEMPT_LOCK" 2>/dev/null || true
68
+
69
+ return 1
70
+ fi
71
+
72
+ if [ -d "$ATTEMPT_LOCK" ]; then
73
+ return 0
74
+ fi
75
+
76
+ if [ ! -f "$ATTEMPT_FILE" ]; then
77
+ return 1
78
+ fi
79
+
80
+ grep -q '"state": "active"' "$ATTEMPT_FILE" || grep -q '"recoveryRequired": true' "$ATTEMPT_FILE"
81
+ }
82
+
83
+ acquire_attempt() {
84
+ mkdir -p "$ATTEMPT_DIR"
85
+ chmod 700 "$ATTEMPT_DIR"
86
+
87
+ if attempt_is_unresolved; then
88
+ return 1
89
+ fi
90
+
91
+ # mkdir is the portable atomic OS-level lock used by the legacy launcher. An orphaned lock is
92
+ # deliberately retained; an expired timestamp cannot prove that a migration did not commit.
93
+ if ! mkdir "$ATTEMPT_LOCK" 2>/dev/null; then
94
+ return 1
95
+ fi
96
+ chmod 700 "$ATTEMPT_LOCK"
97
+
98
+ ATTEMPT_ID="$(date +%s)-$$"
99
+ write_attempt "active" "preparing" "" "false" "false"
100
+ }
101
+
102
+ release_attempt() {
103
+ if [ -n "$ATTEMPT_ID" ]; then
104
+ write_attempt "complete" "complete" "" "false" "$MIGRATION_ENTERED"
105
+ fi
106
+
107
+ rmdir "$ATTEMPT_LOCK" 2>/dev/null || true
108
+ }
109
+
110
+ hold_attempt() {
111
+ local phase="$1"
112
+ local error="$2"
113
+
114
+ write_attempt "recovery_required" "$phase" "$error" "true" "$MIGRATION_ENTERED"
115
+ }
116
+
117
+ finish_before_migration() {
118
+ local phase="$1"
119
+ local error="$2"
120
+
121
+ if [ -n "${TMP_TARBALL:-}" ]; then
122
+ rm -f "$TMP_TARBALL" 2>/dev/null || true
123
+ fi
124
+
125
+ write_attempt "failed_preflight" "$phase" "$error" "false" "false"
126
+ rmdir "$ATTEMPT_LOCK" 2>/dev/null || true
127
+ }
128
+
129
+ phase_requires_hold() {
130
+ case "$CURRENT_PHASE" in
131
+ stopping|quiesced|switching) return 0 ;;
132
+ *) return 1 ;;
133
+ esac
134
+ }
135
+
136
+ fail_before_migration() {
137
+ local phase="$1"
138
+ local error="$2"
139
+
140
+ if [ -n "$ATTEMPT_ID" ]; then
141
+ finish_before_migration "$phase" "$error"
142
+ fi
143
+
144
+ update_status "failed" "failed" "$error"
145
+ FINALIZED="true"
146
+ exit 1
147
+ }
148
+
149
+ restore_before_migration() {
150
+ local previous_target="$1"
151
+ local current_link="$2"
152
+ local new_version_dir="$3"
153
+ local resolved_previous resolved_current
154
+
155
+ [ -n "$previous_target" ] || return 1
156
+ sudo -n ln -sfn "$previous_target" "$current_link" || return 1
157
+
158
+ resolved_previous="$(realpath "$previous_target" 2>/dev/null || true)"
159
+ resolved_current="$(realpath "$current_link" 2>/dev/null || true)"
160
+ [ -n "$resolved_previous" ] && [ "$resolved_previous" = "$resolved_current" ] || return 1
161
+
162
+ rm -rf "$new_version_dir" || return 1
163
+ sudo -n systemctl start "$SERVICE_NAME" 2>/dev/null || return 1
164
+ wait_for_started_service
165
+ }
166
+
167
+ service_property() {
168
+ local property="$1"
169
+
170
+ systemctl show "$SERVICE_NAME" --property="$property" --value
171
+ }
172
+
173
+ process_identity() {
174
+ local pid="$1"
175
+ local proc_root="${SERVICE_PROCESS_ROOT:-/proc}"
176
+ local proc_dir="${proc_root}/${pid}"
177
+ local stat_line start_time executable command
178
+
179
+ [ "$pid" != "0" ] && [ -n "$pid" ] || return 1
180
+
181
+ # SERVICE_PROCESS_ROOT is a private fixture hook; the production default is /proc.
182
+ if [ -r "${proc_dir}/identity" ]; then
183
+ printf '%s|%s\n' "$pid" "$(cat "${proc_dir}/identity")"
184
+ return 0
185
+ fi
186
+
187
+ if [ -r "${proc_dir}/stat" ]; then
188
+ stat_line="$(cat "${proc_dir}/stat")" || return 2
189
+ start_time="$(printf '%s\n' "$stat_line" | awk '{ sub(/^.*\\) /, ""); print $20 }')" || return 2
190
+ [ -n "$start_time" ] || return 2
191
+ executable="$(readlink "${proc_dir}/exe" 2>/dev/null)" || return 2
192
+ [ -n "$executable" ] || return 2
193
+ printf '%s|%s|%s\n' "$pid" "$start_time" "$executable"
194
+ return 0
195
+ fi
196
+
197
+ if ! kill -0 "$pid" 2>/dev/null; then
198
+ return 1
199
+ fi
200
+
201
+ command="$(ps -p "$pid" -o command= 2>/dev/null)" || return 2
202
+ [ -n "$command" ] || return 2
203
+ printf '%s|%s\n' "$pid" "$command"
204
+ }
205
+
206
+ capture_service_identity() {
207
+ local active_state main_pid control_pid control_group cgroup_procs members pid identity
208
+
209
+ active_state="$(service_property ActiveState 2>/dev/null)" || return 2
210
+ main_pid="$(service_property MainPID 2>/dev/null)" || return 2
211
+ control_pid="$(service_property ControlPID 2>/dev/null)" || return 2
212
+ control_group="$(service_property ControlGroup 2>/dev/null)" || return 2
213
+
214
+ case "$active_state" in
215
+ active|activating) ;;
216
+ *) return 1 ;;
217
+ esac
218
+
219
+ [ "$main_pid" -gt 0 ] 2>/dev/null || return 1
220
+ [ "$control_pid" = "0" ] || [ "$control_pid" -gt 0 ] 2>/dev/null || return 2
221
+ [ -n "$control_group" ] || return 2
222
+
223
+ cgroup_procs="${SERVICE_CGROUP_PROCS_FILE:-/sys/fs/cgroup${control_group}/cgroup.procs}"
224
+ [ -r "$cgroup_procs" ] || return 2
225
+ members="$(grep -E '^[0-9]+$' "$cgroup_procs" 2>/dev/null)" || true
226
+ [ -n "$members" ] || return 1
227
+
228
+ CAPTURED_SERVICE_MEMBER_IDENTITIES=""
229
+ while IFS= read -r pid; do
230
+ [ -n "$pid" ] || continue
231
+ identity="$(process_identity "$pid")" || return 2
232
+ CAPTURED_SERVICE_MEMBER_IDENTITIES="${CAPTURED_SERVICE_MEMBER_IDENTITIES}${identity}\n"
233
+ done <<EOF
234
+ $members
235
+ EOF
236
+
237
+ printf '%s\n' "$members" | grep -qx "$main_pid" || return 1
238
+ CAPTURED_SERVICE_CGROUP_PROCS="$cgroup_procs"
239
+ }
240
+
241
+ service_quiesced() {
242
+ local active_state main_pid control_pid cgroup_contents current_members pid captured_identity current_identity identity_status
243
+
244
+ [ -n "$CAPTURED_SERVICE_CGROUP_PROCS" ] || return 2
245
+ active_state="$(service_property ActiveState 2>/dev/null)" || return 2
246
+ main_pid="$(service_property MainPID 2>/dev/null)" || return 2
247
+ control_pid="$(service_property ControlPID 2>/dev/null)" || return 2
248
+
249
+ case "$active_state" in
250
+ inactive|failed) ;;
251
+ *) return 1 ;;
252
+ esac
253
+
254
+ [ "$main_pid" = "0" ] || return 1
255
+ [ "$control_pid" = "0" ] || return 1
256
+
257
+ # Check every member captured before stop independently. This catches KillMode=process
258
+ # survivors even when systemd has already removed the original cgroup directory.
259
+ while IFS= read -r captured_identity; do
260
+ [ -n "$captured_identity" ] || continue
261
+ pid="${captured_identity%%|*}"
262
+ current_identity=""
263
+ if current_identity="$(process_identity "$pid")"; then
264
+ identity_status=0
265
+ else
266
+ identity_status=$?
267
+ fi
268
+ case "$identity_status" in
269
+ 0) [ "$current_identity" != "$captured_identity" ] || return 1 ;;
270
+ 1) ;;
271
+ *) return 2 ;;
272
+ esac
273
+ done <<EOF
274
+ $(printf '%b' "$CAPTURED_SERVICE_MEMBER_IDENTITIES")
275
+ EOF
276
+
277
+ # The original cgroup may be empty or removed after the service reaches inactive/failed with
278
+ # zero MainPID/ControlPID. If it still exists, it must be readable and contain no members.
279
+ if [ -e "$CAPTURED_SERVICE_CGROUP_PROCS" ]; then
280
+ [ -r "$CAPTURED_SERVICE_CGROUP_PROCS" ] || return 2
281
+ if ! cgroup_contents="$(cat "$CAPTURED_SERVICE_CGROUP_PROCS" 2>/dev/null)"; then
282
+ [ ! -e "$CAPTURED_SERVICE_CGROUP_PROCS" ] || return 2
283
+ cgroup_contents=""
284
+ fi
285
+ current_members="$(printf '%s\n' "$cgroup_contents" | grep -E '^[0-9]+$')" || true
286
+ [ -z "$current_members" ] || return 1
287
+ fi
288
+
289
+ return 0
290
+ }
291
+
292
+ wait_for_quiescence() {
293
+ local deadline=$((SECONDS + QUIESCENCE_TIMEOUT_SECONDS))
294
+ local result
295
+
296
+ while [ "$SECONDS" -le "$deadline" ]; do
297
+ if service_quiesced; then
298
+ return 0
299
+ else
300
+ result=$?
301
+ fi
302
+ if [ "$result" -eq 2 ]; then
303
+ return 2
304
+ fi
305
+
306
+ sleep 1
307
+ done
308
+
309
+ return 1
310
+ }
311
+
312
+ wait_for_started_service() {
313
+ local deadline=$((SECONDS + START_TIMEOUT_SECONDS))
314
+ local state main_pid
315
+
316
+ while [ "$SECONDS" -le "$deadline" ]; do
317
+ state="$(service_property ActiveState 2>/dev/null || true)"
318
+ main_pid="$(service_property MainPID 2>/dev/null || true)"
319
+
320
+ if [ "$state" = "active" ] && [ "$main_pid" != "0" ] && [ -n "$main_pid" ]; then
321
+ if [ -z "$HEALTH_URL" ] || curl -fsS --max-time 5 "$HEALTH_URL" >/dev/null 2>&1; then
322
+ return 0
323
+ fi
324
+ fi
325
+
326
+ sleep 1
327
+ done
328
+
329
+ return 1
330
+ }
331
+
10
332
  # Capture once — used for all status writes so timeout detection works correctly
11
333
  STARTED_AT="$(date -Iseconds)"
12
334
 
@@ -45,7 +367,13 @@ update_status() {
45
367
  cleanup() {
46
368
  local exit_code=$?
47
369
 
48
- if [ $exit_code -ne 0 ]; then
370
+ if [ "$exit_code" -ne 0 ] && [ "$FINALIZED" != "true" ]; then
371
+ if [ -n "$ATTEMPT_ID" ] && { [ "$MIGRATION_ENTERED" = "true" ] || phase_requires_hold; }; then
372
+ hold_attempt "interrupted" "Update worker exited with code $exit_code during a state-changing phase"
373
+ elif [ -n "$ATTEMPT_ID" ]; then
374
+ finish_before_migration "failed" "Update process exited with code $exit_code"
375
+ fi
376
+
49
377
  # Only write a generic failure if a specific error wasn't already recorded
50
378
  if [ -f "$STATUS_FILE" ] && grep -q '"status": "failed"' "$STATUS_FILE" 2>/dev/null; then
51
379
  return
@@ -56,6 +384,27 @@ cleanup() {
56
384
 
57
385
  trap cleanup EXIT
58
386
 
387
+ handle_signal() {
388
+ local signal_name="$1"
389
+ local signal_code="$2"
390
+
391
+ if [ -n "$ATTEMPT_ID" ]; then
392
+ if [ "$MIGRATION_ENTERED" = "true" ] || phase_requires_hold; then
393
+ hold_attempt "interrupted" "Update worker received SIG${signal_name} after migration entry"
394
+ else
395
+ finish_before_migration "interrupted" "Update worker received SIG${signal_name} before migration entry"
396
+ fi
397
+ fi
398
+
399
+ update_status "failed" "failed" "Update worker received SIG${signal_name}"
400
+ FINALIZED="true"
401
+ exit "$signal_code"
402
+ }
403
+
404
+ trap 'handle_signal TERM 143' TERM
405
+ trap 'handle_signal INT 130' INT
406
+ trap 'handle_signal HUP 129' HUP
407
+
59
408
  # ──────────────────────────────────────────────────────────────
60
409
  # Image-based update (Raspbian image installs)
61
410
  # ──────────────────────────────────────────────────────────────
@@ -65,6 +414,12 @@ if [ "$INSTALL_TYPE" = "image" ]; then
65
414
  NEW_VERSION_DIR="${IMAGE_BASE_DIR}/v${CLEAN_VERSION}"
66
415
  CURRENT_LINK="${IMAGE_BASE_DIR}/current"
67
416
 
417
+ if ! acquire_attempt; then
418
+ update_status "failed" "failed" "An unresolved update attempt already owns the updater"
419
+ FINALIZED="true"
420
+ exit 1
421
+ fi
422
+
68
423
  # Verify passwordless sudo is available for all required commands.
69
424
  # Without -n, sudo may hang waiting for a password on a detached process.
70
425
  SUDO_CMDS=(
@@ -77,8 +432,7 @@ if [ "$INSTALL_TYPE" = "image" ]; then
77
432
 
78
433
  for cmd in "${SUDO_CMDS[@]}"; do
79
434
  if ! sudo -n -l $cmd >/dev/null 2>&1; then
80
- update_status "failed" "failed" "Passwordless sudo not available for: $cmd"
81
- exit 1
435
+ fail_before_migration "preflight" "Passwordless sudo not available for: $cmd"
82
436
  fi
83
437
  done
84
438
  PREVIOUS_TARGET=""
@@ -86,6 +440,10 @@ if [ "$INSTALL_TYPE" = "image" ]; then
86
440
  # Save the current version for rollback
87
441
  if [ -L "$CURRENT_LINK" ]; then
88
442
  PREVIOUS_TARGET=$(readlink "$CURRENT_LINK")
443
+ case "$PREVIOUS_TARGET" in
444
+ /*) ;;
445
+ *) PREVIOUS_TARGET="${IMAGE_BASE_DIR}/${PREVIOUS_TARGET}" ;;
446
+ esac
89
447
  fi
90
448
 
91
449
  # Guard: refuse to overwrite the currently running version
@@ -94,23 +452,20 @@ if [ "$INSTALL_TYPE" = "image" ]; then
94
452
  RESOLVED_NEW=$(cd "$IMAGE_BASE_DIR" && realpath "$NEW_VERSION_DIR" 2>/dev/null || true)
95
453
 
96
454
  if [ -n "$PREVIOUS_TARGET" ] && [ -n "$RESOLVED_PREV" ] && [ -n "$RESOLVED_NEW" ] && [ "$RESOLVED_PREV" = "$RESOLVED_NEW" ]; then
97
- update_status "failed" "failed" "Target version v${CLEAN_VERSION} is already the active version"
98
- exit 1
455
+ fail_before_migration "preflight" "Target version v${CLEAN_VERSION} is already the active version"
99
456
  fi
100
457
 
101
458
  # ── Download ──
102
459
  update_status "downloading" "downloading"
103
460
 
104
461
  if [ -z "$DOWNLOAD_URL" ]; then
105
- update_status "failed" "failed" "No download URL provided for image update"
106
- exit 1
462
+ fail_before_migration "downloading" "No download URL provided for image update"
107
463
  fi
108
464
 
109
465
  TMP_TARBALL="/tmp/smart-panel-backend-v${CLEAN_VERSION}.tar.gz"
110
466
 
111
467
  curl -fSL -o "$TMP_TARBALL" "$DOWNLOAD_URL" 2>&1 || {
112
- update_status "failed" "failed" "Download failed from ${DOWNLOAD_URL}"
113
- exit 1
468
+ fail_before_migration "downloading" "Download failed from ${DOWNLOAD_URL}"
114
469
  }
115
470
 
116
471
  # ── Extract ──
@@ -121,8 +476,7 @@ if [ "$INSTALL_TYPE" = "image" ]; then
121
476
  tar -xzf "$TMP_TARBALL" -C "$NEW_VERSION_DIR" 2>&1 || {
122
477
  rm -rf "$NEW_VERSION_DIR"
123
478
  rm -f "$TMP_TARBALL"
124
- update_status "failed" "failed" "Failed to extract update archive"
125
- exit 1
479
+ fail_before_migration "installing" "Failed to extract update archive"
126
480
  }
127
481
 
128
482
  rm -f "$TMP_TARBALL"
@@ -130,8 +484,7 @@ if [ "$INSTALL_TYPE" = "image" ]; then
130
484
  # Create the image-install marker in the new version
131
485
  touch "${NEW_VERSION_DIR}/.image-install" || {
132
486
  rm -rf "$NEW_VERSION_DIR"
133
- update_status "failed" "failed" "Failed to create image-install marker"
134
- exit 1
487
+ fail_before_migration "installing" "Failed to create image-install marker"
135
488
  }
136
489
 
137
490
  # ── Verify dependencies ──
@@ -143,8 +496,7 @@ if [ "$INSTALL_TYPE" = "image" ]; then
143
496
  if [ ! -d "node_modules" ]; then
144
497
  npm install --omit=dev 2>&1 || {
145
498
  rm -rf "$NEW_VERSION_DIR"
146
- update_status "failed" "failed" "npm install failed"
147
- exit 1
499
+ fail_before_migration "installing" "npm install failed"
148
500
  }
149
501
  fi
150
502
 
@@ -153,72 +505,122 @@ if [ "$INSTALL_TYPE" = "image" ]; then
153
505
  # hanging on password prompt when running as a detached process.
154
506
  sudo -n chown -R smart-panel:smart-panel "$NEW_VERSION_DIR" || {
155
507
  rm -rf "$NEW_VERSION_DIR"
156
- update_status "failed" "failed" "Failed to set ownership on ${NEW_VERSION_DIR}"
157
- exit 1
508
+ fail_before_migration "installing" "Failed to set ownership on ${NEW_VERSION_DIR}"
158
509
  }
159
510
 
160
511
  # ── Stop service ──
161
512
  update_status "stopping" "stopping"
162
- sudo -n systemctl stop smart-panel 2>/dev/null || true
513
+ write_attempt "active" "stopping" "" "false" "$MIGRATION_ENTERED"
514
+ if ! capture_service_identity; then
515
+ hold_attempt "stopping" "Could not capture service process/cgroup identity before stop"
516
+ update_status "failed" "failed" "Service process/cgroup identity could not be captured; update is held for recovery"
517
+ FINALIZED="true"
518
+ exit 1
519
+ fi
520
+
521
+ if ! sudo -n systemctl stop "$SERVICE_NAME" 2>/dev/null; then
522
+ hold_attempt "stopping" "Service stop failed; writer quiescence is unknown"
523
+ update_status "failed" "failed" "Could not stop ${SERVICE_NAME}; update is held for recovery"
524
+ FINALIZED="true"
525
+ exit 1
526
+ fi
527
+
528
+ if ! wait_for_quiescence; then
529
+ hold_attempt "stopping" "Service did not reach verified quiescence before migration"
530
+ update_status "failed" "failed" "Service quiescence could not be verified; update is held for recovery"
531
+ FINALIZED="true"
532
+ exit 1
533
+ fi
534
+
535
+ write_attempt "active" "quiesced" "" "false" "$MIGRATION_ENTERED"
163
536
 
164
537
  # ── Switch symlink (atomic on same filesystem) ──
165
538
  sudo -n ln -sfn "$NEW_VERSION_DIR" "$CURRENT_LINK" || {
166
- # Revert on failure
167
- if [ -n "$PREVIOUS_TARGET" ]; then
168
- sudo -n ln -sfn "$PREVIOUS_TARGET" "$CURRENT_LINK"
539
+ # Revert only when the old link, target cleanup and old-service health are all verified.
540
+ if restore_before_migration "$PREVIOUS_TARGET" "$CURRENT_LINK" "$NEW_VERSION_DIR"; then
541
+ fail_before_migration "switching" "Failed to switch version symlink"
169
542
  fi
170
- sudo -n systemctl start smart-panel 2>/dev/null || true
171
- update_status "failed" "failed" "Failed to switch version symlink"
543
+
544
+ hold_attempt "switching" "Failed to switch version symlink and recovery start was not verified"
545
+ update_status "failed" "failed" "Failed to switch version symlink; update is held for recovery"
546
+ FINALIZED="true"
172
547
  exit 1
173
548
  }
174
549
 
175
550
  # ── Run database migrations ──
176
551
  update_status "migrating" "migrating"
177
-
178
552
  ENV_FILE="/etc/smart-panel/environment"
179
553
 
180
- if [ -f "${NEW_VERSION_DIR}/dist/dataSource.js" ]; then
181
- (
182
- set -a
183
- # shellcheck source=/dev/null
184
- [ -f "$ENV_FILE" ] && . "$ENV_FILE"
185
- set +a
186
- cd "$NEW_VERSION_DIR"
187
- node node_modules/typeorm/cli.js migration:run -d dist/dataSource.js
188
- ) 2>&1 || {
189
- # Migration failed — revert symlink if possible
190
- update_status "stopping" "stopping"
191
- sudo -n systemctl stop smart-panel 2>/dev/null || true
192
-
193
- if [ -n "$PREVIOUS_TARGET" ]; then
194
- if sudo -n ln -sfn "$PREVIOUS_TARGET" "$CURRENT_LINK"; then
195
- rm -rf "$NEW_VERSION_DIR"
196
- update_status "failed" "failed" "Database migration failed — reverted to previous version"
197
- else
198
- # Symlink revert failed — keep new version so system stays bootable
199
- update_status "failed" "failed" "Database migration failed — symlink revert also failed"
200
- fi
201
- else
202
- # No previous version — keep current in place so system stays bootable
203
- update_status "failed" "failed" "Database migration failed — no previous version to revert to"
204
- fi
554
+ if [ ! -f "${NEW_VERSION_DIR}/dist/dataSource.js" ]; then
555
+ hold_attempt "migrating" "Target data source is missing; update is held for recovery"
556
+ update_status "failed" "failed" "Target data source is missing; update is held for recovery"
557
+ FINALIZED="true"
558
+ exit 1
559
+ fi
205
560
 
206
- sudo -n systemctl start smart-panel 2>/dev/null || true
207
- exit 1
561
+ MIGRATION_ENTERED="true"
562
+ if ! write_attempt "active" "migrating" "" "false" "true"; then
563
+ hold_attempt "migrating" "Could not persist migration-entry checkpoint"
564
+ update_status "failed" "failed" "Could not persist migration-entry checkpoint; update is held for recovery"
565
+ FINALIZED="true"
566
+ exit 1
567
+ fi
568
+
569
+ MIGRATION_LOG="${ATTEMPT_DIR}/${ATTEMPT_ID}.migration.log"
570
+ : > "$MIGRATION_LOG"
571
+ chmod 600 "$MIGRATION_LOG"
572
+ write_attempt "active" "migrating" "" "false" "true"
573
+
574
+ set +e
575
+ (
576
+ set -a
577
+ # shellcheck source=/dev/null
578
+ [ -f "$ENV_FILE" ] && . "$ENV_FILE"
579
+ set +a
580
+ cd "$NEW_VERSION_DIR"
581
+ node node_modules/typeorm/cli.js migration:run -d dist/dataSource.js
582
+ ) 2>&1 | awk -v output="$MIGRATION_LOG" '
583
+ BEGIN { limit = 65536; bytes = 0 }
584
+ {
585
+ if (bytes < limit) {
586
+ line = $0 ORS;
587
+ remaining = limit - bytes;
588
+ if (length(line) > remaining) line = substr(line, 1, remaining);
589
+ printf "%s", line >> output;
590
+ bytes += length(line);
591
+ }
208
592
  }
593
+ '
594
+ pipeline_status=("${PIPESTATUS[@]}")
595
+ migration_exit_code="${pipeline_status[0]}"
596
+ capture_exit_code="${pipeline_status[1]}"
597
+ set -e
598
+
599
+ if [ "$capture_exit_code" -ne 0 ]; then
600
+ hold_attempt "migration_capture_failed" "Could not retain bounded migration diagnostics"
601
+ update_status "failed" "failed" "Migration diagnostics could not be retained; update is held for recovery"
602
+ FINALIZED="true"
603
+ exit 1
604
+ fi
605
+
606
+ if [ "$migration_exit_code" -eq 0 ]; then
607
+ write_attempt "active" "migrated" "" "false" "true"
608
+ else
609
+ hold_attempt "migration_failed" "Database migration failed with exit code ${migration_exit_code}"
610
+ update_status "failed" "failed" "Database migration failed; update is held for recovery"
611
+ FINALIZED="true"
612
+ exit "$migration_exit_code"
209
613
  fi
210
614
 
211
615
  # ── Start service ──
212
616
  update_status "starting" "starting"
213
- sudo -n systemctl start smart-panel 2>&1 || {
214
- # Try to revert if start fails
215
- if [ -n "$PREVIOUS_TARGET" ]; then
216
- sudo -n ln -sfn "$PREVIOUS_TARGET" "$CURRENT_LINK"
217
- sudo -n systemctl start smart-panel 2>/dev/null || true
218
- fi
219
- update_status "failed" "failed" "Failed to start service after update"
617
+ write_attempt "active" "starting" "" "false" "true"
618
+ if ! sudo -n systemctl start "$SERVICE_NAME" 2>&1 || ! wait_for_started_service; then
619
+ hold_attempt "start_failed" "Target service did not reach verified health after migration"
620
+ update_status "failed" "failed" "Target service failed to start or become healthy; update is held for recovery"
621
+ FINALIZED="true"
220
622
  exit 1
221
- }
623
+ fi
222
624
 
223
625
  # ── Cleanup old versions (keep max 2 previous) ──
224
626
  cd "$IMAGE_BASE_DIR"
@@ -239,7 +641,9 @@ if [ "$INSTALL_TYPE" = "image" ]; then
239
641
  fi
240
642
 
241
643
  # ── Mark complete ──
644
+ release_attempt
242
645
  update_status "complete" "complete"
646
+ FINALIZED="true"
243
647
  trap - EXIT
244
648
  exit 0
245
649
  fi
@@ -19,7 +19,16 @@ export declare class UpdateExecutorService implements OnModuleInit {
19
19
  private progressHighWaterMark;
20
20
  constructor(updateService: UpdateService, privilegedWorker: PrivilegedWorkerService, notifications: NotificationsService);
21
21
  onModuleInit(): Promise<void>;
22
+ private readAttempt;
23
+ private isAttemptUnresolved;
24
+ private isAttemptOwnerAlive;
25
+ private readStatusFile;
26
+ private waitForAttemptSettlement;
27
+ private reconcileCompletedAttempt;
28
+ private reconcileLiveAttempt;
22
29
  private checkPendingUpdateStatus;
30
+ private processPendingStatus;
31
+ private cleanupPendingStatus;
23
32
  startUpdate(targetVersion: string): Promise<void>;
24
33
  private watchUpdateJob;
25
34
  private writeStatusFile;