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.
- {redeploy-0.2.16 → redeploy-0.2.17}/PKG-INFO +34 -7
- {redeploy-0.2.16 → redeploy-0.2.17}/README.md +33 -6
- {redeploy-0.2.16 → redeploy-0.2.17}/pyproject.toml +1 -1
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/__init__.py +1 -1
- redeploy-0.2.17/redeploy/apply/__init__.py +16 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/apply/executor.py +161 -1
- redeploy-0.2.17/redeploy/apply/state.py +161 -0
- redeploy-0.2.17/redeploy/audit.py +556 -0
- redeploy-0.2.17/redeploy/checkpoint.py +234 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/cli.py +125 -5
- redeploy-0.2.17/redeploy/markpact/parser.py +148 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/models.py +3 -1
- redeploy-0.2.17/redeploy/tests/test_audit.py +244 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_executor.py +51 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy.egg-info/PKG-INFO +34 -7
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy.egg-info/SOURCES.txt +4 -0
- redeploy-0.2.16/redeploy/apply/__init__.py +0 -4
- redeploy-0.2.16/redeploy/markpact/parser.py +0 -72
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/data_sync.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/detect/__init__.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/detect/detector.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/detect/probes.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/detect/remote.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/detect/templates.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/detect/workflow.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/discovery.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/dsl/__init__.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/dsl/loader.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/dsl/parser.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/dsl_python/__init__.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/dsl_python/context.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/dsl_python/decorators.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/dsl_python/docker_steps.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/dsl_python/exceptions.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/dsl_python/runner.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/dsl_python/steps.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/fleet.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/iac/__init__.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/iac/base.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/iac/docker_compose.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/iac/parsers/__init__.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/iac/parsers/compose.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/iac/registry.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/markpact/__init__.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/markpact/compiler.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/markpact/models.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/observe.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/parse.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/patterns.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/plan/__init__.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/plan/planner.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/plugins/__init__.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/plugins/builtin/__init__.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/plugins/builtin/browser_reload.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/plugins/builtin/notify.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/plugins/builtin/systemd_reload.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/spec_loader.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/ssh.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/steps.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/__init__.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_cli.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_data_sync.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_discovery.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_dsl.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_examples.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_fleet.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_git_integration.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_iac.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_manifest.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_markpact_compiler.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_markpact_parser.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_models.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_observe.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_parse.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_patterns.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_planner_edge.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_plugins.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_probes.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_public_api.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_spec_loader.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_ssh.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_templates.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_verify.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_version.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_version_changelog.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_version_cli.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_version_manifest.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_version_sources.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/tests/test_workflow.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/verify.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/version/__init__.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/version/bump.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/version/changelog.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/version/commits.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/version/diff.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/version/git_integration.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/version/git_transaction.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/version/manifest.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/version/sources/__init__.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/version/sources/base.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/version/sources/json_.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/version/sources/plain.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/version/sources/regex.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/version/sources/toml_.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/version/sources/yaml_.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/version/transaction.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy/version.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy.egg-info/dependency_links.txt +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy.egg-info/entry_points.txt +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy.egg-info/requires.txt +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/redeploy.egg-info/top_level.txt +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/setup.cfg +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/tests/test_iac.py +0 -0
- {redeploy-0.2.16 → redeploy-0.2.17}/tests/test_parse.py +0 -0
- {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.
|
|
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
|
-
    
|
|
31
|
+
  
|
|
32
32
|
|
|
33
|
-
- 🤖 **LLM usage:** $5.
|
|
34
|
-
- 👤 **Human dev:** ~$
|
|
33
|
+
- 🤖 **LLM usage:** $5.4000 (36 commits)
|
|
34
|
+
- 👤 **Human dev:** ~$1130 (11.3h @ $100/h, 30min dedup)
|
|
35
35
|
|
|
36
|
-
Generated on 2026-04-
|
|
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
|
-
    
|
|
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
|
-
    
|
|
7
|
+
  
|
|
8
8
|
|
|
9
|
-
- 🤖 **LLM usage:** $5.
|
|
10
|
-
- 👤 **Human dev:** ~$
|
|
9
|
+
- 🤖 **LLM usage:** $5.4000 (36 commits)
|
|
10
|
+
- 👤 **Human dev:** ~$1130 (11.3h @ $100/h, 30min dedup)
|
|
11
11
|
|
|
12
|
-
Generated on 2026-04-
|
|
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
|
-
    
|
|
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.
|
|
@@ -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.
|
|
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]
|