@fastybird/smart-panel-backend 1.1.0-alpha.3 → 1.1.0-alpha.30

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 (412) hide show
  1. package/dist/app.module.js +8 -0
  2. package/dist/app.module.js.map +1 -1
  3. package/dist/common/filters/conflict-exception.filter.d.ts +6 -0
  4. package/dist/common/filters/conflict-exception.filter.js +59 -0
  5. package/dist/common/filters/conflict-exception.filter.js.map +1 -0
  6. package/dist/common/filters/global-error.filter.js +1 -4
  7. package/dist/common/filters/global-error.filter.js.map +1 -1
  8. package/dist/common/filters/global-filters.d.ts +3 -0
  9. package/dist/common/filters/global-filters.js +22 -0
  10. package/dist/common/filters/global-filters.js.map +1 -0
  11. package/dist/common/filters/unprocessable-entity-exception.filter.d.ts +1 -0
  12. package/dist/common/filters/unprocessable-entity-exception.filter.js +15 -3
  13. package/dist/common/filters/unprocessable-entity-exception.filter.js.map +1 -1
  14. package/dist/dataSource.js +1 -0
  15. package/dist/dataSource.js.map +1 -1
  16. package/dist/main.js +2 -7
  17. package/dist/main.js.map +1 -1
  18. package/dist/migrations/1000000000026-AddHardwareInputCategories.d.ts +7 -0
  19. package/dist/migrations/1000000000026-AddHardwareInputCategories.js +364 -0
  20. package/dist/migrations/1000000000026-AddHardwareInputCategories.js.map +1 -0
  21. package/dist/modules/devices/controllers/channels.controller.d.ts +5 -1
  22. package/dist/modules/devices/controllers/channels.controller.js +34 -2
  23. package/dist/modules/devices/controllers/channels.controller.js.map +1 -1
  24. package/dist/modules/devices/controllers/channels.properties.controller.js +21 -4
  25. package/dist/modules/devices/controllers/channels.properties.controller.js.map +1 -1
  26. package/dist/modules/devices/controllers/devices.channels.controller.d.ts +5 -1
  27. package/dist/modules/devices/controllers/devices.channels.controller.js +38 -2
  28. package/dist/modules/devices/controllers/devices.channels.controller.js.map +1 -1
  29. package/dist/modules/devices/controllers/devices.channels.properties.controller.js +21 -4
  30. package/dist/modules/devices/controllers/devices.channels.properties.controller.js.map +1 -1
  31. package/dist/modules/devices/devices.constants.d.ts +6 -0
  32. package/dist/modules/devices/devices.constants.js +6 -0
  33. package/dist/modules/devices/devices.constants.js.map +1 -1
  34. package/dist/modules/devices/devices.module.js +20 -0
  35. package/dist/modules/devices/devices.module.js.map +1 -1
  36. package/dist/modules/devices/devices.openapi.d.ts +3 -1
  37. package/dist/modules/devices/devices.openapi.js +7 -0
  38. package/dist/modules/devices/devices.openapi.js.map +1 -1
  39. package/dist/modules/devices/dto/channel-input-occurrence.dto.d.ts +10 -0
  40. package/dist/modules/devices/dto/channel-input-occurrence.dto.js +99 -0
  41. package/dist/modules/devices/dto/channel-input-occurrence.dto.js.map +1 -0
  42. package/dist/modules/devices/models/channel-input-capabilities.model.d.ts +20 -0
  43. package/dist/modules/devices/models/channel-input-capabilities.model.js +111 -0
  44. package/dist/modules/devices/models/channel-input-capabilities.model.js.map +1 -0
  45. package/dist/modules/devices/models/channel-input-occurrence.model.d.ts +22 -0
  46. package/dist/modules/devices/models/channel-input-occurrence.model.js +154 -0
  47. package/dist/modules/devices/models/channel-input-occurrence.model.js.map +1 -0
  48. package/dist/modules/devices/services/channel-input-deduplication.service.d.ts +17 -0
  49. package/dist/modules/devices/services/channel-input-deduplication.service.js +88 -0
  50. package/dist/modules/devices/services/channel-input-deduplication.service.js.map +1 -0
  51. package/dist/modules/devices/services/channel-input-occurrences.service.d.ts +21 -0
  52. package/dist/modules/devices/services/channel-input-occurrences.service.js +163 -0
  53. package/dist/modules/devices/services/channel-input-occurrences.service.js.map +1 -0
  54. package/dist/modules/devices/services/channels.properties.service.d.ts +31 -3
  55. package/dist/modules/devices/services/channels.properties.service.js +346 -51
  56. package/dist/modules/devices/services/channels.properties.service.js.map +1 -1
  57. package/dist/modules/devices/services/channels.service.js +42 -38
  58. package/dist/modules/devices/services/channels.service.js.map +1 -1
  59. package/dist/modules/devices/services/command-latency-trace-collector.service.d.ts +101 -0
  60. package/dist/modules/devices/services/command-latency-trace-collector.service.js +451 -0
  61. package/dist/modules/devices/services/command-latency-trace-collector.service.js.map +1 -0
  62. package/dist/modules/devices/services/device-structure-lock.service.d.ts +10 -2
  63. package/dist/modules/devices/services/device-structure-lock.service.js +86 -7
  64. package/dist/modules/devices/services/device-structure-lock.service.js.map +1 -1
  65. package/dist/modules/devices/services/devices.service.js +37 -33
  66. package/dist/modules/devices/services/devices.service.js.map +1 -1
  67. package/dist/modules/devices/services/module-reset.service.d.ts +3 -1
  68. package/dist/modules/devices/services/module-reset.service.js +21 -16
  69. package/dist/modules/devices/services/module-reset.service.js.map +1 -1
  70. package/dist/modules/devices/services/property-command-dispatch.service.d.ts +45 -0
  71. package/dist/modules/devices/services/property-command-dispatch.service.js +227 -0
  72. package/dist/modules/devices/services/property-command-dispatch.service.js.map +1 -0
  73. package/dist/modules/devices/services/property-command-window.service.d.ts +71 -0
  74. package/dist/modules/devices/services/property-command-window.service.js +206 -0
  75. package/dist/modules/devices/services/property-command-window.service.js.map +1 -0
  76. package/dist/modules/devices/services/property-command.service.d.ts +20 -3
  77. package/dist/modules/devices/services/property-command.service.js +56 -10
  78. package/dist/modules/devices/services/property-command.service.js.map +1 -1
  79. package/dist/modules/devices/services/property-metadata.service.d.ts +31 -0
  80. package/dist/modules/devices/services/property-metadata.service.js +160 -0
  81. package/dist/modules/devices/services/property-metadata.service.js.map +1 -0
  82. package/dist/modules/devices/services/property-state-coordinator.service.d.ts +4 -0
  83. package/dist/modules/devices/services/property-state-coordinator.service.js +35 -0
  84. package/dist/modules/devices/services/property-state-coordinator.service.js.map +1 -0
  85. package/dist/modules/devices/services/property-value-lock.service.d.ts +3 -1
  86. package/dist/modules/devices/services/property-value-lock.service.js +13 -2
  87. package/dist/modules/devices/services/property-value-lock.service.js.map +1 -1
  88. package/dist/modules/devices/services/property-value.service.d.ts +1 -0
  89. package/dist/modules/devices/services/property-value.service.js +4 -0
  90. package/dist/modules/devices/services/property-value.service.js.map +1 -1
  91. package/dist/modules/devices/utils/schema.utils.js +1 -1
  92. package/dist/modules/devices/utils/schema.utils.js.map +1 -1
  93. package/dist/modules/platform/platforms/raspberry.platform.js +5 -10
  94. package/dist/modules/platform/platforms/raspberry.platform.js.map +1 -1
  95. package/dist/modules/platform/services/platform.service.d.ts +13 -4
  96. package/dist/modules/platform/services/platform.service.js +57 -35
  97. package/dist/modules/platform/services/platform.service.js.map +1 -1
  98. package/dist/modules/remote-access/dto/update-config.dto.js +5 -0
  99. package/dist/modules/remote-access/dto/update-config.dto.js.map +1 -1
  100. package/dist/modules/remote-access/validators/is-remote-access-url.validator.js +5 -2
  101. package/dist/modules/remote-access/validators/is-remote-access-url.validator.js.map +1 -1
  102. package/dist/modules/system/scripts/update-worker.sh +1168 -61
  103. package/dist/modules/system/services/privileged-worker.service.d.ts +13 -0
  104. package/dist/modules/system/services/privileged-worker.service.js +192 -31
  105. package/dist/modules/system/services/privileged-worker.service.js.map +1 -1
  106. package/dist/modules/system/services/system.service.d.ts +2 -0
  107. package/dist/modules/system/services/system.service.js +22 -1
  108. package/dist/modules/system/services/system.service.js.map +1 -1
  109. package/dist/modules/system/services/update-executor.service.d.ts +20 -2
  110. package/dist/modules/system/services/update-executor.service.js +272 -26
  111. package/dist/modules/system/services/update-executor.service.js.map +1 -1
  112. package/dist/modules/websocket/gateway/websocket.gateway.d.ts +4 -1
  113. package/dist/modules/websocket/gateway/websocket.gateway.js +28 -5
  114. package/dist/modules/websocket/gateway/websocket.gateway.js.map +1 -1
  115. package/dist/modules/websocket/services/command-acknowledgement-trace.service.d.ts +143 -0
  116. package/dist/modules/websocket/services/command-acknowledgement-trace.service.js +523 -0
  117. package/dist/modules/websocket/services/command-acknowledgement-trace.service.js.map +1 -0
  118. package/dist/modules/websocket/websocket.module.js +9 -1
  119. package/dist/modules/websocket/websocket.module.js.map +1 -1
  120. package/dist/plugins/devices-home-assistant/devices-home-assistant.openapi.d.ts +2 -2
  121. package/dist/plugins/devices-home-assistant/devices-home-assistant.plugin.d.ts +6 -1
  122. package/dist/plugins/devices-home-assistant/devices-home-assistant.plugin.js +16 -1
  123. package/dist/plugins/devices-home-assistant/devices-home-assistant.plugin.js.map +1 -1
  124. package/dist/plugins/devices-home-assistant/dto/mapping-preview.dto.d.ts +1 -0
  125. package/dist/plugins/devices-home-assistant/dto/mapping-preview.dto.js +11 -0
  126. package/dist/plugins/devices-home-assistant/dto/mapping-preview.dto.js.map +1 -1
  127. package/dist/plugins/devices-home-assistant/mappers/button.entity.mapper.service.d.ts +19 -0
  128. package/dist/plugins/devices-home-assistant/mappers/button.entity.mapper.service.js +51 -0
  129. package/dist/plugins/devices-home-assistant/mappers/button.entity.mapper.service.js.map +1 -0
  130. package/dist/plugins/devices-home-assistant/mappings/definitions/entity-mappings.yaml +56 -7
  131. package/dist/plugins/devices-home-assistant/models/mapping-preview.model.d.ts +1 -0
  132. package/dist/plugins/devices-home-assistant/models/mapping-preview.model.js +11 -0
  133. package/dist/plugins/devices-home-assistant/models/mapping-preview.model.js.map +1 -1
  134. package/dist/plugins/devices-home-assistant/services/device-adoption.service.js +17 -0
  135. package/dist/plugins/devices-home-assistant/services/device-adoption.service.js.map +1 -1
  136. package/dist/plugins/devices-home-assistant/services/home-assistant-raw-event.service.d.ts +27 -0
  137. package/dist/plugins/devices-home-assistant/services/home-assistant-raw-event.service.js +271 -0
  138. package/dist/plugins/devices-home-assistant/services/home-assistant-raw-event.service.js.map +1 -0
  139. package/dist/plugins/devices-home-assistant/services/home-assistant.ws.service.d.ts +2 -2
  140. package/dist/plugins/devices-home-assistant/services/home-assistant.ws.service.js +33 -14
  141. package/dist/plugins/devices-home-assistant/services/home-assistant.ws.service.js.map +1 -1
  142. package/dist/plugins/devices-home-assistant/services/mapping-preview.service.js +24 -4
  143. package/dist/plugins/devices-home-assistant/services/mapping-preview.service.js.map +1 -1
  144. package/dist/plugins/devices-home-assistant/services/state-changed.event.service.d.ts +4 -1
  145. package/dist/plugins/devices-home-assistant/services/state-changed.event.service.js +84 -5
  146. package/dist/plugins/devices-home-assistant/services/state-changed.event.service.js.map +1 -1
  147. package/dist/plugins/devices-home-assistant/services/wizard.service.js +1 -0
  148. package/dist/plugins/devices-home-assistant/services/wizard.service.js.map +1 -1
  149. package/dist/plugins/devices-home-assistant/utils/ha-event.utils.d.ts +2 -0
  150. package/dist/plugins/devices-home-assistant/utils/ha-event.utils.js +50 -0
  151. package/dist/plugins/devices-home-assistant/utils/ha-event.utils.js.map +1 -0
  152. package/dist/plugins/devices-homekit/listeners/homekit-event.listener.d.ts +4 -1
  153. package/dist/plugins/devices-homekit/listeners/homekit-event.listener.js +39 -4
  154. package/dist/plugins/devices-homekit/listeners/homekit-event.listener.js.map +1 -1
  155. package/dist/plugins/devices-homekit/mappers/base.mapper.js +30 -6
  156. package/dist/plugins/devices-homekit/mappers/base.mapper.js.map +1 -1
  157. package/dist/plugins/devices-homekit/mappers/button.mapper.d.ts +15 -0
  158. package/dist/plugins/devices-homekit/mappers/button.mapper.js +143 -0
  159. package/dist/plugins/devices-homekit/mappers/button.mapper.js.map +1 -0
  160. package/dist/plugins/devices-homekit/mappers/characteristic-write.d.ts +2 -0
  161. package/dist/plugins/devices-homekit/mappers/characteristic-write.js +17 -0
  162. package/dist/plugins/devices-homekit/mappers/characteristic-write.js.map +1 -0
  163. package/dist/plugins/devices-homekit/mappers/homekit-mapper.interface.d.ts +17 -1
  164. package/dist/plugins/devices-homekit/mappers/thermostat-coordinator.d.ts +7 -0
  165. package/dist/plugins/devices-homekit/mappers/thermostat-coordinator.js +96 -84
  166. package/dist/plugins/devices-homekit/mappers/thermostat-coordinator.js.map +1 -1
  167. package/dist/plugins/devices-homekit/services/homekit-mapper-registry.service.d.ts +5 -1
  168. package/dist/plugins/devices-homekit/services/homekit-mapper-registry.service.js +42 -0
  169. package/dist/plugins/devices-homekit/services/homekit-mapper-registry.service.js.map +1 -1
  170. package/dist/plugins/devices-homey/mappings/definitions/channels.yaml +50 -0
  171. package/dist/plugins/devices-homey/mappings/definitions/devices.yaml +18 -0
  172. package/dist/plugins/devices-homey/mappings/definitions/properties.yaml +55 -0
  173. package/dist/plugins/devices-homey/mappings/device-support.js +10 -0
  174. package/dist/plugins/devices-homey/mappings/device-support.js.map +1 -1
  175. package/dist/plugins/devices-homey/mappings/mapping-loader.service.d.ts +1 -0
  176. package/dist/plugins/devices-homey/mappings/mapping-loader.service.js +66 -8
  177. package/dist/plugins/devices-homey/mappings/mapping-loader.service.js.map +1 -1
  178. package/dist/plugins/devices-homey/models/inventory.model.d.ts +2 -1
  179. package/dist/plugins/devices-homey/models/inventory.model.js +1 -0
  180. package/dist/plugins/devices-homey/models/inventory.model.js.map +1 -1
  181. package/dist/plugins/devices-homey/models/mapping-preview.model.d.ts +1 -0
  182. package/dist/plugins/devices-homey/models/mapping-preview.model.js +1 -0
  183. package/dist/plugins/devices-homey/models/mapping-preview.model.js.map +1 -1
  184. package/dist/plugins/devices-homey/services/homey-mapping-preview.service.js +7 -0
  185. package/dist/plugins/devices-homey/services/homey-mapping-preview.service.js.map +1 -1
  186. package/dist/plugins/devices-homey/services/homey-synchronizer.service.d.ts +3 -1
  187. package/dist/plugins/devices-homey/services/homey-synchronizer.service.js +37 -6
  188. package/dist/plugins/devices-homey/services/homey-synchronizer.service.js.map +1 -1
  189. package/dist/plugins/devices-reterminal/services/reterminal-button.service.d.ts +7 -3
  190. package/dist/plugins/devices-reterminal/services/reterminal-button.service.js +63 -17
  191. package/dist/plugins/devices-reterminal/services/reterminal-button.service.js.map +1 -1
  192. package/dist/plugins/devices-shelly-ng/delegates/delegates-manager.service.d.ts +36 -3
  193. package/dist/plugins/devices-shelly-ng/delegates/delegates-manager.service.js +707 -42
  194. package/dist/plugins/devices-shelly-ng/delegates/delegates-manager.service.js.map +1 -1
  195. package/dist/plugins/devices-shelly-ng/delegates/shelly-device.delegate.d.ts +11 -1
  196. package/dist/plugins/devices-shelly-ng/delegates/shelly-device.delegate.js +100 -10
  197. package/dist/plugins/devices-shelly-ng/delegates/shelly-device.delegate.js.map +1 -1
  198. package/dist/plugins/devices-shelly-ng/devices-shelly-ng.constants.js +9 -11
  199. package/dist/plugins/devices-shelly-ng/devices-shelly-ng.constants.js.map +1 -1
  200. package/dist/plugins/devices-shelly-ng/devices-shelly-ng.plugin.js +2 -0
  201. package/dist/plugins/devices-shelly-ng/devices-shelly-ng.plugin.js.map +1 -1
  202. package/dist/plugins/devices-shelly-ng/mappings/definitions/inputs.yaml +80 -0
  203. package/dist/plugins/devices-shelly-ng/mappings/mapping-loader.service.js +7 -1
  204. package/dist/plugins/devices-shelly-ng/mappings/mapping-loader.service.js.map +1 -1
  205. package/dist/plugins/devices-shelly-ng/mappings/mapping.types.d.ts +6 -3
  206. package/dist/plugins/devices-shelly-ng/mappings/schema/mapping-schema.json +5 -0
  207. package/dist/plugins/devices-shelly-ng/services/device-manager.service.js +153 -8
  208. package/dist/plugins/devices-shelly-ng/services/device-manager.service.js.map +1 -1
  209. package/dist/plugins/devices-shelly-ng/services/poll-placement-diagnostics.service.d.ts +51 -0
  210. package/dist/plugins/devices-shelly-ng/services/poll-placement-diagnostics.service.js +549 -0
  211. package/dist/plugins/devices-shelly-ng/services/poll-placement-diagnostics.service.js.map +1 -0
  212. package/dist/plugins/devices-shelly-ng/services/shelly-ng.service.d.ts +14 -2
  213. package/dist/plugins/devices-shelly-ng/services/shelly-ng.service.js +134 -35
  214. package/dist/plugins/devices-shelly-ng/services/shelly-ng.service.js.map +1 -1
  215. package/dist/plugins/devices-shelly-ng/services/shelly-rpc-client.service.js +3 -0
  216. package/dist/plugins/devices-shelly-ng/services/shelly-rpc-client.service.js.map +1 -1
  217. package/dist/plugins/devices-shelly-ng/services/shelly-ws-server.service.d.ts +1 -0
  218. package/dist/plugins/devices-shelly-ng/services/shelly-ws-server.service.js +14 -4
  219. package/dist/plugins/devices-shelly-ng/services/shelly-ws-server.service.js.map +1 -1
  220. package/dist/plugins/devices-shelly-v1/devices-shelly-v1.constants.d.ts +2 -0
  221. package/dist/plugins/devices-shelly-v1/devices-shelly-v1.constants.js +66 -4
  222. package/dist/plugins/devices-shelly-v1/devices-shelly-v1.constants.js.map +1 -1
  223. package/dist/plugins/devices-shelly-v1/devices-shelly-v1.plugin.js +2 -0
  224. package/dist/plugins/devices-shelly-v1/devices-shelly-v1.plugin.js.map +1 -1
  225. package/dist/plugins/devices-shelly-v1/platforms/shelly-v1.device.platform.js +7 -0
  226. package/dist/plugins/devices-shelly-v1/platforms/shelly-v1.device.platform.js.map +1 -1
  227. package/dist/plugins/devices-shelly-v1/services/device-mapper.service.d.ts +3 -1
  228. package/dist/plugins/devices-shelly-v1/services/device-mapper.service.js +20 -2
  229. package/dist/plugins/devices-shelly-v1/services/device-mapper.service.js.map +1 -1
  230. package/dist/plugins/devices-shelly-v1/services/shelly-v1-input-tracker.service.d.ts +16 -0
  231. package/dist/plugins/devices-shelly-v1/services/shelly-v1-input-tracker.service.js +107 -0
  232. package/dist/plugins/devices-shelly-v1/services/shelly-v1-input-tracker.service.js.map +1 -0
  233. package/dist/plugins/devices-shelly-v1/services/shelly-v1.service.d.ts +7 -1
  234. package/dist/plugins/devices-shelly-v1/services/shelly-v1.service.js +98 -3
  235. package/dist/plugins/devices-shelly-v1/services/shelly-v1.service.js.map +1 -1
  236. package/dist/plugins/devices-third-party/controllers/third-party-inputs.controller.d.ts +17 -0
  237. package/dist/plugins/devices-third-party/controllers/third-party-inputs.controller.js +205 -0
  238. package/dist/plugins/devices-third-party/controllers/third-party-inputs.controller.js.map +1 -0
  239. package/dist/plugins/devices-third-party/devices-third-party.openapi.d.ts +1 -1
  240. package/dist/plugins/devices-third-party/devices-third-party.plugin.js +2 -1
  241. package/dist/plugins/devices-third-party/devices-third-party.plugin.js.map +1 -1
  242. package/dist/plugins/devices-third-party/dto/report-input-occurrence-response.dto.d.ts +5 -0
  243. package/dist/plugins/devices-third-party/dto/report-input-occurrence-response.dto.js +50 -0
  244. package/dist/plugins/devices-third-party/dto/report-input-occurrence-response.dto.js.map +1 -0
  245. package/dist/plugins/devices-third-party/dto/report-input-occurrence.dto.d.ts +8 -0
  246. package/dist/plugins/devices-third-party/dto/report-input-occurrence.dto.js +81 -0
  247. package/dist/plugins/devices-third-party/dto/report-input-occurrence.dto.js.map +1 -0
  248. package/dist/plugins/devices-third-party/platforms/third-party-device.platform.js +19 -4
  249. package/dist/plugins/devices-third-party/platforms/third-party-device.platform.js.map +1 -1
  250. package/dist/plugins/devices-virtual/listeners/virtual-projection.listener.d.ts +2 -0
  251. package/dist/plugins/devices-virtual/listeners/virtual-projection.listener.js +57 -0
  252. package/dist/plugins/devices-virtual/listeners/virtual-projection.listener.js.map +1 -1
  253. package/dist/plugins/devices-zigbee2mqtt/devices-zigbee2mqtt.constants.d.ts +3 -0
  254. package/dist/plugins/devices-zigbee2mqtt/devices-zigbee2mqtt.constants.js +41 -5
  255. package/dist/plugins/devices-zigbee2mqtt/devices-zigbee2mqtt.constants.js.map +1 -1
  256. package/dist/plugins/devices-zigbee2mqtt/interfaces/zigbee2mqtt.interface.d.ts +7 -1
  257. package/dist/plugins/devices-zigbee2mqtt/mappings/config-driven.converter.js +16 -15
  258. package/dist/plugins/devices-zigbee2mqtt/mappings/config-driven.converter.js.map +1 -1
  259. package/dist/plugins/devices-zigbee2mqtt/mappings/definitions/remotes.yaml +505 -74
  260. package/dist/plugins/devices-zigbee2mqtt/mappings/schema/mapping-schema.json +3 -3
  261. package/dist/plugins/devices-zigbee2mqtt/mappings/transformers/transformer.types.d.ts +1 -1
  262. package/dist/plugins/devices-zigbee2mqtt/services/base-client-adapter.d.ts +8 -2
  263. package/dist/plugins/devices-zigbee2mqtt/services/base-client-adapter.js +6 -5
  264. package/dist/plugins/devices-zigbee2mqtt/services/base-client-adapter.js.map +1 -1
  265. package/dist/plugins/devices-zigbee2mqtt/services/device-adoption.service.js +18 -11
  266. package/dist/plugins/devices-zigbee2mqtt/services/device-adoption.service.js.map +1 -1
  267. package/dist/plugins/devices-zigbee2mqtt/services/device-mapper.service.d.ts +9 -3
  268. package/dist/plugins/devices-zigbee2mqtt/services/device-mapper.service.js +166 -3
  269. package/dist/plugins/devices-zigbee2mqtt/services/device-mapper.service.js.map +1 -1
  270. package/dist/plugins/devices-zigbee2mqtt/services/mqtt-client-adapter.service.js +5 -2
  271. package/dist/plugins/devices-zigbee2mqtt/services/mqtt-client-adapter.service.js.map +1 -1
  272. package/dist/plugins/devices-zigbee2mqtt/services/zigbee2mqtt.service.d.ts +1 -0
  273. package/dist/plugins/devices-zigbee2mqtt/services/zigbee2mqtt.service.js +44 -4
  274. package/dist/plugins/devices-zigbee2mqtt/services/zigbee2mqtt.service.js.map +1 -1
  275. package/dist/plugins/remote-access-cloudflare-tunnel/controllers/setup.controller.d.ts +17 -0
  276. package/dist/plugins/remote-access-cloudflare-tunnel/controllers/setup.controller.js +147 -0
  277. package/dist/plugins/remote-access-cloudflare-tunnel/controllers/setup.controller.js.map +1 -0
  278. package/dist/plugins/remote-access-cloudflare-tunnel/controllers/status.controller.d.ts +15 -0
  279. package/dist/plugins/remote-access-cloudflare-tunnel/controllers/status.controller.js +98 -0
  280. package/dist/plugins/remote-access-cloudflare-tunnel/controllers/status.controller.js.map +1 -0
  281. package/dist/plugins/remote-access-cloudflare-tunnel/dto/update-config.dto.d.ts +9 -0
  282. package/dist/plugins/remote-access-cloudflare-tunnel/dto/update-config.dto.js +94 -0
  283. package/dist/plugins/remote-access-cloudflare-tunnel/dto/update-config.dto.js.map +1 -0
  284. package/dist/plugins/remote-access-cloudflare-tunnel/models/config.model.d.ts +9 -0
  285. package/dist/plugins/remote-access-cloudflare-tunnel/models/config.model.js +92 -0
  286. package/dist/plugins/remote-access-cloudflare-tunnel/models/config.model.js.map +1 -0
  287. package/dist/plugins/remote-access-cloudflare-tunnel/models/install.model.d.ts +7 -0
  288. package/dist/plugins/remote-access-cloudflare-tunnel/models/install.model.js +48 -0
  289. package/dist/plugins/remote-access-cloudflare-tunnel/models/install.model.js.map +1 -0
  290. package/dist/plugins/remote-access-cloudflare-tunnel/models/status.model.d.ts +42 -0
  291. package/dist/plugins/remote-access-cloudflare-tunnel/models/status.model.js +352 -0
  292. package/dist/plugins/remote-access-cloudflare-tunnel/models/status.model.js.map +1 -0
  293. package/dist/plugins/remote-access-cloudflare-tunnel/remote-access-cloudflare-tunnel.constants.d.ts +24 -0
  294. package/dist/plugins/remote-access-cloudflare-tunnel/remote-access-cloudflare-tunnel.constants.js +27 -0
  295. package/dist/plugins/remote-access-cloudflare-tunnel/remote-access-cloudflare-tunnel.constants.js.map +1 -0
  296. package/dist/plugins/remote-access-cloudflare-tunnel/remote-access-cloudflare-tunnel.exceptions.d.ts +8 -0
  297. package/dist/plugins/remote-access-cloudflare-tunnel/remote-access-cloudflare-tunnel.exceptions.js +19 -0
  298. package/dist/plugins/remote-access-cloudflare-tunnel/remote-access-cloudflare-tunnel.exceptions.js.map +1 -0
  299. package/dist/plugins/remote-access-cloudflare-tunnel/remote-access-cloudflare-tunnel.openapi.d.ts +4 -0
  300. package/dist/plugins/remote-access-cloudflare-tunnel/remote-access-cloudflare-tunnel.openapi.js +20 -0
  301. package/dist/plugins/remote-access-cloudflare-tunnel/remote-access-cloudflare-tunnel.openapi.js.map +1 -0
  302. package/dist/plugins/remote-access-cloudflare-tunnel/remote-access-cloudflare-tunnel.plugin.d.ts +21 -0
  303. package/dist/plugins/remote-access-cloudflare-tunnel/remote-access-cloudflare-tunnel.plugin.js +135 -0
  304. package/dist/plugins/remote-access-cloudflare-tunnel/remote-access-cloudflare-tunnel.plugin.js.map +1 -0
  305. package/dist/plugins/remote-access-cloudflare-tunnel/scripts/cloudflared-setup.sh +185 -0
  306. package/dist/plugins/remote-access-cloudflare-tunnel/services/cloudflare-tunnel-managed.service.d.ts +82 -0
  307. package/dist/plugins/remote-access-cloudflare-tunnel/services/cloudflare-tunnel-managed.service.js +507 -0
  308. package/dist/plugins/remote-access-cloudflare-tunnel/services/cloudflare-tunnel-managed.service.js.map +1 -0
  309. package/dist/plugins/remote-access-cloudflare-tunnel/services/cloudflare-tunnel-provider.service.d.ts +10 -0
  310. package/dist/plugins/remote-access-cloudflare-tunnel/services/cloudflare-tunnel-provider.service.js +37 -0
  311. package/dist/plugins/remote-access-cloudflare-tunnel/services/cloudflare-tunnel-provider.service.js.map +1 -0
  312. package/dist/plugins/remote-access-cloudflare-tunnel/services/cloudflare-tunnel-setup.service.d.ts +38 -0
  313. package/dist/plugins/remote-access-cloudflare-tunnel/services/cloudflare-tunnel-setup.service.js +147 -0
  314. package/dist/plugins/remote-access-cloudflare-tunnel/services/cloudflare-tunnel-setup.service.js.map +1 -0
  315. package/dist/plugins/remote-access-cloudflare-tunnel/services/cloudflared-cli.service.d.ts +15 -0
  316. package/dist/plugins/remote-access-cloudflare-tunnel/services/cloudflared-cli.service.js +67 -0
  317. package/dist/plugins/remote-access-cloudflare-tunnel/services/cloudflared-cli.service.js.map +1 -0
  318. package/dist/plugins/remote-access-cloudflare-tunnel/services/cloudflared-metrics.service.d.ts +8 -0
  319. package/dist/plugins/remote-access-cloudflare-tunnel/services/cloudflared-metrics.service.js +46 -0
  320. package/dist/plugins/remote-access-cloudflare-tunnel/services/cloudflared-metrics.service.js.map +1 -0
  321. package/dist/plugins/remote-access-cloudflare-tunnel/services/cloudflared-process.service.d.ts +27 -0
  322. package/dist/plugins/remote-access-cloudflare-tunnel/services/cloudflared-process.service.js +139 -0
  323. package/dist/plugins/remote-access-cloudflare-tunnel/services/cloudflared-process.service.js.map +1 -0
  324. package/dist/plugins/remote-access-tailscale/controllers/setup.controller.d.ts +1 -0
  325. package/dist/plugins/remote-access-tailscale/controllers/setup.controller.js +26 -10
  326. package/dist/plugins/remote-access-tailscale/controllers/setup.controller.js.map +1 -1
  327. package/dist/plugins/remote-access-tailscale/controllers/status.controller.d.ts +6 -1
  328. package/dist/plugins/remote-access-tailscale/controllers/status.controller.js +28 -3
  329. package/dist/plugins/remote-access-tailscale/controllers/status.controller.js.map +1 -1
  330. package/dist/plugins/remote-access-tailscale/models/status.model.d.ts +19 -0
  331. package/dist/plugins/remote-access-tailscale/models/status.model.js +158 -1
  332. package/dist/plugins/remote-access-tailscale/models/status.model.js.map +1 -1
  333. package/dist/plugins/remote-access-tailscale/remote-access-tailscale.constants.d.ts +3 -0
  334. package/dist/plugins/remote-access-tailscale/remote-access-tailscale.constants.js +4 -1
  335. package/dist/plugins/remote-access-tailscale/remote-access-tailscale.constants.js.map +1 -1
  336. package/dist/plugins/remote-access-tailscale/remote-access-tailscale.exceptions.d.ts +13 -0
  337. package/dist/plugins/remote-access-tailscale/remote-access-tailscale.exceptions.js +27 -0
  338. package/dist/plugins/remote-access-tailscale/remote-access-tailscale.exceptions.js.map +1 -0
  339. package/dist/plugins/remote-access-tailscale/remote-access-tailscale.openapi.d.ts +2 -2
  340. package/dist/plugins/remote-access-tailscale/remote-access-tailscale.openapi.js +2 -0
  341. package/dist/plugins/remote-access-tailscale/remote-access-tailscale.openapi.js.map +1 -1
  342. package/dist/plugins/remote-access-tailscale/remote-access-tailscale.plugin.js +1 -1
  343. package/dist/plugins/remote-access-tailscale/scripts/tailscale-setup.sh +61 -0
  344. package/dist/plugins/remote-access-tailscale/services/tailscale-cli.service.d.ts +9 -0
  345. package/dist/plugins/remote-access-tailscale/services/tailscale-cli.service.js +12 -0
  346. package/dist/plugins/remote-access-tailscale/services/tailscale-cli.service.js.map +1 -1
  347. package/dist/plugins/remote-access-tailscale/services/tailscale-login.service.d.ts +1 -0
  348. package/dist/plugins/remote-access-tailscale/services/tailscale-login.service.js +27 -2
  349. package/dist/plugins/remote-access-tailscale/services/tailscale-login.service.js.map +1 -1
  350. package/dist/plugins/remote-access-tailscale/services/tailscale-node-managed.service.d.ts +31 -1
  351. package/dist/plugins/remote-access-tailscale/services/tailscale-node-managed.service.js +316 -30
  352. package/dist/plugins/remote-access-tailscale/services/tailscale-node-managed.service.js.map +1 -1
  353. package/dist/plugins/remote-access-tailscale/services/tailscale-serve.service.d.ts +8 -3
  354. package/dist/plugins/remote-access-tailscale/services/tailscale-serve.service.js +39 -12
  355. package/dist/plugins/remote-access-tailscale/services/tailscale-serve.service.js.map +1 -1
  356. package/dist/plugins/remote-access-tailscale/services/tailscale-setup.service.d.ts +14 -5
  357. package/dist/plugins/remote-access-tailscale/services/tailscale-setup.service.js +51 -16
  358. package/dist/plugins/remote-access-tailscale/services/tailscale-setup.service.js.map +1 -1
  359. package/dist/plugins/scenes-local/platforms/local-scene.platform.d.ts +3 -1
  360. package/dist/plugins/scenes-local/platforms/local-scene.platform.js +12 -7
  361. package/dist/plugins/scenes-local/platforms/local-scene.platform.js.map +1 -1
  362. package/dist/plugins/simulator/controllers/simulator.controller.d.ts +8 -2
  363. package/dist/plugins/simulator/controllers/simulator.controller.js +102 -2
  364. package/dist/plugins/simulator/controllers/simulator.controller.js.map +1 -1
  365. package/dist/plugins/simulator/dto/simulate-occurrence.dto.d.ts +12 -0
  366. package/dist/plugins/simulator/dto/simulate-occurrence.dto.js +122 -0
  367. package/dist/plugins/simulator/dto/simulate-occurrence.dto.js.map +1 -0
  368. package/dist/plugins/simulator/models/simulator-response.model.d.ts +12 -0
  369. package/dist/plugins/simulator/models/simulator-response.model.js +84 -1
  370. package/dist/plugins/simulator/models/simulator-response.model.js.map +1 -1
  371. package/dist/plugins/simulator/scenarios/definitions/small-apartment.yaml +78 -0
  372. package/dist/plugins/simulator/services/scenario-executor.service.js +3 -1
  373. package/dist/plugins/simulator/services/scenario-executor.service.js.map +1 -1
  374. package/dist/plugins/simulator/services/simulation.service.js +10 -1
  375. package/dist/plugins/simulator/services/simulation.service.js.map +1 -1
  376. package/dist/plugins/simulator/simulator.openapi.d.ts +3 -2
  377. package/dist/plugins/simulator/simulator.openapi.js +5 -0
  378. package/dist/plugins/simulator/simulator.openapi.js.map +1 -1
  379. package/dist/plugins/simulator/simulators/device-simulator.interface.d.ts +2 -0
  380. package/dist/plugins/simulator/simulators/device-simulator.interface.js +83 -0
  381. package/dist/plugins/simulator/simulators/device-simulator.interface.js.map +1 -1
  382. package/dist/plugins/simulator/simulators/index.d.ts +1 -0
  383. package/dist/plugins/simulator/simulators/index.js +1 -0
  384. package/dist/plugins/simulator/simulators/index.js.map +1 -1
  385. package/dist/plugins/simulator/simulators/input-controller.simulator.d.ts +10 -0
  386. package/dist/plugins/simulator/simulators/input-controller.simulator.js +57 -0
  387. package/dist/plugins/simulator/simulators/input-controller.simulator.js.map +1 -0
  388. package/dist/plugins/simulator/simulators/lighting.simulator.js +1 -0
  389. package/dist/plugins/simulator/simulators/lighting.simulator.js.map +1 -1
  390. package/dist/plugins/spaces-home-control/services/climate-intent.service.d.ts +3 -1
  391. package/dist/plugins/spaces-home-control/services/climate-intent.service.js +6 -3
  392. package/dist/plugins/spaces-home-control/services/climate-intent.service.js.map +1 -1
  393. package/dist/plugins/spaces-home-control/services/covers-intent.service.d.ts +3 -1
  394. package/dist/plugins/spaces-home-control/services/covers-intent.service.js +6 -3
  395. package/dist/plugins/spaces-home-control/services/covers-intent.service.js.map +1 -1
  396. package/dist/plugins/spaces-home-control/services/lighting-intent.service.d.ts +3 -1
  397. package/dist/plugins/spaces-home-control/services/lighting-intent.service.js +7 -4
  398. package/dist/plugins/spaces-home-control/services/lighting-intent.service.js.map +1 -1
  399. package/dist/plugins/spaces-home-control/services/space-media-activity.service.d.ts +3 -1
  400. package/dist/plugins/spaces-home-control/services/space-media-activity.service.js +11 -5
  401. package/dist/plugins/spaces-home-control/services/space-media-activity.service.js.map +1 -1
  402. package/dist/plugins/spaces-home-control/services/space-undo-history.service.d.ts +3 -1
  403. package/dist/plugins/spaces-home-control/services/space-undo-history.service.js +8 -5
  404. package/dist/plugins/spaces-home-control/services/space-undo-history.service.js.map +1 -1
  405. package/dist/spec/channels.d.ts +51 -1
  406. package/dist/spec/channels.js +64 -1
  407. package/dist/spec/channels.js.map +1 -1
  408. package/dist/spec/devices.d.ts +155 -2
  409. package/dist/spec/devices.js +161 -2
  410. package/dist/spec/devices.js.map +1 -1
  411. package/dist/tsconfig.build.tsbuildinfo +1 -1
  412. package/package.json +3 -3
