redeploy 0.2.45__tar.gz → 0.2.47__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 (193) hide show
  1. {redeploy-0.2.45 → redeploy-0.2.47}/PKG-INFO +7 -7
  2. {redeploy-0.2.45 → redeploy-0.2.47}/README.md +5 -5
  3. {redeploy-0.2.45 → redeploy-0.2.47}/pyproject.toml +2 -2
  4. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/__init__.py +1 -1
  5. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/apply/__init__.py +10 -0
  6. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/apply/handlers.py +109 -27
  7. redeploy-0.2.47/redeploy/blueprint/extractor.py +117 -0
  8. redeploy-0.2.47/redeploy/blueprint/sources/__init__.py +8 -0
  9. redeploy-0.2.47/redeploy/blueprint/sources/compose.py +146 -0
  10. redeploy-0.2.47/redeploy/blueprint/sources/hardware.py +45 -0
  11. redeploy-0.2.47/redeploy/blueprint/sources/infra.py +43 -0
  12. redeploy-0.2.47/redeploy/blueprint/sources/migration.py +34 -0
  13. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/blueprint.py +3 -63
  14. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/device_map.py +6 -250
  15. redeploy-0.2.47/redeploy/cli/commands/device_map_renderers.py +170 -0
  16. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/hardware.py +35 -22
  17. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/plan_apply.py +82 -13
  18. redeploy-0.2.47/redeploy/cli/query.py +42 -0
  19. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/detect/templates.py +117 -72
  20. redeploy-0.2.47/redeploy/heal.py +414 -0
  21. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/integrations/op3_bridge.py +91 -11
  22. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/models.py +9 -0
  23. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy.egg-info/PKG-INFO +7 -7
  24. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy.egg-info/SOURCES.txt +8 -0
  25. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy.egg-info/requires.txt +1 -1
  26. redeploy-0.2.45/redeploy/blueprint/extractor.py +0 -328
  27. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/apply/exceptions.py +0 -0
  28. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/apply/executor.py +0 -0
  29. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/apply/progress.py +0 -0
  30. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/apply/rollback.py +0 -0
  31. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/apply/state.py +0 -0
  32. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/apply/state_apply.py +0 -0
  33. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/apply/utils/__init__.py +0 -0
  34. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/apply/utils/run_container_build.py +0 -0
  35. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/audit.py +0 -0
  36. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/blueprint/__init__.py +0 -0
  37. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/blueprint/generators/__init__.py +0 -0
  38. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/blueprint/generators/docker_compose.py +0 -0
  39. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/blueprint/generators/migration.py +0 -0
  40. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/__init__.py +0 -0
  41. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/__init__.py +0 -0
  42. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/audit.py +0 -0
  43. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/detect.py +0 -0
  44. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/devices.py +0 -0
  45. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/diagnose.py +0 -0
  46. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/diff.py +0 -0
  47. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/exec_.py +0 -0
  48. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/export.py +0 -0
  49. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/import_.py +0 -0
  50. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/init.py +0 -0
  51. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/inspect.py +0 -0
  52. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/patterns.py +0 -0
  53. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/plugin.py +0 -0
  54. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/probe.py +0 -0
  55. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/push.py +0 -0
  56. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/state.py +0 -0
  57. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/status.py +0 -0
  58. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/target.py +0 -0
  59. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/version/__init__.py +0 -0
  60. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/version/commands.py +0 -0
  61. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/version/helpers.py +0 -0
  62. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/version/monorepo.py +0 -0
  63. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/version/release.py +0 -0
  64. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/version/scanner.py +0 -0
  65. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/workflow.py +0 -0
  66. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/core.py +0 -0
  67. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/display.py +0 -0
  68. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli.py +0 -0
  69. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/config_apply/__init__.py +0 -0
  70. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/config_apply/applier.py +0 -0
  71. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/config_apply/loader.py +0 -0
  72. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/data_sync.py +0 -0
  73. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/detect/__init__.py +0 -0
  74. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/detect/builtin/__init__.py +0 -0
  75. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/detect/detector.py +0 -0
  76. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/detect/hardware.py +0 -0
  77. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/detect/hardware_rules.py +0 -0
  78. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/detect/probes.py +0 -0
  79. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/detect/remote.py +0 -0
  80. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/detect/workflow.py +0 -0
  81. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/discovery.py +0 -0
  82. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/dsl/__init__.py +0 -0
  83. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/dsl/loader.py +0 -0
  84. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/dsl/parser.py +0 -0
  85. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/dsl_python/__init__.py +0 -0
  86. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/dsl_python/context.py +0 -0
  87. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/dsl_python/decorators.py +0 -0
  88. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/dsl_python/docker_steps.py +0 -0
  89. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/dsl_python/exceptions.py +0 -0
  90. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/dsl_python/runner.py +0 -0
  91. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/dsl_python/steps.py +0 -0
  92. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/fleet.py +0 -0
  93. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/hardware/__init__.py +0 -0
  94. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/hardware/config_txt.py +0 -0
  95. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/hardware/data/__init__.py +0 -0
  96. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/hardware/data/hyperpixel.py +0 -0
  97. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/hardware/data/official.py +0 -0
  98. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/hardware/data/waveshare.py +0 -0
  99. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/hardware/fixes.py +0 -0
  100. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/hardware/kiosk/__init__.py +0 -0
  101. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/hardware/kiosk/autostart.py +0 -0
  102. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/hardware/kiosk/browsers.py +0 -0
  103. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/hardware/kiosk/compositors.py +0 -0
  104. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/hardware/kiosk/output_profiles.py +0 -0
  105. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/hardware/panels.py +0 -0
  106. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/hardware/raspi_config.py +0 -0
  107. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/iac/__init__.py +0 -0
  108. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/iac/base.py +0 -0
  109. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/iac/docker_compose.py +0 -0
  110. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/iac/parsers/__init__.py +0 -0
  111. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/iac/parsers/compose.py +0 -0
  112. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/iac/registry.py +0 -0
  113. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/integrations/__init__.py +0 -0
  114. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/markpact/__init__.py +0 -0
  115. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/markpact/compiler.py +0 -0
  116. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/markpact/models.py +0 -0
  117. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/markpact/parser.py +0 -0
  118. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/observe.py +0 -0
  119. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/parse.py +0 -0
  120. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/patterns.py +0 -0
  121. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/plan/__init__.py +0 -0
  122. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/plan/planner.py +0 -0
  123. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/plugins/__init__.py +0 -0
  124. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/plugins/builtin/__init__.py +0 -0
  125. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/plugins/builtin/browser_reload.py +0 -0
  126. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/plugins/builtin/hardware_diagnostic.py +0 -0
  127. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/plugins/builtin/notify.py +0 -0
  128. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/plugins/builtin/process_control.py +0 -0
  129. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/plugins/builtin/systemd_reload.py +0 -0
  130. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/spec_loader.py +0 -0
  131. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/ssh.py +0 -0
  132. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/steps.py +0 -0
  133. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/__init__.py +0 -0
  134. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_audit.py +0 -0
  135. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_cli.py +0 -0
  136. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_data_sync.py +0 -0
  137. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_discovery.py +0 -0
  138. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_dsl.py +0 -0
  139. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_examples.py +0 -0
  140. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_executor.py +0 -0
  141. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_fleet.py +0 -0
  142. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_git_integration.py +0 -0
  143. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_iac.py +0 -0
  144. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_manifest.py +0 -0
  145. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_markpact_compiler.py +0 -0
  146. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_markpact_parser.py +0 -0
  147. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_models.py +0 -0
  148. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_observe.py +0 -0
  149. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_parse.py +0 -0
  150. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_patterns.py +0 -0
  151. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_planner_edge.py +0 -0
  152. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_plugins.py +0 -0
  153. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_probes.py +0 -0
  154. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_public_api.py +0 -0
  155. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_resume.py +0 -0
  156. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_spec_loader.py +0 -0
  157. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_ssh.py +0 -0
  158. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_templates.py +0 -0
  159. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_verify.py +0 -0
  160. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_version.py +0 -0
  161. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_version_changelog.py +0 -0
  162. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_version_cli.py +0 -0
  163. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_version_manifest.py +0 -0
  164. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_version_sources.py +0 -0
  165. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_workflow.py +0 -0
  166. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/verify.py +0 -0
  167. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/version/__init__.py +0 -0
  168. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/version/bump.py +0 -0
  169. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/version/changelog.py +0 -0
  170. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/version/commits.py +0 -0
  171. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/version/diff.py +0 -0
  172. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/version/git_integration.py +0 -0
  173. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/version/git_transaction.py +0 -0
  174. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/version/manifest.py +0 -0
  175. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/version/sources/__init__.py +0 -0
  176. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/version/sources/base.py +0 -0
  177. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/version/sources/json_.py +0 -0
  178. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/version/sources/plain.py +0 -0
  179. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/version/sources/regex.py +0 -0
  180. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/version/sources/toml_.py +0 -0
  181. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/version/sources/yaml_.py +0 -0
  182. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/version/transaction.py +0 -0
  183. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/version.py +0 -0
  184. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy.egg-info/dependency_links.txt +0 -0
  185. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy.egg-info/entry_points.txt +0 -0
  186. {redeploy-0.2.45 → redeploy-0.2.47}/redeploy.egg-info/top_level.txt +0 -0
  187. {redeploy-0.2.45 → redeploy-0.2.47}/setup.cfg +0 -0
  188. {redeploy-0.2.45 → redeploy-0.2.47}/tests/test_hardware_config.py +0 -0
  189. {redeploy-0.2.45 → redeploy-0.2.47}/tests/test_hardware_kiosk.py +0 -0
  190. {redeploy-0.2.45 → redeploy-0.2.47}/tests/test_hardware_rules.py +0 -0
  191. {redeploy-0.2.45 → redeploy-0.2.47}/tests/test_iac.py +0 -0
  192. {redeploy-0.2.45 → redeploy-0.2.47}/tests/test_parse.py +0 -0
  193. {redeploy-0.2.45 → redeploy-0.2.47}/tests/test_redeploy.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redeploy
