redeploy 0.2.16__tar.gz → 0.2.17__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 (115) hide show
  1. {redeploy-0.2.16 → redeploy-0.2.17}/PKG-INFO +34 -7
  2. {redeploy-0.2.16 → redeploy-0.2.17}/README.md +33 -6
  3. {redeploy-0.2.16 → redeploy-0.2.17}/pyproject.toml +1 -1
  4. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/__init__.py +1 -1
  5. redeploy-0.2.17/redeploy/apply/__init__.py +16 -0
  6. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/apply/executor.py +161 -1
  7. redeploy-0.2.17/redeploy/apply/state.py +161 -0
  8. redeploy-0.2.17/redeploy/audit.py +556 -0
  9. redeploy-0.2.17/redeploy/checkpoint.py +234 -0
  10. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/cli.py +125 -5
  11. redeploy-0.2.17/redeploy/markpact/parser.py +148 -0
  12. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/models.py +3 -1
  13. redeploy-0.2.17/redeploy/tests/test_audit.py +244 -0
  14. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_executor.py +51 -0
  15. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy.egg-info/PKG-INFO +34 -7
  16. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy.egg-info/SOURCES.txt +4 -0
  17. redeploy-0.2.16/redeploy/apply/__init__.py +0 -4
  18. redeploy-0.2.16/redeploy/markpact/parser.py +0 -72
  19. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/data_sync.py +0 -0
  20. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/detect/__init__.py +0 -0
  21. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/detect/detector.py +0 -0
  22. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/detect/probes.py +0 -0
  23. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/detect/remote.py +0 -0
  24. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/detect/templates.py +0 -0
  25. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/detect/workflow.py +0 -0
  26. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/discovery.py +0 -0
  27. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/dsl/__init__.py +0 -0
  28. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/dsl/loader.py +0 -0
  29. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/dsl/parser.py +0 -0
  30. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/dsl_python/__init__.py +0 -0
  31. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/dsl_python/context.py +0 -0
  32. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/dsl_python/decorators.py +0 -0
  33. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/dsl_python/docker_steps.py +0 -0
  34. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/dsl_python/exceptions.py +0 -0
  35. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/dsl_python/runner.py +0 -0
  36. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/dsl_python/steps.py +0 -0
  37. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/fleet.py +0 -0
  38. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/iac/__init__.py +0 -0
  39. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/iac/base.py +0 -0
  40. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/iac/docker_compose.py +0 -0
  41. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/iac/parsers/__init__.py +0 -0
  42. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/iac/parsers/compose.py +0 -0
  43. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/iac/registry.py +0 -0
  44. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/markpact/__init__.py +0 -0
  45. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/markpact/compiler.py +0 -0
  46. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/markpact/models.py +0 -0
  47. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/observe.py +0 -0
  48. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/parse.py +0 -0
  49. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/patterns.py +0 -0
  50. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/plan/__init__.py +0 -0
  51. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/plan/planner.py +0 -0
  52. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/plugins/__init__.py +0 -0
  53. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/plugins/builtin/__init__.py +0 -0
  54. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/plugins/builtin/browser_reload.py +0 -0
  55. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/plugins/builtin/notify.py +0 -0
  56. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/plugins/builtin/systemd_reload.py +0 -0
  57. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/spec_loader.py +0 -0
  58. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/ssh.py +0 -0
  59. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/steps.py +0 -0
  60. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/__init__.py +0 -0
  61. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_cli.py +0 -0
  62. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_data_sync.py +0 -0
  63. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_discovery.py +0 -0
  64. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_dsl.py +0 -0
  65. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_examples.py +0 -0
  66. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_fleet.py +0 -0
  67. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_git_integration.py +0 -0
  68. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_iac.py +0 -0
  69. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_manifest.py +0 -0
  70. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_markpact_compiler.py +0 -0
  71. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_markpact_parser.py +0 -0
  72. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_models.py +0 -0
  73. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_observe.py +0 -0
  74. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_parse.py +0 -0
  75. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_patterns.py +0 -0
  76. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_planner_edge.py +0 -0
  77. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_plugins.py +0 -0
  78. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_probes.py +0 -0
  79. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_public_api.py +0 -0
  80. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_spec_loader.py +0 -0
  81. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_ssh.py +0 -0
  82. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_templates.py +0 -0
  83. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_verify.py +0 -0
  84. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_version.py +0 -0
  85. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_version_changelog.py +0 -0
  86. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_version_cli.py +0 -0
  87. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_version_manifest.py +0 -0
  88. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_version_sources.py +0 -0
  89. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_workflow.py +0 -0
  90. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/verify.py +0 -0
  91. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/version/__init__.py +0 -0
  92. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/version/bump.py +0 -0
  93. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/version/changelog.py +0 -0
  94. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/version/commits.py +0 -0
  95. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/version/diff.py +0 -0
  96. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/version/git_integration.py +0 -0
  97. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/version/git_transaction.py +0 -0
  98. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/version/manifest.py +0 -0
  99. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/version/sources/__init__.py +0 -0
  100. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/version/sources/base.py +0 -0
  101. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/version/sources/json_.py +0 -0
  102. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/version/sources/plain.py +0 -0
  103. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/version/sources/regex.py +0 -0
  104. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/version/sources/toml_.py +0 -0
  105. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/version/sources/yaml_.py +0 -0
  106. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/version/transaction.py +0 -0
  107. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/version.py +0 -0
  108. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy.egg-info/dependency_links.txt +0 -0
  109. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy.egg-info/entry_points.txt +0 -0
  110. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy.egg-info/requires.txt +0 -0
  111. {redeploy-0.2.16 → redeploy-0.2.17}/redeploy.egg-info/top_level.txt +0 -0
  112. {redeploy-0.2.16 → redeploy-0.2.17}/setup.cfg +0 -0
  113. {redeploy-0.2.16 → redeploy-0.2.17}/tests/test_iac.py +0 -0
  114. {redeploy-0.2.16 → redeploy-0.2.17}/tests/test_parse.py +0 -0
  115. {redeploy-0.2.16 → redeploy-0.2.17}/tests/test_redeploy.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redeploy