@@ -6,6 +6,839 @@ STATUS_FILE="${STATUS_FILE:-/var/lib/smart-panel/update-status.json}"
6
6
  INSTALL_TYPE="${INSTALL_TYPE:-npm}"
7
7
  IMAGE_BASE_DIR="${IMAGE_BASE_DIR:-/opt/smart-panel}"
8
8
  DOWNLOAD_URL="${DOWNLOAD_URL:-}"
9
+ HEALTH_URL="${HEALTH_URL:-}"
10
+ HEALTH_EXPECTED_VERSION="${HEALTH_EXPECTED_VERSION:-$VERSION}"
11
+ UPDATE_START_MODE="${UPDATE_START_MODE:-running-service}"
12
+
13
+ # `stopped-maintenance` is an operator-only mode. The API always supplies the default
14
+ # running-service mode; this mode is intentionally guarded by a complete, explicit identity
15
+ # packet and is never inferred from a stale status file or an ambient process environment.
16
+ if [ "$UPDATE_START_MODE" != "running-service" ] && [ "$UPDATE_START_MODE" != "stopped-maintenance" ]; then
17
+ printf 'Unsupported update start mode: %s\n' "$UPDATE_START_MODE" >&2
18
+ exit 64
19
+ fi
20
+
21
+ # The attempt record and lock live beside the public status file, never inside a versioned image
22
+ # directory. This lets the record survive a backend restart, target-directory cleanup or a
23
+ # failed migration, while keeping it outside the release tarball and private to the service user.
24
+ ATTEMPT_DIR="${ATTEMPT_DIR:-$(dirname "$STATUS_FILE")/update-attempt}"
25
+ ATTEMPT_FILE="${ATTEMPT_DIR}/attempt.json"
26
+ ATTEMPT_LOCK="${ATTEMPT_DIR}/lock"
27
+ # Only the internal executor supplies its own group; callers cannot select it through the API.
28
+ # Legacy/operator invocations without a reader retain owner-only permissions.
29
+ UPDATE_OBSERVER_GID="${UPDATE_OBSERVER_GID:-}"
30
+ case "$UPDATE_OBSERVER_GID" in
31
+ *[!0-9]*) printf 'Invalid update observer group\n' >&2; exit 64 ;;
32
+ esac
33
+ ATTEMPT_ID=""
34
+ MIGRATION_ENTERED="false"
35
+ FINALIZED="false"
36
+ MIGRATION_LOG=""
37
+ CURRENT_PHASE="preparing"
38
+ SERVICE_NAME="smart-panel"
39
+ SERVICE_UNIT="${SERVICE_UNIT:-${SERVICE_NAME}.service}"
40
+ QUIESCENCE_TIMEOUT_SECONDS="${QUIESCENCE_TIMEOUT_SECONDS:-30}"
41
+ START_TIMEOUT_SECONDS="${START_TIMEOUT_SECONDS:-60}"
42
+ CAPTURED_SERVICE_CGROUP_PROCS=""
43
+ CAPTURED_SERVICE_CGROUP_DIR=""
44
+ CAPTURED_SERVICE_CGROUP_EVENTS=""
45
+ CAPTURED_SERVICE_MEMBER_IDENTITIES=""
46
+ CGROUP_MEMBERS=""
47
+ CGROUP_POPULATED=""
48
+ MAINTENANCE_DIR=""
49
+ DB_BACKUP_PATH="${DB_BACKUP_PATH:-}"
50
+ BACKUP_TMP_PATH=""
51
+ SOURCE_DB_PATH=""
52
+ STOPPED_SERVICE_BASELINE=""
53
+ CGROUP_PROBE_MOUNT_ID=""
54
+ CGROUP_PROBE_PARENT_ID=""
55
+ CGROUP_PROBE_DIR_ID=""
56
+ CGROUP_PROBE_EVENTS_ID=""
57
+ CGROUP_PROBE_NAMESPACE_ID=""
58
+ CGROUP_PROBE_ABSENT="false"
59
+
60
+ json_escape() {
61
+ printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/[[:cntrl:]]//g'
62
+ }
63
+
64
+ source_database_is_valid() {
65
+ [ -n "${SOURCE_DB_PATH:-}" ] || return 1
66
+ [ ! -L "$SOURCE_DB_PATH" ] && [ -f "$SOURCE_DB_PATH" ] && [ -s "$SOURCE_DB_PATH" ]
67
+ }
68
+
69
+ write_attempt() {
70
+ local state="$1"
71
+ local phase="$2"
72
+ local error="${3:-}"
73
+ local recovery_required="${4:-false}"
74
+ local migration_entered="${5:-$MIGRATION_ENTERED}"
75
+ local tmp_file="${ATTEMPT_FILE}.tmp.$$"
76
+ CURRENT_PHASE="$phase"
77
+
78
+ mkdir -p "$ATTEMPT_DIR"
79
+ if [ -n "$UPDATE_OBSERVER_GID" ]; then
80
+ chgrp "$UPDATE_OBSERVER_GID" "$ATTEMPT_DIR" || return 1
81
+ # Traverse to the known attempt file without exposing directory listings or writes.
82
+ chmod 710 "$ATTEMPT_DIR" || return 1
83
+ else
84
+ chmod 700 "$ATTEMPT_DIR"
85
+ fi
86
+
87
+ {
88
+ printf '{\n'
89
+ printf '\t"attemptId": "%s",\n' "$(json_escape "$ATTEMPT_ID")"
90
+ printf '\t"ownerPid": %s,\n' "$$"
91
+ printf '\t"targetVersion": "%s",\n' "$(json_escape "$VERSION")"
92
+ printf '\t"state": "%s",\n' "$(json_escape "$state")"
93
+ printf '\t"phase": "%s",\n' "$(json_escape "$phase")"
94
+ printf '\t"migrationEntered": %s,\n' "$migration_entered"
95
+ printf '\t"recoveryRequired": %s,\n' "$recovery_required"
96
+ printf '\t"migrationLog": "%s",\n' "$(json_escape "$MIGRATION_LOG")"
97
+ printf '\t"updatedAt": "%s",\n' "$(date -Iseconds)"
98
+ printf '\t"error": "%s"\n' "$(json_escape "$error")"
99
+ printf '}\n'
100
+ } > "$tmp_file"
101
+
102
+ chmod 600 "$tmp_file"
103
+ if [ -n "$UPDATE_OBSERVER_GID" ]; then
104
+ chgrp "$UPDATE_OBSERVER_GID" "$tmp_file" || return 1
105
+ chmod 640 "$tmp_file" || return 1
106
+ fi
107
+ mv -f "$tmp_file" "$ATTEMPT_FILE"
108
+ }
109
+
110
+ attempt_is_unresolved() {
111
+ if [ -f "$ATTEMPT_FILE" ] && grep -q '"state": "complete"' "$ATTEMPT_FILE"; then
112
+ # A crash after the durable completion record but before rmdir must not strand a clean
113
+ # attempt forever; completion is the evidence that makes this lock removable.
114
+ rmdir "$ATTEMPT_LOCK" 2>/dev/null || true
115
+
116
+ return 1
117
+ fi
118
+
119
+ if [ -d "$ATTEMPT_LOCK" ]; then
120
+ return 0
121
+ fi
122
+
123
+ if [ ! -f "$ATTEMPT_FILE" ]; then
124
+ return 1
125
+ fi
126
+
127
+ grep -q '"state": "active"' "$ATTEMPT_FILE" || grep -q '"recoveryRequired": true' "$ATTEMPT_FILE"
128
+ }
129
+
130
+ acquire_attempt() {
131
+ mkdir -p "$ATTEMPT_DIR"
132
+ chmod 700 "$ATTEMPT_DIR"
133
+
134
+ if attempt_is_unresolved; then
135
+ return 1
136
+ fi
137
+
138
+ # mkdir is the portable atomic OS-level lock used by the legacy launcher. An orphaned lock is
139
+ # deliberately retained; an expired timestamp cannot prove that a migration did not commit.
140
+ if ! mkdir "$ATTEMPT_LOCK" 2>/dev/null; then
141
+ return 1
142
+ fi
143
+ chmod 700 "$ATTEMPT_LOCK"
144
+
145
+ ATTEMPT_ID="$(date +%s)-$$"
146
+ write_attempt "active" "preparing" "" "false" "false"
147
+ }
148
+
149
+ release_attempt() {
150
+ if [ -n "$ATTEMPT_ID" ]; then
151
+ write_attempt "complete" "complete" "" "false" "$MIGRATION_ENTERED"
152
+ fi
153
+
154
+ rmdir "$ATTEMPT_LOCK" 2>/dev/null || true
155
+ }
156
+
157
+ hold_attempt() {
158
+ local phase="$1"
159
+ local error="$2"
160
+
161
+ write_attempt "recovery_required" "$phase" "$error" "true" "$MIGRATION_ENTERED"
162
+ }
163
+
164
+ finish_before_migration() {
165
+ local phase="$1"
166
+ local error="$2"
167
+
168
+ if [ -n "${BACKUP_TMP_PATH:-}" ]; then
169
+ rm -f "$BACKUP_TMP_PATH" 2>/dev/null || true
170
+ BACKUP_TMP_PATH=""
171
+ fi
172
+ if [ -n "${TMP_TARBALL:-}" ]; then
173
+ rm -f "$TMP_TARBALL" 2>/dev/null || true
174
+ fi
175
+
176
+ write_attempt "failed_preflight" "$phase" "$error" "false" "false"
177
+ rmdir "$ATTEMPT_LOCK" 2>/dev/null || true
178
+ }
179
+
180
+ phase_requires_hold() {
181
+ case "$CURRENT_PHASE" in
182
+ stopping|quiesced|switching) return 0 ;;
183
+ *) return 1 ;;
184
+ esac
185
+ }
186
+
187
+ fail_before_migration() {
188
+ local phase="$1"
189
+ local error="$2"
190
+
191
+ if [ -n "$ATTEMPT_ID" ]; then
192
+ finish_before_migration "$phase" "$error"
193
+ fi
194
+
195
+ update_status "failed" "failed" "$error"
196
+ FINALIZED="true"
197
+ exit 1
198
+ }
199
+
200
+ restore_before_migration() {
201
+ local previous_target="$1"
202
+ local current_link="$2"
203
+ local new_version_dir="$3"
204
+ local resolved_previous resolved_current
205
+
206
+ [ -n "$previous_target" ] || return 1
207
+ sudo -n ln -sfn "$previous_target" "$current_link" || return 1
208
+
209
+ resolved_previous="$(realpath "$previous_target" 2>/dev/null || true)"
210
+ resolved_current="$(realpath "$current_link" 2>/dev/null || true)"
211
+ [ -n "$resolved_previous" ] && [ "$resolved_previous" = "$resolved_current" ] || return 1
212
+
213
+ rm -rf "$new_version_dir" || return 1
214
+ sudo -n systemctl start "$SERVICE_NAME" 2>/dev/null || return 1
215
+ wait_for_started_service
216
+ }
217
+
218
+ service_property() {
219
+ local property="$1"
220
+
221
+ systemctl show "$SERVICE_NAME" --property="$property" --value
222
+ }
223
+
224
+ manager_processes_are_empty() {
225
+ local output
226
+
227
+ command -v busctl >/dev/null 2>&1 || return 2
228
+ output="$(busctl --system --json=short call org.freedesktop.systemd1 /org/freedesktop/systemd1 \
229
+ org.freedesktop.systemd1.Manager GetUnitProcesses s "$SERVICE_UNIT" 2>/dev/null)" || return 2
230
+
231
+ # busctl's JSON preserves the a(sus) signature and tuple boundaries. Never infer an empty
232
+ # process set from omitted digits, a truncated reply, or a successful command with no payload.
233
+ [ "${#output}" -le 65536 ] || return 2
234
+ printf '%s' "$output" | MANAGER_REPLY=true node -e '
235
+ let input = "";
236
+ process.stdin.setEncoding("utf8");
237
+ process.stdin.on("data", (chunk) => { input += chunk; });
238
+ process.stdin.on("end", () => {
239
+ try {
240
+ const reply = JSON.parse(input);
241
+ if (reply?.type !== "a(sus)" || !Array.isArray(reply.data) ||
242
+ reply.data.length !== 1 || !Array.isArray(reply.data[0])) process.exit(2);
243
+ const members = reply.data[0];
244
+ if (members.length > 4096) process.exit(2);
245
+ for (const tuple of members) {
246
+ if (!Array.isArray(tuple) || tuple.length !== 3 ||
247
+ typeof tuple[0] !== "string" || typeof tuple[2] !== "string" ||
248
+ !Number.isInteger(tuple[1]) || tuple[1] < 1 || tuple[1] > 4294967295) process.exit(2);
249
+ }
250
+ process.exit(members.length === 0 ? 0 : 1);
251
+ } catch {
252
+ process.exit(2);
253
+ }
254
+ });
255
+ '
256
+ }
257
+
258
+ service_unit_object() {
259
+ local output
260
+ command -v busctl >/dev/null 2>&1 || return 2
261
+ output="$(busctl --system --json=short call org.freedesktop.systemd1 /org/freedesktop/systemd1 \
262
+ org.freedesktop.systemd1.Manager GetUnit s "$SERVICE_UNIT" 2>/dev/null)" || return 2
263
+ [ "${#output}" -le 4096 ] || return 2
264
+ printf '%s' "$output" | MANAGER_REPLY=true node -e '
265
+ let input = "";
266
+ process.stdin.setEncoding("utf8");
267
+ process.stdin.on("data", (chunk) => { input += chunk; });
268
+ process.stdin.on("end", () => {
269
+ try {
270
+ const reply = JSON.parse(input);
271
+ const object = reply?.data?.[0];
272
+ if (reply.type !== "o" || reply.data.length !== 1 || typeof object !== "string" ||
273
+ !object.startsWith("/org/freedesktop/systemd1/unit/")) process.exit(2);
274
+ process.stdout.write(object);
275
+ } catch { process.exit(2); }
276
+ });
277
+ '
278
+ }
279
+
280
+ systemd_manager_identity() {
281
+ local stat_line start_time
282
+ if [ "${SERVICE_PROCESS_ROOT:-/proc}" != "/proc" ]; then
283
+ process_identity 1
284
+ return $?
285
+ fi
286
+ stat_line="$(cat /proc/1/stat 2>/dev/null)" || return 2
287
+ case "$stat_line" in '1 (systemd) '*) ;; *) return 2 ;; esac
288
+ start_time="$(printf '%s\n' "$stat_line" | awk '{ sub(/^.*\) /, ""); print $20 }')" || return 2
289
+ case "$start_time" in ''|*[!0-9]*) return 2 ;; esac
290
+ [ "$start_time" -gt 0 ] 2>/dev/null || return 2
291
+ printf '1|%s\n' "$start_time"
292
+ }
293
+
294
+ stopped_service_identity() {
295
+ local boot_id manager_identity unit_object property value fragment_path exec_start slice delegate kill_mode unit_id load_state
296
+
297
+ boot_id="$(cat "${SERVICE_BOOT_ID_FILE:-/proc/sys/kernel/random/boot_id}" 2>/dev/null)" || return 2
298
+ [ -n "$boot_id" ] || return 2
299
+ manager_identity="$(systemd_manager_identity)" || return 2
300
+ unit_object="$(service_unit_object)" || return 2
301
+ unit_id="$(service_property Id 2>/dev/null)" || return 2
302
+ load_state="$(service_property LoadState 2>/dev/null)" || return 2
303
+ fragment_path="$(service_property FragmentPath 2>/dev/null)" || return 2
304
+ exec_start="$(service_property ExecStart 2>/dev/null)" || return 2
305
+ slice="$(service_property Slice 2>/dev/null)" || return 2
306
+ delegate="$(service_property Delegate 2>/dev/null)" || return 2
307
+ kill_mode="$(service_property KillMode 2>/dev/null)" || return 2
308
+ [ "$unit_id" = "$SERVICE_UNIT" ] && [ "$load_state" = "loaded" ] || return 2
309
+ [ "$fragment_path" = "/etc/systemd/system/${SERVICE_UNIT}" ] || return 2
310
+ [[ "$exec_start" == *"${IMAGE_BASE_DIR}/current"* ]] || return 2
311
+ [ "$slice" = "system.slice" ] && [ "$delegate" = "no" ] && [ "$kill_mode" = "process" ] || return 2
312
+ printf 'boot=%s\nmanager=%s\nobject=%s\n' "$boot_id" "$manager_identity" "$unit_object"
313
+ for property in FragmentPath ExecStart Slice Delegate KillMode InvocationID Result; do
314
+ value="$(service_property "$property" 2>/dev/null)" || return 2
315
+ [ -n "$value" ] || return 2
316
+ printf '%s=%s\n' "$property" "$value"
317
+ done
318
+ }
319
+
320
+ stopped_service_quiesced() {
321
+ local active_state main_pid control_pid job_id control_group cgroup_dir cgroup_procs cgroup_events
322
+ local member_status population_status probe_status members subtree_file baseline current_baseline cgroup_mount_type
323
+
324
+ active_state="$(service_property ActiveState 2>/dev/null)" || return 2
325
+ if [ -n "${SERVICE_CGROUP_MOUNT_TYPE:-}" ]; then
326
+ [ "$SERVICE_CGROUP_MOUNT_TYPE" = "cgroup2fs" ] || return 2
327
+ else
328
+ cgroup_mount_type="$(LC_ALL=C stat -f -c '%T' "${SERVICE_CGROUP_MOUNT:-/sys/fs/cgroup}" 2>/dev/null || true)"
329
+ [ "$cgroup_mount_type" = "cgroup2fs" ] || return 2
330
+ fi
331
+ main_pid="$(service_property MainPID 2>/dev/null)" || return 2
332
+ control_pid="$(service_property ControlPID 2>/dev/null)" || return 2
333
+ job_id="$(service_property Job 2>/dev/null)" || return 2
334
+ case "$active_state" in inactive|failed) ;; *) return 1 ;; esac
335
+ [ "$main_pid" = "0" ] || return 1
336
+ [ "$control_pid" = "0" ] || return 1
337
+ # systemctl --value prints an empty value when the unit has no pending job.
338
+ [ -z "$job_id" ] || [ "$job_id" = "0" ] || return 1
339
+
340
+ manager_processes_are_empty || return $?
341
+ baseline="$(stopped_service_identity)" || return 2
342
+ if [ -n "$STOPPED_SERVICE_BASELINE" ]; then
343
+ [ "$baseline" = "$STOPPED_SERVICE_BASELINE" ] || return 1
344
+ else
345
+ STOPPED_SERVICE_BASELINE="$baseline"
346
+ fi
347
+
348
+ control_group="$(service_property ControlGroup 2>/dev/null)" || return 2
349
+ # systemd may clear ControlGroup after a stopped unit is reaped. The canonical unit cgroup is
350
+ # still the only acceptable fallback; it is checked against the cgroup-v2 mount below.
351
+ if [ -z "$control_group" ]; then
352
+ [ "$(service_property Slice 2>/dev/null)" = "system.slice" ] || return 2
353
+ [ "$(service_property Delegate 2>/dev/null)" = "no" ] || return 2
354
+ control_group="/system.slice/${SERVICE_UNIT}"
355
+ fi
356
+ [ "$control_group" = "/system.slice/${SERVICE_UNIT}" ] || return 2
357
+ cgroup_procs="${SERVICE_CGROUP_PROCS_FILE:-/sys/fs/cgroup${control_group}/cgroup.procs}"
358
+ cgroup_dir="${SERVICE_CGROUP_DIR:-${cgroup_procs%/cgroup.procs}}"
359
+ cgroup_events="${SERVICE_CGROUP_EVENTS_FILE:-${cgroup_dir}/cgroup.events}"
360
+
361
+ if probe_cgroup_directory "$cgroup_dir"; then
362
+ [ -r "$cgroup_procs" ] || return 2
363
+ [ -r "$cgroup_events" ] || return 2
364
+ read_cgroup_members "$cgroup_procs" || return $?
365
+ members="$CGROUP_MEMBERS"
366
+ [ -z "$members" ] || return 1
367
+ read_cgroup_population "$cgroup_events" || return $?
368
+ [ "$CGROUP_POPULATED" = "0" ] || return 1
369
+ # Check descendants as well as the unit's direct cgroup. A delegated child subtree is not
370
+ # quiescent merely because the parent cgroup.procs is empty.
371
+ local subtree_files symlinks
372
+ symlinks="$(find "$cgroup_dir" -type l -print 2>/dev/null)" || return 2
373
+ [ -z "$symlinks" ] || return 2
374
+ subtree_files="$(find "$cgroup_dir" -type f -name cgroup.procs -print 2>/dev/null)" || return 2
375
+ while IFS= read -r subtree_file; do
376
+ [ -n "$subtree_file" ] || continue
377
+ [ "$subtree_file" = "$cgroup_procs" ] && continue
378
+ read_cgroup_members "$subtree_file" || return $?
379
+ [ -z "$CGROUP_MEMBERS" ] || return 1
380
+ done <<< "$subtree_files"
381
+ current_baseline="$(stopped_service_identity)" || return 2
382
+ [ "$current_baseline" = "$baseline" ] || return 1
383
+ manager_processes_are_empty || return $?
384
+ return 0
385
+ else
386
+ probe_status=$?
387
+ fi
388
+ [ "$probe_status" -eq 1 ] || return 2
389
+
390
+ # A removed original cgroup is valid only when the cgroup-v2 mount and its stable parent remain
391
+ # readable; manager enumeration above is the independent proof that no unit process survived.
392
+ current_baseline="$(stopped_service_identity)" || return 2
393
+ [ "$current_baseline" = "$baseline" ] || return 1
394
+ manager_processes_are_empty || return $?
395
+ return 0
396
+ }
397
+
398
+ wait_for_stopped_service() {
399
+ local deadline=$((SECONDS + QUIESCENCE_TIMEOUT_SECONDS)) result
400
+
401
+ while [ "$SECONDS" -le "$deadline" ]; do
402
+ if stopped_service_quiesced; then return 0; else result=$?; fi
403
+ [ "$result" -eq 2 ] || sleep 1
404
+ [ "$result" -eq 2 ] && return 2
405
+ done
406
+ return 1
407
+ }
408
+
409
+ target_health_is_valid() {
410
+ local payload
411
+
412
+ [ -n "$HEALTH_URL" ] || return 1
413
+ payload="$(curl -fsS --max-time 5 "$HEALTH_URL" 2>/dev/null)" || return 1
414
+
415
+ printf '%s' "$payload" | HEALTH_EXPECTED_VERSION="$HEALTH_EXPECTED_VERSION" node -e '
416
+ let input = "";
417
+ process.stdin.setEncoding("utf8");
418
+ process.stdin.on("data", (chunk) => { input += chunk; });
419
+ process.stdin.on("end", () => {
420
+ try {
421
+ const body = JSON.parse(input);
422
+ const data = body && body.data;
423
+ process.exit(data?.status === "ok" && data.version === process.env.HEALTH_EXPECTED_VERSION ? 0 : 1);
424
+ } catch {
425
+ process.exit(1);
426
+ }
427
+ });
428
+ '
429
+ }
430
+
431
+ target_process_is_stable() {
432
+ local state main_pid start_identity resolved_target expected_target
433
+
434
+ state="$(service_property ActiveState 2>/dev/null || true)"
435
+ main_pid="$(service_property MainPID 2>/dev/null || true)"
436
+ start_identity="$(service_property ExecMainStartTimestampMonotonic 2>/dev/null || true)"
437
+ resolved_target="$(readlink -f "$CURRENT_LINK" 2>/dev/null || true)"
438
+ expected_target="$(realpath "$NEW_VERSION_DIR" 2>/dev/null || true)"
439
+
440
+ [ "$state" = "active" ] || return 1
441
+ [ "$main_pid" != "0" ] && [ -n "$main_pid" ] || return 1
442
+ [ -n "$start_identity" ] || return 1
443
+ [ -n "$CURRENT_LINK" ] && [ -n "$NEW_VERSION_DIR" ] || return 1
444
+ [ -n "$expected_target" ] && [ "$resolved_target" = "$expected_target" ] || return 1
445
+ }
446
+
447
+ process_identity() {
448
+ local pid="$1"
449
+ local proc_root="${SERVICE_PROCESS_ROOT:-/proc}"
450
+ local proc_dir="${proc_root}/${pid}"
451
+ local stat_line start_time executable
452
+
453
+ [ "$pid" != "0" ] && [ -n "$pid" ] || return 1
454
+
455
+ # SERVICE_PROCESS_ROOT is a private fixture hook; the production default is /proc.
456
+ if [ -r "${proc_dir}/identity" ]; then
457
+ printf '%s|%s\n' "$pid" "$(cat "${proc_dir}/identity")"
458
+ return 0
459
+ fi
460
+
461
+ if [ -r "${proc_dir}/stat" ]; then
462
+ stat_line="$(cat "${proc_dir}/stat")" || return 2
463
+ start_time="$(printf '%s\n' "$stat_line" | awk '{ sub(/^.*\) /, ""); print $20 }')" || return 2
464
+ case "$start_time" in ''|*[!0-9]*) return 2 ;; esac
465
+ [ "$start_time" -gt 0 ] 2>/dev/null || return 2
466
+ executable="$(readlink "${proc_dir}/exe" 2>/dev/null)" || return 2
467
+ [ -n "$executable" ] || return 2
468
+ printf '%s|%s|%s\n' "$pid" "$start_time" "$executable"
469
+ return 0
470
+ fi
471
+ [ "$proc_root" = "/proc" ] || return 1
472
+
473
+ if ! kill -0 "$pid" 2>/dev/null; then
474
+ return 1
475
+ fi
476
+ # A live process without readable /proc start ticks and executable has unknown identity.
477
+ return 2
478
+ }
479
+
480
+ read_cgroup_file() {
481
+ CGROUP_FILE_PATH="$1" node -e '
482
+ const fs = require("node:fs");
483
+ let fd;
484
+ try {
485
+ fd = fs.openSync(process.env.CGROUP_FILE_PATH, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
486
+ if (!fs.fstatSync(fd).isFile()) process.exit(2);
487
+ const chunks = [];
488
+ let size = 0;
489
+ while (true) {
490
+ const buffer = Buffer.allocUnsafe(4096);
491
+ const count = fs.readSync(fd, buffer, 0, buffer.length, null);
492
+ if (count === 0) break;
493
+ size += count;
494
+ if (size > 65536) process.exit(2);
495
+ chunks.push(buffer.subarray(0, count));
496
+ }
497
+ process.stdout.write(Buffer.concat(chunks));
498
+ } catch { process.exitCode = 2; }
499
+ finally { if (fd !== undefined) fs.closeSync(fd); }
500
+ '
501
+ }
502
+
503
+ read_cgroup_members() {
504
+ local cgroup_procs="$1"
505
+ local contents members
506
+
507
+ CGROUP_MEMBERS=""
508
+ if ! contents="$(read_cgroup_file "$cgroup_procs" 2>/dev/null)"; then
509
+ return 2
510
+ fi
511
+
512
+ if ! members="$(printf '%s\n' "$contents" | awk '
513
+ length($0) > 0 {
514
+ if ($0 !~ /^[0-9]+$/) {
515
+ exit 2
516
+ }
517
+ print
518
+ }
519
+ ')"; then
520
+ return 2
521
+ fi
522
+
523
+ CGROUP_MEMBERS="$members"
524
+ }
525
+
526
+ read_cgroup_population() {
527
+ local cgroup_events="$1"
528
+ local contents populated
529
+
530
+ CGROUP_POPULATED=""
531
+ if ! contents="$(read_cgroup_file "$cgroup_events" 2>/dev/null)"; then
532
+ return 2
533
+ fi
534
+
535
+ if ! populated="$(printf '%s\n' "$contents" | awk '
536
+ $1 == "populated" {
537
+ count++
538
+ if (NF != 2 || $2 !~ /^[01]$/) {
539
+ invalid=1
540
+ } else {
541
+ value=$2
542
+ }
543
+ }
544
+ END {
545
+ if (count != 1 || invalid) {
546
+ exit 2
547
+ }
548
+ print value
549
+ }
550
+ ')"; then
551
+ return 2
552
+ fi
553
+
554
+ CGROUP_POPULATED="$populated"
555
+ }
556
+
557
+ probe_cgroup_directory() {
558
+ local cgroup_dir="$1"
559
+ local output result mount_id parent_id dir_id events_id namespace_id
560
+
561
+ # lstat keeps a dangling link, permission error, and a genuinely absent cgroup distinct.
562
+ # The mount and parent are identified both inside the probe and across all worker checks.
563
+ # Compare the events node too: kernfs can reinitialize directory timestamps without replacing the cgroup.
564
+ # JavaScript template literals are intentionally passed verbatim.
565
+ # shellcheck disable=SC2016
566
+ if output="$(CGROUP_PROBE_PATH="$cgroup_dir" CGROUP_PROBE_EVENTS="${SERVICE_CGROUP_EVENTS_FILE:-${cgroup_dir}/cgroup.events}" CGROUP_PROBE_MOUNT="${SERVICE_CGROUP_MOUNT:-/sys/fs/cgroup}" node -e '
567
+ const fs = require("node:fs");
568
+ const path = require("node:path");
569
+ const mount = process.env.CGROUP_PROBE_MOUNT;
570
+ const dir = process.env.CGROUP_PROBE_PATH;
571
+ if (!path.isAbsolute(mount) || !path.isAbsolute(dir) || !dir.startsWith(`${mount}/`)) process.exit(2);
572
+ const parent = path.dirname(dir);
573
+ function directoryIdentity(name) {
574
+ const stat = fs.lstatSync(name, { bigint: true });
575
+ if (!stat.isDirectory()) process.exit(2);
576
+ return `${stat.dev}:${stat.ino}`;
577
+ }
578
+ function fileIdentity(name) {
579
+ const stat = fs.lstatSync(name, { bigint: true });
580
+ if (!stat.isFile()) process.exit(2);
581
+ return `${stat.dev}:${stat.ino}`;
582
+ }
583
+ try {
584
+ const mountId = directoryIdentity(mount);
585
+ let namespaceId;
586
+ try { namespaceId = `${fs.readlinkSync("/proc/self/ns/mnt")}:${fs.readlinkSync("/proc/self/ns/cgroup")}`; }
587
+ catch {
588
+ if (!process.env.SERVICE_CGROUP_MOUNT_TYPE) process.exit(2);
589
+ namespaceId = "fixture-namespace";
590
+ }
591
+ if (process.env.SERVICE_CGROUP_MOUNT_TYPE) {
592
+ if (process.env.SERVICE_CGROUP_MOUNT_TYPE !== "cgroup2fs") process.exit(2);
593
+ } else if (fs.statfsSync(mount, { bigint: true }).type !== 0x63677270n) process.exit(2);
594
+ const parentId = directoryIdentity(parent);
595
+ let dirId = "-";
596
+ let eventsId = "-";
597
+ let absent = false;
598
+ try { dirId = directoryIdentity(dir); }
599
+ catch (error) {
600
+ if (error.code !== "ENOENT") process.exit(2);
601
+ absent = true;
602
+ }
603
+ if (!absent) eventsId = fileIdentity(process.env.CGROUP_PROBE_EVENTS);
604
+ if (directoryIdentity(mount) !== mountId || directoryIdentity(parent) !== parentId) process.exit(2);
605
+ if (absent) {
606
+ try { fs.lstatSync(dir); process.exit(2); }
607
+ catch (error) { if (error.code !== "ENOENT") process.exit(2); }
608
+ }
609
+ process.stdout.write(`${mountId}|${parentId}|${dirId}|${eventsId}|${namespaceId}`);
610
+ process.exit(absent ? 1 : 0);
611
+ } catch { process.exit(2); }
612
+ ' 2>/dev/null)"; then
613
+ result=0
614
+ else
615
+ result=$?
616
+ fi
617
+ [ "$result" -eq 0 ] || [ "$result" -eq 1 ] || return 2
618
+ IFS='|' read -r mount_id parent_id dir_id events_id namespace_id <<< "$output"
619
+ [ -n "$mount_id" ] && [ -n "$parent_id" ] && [ -n "$dir_id" ] && [ -n "$events_id" ] && [ -n "$namespace_id" ] || return 2
620
+ if [ -n "$CGROUP_PROBE_MOUNT_ID" ]; then
621
+ [ "$CGROUP_PROBE_MOUNT_ID" = "$mount_id" ] && [ "$CGROUP_PROBE_PARENT_ID" = "$parent_id" ] && \
622
+ [ "$CGROUP_PROBE_NAMESPACE_ID" = "$namespace_id" ] || return 2
623
+ else
624
+ CGROUP_PROBE_MOUNT_ID="$mount_id"
625
+ CGROUP_PROBE_PARENT_ID="$parent_id"
626
+ CGROUP_PROBE_NAMESPACE_ID="$namespace_id"
627
+ fi
628
+ if [ "$result" -eq 1 ]; then
629
+ CGROUP_PROBE_ABSENT="true"
630
+ return 1
631
+ fi
632
+ [ "$CGROUP_PROBE_ABSENT" = "false" ] || return 2
633
+ if [ -n "$CGROUP_PROBE_DIR_ID" ]; then
634
+ [ "$CGROUP_PROBE_DIR_ID" = "$dir_id" ] && [ "$CGROUP_PROBE_EVENTS_ID" = "$events_id" ] || return 2
635
+ else
636
+ CGROUP_PROBE_DIR_ID="$dir_id"
637
+ CGROUP_PROBE_EVENTS_ID="$events_id"
638
+ fi
639
+ return 0
640
+ }
641
+
642
+ capture_service_identity() {
643
+ local active_state main_pid control_pid control_group cgroup_procs cgroup_dir cgroup_events members pid identity member_status
644
+
645
+ active_state="$(service_property ActiveState 2>/dev/null)" || return 2
646
+ main_pid="$(service_property MainPID 2>/dev/null)" || return 2
647
+ control_pid="$(service_property ControlPID 2>/dev/null)" || return 2
648
+ control_group="$(service_property ControlGroup 2>/dev/null)" || return 2
649
+
650
+ case "$active_state" in
651
+ active|activating) ;;
652
+ *) return 1 ;;
653
+ esac
654
+
655
+ [ "$main_pid" -gt 0 ] 2>/dev/null || return 1
656
+ [ "$control_pid" = "0" ] || [ "$control_pid" -gt 0 ] 2>/dev/null || return 2
657
+ [ -n "$control_group" ] || return 2
658
+
659
+ cgroup_procs="${SERVICE_CGROUP_PROCS_FILE:-/sys/fs/cgroup${control_group}/cgroup.procs}"
660
+ cgroup_dir="${SERVICE_CGROUP_DIR:-${cgroup_procs%/cgroup.procs}}"
661
+ cgroup_events="${SERVICE_CGROUP_EVENTS_FILE:-${cgroup_dir}/cgroup.events}"
662
+ if probe_cgroup_directory "$cgroup_dir"; then :; else return 2; fi
663
+ [ -r "$cgroup_procs" ] || return 2
664
+ [ -r "$cgroup_events" ] || return 2
665
+ if read_cgroup_members "$cgroup_procs"; then
666
+ member_status=0
667
+ else
668
+ member_status=$?
669
+ fi
670
+ [ "$member_status" -eq 0 ] || return "$member_status"
671
+ members="$CGROUP_MEMBERS"
672
+ [ -n "$members" ] || return 1
673
+
674
+ CAPTURED_SERVICE_MEMBER_IDENTITIES=""
675
+ while IFS= read -r pid; do
676
+ [ -n "$pid" ] || continue
677
+ identity="$(process_identity "$pid")" || return 2
678
+ CAPTURED_SERVICE_MEMBER_IDENTITIES="${CAPTURED_SERVICE_MEMBER_IDENTITIES}${identity}\n"
679
+ done <<EOF
680
+ $members
681
+ EOF
682
+
683
+ printf '%s\n' "$members" | grep -qx "$main_pid" || return 1
684
+ CAPTURED_SERVICE_CGROUP_PROCS="$cgroup_procs"
685
+ CAPTURED_SERVICE_CGROUP_DIR="$cgroup_dir"
686
+ CAPTURED_SERVICE_CGROUP_EVENTS="$cgroup_events"
687
+ }
688
+
689
+ service_quiesced() {
690
+ local active_state main_pid control_pid cgroup_dir_status member_status population_status probe_status pid captured_identity current_identity identity_status
691
+
692
+ [ -n "$CAPTURED_SERVICE_CGROUP_PROCS" ] || return 2
693
+ active_state="$(service_property ActiveState 2>/dev/null)" || return 2
694
+ main_pid="$(service_property MainPID 2>/dev/null)" || return 2
695
+ control_pid="$(service_property ControlPID 2>/dev/null)" || return 2
696
+
697
+ case "$active_state" in
698
+ inactive|failed) ;;
699
+ *) return 1 ;;
700
+ esac
701
+
702
+ [ "$main_pid" = "0" ] || return 1
703
+ [ "$control_pid" = "0" ] || return 1
704
+
705
+ # Check every member captured before stop independently. This catches KillMode=process
706
+ # survivors even when systemd has already removed the original cgroup directory.
707
+ while IFS= read -r captured_identity; do
708
+ [ -n "$captured_identity" ] || continue
709
+ pid="${captured_identity%%|*}"
710
+ current_identity=""
711
+ if current_identity="$(process_identity "$pid")"; then
712
+ identity_status=0
713
+ else
714
+ identity_status=$?
715
+ fi
716
+ case "$identity_status" in
717
+ 0) [ "$current_identity" != "$captured_identity" ] || return 1 ;;
718
+ 1) ;;
719
+ *) return 2 ;;
720
+ esac
721
+ done <<EOF
722
+ $(printf '%b' "$CAPTURED_SERVICE_MEMBER_IDENTITIES")
723
+ EOF
724
+
725
+ # The original cgroup may be empty or removed after the service reaches inactive/failed with
726
+ # zero MainPID/ControlPID. A missing membership file while its directory remains is an
727
+ # unavailable probe, not proof that the subtree is empty.
728
+ if probe_cgroup_directory "$CAPTURED_SERVICE_CGROUP_DIR"; then
729
+ if read_cgroup_members "$CAPTURED_SERVICE_CGROUP_PROCS"; then
730
+ member_status=0
731
+ else
732
+ member_status=$?
733
+ fi
734
+ if [ "$member_status" -eq 2 ]; then
735
+ if probe_cgroup_directory "$CAPTURED_SERVICE_CGROUP_DIR"; then
736
+ return 2
737
+ else
738
+ probe_status=$?
739
+ fi
740
+ [ "$probe_status" -eq 1 ] || return 2
741
+ return 0
742
+ fi
743
+ [ "$member_status" -eq 0 ] || return "$member_status"
744
+ [ -z "$CGROUP_MEMBERS" ] || return 1
745
+
746
+ if read_cgroup_population "$CAPTURED_SERVICE_CGROUP_EVENTS"; then
747
+ population_status=0
748
+ else
749
+ population_status=$?
750
+ fi
751
+ if [ "$population_status" -eq 2 ]; then
752
+ if probe_cgroup_directory "$CAPTURED_SERVICE_CGROUP_DIR"; then
753
+ return 2
754
+ else
755
+ probe_status=$?
756
+ fi
757
+ [ "$probe_status" -eq 1 ] || return 2
758
+ return 0
759
+ fi
760
+ [ "$population_status" -eq 0 ] || return "$population_status"
761
+ [ "$CGROUP_POPULATED" = "0" ] || return 1
762
+ else
763
+ cgroup_dir_status=$?
764
+ case "$cgroup_dir_status" in
765
+ 1) ;;
766
+ *) return 2 ;;
767
+ esac
768
+ fi
769
+
770
+ return 0
771
+ }
772
+
773
+ wait_for_quiescence() {
774
+ local deadline=$((SECONDS + QUIESCENCE_TIMEOUT_SECONDS))
775
+ local result
776
+
777
+ while [ "$SECONDS" -le "$deadline" ]; do
778
+ if service_quiesced; then
779
+ return 0
780
+ else
781
+ result=$?
782
+ fi
783
+ if [ "$result" -eq 2 ]; then
784
+ return 2
785
+ fi
786
+
787
+ sleep 1
788
+ done
789
+
790
+ return 1
791
+ }
792
+
793
+ wait_for_started_service() {
794
+ local deadline=$((SECONDS + START_TIMEOUT_SECONDS))
795
+ local previous_main_pid=""
796
+ local previous_start_identity=""
797
+ local before_main_pid before_start_identity main_pid start_identity
798
+
799
+ while [ "$SECONDS" -le "$deadline" ]; do
800
+ if target_process_is_stable; then
801
+ before_main_pid="$(service_property MainPID 2>/dev/null || true)"
802
+ before_start_identity="$(service_property ExecMainStartTimestampMonotonic 2>/dev/null || true)"
803
+
804
+ if [ "$before_main_pid" = "0" ] || [ -z "$before_main_pid" ] || [ -z "$before_start_identity" ]; then
805
+ previous_main_pid=""
806
+ previous_start_identity=""
807
+ sleep 1
808
+ continue
809
+ fi
810
+
811
+ if target_health_is_valid && target_process_is_stable; then
812
+ main_pid="$(service_property MainPID 2>/dev/null || true)"
813
+ start_identity="$(service_property ExecMainStartTimestampMonotonic 2>/dev/null || true)"
814
+
815
+ if [ "$before_main_pid" = "$main_pid" ] &&
816
+ [ "$before_start_identity" = "$start_identity" ]; then
817
+ if [ "$main_pid" = "$previous_main_pid" ] &&
818
+ [ "$start_identity" = "$previous_start_identity" ]; then
819
+ return 0
820
+ fi
821
+
822
+ previous_main_pid="$main_pid"
823
+ previous_start_identity="$start_identity"
824
+ else
825
+ previous_main_pid=""
826
+ previous_start_identity=""
827
+ fi
828
+ else
829
+ previous_main_pid=""
830
+ previous_start_identity=""
831
+ fi
832
+ else
833
+ previous_main_pid=""
834
+ previous_start_identity=""
835
+ fi
836
+
837
+ sleep 1
838
+ done
839
+
840
+ return 1
841
+ }
9
842
 
