@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.
- package/dist/modules/devices/utils/schema.utils.js +1 -1
- package/dist/modules/devices/utils/schema.utils.js.map +1 -1
- package/dist/modules/system/scripts/update-worker.sh +463 -59
- package/dist/modules/system/services/update-executor.service.d.ts +9 -0
- package/dist/modules/system/services/update-executor.service.js +170 -25
- package/dist/modules/system/services/update-executor.service.js.map +1 -1
- package/dist/plugins/devices-homekit/listeners/homekit-event.listener.d.ts +2 -0
- package/dist/plugins/devices-homekit/listeners/homekit-event.listener.js +22 -0
- package/dist/plugins/devices-homekit/listeners/homekit-event.listener.js.map +1 -1
- package/dist/plugins/devices-homekit/mappers/button.mapper.d.ts +15 -0
- package/dist/plugins/devices-homekit/mappers/button.mapper.js +143 -0
- package/dist/plugins/devices-homekit/mappers/button.mapper.js.map +1 -0
- package/dist/plugins/devices-homekit/mappers/homekit-mapper.interface.d.ts +8 -0
- package/dist/plugins/devices-homekit/services/homekit-mapper-registry.service.d.ts +5 -1
- package/dist/plugins/devices-homekit/services/homekit-mapper-registry.service.js +42 -0
- package/dist/plugins/devices-homekit/services/homekit-mapper-registry.service.js.map +1 -1
- package/dist/plugins/devices-reterminal/services/reterminal-button.service.d.ts +7 -3
- package/dist/plugins/devices-reterminal/services/reterminal-button.service.js +63 -17
- package/dist/plugins/devices-reterminal/services/reterminal-button.service.js.map +1 -1
- package/dist/plugins/devices-third-party/controllers/third-party-inputs.controller.d.ts +17 -0
- package/dist/plugins/devices-third-party/controllers/third-party-inputs.controller.js +205 -0
- package/dist/plugins/devices-third-party/controllers/third-party-inputs.controller.js.map +1 -0
- package/dist/plugins/devices-third-party/devices-third-party.openapi.d.ts +1 -1
- package/dist/plugins/devices-third-party/devices-third-party.plugin.js +2 -1
- package/dist/plugins/devices-third-party/devices-third-party.plugin.js.map +1 -1
- package/dist/plugins/devices-third-party/dto/report-input-occurrence-response.dto.d.ts +5 -0
- package/dist/plugins/devices-third-party/dto/report-input-occurrence-response.dto.js +50 -0
- package/dist/plugins/devices-third-party/dto/report-input-occurrence-response.dto.js.map +1 -0
- package/dist/plugins/devices-third-party/dto/report-input-occurrence.dto.d.ts +8 -0
- package/dist/plugins/devices-third-party/dto/report-input-occurrence.dto.js +81 -0
- package/dist/plugins/devices-third-party/dto/report-input-occurrence.dto.js.map +1 -0
- package/dist/plugins/devices-third-party/platforms/third-party-device.platform.js +19 -4
- package/dist/plugins/devices-third-party/platforms/third-party-device.platform.js.map +1 -1
- package/dist/plugins/devices-virtual/listeners/virtual-projection.listener.d.ts +2 -0
- package/dist/plugins/devices-virtual/listeners/virtual-projection.listener.js +57 -0
- package/dist/plugins/devices-virtual/listeners/virtual-projection.listener.js.map +1 -1
- package/dist/plugins/simulator/controllers/simulator.controller.d.ts +8 -2
- package/dist/plugins/simulator/controllers/simulator.controller.js +102 -2
- package/dist/plugins/simulator/controllers/simulator.controller.js.map +1 -1
- package/dist/plugins/simulator/dto/simulate-occurrence.dto.d.ts +12 -0
- package/dist/plugins/simulator/dto/simulate-occurrence.dto.js +122 -0
- package/dist/plugins/simulator/dto/simulate-occurrence.dto.js.map +1 -0
- package/dist/plugins/simulator/models/simulator-response.model.d.ts +12 -0
- package/dist/plugins/simulator/models/simulator-response.model.js +84 -1
- package/dist/plugins/simulator/models/simulator-response.model.js.map +1 -1
- package/dist/plugins/simulator/scenarios/definitions/small-apartment.yaml +78 -0
- package/dist/plugins/simulator/services/scenario-executor.service.js +3 -1
- package/dist/plugins/simulator/services/scenario-executor.service.js.map +1 -1
- package/dist/plugins/simulator/services/simulation.service.js +10 -1
- package/dist/plugins/simulator/services/simulation.service.js.map +1 -1
- package/dist/plugins/simulator/simulator.openapi.d.ts +3 -2
- package/dist/plugins/simulator/simulator.openapi.js +5 -0
- package/dist/plugins/simulator/simulator.openapi.js.map +1 -1
- package/dist/plugins/simulator/simulators/device-simulator.interface.d.ts +2 -0
- package/dist/plugins/simulator/simulators/device-simulator.interface.js +83 -0
- package/dist/plugins/simulator/simulators/device-simulator.interface.js.map +1 -1
- package/dist/plugins/simulator/simulators/index.d.ts +1 -0
- package/dist/plugins/simulator/simulators/index.js +1 -0
- package/dist/plugins/simulator/simulators/index.js.map +1 -1
- package/dist/plugins/simulator/simulators/input-controller.simulator.d.ts +10 -0
- package/dist/plugins/simulator/simulators/input-controller.simulator.js +57 -0
- package/dist/plugins/simulator/simulators/input-controller.simulator.js.map +1 -0
- package/dist/plugins/simulator/simulators/lighting.simulator.js +1 -0
- package/dist/plugins/simulator/simulators/lighting.simulator.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
167
|
-
if
|
|
168
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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;
|