redeploy 0.2.70__tar.gz → 0.2.72__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 (256) hide show
  1. {redeploy-0.2.70 → redeploy-0.2.72}/PKG-INFO +7 -7
  2. {redeploy-0.2.70 → redeploy-0.2.72}/README.md +6 -6
  3. {redeploy-0.2.70 → redeploy-0.2.72}/pyproject.toml +1 -1
  4. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/__init__.py +1 -1
  5. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/analyze/preflight_schema.py +9 -15
  6. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/analyze/spec_analyzer.py +2 -5
  7. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/apply/handlers.py +8 -14
  8. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/exec_.py +7 -18
  9. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/config_apply/handlers/display.py +30 -4
  10. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/detect/probes.py +7 -1
  11. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/heal/runner.py +19 -2
  12. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/markpact/__init__.py +2 -0
  13. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/markpact/parser.py +30 -0
  14. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/mcp_server.py +42 -0
  15. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_heal.py +56 -0
  16. redeploy-0.2.72/redeploy/tests/test_security_hardening.py +93 -0
  17. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy.egg-info/PKG-INFO +7 -7
  18. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy.egg-info/SOURCES.txt +1 -0
  19. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/analyze/__init__.py +0 -0
  20. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/analyze/checkers/__init__.py +0 -0
  21. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/apply/__init__.py +0 -0
  22. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/apply/exceptions.py +0 -0
  23. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/apply/executor.py +0 -0
  24. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/apply/progress.py +0 -0
  25. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/apply/rollback.py +0 -0
  26. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/apply/state.py +0 -0
  27. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/apply/state_apply.py +0 -0
  28. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/apply/utils/__init__.py +0 -0
  29. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/apply/utils/run_container_build.py +0 -0
  30. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/audit.py +0 -0
  31. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/blueprint/__init__.py +0 -0
  32. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/blueprint/extractor.py +0 -0
  33. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/blueprint/generators/__init__.py +0 -0
  34. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/blueprint/generators/docker_compose.py +0 -0
  35. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/blueprint/generators/migration.py +0 -0
  36. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/blueprint/sources/__init__.py +0 -0
  37. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/blueprint/sources/compose.py +0 -0
  38. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/blueprint/sources/hardware.py +0 -0
  39. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/blueprint/sources/infra.py +0 -0
  40. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/blueprint/sources/migration.py +0 -0
  41. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/__init__.py +0 -0
  42. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/__init__.py +0 -0
  43. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/audit.py +0 -0
  44. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/blueprint.py +0 -0
  45. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/bump_fix.py +0 -0
  46. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/detect.py +0 -0
  47. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/device_map.py +0 -0
  48. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/device_map_renderers.py +0 -0
  49. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/devices.py +0 -0
  50. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/diagnose.py +0 -0
  51. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/diff.py +0 -0
  52. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/export.py +0 -0
  53. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/gh_workflow.py +0 -0
  54. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/hardware.py +0 -0
  55. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/import_.py +0 -0
  56. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/init.py +0 -0
  57. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/inspect.py +0 -0
  58. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/lint.py +0 -0
  59. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/mcp_cmd.py +0 -0
  60. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/patterns.py +0 -0
  61. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/plan_apply.py +0 -0
  62. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/plugin.py +0 -0
  63. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/probe.py +0 -0
  64. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/prompt_cmd.py +0 -0
  65. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/push.py +0 -0
  66. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/state.py +0 -0
  67. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/status.py +0 -0
  68. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/target.py +0 -0
  69. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/version/__init__.py +0 -0
  70. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/version/commands.py +0 -0
  71. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/version/helpers.py +0 -0
  72. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/version/monorepo.py +0 -0
  73. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/version/release.py +0 -0
  74. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/version/scanner.py +0 -0
  75. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/version/utils/__init__.py +0 -0
  76. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/version/utils/changelog_config.py +0 -0
  77. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/version/utils/git_config.py +0 -0
  78. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/commands/workflow.py +0 -0
  79. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/core.py +0 -0
  80. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/display.py +0 -0
  81. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli/query.py +0 -0
  82. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/cli.py +0 -0
  83. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/config_apply/__init__.py +0 -0
  84. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/config_apply/applier.py +0 -0
  85. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/config_apply/handlers/__init__.py +0 -0
  86. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/config_apply/loader.py +0 -0
  87. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/data_sync.py +0 -0
  88. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/detect/__init__.py +0 -0
  89. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/detect/builtin/__init__.py +0 -0
  90. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/detect/detector.py +0 -0
  91. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/detect/hardware.py +0 -0
  92. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/detect/hardware_rules.py +0 -0
  93. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/detect/remote.py +0 -0
  94. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/detect/templates.py +0 -0
  95. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/detect/workflow.py +0 -0
  96. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/discovery.py +0 -0
  97. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/dsl/__init__.py +0 -0
  98. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/dsl/loader.py +0 -0
  99. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/dsl/parser.py +0 -0
  100. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/dsl_python/__init__.py +0 -0
  101. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/dsl_python/context.py +0 -0
  102. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/dsl_python/decorators.py +0 -0
  103. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/dsl_python/docker_steps.py +0 -0
  104. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/dsl_python/exceptions.py +0 -0
  105. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/dsl_python/runner.py +0 -0
  106. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/dsl_python/steps.py +0 -0
  107. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/fleet.py +0 -0
  108. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/hardware/__init__.py +0 -0
  109. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/hardware/config_txt.py +0 -0
  110. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/hardware/data/__init__.py +0 -0
  111. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/hardware/data/hyperpixel.py +0 -0
  112. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/hardware/data/official.py +0 -0
  113. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/hardware/data/waveshare.py +0 -0
  114. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/hardware/fixes.py +0 -0
  115. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/hardware/kiosk/__init__.py +0 -0
  116. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/hardware/kiosk/autostart.py +0 -0
  117. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/hardware/kiosk/browsers.py +0 -0
  118. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/hardware/kiosk/compositors.py +0 -0
  119. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/hardware/kiosk/output_profiles.py +0 -0
  120. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/hardware/panels.py +0 -0
  121. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/hardware/raspi_config.py +0 -0
  122. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/heal/__init__.py +0 -0
  123. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/heal/decider.py +0 -0
  124. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/heal/hint_provider.py +0 -0
  125. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/heal/log_writer.py +0 -0
  126. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/heal/loop_detector.py +0 -0
  127. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/heal.py +0 -0
  128. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/iac/__init__.py +0 -0
  129. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/iac/base.py +0 -0
  130. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/iac/config_hints.py +0 -0
  131. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/iac/docker_compose.py +0 -0
  132. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/iac/parsers/__init__.py +0 -0
  133. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/iac/parsers/compose.py +0 -0
  134. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/iac/registry.py +0 -0
  135. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/integrations/__init__.py +0 -0
  136. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/integrations/op3_bridge.py +0 -0
  137. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/markpact/compiler.py +0 -0
  138. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/markpact/models.py +0 -0
  139. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/models/__init__.py +0 -0
  140. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/models/blueprint.py +0 -0
  141. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/models/devices.py +0 -0
  142. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/models/enums.py +0 -0
  143. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/models/hardware.py +0 -0
  144. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/models/infra.py +0 -0
  145. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/models/manifest.py +0 -0
  146. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/models/persisted.py +0 -0
  147. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/models/pipeline.py +0 -0
  148. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/models/plan.py +0 -0
  149. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/models/spec.py +0 -0
  150. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/observe.py +0 -0
  151. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/parse.py +0 -0
  152. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/patterns.py +0 -0
  153. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/plan/__init__.py +0 -0
  154. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/plan/planner.py +0 -0
  155. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/plugins/__init__.py +0 -0
  156. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/plugins/builtin/__init__.py +0 -0
  157. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/plugins/builtin/browser_reload.py +0 -0
  158. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/plugins/builtin/hardware_diagnostic.py +0 -0
  159. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/plugins/builtin/notify.py +0 -0
  160. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/plugins/builtin/process_control.py +0 -0
  161. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/plugins/builtin/systemd_reload.py +0 -0
  162. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/schema.py +0 -0
  163. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/spec_loader.py +0 -0
  164. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/ssh.py +0 -0
  165. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/steps/__init__.py +0 -0
  166. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/steps/docker.py +0 -0
  167. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/steps/generic.py +0 -0
  168. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/steps/hardware.py +0 -0
  169. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/steps/k3s.py +0 -0
  170. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/steps/kiosk.py +0 -0
  171. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/steps/podman.py +0 -0
  172. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/steps/process.py +0 -0
  173. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/steps/scm.py +0 -0
  174. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/steps/transfer.py +0 -0
  175. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/__init__.py +0 -0
  176. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_analyze.py +0 -0
  177. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_audit.py +0 -0
  178. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_cli.py +0 -0
  179. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_data_sync.py +0 -0
  180. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_discovery.py +0 -0
  181. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_dsl.py +0 -0
  182. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_examples.py +0 -0
  183. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_executor.py +0 -0
  184. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_fleet.py +0 -0
  185. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_git_integration.py +0 -0
  186. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_heal_decider.py +0 -0
  187. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_hooks.py +0 -0
  188. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_iac.py +0 -0
  189. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_iac_compose_contract.py +0 -0
  190. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_iac_docker_compose_contract.py +0 -0
  191. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_manifest.py +0 -0
  192. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_markpact_compiler.py +0 -0
  193. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_markpact_parser.py +0 -0
  194. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_models.py +0 -0
  195. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_observe.py +0 -0
  196. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_parse.py +0 -0
  197. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_patterns.py +0 -0
  198. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_planner_edge.py +0 -0
  199. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_plugins.py +0 -0
  200. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_probes.py +0 -0
  201. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_public_api.py +0 -0
  202. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_resume.py +0 -0
  203. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_spec_loader.py +0 -0
  204. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_ssh.py +0 -0
  205. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_steps_registry.py +0 -0
  206. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_templates.py +0 -0
  207. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_verify.py +0 -0
  208. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_version.py +0 -0
  209. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_version_changelog.py +0 -0
  210. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_version_cli.py +0 -0
  211. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_version_manifest.py +0 -0
  212. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_version_sources.py +0 -0
  213. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/tests/test_workflow.py +0 -0
  214. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/verify.py +0 -0
  215. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/version/__init__.py +0 -0
  216. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/version/bump.py +0 -0
  217. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/version/changelog.py +0 -0
  218. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/version/commits.py +0 -0
  219. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/version/diff.py +0 -0
  220. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/version/git_integration.py +0 -0
  221. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/version/git_transaction.py +0 -0
  222. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/version/manifest.py +0 -0
  223. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/version/sources/__init__.py +0 -0
  224. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/version/sources/base.py +0 -0
  225. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/version/sources/json_.py +0 -0
  226. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/version/sources/plain.py +0 -0
  227. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/version/sources/regex.py +0 -0
  228. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/version/sources/toml_.py +0 -0
  229. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/version/sources/yaml_.py +0 -0
  230. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/version/transaction.py +0 -0
  231. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy/version.py +0 -0
  232. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy.egg-info/dependency_links.txt +0 -0
  233. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy.egg-info/entry_points.txt +0 -0
  234. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy.egg-info/requires.txt +0 -0
  235. {redeploy-0.2.70 → redeploy-0.2.72}/redeploy.egg-info/top_level.txt +0 -0
  236. {redeploy-0.2.70 → redeploy-0.2.72}/setup.cfg +0 -0
  237. {redeploy-0.2.70 → redeploy-0.2.72}/tests/test_blueprint_sources.py +0 -0
  238. {redeploy-0.2.70 → redeploy-0.2.72}/tests/test_blueprint_sources_compose.py +0 -0
  239. {redeploy-0.2.70 → redeploy-0.2.72}/tests/test_cli_gh_workflow.py +0 -0
  240. {redeploy-0.2.70 → redeploy-0.2.72}/tests/test_cli_query.py +0 -0
  241. {redeploy-0.2.70 → redeploy-0.2.72}/tests/test_example_argocd_flux_plugin.py +0 -0
  242. {redeploy-0.2.70 → redeploy-0.2.72}/tests/test_example_gitops_ci_plugin.py +0 -0
  243. {redeploy-0.2.70 → redeploy-0.2.72}/tests/test_example_helm_kustomize_plugin.py +0 -0
  244. {redeploy-0.2.70 → redeploy-0.2.72}/tests/test_hardware_config.py +0 -0
  245. {redeploy-0.2.70 → redeploy-0.2.72}/tests/test_hardware_kiosk.py +0 -0
  246. {redeploy-0.2.70 → redeploy-0.2.72}/tests/test_hardware_rules.py +0 -0
  247. {redeploy-0.2.70 → redeploy-0.2.72}/tests/test_heal_decider.py +0 -0
  248. {redeploy-0.2.70 → redeploy-0.2.72}/tests/test_heal_loop_detector.py +0 -0
  249. {redeploy-0.2.70 → redeploy-0.2.72}/tests/test_iac.py +0 -0
  250. {redeploy-0.2.70 → redeploy-0.2.72}/tests/test_iac_config_hints.py +0 -0
  251. {redeploy-0.2.70 → redeploy-0.2.72}/tests/test_iac_plugin_loading.py +0 -0
  252. {redeploy-0.2.70 → redeploy-0.2.72}/tests/test_import_plugin_template.py +0 -0
  253. {redeploy-0.2.70 → redeploy-0.2.72}/tests/test_parse.py +0 -0
  254. {redeploy-0.2.70 → redeploy-0.2.72}/tests/test_prompt_llm.py +0 -0
  255. {redeploy-0.2.70 → redeploy-0.2.72}/tests/test_redeploy.py +0 -0
  256. {redeploy-0.2.70 → redeploy-0.2.72}/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.70