3
- Version: 0.2.45
3
+ Version: 0.2.47
4
4
  Summary: Infrastructure migration toolkit: detect → plan → apply
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -23,24 +23,24 @@ Requires-Dist: goal>=2.1.0; extra == "dev"
23
23
  Requires-Dist: costs>=0.1.20; extra == "dev"
24
24
  Requires-Dist: pfix>=0.1.60; extra == "dev"
25
25
  Provides-Extra: op3
26
- Requires-Dist: op3>=0.1.4; extra == "op3"
26
+ Requires-Dist: op3>=0.1.8; extra == "op3"
27
27
 
28
28
  # redeploy
29
29
 
30
30
 
31
31
  ## AI Cost Tracking
32
32
 
33
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.45-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
34
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-23.2h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
33
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.47-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
34
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-24.0h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
35
35
 
36
- - 🤖 **LLM usage:** $7.5000 (66 commits)
37
- - 👤 **Human dev:** ~$2318 (23.2h @ $100/h, 30min dedup)
36
+ - 🤖 **LLM usage:** $7.5000 (68 commits)
37
+ - 👤 **Human dev:** ~$2398 (24.0h @ $100/h, 30min dedup)
38
38
 
39
39
  Generated on 2026-04-21 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
40
40
 
41
41
  ---