3
- Version: 0.2.16
3
+ Version: 0.2.17
4
4
  Summary: Infrastructure migration toolkit: detect → plan → apply
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -27,17 +27,17 @@ Requires-Dist: pfix>=0.1.60; extra == "dev"
27
27
 
28
28
  ## AI Cost Tracking
29
29
 
30
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.16-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
31
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$5.25-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-11.3h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
30
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.17-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
31
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$5.40-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-11.3h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
32
32
 
33
- - 🤖 **LLM usage:** $5.2500 (35 commits)
34
- - 👤 **Human dev:** ~$1128 (11.3h @ $100/h, 30min dedup)
33
+ - 🤖 **LLM usage:** $5.4000 (36 commits)
34
+ - 👤 **Human dev:** ~$1130 (11.3h @ $100/h, 30min dedup)
35
35
 
36
- Generated on 2026-04-20 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
36
+ Generated on 2026-04-21 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
37
37
 
38
38
  ---
39
39
 
40
- ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.16-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
40
+ ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.17-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
41
41
 
42
42
  Infrastructure migration and device deploy toolkit — VPS, Raspberry Pi kiosk, Podman Quadlet, k3s.
43
43
 
@@ -401,6 +401,33 @@ def notify_slack(ctx: PluginContext) -> None:
401
401
  | `params` | `dict` | Shortcut for `step.plugin_params` |
402
402
  | `dry_run` | `bool` | Skip side-effects if True |
403
403
 
404
+ ## Inline Scripts
405
+
406
+ Execute multiline bash scripts directly from YAML without external files:
407
+
408
+ ```yaml
409
+ extra_steps:
410
+ - id: configure_kiosk
411
+ action: inline_script
412
+ description: "Deploy kiosk launch script"
413
+ command: |
414
+ #!/bin/bash
415
+ mkdir -p ~/c2004/config
416
+ cat > ~/c2004/config/kiosk-launch.sh << 'EOF'
417
+ #!/bin/bash
418
+ if command -v chromium-browser >/dev/null 2>&1; then
419
+ chromium-browser --kiosk http://localhost:8100
420
+ elif command -v firefox >/dev/null 2>&1; then
421
+ firefox --kiosk http://localhost:8100
422
+ fi
423
+ EOF
424
+ chmod +x ~/c2004/config/kiosk-launch.sh
425
+ risk: medium
426
+ timeout: 60
427
+ ```
428
+
429
+ The script is base64-encoded and executed via SSH with automatic temp file cleanup. Use `command` field for multiline script content (YAML `|` preserves newlines).
430
+
404
431
 
405
432
 
406
433
  Place in project root — `redeploy run` (no args) uses it automatically.
@@ -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.16-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-$5.25-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-11.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.17-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-$5.40-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-11.3h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $5.2500 (35 commits)
10
- - 👤 **Human dev:** ~$1128 (11.3h @ $100/h, 30min dedup)
9
+ - 🤖 **LLM usage:** $5.4000 (36 commits)
10
+ - 👤 **Human dev:** ~$1130 (11.3h @ $100/h, 30min dedup)
11
11
 
12
- Generated on 2026-04-20 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
12
+ Generated on 2026-04-21 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
13
13
 
14
14
  ---
15
15
 
16
- ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.16-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.17-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
 
@@ -377,6 +377,33 @@ def notify_slack(ctx: PluginContext) -> None:
377
377
  | `params` | `dict` | Shortcut for `step.plugin_params` |
378
378
  | `dry_run` | `bool` | Skip side-effects if True |
379
379
 
380
+ ## Inline Scripts
381
+
382
+ Execute multiline bash scripts directly from YAML without external files:
383
+
384
+ ```yaml
385
+ extra_steps:
386
+ - id: configure_kiosk
387
+ action: inline_script
388
+ description: "Deploy kiosk launch script"
389
+ command: |
390
+ #!/bin/bash
391
+ mkdir -p ~/c2004/config
392
+ cat > ~/c2004/config/kiosk-launch.sh << 'EOF'
393
+ #!/bin/bash
394
+ if command -v chromium-browser >/dev/null 2>&1; then
395
+ chromium-browser --kiosk http://localhost:8100
396
+ elif command -v firefox >/dev/null 2>&1; then
397
+ firefox --kiosk http://localhost:8100
398
+ fi
399
+ EOF
400
+ chmod +x ~/c2004/config/kiosk-launch.sh
401
+ risk: medium
402
+ timeout: 60
403
+ ```
404
+
405
+ The script is base64-encoded and executed via SSH with automatic temp file cleanup. Use `command` field for multiline script content (YAML `|` preserves newlines).
406
+
380
407
 
381
408
 
382
409
  Place in project root — `redeploy run` (no args) uses it automatically.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "redeploy"
7
- version = "0.2.16"
7
+ version = "0.2.17"
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.16"
10
+ __version__ = "0.2.17"
11
11
 
12
12
  # ── Core models ───────────────────────────────────────────────────────────────
13
13
  from .models import ( # noqa: F401
@@ -0,0 +1,16 @@
1
+ """apply — Execute a MigrationPlan step by step."""
2
+ from .executor import Executor
3
+ from .state import (
4
+ DEFAULT_STATE_DIR,
5
+ ResumeState,
6
+ default_state_path,
7
+ state_key,
8
+ )
9
+
10
+ __all__ = [
11
+ "Executor",
12
+ "ResumeState",
13
+ "default_state_path",
14
+ "state_key",
15
+ "DEFAULT_STATE_DIR",
16
+ ]
@@ -15,6 +15,7 @@ from loguru import logger
15
15
  from ..detect.remote import RemoteProbe
16
16
  from ..models import MigrationPlan, MigrationStep, StepAction, StepStatus
17
17
  from ..plugins import PluginContext, registry as _plugin_registry
18
+ from .state import ResumeState, default_state_path
18
19
 
19
20
 
20
21
  class ProgressEmitter:
@@ -124,7 +125,11 @@ class Executor:
124
125
  progress_yaml: bool = False,
125
126
  progress_stream: IO[str] = None,
126
127
  audit_log: bool = True,
127
- audit_path: Optional["Path"] = None):
128
+ audit_path: Optional["Path"] = None,
129
+ resume: bool = False,
130
+ from_step: Optional[str] = None,
131
+ state_path: Optional["Path"] = None,
132
+ spec_path: Optional[str] = None):
128
133
  self.plan = plan
129
134
  self.dry_run = dry_run
130
135
  self.probe = RemoteProbe(plan.host)
@@ -138,10 +143,39 @@ class Executor:
138
143
  self._audit_path = audit_path
139
144
  self._t0: float = 0.0
140
145
 
146
+ # ── resume / checkpoint ──────────────────────────────────────────────
147
+ self._resume = resume
148
+ self._from_step = from_step
149
+ spec_id = spec_path or plan.target_file or plan.infra_file or plan.app
150
+ if state_path is None and not dry_run:
151
+ state_path = default_state_path(spec_id, plan.host)
152
+ self._state_path: Optional[Path] = (
153
+ Path(state_path) if state_path is not None else None
154
+ )
155
+ self._state: Optional[ResumeState] = None
156
+ if self._state_path is not None and not dry_run:
157
+ self._state = ResumeState.load_or_new(
158
+ self._state_path,
159
+ spec_path=str(spec_id),
160
+ host=plan.host,
161
+ total_steps=len(plan.steps),
162
+ )
163
+ # Keep total_steps in sync if the spec changed between runs.
164
+ self._state.total_steps = len(plan.steps)
165
+
141
166
  @property
142
167
  def completed_steps(self) -> list[MigrationStep]:
143
168
  return list(self._completed)
144
169
 
170
+ @property
171
+ def state(self) -> Optional[ResumeState]:
172
+ """Current ResumeState (None when dry_run or disabled)."""
173
+ return self._state
174
+
175
+ @property
176
+ def state_path(self) -> Optional[Path]:
177
+ return self._state_path
178
+
145
179
  def run(self) -> bool:
146
180
  """Execute all steps. Returns True if all passed."""
147
181
  self._t0 = time.monotonic()
@@ -151,19 +185,34 @@ class Executor:
151
185
  if self._emitter:
152
186
  self._emitter.start(self.plan)
153
187
 
188
+ skip_ids = self._compute_skip_set()
189
+ if skip_ids:
190
+ logger.info(f"resume: skipping {len(skip_ids)} already-completed step(s): "
191
+ f"{', '.join(sorted(skip_ids))}")
192
+
154
193
  ok = True
155
194
  for i, step in enumerate(self.plan.steps, 1):
195
+ if step.id in skip_ids:
196
+ step.status = StepStatus.SKIPPED
197
+ step.result = "resumed: previously completed"
198
+ if self._emitter:
199
+ self._emitter.step_done(i, step)
200
+ continue
156
201
  try:
157
202
  if self._emitter:
158
203
  self._emitter.step_start(i, step)
159
204
  self._execute_step(step)
160
205
  self._completed.append(step)
206
+ if self._state is not None:
207
+ self._state.mark_done(step.id)
161
208
  if self._emitter:
162
209
  self._emitter.step_done(i, step)
163
210
  except StepError as e:
164
211
  logger.error(f"Step failed: {e}")
165
212
  step.status = StepStatus.FAILED
166
213
  step.error = str(e)
214
+ if self._state is not None:
215
+ self._state.mark_failed(step.id, str(e))
167
216
  if self._emitter:
168
217
  self._emitter.step_fail(i, step, str(e))
169
218
  self._emitter.failed(len(self._completed), len(self.plan.steps), str(e))
@@ -177,9 +226,40 @@ class Executor:
177
226
  logger.info(f"{'[DRY RUN] ' if self.dry_run else ''}All {len(self.plan.steps)} steps completed")
178
227
  if self._emitter:
179
228
  self._emitter.done(len(self.plan.steps))
229
+ # Plan finished cleanly — drop the checkpoint.
230
+ if self._state is not None:
231
+ self._state.remove()
232
+ else:
233
+ if self._state is not None and self._state_path is not None:
234
+ logger.info(f"resume: checkpoint saved → {self._state_path} "
235
+ f"({self._state.completed_count}/{self._state.total_steps} done)")
180
236
  self._write_audit(ok=ok, elapsed_s=elapsed)
181
237
  return ok
182
238
 
239
+ def _compute_skip_set(self) -> set[str]:
240
+ """Determine which step ids should be skipped this run.
241
+
242
+ Sources (in priority order):
243
+ 1. ``--from-step <id>``: skip every step BEFORE that id.
244
+ 2. ``--resume`` + persisted state: skip every previously completed id.
245
+ """
246
+ skip: set[str] = set()
247
+ ids = [s.id for s in self.plan.steps]
248
+
249
+ if self._from_step:
250
+ if self._from_step not in ids:
251
+ logger.warning(
252
+ f"--from-step '{self._from_step}' not found in plan; running full plan"
253
+ )
254
+ else:
255
+ idx = ids.index(self._from_step)
256
+ skip.update(ids[:idx])
257
+
258
+ if self._resume and self._state is not None:
259
+ skip.update(self._state.completed_step_ids)
260
+
261
+ return skip
262
+
183
263
  def _write_audit(self, *, ok: bool, elapsed_s: float) -> None:
184
264
  if not self._audit_log:
185
265
  return
@@ -219,6 +299,7 @@ class Executor:
219
299
  StepAction.VERSION_CHECK: self._run_version_check,
220
300
  StepAction.WAIT: self._run_wait,
221
301
  StepAction.PLUGIN: self._run_plugin,
302
+ StepAction.INLINE_SCRIPT: self._run_inline_script,
222
303
  }
223
304
 
224
305
  handler = dispatch.get(step.action)
@@ -456,16 +537,95 @@ class Executor:
456
537
  step.status = StepStatus.DONE
457
538
  step.result = f"waited {total}s"
458
539
 
540
+ def _run_inline_script(self, step: MigrationStep) -> None:
541
+ """Execute multiline bash script via SSH using base64 encoding."""
542
+ import base64
543
+ from pathlib import Path
544
+
545
+ script = step.command
546
+
547
+ # If command_ref is set, extract script from markdown file
548
+ if step.command_ref:
549
+ script = self._resolve_command_ref(step.command_ref, step)
550
+
551
+ if not script:
552
+ raise StepError(step, "inline_script requires command field or command_ref with script content")
553
+
554
+ # Base64 encode the script to safely pass it through SSH
555
+ encoded = base64.b64encode(script.encode()).decode()
556
+ timeout = step.timeout or 300
557
+
558
+ # Create temp file, decode script, run it, then clean up
559
+ cmd = (
560
+ f"tmpfile=$(mktemp) && "
561
+ f"echo '{encoded}' | base64 -d > \"$tmpfile\" && "
562
+ f"chmod +x \"$tmpfile\" && "
563
+ f"\"$tmpfile\"; "
564
+ f"rc=$?; "
565
+ f"rm -f \"$tmpfile\"; "
566
+ f"exit $rc"
567
+ )
568
+
569
+ r = self.probe.run(cmd, timeout=timeout)
570
+ step.result = r.out[:500] if r.out else "script executed"
571
+ if not r.ok:
572
+ raise StepError(step, f"script failed with exit={r.exit_code}: {r.stderr[:200]}")
573
+ step.status = StepStatus.DONE
574
+
575
+ def _resolve_command_ref(self, command_ref: str, step: MigrationStep) -> str:
576
+ """Resolve command_ref to script content from markdown file.
577
+
578
+ command_ref formats:
579
+ - "./file.md#section-id" - script from section in specific file
580
+ - "#section-id" - script from section in current spec file
581
+ """
582
+ from pathlib import Path
583
+ from ..markpact.parser import extract_script_from_markdown
584
+
585
+ # Parse command_ref
586
+ if "#" in command_ref:
587
+ file_part, section_id = command_ref.split("#", 1)
588
+ file_path = file_part if file_part else getattr(self.plan, 'spec_path', None)
589
+ else:
590
+ section_id = command_ref
591
+ file_path = getattr(self.plan, 'spec_path', None)
592
+
593
+ if not file_path:
594
+ raise StepError(step, f"Cannot resolve command_ref '{command_ref}': no file path available")
595
+
596
+ file_path = Path(file_path)
597
+ if not file_path.exists():
598
+ raise StepError(step, f"Command ref file not found: {file_path}")
599
+
600
+ # Extract script from markdown
601
+ markdown_content = file_path.read_text(encoding="utf-8")
602
+ script = extract_script_from_markdown(markdown_content, section_id, language="bash")
603
+
604
+ if script is None:
605
+ raise StepError(step, f"Could not find bash script in section '{section_id}' of {file_path}")
606
+
607
+ return script
608
+
459
609
  # ── rollback ──────────────────────────────────────────────────────────────
460
610
 
461
611
  def _rollback(self) -> None:
462
612
  logger.warning("Rolling back completed steps...")
613
+ rolled_back: list[str] = []
463
614
  for step in reversed(self._completed):
464
615
  if step.rollback_command:
465
616
  logger.info(f" ↩ rollback [{step.id}]: {step.rollback_command}")
466
617
  r = self.probe.run(step.rollback_command, timeout=120)
467
618
  if not r.ok:
468
619
  logger.warning(f" rollback failed: {r.stderr[:100]}")
620
+ else:
621
+ rolled_back.append(step.id)
622
+ # Forget rolled-back steps so a subsequent --resume re-executes them.
623
+ if rolled_back and self._state is not None:
624
+ self._state.completed_step_ids = [
625
+ sid for sid in self._state.completed_step_ids
626
+ if sid not in set(rolled_back)
627
+ ]
628
+ self._state.save()
469
629
 
470
630
  # ── summary ───────────────────────────────────────────────────────────────
471
631
 
@@ -0,0 +1,161 @@
1
+ """Persistent execution state — enables resuming an interrupted MigrationPlan.
2
+
3
+ State files live under ``.redeploy/state/<key>.yaml`` (relative to CWD by
4
+ default). The key is derived from the spec path + host so multiple plans can
5
+ checkpoint independently without colliding.
6
+
7
+ After every successful step the executor calls :meth:`ResumeState.mark_done`
8
+ which atomically rewrites the file. On the next run with ``--resume`` the
9
+ executor loads the file, marks the listed steps as ``SKIPPED`` and continues
10
+ from the first un-completed step.
11
+
12
+ When the plan completes successfully the state file is removed (no stale
13
+ checkpoints to confuse future runs).
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import os
19
+ import tempfile
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+ from typing import Iterable, Optional
23
+
24
+ import yaml
25
+ from pydantic import BaseModel, Field
26
+
27
+
28
+ DEFAULT_STATE_DIR = Path(".redeploy") / "state"
29
+
30
+
31
+ def _now_iso() -> str:
32
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
33
+
34
+
35
+ def _slug(value: str) -> str:
36
+ safe = "".join(c if c.isalnum() or c in "-._" else "_" for c in value)
37
+ return safe.strip("_") or "spec"
38
+
39
+
40
+ def state_key(spec_path: str | os.PathLike[str], host: str) -> str:
41
+ """Stable, filesystem-safe identifier for one (spec, host) checkpoint."""
42
+ spec_str = str(spec_path)
43
+ digest = hashlib.sha1(f"{spec_str}|{host}".encode()).hexdigest()[:8]
44
+ return f"{_slug(Path(spec_str).stem)}-{_slug(host)}-{digest}"
45
+
46
+
47
+ def default_state_path(spec_path: str | os.PathLike[str], host: str,
48
+ base_dir: Optional[Path] = None) -> Path:
49
+ base = Path(base_dir) if base_dir else DEFAULT_STATE_DIR
50
+ return base / f"{state_key(spec_path, host)}.yaml"
51
+
52
+
53
+ class ResumeState(BaseModel):
54
+ """Checkpoint for a single MigrationPlan execution."""
55
+
56
+ spec_path: str = ""
57
+ host: str = ""
58
+ total_steps: int = 0
59
+ completed_step_ids: list[str] = Field(default_factory=list)
60
+ failed_step_id: Optional[str] = None
61
+ failed_error: Optional[str] = None
62
+ started_at: str = Field(default_factory=_now_iso)
63
+ updated_at: str = Field(default_factory=_now_iso)
64
+
65
+ # Not persisted to disk — runtime helper.
66
+ path: Optional[Path] = None
67
+
68
+ model_config = {"arbitrary_types_allowed": True}
69
+
70
+ # ── construction / IO ────────────────────────────────────────────────────
71
+
72
+ @classmethod
73
+ def load(cls, path: str | os.PathLike[str]) -> "ResumeState":
74
+ p = Path(path)
75
+ with p.open() as f:
76
+ data = yaml.safe_load(f) or {}
77
+ data.pop("path", None)
78
+ state = cls(**data)
79
+ state.path = p
80
+ return state
81
+
82
+ @classmethod
83
+ def load_or_new(cls, path: str | os.PathLike[str], *,
84
+ spec_path: str = "", host: str = "",
85
+ total_steps: int = 0) -> "ResumeState":
86
+ p = Path(path)
87
+ if p.exists():
88
+ return cls.load(p)
89
+ state = cls(spec_path=spec_path, host=host, total_steps=total_steps)
90
+ state.path = p
91
+ return state
92
+
93
+ def save(self, path: Optional[str | os.PathLike[str]] = None) -> Path:
94
+ target = Path(path) if path else self.path
95
+ if target is None:
96
+ raise ValueError("ResumeState.save: no path configured")
97
+ target.parent.mkdir(parents=True, exist_ok=True)
98
+ self.updated_at = _now_iso()
99
+ payload = self.model_dump(exclude={"path"})
100
+ # Atomic write: tmp file + rename keeps readers consistent.
101
+ fd, tmp_name = tempfile.mkstemp(prefix=".state-", suffix=".yaml",
102
+ dir=str(target.parent))
103
+ try:
104
+ with os.fdopen(fd, "w") as f:
105
+ yaml.safe_dump(payload, f, sort_keys=False, allow_unicode=True)
106
+ os.replace(tmp_name, target)
107
+ except Exception:
108
+ try:
109
+ os.unlink(tmp_name)
110
+ except OSError:
111
+ pass
112
+ raise
113
+ self.path = target
114
+ return target
115
+
116
+ def remove(self) -> None:
117
+ if self.path and self.path.exists():
118
+ try:
119
+ self.path.unlink()
120
+ except OSError:
121
+ pass
122
+
123
+ # ── mutators ─────────────────────────────────────────────────────────────
124
+
125
+ def mark_done(self, step_id: str) -> None:
126
+ if step_id not in self.completed_step_ids:
127
+ self.completed_step_ids.append(step_id)
128
+ self.failed_step_id = None
129
+ self.failed_error = None
130
+ self.save()
131
+
132
+ def mark_failed(self, step_id: str, error: str) -> None:
133
+ self.failed_step_id = step_id
134
+ self.failed_error = error
135
+ self.save()
136
+
137
+ def reset(self) -> None:
138
+ self.completed_step_ids = []
139
+ self.failed_step_id = None
140
+ self.failed_error = None
141
+ self.started_at = _now_iso()
142
+ self.save()
143
+
144
+ # ── queries ──────────────────────────────────────────────────────────────
145
+
146
+ def is_done(self, step_id: str) -> bool:
147
+ return step_id in self.completed_step_ids
148
+
149
+ @property
150
+ def completed_count(self) -> int:
151
+ return len(self.completed_step_ids)
152
+
153
+ @property
154
+ def remaining(self) -> int:
155
+ return max(0, self.total_steps - self.completed_count)
156
+
157
+
158
+ def filter_resumable(step_ids: Iterable[str], state: ResumeState) -> list[str]:
159
+ """Return ids that are NOT yet completed (preserves order)."""
160
+ done = set(state.completed_step_ids)
161
+ return [sid for sid in step_ids if sid not in done]