redeploy 0.2.66__tar.gz → 0.2.67__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 (243) hide show
  1. {redeploy-0.2.66 → redeploy-0.2.67}/PKG-INFO +6 -6
  2. {redeploy-0.2.66 → redeploy-0.2.67}/README.md +5 -5
  3. {redeploy-0.2.66 → redeploy-0.2.67}/pyproject.toml +1 -1
  4. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/__init__.py +1 -1
  5. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/analyze/spec_analyzer.py +21 -7
  6. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/apply/handlers.py +20 -6
  7. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/commands/plan_apply.py +367 -3
  8. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/schema.py +3 -1
  9. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_analyze.py +53 -0
  10. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_executor.py +1 -1
  11. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy.egg-info/PKG-INFO +6 -6
  12. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/analyze/__init__.py +0 -0
  13. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/analyze/checkers/__init__.py +0 -0
  14. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/analyze/preflight_schema.py +0 -0
  15. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/apply/__init__.py +0 -0
  16. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/apply/exceptions.py +0 -0
  17. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/apply/executor.py +0 -0
  18. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/apply/progress.py +0 -0
  19. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/apply/rollback.py +0 -0
  20. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/apply/state.py +0 -0
  21. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/apply/state_apply.py +0 -0
  22. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/apply/utils/__init__.py +0 -0
  23. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/apply/utils/run_container_build.py +0 -0
  24. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/audit.py +0 -0
  25. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/blueprint/__init__.py +0 -0
  26. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/blueprint/extractor.py +0 -0
  27. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/blueprint/generators/__init__.py +0 -0
  28. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/blueprint/generators/docker_compose.py +0 -0
  29. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/blueprint/generators/migration.py +0 -0
  30. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/blueprint/sources/__init__.py +0 -0
  31. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/blueprint/sources/compose.py +0 -0
  32. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/blueprint/sources/hardware.py +0 -0
  33. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/blueprint/sources/infra.py +0 -0
  34. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/blueprint/sources/migration.py +0 -0
  35. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/__init__.py +0 -0
  36. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/commands/__init__.py +0 -0
  37. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/commands/audit.py +0 -0
  38. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/commands/blueprint.py +0 -0
  39. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/commands/bump_fix.py +0 -0
  40. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/commands/detect.py +0 -0
  41. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/commands/device_map.py +0 -0
  42. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/commands/device_map_renderers.py +0 -0
  43. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/commands/devices.py +0 -0
  44. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/commands/diagnose.py +0 -0
  45. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/commands/diff.py +0 -0
  46. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/commands/exec_.py +0 -0
  47. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/commands/export.py +0 -0
  48. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/commands/hardware.py +0 -0
  49. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/commands/import_.py +0 -0
  50. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/commands/init.py +0 -0
  51. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/commands/inspect.py +0 -0
  52. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/commands/lint.py +0 -0
  53. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/commands/mcp_cmd.py +0 -0
  54. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/commands/patterns.py +0 -0
  55. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/commands/plugin.py +0 -0
  56. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/commands/probe.py +0 -0
  57. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/commands/prompt_cmd.py +0 -0
  58. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/commands/push.py +0 -0
  59. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/commands/state.py +0 -0
  60. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/commands/status.py +0 -0
  61. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/commands/target.py +0 -0
  62. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/commands/version/__init__.py +0 -0
  63. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/commands/version/commands.py +0 -0
  64. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/commands/version/helpers.py +0 -0
  65. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/commands/version/monorepo.py +0 -0
  66. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/commands/version/release.py +0 -0
  67. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/commands/version/scanner.py +0 -0
  68. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/commands/version/utils/__init__.py +0 -0
  69. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/commands/version/utils/changelog_config.py +0 -0
  70. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/commands/version/utils/git_config.py +0 -0
  71. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/commands/workflow.py +0 -0
  72. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/core.py +0 -0
  73. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/display.py +0 -0
  74. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli/query.py +0 -0
  75. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/cli.py +0 -0
  76. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/config_apply/__init__.py +0 -0
  77. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/config_apply/applier.py +0 -0
  78. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/config_apply/loader.py +0 -0
  79. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/data_sync.py +0 -0
  80. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/detect/__init__.py +0 -0
  81. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/detect/builtin/__init__.py +0 -0
  82. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/detect/detector.py +0 -0
  83. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/detect/hardware.py +0 -0
  84. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/detect/hardware_rules.py +0 -0
  85. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/detect/probes.py +0 -0
  86. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/detect/remote.py +0 -0
  87. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/detect/templates.py +0 -0
  88. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/detect/workflow.py +0 -0
  89. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/discovery.py +0 -0
  90. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/dsl/__init__.py +0 -0
  91. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/dsl/loader.py +0 -0
  92. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/dsl/parser.py +0 -0
  93. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/dsl_python/__init__.py +0 -0
  94. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/dsl_python/context.py +0 -0
  95. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/dsl_python/decorators.py +0 -0
  96. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/dsl_python/docker_steps.py +0 -0
  97. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/dsl_python/exceptions.py +0 -0
  98. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/dsl_python/runner.py +0 -0
  99. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/dsl_python/steps.py +0 -0
  100. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/fleet.py +0 -0
  101. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/hardware/__init__.py +0 -0
  102. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/hardware/config_txt.py +0 -0
  103. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/hardware/data/__init__.py +0 -0
  104. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/hardware/data/hyperpixel.py +0 -0
  105. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/hardware/data/official.py +0 -0
  106. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/hardware/data/waveshare.py +0 -0
  107. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/hardware/fixes.py +0 -0
  108. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/hardware/kiosk/__init__.py +0 -0
  109. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/hardware/kiosk/autostart.py +0 -0
  110. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/hardware/kiosk/browsers.py +0 -0
  111. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/hardware/kiosk/compositors.py +0 -0
  112. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/hardware/kiosk/output_profiles.py +0 -0
  113. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/hardware/panels.py +0 -0
  114. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/hardware/raspi_config.py +0 -0
  115. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/heal/__init__.py +0 -0
  116. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/heal/decider.py +0 -0
  117. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/heal/hint_provider.py +0 -0
  118. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/heal/log_writer.py +0 -0
  119. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/heal/loop_detector.py +0 -0
  120. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/heal/runner.py +0 -0
  121. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/heal.py +0 -0
  122. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/iac/__init__.py +0 -0
  123. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/iac/base.py +0 -0
  124. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/iac/config_hints.py +0 -0
  125. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/iac/docker_compose.py +0 -0
  126. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/iac/parsers/__init__.py +0 -0
  127. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/iac/parsers/compose.py +0 -0
  128. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/iac/registry.py +0 -0
  129. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/integrations/__init__.py +0 -0
  130. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/integrations/op3_bridge.py +0 -0
  131. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/markpact/__init__.py +0 -0
  132. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/markpact/compiler.py +0 -0
  133. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/markpact/models.py +0 -0
  134. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/markpact/parser.py +0 -0
  135. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/mcp_server.py +0 -0
  136. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/models/__init__.py +0 -0
  137. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/models/blueprint.py +0 -0
  138. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/models/devices.py +0 -0
  139. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/models/enums.py +0 -0
  140. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/models/hardware.py +0 -0
  141. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/models/infra.py +0 -0
  142. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/models/manifest.py +0 -0
  143. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/models/persisted.py +0 -0
  144. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/models/pipeline.py +0 -0
  145. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/models/plan.py +0 -0
  146. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/models/spec.py +0 -0
  147. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/observe.py +0 -0
  148. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/parse.py +0 -0
  149. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/patterns.py +0 -0
  150. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/plan/__init__.py +0 -0
  151. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/plan/planner.py +0 -0
  152. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/plugins/__init__.py +0 -0
  153. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/plugins/builtin/__init__.py +0 -0
  154. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/plugins/builtin/browser_reload.py +0 -0
  155. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/plugins/builtin/hardware_diagnostic.py +0 -0
  156. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/plugins/builtin/notify.py +0 -0
  157. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/plugins/builtin/process_control.py +0 -0
  158. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/plugins/builtin/systemd_reload.py +0 -0
  159. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/spec_loader.py +0 -0
  160. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/ssh.py +0 -0
  161. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/steps/__init__.py +0 -0
  162. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/steps/builtins.py +0 -0
  163. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/steps/kiosk.py +0 -0
  164. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/__init__.py +0 -0
  165. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_audit.py +0 -0
  166. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_cli.py +0 -0
  167. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_data_sync.py +0 -0
  168. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_discovery.py +0 -0
  169. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_dsl.py +0 -0
  170. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_examples.py +0 -0
  171. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_fleet.py +0 -0
  172. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_git_integration.py +0 -0
  173. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_heal.py +0 -0
  174. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_heal_decider.py +0 -0
  175. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_hooks.py +0 -0
  176. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_iac.py +0 -0
  177. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_iac_compose_contract.py +0 -0
  178. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_iac_docker_compose_contract.py +0 -0
  179. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_manifest.py +0 -0
  180. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_markpact_compiler.py +0 -0
  181. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_markpact_parser.py +0 -0
  182. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_models.py +0 -0
  183. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_observe.py +0 -0
  184. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_parse.py +0 -0
  185. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_patterns.py +0 -0
  186. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_planner_edge.py +0 -0
  187. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_plugins.py +0 -0
  188. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_probes.py +0 -0
  189. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_public_api.py +0 -0
  190. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_resume.py +0 -0
  191. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_spec_loader.py +0 -0
  192. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_ssh.py +0 -0
  193. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_templates.py +0 -0
  194. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_verify.py +0 -0
  195. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_version.py +0 -0
  196. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_version_changelog.py +0 -0
  197. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_version_cli.py +0 -0
  198. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_version_manifest.py +0 -0
  199. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_version_sources.py +0 -0
  200. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/tests/test_workflow.py +0 -0
  201. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/verify.py +0 -0
  202. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/version/__init__.py +0 -0
  203. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/version/bump.py +0 -0
  204. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/version/changelog.py +0 -0
  205. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/version/commits.py +0 -0
  206. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/version/diff.py +0 -0
  207. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/version/git_integration.py +0 -0
  208. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/version/git_transaction.py +0 -0
  209. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/version/manifest.py +0 -0
  210. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/version/sources/__init__.py +0 -0
  211. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/version/sources/base.py +0 -0
  212. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/version/sources/json_.py +0 -0
  213. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/version/sources/plain.py +0 -0
  214. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/version/sources/regex.py +0 -0
  215. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/version/sources/toml_.py +0 -0
  216. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/version/sources/yaml_.py +0 -0
  217. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/version/transaction.py +0 -0
  218. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy/version.py +0 -0
  219. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy.egg-info/SOURCES.txt +0 -0
  220. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy.egg-info/dependency_links.txt +0 -0
  221. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy.egg-info/entry_points.txt +0 -0
  222. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy.egg-info/requires.txt +0 -0
  223. {redeploy-0.2.66 → redeploy-0.2.67}/redeploy.egg-info/top_level.txt +0 -0
  224. {redeploy-0.2.66 → redeploy-0.2.67}/setup.cfg +0 -0
  225. {redeploy-0.2.66 → redeploy-0.2.67}/tests/test_blueprint_sources.py +0 -0
  226. {redeploy-0.2.66 → redeploy-0.2.67}/tests/test_blueprint_sources_compose.py +0 -0
  227. {redeploy-0.2.66 → redeploy-0.2.67}/tests/test_cli_query.py +0 -0
  228. {redeploy-0.2.66 → redeploy-0.2.67}/tests/test_example_argocd_flux_plugin.py +0 -0
  229. {redeploy-0.2.66 → redeploy-0.2.67}/tests/test_example_gitops_ci_plugin.py +0 -0
  230. {redeploy-0.2.66 → redeploy-0.2.67}/tests/test_example_helm_kustomize_plugin.py +0 -0
  231. {redeploy-0.2.66 → redeploy-0.2.67}/tests/test_hardware_config.py +0 -0
  232. {redeploy-0.2.66 → redeploy-0.2.67}/tests/test_hardware_kiosk.py +0 -0
  233. {redeploy-0.2.66 → redeploy-0.2.67}/tests/test_hardware_rules.py +0 -0
  234. {redeploy-0.2.66 → redeploy-0.2.67}/tests/test_heal_decider.py +0 -0
  235. {redeploy-0.2.66 → redeploy-0.2.67}/tests/test_heal_loop_detector.py +0 -0
  236. {redeploy-0.2.66 → redeploy-0.2.67}/tests/test_iac.py +0 -0
  237. {redeploy-0.2.66 → redeploy-0.2.67}/tests/test_iac_config_hints.py +0 -0
  238. {redeploy-0.2.66 → redeploy-0.2.67}/tests/test_iac_plugin_loading.py +0 -0
  239. {redeploy-0.2.66 → redeploy-0.2.67}/tests/test_import_plugin_template.py +0 -0
  240. {redeploy-0.2.66 → redeploy-0.2.67}/tests/test_parse.py +0 -0
  241. {redeploy-0.2.66 → redeploy-0.2.67}/tests/test_prompt_llm.py +0 -0
  242. {redeploy-0.2.66 → redeploy-0.2.67}/tests/test_redeploy.py +0 -0
  243. {redeploy-0.2.66 → redeploy-0.2.67}/tests/test_steps_kiosk.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redeploy
3
- Version: 0.2.66
3
+ Version: 0.2.67
4
4
  Summary: Infrastructure migration toolkit: detect → plan → apply
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -32,17 +32,17 @@ Requires-Dist: mcp>=1.0; extra == "mcp"
32
32
 
33
33
  ## AI Cost Tracking
34
34
 
35
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.66-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
36
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-33.3h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
35
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.67-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
36
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-34.2h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
37
37
 
38
- - 🤖 **LLM usage:** $7.5000 (86 commits)
39
- - 👤 **Human dev:** ~$3331 (33.3h @ $100/h, 30min dedup)
38
+ - 🤖 **LLM usage:** $7.5000 (88 commits)
39
+ - 👤 **Human dev:** ~$3416 (34.2h @ $100/h, 30min dedup)
40
40
 
41
41
  Generated on 2026-04-23 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
42
42
 
43
43
  ---
44
44
 
45
- ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.66-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
45
+ ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.67-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
46
46
 
47
47
  Infrastructure migration and device deploy toolkit — VPS, Raspberry Pi kiosk, Podman Quadlet, k3s.
48
48
 
@@ -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.66-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-33.3h-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.67-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-34.2h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $7.5000 (86 commits)
10
- - 👤 **Human dev:** ~$3331 (33.3h @ $100/h, 30min dedup)
9
+ - 🤖 **LLM usage:** $7.5000 (88 commits)
10
+ - 👤 **Human dev:** ~$3416 (34.2h @ $100/h, 30min dedup)
11
11
 
