redeploy 0.2.1__tar.gz → 0.2.4__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 (95) hide show
  1. {redeploy-0.2.1 → redeploy-0.2.4}/PKG-INFO +57 -7
  2. {redeploy-0.2.1 → redeploy-0.2.4}/README.md +56 -6
  3. {redeploy-0.2.1 → redeploy-0.2.4}/pyproject.toml +1 -1
  4. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/__init__.py +52 -1
  5. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/apply/executor.py +54 -7
  6. redeploy-0.2.4/redeploy/cli.py +2551 -0
  7. redeploy-0.2.4/redeploy/dsl/__init__.py +30 -0
  8. redeploy-0.2.4/redeploy/dsl/loader.py +403 -0
  9. redeploy-0.2.4/redeploy/dsl/parser.py +261 -0
  10. redeploy-0.2.4/redeploy/iac/__init__.py +47 -0
  11. redeploy-0.2.4/redeploy/iac/base.py +275 -0
  12. redeploy-0.2.4/redeploy/iac/docker_compose.py +418 -0
  13. redeploy-0.2.4/redeploy/iac/parsers/__init__.py +1 -0
  14. redeploy-0.2.4/redeploy/iac/parsers/compose.py +287 -0
  15. redeploy-0.2.4/redeploy/iac/registry.py +37 -0
  16. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/models.py +43 -1
  17. redeploy-0.2.4/redeploy/observe.py +313 -0
  18. redeploy-0.2.4/redeploy/plugins/__init__.py +180 -0
  19. redeploy-0.2.4/redeploy/plugins/builtin/__init__.py +1 -0
  20. redeploy-0.2.4/redeploy/plugins/builtin/browser_reload.py +120 -0
  21. redeploy-0.2.4/redeploy/plugins/builtin/notify.py +168 -0
  22. redeploy-0.2.4/redeploy/plugins/builtin/systemd_reload.py +98 -0
  23. redeploy-0.2.4/redeploy/tests/test_dsl.py +354 -0
  24. redeploy-0.2.4/redeploy/tests/test_git_integration.py +325 -0
  25. redeploy-0.2.4/redeploy/tests/test_iac.py +461 -0
  26. redeploy-0.2.4/redeploy/tests/test_observe.py +369 -0
  27. redeploy-0.2.4/redeploy/tests/test_plugins.py +410 -0
  28. redeploy-0.2.4/redeploy/tests/test_version_changelog.py +356 -0
  29. redeploy-0.2.4/redeploy/tests/test_version_cli.py +172 -0
  30. redeploy-0.2.4/redeploy/tests/test_version_manifest.py +658 -0
  31. redeploy-0.2.4/redeploy/tests/test_version_sources.py +517 -0
  32. redeploy-0.2.4/redeploy/version/__init__.py +148 -0
  33. redeploy-0.2.4/redeploy/version/bump.py +284 -0
  34. redeploy-0.2.4/redeploy/version/changelog.py +195 -0
  35. redeploy-0.2.4/redeploy/version/commits.py +218 -0
  36. redeploy-0.2.4/redeploy/version/diff.py +118 -0
  37. redeploy-0.2.4/redeploy/version/git_integration.py +142 -0
  38. redeploy-0.2.4/redeploy/version/git_transaction.py +105 -0
  39. redeploy-0.2.4/redeploy/version/manifest.py +169 -0
  40. redeploy-0.2.4/redeploy/version/sources/__init__.py +65 -0
  41. redeploy-0.2.4/redeploy/version/sources/base.py +44 -0
  42. redeploy-0.2.4/redeploy/version/sources/json_.py +94 -0
  43. redeploy-0.2.4/redeploy/version/sources/plain.py +28 -0
  44. redeploy-0.2.4/redeploy/version/sources/regex.py +69 -0
  45. redeploy-0.2.4/redeploy/version/sources/toml_.py +108 -0
  46. redeploy-0.2.4/redeploy/version/sources/yaml_.py +98 -0
  47. redeploy-0.2.4/redeploy/version/transaction.py +162 -0
  48. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy.egg-info/PKG-INFO +57 -7
  49. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy.egg-info/SOURCES.txt +41 -0
  50. redeploy-0.2.4/tests/test_iac.py +511 -0
  51. redeploy-0.2.1/redeploy/cli.py +0 -1188
  52. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/apply/__init__.py +0 -0
  53. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/data_sync.py +0 -0
  54. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/detect/__init__.py +0 -0
  55. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/detect/detector.py +0 -0
  56. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/detect/probes.py +0 -0
  57. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/detect/remote.py +0 -0
  58. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/detect/templates.py +0 -0
  59. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/detect/workflow.py +0 -0
  60. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/discovery.py +0 -0
  61. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/fleet.py +0 -0
  62. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/parse.py +0 -0
  63. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/patterns.py +0 -0
  64. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/plan/__init__.py +0 -0
  65. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/plan/planner.py +0 -0
  66. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/ssh.py +0 -0
  67. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/steps.py +0 -0
  68. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/tests/__init__.py +0 -0
  69. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/tests/test_cli.py +0 -0
  70. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/tests/test_data_sync.py +0 -0
  71. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/tests/test_discovery.py +0 -0
  72. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/tests/test_examples.py +0 -0
  73. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/tests/test_executor.py +0 -0
  74. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/tests/test_fleet.py +0 -0
  75. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/tests/test_manifest.py +0 -0
  76. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/tests/test_models.py +0 -0
  77. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/tests/test_parse.py +0 -0
  78. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/tests/test_patterns.py +0 -0
  79. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/tests/test_planner_edge.py +0 -0
  80. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/tests/test_probes.py +0 -0
  81. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/tests/test_public_api.py +0 -0
  82. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/tests/test_ssh.py +0 -0
  83. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/tests/test_templates.py +0 -0
  84. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/tests/test_verify.py +0 -0
  85. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/tests/test_version.py +0 -0
  86. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/tests/test_workflow.py +0 -0
  87. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/verify.py +0 -0
  88. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy/version.py +0 -0
  89. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy.egg-info/dependency_links.txt +0 -0
  90. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy.egg-info/entry_points.txt +0 -0
  91. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy.egg-info/requires.txt +0 -0
  92. {redeploy-0.2.1 → redeploy-0.2.4}/redeploy.egg-info/top_level.txt +0 -0
  93. {redeploy-0.2.1 → redeploy-0.2.4}/setup.cfg +0 -0
  94. {redeploy-0.2.1 → redeploy-0.2.4}/tests/test_parse.py +0 -0
  95. {redeploy-0.2.1 → redeploy-0.2.4}/tests/test_redeploy.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redeploy
