lager-cli 0.32.3__tar.gz → 0.32.5__tar.gz

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 (204) hide show
  1. {lager_cli-0.32.3/lager_cli.egg-info → lager_cli-0.32.5}/PKG-INFO +1 -1
  2. {lager_cli-0.32.3 → lager_cli-0.32.5}/__init__.py +1 -1
  3. {lager_cli-0.32.3 → lager_cli-0.32.5}/battery/websocket_client.py +30 -13
  4. {lager_cli-0.32.3 → lager_cli-0.32.5}/box_storage.py +67 -21
  5. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/box/boxes.py +26 -1
  6. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/box/config.py +7 -0
  7. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/box/diagnose.py +7 -0
  8. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/box/lock.py +7 -0
  9. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/communication/uart.py +4 -0
  10. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/communication/websocket_client.py +31 -17
  11. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/development/debug/service_client.py +11 -0
  12. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/utility/uninstall.py +8 -3
  13. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/utility/update.py +7 -0
  14. {lager_cli-0.32.3 → lager_cli-0.32.5}/context/session.py +10 -1
  15. {lager_cli-0.32.3 → lager_cli-0.32.5}/core/version_skew.py +9 -0
  16. {lager_cli-0.32.3 → lager_cli-0.32.5}/core/ws_diagnose.py +18 -1
  17. {lager_cli-0.32.3 → lager_cli-0.32.5}/deployment/scripts/setup_and_deploy_box.sh +19 -15
  18. {lager_cli-0.32.3 → lager_cli-0.32.5}/gateway_auth.py +135 -12
  19. {lager_cli-0.32.3 → lager_cli-0.32.5/lager_cli.egg-info}/PKG-INFO +1 -1
  20. {lager_cli-0.32.3 → lager_cli-0.32.5}/lager_cli.egg-info/SOURCES.txt +1 -0
  21. {lager_cli-0.32.3 → lager_cli-0.32.5}/status.py +8 -0
  22. {lager_cli-0.32.3 → lager_cli-0.32.5}/supply/websocket_client.py +30 -13
  23. {lager_cli-0.32.3 → lager_cli-0.32.5}/tests/test_box_lager_imports.py +48 -48
  24. {lager_cli-0.32.3 → lager_cli-0.32.5}/tests/test_gateway_auth.py +160 -0
  25. lager_cli-0.32.5/tests/test_gateway_callsites.py +177 -0
  26. lager_cli-0.32.5/tests/test_io_imports.py +266 -0
  27. lager_cli-0.32.3/tests/test_io_imports.py +0 -351
  28. {lager_cli-0.32.3 → lager_cli-0.32.5}/LICENSE +0 -0
  29. {lager_cli-0.32.3 → lager_cli-0.32.5}/MANIFEST.in +0 -0
  30. {lager_cli-0.32.3 → lager_cli-0.32.5}/README.md +0 -0
  31. {lager_cli-0.32.3 → lager_cli-0.32.5}/__main__.py +0 -0
  32. {lager_cli-0.32.3 → lager_cli-0.32.5}/address_utils.py +0 -0
  33. {lager_cli-0.32.3 → lager_cli-0.32.5}/battery/__init__.py +0 -0
  34. {lager_cli-0.32.3 → lager_cli-0.32.5}/battery/battery_tui.py +0 -0
  35. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/__init__.py +0 -0
  36. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/box/__init__.py +0 -0
  37. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/box/_host_ops.py +0 -0
  38. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/box/_mount_prep.py +0 -0
  39. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/box/_pip_validation.py +0 -0
  40. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/box/_shim_verbs.py +0 -0
  41. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/box/_ssh.py +0 -0
  42. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/box/box_group.py +0 -0
  43. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/box/dut.py +0 -0
  44. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/box/hello.py +0 -0
  45. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/box/instruments.py +0 -0
  46. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/box/labjack_pins.py +0 -0
  47. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/box/net_tui.py +0 -0
  48. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/box/nets.py +0 -0
  49. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/box/ssh.py +0 -0
  50. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/box/ssh_setup.py +0 -0
  51. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/communication/__init__.py +0 -0
  52. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/communication/ble.py +0 -0
  53. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/communication/blufi.py +0 -0
  54. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/communication/i2c.py +0 -0
  55. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/communication/router.py +0 -0
  56. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/communication/spi.py +0 -0
  57. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/communication/usb.py +0 -0
  58. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/communication/wifi.py +0 -0
  59. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/development/__init__.py +0 -0
  60. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/development/arm.py +0 -0
  61. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/development/debug/__init__.py +0 -0
  62. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/development/debug/commands.py +0 -0
  63. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/development/debug/gdb.py +0 -0
  64. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/development/debug/net_cache.py +0 -0
  65. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/development/debug/service_helper.py +0 -0
  66. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/development/debug/tunnel.py +0 -0
  67. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/development/devenv.py +0 -0
  68. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/development/python.py +0 -0
  69. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/measurement/__init__.py +0 -0
  70. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/measurement/adc.py +0 -0
  71. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/measurement/dac.py +0 -0
  72. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/measurement/energy.py +0 -0
  73. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/measurement/gpi.py +0 -0
  74. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/measurement/gpo.py +0 -0
  75. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/measurement/logic.py +0 -0
  76. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/measurement/scope.py +0 -0
  77. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/measurement/thermocouple.py +0 -0
  78. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/measurement/watt.py +0 -0
  79. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/power/__init__.py +0 -0
  80. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/power/battery.py +0 -0
  81. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/power/battery_model_csv.py +0 -0
  82. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/power/eload.py +0 -0
  83. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/power/solar.py +0 -0
  84. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/power/supply.py +0 -0
  85. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/utility/__init__.py +0 -0
  86. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/utility/binaries.py +0 -0
  87. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/utility/defaults.py +0 -0
  88. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/utility/exec_.py +0 -0
  89. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/utility/install.py +0 -0
  90. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/utility/install_wheel.py +0 -0
  91. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/utility/login.py +0 -0
  92. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/utility/logs.py +0 -0
  93. {lager_cli-0.32.3 → lager_cli-0.32.5}/commands/utility/webcam.py +0 -0
  94. {lager_cli-0.32.3 → lager_cli-0.32.5}/config.py +0 -0
  95. {lager_cli-0.32.3 → lager_cli-0.32.5}/context/__init__.py +0 -0
  96. {lager_cli-0.32.3 → lager_cli-0.32.5}/context/ci_detection.py +0 -0
  97. {lager_cli-0.32.3 → lager_cli-0.32.5}/context/constants.py +0 -0
  98. {lager_cli-0.32.3 → lager_cli-0.32.5}/context/core.py +0 -0
  99. {lager_cli-0.32.3 → lager_cli-0.32.5}/context/error_handlers.py +0 -0
  100. {lager_cli-0.32.3 → lager_cli-0.32.5}/core/__init__.py +0 -0
  101. {lager_cli-0.32.3 → lager_cli-0.32.5}/core/group_usage.py +0 -0
  102. {lager_cli-0.32.3 → lager_cli-0.32.5}/core/matchers.py +0 -0
  103. {lager_cli-0.32.3 → lager_cli-0.32.5}/core/net_group.py +0 -0
  104. {lager_cli-0.32.3 → lager_cli-0.32.5}/core/net_helpers.py +0 -0
  105. {lager_cli-0.32.3 → lager_cli-0.32.5}/core/net_storage.py +0 -0
  106. {lager_cli-0.32.3 → lager_cli-0.32.5}/core/param_types.py +0 -0
  107. {lager_cli-0.32.3 → lager_cli-0.32.5}/core/ssh_utils.py +0 -0
  108. {lager_cli-0.32.3 → lager_cli-0.32.5}/core/utils.py +0 -0
  109. {lager_cli-0.32.3 → lager_cli-0.32.5}/deployment/__init__.py +0 -0
  110. {lager_cli-0.32.3 → lager_cli-0.32.5}/deployment/scripts/__init__.py +0 -0
  111. {lager_cli-0.32.3 → lager_cli-0.32.5}/deployment/scripts/configure_docker_dns.py +0 -0
  112. {lager_cli-0.32.3 → lager_cli-0.32.5}/deployment/scripts/configure_docker_dns.sh +0 -0
  113. {lager_cli-0.32.3 → lager_cli-0.32.5}/deployment/scripts/convert_to_sparse_checkout.sh +0 -0
  114. {lager_cli-0.32.3 → lager_cli-0.32.5}/deployment/scripts/setup_ssh_key.sh +0 -0
  115. {lager_cli-0.32.3 → lager_cli-0.32.5}/deployment/security/__init__.py +0 -0
  116. {lager_cli-0.32.3 → lager_cli-0.32.5}/deployment/security/secure_box_firewall.sh +0 -0
  117. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/__init__.py +0 -0
  118. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/common/__init__.py +0 -0
  119. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/common/construct_utils.py +0 -0
  120. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/common/exceptions.py +0 -0
  121. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/common/py3compat.py +0 -0
  122. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/common/utils.py +0 -0
  123. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/construct/__init__.py +0 -0
  124. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/construct/adapters.py +0 -0
  125. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/construct/core.py +0 -0
  126. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/construct/debug.py +0 -0
  127. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/construct/macros.py +0 -0
  128. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/dwarf/__init__.py +0 -0
  129. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/dwarf/abbrevtable.py +0 -0
  130. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/dwarf/aranges.py +0 -0
  131. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/dwarf/callframe.py +0 -0
  132. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/dwarf/compileunit.py +0 -0
  133. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/dwarf/constants.py +0 -0
  134. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/dwarf/descriptions.py +0 -0
  135. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/dwarf/die.py +0 -0
  136. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/dwarf/dwarf_expr.py +0 -0
  137. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/dwarf/dwarfinfo.py +0 -0
  138. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/dwarf/enums.py +0 -0
  139. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/dwarf/lineprogram.py +0 -0
  140. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/dwarf/locationlists.py +0 -0
  141. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/dwarf/namelut.py +0 -0
  142. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/dwarf/ranges.py +0 -0
  143. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/dwarf/structs.py +0 -0
  144. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/ehabi/__init__.py +0 -0
  145. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/ehabi/constants.py +0 -0
  146. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/ehabi/decoder.py +0 -0
  147. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/ehabi/ehabiinfo.py +0 -0
  148. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/ehabi/structs.py +0 -0
  149. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/elf/__init__.py +0 -0
  150. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/elf/constants.py +0 -0
  151. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/elf/descriptions.py +0 -0
  152. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/elf/dynamic.py +0 -0
  153. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/elf/elffile.py +0 -0
  154. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/elf/enums.py +0 -0
  155. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/elf/gnuversions.py +0 -0
  156. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/elf/hash.py +0 -0
  157. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/elf/notes.py +0 -0
  158. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/elf/relocation.py +0 -0
  159. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/elf/sections.py +0 -0
  160. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/elf/segments.py +0 -0
  161. {lager_cli-0.32.3 → lager_cli-0.32.5}/elftools/elf/structs.py +0 -0
  162. {lager_cli-0.32.3 → lager_cli-0.32.5}/errors.py +0 -0
  163. {lager_cli-0.32.3 → lager_cli-0.32.5}/exceptions.py +0 -0
  164. {lager_cli-0.32.3 → lager_cli-0.32.5}/impl/__init__.py +0 -0
  165. {lager_cli-0.32.3 → lager_cli-0.32.5}/impl/box_config.py +0 -0
  166. {lager_cli-0.32.3 → lager_cli-0.32.5}/impl/measurement/__init__.py +0 -0
  167. {lager_cli-0.32.3 → lager_cli-0.32.5}/impl/measurement/scope.py +0 -0
  168. {lager_cli-0.32.3 → lager_cli-0.32.5}/impl/measurement/scope_stream.py +0 -0
  169. {lager_cli-0.32.3 → lager_cli-0.32.5}/impl/power/__init__.py +0 -0
  170. {lager_cli-0.32.3 → lager_cli-0.32.5}/impl/power/enable_disable.py +0 -0
  171. {lager_cli-0.32.3 → lager_cli-0.32.5}/lager_cli.egg-info/dependency_links.txt +0 -0
  172. {lager_cli-0.32.3 → lager_cli-0.32.5}/lager_cli.egg-info/entry_points.txt +0 -0
  173. {lager_cli-0.32.3 → lager_cli-0.32.5}/lager_cli.egg-info/requires.txt +0 -0
  174. {lager_cli-0.32.3 → lager_cli-0.32.5}/lager_cli.egg-info/top_level.txt +0 -0
  175. {lager_cli-0.32.3 → lager_cli-0.32.5}/main.py +0 -0
  176. {lager_cli-0.32.3 → lager_cli-0.32.5}/pyproject.toml +0 -0
  177. {lager_cli-0.32.3 → lager_cli-0.32.5}/safe_unpickle.py +0 -0
  178. {lager_cli-0.32.3 → lager_cli-0.32.5}/setup.cfg +0 -0
  179. {lager_cli-0.32.3 → lager_cli-0.32.5}/setup.py +0 -0
  180. {lager_cli-0.32.3 → lager_cli-0.32.5}/simple_hdlc.py +0 -0
  181. {lager_cli-0.32.3 → lager_cli-0.32.5}/sort_utils.py +0 -0
  182. {lager_cli-0.32.3 → lager_cli-0.32.5}/supply/__init__.py +0 -0
  183. {lager_cli-0.32.3 → lager_cli-0.32.5}/supply/supply_tui.py +0 -0
  184. {lager_cli-0.32.3 → lager_cli-0.32.5}/terminal/__init__.py +0 -0
  185. {lager_cli-0.32.3 → lager_cli-0.32.5}/terminal/core/__init__.py +0 -0
  186. {lager_cli-0.32.3 → lager_cli-0.32.5}/terminal/core/executor.py +0 -0
  187. {lager_cli-0.32.3 → lager_cli-0.32.5}/terminal/main.py +0 -0
  188. {lager_cli-0.32.3 → lager_cli-0.32.5}/terminal/ui/__init__.py +0 -0
  189. {lager_cli-0.32.3 → lager_cli-0.32.5}/terminal/ui/completer.py +0 -0
  190. {lager_cli-0.32.3 → lager_cli-0.32.5}/terminal/ui/display.py +0 -0
  191. {lager_cli-0.32.3 → lager_cli-0.32.5}/terminal/ui/logo.py +0 -0
  192. {lager_cli-0.32.3 → lager_cli-0.32.5}/terminal/ui/repl.py +0 -0
  193. {lager_cli-0.32.3 → lager_cli-0.32.5}/terminal/ui/themes.py +0 -0
  194. {lager_cli-0.32.3 → lager_cli-0.32.5}/tests/test_box_storage.py +0 -0
  195. {lager_cli-0.32.3 → lager_cli-0.32.5}/tests/test_update_gate.py +0 -0
  196. {lager_cli-0.32.3 → lager_cli-0.32.5}/update_check.py +0 -0
  197. {lager_cli-0.32.3 → lager_cli-0.32.5}/vendor/PyCRC/CRC16.py +0 -0
  198. {lager_cli-0.32.3 → lager_cli-0.32.5}/vendor/PyCRC/CRC16DNP.py +0 -0
  199. {lager_cli-0.32.3 → lager_cli-0.32.5}/vendor/PyCRC/CRC16Kermit.py +0 -0
  200. {lager_cli-0.32.3 → lager_cli-0.32.5}/vendor/PyCRC/CRC16SICK.py +0 -0
  201. {lager_cli-0.32.3 → lager_cli-0.32.5}/vendor/PyCRC/CRC32.py +0 -0
  202. {lager_cli-0.32.3 → lager_cli-0.32.5}/vendor/PyCRC/CRCCCITT.py +0 -0
  203. {lager_cli-0.32.3 → lager_cli-0.32.5}/vendor/PyCRC/__init__.py +0 -0
  204. {lager_cli-0.32.3 → lager_cli-0.32.5}/vendor/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: lager-cli