3
+ Version: 0.2.72
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.70-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-36.8h-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.72-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-38.5h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
37
37
 
38
- - 🤖 **LLM usage:** $7.5000 (91 commits)
39
- - 👤 **Human dev:** ~$3684 (36.8h @ $100/h, 30min dedup)
38
+ - 🤖 **LLM usage:** $7.5000 (93 commits)
39
+ - 👤 **Human dev:** ~$3848 (38.5h @ $100/h, 30min dedup)
40
40
 
41
- Generated on 2026-04-23 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
41
+ Generated on 2026-04-24 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.70-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.72-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.70-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-36.8h-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.72-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-38.5h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $7.5000 (91 commits)
10
- - 👤 **Human dev:** ~$3684 (36.8h @ $100/h, 30min dedup)
9
+ - 🤖 **LLM usage:** $7.5000 (93 commits)
10
+ - 👤 **Human dev:** ~$3848 (38.5h @ $100/h, 30min dedup)
11
11
 
12
- Generated on 2026-04-23 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
12
+ Generated on 2026-04-24 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.70-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.72-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.70"
7
+ version = "0.2.72"
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.70"
10
+ __version__ = "0.2.72"
11
11
 
12
12
  # ── Core models ───────────────────────────────────────────────────────────────
13
13
  from .models import ( # noqa: F401
@@ -124,7 +124,7 @@ def save_preflight_schema(schema: dict[str, Any], output_path: Path) -> None:
124
124
 
125
125
 
126
126
  def _resolve_command_refs(spec, spec_path: Path, blockers: list[dict[str, Any]]) -> list[dict[str, Any]]:
127
- from ..markpact.parser import extract_script_by_ref, extract_script_from_markdown
127
+ from ..markpact.parser import resolve_script_ref
128
128
 
129
129
  out: list[dict[str, Any]] = []
130
130
  for step in spec.extra_steps:
@@ -156,14 +156,11 @@ def _resolve_command_refs(spec, spec_path: Path, blockers: list[dict[str, Any]])
156
156
  })