42
42
 
43
- ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.45-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
43
+ ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.47-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
44
44
 
45
45
  Infrastructure migration and device deploy toolkit — VPS, Raspberry Pi kiosk, Podman Quadlet, k3s.
46
46
 
@@ -3,17 +3,17 @@
3
3
 
4
4
  ## AI Cost Tracking
5
5
 
6
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.45-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
7
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-23.2h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
6
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.47-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
7
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-24.0h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $7.5000 (66 commits)
10
- - 👤 **Human dev:** ~$2318 (23.2h @ $100/h, 30min dedup)
9
+ - 🤖 **LLM usage:** $7.5000 (68 commits)
10
+ - 👤 **Human dev:** ~$2398 (24.0h @ $100/h, 30min dedup)
11
11
 
12
12
  Generated on 2026-04-21 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
13
13
 
14
14
  ---
15
15
 
16
- ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.45-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
16
+ ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.47-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
17
17
 
18
18
  Infrastructure migration and device deploy toolkit — VPS, Raspberry Pi kiosk, Podman Quadlet, k3s.
19
19
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "redeploy"
7
- version = "0.2.45"
7
+ version = "0.2.47"
8
8
  description = "Infrastructure migration toolkit: detect → plan → apply"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -32,7 +32,7 @@ dev = ["pytest>=8.0", "ruff>=0.4",
32
32
  "costs>=0.1.20",
33
33
  "pfix>=0.1.60",
34
34
  ]
35
- op3 = ["op3>=0.1.4"]
35
+ op3 = ["op3>=0.1.8"]
36
36
 
37
37
  [tool.ruff]
38
38
  line-length = 100
@@ -7,7 +7,7 @@ Public API (stable from 0.2.0, semver guaranteed)::
7
7
 
8
8
  Everything not listed in ``__all__`` is internal and may change without notice.