10
843
  # Capture once — used for all status writes so timeout detection works correctly
11
844
  STARTED_AT="$(date -Iseconds)"
@@ -45,7 +878,17 @@ update_status() {
45
878
  cleanup() {
46
879
  local exit_code=$?
47
880
 
48
- if [ $exit_code -ne 0 ]; then
881
+ if [ -n "${BACKUP_TMP_PATH:-}" ]; then
882
+ rm -f "$BACKUP_TMP_PATH" 2>/dev/null || true
883
+ BACKUP_TMP_PATH=""
884
+ fi
885
+ if [ "$exit_code" -ne 0 ] && [ "$FINALIZED" != "true" ]; then
886
+ if [ -n "$ATTEMPT_ID" ] && { [ "$MIGRATION_ENTERED" = "true" ] || phase_requires_hold; }; then
887
+ hold_attempt "interrupted" "Update worker exited with code $exit_code during a state-changing phase"
888
+ elif [ -n "$ATTEMPT_ID" ]; then
889
+ finish_before_migration "failed" "Update process exited with code $exit_code"
890
+ fi
891
+
49
892
  # Only write a generic failure if a specific error wasn't already recorded
50
893
  if [ -f "$STATUS_FILE" ] && grep -q '"status": "failed"' "$STATUS_FILE" 2>/dev/null; then
51
894
  return
@@ -56,6 +899,27 @@ cleanup() {
56
899
 
57
900
  trap cleanup EXIT
58
901
 
902
+ handle_signal() {
903
+ local signal_name="$1"
904
+ local signal_code="$2"
905
+
906
+ if [ -n "$ATTEMPT_ID" ]; then
907
+ if [ "$MIGRATION_ENTERED" = "true" ] || phase_requires_hold; then
908
+ hold_attempt "interrupted" "Update worker received SIG${signal_name} after migration entry"
909
+ else
910
+ finish_before_migration "interrupted" "Update worker received SIG${signal_name} before migration entry"
911
+ fi
912
+ fi
913
+
914
+ update_status "failed" "failed" "Update worker received SIG${signal_name}"
915
+ FINALIZED="true"
916
+ exit "$signal_code"
917
+ }
918
+
919
+ trap 'handle_signal TERM 143' TERM
920
+ trap 'handle_signal INT 130' INT
921
+ trap 'handle_signal HUP 129' HUP
922
+
59
923
  # ──────────────────────────────────────────────────────────────
60
924
  # Image-based update (Raspbian image installs)
61
925
  # ──────────────────────────────────────────────────────────────
@@ -64,6 +928,85 @@ if [ "$INSTALL_TYPE" = "image" ]; then
64
928
  CLEAN_VERSION="${VERSION#v}"
65
929
  NEW_VERSION_DIR="${IMAGE_BASE_DIR}/v${CLEAN_VERSION}"
66
930
  CURRENT_LINK="${IMAGE_BASE_DIR}/current"
931
+ ENV_FILE="${ENV_FILE:-/etc/smart-panel/environment}"
932
+
933
+ if [ "$UPDATE_START_MODE" = "stopped-maintenance" ]; then
934
+ case "$VERSION" in latest|vlatest|'')
935
+ update_status "failed" "failed" "Stopped maintenance requires an exact target version"
936
+ FINALIZED="true"
937
+ exit 1
938
+ ;; esac
939
+ for required in EXPECTED_CURRENT_VERSION TARGET_ARCHIVE_SHA256 TARGET_WORKER_SHA256 EXPECTED_IMAGE_BASE_DIR \
940
+ EXPECTED_STATUS_FILE EXPECTED_ATTEMPT_DIR EXPECTED_DB_PATH DB_BACKUP_PATH; do
941
+ if [ -z "${!required:-}" ]; then
942
+ update_status "failed" "failed" "Stopped maintenance identity is missing: ${required}"
943
+ FINALIZED="true"
944
+ exit 1
945
+ fi
946
+ done
947
+ TARGET_ARCHIVE_SHA256="$(printf '%s' "$TARGET_ARCHIVE_SHA256" | tr '[:upper:]' '[:lower:]')"
948
+ TARGET_WORKER_SHA256="$(printf '%s' "$TARGET_WORKER_SHA256" | tr '[:upper:]' '[:lower:]')"
949
+ case "$TARGET_ARCHIVE_SHA256$TARGET_WORKER_SHA256" in *[!0123456789abcdef]*)
950
+ update_status "failed" "failed" "Stopped maintenance checksum is invalid"
951
+ FINALIZED="true"
952
+ exit 1
953
+ ;; esac
954
+ [ "${#TARGET_ARCHIVE_SHA256}" -eq 64 ] && [ "${#TARGET_WORKER_SHA256}" -eq 64 ] || {
955
+ update_status "failed" "failed" "Stopped maintenance checksum must be SHA-256"
956
+ FINALIZED="true"
957
+ exit 1
958
+ }
959
+ [ "$EXPECTED_IMAGE_BASE_DIR" = "$IMAGE_BASE_DIR" ] || {
960
+ update_status "failed" "failed" "Stopped maintenance image base identity mismatch"
961
+ FINALIZED="true"
962
+ exit 1
963
+ }
964
+ [ "$EXPECTED_STATUS_FILE" = "$STATUS_FILE" ] || {
965
+ update_status "failed" "failed" "Stopped maintenance status identity mismatch"
966
+ FINALIZED="true"
967
+ exit 1
968
+ }
969
+ [ "$EXPECTED_ATTEMPT_DIR" = "$ATTEMPT_DIR" ] || {
970
+ update_status "failed" "failed" "Stopped maintenance attempt identity mismatch"
971
+ FINALIZED="true"
972
+ exit 1
973
+ }
974
+ [ "$EXPECTED_DB_PATH" = "${FB_DB_PATH:-}" ] || {
975
+ update_status "failed" "failed" "Stopped maintenance database identity mismatch"
976
+ FINALIZED="true"
977
+ exit 1
978
+ }
979
+ if [ -f "$ENV_FILE" ]; then
980
+ configured_db_path="$(
981
+ set -a
982
+ # shellcheck source=/dev/null
983
+ . "$ENV_FILE"
984
+ printf '%s' "${FB_DB_PATH:-}"
985
+ )"
986
+ [ "$configured_db_path" = "$EXPECTED_DB_PATH" ] || \
987
+ fail_before_migration "preflight" "Configured database identity does not match stopped-maintenance packet"
988
+ fi
989
+ if [ -e "$DB_BACKUP_PATH" ] || [ -L "$DB_BACKUP_PATH" ]; then
990
+ fail_before_migration "preflight" "Stopped maintenance backup path already exists"
991
+ fi
992
+ SOURCE_DB_PATH="${FB_DB_PATH%/}/database.sqlite"
993
+ source_database_is_valid || fail_before_migration "preflight" "Stopped maintenance source database is missing, symlinked, or empty"
994
+ fi
995
+
996
+ # Image updates must verify the target release through the existing local health route. A
997
+ # missing route would otherwise silently reduce readiness to a PID check and reintroduce the
998
+ # startup/worker circular wait that the updater is designed to avoid.
999
+ if [ -z "$HEALTH_URL" ]; then
1000
+ update_status "failed" "failed" "No local health URL configured for image update"
1001
+ FINALIZED="true"
1002
+ exit 1
1003
+ fi
1004
+
1005
+ if ! acquire_attempt; then
1006
+ update_status "failed" "failed" "An unresolved update attempt already owns the updater"
1007
+ FINALIZED="true"
1008
+ exit 1
1009
+ fi
67
1010
 
