redeploy 0.1.1__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.1.1/PKG-INFO +168 -0
- redeploy-0.1.1/README.md +145 -0
- redeploy-0.1.1/pyproject.toml +68 -0
- redeploy-0.1.1/redeploy/__init__.py +2 -0
- redeploy-0.1.1/redeploy/apply/__init__.py +4 -0
- redeploy-0.1.1/redeploy/apply/executor.py +199 -0
- redeploy-0.1.1/redeploy/cli.py +279 -0
- redeploy-0.1.1/redeploy/detect/__init__.py +4 -0
- redeploy-0.1.1/redeploy/detect/detector.py +96 -0
- redeploy-0.1.1/redeploy/detect/probes.py +272 -0
- redeploy-0.1.1/redeploy/detect/remote.py +53 -0
- redeploy-0.1.1/redeploy/models.py +189 -0
- redeploy-0.1.1/redeploy/plan/__init__.py +4 -0
- redeploy-0.1.1/redeploy/plan/planner.py +284 -0
- redeploy-0.1.1/redeploy/tests/__init__.py +0 -0
- redeploy-0.1.1/redeploy/tests/test_models.py +119 -0
- redeploy-0.1.1/redeploy.egg-info/PKG-INFO +168 -0
- redeploy-0.1.1/redeploy.egg-info/SOURCES.txt +22 -0
- redeploy-0.1.1/redeploy.egg-info/dependency_links.txt +1 -0
- redeploy-0.1.1/redeploy.egg-info/entry_points.txt +2 -0
- redeploy-0.1.1/redeploy.egg-info/requires.txt +17 -0
- redeploy-0.1.1/redeploy.egg-info/top_level.txt +1 -0
- redeploy-0.1.1/setup.cfg +4 -0
- redeploy-0.1.1/tests/test_redeploy.py +11 -0
redeploy-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: redeploy
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Infrastructure migration toolkit: detect → plan → apply
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: pydantic>=2.0
|
|
8
|
+
Requires-Dist: pyyaml>=6.0
|
|
9
|
+
Requires-Dist: click>=8.0
|
|
10
|
+
Requires-Dist: loguru>=0.7
|
|
11
|
+
Requires-Dist: paramiko>=3.0
|
|
12
|
+
Requires-Dist: httpx>=0.25
|
|
13
|
+
Requires-Dist: rich>=13.0
|
|
14
|
+
Requires-Dist: goal>=2.1.0
|
|
15
|
+
Requires-Dist: costs>=0.1.20
|
|
16
|
+
Requires-Dist: pfix>=0.1.60
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
19
|
+
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
20
|
+
Requires-Dist: goal>=2.1.0; extra == "dev"
|
|
21
|
+
Requires-Dist: costs>=0.1.20; extra == "dev"
|
|
22
|
+
Requires-Dist: pfix>=0.1.60; extra == "dev"
|
|
23
|
+
|
|
24
|
+
# redeploy
|
|
25
|
+
|
|
26
|
+
## AI Cost Tracking
|
|
27
|
+
|
|
28
|
+
 