9
9
  """
10
- __version__ = "0.2.45"
10
+ __version__ = "0.2.47"
11
11
 
12
12
  # ── Core models ───────────────────────────────────────────────────────────────
13
13
  from .models import ( # noqa: F401
@@ -5,10 +5,15 @@ from .handlers import (
5
5
  run_container_log_tail,
6
6
  run_docker_build,
7
7
  run_docker_health_wait,
8
+ run_ensure_autostart_entry,
9
+ run_ensure_browser_kiosk_script,
10
+ run_ensure_config_line,
11
+ run_ensure_kanshi_profile,
8
12
  run_http_check,
9
13
  run_inline_script,
10
14
  run_plugin,
11
15
  run_podman_build,
16
+ run_raspi_config,
12
17
  run_rsync,
13
18
  run_scp,
14
19
  run_ssh,
@@ -37,10 +42,15 @@ __all__ = [
37
42
  "run_container_log_tail",
38
43
  "run_docker_build",
39
44
  "run_docker_health_wait",
45
+ "run_ensure_autostart_entry",
46
+ "run_ensure_browser_kiosk_script",
47
+ "run_ensure_config_line",
48
+ "run_ensure_kanshi_profile",
40
49
  "run_http_check",
41
50
  "run_inline_script",
42
51
  "run_plugin",
43
52
  "run_podman_build",
53
+ "run_raspi_config",
44
54
  "run_rsync",
45
55
  "run_scp",
46
56
  "run_ssh",
@@ -405,14 +405,36 @@ def run_raspi_config(step: MigrationStep, probe: "RemoteProbe") -> None:
405
405
  def run_ensure_kanshi_profile(step: MigrationStep, probe: "RemoteProbe") -> None:
406
406
  """Idempotently write or replace a named kanshi output profile.
407
407
 
408
- step.command — the profile block to write (rendered kanshi syntax)
409
- step.config_file — kanshi config path (default: ~/.config/kanshi/config)
408
+ Declarative mode (preferred)::
409
+
410
+ profile_name: waveshare-only
411
+ outputs_on: [DSI-2]
412
+ outputs_off: [HDMI-A-2]
413
+
414
+ Legacy mode (pre-rendered block)::
415
+
416
+ step.command — the profile block to write (rendered kanshi syntax)
417
+ step.config_file — kanshi config path (default: ~/.config/kanshi/config)
410
418
  """
411
419
  from ..hardware.kiosk.output_profiles import OutputProfile
412
420
 
413
- profile_block = step.command
421
+ # Declarative mode: build profile from semantic fields
422
+ if step.profile_name:
423
+ profile = OutputProfile(
424
+ name=step.profile_name,
425
+ enabled=list(step.outputs_on),
426
+ disabled=list(step.outputs_off),
427
+ )
428
+ profile_block = profile.to_kanshi_config()
429
+ else:
430
+ profile_block = step.command
431
+
414
432
  if not profile_block:
415
- raise StepError(step, "ensure_kanshi_profile requires step.command with profile block")
433
+ raise StepError(
434
+ step,
435
+ "ensure_kanshi_profile requires profile_name+outputs_on/outputs_off "
436
+ "or step.command with pre-rendered profile block",
437
+ )
416
438
 
417
439
  config_path = step.config_file or "~/.config/kanshi/config"
418
440
  mkdir_cmd = f"mkdir -p $(dirname {config_path})"
@@ -462,56 +484,116 @@ def run_ensure_kanshi_profile(step: MigrationStep, probe: "RemoteProbe") -> None
462
484
 
463
485
 
464
486
  def run_ensure_autostart_entry(step: MigrationStep, probe: "RemoteProbe") -> None:
465
- """Idempotently add or replace a keyed entry in a compositor autostart file.
487
+ """Idempotently add or replace keyed entries in a compositor autostart file.
466
488
 
467
- step.config_file — path to autostart file
468
- step.config_line — the line to write (the entry body)
469
- step.config_section — used as the entry key for idempotent marker
470
- """
471
- from ..hardware.kiosk.autostart import AutostartEntry, ensure_autostart_entry
489
+ Declarative mode (preferred)::
472
490
 
473
- if not step.config_file or not step.config_line:
474
- raise StepError(step, "ensure_autostart_entry requires config_file and config_line")
491
+ compositor: labwc
492
+ entries:
493
+ - "kanshid &"
494
+ - "sleep 3"
495
+ - "bash /home/pi/c2004/scripts/kiosk-launch.sh &"
475
496
 
476
- key = step.config_section or "redeploy"
477
- entry = AutostartEntry(key=key, line=step.config_line)
497
+ Legacy mode (single entry)::
478
498
 