12
12
  Generated on 2026-04-23 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.66-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.67-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.66"
7
+ version = "0.2.67"
8
8
  description = "Infrastructure migration toolkit: detect → plan → apply"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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.66"
10
+ __version__ = "0.2.67"
11
11
 
12
12
  # ── Core models ───────────────────────────────────────────────────────────────
13
13
  from .models import ( # noqa: F401
@@ -293,6 +293,7 @@ class _ComposeChecker(_Checker):
293
293
  """Validate docker-compose files declared in spec or found in project."""
294
294
 
295
295
  def check(self, spec, document, base_dir, result):
296
+ ign = _IgnoreList(base_dir)
296
297
  compose_files = list(spec.target.compose_files or [])
297
298
  # Also try to discover common names if none explicitly declared
298
299
  if not compose_files:
@@ -310,15 +311,22 @@ class _ComposeChecker(_Checker):
310
311
  suggestion=f"Create {path} or remove from target.compose_files."
311
312
  )
312
313
  continue
313
- self._scan_compose(path, base_dir, result)
314
+ self._scan_compose(path, base_dir, result, ign)
314
315
 
315
- def _scan_compose(self, path: Path, base_dir: Path, result: AnalysisResult):
316
+ def _scan_compose(self, path: Path, base_dir: Path, result: AnalysisResult, ign: _IgnoreList):
316
317
  try:
317
318
  data = yaml.safe_load(path.read_text()) or {}
318
319
  except Exception as exc:
319
320
  result.add(IssueSeverity.ERROR, "compose", f"Cannot parse {path}: {exc}")
320
321
  return
321
322
 
323
+ def _skip_ignored(check_path: Path) -> bool:
324
+ try:
325
+ rel = check_path.resolve().relative_to(base_dir.resolve())
326
+ return ign.is_ignored(rel)
327
+ except ValueError:
328
+ return False
329
+
322
330
  services = data.get("services", {})
323
331
  for svc_name, svc in services.items():
324
332
  if not isinstance(svc, dict):
@@ -337,7 +345,7 @@ class _ComposeChecker(_Checker):
337
345
  dockerfile = build.get("dockerfile")
338
346
  if dockerfile:
339
347
  df_path = ctx_path / dockerfile
340
- if not df_path.exists():
348
+ if not df_path.exists() and not _skip_ignored(df_path):
341
349
  result.add(
342
350
  IssueSeverity.ERROR, "compose",
343
351
  f"Service '{svc_name}' Dockerfile missing: {df_path}",
@@ -349,7 +357,7 @@ class _ComposeChecker(_Checker):
349
357
  env_files = [env_files]
350
358
  for ef in env_files:
351
359
  ef_path = base_dir / ef if not str(ef).startswith("/") else Path(ef)
352
- if not ef_path.exists():
360
+ if not ef_path.exists() and not _skip_ignored(ef_path):
353
361
  result.add(
354
362
  IssueSeverity.WARNING, "compose",
355
363
  f"Service '{svc_name}' env_file missing: {ef}",
@@ -362,7 +370,7 @@ class _ComposeChecker(_Checker):
362
370
  host_part = vol.split(":", 1)[0]
363
371
  # Skip named volumes and relative paths that exist
364
372
  if host_part.startswith("/"):
365
- if not Path(host_part).exists():
373
+ if not Path(host_part).exists() and not _skip_ignored(Path(host_part)):
366
374
  result.add(
367
375
  IssueSeverity.WARNING, "compose",
368
376
  f"Service '{svc_name}' volume host path missing: {host_part}",
@@ -370,7 +378,7 @@ class _ComposeChecker(_Checker):
370
378
  )
371
379
  elif host_part != "." and "/" in host_part:
372
380
  hp = base_dir / host_part
373
- if not hp.exists():
381
+ if not hp.exists() and not _skip_ignored(hp):
374
382
  result.add(
375
383
  IssueSeverity.WARNING, "compose",
376
384
  f"Service '{svc_name}' relative volume path missing: {hp}",
@@ -687,9 +695,11 @@ class SpecAnalyzer:
687
695
  ]
688
696
 
689
697
  def __init__(self, base_dir: Path | None = None,
690
- checkers: list[_Checker] | None = None):
698
+ checkers: list[_Checker] | None = None,
699
+ auto_create_redeployignore: bool = True):
691
700
  self.base_dir = base_dir or Path.cwd()
692
701
  self.checkers = checkers or list(self.DEFAULT_CHECKERS)
702
+ self.auto_create_redeployignore = auto_create_redeployignore
693
703
 
694
704
  def analyze(self, spec: MigrationSpec,
695
705
  document: MarkpactDocument | None = None) -> AnalysisResult:
@@ -704,6 +714,10 @@ class SpecAnalyzer:
704
714
  from ..markpact.compiler import compile_markpact_document, MarkpactCompileError
705
715
  from ..models.spec import MigrationSpec as MS
706
716
 
717
+ # Ensure .redeployignore exists with sensible defaults
718
+ if self.auto_create_redeployignore:
719
+ ensure_redeployignore(self.base_dir)
720
+
707
721
  document = None
708
722
  spec = None
709
723
  result = AnalysisResult()
@@ -21,6 +21,20 @@ if TYPE_CHECKING:
21
21
  from .progress import ProgressEmitter
22
22
 
23
23
 
24
+ def _format_step_output(stdout: str, stderr: str, max_chars: int = 8000) -> str:
25
+ out = str(stdout or "").strip()
26
+ err = str(stderr or "").strip()
27
+ chunks: list[str] = []
28
+ if out:
29
+ chunks.append("stdout:\n" + out)
30
+ if err:
31
+ chunks.append("stderr:\n" + err)
32
+ if not chunks:
33
+ return "ok"
34
+ joined = "\n\n".join(chunks)
35
+ return joined[:max_chars]
36
+
37
+
24
38
  def run_ssh(step: MigrationStep, probe: RemoteProbe) -> None:
25
39
  """Execute SSH command on remote host."""
26
40
  cmd = step.command
@@ -28,9 +42,9 @@ def run_ssh(step: MigrationStep, probe: RemoteProbe) -> None:
28
42
  raise StepError(step, "No command specified")
29
43
  timeout = step.timeout or 300
30
44
  r = probe.run(cmd, timeout=timeout)
31
- step.result = r.out[:500]
45
+ step.result = _format_step_output(r.out, r.stderr)
32
46
  if not r.ok:
33
- raise StepError(step, f"exit={r.exit_code}: {r.stderr[:200]}")
47
+ raise StepError(step, f"exit={r.exit_code}: {_format_step_output(r.out, r.stderr, 400)}")
34
48
  step.status = StepStatus.DONE
35
49
 
36
50
 
@@ -51,9 +65,9 @@ def run_scp(step: MigrationStep, probe: RemoteProbe, plan: MigrationPlan) -> Non
51
65
  step.src, f"{plan.host}:{step.dst}"]
52
66
  result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
53
67
  if result.returncode != 0:
54
- raise StepError(step, f"scp failed: {result.stderr[:200]}")
68
+ raise StepError(step, f"scp failed: {_format_step_output(result.stdout, result.stderr, 400)}")
55
69
  step.status = StepStatus.DONE
56
- step.result = "ok"
70
+ step.result = _format_step_output(result.stdout, result.stderr)
57
71
 
58
72
 
59
73
  def run_rsync(step: MigrationStep, probe: RemoteProbe, plan: MigrationPlan) -> None:
@@ -80,9 +94,9 @@ def run_rsync(step: MigrationStep, probe: RemoteProbe, plan: MigrationPlan) -> N
80
94
  cmd += [step.src, dst]
81
95
  result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
82
96
  if result.returncode != 0:
83
- raise StepError(step, f"rsync failed: {result.stderr[:200]}")
97
+ raise StepError(step, f"rsync failed: {_format_step_output(result.stdout, result.stderr, 400)}")
84
98
  step.status = StepStatus.DONE
85
- step.result = "ok"
99
+ step.result = _format_step_output(result.stdout, result.stderr)
86
100
 
87
101
 
88
102
  def _ensure_remote_parent_dir(probe: RemoteProbe, remote_dst: str) -> None:
@@ -1,10 +1,13 @@
1
1
  """plan, apply, migrate, run commands — Migration planning and execution."""
2
2
  from __future__ import annotations
3
3
 
4
+ import subprocess
4
5
  import sys
6
+ from datetime import datetime, timezone
5
7
  from pathlib import Path
6
8
 
7
9
  import click
10
+ from loguru import logger
8
11
  from rich.console import Console
9
12
  from rich.table import Table
10
13
 
@@ -234,6 +237,18 @@ def migrate(ctx, host, app, domain, target, strategy, target_version,
234
237
  @click.option(
235
238
  "-o", "--output", default=None, type=click.Path(), help="Save apply results to file"
236
239
  )
240
+ @click.option(
241
+ "--report/--no-report", default=False, show_default=True,
242
+ help="Write Markdown execution report next to the spec file."
243
+ )
244
+ @click.option(
245
+ "--report-file", default=None, type=click.Path(),
246
+ help="Override Markdown report output path."
247
+ )
248
+ @click.option(
249
+ "--sync-project/--no-sync-project", default=True, show_default=True,
250
+ help="Before apply, sync full local project tree to target.remote_dir (respects .gitignore/.redeployignore)."
251
+ )
237
252
  @click.option(
238
253
  "--env", "env_name", default="",
239
254
  help="Named environment from redeploy.yaml (e.g. prod, dev, rpi5)"
@@ -289,6 +304,7 @@ def migrate(ctx, host, app, domain, target, strategy, target_version,
289
304
  )
290
305
  @click.pass_context
291
306
  def run(ctx, spec_file, dry_run, plan_only, do_detect, plan_out, output,
307
+ report, report_file, sync_project,
292
308
  env_name, progress_yaml, resume, from_step, state_file, no_state,
293
309
  heal, fix_hint, max_heal_retries, lint,
294
310
  preflight, preflight_schema_out, preflight_remote, strict_preflight):
@@ -327,6 +343,19 @@ def run(ctx, spec_file, dry_run, plan_only, do_detect, plan_out, output,
327
343
  sys.exit(1)
328
344
 
329
345
  spec = load_spec_or_exit(console, resolved_spec)
346
+ started_at = datetime.now(timezone.utc)
347
+
348
+ # Redirect all logger output to a dated file under .redeploy/logs/
349
+ log_dir = Path.cwd() / ".redeploy" / "logs"
350
+ log_dir.mkdir(parents=True, exist_ok=True)
351
+ log_file = log_dir / f"redeploy-{started_at.strftime('%Y%m%d_%H%M%S')}.log"
352
+ file_handler_id = logger.add(
353
+ log_file,
354
+ level="DEBUG",
355
+ format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level:<8} | {name}:{function}:{line} | {message}",
356
+ enqueue=True,
357
+ )
358
+ logger.info("redeploy run started — spec={}", resolved_spec)
330
359
 
331
360
  # Overlay manifest values
332
361
  _apply_manifest_to_spec(console, manifest, spec, env_name)
@@ -349,6 +378,7 @@ def run(ctx, spec_file, dry_run, plan_only, do_detect, plan_out, output,
349
378
  console.print(f" [dim]→ {i.suggestion}[/dim]")
350
379
  if not lint_result.passed:
351
380
  console.print("\n[red]✗ lint failed — fix errors above or use --no-lint to skip.[/red]")
381
+ logger.remove(file_handler_id)
352
382
  sys.exit(1)
353
383
  else:
354
384
  console.print(f"[yellow]⚠ lint passed with {len(lint_result.warnings())} warning(s)[/yellow]")
@@ -365,6 +395,10 @@ def run(ctx, spec_file, dry_run, plan_only, do_detect, plan_out, output,
365
395
  console.print(f"\n[bold]plan[/bold]")
366
396
  migration = planner.run()
367
397
 
398
+ # Keep remote app tree synchronized on every deployment run.
399
+ if sync_project:
400
+ _inject_project_sync_step(migration, spec, Path.cwd(), console)
401
+
368
402
  if plan_out:
369
403
  planner.save(migration, Path(plan_out))
370
404
  console.print(f" [dim]plan saved → {plan_out}[/dim]")
@@ -396,17 +430,28 @@ def run(ctx, spec_file, dry_run, plan_only, do_detect, plan_out, output,
396
430
  console.print(f" [dim]... and {blockers - 10} more blockers in schema file[/dim]")
397
431
  if strict_preflight:
398
432
  console.print("\n[red]✗ strict preflight failed — aborting before apply[/red]")
433
+ logger.remove(file_handler_id)
399
434
  sys.exit(1)
400
435
  else:
401
436
  console.print("[green] ✓ preflight passed (no blockers)[/green]")
402
437
 
403
438
  if plan_only:
439
+ if report:
440
+ _write_markdown_report(
441
+ console=console,
442
+ migration=migration,
443
+ spec_path=Path(resolved_spec),
444
+ started_at=started_at,
445
+ ok=True,
446
+ executed=False,
447
+ report_file=Path(report_file) if report_file else None,
448
+ )
404
449
  console.print("\n[dim]--plan-only: stopping before apply[/dim]")
450
+ logger.remove(file_handler_id)
405
451
  return
406
452
 
407
453
  # Apply
408
454
  load_user_plugins()
409
-
410
455
  # Self-healing mode
411
456
  if heal and not dry_run:
412
457
  _load_dotenv()
@@ -441,7 +486,19 @@ def run(ctx, spec_file, dry_run, plan_only, do_detect, plan_out, output,
441
486
  spec_path=str(resolved_spec)
442
487
  )
443
488
 
489
+ if report:
490
+ _write_markdown_report(
491
+ console=console,
492
+ migration=migration,
493
+ spec_path=Path(resolved_spec),
494
+ started_at=started_at,
495
+ ok=ok,
496
+ executed=True,
497
+ report_file=Path(report_file) if report_file else None,
498
+ )
499
+
444
500
  if not ok:
501
+ logger.remove(file_handler_id)
445
502
  sys.exit(1)
446
503
 
447
504
 
@@ -599,8 +656,8 @@ def _ensure_redeployignore(project_root: Path, console) -> None:
599
656
  ".git/",
600
657
  ".venv/",
601
658
  "venv/",
602
- ".env",
603
- ".env.*",
659
+ "# NOTE: .env is intentionally not excluded by default",
660
+ "# so deployment workflows can choose to sync it.",
604
661
  "",
605
662
  "# Python / Node caches",
606
663
  "__pycache__/",
@@ -639,3 +696,310 @@ def _ensure_redeployignore(project_root: Path, console) -> None:
639
696
 
640
697
  target.write_text("\n".join(lines) + "\n", encoding="utf-8")
641
698
  console.print(f"[dim]generated {target.name} (first run)[/dim]")
699
+
700
+
701
+ def _inject_project_sync_step(migration, spec, project_root: Path, console) -> None:
702
+ """Inject a full-project rsync step unless already present.
703
+
704
+ This keeps remote tree aligned with local sources and catches missing files.
705
+ """
706
+ from ...models import MigrationStep, StepAction
707
+
708
+ if any(s.id == "sync_project_tree" for s in migration.steps):
709
+ return
710
+
711
+ host = (migration.host or "").strip().lower()
712
+ if host in {"", "local", "localhost", "127.0.0.1"}:
713
+ return
714
+
715
+ remote_dir = (getattr(spec.target, "remote_dir", "") or "").strip()
716
+ if not remote_dir:
717
+ return
718
+
719
+ sync_step = MigrationStep(
720
+ id="sync_project_tree",
721
+ action=StepAction.RSYNC,
722
+ description="Sync full project tree to remote_dir (respect .gitignore/.redeployignore)",
723
+ src=f"{project_root.as_posix().rstrip('/')}/",
724
+ dst=f"{remote_dir.rstrip('/')}/",
725
+ excludes=[
726
+ ".git/",
727
+ ".redeploy/state/",
728
+ ".redeploy/logs/",
729
+ ],
730
+ )
731
+
732
+ insert_at = 0
733
+ for idx, step in enumerate(migration.steps):
734
+ if step.id == "sync_env":
735
+ insert_at = idx + 1
736
+ break
737
+ migration.steps.insert(insert_at, sync_step)
738
+ console.print("[dim]plan: injected sync_project_tree (full rsync before apply)[/dim]")
739
+
740
+
741
+ def _default_report_path(spec_path: Path) -> Path:
742
+ return spec_path.parent / f".{spec_path.stem}.md"
743
+
744
+
745
+ def _resolve_audit_entry(migration, started_at: datetime, ok: bool):
746
+ from ...observe import AuditEntry, DeployAuditLog
747
+
748
+ log = DeployAuditLog()
749
+ entries = log.filter(host=migration.host, app=migration.app, since=started_at)
750
+ if entries:
751
+ return entries[-1]
752
+
753
+ # Fallback if audit record is unavailable for any reason.
754
+ done_count = sum(1 for s in migration.steps if str(s.status).endswith("DONE"))
755
+ failed_count = sum(1 for s in migration.steps if str(s.status).endswith("FAILED"))
756
+ if ok and done_count == 0 and failed_count == 0:
757
+ # plan-only fallback: treat all planned steps as successful planning output
758
+ done_count = len(migration.steps)
759
+
760
+ data = {
761
+ "ts": datetime.now(timezone.utc).isoformat(),
762
+ "host": migration.host,
763
+ "app": migration.app,
764
+ "from_strategy": migration.from_strategy.value,
765
+ "to_strategy": migration.to_strategy.value,
766
+ "ok": ok,
767
+ "dry_run": False,
768
+ "elapsed_s": 0.0,
769
+ "steps_total": len(migration.steps),
770
+ "steps_ok": done_count,
771
+ "steps_failed": failed_count,
772
+ "steps": [
773
+ {
774
+ "id": s.id,
775
+ "action": s.action.value,
776
+ "status": str(s.status).split(".")[-1].lower(),
777
+ "result": s.result,
778
+ "error": s.error,
779
+ }
780
+ for s in migration.steps
781
+ ],
782
+ }
783
+ return AuditEntry(data)
784
+
785
+
786
+ def _step_command_block(step) -> str:
787
+ if step.command:
788
+ return step.command.strip()
789
+ if step.command_ref:
790
+ return f"command_ref: {step.command_ref}"
791
+ if step.action.value == "rsync":
792
+ parts = [f"rsync {step.src or ''} -> {step.dst or ''}"]
793
+ if step.excludes:
794
+ parts.append("excludes:")
795
+ parts.extend(f"- {x}" for x in step.excludes)
796
+ return "\n".join(parts)
797
+ if step.action.value == "scp":
798
+ return f"scp {step.src or ''} -> {step.dst or ''}"
799
+ if step.action.value in {"http_check", "version_check"}:
800
+ return f"url: {step.url or ''}\nexpect: {step.expect or ''}".strip()
801
+ return f"action: {step.action.value}"
802
+
803
+
804
+ def _build_checksum_verification(migration, executed: bool) -> dict | None:
805
+ """Build post-deploy sync verification with checksum-aware rsync dry-run."""
806
+ sync_step = next((s for s in migration.steps if s.id == "sync_project_tree"), None)
807
+ if not sync_step:
808
+ return None
809
+
810
+ src = (sync_step.src or "").strip()
811
+ dst = (sync_step.dst or "").strip()
812
+ host = (migration.host or "").strip()
813
+
814
+ local_root = Path(src.rstrip("/")).expanduser() if src else Path.cwd()
815
+ remote_root = dst.rstrip("/") if dst else ""
816
+
817
+ if not executed:
818
+ return {
819
+ "status": "skipped",
820
+ "reason": "plan-only run (apply not executed)",
821
+ "host": host,
822
+ "local_root": str(local_root),
823
+ "remote_root": remote_root,
824
+ "command": "(not executed)",
825
+ "changed_paths": [],
826
+ "changed_count": 0,
827
+ }
828
+
829
+ if not host or host.lower() in {"local", "localhost", "127.0.0.1"}:
830
+ return {
831
+ "status": "skipped",
832
+ "reason": "remote checksum verification requires SSH host",
833
+ "host": host,
834
+ "local_root": str(local_root),
835
+ "remote_root": remote_root,
836
+ "command": "(not executed)",
837
+ "changed_paths": [],
838
+ "changed_count": 0,
839
+ }
840
+
841
+ if not local_root.exists() or not remote_root:
842
+ return {
843
+ "status": "error",
844
+ "reason": "sync scope is incomplete (missing local or remote root)",
845
+ "host": host,
846
+ "local_root": str(local_root),
847
+ "remote_root": remote_root,
848
+ "command": "(not executed)",
849
+ "changed_paths": [],
850
+ "changed_count": 0,
851
+ }
852
+
853
+ cmd = ["rsync", "-a", "-z", "--delete", "--checksum", "--dry-run", "--itemize-changes"]
854
+ if (local_root / ".gitignore").exists():
855
+ cmd += ["--filter=:- .gitignore"]
856
+ if (local_root / ".redeployignore").exists():
857
+ cmd += ["--filter=:- .redeployignore"]
858
+ for ex in sync_step.excludes or []:
859
+ cmd += ["--exclude", ex]
860
+ cmd += [f"{local_root.as_posix().rstrip('/')}/", f"{host}:{remote_root.rstrip('/')}/"]
861
+
862
+ command_preview = " ".join(cmd)
863
+ try:
864
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=1200)
865
+ except Exception as exc: # pragma: no cover - defensive, non-fatal for report
866
+ return {
867
+ "status": "error",
868
+ "reason": str(exc),
869
+ "host": host,
870
+ "local_root": str(local_root),
871
+ "remote_root": remote_root,
872
+ "command": command_preview,
873
+ "changed_paths": [],
874
+ "changed_count": 0,
875
+ }
876
+
877
+ raw_lines = [ln.rstrip() for ln in (result.stdout or "").splitlines() if ln.strip()]
878
+ changed_lines = [
879
+ ln
880
+ for ln in raw_lines
881
+ if ln not in {"sending incremental file list"}
882
+ and not ln.startswith("sent ")
883
+ and not ln.startswith("total size is ")
884
+ and not ln.startswith("created directory ")
885
+ ]
886
+
887
+ status = "match" if result.returncode == 0 and not changed_lines else "mismatch"
888
+ reason = ""
889
+ if result.returncode != 0:
890
+ status = "error"
891
+ reason = (result.stderr or "").strip() or f"rsync exited with {result.returncode}"
892
+
893
+ return {
894
+ "status": status,
895
+ "reason": reason,
896
+ "host": host,
897
+ "local_root": str(local_root),
898
+ "remote_root": remote_root,
899
+ "command": command_preview,
900
+ "changed_paths": changed_lines[:200],
901
+ "changed_count": len(changed_lines),
902
+ }
903
+
904
+
905
+ def _render_markdown_report(entry, migration, spec_path: Path, checksum: dict | None = None) -> str:
906
+ by_id = {s.id: s for s in migration.steps}
907
+ lines: list[str] = [
908
+ f"# Redeploy Execution Report - {spec_path.name}",
909
+ "",
910
+ f"- Timestamp: {entry.ts}",
911
+ f"- Spec: {spec_path}",
912
+ f"- Host: {entry.host}",
913
+ f"- App: {entry.app}",
914
+ f"- Strategy: {entry.from_strategy} -> {entry.to_strategy}",
915
+ f"- Result: {'SUCCESS' if entry.ok else 'FAILED'}",
916
+ f"- Steps: {entry.steps_ok}/{entry.steps_total} ok",
917
+ f"- Elapsed: {entry.elapsed_s:.1f}s",
918
+ "",
919
+ ]
920
+
921
+ if checksum:
922
+ status = (checksum.get("status") or "unknown").upper()
923
+ lines += [
924
+ "## Sync Checksum Verification",
925
+ "",
926
+ "- Method: rsync --checksum --dry-run --itemize-changes",
927
+ f"- Scope: {checksum.get('local_root', '')} -> {checksum.get('host', '')}:{checksum.get('remote_root', '')}",
928
+ f"- Status: {status}",
929
+ f"- Changed Paths: {checksum.get('changed_count', 0)}",
930
+ ]
931
+ reason = (checksum.get("reason") or "").strip()
932
+ if reason:
933
+ lines.append(f"- Note: {reason}")
934
+ lines += [
935
+ "",
936
+ "#### Command",
937
+ "```bash",
938
+ checksum.get("command") or "(not executed)",
939
+ "```",
940
+ "",
941
+ ]
942
+ if checksum.get("changed_paths"):
943
+ lines += [
944
+ "#### Differences",
945
+ "```text",
946
+ "\n".join(checksum.get("changed_paths") or []),
947
+ "```",
948
+ "",
949
+ ]
950
+
951
+ lines += [
952
+ "## Step Logs",
953
+ "",
954
+ ]
955
+
956
+ for i, step_row in enumerate(entry.steps, 1):
957
+ sid = step_row.get("id", "")
958
+ status = (step_row.get("status") or "").upper()
959
+ action = step_row.get("action", "")
960
+ model_step = by_id.get(sid)
961
+ desc = model_step.description if model_step else ""
962
+ cmd = _step_command_block(model_step) if model_step else f"action: {action}"
963
+ result = (step_row.get("result") or "").strip()
964
+ error = (step_row.get("error") or "").strip()
965
+ log_text = error or result or "(no output captured)"
966
+
967
+ lines += [
968
+ f"### {i}. {sid} [{status}]",
969
+ "",
970
+ f"- Action: {action}",
971
+ f"- Description: {desc}",
972
+ "",
973
+ "#### Executed",
974
+ "```bash",
975
+ cmd,
976
+ "```",
977
+ "",
978
+ "#### Output",
979
+ "```text",
980
+ log_text,
981
+ "```",
982
+ "",
983
+ ]
984
+
985
+ return "\n".join(lines)
986
+
987
+
988
+ def _write_markdown_report(
989
+ console,
990
+ migration,
991
+ spec_path: Path,
992
+ started_at: datetime,
993
+ ok: bool,
994
+ executed: bool,
995
+ report_file: Path | None = None,
996
+ ) -> None:
997
+ entry = _resolve_audit_entry(migration, started_at=started_at, ok=ok)
998
+ checksum = _build_checksum_verification(migration, executed=executed)
999
+ out_path = report_file or _default_report_path(spec_path)
1000
+ out_path.parent.mkdir(parents=True, exist_ok=True)
1001
+ out_path.write_text(
1002
+ _render_markdown_report(entry, migration, spec_path, checksum=checksum),
1003
+ encoding="utf-8",
1004
+ )
1005
+ console.print(f"[bold]report[/bold] [dim]saved → {out_path}[/dim]")