redeploy 0.2.26__tar.gz → 0.2.28__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.26 → redeploy-0.2.28}/PKG-INFO +6 -6
- {redeploy-0.2.26 → redeploy-0.2.28}/README.md +5 -5
- {redeploy-0.2.26 → redeploy-0.2.28}/pyproject.toml +1 -1
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/__init__.py +1 -1
- redeploy-0.2.28/redeploy/apply/__init__.py +49 -0
- redeploy-0.2.28/redeploy/apply/exceptions.py +12 -0
- redeploy-0.2.28/redeploy/apply/executor.py +261 -0
- redeploy-0.2.28/redeploy/apply/handlers.py +422 -0
- redeploy-0.2.28/redeploy/apply/progress.py +107 -0
- redeploy-0.2.28/redeploy/apply/rollback.py +44 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy.egg-info/PKG-INFO +6 -6
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy.egg-info/SOURCES.txt +4 -0
- redeploy-0.2.26/redeploy/apply/__init__.py +0 -16
- redeploy-0.2.26/redeploy/apply/executor.py +0 -739
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/apply/state.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/audit.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/__init__.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/__init__.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/audit.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/detect.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/devices.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/diagnose.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/diff.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/exec_.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/export.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/import_.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/init.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/inspect.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/patterns.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/plan_apply.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/plugin.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/probe.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/state.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/status.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/target.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/version.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/commands/workflow.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/core.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli/display.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/cli.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/data_sync.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/detect/__init__.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/detect/detector.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/detect/probes.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/detect/remote.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/detect/templates.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/detect/workflow.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/discovery.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/dsl/__init__.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/dsl/loader.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/dsl/parser.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/dsl_python/__init__.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/dsl_python/context.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/dsl_python/decorators.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/dsl_python/docker_steps.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/dsl_python/exceptions.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/dsl_python/runner.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/dsl_python/steps.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/fleet.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/iac/__init__.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/iac/base.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/iac/docker_compose.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/iac/parsers/__init__.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/iac/parsers/compose.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/iac/registry.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/markpact/__init__.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/markpact/compiler.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/markpact/models.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/markpact/parser.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/models.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/observe.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/parse.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/patterns.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/plan/__init__.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/plan/planner.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/plugins/__init__.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/plugins/builtin/__init__.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/plugins/builtin/browser_reload.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/plugins/builtin/notify.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/plugins/builtin/systemd_reload.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/spec_loader.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/ssh.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/steps.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/__init__.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_audit.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_cli.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_data_sync.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_discovery.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_dsl.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_examples.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_executor.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_fleet.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_git_integration.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_iac.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_manifest.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_markpact_compiler.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_markpact_parser.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_models.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_observe.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_parse.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_patterns.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_planner_edge.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_plugins.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_probes.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_public_api.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_resume.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_spec_loader.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_ssh.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_templates.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_verify.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_version.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_version_changelog.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_version_cli.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_version_manifest.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_version_sources.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/tests/test_workflow.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/verify.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/version/__init__.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/version/bump.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/version/changelog.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/version/commits.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/version/diff.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/version/git_integration.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/version/git_transaction.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/version/manifest.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/version/sources/__init__.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/version/sources/base.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/version/sources/json_.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/version/sources/plain.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/version/sources/regex.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/version/sources/toml_.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/version/sources/yaml_.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/version/transaction.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy/version.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy.egg-info/dependency_links.txt +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy.egg-info/entry_points.txt +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy.egg-info/requires.txt +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/redeploy.egg-info/top_level.txt +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/setup.cfg +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/tests/test_iac.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/tests/test_parse.py +0 -0
- {redeploy-0.2.26 → redeploy-0.2.28}/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.28
|
|
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:** $
|
|
34
|
-
- 👤 **Human dev:** ~$
|
|
33
|
+
- 🤖 **LLM usage:** $7.2000 (48 commits)
|
|
34
|
+
- 👤 **Human dev:** ~$1450 (14.5h @ $100/h, 30min dedup)
|
|
35
35
|
|
|
36
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
|
|
|
@@ -3,17 +3,17 @@
|
|
|
3
3
|
|
|
4
4
|
## AI Cost Tracking
|
|
5
5
|
|
|
6
|
-
    
|
|
7
|
+
  
|
|
8
8
|
|
|
9
|
-
- 🤖 **LLM usage:** $
|
|
10
|
-
- 👤 **Human dev:** ~$
|
|
9
|
+
- 🤖 **LLM usage:** $7.2000 (48 commits)
|
|
10
|
+
- 👤 **Human dev:** ~$1450 (14.5h @ $100/h, 30min dedup)
|
|
11
11
|
|
|
12
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
|
|
|
@@ -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.28"
|
|
11
11
|
|
|
12
12
|
# ── Core models ───────────────────────────────────────────────────────────────
|
|
13
13
|
from .models import ( # noqa: F401
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""apply — Execute a MigrationPlan step by step."""
|
|
2
|
+
from .exceptions import StepError
|
|
3
|
+
from .executor import Executor
|
|
4
|
+
from .handlers import (
|
|
5
|
+
run_container_log_tail,
|
|
6
|
+
run_docker_build,
|
|
7
|
+
run_docker_health_wait,
|
|
8
|
+
run_http_check,
|
|
9
|
+
run_inline_script,
|
|
10
|
+
run_plugin,
|
|
11
|
+
run_podman_build,
|
|
12
|
+
run_rsync,
|
|
13
|
+
run_scp,
|
|
14
|
+
run_ssh,
|
|
15
|
+
run_version_check,
|
|
16
|
+
run_wait,
|
|
17
|
+
)
|
|
18
|
+
from .progress import ProgressEmitter
|
|
19
|
+
from .rollback import rollback_steps
|
|
20
|
+
from .state import (
|
|
21
|
+
DEFAULT_STATE_DIR,
|
|
22
|
+
ResumeState,
|
|
23
|
+
default_state_path,
|
|
24
|
+
state_key,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"Executor",
|
|
29
|
+
"ProgressEmitter",
|
|
30
|
+
"ResumeState",
|
|
31
|
+
"StepError",
|
|
32
|
+
"rollback_steps",
|
|
33
|
+
"default_state_path",
|
|
34
|
+
"state_key",
|
|
35
|
+
"DEFAULT_STATE_DIR",
|
|
36
|
+
# Handlers
|
|
37
|
+
"run_container_log_tail",
|
|
38
|
+
"run_docker_build",
|
|
39
|
+
"run_docker_health_wait",
|
|
40
|
+
"run_http_check",
|
|
41
|
+
"run_inline_script",
|
|
42
|
+
"run_plugin",
|
|
43
|
+
"run_podman_build",
|
|
44
|
+
"run_rsync",
|
|
45
|
+
"run_scp",
|
|
46
|
+
"run_ssh",
|
|
47
|
+
"run_version_check",
|
|
48
|
+
"run_wait",
|
|
49
|
+
]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Exceptions for the apply package."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from ..models import MigrationStep
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class StepError(Exception):
|
|
8
|
+
"""Exception raised when a migration step fails."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, step: MigrationStep, msg: str):
|
|
11
|
+
self.step = step
|
|
12
|
+
super().__init__(f"[{step.id}] {msg}")
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Executor — runs MigrationPlan steps, handles rollback on failure."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import IO, Optional
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
from loguru import logger
|
|
10
|
+
|
|
11
|
+
from ..detect.remote import RemoteProbe
|
|
12
|
+
from ..models import MigrationPlan, MigrationStep, StepAction, StepStatus
|
|
13
|
+
from .exceptions import StepError
|
|
14
|
+
from .handlers import (
|
|
15
|
+
run_ssh, run_scp, run_rsync, run_docker_build, run_podman_build,
|
|
16
|
+
run_docker_health_wait, run_container_log_tail, run_http_check,
|
|
17
|
+
run_version_check, run_plugin, run_wait, run_inline_script,
|
|
18
|
+
)
|
|
19
|
+
from .progress import ProgressEmitter
|
|
20
|
+
from .rollback import rollback_steps
|
|
21
|
+
from .state import ResumeState, default_state_path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Executor:
|
|
25
|
+
"""Execute MigrationPlan steps on a remote host."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, plan: MigrationPlan, dry_run: bool = False,
|
|
28
|
+
ssh_key: Optional[str] = None,
|
|
29
|
+
progress_yaml: bool = False,
|
|
30
|
+
progress_stream: IO[str] = None,
|
|
31
|
+
audit_log: bool = True,
|
|
32
|
+
audit_path: Optional["Path"] = None,
|
|
33
|
+
resume: bool = False,
|
|
34
|
+
from_step: Optional[str] = None,
|
|
35
|
+
state_path: Optional["Path"] = None,
|
|
36
|
+
spec_path: Optional[str] = None):
|
|
37
|
+
self.plan = plan
|
|
38
|
+
self.dry_run = dry_run
|
|
39
|
+
self.probe = RemoteProbe(plan.host)
|
|
40
|
+
if ssh_key:
|
|
41
|
+
self.probe.key = ssh_key
|
|
42
|
+
self._completed: list[MigrationStep] = []
|
|
43
|
+
self._emitter: Optional[ProgressEmitter] = (
|
|
44
|
+
ProgressEmitter(progress_stream) if progress_yaml else None
|
|
45
|
+
)
|
|
46
|
+
self._audit_log = audit_log
|
|
47
|
+
self._audit_path = audit_path
|
|
48
|
+
self._t0: float = 0.0
|
|
49
|
+
|
|
50
|
+
# ── spec path for command_ref resolution ─────────────────────────────
|
|
51
|
+
if spec_path and not plan.spec_path:
|
|
52
|
+
plan.spec_path = spec_path
|
|
53
|
+
|
|
54
|
+
# ── resume / checkpoint ──────────────────────────────────────────────
|
|
55
|
+
self._resume = resume
|
|
56
|
+
self._from_step = from_step
|
|
57
|
+
spec_id = spec_path or plan.spec_path or plan.target_file or plan.infra_file or plan.app
|
|
58
|
+
if state_path is None and not dry_run:
|
|
59
|
+
state_path = default_state_path(spec_id, plan.host)
|
|
60
|
+
self._state_path: Optional[Path] = (
|
|
61
|
+
Path(state_path) if state_path is not None else None
|
|
62
|
+
)
|
|
63
|
+
self._state: Optional[ResumeState] = None
|
|
64
|
+
if self._state_path is not None and not dry_run:
|
|
65
|
+
self._state = ResumeState.load_or_new(
|
|
66
|
+
self._state_path,
|
|
67
|
+
spec_path=str(spec_id),
|
|
68
|
+
host=plan.host,
|
|
69
|
+
total_steps=len(plan.steps),
|
|
70
|
+
)
|
|
71
|
+
# Keep total_steps in sync if the spec changed between runs.
|
|
72
|
+
self._state.total_steps = len(plan.steps)
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def completed_steps(self) -> list[MigrationStep]:
|
|
76
|
+
return list(self._completed)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def state(self) -> Optional[ResumeState]:
|
|
80
|
+
"""Current ResumeState (None when dry_run or disabled)."""
|
|
81
|
+
return self._state
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def state_path(self) -> Optional[Path]:
|
|
85
|
+
return self._state_path
|
|
86
|
+
|
|
87
|
+
def run(self) -> bool:
|
|
88
|
+
"""Execute all steps. Returns True if all passed."""
|
|
89
|
+
self._t0 = time.monotonic()
|
|
90
|
+
prefix = "[DRY RUN] " if self.dry_run else ""
|
|
91
|
+
logger.info(f"{prefix}Applying plan: {len(self.plan.steps)} steps "
|
|
92
|
+
f"({self.plan.from_strategy.value} → {self.plan.to_strategy.value})")
|
|
93
|
+
if self._emitter:
|
|
94
|
+
self._emitter.start(self.plan)
|
|
95
|
+
|
|
96
|
+
skip_ids = self._compute_skip_set()
|
|
97
|
+
if skip_ids:
|
|
98
|
+
logger.info(f"resume: skipping {len(skip_ids)} already-completed step(s): "
|
|
99
|
+
f"{', '.join(sorted(skip_ids))}")
|
|
100
|
+
|
|
101
|
+
ok = self._execute_steps_loop(skip_ids)
|
|
102
|
+
elapsed = time.monotonic() - self._t0
|
|
103
|
+
|
|
104
|
+
self._handle_completion(ok, elapsed)
|
|
105
|
+
self._write_audit(ok=ok, elapsed_s=elapsed)
|
|
106
|
+
return ok
|
|
107
|
+
|
|
108
|
+
def _execute_steps_loop(self, skip_ids: set[str]) -> bool:
|
|
109
|
+
"""Execute steps, handling skips and errors. Returns True if all passed."""
|
|
110
|
+
ok = True
|
|
111
|
+
for i, step in enumerate(self.plan.steps, 1):
|
|
112
|
+
if step.id in skip_ids:
|
|
113
|
+
self._skip_step(i, step)
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
if self._emitter:
|
|
118
|
+
self._emitter.step_start(i, step)
|
|
119
|
+
self._execute_step(step)
|
|
120
|
+
self._completed.append(step)
|
|
121
|
+
if self._state is not None:
|
|
122
|
+
self._state.mark_done(step.id)
|
|
123
|
+
if self._emitter:
|
|
124
|
+
self._emitter.step_done(i, step)
|
|
125
|
+
except StepError as e:
|
|
126
|
+
self._handle_step_failure(i, step, e)
|
|
127
|
+
ok = False
|
|
128
|
+
break
|
|
129
|
+
return ok
|
|
130
|
+
|
|
131
|
+
def _skip_step(self, i: int, step: MigrationStep) -> None:
|
|
132
|
+
"""Handle a skipped step due to resume."""
|
|
133
|
+
step.status = StepStatus.SKIPPED
|
|
134
|
+
step.result = "resumed: previously completed"
|
|
135
|
+
if self._emitter:
|
|
136
|
+
self._emitter.step_done(i, step)
|
|
137
|
+
|
|
138
|
+
def _handle_step_failure(self, i: int, step: MigrationStep, error: StepError) -> None:
|
|
139
|
+
"""Handle a step failure."""
|
|
140
|
+
logger.error(f"Step failed: {error}")
|
|
141
|
+
step.status = StepStatus.FAILED
|
|
142
|
+
step.error = str(error)
|
|
143
|
+
if self._state is not None:
|
|
144
|
+
self._state.mark_failed(step.id, str(error))
|
|
145
|
+
if self._emitter:
|
|
146
|
+
self._emitter.step_fail(i, step, str(error))
|
|
147
|
+
self._emitter.failed(len(self._completed), len(self.plan.steps), str(error))
|
|
148
|
+
if not self.dry_run:
|
|
149
|
+
self._rollback()
|
|
150
|
+
|
|
151
|
+
def _handle_completion(self, ok: bool, elapsed: float) -> None:
|
|
152
|
+
"""Handle plan completion or failure."""
|
|
153
|
+
if ok:
|
|
154
|
+
logger.info(f"{'[DRY RUN] ' if self.dry_run else ''}All {len(self.plan.steps)} steps completed")
|
|
155
|
+
if self._emitter:
|
|
156
|
+
self._emitter.done(len(self.plan.steps))
|
|
157
|
+
# Plan finished cleanly — drop the checkpoint.
|
|
158
|
+
if self._state is not None:
|
|
159
|
+
self._state.remove()
|
|
160
|
+
else:
|
|
161
|
+
if self._state is not None and self._state_path is not None:
|
|
162
|
+
logger.info(f"resume: checkpoint saved → {self._state_path} "
|
|
163
|
+
f"({self._state.completed_count}/{self._state.total_steps} done)")
|
|
164
|
+
|
|
165
|
+
def _compute_skip_set(self) -> set[str]:
|
|
166
|
+
"""Determine which step ids should be skipped this run.
|
|
167
|
+
|
|
168
|
+
Sources (in priority order):
|
|
169
|
+
1. ``--from-step <id>``: skip every step BEFORE that id.
|
|
170
|
+
2. ``--resume`` + persisted state: skip every previously completed id.
|
|
171
|
+
"""
|
|
172
|
+
skip: set[str] = set()
|
|
173
|
+
ids = [s.id for s in self.plan.steps]
|
|
174
|
+
|
|
175
|
+
if self._from_step:
|
|
176
|
+
if self._from_step not in ids:
|
|
177
|
+
logger.warning(
|
|
178
|
+
f"--from-step '{self._from_step}' not found in plan; running full plan"
|
|
179
|
+
)
|
|
180
|
+
else:
|
|
181
|
+
idx = ids.index(self._from_step)
|
|
182
|
+
skip.update(ids[:idx])
|
|
183
|
+
|
|
184
|
+
if self._resume and self._state is not None:
|
|
185
|
+
skip.update(self._state.completed_step_ids)
|
|
186
|
+
|
|
187
|
+
return skip
|
|
188
|
+
|
|
189
|
+
def _write_audit(self, *, ok: bool, elapsed_s: float) -> None:
|
|
190
|
+
if not self._audit_log:
|
|
191
|
+
return
|
|
192
|
+
try:
|
|
193
|
+
from ..observe import DeployAuditLog
|
|
194
|
+
log = DeployAuditLog(path=self._audit_path)
|
|
195
|
+
log.record(self.plan, self._completed, ok=ok,
|
|
196
|
+
elapsed_s=elapsed_s, dry_run=self.dry_run)
|
|
197
|
+
except Exception as exc: # never crash the executor
|
|
198
|
+
logger.debug(f"audit_log write failed (non-fatal): {exc}")
|
|
199
|
+
|
|
200
|
+
# ── step dispatcher ───────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
def _execute_step(self, step: MigrationStep) -> None:
|
|
203
|
+
logger.info(f" {'[DRY]' if self.dry_run else '→'} [{step.id}] {step.description}")
|
|
204
|
+
step.status = StepStatus.RUNNING
|
|
205
|
+
|
|
206
|
+
if self.dry_run:
|
|
207
|
+
step.status = StepStatus.DONE
|
|
208
|
+
step.result = "dry-run"
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
dispatch = {
|
|
212
|
+
StepAction.SYSTEMCTL_STOP: lambda s: run_ssh(s, self.probe),
|
|
213
|
+
StepAction.SYSTEMCTL_DISABLE: lambda s: run_ssh(s, self.probe),
|
|
214
|
+
StepAction.SYSTEMCTL_START: lambda s: run_ssh(s, self.probe),
|
|
215
|
+
StepAction.KUBECTL_DELETE: lambda s: run_ssh(s, self.probe),
|
|
216
|
+
StepAction.DOCKER_COMPOSE_UP: lambda s: run_ssh(s, self.probe),
|
|
217
|
+
StepAction.DOCKER_COMPOSE_DOWN: lambda s: run_ssh(s, self.probe),
|
|
218
|
+
StepAction.DOCKER_BUILD: lambda s: run_docker_build(s, self.probe, self._emitter),
|
|
219
|
+
StepAction.DOCKER_HEALTH_WAIT: lambda s: run_docker_health_wait(s, self.probe),
|
|
220
|
+
StepAction.CONTAINER_LOG_TAIL: lambda s: run_container_log_tail(s, self.probe),
|
|
221
|
+
StepAction.PODMAN_BUILD: lambda s: run_podman_build(s, self.probe, self._emitter),
|
|
222
|
+
StepAction.SSH_CMD: lambda s: run_ssh(s, self.probe),
|
|
223
|
+
StepAction.SCP: lambda s: run_scp(s, self.probe, self.plan),
|
|
224
|
+
StepAction.RSYNC: lambda s: run_rsync(s, self.probe, self.plan),
|
|
225
|
+
StepAction.HTTP_CHECK: lambda s: run_http_check(s, self.probe),
|
|
226
|
+
StepAction.VERSION_CHECK: lambda s: run_version_check(s, self.probe),
|
|
227
|
+
StepAction.WAIT: lambda s: run_wait(s),
|
|
228
|
+
StepAction.PLUGIN: lambda s: run_plugin(s, self.probe, self.plan, self._emitter, self.dry_run),
|
|
229
|
+
StepAction.INLINE_SCRIPT: lambda s: run_inline_script(s, self.probe, self.plan),
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
handler = dispatch.get(step.action)
|
|
233
|
+
if not handler:
|
|
234
|
+
raise StepError(step, f"No handler for action {step.action}")
|
|
235
|
+
handler(step)
|
|
236
|
+
|
|
237
|
+
# ── rollback ──────────────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
def _rollback(self) -> None:
|
|
240
|
+
rollback_steps(self._completed, self.probe, self._state)
|
|
241
|
+
|
|
242
|
+
# ── summary ───────────────────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
def summary(self) -> str:
|
|
245
|
+
total = len(self.plan.steps)
|
|
246
|
+
done = sum(1 for s in self.plan.steps if s.status == StepStatus.DONE)
|
|
247
|
+
failed = sum(1 for s in self.plan.steps if s.status == StepStatus.FAILED)
|
|
248
|
+
icon = "✅" if failed == 0 else "❌"
|
|
249
|
+
return f"{icon} {done}/{total} steps completed" + (f", {failed} failed" if failed else "")
|
|
250
|
+
|
|
251
|
+
@staticmethod
|
|
252
|
+
def from_file(plan_path: Path) -> "Executor":
|
|
253
|
+
with plan_path.open() as f:
|
|
254
|
+
raw = yaml.safe_load(f)
|
|
255
|
+
plan = MigrationPlan(**raw)
|
|
256
|
+
return Executor(plan)
|
|
257
|
+
|
|
258
|
+
def save_results(self, output: Path) -> None:
|
|
259
|
+
data = self.plan.model_dump(mode="json")
|
|
260
|
+
output.write_text(yaml.dump(data, default_flow_style=False, allow_unicode=True))
|
|
261
|
+
logger.info(f"Results saved to {output}")
|