479
- r = probe.run(f"cat {step.config_file} 2>/dev/null || echo ''", timeout=10)
499
+ step.config_file — path to autostart file
500
+ step.config_line — the line to write (the entry body)
501
+ step.config_section — used as the entry key for idempotent marker
502
+ """
503
+ from ..hardware.kiosk.autostart import AutostartEntry, ensure_autostart_entry
504
+ from ..hardware.kiosk.compositors import COMPOSITORS
505
+
506
+ # Determine autostart path
507
+ config_file = step.config_file
508
+ if not config_file and step.compositor:
509
+ comp = COMPOSITORS.get(step.compositor)
510
+ if not comp:
511
+ raise StepError(step, f"Unknown compositor '{step.compositor}'")
512
+ config_file = comp.autostart_abs()
513
+ if not config_file:
514
+ raise StepError(step, "ensure_autostart_entry requires config_file or compositor")
515
+
516
+ # Collect entries to apply
517
+ entries: list[AutostartEntry] = []
518
+ if step.entries:
519
+ for i, line in enumerate(step.entries):
520
+ key = f"redeploy-{i}-{line.split()[0] if line else 'entry'}"
521
+ entries.append(AutostartEntry(key=key, line=line))
522
+ elif step.config_line:
523
+ key = step.config_section or "redeploy"
524
+ entries.append(AutostartEntry(key=key, line=step.config_line))
525
+
526
+ if not entries:
527
+ raise StepError(step, "ensure_autostart_entry requires entries or config_line")
528
+
529
+ r = probe.run(f"cat {config_file} 2>/dev/null || echo ''", timeout=10)
480
530
  existing = r.out if r.ok else ""
481
531
 
482
- new_content, changed = ensure_autostart_entry(existing, entry)
483
- if not changed:
532
+ changed_any = False
533
+ new_content = existing
534
+ for entry in entries:
535
+ new_content, changed = ensure_autostart_entry(new_content, entry)
536
+ if changed:
537
+ changed_any = True
538
+
539
+ if not changed_any:
484
540
  step.status = StepStatus.DONE
485
- step.result = f"no-op: entry [{key}] already correct"
541
+ step.result = f"no-op: {len(entries)} autostart entry(ies) already correct"
486
542
  return
487
543
 
488
- mkdir_cmd = f"mkdir -p $(dirname {step.config_file})"
544
+ mkdir_cmd = f"mkdir -p $(dirname {config_file})"
489
545
  probe.run(mkdir_cmd, timeout=10)
490
546
 
491
547
  encoded = base64.b64encode(new_content.encode()).decode()
492
548
  tmp = f"/tmp/redeploy-autostart-{step.id}.txt"
493
549
  write_r = probe.run(
494
- f"echo '{encoded}' | base64 -d | tee {tmp} > /dev/null && mv {tmp} {step.config_file}",
550
+ f"echo '{encoded}' | base64 -d | tee {tmp} > /dev/null && mv {tmp} {config_file}",
495
551
  timeout=15,
496
552
  )
497
553
  if not write_r.ok:
498
- raise StepError(step, f"Cannot write {step.config_file}: {write_r.stderr[:200]}")
554
+ raise StepError(step, f"Cannot write {config_file}: {write_r.stderr[:200]}")
499
555
 
500
556
  step.status = StepStatus.DONE
501
- step.result = f"entry [{key}] written to {step.config_file}"
557
+ step.result = f"{len(entries)} autostart entry(ies) written to {config_file}"
502
558
 
503
559
 
504
560
  def run_ensure_browser_kiosk_script(step: MigrationStep, probe: "RemoteProbe") -> None:
505
561
  """Write a kiosk-launch.sh script to the remote device.
506
562
 
507
- step.command — the full script content to write
508
- step.dst — destination path (default: ~/kiosk-launch.sh)
563
+ Declarative mode (preferred)::
564
+
565
+ browser_profile: chromium_wayland_kiosk
566
+ url: "http://localhost:8100/connect-id?font=xlarge&theme=dark"
567
+ kiosk_script_path: /home/pi/c2004/scripts/kiosk-launch.sh
568
+
569
+ Legacy mode (pre-rendered script)::
570
+
571
+ step.command — the full script content to write
572
+ step.dst — destination path (default: ~/kiosk-launch.sh)
509
573
  """
510
- script_content = step.command
574
+ from ..hardware.kiosk.browsers import CHROMIUM_WAYLAND_KIOSK
575
+
576
+ # Declarative mode: build script from profile + URL
577
+ if step.browser_profile:
578
+ # Registry lookup (extend when more profiles exist)
579
+ if step.browser_profile == "chromium_wayland_kiosk":
580
+ profile = CHROMIUM_WAYLAND_KIOSK
581
+ else:
582
+ raise StepError(step, f"Unknown browser_profile '{step.browser_profile}'")
583
+ if not step.url:
584
+ raise StepError(step, "browser_profile mode requires 'url' field")
585
+ script_content = profile.build_launch_cmd(step.url)
586
+ else:
587
+ script_content = step.command
588
+
511
589
  if not script_content:
512
- raise StepError(step, "ensure_browser_kiosk_script requires step.command with script body")
590
+ raise StepError(
591
+ step,
592
+ "ensure_browser_kiosk_script requires browser_profile+url "
593
+ "or step.command with pre-rendered script body",
594
+ )
513
595
 
514
- dst = step.dst or "~/kiosk-launch.sh"
596
+ dst = step.kiosk_script_path or step.dst or "~/kiosk-launch.sh"
515
597
 
516
598
  r = probe.run(f"cat {dst} 2>/dev/null || echo ''", timeout=10)
517
599
  existing = r.out if r.ok else ""
@@ -0,0 +1,117 @@
1
+ """Blueprint extractor — derive a DeviceBlueprint from multiple sources.
2
+
3
+ Sources (in order of priority):
4
+ 1. Running InfraState (podman ps / docker inspect on live device)
5
+ 2. DeviceMap hardware snapshot
6
+ 3. docker-compose YAML files
7
+ 4. markpact / migration YAML definitions
8
+
9
+ The extractor reconciles all available information into a single
10
+ DeviceBlueprint that is self-contained and portable.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ from ..models import (
18
+ BlueprintSource,
19
+ DeviceBlueprint,
20
+ DeviceMap,
21
+ InfraState,
22
+ ServiceSpec,
23
+ )
24
+
25
+
26
+ def extract_blueprint(
27
+ *,
28
+ name: str,
29
+ device_map: Optional[DeviceMap] = None,
30
+ infra: Optional[InfraState] = None,
31
+ compose_files: Optional[list[Path]] = None,
32
+ migration_file: Optional[Path] = None,
33
+ markpact_files: Optional[list[Path]] = None,
34
+ version: str = "0.0.0",
35
+ description: str = "",
36
+ tags: Optional[list[str]] = None,
37
+ detect_live: bool = False,
38
+ host: Optional[str] = None,
39
+ ) -> DeviceBlueprint:
40
+ """Build a DeviceBlueprint by reconciling all available sources.
41
+
42
+ Parameters
43
+ ----------
44
+ name:
45
+ Human-readable blueprint name, e.g. ``"c2004-kiosk"``.
46
+ device_map:
47
+ Previously saved DeviceMap (hardware + infra snapshot).
48
+ infra:
49
+ InfraState if already probed (skips live detection).
50
+ compose_files:
51
+ Local docker-compose YAML files to parse service specs from.
52
+ migration_file:
53
+ Local migration.yaml to extract app version and strategy from.
54
+ markpact_files:
55
+ Local markdown files with markpact fenced blocks.
56
+ detect_live:
57
+ If True and *host* is given, probe the host via SSH for a fresh
58
+ InfraState even if *device_map* already has one.
59
+ host:
60
+ SSH target used when ``detect_live=True``.
61
+ """
62
+ if detect_live and host and not infra:
63
+ from ..detect import Detector
64
+ infra = infra or Detector(host).run()
65
+
66
+ from .sources.compose import merge_compose_files
67
+ from .sources.hardware import build_hw_requirements
68
+ from .sources.infra import extract_services_from_infra, infer_app_url
69
+ from .sources.migration import parse_migration_meta
70
+
71
+ # Prefer freshly probed infra, fall back to device_map.infra
72
+ _infra: Optional[InfraState] = infra or (device_map.infra if device_map else None)
73
+ _hw = device_map.hardware if device_map else None
74
+
75
+ services: list[ServiceSpec] = []
76
+ seen: set[str] = set()
77
+
78
+ # ── 1. Live InfraState ────────────────────────────────────────────────────
79
+ if _infra:
80
+ services.extend(extract_services_from_infra(_infra, seen))
81
+
82
+ # ── 2. docker-compose files ───────────────────────────────────────────────
83
+ merge_compose_files(list(compose_files or []), services, seen)
84
+
85
+ # ── 3. migration.yaml ─────────────────────────────────────────────────────
86
+ app_version = version
87
+ deploy_strategy = "podman_quadlet"
88
+ if migration_file and migration_file.exists():
89
+ meta = parse_migration_meta(migration_file)
90
+ app_version = meta.get("version", version)
91
+ deploy_strategy = meta.get("strategy", deploy_strategy)
92
+
93
+ # ── 4. Hardware requirements ────────────────────────────────────────────
94
+ hw_req = build_hw_requirements(_hw)
95
+
96
+ # ── 5. App URL ────────────────────────────────────────────────────────────
97
+ app_url = infer_app_url(_infra)
98
+
99
+ source = BlueprintSource(
100
+ device_id=device_map.id if device_map else host,
101
+ device_name=device_map.name if device_map else "",
102
+ migration_file=str(migration_file) if migration_file else None,
103
+ markpact_files=[str(p) for p in (markpact_files or [])],
104
+ compose_files=[str(p) for p in (compose_files or [])],
105
+ )
106
+
107
+ return DeviceBlueprint(
108
+ name=name,
109
+ version=app_version,
110
+ description=description,
111
+ tags=tags or (device_map.tags if device_map else []),
112
+ services=services,
113
+ hardware=hw_req,
114
+ app_url=app_url,
115
+ deploy_strategy=deploy_strategy,
116
+ source=source,
117
+ )
@@ -0,0 +1,8 @@
1
+ """Blueprint source adapters — extract partial blueprints from multiple inputs.
2
+
3
+ Each module in this package exposes a narrow, single-responsibility function
4
+ that reads one kind of source (live infra, docker-compose, migration.yaml,
5
+ DeviceMap hardware, …) and returns structured data for the orchestrator in
6
+ :mod:`redeploy.blueprint.extractor` to merge into a ``DeviceBlueprint``.
7
+ """
8
+ from __future__ import annotations
@@ -0,0 +1,146 @@
1
+ """Parse docker-compose YAML files into :class:`ServiceSpec` objects."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import yaml
9
+
10
+ from ...models import ServicePort, ServiceSpec, VolumeMount
11
+
12
+
13
+ def merge_compose_files(
14
+ compose_files: list[Path],
15
+ services: list[ServiceSpec],
16
+ seen: set[str],
17
+ ) -> None:
18
+ """Parse each docker-compose file and merge specs into *services* / *seen*."""
19
+ for cf in compose_files:
20
+ _merge_compose(cf, services, seen)
21
+
22
+
23
+ def _merge_compose(path: Path, services: list[ServiceSpec], seen: set[str]) -> None:
24
+ """Parse a single docker-compose YAML and merge service specs."""
25
+ try:
26
+ raw = yaml.safe_load(path.read_text())
27
+ except Exception:
28
+ return
29
+ if not isinstance(raw, dict) or "services" not in raw:
30
+ return
31
+
32
+ for svc_name, svc_def in raw["services"].items():
33
+ if not isinstance(svc_def, dict):
34
+ continue
35
+
36
+ existing = next((s for s in services if s.name == svc_name), None)
37
+ image = svc_def.get("image", "")
38
+ platform = svc_def.get("platform", "")
39
+ ports = _parse_compose_ports(svc_def.get("ports", []))
40
+ volumes = _parse_compose_volumes(svc_def.get("volumes", []))
41
+ env = _parse_compose_env(svc_def.get("environment", {}))
42
+ healthcheck = _parse_compose_healthcheck(svc_def.get("healthcheck", {}))
43
+ depends = svc_def.get("depends_on", [])
44
+ if isinstance(depends, dict):
45
+ depends = list(depends.keys())
46
+ command = svc_def.get("command", None)
47
+ if isinstance(command, list):
48
+ command = " ".join(str(c) for c in command)
49
+ restart = svc_def.get("restart", "unless-stopped")
50
+ privileged = bool(svc_def.get("privileged", False))
51
+
52
+ if existing:
53
+ if not existing.image and image:
54
+ existing.image = image
55
+ if not existing.platform and platform:
56
+ existing.platform = platform
57
+ if not existing.ports:
58
+ existing.ports = ports
59
+ if not existing.volumes:
60
+ existing.volumes = volumes
61
+ existing.env.update(env)
62
+ if not existing.healthcheck and healthcheck:
63
+ existing.healthcheck = healthcheck
64
+ if not existing.depends_on:
65
+ existing.depends_on = depends
66
+ existing.source_ref = str(path)
67
+ else:
68
+ seen.add(svc_name)
69
+ services.append(ServiceSpec(
70
+ name=svc_name,
71
+ image=image,
72
+ platform=platform,
73
+ ports=ports,
74
+ volumes=volumes,
75
+ env=env,
76
+ command=command,
77
+ healthcheck=healthcheck,
78
+ depends_on=depends,
79
+ restart=restart,
80
+ privileged=privileged,
81
+ source_ref=str(path),
82
+ ))
83
+
84
+
85
+ def _parse_compose_ports(raw: list) -> list[ServicePort]:
86
+ result = []
87
+ for entry in raw:
88
+ if isinstance(entry, int):
89
+ result.append(ServicePort(host=entry, container=entry))
90
+ elif isinstance(entry, str):
91
+ m = re.match(r"(?:[\d.]+:)?(\d+):(\d+)(?:/(tcp|udp))?", entry)
92
+ if m:
93
+ result.append(ServicePort(
94
+ host=int(m.group(1)),
95
+ container=int(m.group(2)),
96
+ protocol=m.group(3) or "tcp",
97
+ ))
98
+ elif isinstance(entry, dict):
99
+ result.append(ServicePort(
100
+ host=int(entry.get("published", entry.get("target", 0))),
101
+ container=int(entry.get("target", 0)),
102
+ protocol=entry.get("protocol", "tcp"),
103
+ ))
104
+ return result
105
+
106
+
107
+ def _parse_compose_volumes(raw: list | dict) -> list[VolumeMount]:
108
+ result = []
109
+ items = raw if isinstance(raw, list) else [f"{k}:{v}" for k, v in raw.items()]
110
+ for entry in items:
111
+ if isinstance(entry, str) and ":" in entry:
112
+ parts = entry.split(":")
113
+ result.append(VolumeMount(
114
+ host=parts[0],
115
+ container=parts[1],
116
+ read_only=len(parts) > 2 and "ro" in parts[2],
117
+ ))
118
+ elif isinstance(entry, dict):
119
+ result.append(VolumeMount(
120
+ host=entry.get("source", ""),
121
+ container=entry.get("target", ""),
122
+ read_only=entry.get("read_only", False),
123
+ ))
124
+ return result
125
+
126
+
127
+ def _parse_compose_env(raw: dict | list) -> dict[str, str]:
128
+ if isinstance(raw, dict):
129
+ return {str(k): str(v) for k, v in raw.items() if v is not None}
130
+ result = {}
131
+ for entry in raw:
132
+ if isinstance(entry, str) and "=" in entry:
133
+ k, _, v = entry.partition("=")
134
+ result[k.strip()] = v.strip()
135
+ return result
136
+
137
+
138
+ def _parse_compose_healthcheck(raw: dict) -> Optional[str]:
139
+ if not raw:
140
+ return None
141
+ test = raw.get("test", [])
142
+ if isinstance(test, list) and len(test) > 1:
143
+ return " ".join(str(t) for t in test[1:])
144
+ if isinstance(test, str):
145
+ return test
146
+ return None
@@ -0,0 +1,45 @@
1
+ """Build ``HardwareRequirements`` from a DeviceMap hardware snapshot."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional
5
+
6
+ from ...models import HardwareRequirements
7
+
8
+
9
+ def build_hw_requirements(hw) -> HardwareRequirements:
10
+ """Derive hardware requirements from a probed *hw* object."""
11
+ if hw is None:
12
+ return HardwareRequirements()
13
+
14
+ display_type = None
15
+ display_res = None
16
+ features: list[str] = []
17
+
18
+ # DRM / DSI
19
+ for output in getattr(hw, "drm_outputs", []):
20
+ if "DSI" in (output.connector or ""):
21
+ display_type = "DSI"
22
+ if output.modes:
23
+ display_res = output.modes[0]
24
+
25
+ if getattr(hw, "backlights", []):
26
+ features.append("backlight")
27
+ if getattr(hw, "i2c_buses", []):
28
+ features.append("i2c")
29
+
30
+ # Infer arch from board name
31
+ board = getattr(hw, "board", "") or ""
32
+ arch = "linux/arm64" if any(x in board.lower() for x in ("rpi", "raspberry", "aarch64", "pi")) else ""
33
+
34
+ # Overlays → wayland / kiosk hints
35
+ for overlay in getattr(hw, "dsi_overlays", []):
36
+ if "dsi" in overlay.lower():
37
+ features.append("wayland")
38
+
39
+ return HardwareRequirements(
40
+ arch=arch,
41
+ display_type=display_type,
42
+ display_resolution=display_res,
43
+ i2c_required=bool(getattr(hw, "i2c_buses", [])),
44
+ features=features,
45
+ )
@@ -0,0 +1,43 @@
1
+ """Extract service specs and app URL from a live or cached ``InfraState``."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional
5
+
6
+ from ...models import InfraState, ServiceSpec
7
+
8
+
9
+ def extract_services_from_infra(infra: InfraState, seen: set[str]) -> list[ServiceSpec]:
10
+ """Return :class:`ServiceSpec` objects for every service found in *infra*.
11
+
12
+ Skips entries whose ``name`` is already in *seen* and adds the new names.
13
+ """
14
+ result: list[ServiceSpec] = []
15
+ for svcs in infra.services.values():
16
+ for svc in svcs:
17
+ if svc.name in seen:
18
+ continue
19
+ seen.add(svc.name)
20
+ result.append(ServiceSpec(
21
+ name=svc.name,
22
+ image=getattr(svc, "image", ""),
23
+ platform="", # unknown at this point
24
+ source_ref="infra:live",
25
+ ))
26
+ return result
27
+
28
+
29
+ def infer_app_url(infra: Optional[InfraState]) -> str | None:
30
+ """Guess the application URL from open ports on *infra*.
31
+
32
+ Prefers ports ``80 → http://host``, ``443 → https://host``, then
33
+ ``8100``, ``8080`` with explicit port numbers.
34
+ """
35
+ if not infra or not infra.host:
36
+ return None
37
+ host = infra.host.split("@")[-1]
38
+ for port in (80, 8100, 8080, 443):
39
+ if port in (infra.ports or {}):
40
+ if port in (80, 443):
41
+ return f"http://{host}" if port == 80 else f"https://{host}"
42
+ return f"http://{host}:{port}"
43
+ return None