157
157
  continue
158
158
  text = ref_file.read_text(encoding="utf-8", errors="replace")
159
- by_ref = extract_script_by_ref(text, ref_id, language="bash")
160
- by_heading = extract_script_from_markdown(text, ref_id, language="bash")
161
- if by_ref is not None:
159
+ result = resolve_script_ref(text, ref_id, language="bash")
160
+ if result is not None:
162
161
  entry["resolved"] = True
163
- entry["mode"] = "markpact_ref"
164
- elif by_heading is not None:
165
- entry["resolved"] = True
166
- entry["mode"] = "heading"
162
+ _, lookup_method = result
163
+ entry["mode"] = "markpact_ref" if lookup_method == "markpact:ref" else "heading"
167
164
  else:
168
165
  blockers.append({
169
166
  "type": "missing_command_ref",
@@ -178,14 +175,11 @@ def _resolve_command_refs(spec, spec_path: Path, blockers: list[dict[str, Any]])
178
175
  entry["file"] = str(ref_file)
179
176
  entry["ref"] = cref
180
177
  text = ref_file.read_text(encoding="utf-8", errors="replace") if ref_file.exists() else ""
181
- by_ref = extract_script_by_ref(text, cref, language="bash")
182
- by_heading = extract_script_from_markdown(text, cref, language="bash")
183
- if by_ref is not None:
184
- entry["resolved"] = True
185
- entry["mode"] = "markpact_ref"
186
- elif by_heading is not None:
178
+ result = resolve_script_ref(text, cref, language="bash")
179
+ if result is not None:
187
180
  entry["resolved"] = True
188
- entry["mode"] = "heading"
181
+ _, lookup_method = result
182
+ entry["mode"] = "markpact_ref" if lookup_method == "markpact:ref" else "heading"
189
183
  else:
190
184
  blockers.append({
191
185
  "type": "missing_command_ref",
@@ -523,7 +523,7 @@ class _CommandRefChecker(_Checker):
523
523
  """Validate command_ref references for nested markdown/script dependencies."""
524
524
 
525
525
  def check(self, spec, document, base_dir, result):
526
- from ..markpact.parser import extract_script_by_ref, extract_script_from_markdown
526
+ from ..markpact.parser import resolve_script_ref
527
527
 
528
528
  in_doc_refs: set[str] = set()
529
529
  if document:
@@ -551,10 +551,7 @@ class _CommandRefChecker(_Checker):
551
551
  )
552
552
  continue
553
553
  text = file_path.read_text(encoding="utf-8", errors="replace")
554
- if not (
555
- extract_script_by_ref(text, ref_id, language="bash")
556
- or extract_script_from_markdown(text, ref_id, language="bash")
557
- ):
554
+ if resolve_script_ref(text, ref_id, language="bash") is None:
558
555
  result.add(
559
556
  IssueSeverity.ERROR,
560
557
  "command_ref",
@@ -350,7 +350,7 @@ def _resolve_command_ref(command_ref: str, step: MigrationStep, plan: MigrationP
350
350
  - "#section-id" - script from section in current spec file
351
351
  - "#kiosk-browser-configuration-script" - markpact:ref block
352
352
  """
353
- from ..markpact.parser import extract_script_from_markdown, extract_script_by_ref
353
+ from ..markpact import resolve_script_ref
354
354
 
355
355
  # Parse command_ref
356
356
  if "#" in command_ref:
@@ -367,21 +367,15 @@ def _resolve_command_ref(command_ref: str, step: MigrationStep, plan: MigrationP
367
367
  if not file_path.exists():
368
368
  raise StepError(step, f"Command ref file not found: {file_path}")
369
369
 
370
- # Extract script from markdown - try both methods:
371
- # 1. First try markpact:ref codeblock (new format)
372
- # 2. Then try section heading (old format)
373
370
  markdown_content = file_path.read_text(encoding="utf-8")
371
+ result = resolve_script_ref(markdown_content, section_id, language="bash")
374
372
 
375
- # Try markpact:ref format first
376
- script = extract_script_by_ref(markdown_content, section_id, language="bash")
377
-
378
- # Fallback to section heading format
379
- if script is None:
380
- script = extract_script_from_markdown(markdown_content, section_id, language="bash")
381
-
382
- if script is None:
383
- raise StepError(step, f"Could not find bash script for ref '{section_id}' in {file_path} (tried markpact:ref and section heading)")
384
-
373
+ if result is None:
374
+ raise StepError(
375
+ step,
376
+ f"Could not find bash script for ref '{section_id}' in {file_path} (tried markpact:ref and section heading)"
377
+ )
378
+ script, _ = result
385
379
  return script
386
380
 
387
381
 
@@ -34,7 +34,6 @@ def exec_cmd(ctx, ref, host, markdown_file, dry_run, timeout):
34
34
  redeploy exec '#install-deps' --host root@server.com --file deploy.md --dry-run
35
35
  redeploy exec 'kiosk-browser-configuration-script' --host pi@192.168.188.108 --file migration.md
36
36
  """
37
- from ...markpact.parser import extract_script_by_ref
38
37
  from ...apply.executor import Executor, MigrationPlan, MigrationStep, StepAction
39
38
 
40
39
  console = Console()
@@ -65,15 +64,14 @@ def exec_cmd(ctx, ref, host, markdown_file, dry_run, timeout):
65
64
  console.print(f"[dim]Reading {md_path}...[/dim]")
66
65
  md_content = md_path.read_text(encoding="utf-8")
67
66
 
68
- script = extract_script_by_ref(md_content, ref_id, language="bash")
69
- lookup_method = "markpact:ref"
70
-
71
- if script is None:
67
+ result = _extract_script_for_ref(md_content, ref_id)
68
+ if result is None:
72
69
  console.print(f"[red]✗ Could not find bash script with ref '#{ref_id}'[/red]")
73
70
  console.print("[dim] Make sure there's either:[/dim]")
74
71
  console.print("[dim] - A codeblock: ```bash markpact:ref {ref_id}[/dim]")
75
72
  console.print("[dim] - Or a ## section heading and ```bash codeblock[/dim]")
76
73
  sys.exit(1)
74
+ script, lookup_method = result
77
75
 
78
76
  console.print(f"[green]✓[/green] Found script ({len(script)} chars) via {lookup_method} '#{ref_id}'")
79
77
 
@@ -183,8 +181,6 @@ def exec_multi_cmd(ctx, refs, host, markdown_file, dry_run, timeout, parallel):
183
181
 
184
182
  def _extract_all_scripts(md_path, ref_list, console) -> list[tuple[str, str, str]]:
185
183
  """Extract all scripts from markdown by ref list."""
186
- from ...markpact.parser import extract_script_from_markdown, extract_script_by_ref
187
-
188
184
  md_content = md_path.read_text(encoding="utf-8")
189
185
  scripts: list[tuple[str, str, str]] = []
190
186
 
@@ -202,19 +198,12 @@ def _extract_all_scripts(md_path, ref_list, console) -> list[tuple[str, str, str
202
198
 
203
199
  def _extract_script_for_ref(md_content, ref_id) -> tuple[str, str] | None:
204
200
  """Extract script for a single ref."""
205
- from ...markpact.parser import extract_script_from_markdown, extract_script_by_ref
206
-
207
- # Try markpact:ref first
208
- script = extract_script_by_ref(md_content, ref_id, language="bash")
209
- lookup_method = "markpact:ref"
210
-
211
- if script is None and ref_id.startswith("#"):
212
- script = extract_script_from_markdown(md_content, ref_id[1:], language="bash")
213
- lookup_method = "section"
201
+ from ...markpact import resolve_script_ref
214
202
 
215
- if script is None:
203
+ result = resolve_script_ref(md_content, ref_id, language="bash")
204
+ if result is None:
216
205
  return None
217
- return script, lookup_method
206
+ return result
218
207
 
219
208
 
220
209
  def _execute_single_script(ref_id, script, host, timeout) -> bool:
@@ -2,12 +2,34 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import re
5
+ import shlex
5
6
  import sys
6
7
 
7
8
  from rich.console import Console
8
9
 
9
10
  from ...detect.remote import RemoteProbe
10
- from ...hardware.kiosk.compositors import DSI_OUTPUT_NAME
11
+
12
+
13
+ _SAFE_OUTPUT_RE = re.compile(r"^[A-Za-z0-9_.:-]+$")
14
+ _ALLOWED_TRANSFORMS = {
15
+ "normal",
16
+ "90",
17
+ "180",
18
+ "270",
19
+ "flipped",
20
+ "flipped-90",
21
+ "flipped-180",
22
+ "flipped-270",
23
+ }
24
+
25
+
26
+ def _validate_display_inputs(output_name: str, transform: str) -> None:
27
+ if not _SAFE_OUTPUT_RE.match(output_name):
28
+ raise ValueError(f"Unsafe display output name: {output_name!r}")
29
+ if transform not in _ALLOWED_TRANSFORMS:
30
+ raise ValueError(
31
+ f"Unsupported transform: {transform!r}. Allowed: {sorted(_ALLOWED_TRANSFORMS)}"
32
+ )
11
33
 
12
34
 
13
35
  def apply_display_transform(
@@ -30,12 +52,15 @@ def apply_display_transform(
30
52
  One of the wlr-randr transforms (``normal``, ``90``, ``180``, ``270``,
31
53
  ``flipped``, …).
32
54
  """
55
+ _validate_display_inputs(output_name, transform)
56
+
33
57
  console.print(f"[cyan]→ Setting transform {transform} on {output_name}[/cyan]")
34
58
 
35
59
  # 1. Apply immediately via wlr-randr
36
60
  wlr_cmd = (
37
61
  f"WAYLAND_DISPLAY=wayland-0 XDG_RUNTIME_DIR=/run/user/$(id -u) "
38
- f"wlr-randr --output {output_name} --transform {transform} 2>&1"
62
+ f"wlr-randr --output {shlex.quote(output_name)} "
63
+ f"--transform {shlex.quote(transform)} 2>&1"
39
64
  )
40
65
  r = probe.run(wlr_cmd)
41
66
  if r.ok:
@@ -48,7 +73,8 @@ def apply_display_transform(
48
73
 
49
74
  # 2. Persist in kanshi config — update or create transform line
50
75
  kanshi_cfg_path = "~/.config/kanshi/config"
51
- read_r = probe.run(f"cat {kanshi_cfg_path} 2>/dev/null")
76
+ quoted_cfg_path = shlex.quote(kanshi_cfg_path)
77
+ read_r = probe.run(f"cat {quoted_cfg_path} 2>/dev/null")
52
78
 
53
79
  if read_r.ok and read_r.out.strip():
54
80
  current = read_r.out
@@ -77,7 +103,7 @@ def apply_display_transform(
77
103
  # Write updated config
78
104
  escaped = new_cfg.replace("'", "'\\''")
79
105
  write_r = probe.run(
80
- f"mkdir -p ~/.config/kanshi && printf '%s' '{escaped}' > {kanshi_cfg_path}"
106
+ f"mkdir -p ~/.config/kanshi && printf '%s' '{escaped}' > {quoted_cfg_path}"
81
107
  )
82
108
  if write_r.ok:
83
109
  console.print(f"[green] ✓ kanshi config updated ({kanshi_cfg_path})[/green]")
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
 
4
4
  import json
5
5
  import re
6
+ import shlex
6
7
  from typing import Optional
7
8
 
8
9
  from ..models import (
@@ -12,6 +13,9 @@ from ..models import (
12
13
  from .remote import RemoteProbe
13
14
 
14
15
 
16
+ _SAFE_K8S_NS_RE = re.compile(r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")
17
+
18
+
15
19
  def probe_runtime(p: RemoteProbe) -> RuntimeInfo:
16
20
  """Detect installed runtimes: docker, k3s, podman, systemd."""
17
21
  def ver(cmd: str) -> Optional[str]:
@@ -126,8 +130,10 @@ def probe_k3s_services(p: RemoteProbe, namespaces: list[str]) -> list[ServiceInf
126
130
  for ns in namespaces:
127
131
  if ns in ("kube-system", "kube-public", "kube-node-lease", "cert-manager"):
128
132
  continue
133
+ if not _SAFE_K8S_NS_RE.match(ns):
134
+ continue
129
135
  r = p.run(
130
- f"k3s kubectl get pods -n {ns} --no-headers 2>/dev/null"
136
+ f"k3s kubectl get pods -n {shlex.quote(ns)} --no-headers 2>/dev/null"
131
137
  " | awk '{print $1\"|\"$2\"|\"$3}'"
132
138
  )
133
139
  if not r.ok:
@@ -228,8 +228,25 @@ class HealRunner:
228
228
  if decision.action is Action.ABORT:
229
229
  break
230
230
  if decision.action is Action.SKIP:
231
- # TODO: mark step as skipped in executor state and continue
232
- break
231
+ if not failed_step or executor.state is None:
232
+ self.console.print(
233
+ " [yellow]cannot skip without checkpointed state; aborting heal loop[/yellow]"
234
+ )
235
+ break
236
+
237
+ executor.state.mark_done(failed_step)
238
+ self.console.print(
239
+ f" [yellow]marking step as skipped in checkpoint:[/yellow] `{failed_step}`"
240
+ )
241
+
242
+ executor = self._make_executor(resume=True)
243
+ ok = executor.run()
244
+ self.console.print(f"\n{executor.summary()}")
245
+ if ok:
246
+ self._loop_detector.reset_all()
247
+ write_repair_log(self.spec_path, self.version, self.repairs)
248
+ return True
249
+ continue
233
250
 
234
251
  # Retry
235
252
  executor = self._make_executor(resume=True)
@@ -7,6 +7,7 @@ from .parser import (
7
7
  parse_markpact_file,
8
8
  parse_markpact_file_with_refs,
9
9
  parse_markpact_text,
10
+ resolve_script_ref,
10
11
  )
11
12
 
12
13
  __all__ = [
@@ -21,4 +22,5 @@ __all__ = [
21
22
  "parse_markpact_file",
22
23
  "parse_markpact_file_with_refs",
23
24
  "parse_markpact_text",
25
+ "resolve_script_ref",
24
26
  ]
@@ -278,3 +278,33 @@ def _handle_fence_line(
278
278
  return None, script_lines, True, "\n".join(script_lines)
279
279
  return None, script_lines, False, None
280
280
  return code_block_lang, script_lines, False, None
281
+
282
+
283
+ def resolve_script_ref(
284
+ md_content: str,
285
+ ref_id: str,
286
+ language: str = "bash",
287
+ ) -> tuple[str, str] | None:
288
+ """Resolve a script reference in markdown, trying markpact:ref then section heading.
289
+
290
+ Returns ``(script_text, lookup_method)`` or ``None`` if not found.
291
+ """
292
+ script = extract_script_by_ref(md_content, ref_id, language=language)
293
+ if script is not None:
294
+ return script, "markpact:ref"
295
+
296
+ # Try section heading fallback (ref may already have # stripped)
297
+ section_name = ref_id[1:] if ref_id.startswith("#") else ref_id
298
+ script = extract_script_from_markdown(md_content, section_name, language=language)
299
+ if script is not None:
300
+ return script, "section"
301
+
302
+ # Kebab-case fallback: "test-script-section" -> "Test Script Section"
303
+ if "-" in section_name:
304
+ script = extract_script_from_markdown(
305
+ md_content, section_name.replace("-", " "), language=language
306
+ )
307
+ if script is not None:
308
+ return script, "section"
309
+
310
+ return None
@@ -24,6 +24,7 @@ from __future__ import annotations
24
24
 
25
25
  import io
26
26
  import os
27
+ import re
27
28
  import subprocess
28
29
  import sys
29
30
  from pathlib import Path
@@ -45,6 +46,20 @@ mcp = FastMCP(
45
46
  # Helpers
46
47
  # ---------------------------------------------------------------------------
47
48
 
49
+ _SAFE_HOST_RE = re.compile(r"^[A-Za-z0-9_.@:-]+$")
50
+ _UNSAFE_SSH_TOKENS = (
51
+ "&&",
52
+ "||",
53
+ ";",
54
+ "|",
55
+ "`",
56
+ "$(",
57
+ ">",
58
+ "<",
59
+ "\n",
60
+ "\r",
61
+ )
62
+
48
63
  def _redeploy_bin() -> str:
49
64
  """Resolve the redeploy binary path (same venv as this process)."""
50
65
  import shutil
@@ -80,6 +95,24 @@ def _run(*args: str, cwd: str | None = None, timeout: int = 120) -> dict:
80
95
  return {"returncode": -1, "stdout": "", "stderr": str(exc), "success": False}
81
96
 
82
97
 
98
+ def _validate_exec_ssh_inputs(host: str, command: str) -> str | None:
99
+ """Return validation error message for unsafe exec_ssh inputs, or None."""
100
+ if not _SAFE_HOST_RE.match(host):
101
+ return f"Unsafe host format: {host!r}"
102
+
103
+ # Allow opting out explicitly for trusted environments.
104
+ if os.getenv("REDEPLOY_MCP_ALLOW_UNSAFE_SSH", "").lower() in {"1", "true", "yes"}:
105
+ return None
106
+
107
+ for token in _UNSAFE_SSH_TOKENS:
108
+ if token in command:
109
+ return (
110
+ "Unsafe command token detected "
111
+ f"({token!r}). Set REDEPLOY_MCP_ALLOW_UNSAFE_SSH=1 to bypass explicitly."
112
+ )
113
+ return None
114
+
115
+
83
116
  # ---------------------------------------------------------------------------
84
117
  # Tools
85
118
  # ---------------------------------------------------------------------------
@@ -218,6 +251,15 @@ def exec_ssh(
218
251
  Returns stdout, stderr and return code.
219
252
  CAUTION: this executes arbitrary commands — confirm with user before use.
220
253
  """
254
+ validation_error = _validate_exec_ssh_inputs(host, command)
255
+ if validation_error:
256
+ return {
257
+ "returncode": -1,
258
+ "stdout": "",
259
+ "stderr": validation_error,
260
+ "success": False,
261
+ }
262
+
221
263
  try:
222
264
  proc = subprocess.run(
223
265
  ["ssh", "-o", "ConnectTimeout=10", "-o", "BatchMode=yes", host, command],
@@ -1,4 +1,5 @@
1
1
  from redeploy.heal import HealLoopDetector
2
+ from redeploy.heal.decider import Action, Decision
2
3
 
3
4
 
4
5
  def test_loop_detector_triggers_on_identical_hint_streak():
@@ -124,3 +125,58 @@ def test_heal_runner_reverts_invalid_llm_patch_without_crash(monkeypatch, tmp_pa
124
125
 
125
126
  assert runner.run() is False
126
127
  assert spec_path.read_text() == original
128
+
129
+
130
+ def test_heal_runner_skip_marks_state_and_resumes(monkeypatch, tmp_path):
131
+ from redeploy.heal import HealRunner
132
+
133
+ spec_path = tmp_path / "spec.md"
134
+ spec_path.write_text("steps:\n - id: sync_env\n")
135
+
136
+ class _State:
137
+ def __init__(self):
138
+ self.marked: list[str] = []
139
+
140
+ def mark_done(self, step_id: str):
141
+ self.marked.append(step_id)
142
+
143
+ class _FakeExecutor:
144
+ def __init__(self, ok: bool, state):
145
+ self._ok = ok
146
+ self.state = state
147
+
148
+ def run(self):
149
+ return self._ok
150
+
151
+ def summary(self):
152
+ return "Step failed: [sync_env] exit=1: boom"
153
+
154
+ state = _State()
155
+ make_calls: list[bool] = []
156
+
157
+ def _fake_make_executor(self, resume=False):
158
+ make_calls.append(resume)
159
+ if not resume:
160
+ return _FakeExecutor(False, state)
161
+ return _FakeExecutor(True, state)
162
+
163
+ monkeypatch.setattr(HealRunner, "_make_executor", _fake_make_executor)
164
+ monkeypatch.setattr(
165
+ HealRunner,
166
+ "_heal_step",
167
+ lambda *_a, **_k: (Decision(Action.SKIP, "skip"), "sync_env", "hint"),
168
+ )
169
+ monkeypatch.setattr("redeploy.heal.runner.write_repair_log", lambda *_a, **_k: None)
170
+
171
+ runner = HealRunner(
172
+ migration=object(),
173
+ spec_path=spec_path,
174
+ host="pi@test",
175
+ max_retries=2,
176
+ dry_run=True,
177
+ version="0.0.0",
178
+ )
179
+
180
+ assert runner.run() is True
181
+ assert state.marked == ["sync_env"]
182
+ assert make_calls == [False, True]
@@ -0,0 +1,93 @@
1
+ """Security hardening tests for command construction and MCP guards."""
2
+ from __future__ import annotations
3
+
4
+ import io
5
+ from unittest.mock import patch
6
+
7
+ import pytest
8
+ from rich.console import Console
9
+
10
+ from redeploy.config_apply.handlers.display import apply_display_transform
11
+ from redeploy.detect.probes import probe_k3s_services
12
+
13
+
14
+ class _Result:
15
+ def __init__(self, ok: bool = True, out: str = ""):
16
+ self.ok = ok
17
+ self.out = out
18
+
19
+
20
+ class _Probe:
21
+ def __init__(self):
22
+ self.commands: list[str] = []
23
+
24
+ def run(self, cmd: str):
25
+ self.commands.append(cmd)
26
+ if cmd.startswith("cat "):
27
+ return _Result(ok=False, out="")
28
+ return _Result(ok=True, out="")
29
+
30
+
31
+ def test_apply_display_transform_rejects_unsafe_output_name():
32
+ probe = _Probe()
33
+ console = Console(file=io.StringIO(), force_terminal=False)
34
+
35
+ with pytest.raises(ValueError, match="Unsafe display output name"):
36
+ apply_display_transform(
37
+ console,
38
+ probe,
39
+ "DSI-2; rm -rf /",
40
+ "270",
41
+ )
42
+
43
+
44
+ def test_apply_display_transform_rejects_unsupported_transform():
45
+ probe = _Probe()
46
+ console = Console(file=io.StringIO(), force_terminal=False)
47
+
48
+ with pytest.raises(ValueError, match="Unsupported transform"):
49
+ apply_display_transform(console, probe, "DSI-2", "drop-table")
50
+
51
+
52
+ def test_probe_k3s_services_skips_unsafe_namespace():
53
+ commands: list[str] = []
54
+
55
+ class Probe:
56
+ def run(self, cmd: str):
57
+ commands.append(cmd)
58
+ return _Result(ok=True, out="pod-a|1/1|Running")
59
+
60
+ services = probe_k3s_services(Probe(), ["default; rm -rf /", "c2004"])
61
+
62
+ assert len(services) == 1
63
+ assert services[0].name == "pod-a"
64
+ assert all("default; rm -rf /" not in c for c in commands)
65
+
66
+
67
+ def test_exec_ssh_rejects_unsafe_command_tokens(monkeypatch):
68
+ pytest.importorskip("mcp.server.fastmcp")
69
+ from redeploy import mcp_server
70
+
71
+ with patch("redeploy.mcp_server.subprocess.run") as run:
72
+ result = mcp_server.exec_ssh("pi@192.168.1.10", "echo ok && whoami")
73
+
74
+ assert result["success"] is False
75
+ assert "Unsafe command token" in result["stderr"]
76
+ run.assert_not_called()
77
+
78
+
79
+ def test_exec_ssh_allows_override_for_trusted_environments(monkeypatch):
80
+ pytest.importorskip("mcp.server.fastmcp")
81
+ from redeploy import mcp_server
82
+
83
+ monkeypatch.setenv("REDEPLOY_MCP_ALLOW_UNSAFE_SSH", "1")
84
+
85
+ class Proc:
86
+ returncode = 0
87
+ stdout = "ok"
88
+ stderr = ""
89
+
90
+ with patch("redeploy.mcp_server.subprocess.run", return_value=Proc()):
91
+ result = mcp_server.exec_ssh("pi@192.168.1.10", "echo ok && whoami")
92
+
93
+ assert result["success"] is True