3
- Version: 0.2.1
3
+ Version: 0.2.4
4
4
  Summary: Infrastructure migration toolkit: detect → plan → apply
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -26,17 +26,17 @@ Requires-Dist: pfix>=0.1.60; extra == "dev"
26
26
 
27
27
  ## AI Cost Tracking
28
28
 
29
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.1-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
30
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$2.40-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-4.2h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
29
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.4-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
30
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$3.30-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-5.3h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
31
31
 
32
- - 🤖 **LLM usage:** $2.4000 (16 commits)
33
- - 👤 **Human dev:** ~$420 (4.2h @ $100/h, 30min dedup)
32
+ - 🤖 **LLM usage:** $3.3000 (22 commits)
33
+ - 👤 **Human dev:** ~$533 (5.3h @ $100/h, 30min dedup)
34
34
 
35
35
  Generated on 2026-04-20 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
36
36
 
37
37
  ---
38
38
 
39
- ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.1-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
39
+ ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.4-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
40
40
 
41
41
  Infrastructure migration and device deploy toolkit — VPS, Raspberry Pi kiosk, Podman Quadlet, k3s.
42
42
 
@@ -350,7 +350,57 @@ extra_steps:
350
350
  insert_before: docker_build_pull # runs before build, not after verify
351
351
  ```
352
352
 
353
- ## `redeploy.yaml` project manifest
353
+ ## Plugin system
354
+
355
+ Extend the step pipeline with custom action types using `action: plugin`:
356
+
357
+ ```yaml
358
+ extra_steps:
359
+ - id: reload_kiosk
360
+ action: plugin
361
+ plugin_type: browser_reload
362
+ description: Reload kiosk browser after deploy
363
+ plugin_params:
364
+ port: 9222
365
+ ignore_cache: true
366
+ url_contains: "localhost:8100"
367
+ ```
368
+
369
+ ### Built-in plugins
370
+
371
+ | `plugin_type` | Description | `plugin_params` |
372
+ |---------------|-------------|-----------------|
373
+ | `browser_reload` | Reload Chromium via CDP (Chrome DevTools Protocol) over SSH | `port` (9222), `ignore_cache` (true), `url_contains` ("") |
374
+
375
+ ### Writing a custom plugin
376
+
377
+ Place a `.py` file in `./redeploy_plugins/` (project-local) or `~/.redeploy/plugins/` (user-global):
378
+
379
+ ```python
380
+ # ./redeploy_plugins/notify.py
381
+ from redeploy.plugins import register_plugin, PluginContext
382
+ from redeploy.models import StepStatus
383
+
384
+ @register_plugin("notify_slack")
385
+ def notify_slack(ctx: PluginContext) -> None:
386
+ webhook = ctx.params["webhook"]
387
+ ctx.probe.run(f"curl -X POST {webhook} -d '{{\"text\":\"deployed!\"}}'")
388
+ ctx.step.result = "notified"
389
+ ctx.step.status = StepStatus.DONE
390
+ ```
391
+
392
+ `PluginContext` fields:
393
+
394
+ | Field | Type | Description |
395
+ |-------|------|-------------|
396
+ | `step` | `MigrationStep` | Current step — set `result` and `status` here |
397
+ | `host` | `str` | SSH host (e.g. `pi@192.168.1.5`) |
398
+ | `probe` | `RemoteProbe` | Call `probe.run(cmd)` for remote SSH commands |
399
+ | `emitter` | `ProgressEmitter?` | Emit mid-step progress: `emitter.progress(step.id, msg)` |
400
+ | `params` | `dict` | Shortcut for `step.plugin_params` |
401
+ | `dry_run` | `bool` | Skip side-effects if True |
402
+
403
+
354
404
 
355
405
  Place in project root — `redeploy run` (no args) uses it automatically.
356
406
  Supports **named environments** for multi-target projects:
@@ -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.1-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-$2.40-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-4.2h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
6
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.4-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-$3.30-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-5.3h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $2.4000 (16 commits)
10
- - 👤 **Human dev:** ~$420 (4.2h @ $100/h, 30min dedup)
9
+ - 🤖 **LLM usage:** $3.3000 (22 commits)
10
+ - 👤 **Human dev:** ~$533 (5.3h @ $100/h, 30min dedup)
11
11
 
12
12
  Generated on 2026-04-20 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.1-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.4-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
 
@@ -327,7 +327,57 @@ extra_steps:
327
327
  insert_before: docker_build_pull # runs before build, not after verify
328
328
  ```