68
1011
  # Verify passwordless sudo is available for all required commands.
69
1012
  # Without -n, sudo may hang waiting for a password on a detached process.
@@ -77,8 +1020,7 @@ if [ "$INSTALL_TYPE" = "image" ]; then
77
1020
 
78
1021
  for cmd in "${SUDO_CMDS[@]}"; do
79
1022
  if ! sudo -n -l $cmd >/dev/null 2>&1; then
80
- update_status "failed" "failed" "Passwordless sudo not available for: $cmd"
81
- exit 1
1023
+ fail_before_migration "preflight" "Passwordless sudo not available for: $cmd"
82
1024
  fi
83
1025
  done
84
1026
  PREVIOUS_TARGET=""
@@ -86,6 +1028,24 @@ if [ "$INSTALL_TYPE" = "image" ]; then
86
1028
  # Save the current version for rollback
87
1029
  if [ -L "$CURRENT_LINK" ]; then
88
1030
  PREVIOUS_TARGET=$(readlink "$CURRENT_LINK")
1031
+ case "$PREVIOUS_TARGET" in
1032
+ /*) ;;
1033
+ *) PREVIOUS_TARGET="${IMAGE_BASE_DIR}/${PREVIOUS_TARGET}" ;;
1034
+ esac
1035
+ fi
1036
+
1037
+ if [ "$UPDATE_START_MODE" = "stopped-maintenance" ]; then
1038
+ EXPECTED_CURRENT_LINK="${IMAGE_BASE_DIR}/v${EXPECTED_CURRENT_VERSION#v}"
1039
+ RESOLVED_CURRENT="$(realpath "$CURRENT_LINK" 2>/dev/null || true)"
1040
+ RESOLVED_EXPECTED="$(realpath "$EXPECTED_CURRENT_LINK" 2>/dev/null || true)"
1041
+ [ -n "$RESOLVED_CURRENT" ] && [ "$RESOLVED_CURRENT" = "$RESOLVED_EXPECTED" ] || \
1042
+ fail_before_migration "preflight" "Current release/link identity does not match stopped-maintenance packet"
1043
+ if ! wait_for_stopped_service; then
1044
+ hold_attempt "stopping" "Stopped service did not reach verified quiescence"
1045
+ update_status "failed" "failed" "Stopped service quiescence could not be verified; update is held for recovery"
1046
+ FINALIZED="true"
1047
+ exit 1
1048
+ fi
89
1049
  fi
90
1050
 
91
1051
  # Guard: refuse to overwrite the currently running version
@@ -94,25 +1054,28 @@ if [ "$INSTALL_TYPE" = "image" ]; then
94
1054
  RESOLVED_NEW=$(cd "$IMAGE_BASE_DIR" && realpath "$NEW_VERSION_DIR" 2>/dev/null || true)
95
1055
 
96
1056
  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
1057
+ fail_before_migration "preflight" "Target version v${CLEAN_VERSION} is already the active version"
99
1058
  fi
100
1059
 
101
1060
  # ── Download ──
102
1061
  update_status "downloading" "downloading"
103
1062
 
104
1063
  if [ -z "$DOWNLOAD_URL" ]; then
105
- update_status "failed" "failed" "No download URL provided for image update"
106
- exit 1
1064
+ fail_before_migration "downloading" "No download URL provided for image update"
107
1065
  fi
108
1066
 
109
1067
  TMP_TARBALL="/tmp/smart-panel-backend-v${CLEAN_VERSION}.tar.gz"
110
1068
 
111
1069
  curl -fSL -o "$TMP_TARBALL" "$DOWNLOAD_URL" 2>&1 || {
112
- update_status "failed" "failed" "Download failed from ${DOWNLOAD_URL}"
113
- exit 1
1070
+ fail_before_migration "downloading" "Download failed from ${DOWNLOAD_URL}"
114
1071
  }
115
1072
 
1073
+ if [ "$UPDATE_START_MODE" = "stopped-maintenance" ]; then
1074
+ actual_archive_sha256="$(sha256sum "$TMP_TARBALL" 2>/dev/null | awk '{print $1}')"
1075
+ [ "$actual_archive_sha256" = "$TARGET_ARCHIVE_SHA256" ] || \
1076
+ fail_before_migration "downloading" "Downloaded archive checksum does not match maintenance packet"
1077
+ fi
1078
+
116
1079
  # ── Extract ──
117
1080
  update_status "installing" "installing"
118
1081
 
@@ -121,17 +1084,36 @@ if [ "$INSTALL_TYPE" = "image" ]; then
121
1084
  tar -xzf "$TMP_TARBALL" -C "$NEW_VERSION_DIR" 2>&1 || {
122
1085
  rm -rf "$NEW_VERSION_DIR"
123
1086
  rm -f "$TMP_TARBALL"
124
- update_status "failed" "failed" "Failed to extract update archive"
125
- exit 1
1087
+ fail_before_migration "installing" "Failed to extract update archive"
126
1088
  }
127
1089
 
128
1090
  rm -f "$TMP_TARBALL"
129
1091
 
1092
+ if [ "$UPDATE_START_MODE" = "stopped-maintenance" ]; then
1093
+ target_worker="${NEW_VERSION_DIR}/dist/modules/system/scripts/update-worker.sh"
1094
+ [ -f "$target_worker" ] || {
1095
+ rm -rf "$NEW_VERSION_DIR"
1096
+ fail_before_migration "installing" "Target release worker is missing"
1097
+ }
1098
+ actual_worker_sha256="$(sha256sum "$target_worker" 2>/dev/null | awk '{print $1}')"
1099
+ [ "$actual_worker_sha256" = "$TARGET_WORKER_SHA256" ] || {
1100
+ rm -rf "$NEW_VERSION_DIR"
1101
+ fail_before_migration "installing" "Target release worker checksum does not match maintenance packet"
1102
+ }
1103
+ MAINTENANCE_DIR="${MAINTENANCE_DIR:-${ATTEMPT_DIR}/maintenance-${ATTEMPT_ID}}"
1104
+ case "$MAINTENANCE_DIR" in
1105
+ "$IMAGE_BASE_DIR"/*|"$NEW_VERSION_DIR"/*) fail_before_migration "installing" "Maintenance directory is inside a release tree" ;;
1106
+ esac
1107
+ mkdir -p "$MAINTENANCE_DIR"
1108
+ chmod 700 "$MAINTENANCE_DIR"
1109
+ cp "$target_worker" "$MAINTENANCE_DIR/update-worker.sh"
1110
+ chmod 700 "$MAINTENANCE_DIR/update-worker.sh"
1111
+ fi
1112
+
130
1113
  # Create the image-install marker in the new version
131
1114
  touch "${NEW_VERSION_DIR}/.image-install" || {
132
1115
  rm -rf "$NEW_VERSION_DIR"
133
- update_status "failed" "failed" "Failed to create image-install marker"
134
- exit 1
1116
+ fail_before_migration "installing" "Failed to create image-install marker"
135
1117
  }
136
1118
 
137
1119
  # ── Verify dependencies ──
@@ -143,8 +1125,7 @@ if [ "$INSTALL_TYPE" = "image" ]; then
143
1125
  if [ ! -d "node_modules" ]; then
144
1126
  npm install --omit=dev 2>&1 || {
145
1127
  rm -rf "$NEW_VERSION_DIR"
146
- update_status "failed" "failed" "npm install failed"
147
- exit 1
1128
+ fail_before_migration "installing" "npm install failed"
148
1129
  }
149
1130
  fi
150
1131
 
@@ -153,72 +1134,196 @@ if [ "$INSTALL_TYPE" = "image" ]; then
153
1134
  # hanging on password prompt when running as a detached process.
154
1135
  sudo -n chown -R smart-panel:smart-panel "$NEW_VERSION_DIR" || {
155
1136
  rm -rf "$NEW_VERSION_DIR"
156
- update_status "failed" "failed" "Failed to set ownership on ${NEW_VERSION_DIR}"
157
- exit 1
1137
+ fail_before_migration "installing" "Failed to set ownership on ${NEW_VERSION_DIR}"
158
1138
  }
159
1139
 
160
- # ── Stop service ──
1140
+ # ── Stop/qualify service ──
161
1141
  update_status "stopping" "stopping"
162
- sudo -n systemctl stop smart-panel 2>/dev/null || true
1142
+ write_attempt "active" "stopping" "" "false" "$MIGRATION_ENTERED"
1143
+ if [ "$UPDATE_START_MODE" = "stopped-maintenance" ]; then
1144
+ if ! wait_for_stopped_service; then
1145
+ hold_attempt "stopping" "Stopped service changed or failed quiescence recheck"
1146
+ update_status "failed" "failed" "Stopped service quiescence could not be rechecked; update is held for recovery"
1147
+ FINALIZED="true"
1148
+ exit 1
1149
+ fi
1150
+ else
1151
+ if ! capture_service_identity; then
1152
+ hold_attempt "stopping" "Could not capture service process/cgroup identity before stop"
1153
+ update_status "failed" "failed" "Service process/cgroup identity could not be captured; update is held for recovery"
1154
+ FINALIZED="true"
1155
+ exit 1
1156
+ fi
1157
+
1158
+ if ! sudo -n systemctl stop "$SERVICE_NAME" 2>/dev/null; then
1159
+ hold_attempt "stopping" "Service stop failed; writer quiescence is unknown"
1160
+ update_status "failed" "failed" "Could not stop ${SERVICE_NAME}; update is held for recovery"
1161
+ FINALIZED="true"
1162
+ exit 1
1163
+ fi
1164
+
1165
+ if ! wait_for_quiescence; then
1166
+ hold_attempt "stopping" "Service did not reach verified quiescence before migration"
1167
+ update_status "failed" "failed" "Service quiescence could not be verified; update is held for recovery"
1168
+ FINALIZED="true"
1169
+ exit 1
1170
+ fi
1171
+
1172
+ write_attempt "active" "quiesced" "" "false" "$MIGRATION_ENTERED"
1173
+ fi
1174
+
1175
+ if [ "$UPDATE_START_MODE" = "stopped-maintenance" ]; then
1176
+ # A consistent backup is an input safeguard, never a rollback mechanism. SQLite's own
1177
+ # backup API is required so a copied WAL database cannot be mistaken for a snapshot.
1178
+ command -v sqlite3 >/dev/null 2>&1 || {
1179
+ rm -rf "$NEW_VERSION_DIR"
1180
+ fail_before_migration "quiesced" "sqlite3 is required for stopped-maintenance backup"
1181
+ }
1182
+ if [ -e "$DB_BACKUP_PATH" ] || [ -L "$DB_BACKUP_PATH" ]; then
1183
+ rm -rf "$NEW_VERSION_DIR"
1184
+ fail_before_migration "quiesced" "Stopped maintenance backup path was created during the run"
1185
+ fi
1186
+ source_database_is_valid || {
1187
+ rm -rf "$NEW_VERSION_DIR"
1188
+ fail_before_migration "quiesced" "Stopped maintenance source database became unavailable"
1189
+ }
1190
+ mkdir -p "$(dirname "$DB_BACKUP_PATH")" || {
1191
+ rm -rf "$NEW_VERSION_DIR"
1192
+ fail_before_migration "quiesced" "Could not create SQLite backup directory"
1193
+ }
1194
+ BACKUP_TMP_PATH="$(mktemp "${DB_BACKUP_PATH}.tmp-${ATTEMPT_ID}.XXXXXX")" || {
1195
+ rm -rf "$NEW_VERSION_DIR"
1196
+ fail_before_migration "quiesced" "Could not create temporary SQLite backup"
1197
+ }
1198
+ if ! sqlite3 "${SOURCE_DB_PATH}" ".backup '${BACKUP_TMP_PATH}'"; then
1199
+ rm -f "$BACKUP_TMP_PATH"
1200
+ BACKUP_TMP_PATH=""
1201
+ rm -rf "$NEW_VERSION_DIR"
1202
+ fail_before_migration "quiesced" "Consistent SQLite backup failed"
1203
+ fi
1204
+ if [ ! -s "$BACKUP_TMP_PATH" ]; then
1205
+ rm -f "$BACKUP_TMP_PATH"
1206
+ BACKUP_TMP_PATH=""
1207
+ rm -rf "$NEW_VERSION_DIR"
1208
+ fail_before_migration "quiesced" "Consistent SQLite backup is empty"
1209
+ fi
1210
+ if ! ln "$BACKUP_TMP_PATH" "$DB_BACKUP_PATH" 2>/dev/null; then
1211
+ rm -f "$BACKUP_TMP_PATH"
1212
+ BACKUP_TMP_PATH=""
1213
+ rm -rf "$NEW_VERSION_DIR"
1214
+ if [ -e "$DB_BACKUP_PATH" ] || [ -L "$DB_BACKUP_PATH" ]; then
1215
+ fail_before_migration "quiesced" "Stopped maintenance backup path was created during the run"
1216
+ fi
1217
+ fail_before_migration "quiesced" "Consistent SQLite backup publish failed"
1218
+ fi
1219
+ rm -f "$BACKUP_TMP_PATH"
1220
+ BACKUP_TMP_PATH=""
1221
+ fi
1222
+ if [ "$UPDATE_START_MODE" = "stopped-maintenance" ] && ! stopped_service_quiesced; then
1223
+ hold_attempt "quiesced" "Stopped service changed after backup before switch"
1224
+ update_status "failed" "failed" "Stopped service changed after backup; update is held for recovery"
1225
+ FINALIZED="true"
1226
+ exit 1
1227
+ fi
163
1228
 
164
1229
  # ── Switch symlink (atomic on same filesystem) ──
165
1230
  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"
1231
+ if [ "$UPDATE_START_MODE" = "stopped-maintenance" ]; then
1232
+ hold_attempt "switching" "Failed to switch version symlink in stopped maintenance"
1233
+ update_status "failed" "failed" "Failed to switch version symlink; update is held for recovery"
1234
+ FINALIZED="true"
1235
+ exit 1
169
1236
  fi
170
- sudo -n systemctl start smart-panel 2>/dev/null || true
171
- update_status "failed" "failed" "Failed to switch version symlink"
1237
+ # Revert only when the old link, target cleanup and old-service health are all verified.
1238
+ if restore_before_migration "$PREVIOUS_TARGET" "$CURRENT_LINK" "$NEW_VERSION_DIR"; then
1239
+ fail_before_migration "switching" "Failed to switch version symlink"
1240
+ fi
1241
+
1242
+ hold_attempt "switching" "Failed to switch version symlink and recovery start was not verified"
1243
+ update_status "failed" "failed" "Failed to switch version symlink; update is held for recovery"
1244
+ FINALIZED="true"
172
1245
  exit 1
173
1246
  }
174
1247
 
175
1248
  # ── Run database migrations ──
176
1249
  update_status "migrating" "migrating"
177
1250
 
178
- ENV_FILE="/etc/smart-panel/environment"
179
-
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
1251
+ if [ ! -f "${NEW_VERSION_DIR}/dist/dataSource.js" ]; then
1252
+ hold_attempt "migrating" "Target data source is missing; update is held for recovery"
1253
+ update_status "failed" "failed" "Target data source is missing; update is held for recovery"
1254
+ FINALIZED="true"
1255
+ exit 1
1256
+ fi
1257
+ if [ "$UPDATE_START_MODE" = "stopped-maintenance" ] && ! stopped_service_quiesced; then
1258
+ hold_attempt "migrating" "Stopped service changed before migration entry"
1259
+ update_status "failed" "failed" "Stopped service changed before migration entry; update is held for recovery"
1260
+ FINALIZED="true"
1261
+ exit 1
1262
+ fi
205
1263
 
206
- sudo -n systemctl start smart-panel 2>/dev/null || true
207
- exit 1
1264
+ MIGRATION_ENTERED="true"
1265
+ if ! write_attempt "active" "migrating" "" "false" "true"; then
1266
+ hold_attempt "migrating" "Could not persist migration-entry checkpoint"
1267
+ update_status "failed" "failed" "Could not persist migration-entry checkpoint; update is held for recovery"
1268
+ FINALIZED="true"
1269
+ exit 1
1270
+ fi
1271
+
1272
+ MIGRATION_LOG="${ATTEMPT_DIR}/${ATTEMPT_ID}.migration.log"
1273
+ : > "$MIGRATION_LOG"
1274
+ chmod 600 "$MIGRATION_LOG"
1275
+ write_attempt "active" "migrating" "" "false" "true"
1276
+
1277
+ set +e
1278
+ (
1279
+ set -a
1280
+ # shellcheck source=/dev/null
1281
+ [ -f "$ENV_FILE" ] && . "$ENV_FILE"
1282
+ set +a
1283
+ cd "$NEW_VERSION_DIR"
1284
+ node node_modules/typeorm/cli.js migration:run -d dist/dataSource.js
1285
+ ) 2>&1 | awk -v output="$MIGRATION_LOG" '
1286
+ BEGIN { limit = 65536; bytes = 0 }
1287
+ {
1288
+ if (bytes < limit) {
1289
+ line = $0 ORS;
1290
+ remaining = limit - bytes;
1291
+ if (length(line) > remaining) line = substr(line, 1, remaining);
1292
+ printf "%s", line >> output;
1293
+ bytes += length(line);
1294
+ }
208
1295
  }
1296
+ '
1297
+ pipeline_status=("${PIPESTATUS[@]}")
1298
+ migration_exit_code="${pipeline_status[0]}"
1299
+ capture_exit_code="${pipeline_status[1]}"
1300
+ set -e
1301
+
1302
+ if [ "$capture_exit_code" -ne 0 ]; then
1303
+ hold_attempt "migration_capture_failed" "Could not retain bounded migration diagnostics"
1304
+ update_status "failed" "failed" "Migration diagnostics could not be retained; update is held for recovery"
1305
+ FINALIZED="true"
1306
+ exit 1
1307
+ fi
1308
+
1309
+ if [ "$migration_exit_code" -eq 0 ]; then
1310
+ write_attempt "active" "migrated" "" "false" "true"
1311
+ else
1312
+ hold_attempt "migration_failed" "Database migration failed with exit code ${migration_exit_code}"
1313
+ update_status "failed" "failed" "Database migration failed; update is held for recovery"
1314
+ FINALIZED="true"
1315
+ exit "$migration_exit_code"
209
1316
  fi
210
1317
 
211
1318
  # ── Start service ──
212
1319
  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"
1320
+ write_attempt "active" "starting" "" "false" "true"
1321
+ if ! sudo -n systemctl start "$SERVICE_NAME" 2>&1 || ! wait_for_started_service; then
1322
+ hold_attempt "start_failed" "Target service did not reach verified health after migration"
1323
+ update_status "failed" "failed" "Target service failed to start or become healthy; update is held for recovery"
1324
+ FINALIZED="true"
220
1325
  exit 1
221
- }
1326
+ fi
222
1327
 
223
1328
  # ── Cleanup old versions (keep max 2 previous) ──
224
1329
  cd "$IMAGE_BASE_DIR"
@@ -239,7 +1344,9 @@ if [ "$INSTALL_TYPE" = "image" ]; then
239
1344
  fi
240
1345
 
241
1346
  # ── Mark complete ──
1347
+ release_attempt
242
1348
  update_status "complete" "complete"
1349
+ FINALIZED="true"
243
1350
  trap - EXIT
244
1351
  exit 0
245
1352
  fi