redeploy 0.2.16__tar.gz → 0.2.18__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 (116) hide show
  1. {redeploy-0.2.16 → redeploy-0.2.18}/PKG-INFO +136 -7
  2. {redeploy-0.2.16 → redeploy-0.2.18}/README.md +135 -6
  3. {redeploy-0.2.16 → redeploy-0.2.18}/pyproject.toml +1 -1
  4. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/__init__.py +1 -1
  5. redeploy-0.2.18/redeploy/apply/__init__.py +16 -0
  6. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/apply/executor.py +174 -1
  7. redeploy-0.2.18/redeploy/apply/state.py +161 -0
  8. redeploy-0.2.18/redeploy/audit.py +556 -0
  9. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/cli.py +448 -5
  10. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/markpact/__init__.py +11 -1
  11. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/markpact/compiler.py +7 -2
  12. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/markpact/models.py +3 -1
  13. redeploy-0.2.18/redeploy/markpact/parser.py +255 -0
  14. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/models.py +4 -1
  15. redeploy-0.2.18/redeploy/tests/test_audit.py +244 -0
  16. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/tests/test_executor.py +107 -0
  17. redeploy-0.2.18/redeploy/tests/test_markpact_parser.py +93 -0
  18. redeploy-0.2.18/redeploy/tests/test_resume.py +271 -0
  19. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy.egg-info/PKG-INFO +136 -7
  20. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy.egg-info/SOURCES.txt +4 -0
  21. redeploy-0.2.16/redeploy/apply/__init__.py +0 -4
  22. redeploy-0.2.16/redeploy/markpact/parser.py +0 -72
  23. redeploy-0.2.16/redeploy/tests/test_markpact_parser.py +0 -41
  24. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/data_sync.py +0 -0
  25. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/detect/__init__.py +0 -0
  26. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/detect/detector.py +0 -0
  27. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/detect/probes.py +0 -0
  28. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/detect/remote.py +0 -0
  29. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/detect/templates.py +0 -0
  30. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/detect/workflow.py +0 -0
  31. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/discovery.py +0 -0
  32. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/dsl/__init__.py +0 -0
  33. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/dsl/loader.py +0 -0
  34. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/dsl/parser.py +0 -0
  35. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/dsl_python/__init__.py +0 -0
  36. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/dsl_python/context.py +0 -0
  37. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/dsl_python/decorators.py +0 -0
  38. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/dsl_python/docker_steps.py +0 -0
  39. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/dsl_python/exceptions.py +0 -0
  40. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/dsl_python/runner.py +0 -0
  41. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/dsl_python/steps.py +0 -0
  42. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/fleet.py +0 -0
  43. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/iac/__init__.py +0 -0
  44. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/iac/base.py +0 -0
  45. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/iac/docker_compose.py +0 -0
  46. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/iac/parsers/__init__.py +0 -0
  47. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/iac/parsers/compose.py +0 -0
  48. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/iac/registry.py +0 -0
  49. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/observe.py +0 -0
  50. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/parse.py +0 -0
  51. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/patterns.py +0 -0
  52. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/plan/__init__.py +0 -0
  53. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/plan/planner.py +0 -0
  54. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/plugins/__init__.py +0 -0
  55. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/plugins/builtin/__init__.py +0 -0
  56. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/plugins/builtin/browser_reload.py +0 -0
  57. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/plugins/builtin/notify.py +0 -0
  58. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/plugins/builtin/systemd_reload.py +0 -0
  59. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/spec_loader.py +0 -0
  60. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/ssh.py +0 -0
  61. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/steps.py +0 -0
  62. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/tests/__init__.py +0 -0
  63. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/tests/test_cli.py +0 -0
  64. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/tests/test_data_sync.py +0 -0
  65. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/tests/test_discovery.py +0 -0
  66. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/tests/test_dsl.py +0 -0
  67. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/tests/test_examples.py +0 -0
  68. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/tests/test_fleet.py +0 -0
  69. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/tests/test_git_integration.py +0 -0
  70. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/tests/test_iac.py +0 -0
  71. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/tests/test_manifest.py +0 -0
  72. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/tests/test_markpact_compiler.py +0 -0
  73. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/tests/test_models.py +0 -0
  74. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/tests/test_observe.py +0 -0
  75. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/tests/test_parse.py +0 -0
  76. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/tests/test_patterns.py +0 -0
  77. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/tests/test_planner_edge.py +0 -0
  78. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/tests/test_plugins.py +0 -0
  79. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/tests/test_probes.py +0 -0
  80. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/tests/test_public_api.py +0 -0
  81. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/tests/test_spec_loader.py +0 -0
  82. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/tests/test_ssh.py +0 -0
  83. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/tests/test_templates.py +0 -0
  84. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/tests/test_verify.py +0 -0
  85. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/tests/test_version.py +0 -0
  86. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/tests/test_version_changelog.py +0 -0
  87. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/tests/test_version_cli.py +0 -0
  88. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/tests/test_version_manifest.py +0 -0
  89. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/tests/test_version_sources.py +0 -0
  90. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/tests/test_workflow.py +0 -0
  91. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/verify.py +0 -0
  92. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/version/__init__.py +0 -0
  93. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/version/bump.py +0 -0
  94. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/version/changelog.py +0 -0
  95. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/version/commits.py +0 -0
  96. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/version/diff.py +0 -0
  97. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/version/git_integration.py +0 -0
  98. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/version/git_transaction.py +0 -0
  99. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/version/manifest.py +0 -0
  100. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/version/sources/__init__.py +0 -0
  101. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/version/sources/base.py +0 -0
  102. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/version/sources/json_.py +0 -0
  103. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/version/sources/plain.py +0 -0
  104. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/version/sources/regex.py +0 -0
  105. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/version/sources/toml_.py +0 -0
  106. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/version/sources/yaml_.py +0 -0
  107. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/version/transaction.py +0 -0
  108. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy/version.py +0 -0
  109. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy.egg-info/dependency_links.txt +0 -0
  110. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy.egg-info/entry_points.txt +0 -0
  111. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy.egg-info/requires.txt +0 -0
  112. {redeploy-0.2.16 → redeploy-0.2.18}/redeploy.egg-info/top_level.txt +0 -0
  113. {redeploy-0.2.16 → redeploy-0.2.18}/setup.cfg +0 -0
  114. {redeploy-0.2.16 → redeploy-0.2.18}/tests/test_iac.py +0 -0
  115. {redeploy-0.2.16 → redeploy-0.2.18}/tests/test_parse.py +0 -0
  116. {redeploy-0.2.16 → redeploy-0.2.18}/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.18
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.18-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.55-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-12.6h-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.5500 (37 commits)
34
+ - 👤 **Human dev:** ~$1255 (12.6h @ $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.18-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,135 @@ 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
+
431
+ ### Script References (`command_ref`)
432
+
433
+ Instead of duplicating scripts in YAML, reference a script defined in a markdown codeblock:
434
+
435
+ ```yaml
436
+ extra_steps:
437
+ - id: configure_kiosk
438
+ action: inline_script
439
+ description: "Execute kiosk script from markdown"
440
+ command_ref: "#kiosk-browser-configuration-script"
441
+ risk: medium
442
+ ```
443
+
444
+ In your migration markdown file, define the script in a section:
445
+
446
+ ```markdown
447
+ ## Kiosk Browser Configuration Script
448
+
449
+ ```bash
450
+ #!/bin/bash
451
+ # Auto-detect browser...
452
+ if command -v chromium-browser >/dev/null 2>&1; then
453
+ chromium-browser --kiosk http://localhost:8100
454
+ fi
455
+ ```
456
+ ```
457
+
458
+ **Benefits:**
459
+ - Single source of truth — script lives in one place (markdown codeblock)
460
+ - No duplication between markdown documentation and YAML
461
+ - Easy to read and maintain
462
+ - Changes to the codeblock automatically apply to the deployment
463
+
464
+ **Reference formats:**
465
+ - `"#section-id"` — script from section in current spec file
466
+ - `"./file.md#section-id"` — script from section in specific file
467
+
468
+ The section ID is derived from the heading: spaces become hyphens, lowercase.
469
+ Example: `## Kiosk Browser Configuration Script` → `#kiosk-browser-configuration-script`
470
+
471
+ ### Execute Script by Reference (`redeploy exec`)
472
+
473
+ Run a single script from markdown without running the full migration:
474
+
475
+ ```bash
476
+ # Execute script from codeblock on remote host
477
+ redeploy exec '#kiosk-browser-configuration-script' \
478
+ --host pi@192.168.188.108 \
479
+ --file migration.podman-rpi5-resume.md
480
+
481
+ # With file in reference
482
+ redeploy exec './migration.md#install-deps' --host root@server.com
483
+
484
+ # Using markpact:ref (more explicit)
485
+ redeploy exec 'kiosk-script-id' --host pi@192.168.188.108 --file migration.md
486
+
487
+ # Dry-run to preview script
488
+ redeploy exec '#backup-script' --host pi@192.168.188.108 --file ops.md --dry-run
489
+ ```
490
+
491
+ This is useful for:
492
+ - One-off operations defined in markdown docs
493
+ - Testing individual scripts before full migration
494
+ - Running maintenance tasks
495
+
496
+ ### Execute Multiple Scripts (`redeploy exec-multi`)
497
+
498
+ Test multiple scripts at once:
499
+
500
+ ```bash
501
+ # Execute multiple scripts by ref
502
+ redeploy exec-multi 'kiosk-script,install-deps,cleanup' \
503
+ --host pi@192.168.188.108 \
504
+ --file migration.md
505
+
506
+ # Mix of markpact:ref and section headings
507
+ redeploy exec-multi 'script1,#section2,script3' \
508
+ --host root@server.com \
509
+ --file deploy.md \
510
+ --dry-run
511
+ ```
512
+
513
+ ### Marking Codeblocks with `markpact:ref`
514
+
515
+ For more explicit script identification, use `markpact:ref <id>` in codeblock:
516
+
517
+ ```markdown
518
+ ```bash markpact:ref kiosk-browser-configuration-script
519
+ #!/bin/bash
520
+ # Auto-detect browser...
521
+ if command -v chromium-browser >/dev/null 2>&1; then
522
+ chromium-browser --kiosk http://localhost:8100
523
+ fi
524
+ ```
525
+ ```
526
+
527
+ Benefits of `markpact:ref`:
528
+ - Explicit ID assignment (not derived from heading)
529
+ - Multiple scripts per section
530
+ - Can reference by simple ID instead of full heading
531
+ - Self-documenting in markdown
532
+
404
533
 
405
534
 
406
535
  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.18-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.55-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-12.6h-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.5500 (37 commits)
10
+ - 👤 **Human dev:** ~$1255 (12.6h @ $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.18-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,135 @@ 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
+
407
+ ### Script References (`command_ref`)
408
+
409
+ Instead of duplicating scripts in YAML, reference a script defined in a markdown codeblock:
410
+
411
+ ```yaml
412
+ extra_steps:
413
+ - id: configure_kiosk
414
+ action: inline_script
415
+ description: "Execute kiosk script from markdown"
416
+ command_ref: "#kiosk-browser-configuration-script"
417
+ risk: medium
418
+ ```
419
+
420
+ In your migration markdown file, define the script in a section:
421
+
422
+ ```markdown
423
+ ## Kiosk Browser Configuration Script
424
+
425
+ ```bash
426
+ #!/bin/bash
427
+ # Auto-detect browser...
428
+ if command -v chromium-browser >/dev/null 2>&1; then
429
+ chromium-browser --kiosk http://localhost:8100
430
+ fi
431
+ ```
432
+ ```
433
+
434
+ **Benefits:**
435
+ - Single source of truth — script lives in one place (markdown codeblock)
436
+ - No duplication between markdown documentation and YAML
437
+ - Easy to read and maintain
438
+ - Changes to the codeblock automatically apply to the deployment
439
+
440
+ **Reference formats:**
441
+ - `"#section-id"` — script from section in current spec file
442
+ - `"./file.md#section-id"` — script from section in specific file
443
+
444
+ The section ID is derived from the heading: spaces become hyphens, lowercase.
445
+ Example: `## Kiosk Browser Configuration Script` → `#kiosk-browser-configuration-script`
446
+
447
+ ### Execute Script by Reference (`redeploy exec`)
448
+
449
+ Run a single script from markdown without running the full migration:
450
+
451
+ ```bash
452
+ # Execute script from codeblock on remote host
453
+ redeploy exec '#kiosk-browser-configuration-script' \
454
+ --host pi@192.168.188.108 \
455
+ --file migration.podman-rpi5-resume.md
456
+
457
+ # With file in reference
458
+ redeploy exec './migration.md#install-deps' --host root@server.com
459
+
460
+ # Using markpact:ref (more explicit)
461
+ redeploy exec 'kiosk-script-id' --host pi@192.168.188.108 --file migration.md
462
+
463
+ # Dry-run to preview script
464
+ redeploy exec '#backup-script' --host pi@192.168.188.108 --file ops.md --dry-run
465
+ ```
466
+
467
+ This is useful for:
468
+ - One-off operations defined in markdown docs
469
+ - Testing individual scripts before full migration
470
+ - Running maintenance tasks
471
+
472
+ ### Execute Multiple Scripts (`redeploy exec-multi`)
473
+
474
+ Test multiple scripts at once:
475
+
476
+ ```bash
477
+ # Execute multiple scripts by ref
478
+ redeploy exec-multi 'kiosk-script,install-deps,cleanup' \
479
+ --host pi@192.168.188.108 \
480
+ --file migration.md
481
+
482
+ # Mix of markpact:ref and section headings
483
+ redeploy exec-multi 'script1,#section2,script3' \
484
+ --host root@server.com \
485
+ --file deploy.md \
486
+ --dry-run
487
+ ```
488
+
489
+ ### Marking Codeblocks with `markpact:ref`
490
+
491
+ For more explicit script identification, use `markpact:ref <id>` in codeblock:
492
+
493
+ ```markdown
494
+ ```bash markpact:ref kiosk-browser-configuration-script
495
+ #!/bin/bash
496
+ # Auto-detect browser...
497
+ if command -v chromium-browser >/dev/null 2>&1; then
498
+ chromium-browser --kiosk http://localhost:8100
499
+ fi
500
+ ```
501
+ ```
502
+
503
+ Benefits of `markpact:ref`:
504
+ - Explicit ID assignment (not derived from heading)
505
+ - Multiple scripts per section
506
+ - Can reference by simple ID instead of full heading
507
+ - Self-documenting in markdown
508
+
380
509
 
381
510
 
382
511
  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.18"
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.18"
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,43 @@ class Executor:
138
143
  self._audit_path = audit_path
139
144
  self._t0: float = 0.0
140
145
 
146
+ # ── spec path for command_ref resolution ─────────────────────────────
147
+ if spec_path and not plan.spec_path:
148
+ plan.spec_path = spec_path
149
+
150
+ # ── resume / checkpoint ──────────────────────────────────────────────
151
+ self._resume = resume
152
+ self._from_step = from_step
153
+ spec_id = spec_path or plan.spec_path or plan.target_file or plan.infra_file or plan.app
154
+ if state_path is None and not dry_run:
155
+ state_path = default_state_path(spec_id, plan.host)
156
+ self._state_path: Optional[Path] = (
157
+ Path(state_path) if state_path is not None else None
158
+ )
159
+ self._state: Optional[ResumeState] = None
160
+ if self._state_path is not None and not dry_run:
161
+ self._state = ResumeState.load_or_new(
162
+ self._state_path,
163
+ spec_path=str(spec_id),
164
+ host=plan.host,
165
+ total_steps=len(plan.steps),
166
+ )
167
+ # Keep total_steps in sync if the spec changed between runs.
168
+ self._state.total_steps = len(plan.steps)
169
+
141
170
  @property
142
171
  def completed_steps(self) -> list[MigrationStep]:
143
172
  return list(self._completed)
144
173
 
174
+ @property
175
+ def state(self) -> Optional[ResumeState]:
176
+ """Current ResumeState (None when dry_run or disabled)."""
177
+ return self._state
178
+
179
+ @property
180
+ def state_path(self) -> Optional[Path]:
181
+ return self._state_path
182
+
145
183
  def run(self) -> bool:
146
184
  """Execute all steps. Returns True if all passed."""
147
185
  self._t0 = time.monotonic()
@@ -151,19 +189,34 @@ class Executor:
151
189
  if self._emitter:
152
190
  self._emitter.start(self.plan)
153
191
 
192
+ skip_ids = self._compute_skip_set()
193
+ if skip_ids:
194
+ logger.info(f"resume: skipping {len(skip_ids)} already-completed step(s): "
195
+ f"{', '.join(sorted(skip_ids))}")
196
+
154
197
  ok = True
155
198
  for i, step in enumerate(self.plan.steps, 1):
199
+ if step.id in skip_ids:
200
+ step.status = StepStatus.SKIPPED
201
+ step.result = "resumed: previously completed"
202
+ if self._emitter:
203
+ self._emitter.step_done(i, step)
204
+ continue
156
205
  try:
157
206
  if self._emitter:
158
207
  self._emitter.step_start(i, step)
159
208
  self._execute_step(step)
160
209
  self._completed.append(step)
210
+ if self._state is not None:
211
+ self._state.mark_done(step.id)
161
212
  if self._emitter:
162
213
  self._emitter.step_done(i, step)
163
214
  except StepError as e:
164
215
  logger.error(f"Step failed: {e}")
165
216
  step.status = StepStatus.FAILED
166
217
  step.error = str(e)
218
+ if self._state is not None:
219
+ self._state.mark_failed(step.id, str(e))
167
220
  if self._emitter:
168
221
  self._emitter.step_fail(i, step, str(e))
169
222
  self._emitter.failed(len(self._completed), len(self.plan.steps), str(e))
@@ -177,9 +230,40 @@ class Executor:
177
230
  logger.info(f"{'[DRY RUN] ' if self.dry_run else ''}All {len(self.plan.steps)} steps completed")
178
231
  if self._emitter:
179
232
  self._emitter.done(len(self.plan.steps))
233
+ # Plan finished cleanly — drop the checkpoint.
234
+ if self._state is not None:
235
+ self._state.remove()
236
+ else:
237
+ if self._state is not None and self._state_path is not None:
238
+ logger.info(f"resume: checkpoint saved → {self._state_path} "
239
+ f"({self._state.completed_count}/{self._state.total_steps} done)")
180
240
  self._write_audit(ok=ok, elapsed_s=elapsed)
181
241
  return ok
182
242
 
243
+ def _compute_skip_set(self) -> set[str]:
244
+ """Determine which step ids should be skipped this run.
245
+
246
+ Sources (in priority order):
247
+ 1. ``--from-step <id>``: skip every step BEFORE that id.
248
+ 2. ``--resume`` + persisted state: skip every previously completed id.
249
+ """
250
+ skip: set[str] = set()
251
+ ids = [s.id for s in self.plan.steps]
252
+
253
+ if self._from_step:
254
+ if self._from_step not in ids:
255
+ logger.warning(
256
+ f"--from-step '{self._from_step}' not found in plan; running full plan"
257
+ )
258
+ else:
259
+ idx = ids.index(self._from_step)
260
+ skip.update(ids[:idx])
261
+
262
+ if self._resume and self._state is not None:
263
+ skip.update(self._state.completed_step_ids)
264
+
265
+ return skip
266
+
183
267
  def _write_audit(self, *, ok: bool, elapsed_s: float) -> None:
184
268
  if not self._audit_log:
185
269
  return
@@ -219,6 +303,7 @@ class Executor:
219
303
  StepAction.VERSION_CHECK: self._run_version_check,
220
304
  StepAction.WAIT: self._run_wait,
221
305
  StepAction.PLUGIN: self._run_plugin,
306
+ StepAction.INLINE_SCRIPT: self._run_inline_script,
222
307
  }
223
308
 
224
309
  handler = dispatch.get(step.action)
@@ -456,16 +541,104 @@ class Executor:
456
541
  step.status = StepStatus.DONE
457
542
  step.result = f"waited {total}s"
458
543
 
544
+ def _run_inline_script(self, step: MigrationStep) -> None:
545
+ """Execute multiline bash script via SSH using base64 encoding."""
546
+ import base64
547
+ from pathlib import Path
548
+
549
+ script = step.command
550
+
551
+ # If command_ref is set, extract script from markdown file
552
+ if step.command_ref:
553
+ script = self._resolve_command_ref(step.command_ref, step)
554
+
555
+ if not script:
556
+ raise StepError(step, "inline_script requires command field or command_ref with script content")
557
+
558
+ # Base64 encode the script to safely pass it through SSH
559
+ encoded = base64.b64encode(script.encode()).decode()
560
+ timeout = step.timeout or 300
561
+
562
+ # Create temp file, decode script, run it, then clean up
563
+ cmd = (
564
+ f"tmpfile=$(mktemp) && "
565
+ f"echo '{encoded}' | base64 -d > \"$tmpfile\" && "
566
+ f"chmod +x \"$tmpfile\" && "
567
+ f"\"$tmpfile\"; "
568
+ f"rc=$?; "
569
+ f"rm -f \"$tmpfile\"; "
570
+ f"exit $rc"
571
+ )
572
+
573
+ r = self.probe.run(cmd, timeout=timeout)
574
+ step.result = r.out[:500] if r.out else "script executed"
575
+ if not r.ok:
576
+ raise StepError(step, f"script failed with exit={r.exit_code}: {r.stderr[:200]}")
577
+ step.status = StepStatus.DONE
578
+
579
+ def _resolve_command_ref(self, command_ref: str, step: MigrationStep) -> str:
580
+ """Resolve command_ref to script content from markdown file.
581
+
582
+ command_ref formats:
583
+ - "./file.md#section-id" - script from section in specific file
584
+ - "#section-id" - script from section in current spec file
585
+ - "#kiosk-browser-configuration-script" - markpact:ref block
586
+ """
587
+ from pathlib import Path
588
+ from ..markpact.parser import extract_script_from_markdown, extract_script_by_ref
589
+
590
+ # Parse command_ref
591
+ if "#" in command_ref:
592
+ file_part, section_id = command_ref.split("#", 1)
593
+ file_path = file_part if file_part else getattr(self.plan, 'spec_path', None)
594
+ else:
595
+ section_id = command_ref
596
+ file_path = getattr(self.plan, 'spec_path', None)
597
+
598
+ if not file_path:
599
+ raise StepError(step, f"Cannot resolve command_ref '{command_ref}': no file path available")
600
+
601
+ file_path = Path(file_path)
602
+ if not file_path.exists():
603
+ raise StepError(step, f"Command ref file not found: {file_path}")
604
+
605
+ # Extract script from markdown - try both methods:
606
+ # 1. First try markpact:ref codeblock (new format)
607
+ # 2. Then try section heading (old format)
608
+ markdown_content = file_path.read_text(encoding="utf-8")
609
+
610
+ # Try markpact:ref format first
611
+ script = extract_script_by_ref(markdown_content, section_id, language="bash")
612
+
613
+ # Fallback to section heading format
614
+ if script is None:
615
+ script = extract_script_from_markdown(markdown_content, section_id, language="bash")
616
+
617
+ if script is None:
618
+ raise StepError(step, f"Could not find bash script for ref '{section_id}' in {file_path} (tried markpact:ref and section heading)")
619
+
620
+ return script
621
+
459
622
  # ── rollback ──────────────────────────────────────────────────────────────
460
623
 
461
624
  def _rollback(self) -> None:
462
625
  logger.warning("Rolling back completed steps...")
626
+ rolled_back: list[str] = []
463
627
  for step in reversed(self._completed):
464
628
  if step.rollback_command:
465
629
  logger.info(f" ↩ rollback [{step.id}]: {step.rollback_command}")
466
630
  r = self.probe.run(step.rollback_command, timeout=120)
467
631
  if not r.ok:
468
632
  logger.warning(f" rollback failed: {r.stderr[:100]}")
633
+ else:
634
+ rolled_back.append(step.id)
635
+ # Forget rolled-back steps so a subsequent --resume re-executes them.
636
+ if rolled_back and self._state is not None:
637
+ self._state.completed_step_ids = [
638
+ sid for sid in self._state.completed_step_ids
639
+ if sid not in set(rolled_back)
640
+ ]
641
+ self._state.save()
469
642
 
470
643
  # ── summary ───────────────────────────────────────────────────────────────
471
644