329
329
 
330
- ## `redeploy.yaml` project manifest
330
+ ## Plugin system
331
+
332
+ Extend the step pipeline with custom action types using `action: plugin`:
333
+
334
+ ```yaml
335
+ extra_steps:
336
+ - id: reload_kiosk
337
+ action: plugin
338
+ plugin_type: browser_reload
339
+ description: Reload kiosk browser after deploy
340
+ plugin_params:
341
+ port: 9222
342
+ ignore_cache: true
343
+ url_contains: "localhost:8100"
344
+ ```
345
+
346
+ ### Built-in plugins
347
+
348
+ | `plugin_type` | Description | `plugin_params` |
349
+ |---------------|-------------|-----------------|
350
+ | `browser_reload` | Reload Chromium via CDP (Chrome DevTools Protocol) over SSH | `port` (9222), `ignore_cache` (true), `url_contains` ("") |
351
+
352
+ ### Writing a custom plugin
353
+
354
+ Place a `.py` file in `./redeploy_plugins/` (project-local) or `~/.redeploy/plugins/` (user-global):
355
+
356
+ ```python
357
+ # ./redeploy_plugins/notify.py
358
+ from redeploy.plugins import register_plugin, PluginContext
359
+ from redeploy.models import StepStatus
360
+
361
+ @register_plugin("notify_slack")
362
+ def notify_slack(ctx: PluginContext) -> None:
363
+ webhook = ctx.params["webhook"]
364
+ ctx.probe.run(f"curl -X POST {webhook} -d '{{\"text\":\"deployed!\"}}'")
365
+ ctx.step.result = "notified"
366
+ ctx.step.status = StepStatus.DONE
367
+ ```
368
+
369
+ `PluginContext` fields:
370
+
371
+ | Field | Type | Description |
372
+ |-------|------|-------------|
373
+ | `step` | `MigrationStep` | Current step — set `result` and `status` here |
374
+ | `host` | `str` | SSH host (e.g. `pi@192.168.1.5`) |
375
+ | `probe` | `RemoteProbe` | Call `probe.run(cmd)` for remote SSH commands |
376
+ | `emitter` | `ProgressEmitter?` | Emit mid-step progress: `emitter.progress(step.id, msg)` |
377
+ | `params` | `dict` | Shortcut for `step.plugin_params` |
378
+ | `dry_run` | `bool` | Skip side-effects if True |
379
+
380
+
331
381
 