|
|
29
|
+
|
|
30
|
+
This project uses AI-generated code. Total cost: **$0.1500** with **1** AI commits.
|
|
31
|
+
|
|
32
|
+
Generated on 2026-04-20 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/models/openrouter/qwen/qwen3-coder-next)
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
Infrastructure migration toolkit z 3 funkcjami:
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
redeploy detect → infra.yaml (co jest teraz)
|
|
42
|
+
redeploy plan → migration-plan.yaml (co zrobić)
|
|
43
|
+
redeploy apply → wykonanie planu (wykonaj migrację)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Instalacja
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install -e .
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Użycie
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# 1. Wykryj aktualną infrastrukturę (lokalną lub zdalną)
|
|
56
|
+
redeploy detect --host root@87.106.87.183 --app c2004 -o infra.yaml
|
|
57
|
+
|
|
58
|
+
# 2. Zaplanuj migrację (np. z k3s → docker_full)
|
|
59
|
+
redeploy plan --infra infra.yaml --target target.yaml -o migration-plan.yaml
|
|
60
|
+
|
|
61
|
+
# 3. Wykonaj plan
|
|
62
|
+
redeploy apply --plan migration-plan.yaml
|
|
63
|
+
|
|
64
|
+
# Lub wszystko naraz (detect → plan → apply)
|
|
65
|
+
redeploy migrate --host root@87.106.87.183 --app c2004 --target target.yaml
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Funkcje
|
|
69
|
+
|
|
70
|
+
### `detect` — Skanowanie infrastruktury
|
|
71
|
+
|
|
72
|
+
Wykrywa aktualny stan:
|
|
73
|
+
- Zdalny host: SSH, Docker, k3s, systemd, Podman, porty
|
|
74
|
+
- Usługi: działające kontenery, pody k8s/k3s, serwisy systemd
|
|
75
|
+
- Sieć: iptables, nginx, Traefik, zajęte porty
|
|
76
|
+
- Aplikacja: wersja, healthcheck, DB
|
|
77
|
+
- Wyjście: `infra.yaml`
|
|
78
|
+
|
|
79
|
+
### `plan` — Planowanie migracji
|
|
80
|
+
|
|
81
|
+
Na podstawie `infra.yaml` (stan obecny) i `target.yaml` (cel):
|
|
82
|
+
- Identyfikuje konflikty (porty, stare serwisy)
|
|
83
|
+
- Generuje sekwencję kroków migracji
|
|
84
|
+
- Szacuje ryzyko i downtime
|
|
85
|
+
- Wyjście: `migration-plan.yaml`
|
|
86
|
+
|
|
87
|
+
### `apply` — Wykonanie migracji
|
|
88
|
+
|
|
89
|
+
Wykonuje plan krok po kroku:
|
|
90
|
+
- Dry-run przed wykonaniem
|
|
91
|
+
- Rollback przy błędzie
|
|
92
|
+
- Verify po każdym etapie
|
|
93
|
+
- Log wszystkich operacji
|
|
94
|
+
|
|
95
|
+
## Format plików
|
|
96
|
+
|
|
97
|
+
### `infra.yaml` (wynik detect)
|
|
98
|
+
|
|
99
|
+
```yaml
|
|
100
|
+
host: root@87.106.87.183
|
|
101
|
+
app: c2004
|
|
102
|
+
scanned_at: "2026-04-20T13:00:00"
|
|
103
|
+
runtime:
|
|
104
|
+
docker: "27.0.3"
|
|
105
|
+
k3s: "v1.31.5+k3s1" # lub null
|
|
106
|
+
podman: null
|
|
107
|
+
systemd: "257"
|
|
108
|
+
ports:
|
|
109
|
+
80: { process: docker-proxy, via: traefik }
|
|
110
|
+
443: { process: docker-proxy, via: traefik }
|
|
111
|
+
services:
|
|
112
|
+
docker:
|
|
113
|
+
- name: c2004-backend
|
|
114
|
+
image: c2004-backend
|
|
115
|
+
status: healthy
|
|
116
|
+
version: "1.0.19"
|
|
117
|
+
ports: [8000]
|
|
118
|
+
k3s:
|
|
119
|
+
- namespace: identification
|
|
120
|
+
name: backend
|
|
121
|
+
status: running
|
|
122
|
+
version: "1.0.18"
|
|
123
|
+
conflicts:
|
|
124
|
+
- type: port_steal
|
|
125
|
+
description: "k3s iptables DNAT on :443 before docker-proxy"
|
|
126
|
+
severity: high
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### `target.yaml` (cel migracji)
|
|
130
|
+
|
|
131
|
+
```yaml
|
|
132
|
+
strategy: docker_full
|
|
133
|
+
app: c2004
|
|
134
|
+
compose_files:
|
|
135
|
+
- docker-compose.vps.yml
|
|
136
|
+
env_file: envs/vps.env
|
|
137
|
+
stop_services:
|
|
138
|
+
- k3s
|
|
139
|
+
- nginx
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### `migration-plan.yaml` (wynik plan)
|
|
143
|
+
|
|
144
|
+
```yaml
|
|
145
|
+
from_infra: infra.yaml
|
|
146
|
+
target: target.yaml
|
|
147
|
+
created_at: "2026-04-20T13:05:00"
|
|
148
|
+
risk: medium
|
|
149
|
+
estimated_downtime: "30s"
|
|
150
|
+
steps:
|
|
151
|
+
- id: stop_k3s
|
|
152
|
+
action: systemctl_stop
|
|
153
|
+
service: k3s
|
|
154
|
+
reason: "k3s steals iptables DNAT on :443"
|
|
155
|
+
- id: deploy_docker
|
|
156
|
+
action: docker_compose_up
|
|
157
|
+
compose: docker-compose.vps.yml
|
|
158
|
+
flags: [--build, -d]
|
|
159
|
+
- id: verify
|
|
160
|
+
action: http_check
|
|
161
|
+
url: https://c2004.mask.services/api/v1/health
|
|
162
|
+
expect: "1.0.19"
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
## License
|
|
167
|
+
|
|
168
|
+
Licensed under Apache-2.0.
|
redeploy-0.1.1/README.md
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# redeploy
|
|
2
|
+
|
|
3
|
+
## AI Cost Tracking
|
|
4
|
+
|
|
5
|
+
 
|
|
6
|
+
|
|
7
|
+
This project uses AI-generated code. Total cost: **$0.1500** with **1** AI commits.
|
|
8
|
+
|
|
9
|
+
Generated on 2026-04-20 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/models/openrouter/qwen/qwen3-coder-next)
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
Infrastructure migration toolkit z 3 funkcjami:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
redeploy detect → infra.yaml (co jest teraz)
|
|
19
|
+
redeploy plan → migration-plan.yaml (co zrobić)
|
|
20
|
+
redeploy apply → wykonanie planu (wykonaj migrację)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Instalacja
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install -e .
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Użycie
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# 1. Wykryj aktualną infrastrukturę (lokalną lub zdalną)
|
|
33
|
+
redeploy detect --host root@87.106.87.183 --app c2004 -o infra.yaml
|
|
34
|
+
|
|
35
|
+
# 2. Zaplanuj migrację (np. z k3s → docker_full)
|
|
36
|
+
redeploy plan --infra infra.yaml --target target.yaml -o migration-plan.yaml
|
|
37
|
+
|
|
38
|
+
# 3. Wykonaj plan
|
|
39
|
+
redeploy apply --plan migration-plan.yaml
|
|
40
|
+
|
|
41
|
+
# Lub wszystko naraz (detect → plan → apply)
|
|
42
|
+
redeploy migrate --host root@87.106.87.183 --app c2004 --target target.yaml
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Funkcje
|
|
46
|
+
|
|
47
|
+
### `detect` — Skanowanie infrastruktury
|
|
48
|
+
|
|
49
|
+
Wykrywa aktualny stan:
|
|
50
|
+
- Zdalny host: SSH, Docker, k3s, systemd, Podman, porty
|
|
51
|
+
- Usługi: działające kontenery, pody k8s/k3s, serwisy systemd
|
|
52
|
+
- Sieć: iptables, nginx, Traefik, zajęte porty
|
|
53
|
+
- Aplikacja: wersja, healthcheck, DB
|
|
54
|
+
- Wyjście: `infra.yaml`
|
|
55
|
+
|
|
56
|
+
### `plan` — Planowanie migracji
|
|
57
|
+
|
|
58
|
+
Na podstawie `infra.yaml` (stan obecny) i `target.yaml` (cel):
|
|
59
|
+
- Identyfikuje konflikty (porty, stare serwisy)
|
|
60
|
+
- Generuje sekwencję kroków migracji
|
|
61
|
+
- Szacuje ryzyko i downtime
|
|
62
|
+
- Wyjście: `migration-plan.yaml`
|
|
63
|
+
|
|
64
|
+
### `apply` — Wykonanie migracji
|
|
65
|
+
|
|
66
|
+
Wykonuje plan krok po kroku:
|
|
67
|
+
- Dry-run przed wykonaniem
|
|
68
|
+
- Rollback przy błędzie
|
|
69
|
+
- Verify po każdym etapie
|
|
70
|
+
- Log wszystkich operacji
|
|
71
|
+
|
|
72
|
+
## Format plików
|
|
73
|
+
|
|
74
|
+
### `infra.yaml` (wynik detect)
|
|
75
|
+
|
|
76
|
+
```yaml
|
|
77
|
+
host: root@87.106.87.183
|
|
78
|
+
app: c2004
|
|
79
|
+
scanned_at: "2026-04-20T13:00:00"
|
|
80
|
+
runtime:
|
|
81
|
+
docker: "27.0.3"
|
|
82
|
+
k3s: "v1.31.5+k3s1" # lub null
|
|
83
|
+
podman: null
|
|
84
|
+
systemd: "257"
|
|
85
|
+
ports:
|
|
86
|
+
80: { process: docker-proxy, via: traefik }
|
|
87
|
+
443: { process: docker-proxy, via: traefik }
|
|
88
|
+
services:
|
|
89
|
+
docker:
|
|
90
|
+
- name: c2004-backend
|
|
91
|
+
image: c2004-backend
|
|
92
|
+
status: healthy
|
|
93
|
+
version: "1.0.19"
|
|
94
|
+
ports: [8000]
|
|
95
|
+
k3s:
|
|
96
|
+
- namespace: identification
|
|
97
|
+
name: backend
|
|
98
|
+
status: running
|
|
99
|
+
version: "1.0.18"
|
|
100
|
+
conflicts:
|
|
101
|
+
- type: port_steal
|
|
102
|
+
description: "k3s iptables DNAT on :443 before docker-proxy"
|
|
103
|
+
severity: high
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### `target.yaml` (cel migracji)
|
|
107
|
+
|
|
108
|
+
```yaml
|
|
109
|
+
strategy: docker_full
|
|
110
|
+
app: c2004
|
|
111
|
+
compose_files:
|
|
112
|
+
- docker-compose.vps.yml
|
|
113
|
+
env_file: envs/vps.env
|
|
114
|
+
stop_services:
|
|
115
|
+
- k3s
|
|
116
|
+
- nginx
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### `migration-plan.yaml` (wynik plan)
|
|
120
|
+
|
|
121
|
+
```yaml
|
|
122
|
+
from_infra: infra.yaml
|
|
123
|
+
target: target.yaml
|
|
124
|
+
created_at: "2026-04-20T13:05:00"
|
|
125
|
+
risk: medium
|
|
126
|
+
estimated_downtime: "30s"
|
|
127
|
+
steps:
|
|
128
|
+
- id: stop_k3s
|
|
129
|
+
action: systemctl_stop
|
|
130
|
+
service: k3s
|
|
131
|
+
reason: "k3s steals iptables DNAT on :443"
|
|
132
|
+
- id: deploy_docker
|
|
133
|
+
action: docker_compose_up
|
|
134
|
+
compose: docker-compose.vps.yml
|
|
135
|
+
flags: [--build, -d]
|
|
136
|
+
- id: verify
|
|
137
|
+
action: http_check
|
|
138
|
+
url: https://c2004.mask.services/api/v1/health
|
|
139
|
+
expect: "1.0.19"
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
## License
|
|
144
|
+
|
|
145
|
+
Licensed under Apache-2.0.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "redeploy"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Infrastructure migration toolkit: detect → plan → apply"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"pydantic>=2.0",
|
|
13
|
+
"pyyaml>=6.0",
|
|
14
|
+
"click>=8.0",
|
|
15
|
+
"loguru>=0.7",
|
|
16
|
+
"paramiko>=3.0",
|
|
17
|
+
"httpx>=0.25",
|
|
18
|
+
"rich>=13.0",
|
|
19
|
+
"goal>=2.1.0",
|
|
20
|
+
"costs>=0.1.20",
|
|
21
|
+
"pfix>=0.1.60",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
redeploy = "redeploy.cli:cli"
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = ["pytest>=8.0", "ruff>=0.4",
|
|
29
|
+
"goal>=2.1.0",
|
|
30
|
+
"costs>=0.1.20",
|
|
31
|
+
"pfix>=0.1.60",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[tool.ruff]
|
|
35
|
+
line-length = 100
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.packages.find]
|
|
38
|
+
where = ["."]
|
|
39
|
+
include = ["redeploy*"]
|
|
40
|
+
|
|
41
|
+
[tool.pfix]
|
|
42
|
+
# Self-healing Python configuration
|
|
43
|
+
model = "openrouter/qwen/qwen3-coder-next"
|
|
44
|
+
auto_apply = true
|
|
45
|
+
auto_install_deps = true
|
|
46
|
+
auto_restart = false
|
|
47
|
+
max_retries = 3
|
|
48
|
+
create_backups = false
|
|
49
|
+
git_auto_commit = false
|
|
50
|
+
|
|
51
|
+
[tool.pfix.runtime_todo]
|
|
52
|
+
enabled = true
|
|
53
|
+
todo_file = "TODO.md"
|
|
54
|
+
min_severity = "low"
|
|
55
|
+
deduplicate = true
|
|
56
|
+
|
|
57
|
+
[tool.costs]
|
|
58
|
+
# AI Cost tracking configuration
|
|
59
|
+
badge = true
|
|
60
|
+
update_readme = true
|
|
61
|
+
readme_path = "README.md"
|
|
62
|
+
default_model = "openrouter/qwen/qwen3-coder-next"
|
|
63
|
+
analysis_mode = "byok"
|
|
64
|
+
full_history = true
|
|
65
|
+
max_commits = 500
|
|
66
|
+
|
|
67
|
+
# Cost thresholds for badge colors (USD)
|
|
68
|
+
badge_color_thresholds = { low = 1.0, medium = 5.0, high = 10.0, critical = 50.0 }
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Executor — runs MigrationPlan steps, handles rollback on failure."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import subprocess
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import yaml
|
|
11
|
+
from loguru import logger
|
|
12
|
+
|
|
13
|
+
from ..detect.remote import RemoteProbe
|
|
14
|
+
from ..models import MigrationPlan, MigrationStep, StepAction, StepStatus
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class StepError(Exception):
|
|
18
|
+
def __init__(self, step: MigrationStep, msg: str):
|
|
19
|
+
self.step = step
|
|
20
|
+
super().__init__(f"[{step.id}] {msg}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Executor:
|
|
24
|
+
"""Execute MigrationPlan steps on a remote host."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, plan: MigrationPlan, dry_run: bool = False):
|
|
27
|
+
self.plan = plan
|
|
28
|
+
self.dry_run = dry_run
|
|
29
|
+
self.probe = RemoteProbe(plan.host)
|
|
30
|
+
self._completed: list[MigrationStep] = []
|
|
31
|
+
|
|
32
|
+
def run(self) -> bool:
|
|
33
|
+
"""Execute all steps. Returns True if all passed."""
|
|
34
|
+
prefix = "[DRY RUN] " if self.dry_run else ""
|
|
35
|
+
logger.info(f"{prefix}Applying plan: {len(self.plan.steps)} steps "
|
|
36
|
+
f"({self.plan.from_strategy.value} → {self.plan.to_strategy.value})")
|
|
37
|
+
|
|
38
|
+
for step in self.plan.steps:
|
|
39
|
+
try:
|
|
40
|
+
self._execute_step(step)
|
|
41
|
+
self._completed.append(step)
|
|
42
|
+
except StepError as e:
|
|
43
|
+
logger.error(f"Step failed: {e}")
|
|
44
|
+
step.status = StepStatus.FAILED
|
|
45
|
+
step.error = str(e)
|
|
46
|
+
if not self.dry_run:
|
|
47
|
+
self._rollback()
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
logger.info(f"{'[DRY RUN] ' if self.dry_run else ''}All {len(self.plan.steps)} steps completed")
|
|
51
|
+
return True
|
|
52
|
+
|
|
53
|
+
# ── step dispatcher ───────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
def _execute_step(self, step: MigrationStep) -> None:
|
|
56
|
+
logger.info(f" {'[DRY]' if self.dry_run else '→'} [{step.id}] {step.description}")
|
|
57
|
+
step.status = StepStatus.RUNNING
|
|
58
|
+
|
|
59
|
+
if self.dry_run:
|
|
60
|
+
step.status = StepStatus.DONE
|
|
61
|
+
step.result = "dry-run"
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
dispatch = {
|
|
65
|
+
StepAction.SYSTEMCTL_STOP: self._run_ssh,
|
|
66
|
+
StepAction.SYSTEMCTL_DISABLE: self._run_ssh,
|
|
67
|
+
StepAction.SYSTEMCTL_START: self._run_ssh,
|
|
68
|
+
StepAction.KUBECTL_DELETE: self._run_ssh,
|
|
69
|
+
StepAction.DOCKER_COMPOSE_UP: self._run_ssh,
|
|
70
|
+
StepAction.DOCKER_COMPOSE_DOWN: self._run_ssh,
|
|
71
|
+
StepAction.DOCKER_BUILD: self._run_ssh,
|
|
72
|
+
StepAction.SSH_CMD: self._run_ssh,
|
|
73
|
+
StepAction.SCP: self._run_scp,
|
|
74
|
+
StepAction.RSYNC: self._run_rsync,
|
|
75
|
+
StepAction.HTTP_CHECK: self._run_http_check,
|
|
76
|
+
StepAction.VERSION_CHECK: self._run_version_check,
|
|
77
|
+
StepAction.WAIT: self._run_wait,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
handler = dispatch.get(step.action)
|
|
81
|
+
if not handler:
|
|
82
|
+
raise StepError(step, f"No handler for action {step.action}")
|
|
83
|
+
handler(step)
|
|
84
|
+
|
|
85
|
+
# ── handlers ─────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
def _run_ssh(self, step: MigrationStep) -> None:
|
|
88
|
+
cmd = step.command
|
|
89
|
+
if not cmd:
|
|
90
|
+
raise StepError(step, "No command specified")
|
|
91
|
+
r = self.probe.run(cmd, timeout=300)
|
|
92
|
+
step.result = r.out[:500]
|
|
93
|
+
if not r.ok:
|
|
94
|
+
raise StepError(step, f"exit={r.returncode}: {r.stderr[:200]}")
|
|
95
|
+
step.status = StepStatus.DONE
|
|
96
|
+
|
|
97
|
+
def _run_scp(self, step: MigrationStep) -> None:
|
|
98
|
+
if not step.src or not step.dst:
|
|
99
|
+
raise StepError(step, "scp requires src and dst")
|
|
100
|
+
if self.probe.is_local:
|
|
101
|
+
cmd = ["cp", step.src, step.dst]
|
|
102
|
+
else:
|
|
103
|
+
cmd = ["scp", "-o", "StrictHostKeyChecking=no",
|
|
104
|
+
step.src, f"{self.plan.host}:{step.dst}"]
|
|
105
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
|
106
|
+
if result.returncode != 0:
|
|
107
|
+
raise StepError(step, f"scp failed: {result.stderr[:200]}")
|
|
108
|
+
step.status = StepStatus.DONE
|
|
109
|
+
step.result = "ok"
|
|
110
|
+
|
|
111
|
+
def _run_rsync(self, step: MigrationStep) -> None:
|
|
112
|
+
if not step.src or not step.dst:
|
|
113
|
+
raise StepError(step, "rsync requires src and dst")
|
|
114
|
+
if self.probe.is_local:
|
|
115
|
+
dst = step.dst
|
|
116
|
+
else:
|
|
117
|
+
dst = f"{self.plan.host}:{step.dst}"
|
|
118
|
+
cmd = ["rsync", "-az", "--delete", step.src, dst]
|
|
119
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
|
120
|
+
if result.returncode != 0:
|
|
121
|
+
raise StepError(step, f"rsync failed: {result.stderr[:200]}")
|
|
122
|
+
step.status = StepStatus.DONE
|
|
123
|
+
step.result = "ok"
|
|
124
|
+
|
|
125
|
+
def _run_http_check(self, step: MigrationStep, retries: int = 5, delay: int = 8) -> None:
|
|
126
|
+
if not step.url:
|
|
127
|
+
raise StepError(step, "http_check requires url")
|
|
128
|
+
last_err = ""
|
|
129
|
+
for attempt in range(retries):
|
|
130
|
+
try:
|
|
131
|
+
r = httpx.get(step.url, timeout=10, verify=False, follow_redirects=True)
|
|
132
|
+
body = r.text
|
|
133
|
+
if step.expect and step.expect not in body:
|
|
134
|
+
last_err = f"expected '{step.expect}' not found in response"
|
|
135
|
+
elif r.status_code >= 400:
|
|
136
|
+
last_err = f"HTTP {r.status_code}"
|
|
137
|
+
else:
|
|
138
|
+
step.status = StepStatus.DONE
|
|
139
|
+
step.result = body[:200]
|
|
140
|
+
return
|
|
141
|
+
except Exception as e:
|
|
142
|
+
last_err = str(e)
|
|
143
|
+
logger.debug(f" retry {attempt + 1}/{retries}: {last_err}")
|
|
144
|
+
time.sleep(delay)
|
|
145
|
+
raise StepError(step, f"HTTP check failed after {retries} retries: {last_err}")
|
|
146
|
+
|
|
147
|
+
def _run_version_check(self, step: MigrationStep) -> None:
|
|
148
|
+
if not step.url or not step.expect:
|
|
149
|
+
raise StepError(step, "version_check requires url and expect")
|
|
150
|
+
try:
|
|
151
|
+
r = httpx.get(step.url, timeout=10, verify=False, follow_redirects=True)
|
|
152
|
+
body = r.text
|
|
153
|
+
if step.expect not in body:
|
|
154
|
+
raise StepError(step, f"version '{step.expect}' not found in response: {body[:100]}")
|
|
155
|
+
step.status = StepStatus.DONE
|
|
156
|
+
step.result = f"version {step.expect} confirmed"
|
|
157
|
+
except StepError:
|
|
158
|
+
raise
|
|
159
|
+
except Exception as e:
|
|
160
|
+
raise StepError(step, str(e))
|
|
161
|
+
|
|
162
|
+
def _run_wait(self, step: MigrationStep) -> None:
|
|
163
|
+
if step.seconds > 0:
|
|
164
|
+
logger.debug(f" waiting {step.seconds}s...")
|
|
165
|
+
time.sleep(step.seconds)
|
|
166
|
+
step.status = StepStatus.DONE
|
|
167
|
+
step.result = f"waited {step.seconds}s"
|
|
168
|
+
|
|
169
|
+
# ── rollback ──────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
def _rollback(self) -> None:
|
|
172
|
+
logger.warning("Rolling back completed steps...")
|
|
173
|
+
for step in reversed(self._completed):
|
|
174
|
+
if step.rollback_command:
|
|
175
|
+
logger.info(f" ↩ rollback [{step.id}]: {step.rollback_command}")
|
|
176
|
+
r = self.probe.run(step.rollback_command, timeout=120)
|
|
177
|
+
if not r.ok:
|
|
178
|
+
logger.warning(f" rollback failed: {r.stderr[:100]}")
|
|
179
|
+
|
|
180
|
+
# ── summary ───────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
def summary(self) -> str:
|
|
183
|
+
total = len(self.plan.steps)
|
|
184
|
+
done = sum(1 for s in self.plan.steps if s.status == StepStatus.DONE)
|
|
185
|
+
failed = sum(1 for s in self.plan.steps if s.status == StepStatus.FAILED)
|
|
186
|
+
icon = "✅" if failed == 0 else "❌"
|
|
187
|
+
return f"{icon} {done}/{total} steps completed" + (f", {failed} failed" if failed else "")
|
|
188
|
+
|
|
189
|
+
@staticmethod
|
|
190
|
+
def from_file(plan_path: Path) -> "Executor":
|
|
191
|
+
with plan_path.open() as f:
|
|
192
|
+
raw = yaml.safe_load(f)
|
|
193
|
+
plan = MigrationPlan(**raw)
|
|
194
|
+
return Executor(plan)
|
|
195
|
+
|
|
196
|
+
def save_results(self, output: Path) -> None:
|
|
197
|
+
data = self.plan.model_dump(mode="json")
|
|
198
|
+
output.write_text(yaml.dump(data, default_flow_style=False, allow_unicode=True))
|
|
199
|
+
logger.info(f"Results saved to {output}")
|