3
- Version: 0.32.3
3
+ Version: 0.32.5
4
4
  Summary: Lager CLI - Box and Docker connectivity
5
5
  Home-page: https://github.com/lagerdata/lager
6
6
  Author: Lager Data
@@ -7,4 +7,4 @@ Lager CLI
7
7
  A Command Line Interface for Lager Data
8
8
  """
9
9
 
10
- __version__ = '0.32.3'
10
+ __version__ = '0.32.5'
@@ -132,19 +132,36 @@ class BatteryWebSocketClient:
132
132
  Returns:
133
133
  True if connected successfully, False otherwise
134
134
  """
135
- try:
136
- from ..gateway_auth import auth_headers_for_url
137
- self.sio.connect(
138
- self.box_url,
139
- namespaces=['/battery'],
140
- wait_timeout=timeout,
141
- headers=auth_headers_for_url(self.box_url)
142
- )
143
- return True
144
- except Exception as e:
145
- if self.on_error:
146
- self.on_error(f"Could not connect to box at {self.box_url}: {str(e)}")
147
- return False
135
+ from ..gateway_auth import (
136
+ auth_headers_for_url, plain_error_text, ws_handshake_recovery,
137
+ )
138
+ headers = auth_headers_for_url(self.box_url)
139
+ for attempt in (0, 1):
140
+ try:
141
+ self.sio.connect(
142
+ self.box_url,
143
+ namespaces=['/battery'],
144
+ wait_timeout=timeout,
145
+ headers=headers
146
+ )
147
+ return True
148
+ except Exception as e:
149
+ # The handshake exception hides the HTTP response, so probe
150
+ # the box over HTTP: a gated box's first contact records the
151
+ # mapping and hands back the token for one retry; a genuine
152
+ # denial becomes the actionable message.
153
+ retry_headers, denial = ws_handshake_recovery(self.box_url, headers)
154
+ if denial is not None:
155
+ if self.on_error:
156
+ self.on_error(plain_error_text(denial))
157
+ return False
158
+ if attempt == 0 and retry_headers:
159
+ headers = retry_headers
160
+ continue
161
+ if self.on_error:
162
+ self.on_error(f"Could not connect to box at {self.box_url}: {str(e)}")
163
+ return False
164
+ return False
148
165
 
149
166
  def start_monitoring(self) -> bool:
150
167
  """
@@ -583,43 +583,49 @@ def _gateway_kwargs(ip):
583
583
 
584
584
  def _resend_with_auth(prepared, headers):
585
585
  """Re-send an already-prepared request with extra headers merged in.
586
- Returns the new response, or None if the resend itself failed."""
586
+ Returns the new response, or None if the resend itself failed.
587
+
588
+ Sent with ``stream=True`` so callers that stream the original request
589
+ (pip/python output) keep streaming on the retried response; non-streaming
590
+ callers read ``.json()``/``.text`` which consumes it identically."""
587
591
  import requests
588
592
  req = prepared.copy()
589
593
  for key, value in headers.items():
590
594
  req.headers[key] = value
591
595
  try:
592
- return requests.Session().send(req, timeout=30, stream=False)
596
+ return requests.Session().send(req, timeout=30, stream=True)
593
597
  except requests.RequestException:
594
598
  return None
595
599
 
596
600
 
597
- def _check_gateway(resp, ip):
598
- """Resolve a gateway response, returning the response the caller should use.
599
-
600
- On a plain (un-gated) box this is a passthrough. On a gated box:
601
+ def _resolve_gateway(resp, ip):
602
+ """Record-and-retry core shared by :func:`_check_gateway` and
603
+ :func:`check_gateway_status` — the single implementation of gateway
604
+ discovery. Returns ``(resp, denied)``:
601
605
 
606
+ - Non-gateway responses (no discovery header) pass through untouched
607
+ with ``denied=False`` — plain boxes are never affected.
602
608
  - First contact — we sent no token (the box→auth-server link is only
603
609
  learned from this very 401's discovery header) but we already hold a
604
610
  session for that server: record the mapping, attach the token, and
605
- retry the request once. The caller gets the authenticated response and
606
- never sees the round trip. A plain box never receives the token,
607
- because only a gateway sends the discovery header.
608
- - Genuine denials (revoked session, no access grant, auth server down)
609
- raise the actionable `lager login` / "ask your admin" error as before.
610
-
611
- Callers should adopt the return value: ``resp = _check_gateway(resp, ip)``.
611
+ retry the request once. On success the retried, authenticated response
612
+ is returned with ``denied=False``. A plain box never receives the
613
+ token, because only a gateway sends the discovery header.
614
+ - Anything else is a genuine denial: the mapping is still recorded (so
615
+ the next attempt authenticates) and ``denied=True`` tells the caller
616
+ to handle ``resp`` as a gateway denial.
612
617
  """
613
618
  from .gateway_auth import (
614
- DISCOVERY_HEADER, handle_gateway_denial,
615
- record_box_auth_server, auth_headers_for_box,
619
+ DISCOVERY_HEADER, record_box_auth_server, auth_headers_for_box,
616
620
  )
617
- if not (resp.status_code in (401, 403, 503) and DISCOVERY_HEADER in resp.headers):
618
- return resp
621
+ # getattr: tolerate response-shaped fakes without a headers attribute.
622
+ resp_headers = getattr(resp, 'headers', None) or {}
623
+ if not (resp.status_code in (401, 403, 503) and DISCOVERY_HEADER in resp_headers):
624
+ return resp, False
619
625
 
626
+ record_box_auth_server(ip, resp_headers[DISCOVERY_HEADER])
620
627
  sent_auth = 'Authorization' in getattr(resp.request, 'headers', {})
621
628
  if resp.status_code == 401 and not sent_auth:
622
- record_box_auth_server(ip, resp.headers[DISCOVERY_HEADER])
623
629
  headers = auth_headers_for_box(ip)
624
630
  if headers:
625
631
  retried = _resend_with_auth(resp.request, headers)
@@ -627,13 +633,47 @@ def _check_gateway(resp, ip):
627
633
  gated = (retried.status_code in (401, 403, 503)
628
634
  and DISCOVERY_HEADER in retried.headers)
629
635
  if not gated:
630
- return retried # transparently authenticated
631
- resp = retried # still refused — report on the retry
636
+ return retried, False # transparently authenticated
637
+ resp = retried # still refused — report on the retry
638
+
639
+ return resp, True
640
+
641
+
642
+ def _check_gateway(resp, ip):
643
+ """Resolve a gateway response, returning the response the caller should use.
644
+
645
+ On a plain (un-gated) box this is a passthrough. On a gated box the
646
+ first contact is retried transparently (see :func:`_resolve_gateway`);
647
+ genuine denials (revoked session, no access grant, auth server down)
648
+ raise the actionable `lager login` / "ask your admin" error as before.
632
649
 
633
- handle_gateway_denial(resp, ip) # raises the actionable error
650
+ Callers should adopt the return value: ``resp = _check_gateway(resp, ip)``.
651
+ """
652
+ from .gateway_auth import handle_gateway_denial
653
+ resp, denied = _resolve_gateway(resp, ip)
654
+ if denied:
655
+ handle_gateway_denial(resp, ip) # raises the actionable error
634
656
  return resp
635
657
 
636
658
 
659
+ def check_gateway_status(resp, ip):
660
+ """Non-raising variant of :func:`_check_gateway` for fan-out and
661
+ fail-open callers (`lager boxes`, health polls) that must not abort on a
662
+ single box's denial.
663
+
664
+ Performs the same record-and-retry as ``_check_gateway``. Returns
665
+ ``(resp, label)``: ``label`` is None when the caller should simply use
666
+ ``resp`` (plain box, or the retry authenticated transparently), else a
667
+ short user-facing verdict — 'sign-in required', 'session rejected',
668
+ 'no access', or 'auth server down'.
669
+ """
670
+ from .gateway_auth import denial_label
671
+ resp, denied = _resolve_gateway(resp, ip)
672
+ if not denied:
673
+ return resp, None
674
+ return resp, denial_label(resp)
675
+
676
+
637
677
  def _lock_url(ip, suffix=''):
638
678
  # Lock state is shared box-wide; both the :5000 and :9000 servers expose
639
679
  # it via lager.lock_state. The CLI talks to :9000 (the primary HTTP API).
@@ -807,6 +847,9 @@ def release_box_lock(ip, holder, *, quiet=True):
807
847
  )
808
848
  return False
809
849
 
850
+ # Non-raising gateway check (this function promises never to raise);
851
+ # a denial falls through to the failure warning below.
852
+ resp, _gate_verdict = check_gateway_status(resp, ip)
810
853
  if resp.status_code == 200:
811
854
  return True
812
855
  if not quiet:
@@ -848,6 +891,9 @@ def heartbeat_box_lock(ip, holder, *, quiet=True):
848
891
  fg='yellow', err=True,
849
892
  )
850
893
  return False
894
+ # Non-raising gateway check; a denial reads as a failed heartbeat, which
895
+ # callers already treat as "carry on" (server TTL is authoritative).
896
+ resp, _gate_verdict = check_gateway_status(resp, ip)
851
897
  return resp.status_code == 200
852
898
 
853
899
 
@@ -47,6 +47,7 @@ def _list_boxes_live(port=9000, timeout=5):
47
47
 
48
48
  results = []
49
49
  failed_count = 0
50
+ auth_failed_count = 0
50
51
  needs_update_count = 0
51
52
  newer_count = 0
52
53
 
@@ -67,8 +68,14 @@ def _list_boxes_live(port=9000, timeout=5):
67
68
  busy_info = ''
68
69
  try:
69
70
  from ...gateway_auth import auth_headers_for_box
71
+ from ...box_storage import check_gateway_status
70
72
  lock_resp = requests.get(f'http://{ip}:{port}/lock', timeout=3,
71
73
  headers={'Cache-Control': 'no-cache', **auth_headers_for_box(ip)})
74
+ # Non-raising gateway check: one gated box must not abort the
75
+ # whole table. On first contact this records the box→auth-server
76
+ # mapping and retries with the stored token, so the /status call
77
+ # below authenticates normally (no second round trip).
78
+ lock_resp, _ = check_gateway_status(lock_resp, ip)
72
79
  if lock_resp.status_code == 200:
73
80
  lock_data = lock_resp.json()
74
81
  if lock_data.get('locked'):
@@ -81,12 +88,18 @@ def _list_boxes_live(port=9000, timeout=5):
81
88
  # It predates the newer capability fields, so even older box images
82
89
  # answer it — unlike a brand-new endpoint would.
83
90
  from ...gateway_auth import auth_headers_for_box
91
+ from ...box_storage import check_gateway_status
84
92
  url = f'http://{ip}:{port}/status'
85
93
  headers = {'Cache-Control': 'no-cache', 'Pragma': 'no-cache',
86
94
  **auth_headers_for_box(ip)}
87
95
  response = requests.get(url, timeout=timeout, headers=headers)
96
+ response, gate_verdict = check_gateway_status(response, ip)
88
97
 
89
- if response.status_code == 200:
98
+ if gate_verdict:
99
+ results.append((name, ip, user, '-', gate_verdict, locked_by, busy_info))
100
+ auth_failed_count += 1
101
+
102
+ elif response.status_code == 200:
90
103
  try:
91
104
  data = response.json()
92
105
  box_version = data.get('version') or data.get('box_version')
@@ -205,6 +218,18 @@ def _list_boxes_live(port=9000, timeout=5):
205
218
  if failed_count > 0:
206
219
  box_word = 'box' if failed_count == 1 else 'boxes'
207
220
  click.secho(f'{failed_count} {box_word} could not be reached', fg='red')
221
+ if auth_failed_count > 0:
222
+ from ...gateway_auth import auth_server_for_box
223
+ box_word = 'box needs' if auth_failed_count == 1 else 'boxes need'
224
+ click.secho(f'{auth_failed_count} {box_word} sign-in or an access grant', fg='red')
225
+ # The denial recorded each box's auth server, so we can say exactly
226
+ # where to sign in. Usually one server covers the whole fleet.
227
+ signin_urls = sorted({
228
+ url for r in results if r[4] in ('sign-in required', 'session rejected')
229
+ for url in [auth_server_for_box(r[1])] if url
230
+ })
231
+ for url in signin_urls:
232
+ click.secho(f' Sign in with: lager login {url}', fg='red')
208
233
 
209
234
  root_locked = [r[0] for r in results if r[5] == 'root']
210
235
  if root_locked:
@@ -1627,8 +1627,15 @@ def _box_api_responding(box_ip: str, *, timeout: float = 2.0) -> bool:
1627
1627
  poll cheaply."""
1628
1628
  try:
1629
1629
  from ...gateway_auth import auth_headers_for_box
1630
+ from ...box_storage import check_gateway_status
1630
1631
  r = requests.get(f"http://{box_ip}:{_BOX_API_PORT}/hello", timeout=timeout,
1631
1632
  headers=auth_headers_for_box(box_ip))
1633
+ # Non-raising gateway check: first contact with a gated box records
1634
+ # the mapping and retries with the stored token so the probe sees the
1635
+ # real /hello. A genuine denial reads as not-responding (False) —
1636
+ # this probe sits inside the apply/restart poll loop, which must not
1637
+ # abort mid-restart; the flow's own requests surface auth errors.
1638
+ r, _gate_verdict = check_gateway_status(r, box_ip)
1632
1639
  return r.status_code == 200
1633
1640
  except Exception:
1634
1641
  return False
@@ -30,6 +30,7 @@ import click
30
30
  import requests
31
31
 
32
32
  from ...box_storage import resolve_and_validate_box_with_name
33
+ from ...errors import LagerError
33
34
 
34
35
 
35
36
  # Net-role choices for --type. Mirrors the role strings produced by
@@ -51,8 +52,10 @@ def _fetch_net_info(
51
52
  that — otherwise the returned role wins."""
52
53
  try:
53
54
  from ...gateway_auth import auth_headers_for_box
55
+ from ...box_storage import _check_gateway
54
56
  r = requests.get(f'http://{box_ip}:9000/nets/list', timeout=5,
55
57
  headers=auth_headers_for_box(box_ip))
58
+ r = _check_gateway(r, box_ip)
56
59
  r.raise_for_status()
57
60
  nets = r.json()
58
61
  # Older box images wrap the list: {"nets": [...]}. Unwrap so the
@@ -72,6 +75,10 @@ def _fetch_net_info(
72
75
  f'Check network/Tailscale connectivity, then `lager hello --box {display_name}`.',
73
76
  fg='red'), err=True)
74
77
  return None, None
78
+ except LagerError:
79
+ # A gateway denial from _check_gateway is already actionable — let it
80
+ # render as-is rather than collapsing into the generic message below.
81
+ raise
75
82
  except Exception as e:
76
83
  click.echo(click.style(f'Could not fetch net list from box: {e}', fg='red'), err=True)
77
84
  return None, None
@@ -35,6 +35,7 @@ def lock(ctx, box, lock_user):
35
35
  # lock immune to the new heartbeat-driven auto-reap that ephemeral
36
36
  # `lager python` locks participate in.
37
37
  from ...gateway_auth import auth_headers_for_box
38
+ from ...box_storage import _check_gateway
38
39
  resp = requests.post(
39
40
  f'http://{ip}:9000/lock',
40
41
  headers=auth_headers_for_box(ip),
@@ -45,6 +46,7 @@ def lock(ctx, box, lock_user):
45
46
  },
46
47
  timeout=5,
47
48
  )
49
+ resp = _check_gateway(resp, ip)
48
50
  except requests.exceptions.RequestException as e:
49
51
  click.secho(f"Error: Could not reach box '{display_name}': {e}", fg='red', err=True)
50
52
  ctx.exit(1)
@@ -80,12 +82,17 @@ def unlock(ctx, box, force):
80
82
 
81
83
  try:
82
84
  from ...gateway_auth import auth_headers_for_box
85
+ from ...box_storage import _check_gateway
83
86
  resp = requests.post(
84
87
  f'http://{ip}:9000/unlock',
85
88
  headers=auth_headers_for_box(ip),
86
89
  json={'user': user, 'force': force},
87
90
  timeout=5,
88
91
  )
92
+ # Gateway denials raise here with the actionable sign-in error; the
93
+ # 403 branch below keeps meaning "locked by another user" only (a
94
+ # plain box's application 403 has no discovery header).
95
+ resp = _check_gateway(resp, ip)
89
96
  except requests.exceptions.RequestException as e:
90
97
  click.secho(f"Error: Could not reach box '{display_name}': {e}", fg='red', err=True)
91
98
  ctx.exit(1)
@@ -100,8 +100,12 @@ def _run_query_instruments(ctx: click.Context, box_ip: str) -> list[dict]:
100
100
  """
101
101
  try:
102
102
  from ...gateway_auth import auth_headers_for_box
103
+ from ...box_storage import _check_gateway
103
104
  resp = requests.get(f'http://{box_ip}:9000/instruments/list', timeout=15,
104
105
  headers=auth_headers_for_box(box_ip))
106
+ # A gateway denial raises the actionable LagerError (not caught
107
+ # below): an auth problem must not be reported as "no instruments".
108
+ resp = _check_gateway(resp, box_ip)
105
109
  if resp.status_code == 200:
106
110
  data = resp.json()
107
111
  if isinstance(data, list):
@@ -260,23 +260,37 @@ class UARTWebSocketClient:
260
260
  self._setup_terminal()
261
261
 
262
262
  # Connect to WebSocket
263
- try:
264
- from ...gateway_auth import auth_headers_for_url
265
- self.sio.connect(
266
- self.box_url,
267
- namespaces=['/uart'],
268
- wait_timeout=10,
269
- headers=auth_headers_for_url(self.box_url)
270
- )
271
- except Exception as e:
272
- # Preserve the return-code contract (don't sys.exit / .die here):
273
- # the caller relies on the return value and the finally-block
274
- # terminal restore below.
275
- from ...errors import connection_error
276
- from urllib.parse import urlparse
277
- host = urlparse(self.box_url).hostname or self.box_url
278
- connection_error(e, host=host).show()
279
- return 1
263
+ from ...gateway_auth import auth_headers_for_url, ws_handshake_recovery
264
+ headers = auth_headers_for_url(self.box_url)
265
+ for attempt in (0, 1):
266
+ try:
267
+ self.sio.connect(
268
+ self.box_url,
269
+ namespaces=['/uart'],
270
+ wait_timeout=10,
271
+ headers=headers
272
+ )
273
+ break
274
+ except Exception as e:
275
+ # The handshake exception hides the HTTP response, so
276
+ # probe the box over HTTP: a gated box's first contact
277
+ # records the mapping and hands back the token for one
278
+ # retry; a genuine denial shows the actionable error.
279
+ # Preserve the return-code contract (don't sys.exit /
280
+ # .die here): the caller relies on the return value and
281
+ # the finally-block terminal restore below.
282
+ retry_headers, denial = ws_handshake_recovery(self.box_url, headers)
283
+ if denial is not None:
284
+ denial.show()
285
+ return 1
286
+ if attempt == 0 and retry_headers:
287
+ headers = retry_headers
288
+ continue
289
+ from ...errors import connection_error
290
+ from urllib.parse import urlparse
291
+ host = urlparse(self.box_url).hostname or self.box_url
292
+ connection_error(e, host=host).show()
293
+ return 1
280
294
 
281
295
  # Start UART session
282
296
  self.sio.emit('start_uart', {
@@ -44,6 +44,17 @@ class DebugServiceClient:
44
44
  'User-Agent': 'lager-cli/1.0',
45
45
  })
46
46
 
47
+ if not ssh_tunnel:
48
+ # Direct connection may cross an authenticating gateway: attach
49
+ # the bearer token when this box is known to be gated, and turn
50
+ # gateway denials into actionable errors instead of raw 401s.
51
+ # No-op for plain boxes (the hook only fires on the discovery
52
+ # header, and no token is sent to an unknown box). Tunneled
53
+ # connections terminate at 127.0.0.1 and never see the gateway.
54
+ from ....gateway_auth import auth_headers_for_box, gateway_response_hook
55
+ self.session.headers.update(auth_headers_for_box(box_host))
56
+ self.session.hooks['response'] = [gateway_response_hook(box_host)]
57
+
47
58
  def health_check(self) -> Dict[str, Any]:
48
59
  """Check if service is healthy."""
49
60
  response = self.session.get(f'{self.base_url}/health', timeout=5)
@@ -603,7 +603,7 @@ def uninstall(ctx, box, ip, user, keep_config, keep_docker_images, remove_all, y
603
603
  click.echo(" - Docker containers (lager, pigpio)")
604
604
  click.echo(" - Docker network (lagernet)")
605
605
  if not keep_docker_images:
606
- click.echo(" - Docker images (ALL unused images on the box, not only lager's)")
606
+ click.echo(" - Docker images (the lager image and dangling layers only)")
607
607
  click.echo(" - ~/box directory")
608
608
  if not keep_config:
609
609
  click.echo(" - /etc/lager directory (saved nets)")
@@ -707,10 +707,15 @@ def uninstall(ctx, box, ip, user, keep_config, keep_docker_images, remove_all, y
707
707
  run_ssh("docker network rm lagernet 2>/dev/null", "Removing lagernet network", allow_fail=True)
708
708
  click.echo()
709
709
 
710
- # Remove Docker images (unless --keep-docker-images)
710
+ # Remove Docker images (unless --keep-docker-images). Scoped to the
711
+ # lager image plus dangling layers: 'prune -af' would also delete
712
+ # images belonging to any OTHER stopped container on the box (a
713
+ # management agent, a user's own services) — infrastructure this
714
+ # command cannot restore.
711
715
  click.secho("[Step 2/5] Cleaning Docker...", fg='cyan')
712
716
  if not keep_docker_images:
713
- run_ssh("docker image prune -af 2>/dev/null", "Removing Docker images", allow_fail=True)
717
+ run_ssh("docker rmi -f lager 2>/dev/null", "Removing lager image", allow_fail=True)
718
+ run_ssh("docker image prune -f 2>/dev/null", "Removing dangling images", allow_fail=True)
714
719
  run_ssh("docker builder prune -af 2>/dev/null", "Clearing Docker build cache", allow_fail=True)
715
720
  else:
716
721
  click.echo(" Skipping Docker image removal (--keep-docker-images)")
@@ -74,8 +74,15 @@ def wait_for_box_ready(box_ip, *, timeout_s=60, initial_delay_s=2):
74
74
  for port in sorted(pending):
75
75
  try:
76
76
  from ...gateway_auth import auth_headers_for_box
77
+ from ...box_storage import check_gateway_status
77
78
  r = requests.get(f'http://{box_ip}:{port}/health', timeout=3,
78
79
  headers=auth_headers_for_box(box_ip))
80
+ # Non-raising gateway check: first contact with a gated box
81
+ # records the mapping and retries with the stored token so a
82
+ # healthy gated box reads as ready. A denial reads as
83
+ # not-ready and falls to the timeout path rather than
84
+ # aborting the update's readiness poll.
85
+ r, _gate_verdict = check_gateway_status(r, box_ip)
79
86
  if r.status_code == 200:
80
87
  pending.discard(port)
81
88
  except requests.exceptions.RequestException:
@@ -358,8 +358,10 @@ class DirectIPSession:
358
358
 
359
359
  try:
360
360
  from ..gateway_auth import auth_headers_for_box
361
+ from ..box_storage import _check_gateway
361
362
  response = requests.post(url, json=payload, timeout=5,
362
363
  headers=auth_headers_for_box(self.ip_address))
364
+ response = _check_gateway(response, self.ip_address)
363
365
  return response
364
366
  except requests.exceptions.RequestException as e:
365
367
  # Return a mock response with error
@@ -479,12 +481,19 @@ class DirectIPSession:
479
481
  url = f'http://{self.ip_address}:5001/run/pip'
480
482
 
481
483
  try:
484
+ from ..gateway_auth import auth_headers_for_box
485
+ from ..box_storage import _check_gateway
482
486
  response = requests.post(
483
487
  url,
484
488
  json={'args': args},
485
489
  stream=True,
486
- timeout=600 # 10 minute timeout for pip operations
490
+ timeout=600, # 10 minute timeout for pip operations
491
+ headers=auth_headers_for_box(self.ip_address),
487
492
  )
493
+ # Gateway discovery/denial handling; on a gated box's first
494
+ # contact the request is retried with the stored token so the
495
+ # caller streams the real pip output.
496
+ response = _check_gateway(response, self.ip_address)
488
497
 
489
498
  # Check for HTTP errors
490
499
  if response.status_code != 200:
@@ -61,8 +61,17 @@ def check_and_warn(box_ip: str, box_name: str | None = None) -> None:
61
61
  # it's the same endpoint `lager box hello` uses. 1.5s timeout
62
62
  # keeps the latency penalty bounded if the box is briefly slow.
63
63
  from ..gateway_auth import auth_headers_for_box
64
+ from ..box_storage import check_gateway_status
64
65
  r = requests.get(f'http://{box_ip}:9000/status', timeout=1.5,
65
66
  headers=auth_headers_for_box(box_ip))
67
+ # Non-raising gateway check: on first contact with a gated box this
68
+ # records the mapping and retries with the stored token; a genuine
69
+ # denial just skips the warning (this module is fail-open — the
70
+ # command's own request will surface the actionable auth error).
71
+ r, gate_verdict = check_gateway_status(r, box_ip)
72
+ if gate_verdict:
73
+ logger.debug('version-skew check: gateway denied (%s)', gate_verdict)
74
+ return
66
75
  if r.status_code == 404:
67
76
  # The :9000 server answered but has no /status route — the box
68
77
  # image predates the :9000 API surface this CLI requires. That is
@@ -37,9 +37,26 @@ def make_ws_failure_message(box_ip: str, original_error: str | Exception = '') -
37
37
  # /health is a cheap, always-present endpoint on box_http_server
38
38
  # (port 9000). 2s timeout matches the worst case of a healthy box
39
39
  # under temporary load.
40
- from ..gateway_auth import auth_headers_for_box
40
+ from ..gateway_auth import auth_headers_for_box, auth_server_for_box
41
+ from ..box_storage import check_gateway_status
41
42
  r = requests.get(f'http://{box_ip}:9000/health', timeout=2.0,
42
43
  headers=auth_headers_for_box(box_ip))
44
+ # Non-raising gateway check (this helper promises never to raise):
45
+ # first contact with a gated box records the mapping and retries with
46
+ # the stored token; a genuine denial becomes the actionable message.
47
+ r, gate_verdict = check_gateway_status(r, box_ip)
48
+ if gate_verdict:
49
+ url = auth_server_for_box(box_ip) or 'your auth server'
50
+ if gate_verdict in ('sign-in required', 'session rejected'):
51
+ action = f'sign in with `lager login {url}` and retry.'
52
+ elif gate_verdict == 'no access':
53
+ action = 'ask an org admin to grant you access to this box.'
54
+ else: # auth server down
55
+ action = 'its auth server is unreachable — try again shortly.'
56
+ return (
57
+ f'{base}{detail}.\n'
58
+ f'Action: the box refused the connection ({gate_verdict}) — {action}'
59
+ )
43
60
  if r.status_code == 200:
44
61
  return (
45
62
  f'{base}{detail}.\n'
@@ -1339,33 +1339,37 @@ print_info "Ensuring Docker service is enabled (for auto-start on boot)..."
1339
1339
  ssh $SSH_OPTS "${BOX_USER}@${BOX_IP}" "sudo systemctl enable docker >/dev/null 2>&1 || true"
1340
1340
  print_success "Docker service enabled"
1341
1341
 
1342
- print_info "Stopping and removing all existing containers..."
1343
- # Update restart policy first to prevent auto-restart, then force remove all containers
1342
+ print_info "Stopping and removing lager containers..."
1343
+ # Scoped to the containers this deployment owns (lager, pigpio, and the
1344
+ # legacy controller). A box may run third-party containers alongside lager
1345
+ # — a management agent, a user's own services — and removing containers we
1346
+ # did not create takes down infrastructure this script cannot restore.
1347
+ # Update restart policy first to prevent auto-restart, then force remove.
1344
1348
  ssh $SSH_OPTS "${BOX_USER}@${BOX_IP}" "
1345
- # Disable auto-restart on all containers first
1346
- docker update --restart=no \$(docker ps -aq) 2>/dev/null || true
1347
- # Stop all running containers
1348
- docker stop \$(docker ps -q) 2>/dev/null || true
1349
- # Remove all containers (running and stopped)
1350
- docker rm -f \$(docker ps -aq) 2>/dev/null || true
1351
- # Prune any leftover containers
1352
- docker container prune -f 2>/dev/null || true
1349
+ for c in lager pigpio controller; do
1350
+ docker update --restart=no \$c 2>/dev/null || true
1351
+ docker stop \$c 2>/dev/null || true
1352
+ docker rm -f \$c 2>/dev/null || true
1353
+ done
1353
1354
  # Wait a moment for Docker to clean up
1354
1355
  sleep 2
1355
1356
  " 2>/dev/null || true
1356
- print_success "All containers cleaned up"
1357
+ print_success "Lager containers cleaned up"
1357
1358
 
1358
- print_info "Cleaning up Docker images and build cache to free disk space..."
1359
+ print_info "Cleaning up Docker build cache and dangling images..."
1359
1360
  echo ""
1361
+ # Dangling-only (no -a): 'prune -af' would also delete images belonging to
1362
+ # third-party containers stopped at this moment, which cannot be re-pulled
1363
+ # by this script.
1360
1364
  ssh $SSH_OPTS "${BOX_USER}@${BOX_IP}" "
1361
- echo 'Removing unused Docker images...'
1362
- docker image prune -af 2>/dev/null || true
1365
+ echo 'Removing dangling Docker images...'
1366
+ docker image prune -f 2>/dev/null || true
1363
1367
  echo 'Removing Docker build cache...'
1364
1368
  docker builder prune -af 2>/dev/null || true
1365
1369
  echo 'Cleanup complete'
1366
1370
  " 2>/dev/null || true
1367
1371
  echo ""
1368
- print_success "Docker images and build cache cleaned up"
1372
+ print_success "Docker build cache and dangling images cleaned up"
1369
1373
 
1370
1374
  print_info "Checking available disk space..."
1371
1375
  ssh $SSH_OPTS "${BOX_USER}@${BOX_IP}" "df -h / | tail -n 1 | awk '{print \"Available: \" \$4 \" (\" \$5 \" used)\"}'" 2>/dev/null || true