332
382
  Place in project root — `redeploy run` (no args) uses it automatically.
333
383
  Supports **named environments** for multi-target projects:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "redeploy"
7
- version = "0.2.1"
7
+ version = "0.2.4"
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.1"
10
+ __version__ = "0.2.4"
11
11
 
12
12
  # ── Core models ───────────────────────────────────────────────────────────────
13
13
  from .models import ( # noqa: F401
@@ -55,6 +55,36 @@ from .patterns import ( # noqa: F401
55
55
  pattern_registry,
56
56
  )
57
57
 
58
+ # ── Observability (first-class from 0.2.0) ────────────────────────────────────
59
+ from .observe import ( # noqa: F401
60
+ AuditEntry,
61
+ DeployAuditLog,
62
+ DeployReport,
63
+ )
64
+
65
+ # ── IaC parsers (first-class from 0.3.0) ──────────────────────────────────────
66
+ from .iac import ( # noqa: F401
67
+ ParsedSpec,
68
+ Parser,
69
+ ParserRegistry,
70
+ PortInfo,
71
+ ServiceInfo,
72
+ VolumeInfo,
73
+ ConversionWarning,
74
+ parse_file,
75
+ parse_dir,
76
+ parser_registry,
77
+ )
78
+
79
+ # ── Plugin system (first-class from 0.2.2) ────────────────────────────────────
80
+ from .plugins import ( # noqa: F401
81
+ PluginContext,
82
+ PluginRegistry,
83
+ register_plugin,
84
+ load_user_plugins,
85
+ registry as plugin_registry,
86
+ )
87
+
58
88
  __all__ = [
59
89
  # version
60
90
  "__version__",
@@ -95,4 +125,25 @@ __all__ = [
95
125
  "get_pattern",
96
126
  "list_patterns",
97
127
  "pattern_registry",
128
+ # observability
129
+ "AuditEntry",
130
+ "DeployAuditLog",
131
+ "DeployReport",
132
+ # iac parsers
133
+ "ParsedSpec",
134
+ "Parser",
135
+ "ParserRegistry",
136
+ "PortInfo",
137
+ "ServiceInfo",
138
+ "VolumeInfo",
139
+ "ConversionWarning",
140
+ "parse_file",
141
+ "parse_dir",
142
+ "parser_registry",
143
+ # plugin system
144
+ "PluginContext",
145
+ "PluginRegistry",
146
+ "register_plugin",
147
+ "load_user_plugins",
148
+ "plugin_registry",
98
149
  ]
@@ -14,6 +14,7 @@ from loguru import logger
14
14
 
15
15
  from ..detect.remote import RemoteProbe
16
16
  from ..models import MigrationPlan, MigrationStep, StepAction, StepStatus
17
+ from ..plugins import PluginContext, registry as _plugin_registry
17
18
 
18
19
 
19
20
  class ProgressEmitter:
@@ -121,7 +122,9 @@ class Executor:
121
122
  def __init__(self, plan: MigrationPlan, dry_run: bool = False,
122
123
  ssh_key: Optional[str] = None,
123
124
  progress_yaml: bool = False,
124
- progress_stream: IO[str] = None):
125
+ progress_stream: IO[str] = None,
126
+ audit_log: bool = True,
127
+ audit_path: Optional["Path"] = None):
125
128
  self.plan = plan
126
129
  self.dry_run = dry_run
127
130
  self.probe = RemoteProbe(plan.host)
@@ -131,15 +134,24 @@ class Executor:
131
134
  self._emitter: Optional[ProgressEmitter] = (
132
135
  ProgressEmitter(progress_stream) if progress_yaml else None
133
136
  )
137
+ self._audit_log = audit_log
138
+ self._audit_path = audit_path
139
+ self._t0: float = 0.0
140
+
141
+ @property
142
+ def completed_steps(self) -> list[MigrationStep]:
143
+ return list(self._completed)
134
144
 
135
145
  def run(self) -> bool:
136
146
  """Execute all steps. Returns True if all passed."""
147
+ self._t0 = time.monotonic()
137
148
  prefix = "[DRY RUN] " if self.dry_run else ""
138
149
  logger.info(f"{prefix}Applying plan: {len(self.plan.steps)} steps "
139
150
  f"({self.plan.from_strategy.value} → {self.plan.to_strategy.value})")
140
151
  if self._emitter:
141
152
  self._emitter.start(self.plan)
142
153
 
154
+ ok = True
143
155
  for i, step in enumerate(self.plan.steps, 1):
144
156
  try:
145
157
  if self._emitter:
@@ -157,12 +169,27 @@ class Executor:
157
169
  self._emitter.failed(len(self._completed), len(self.plan.steps), str(e))
158
170
  if not self.dry_run:
159
171
  self._rollback()
160
- return False
161
-
162
- logger.info(f"{'[DRY RUN] ' if self.dry_run else ''}All {len(self.plan.steps)} steps completed")
163
- if self._emitter:
164
- self._emitter.done(len(self.plan.steps))
165
- return True
172
+ ok = False
173
+ break
174
+
175
+ elapsed = time.monotonic() - self._t0
176
+ if ok:
177
+ logger.info(f"{'[DRY RUN] ' if self.dry_run else ''}All {len(self.plan.steps)} steps completed")
178
+ if self._emitter:
179
+ self._emitter.done(len(self.plan.steps))
180
+ self._write_audit(ok=ok, elapsed_s=elapsed)
181
+ return ok
182
+
183
+ def _write_audit(self, *, ok: bool, elapsed_s: float) -> None:
184
+ if not self._audit_log:
185
+ return
186
+ try:
187
+ from ..observe import DeployAuditLog
188
+ log = DeployAuditLog(path=self._audit_path)
189
+ log.record(self.plan, self._completed, ok=ok,
190
+ elapsed_s=elapsed_s, dry_run=self.dry_run)
191
+ except Exception as exc: # never crash the executor
192
+ logger.debug(f"audit_log write failed (non-fatal): {exc}")
166
193
 
167
194
  # ── step dispatcher ───────────────────────────────────────────────────────
168
195
 
@@ -191,6 +218,7 @@ class Executor:
191
218
  StepAction.HTTP_CHECK: self._run_http_check,
192
219
  StepAction.VERSION_CHECK: self._run_version_check,
193
220
  StepAction.WAIT: self._run_wait,
221
+ StepAction.PLUGIN: self._run_plugin,
194
222
  }
195
223
 
196
224
  handler = dispatch.get(step.action)
@@ -391,6 +419,25 @@ class Executor:
391
419
  step.status = StepStatus.DONE
392
420
  step.result = f"version {step.expect} confirmed"
393
421
 
422
+ def _run_plugin(self, step: MigrationStep) -> None:
423
+ """Dispatch to a registered plugin handler."""
424
+ plugin_type = step.plugin_type
425
+ if not plugin_type:
426
+ raise StepError(step, "plugin action requires plugin_type field")
427
+ handler = _plugin_registry.get(plugin_type)
428
+ if not handler:
429
+ available = ", ".join(_plugin_registry.names()) or "(none loaded)"
430
+ raise StepError(step, f"unknown plugin_type '{plugin_type}'. Available: {available}")
431
+ ctx = PluginContext(
432
+ step=step,
433
+ host=self.plan.host,
434
+ probe=self.probe,
435
+ emitter=self._emitter,
436
+ params=step.plugin_params,
437
+ dry_run=self.dry_run,
438
+ )
439
+ handler(ctx)
440
+
394
441
  def _run_wait(self, step: MigrationStep) -> None:
395
442
  total = step.seconds
396
443
  if total <= 0: