redeploy 0.2.9__tar.gz → 0.2.11__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.9 → redeploy-0.2.11}/PKG-INFO +7 -6
- {redeploy-0.2.9 → redeploy-0.2.11}/README.md +5 -5
- {redeploy-0.2.9 → redeploy-0.2.11}/pyproject.toml +2 -1
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/__init__.py +1 -1
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/cli.py +16 -6
- redeploy-0.2.11/redeploy/markpact/__init__.py +14 -0
- redeploy-0.2.11/redeploy/markpact/compiler.py +172 -0
- redeploy-0.2.11/redeploy/markpact/models.py +24 -0
- redeploy-0.2.11/redeploy/markpact/parser.py +72 -0
- redeploy-0.2.11/redeploy/spec_loader.py +47 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_cli.py +98 -0
- redeploy-0.2.11/redeploy/tests/test_markpact_compiler.py +147 -0
- redeploy-0.2.11/redeploy/tests/test_markpact_parser.py +41 -0
- redeploy-0.2.11/redeploy/tests/test_spec_loader.py +105 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy.egg-info/PKG-INFO +7 -6
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy.egg-info/SOURCES.txt +8 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy.egg-info/requires.txt +1 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/apply/__init__.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/apply/executor.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/data_sync.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/detect/__init__.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/detect/detector.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/detect/probes.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/detect/remote.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/detect/templates.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/detect/workflow.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/discovery.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/dsl/__init__.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/dsl/loader.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/dsl/parser.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/dsl_python/__init__.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/dsl_python/context.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/dsl_python/decorators.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/dsl_python/docker_steps.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/dsl_python/exceptions.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/dsl_python/runner.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/dsl_python/steps.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/fleet.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/iac/__init__.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/iac/base.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/iac/docker_compose.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/iac/parsers/__init__.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/iac/parsers/compose.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/iac/registry.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/models.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/observe.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/parse.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/patterns.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/plan/__init__.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/plan/planner.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/plugins/__init__.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/plugins/builtin/__init__.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/plugins/builtin/browser_reload.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/plugins/builtin/notify.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/plugins/builtin/systemd_reload.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/ssh.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/steps.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/__init__.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_data_sync.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_discovery.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_dsl.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_examples.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_executor.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_fleet.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_git_integration.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_iac.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_manifest.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_models.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_observe.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_parse.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_patterns.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_planner_edge.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_plugins.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_probes.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_public_api.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_ssh.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_templates.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_verify.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_version.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_version_changelog.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_version_cli.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_version_manifest.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_version_sources.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_workflow.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/verify.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/version/__init__.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/version/bump.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/version/changelog.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/version/commits.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/version/diff.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/version/git_integration.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/version/git_transaction.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/version/manifest.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/version/sources/__init__.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/version/sources/base.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/version/sources/json_.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/version/sources/plain.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/version/sources/regex.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/version/sources/toml_.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/version/sources/yaml_.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/version/transaction.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/version.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy.egg-info/dependency_links.txt +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy.egg-info/entry_points.txt +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/redeploy.egg-info/top_level.txt +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/setup.cfg +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/tests/test_iac.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/tests/test_parse.py +0 -0
- {redeploy-0.2.9 → redeploy-0.2.11}/tests/test_redeploy.py +0 -0
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: redeploy
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.11
|
|
4
4
|
Summary: Infrastructure migration toolkit: detect → plan → apply
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
7
7
|
Requires-Dist: pydantic>=2.0
|
|
8
8
|
Requires-Dist: pyyaml>=6.0
|
|
9
|
+
Requires-Dist: markdown-it-py>=3.0
|
|
9
10
|
Requires-Dist: click>=8.0
|
|
10
11
|
Requires-Dist: loguru>=0.7
|
|
11
12
|
Requires-Dist: paramiko>=3.0
|
|
@@ -26,17 +27,17 @@ Requires-Dist: pfix>=0.1.60; extra == "dev"
|
|
|
26
27
|
|
|
27
28
|
## AI Cost Tracking
|
|
28
29
|
|
|
29
|
-
    
|
|
31
|
+
  
|
|
31
32
|
|
|
32
|
-
- 🤖 **LLM usage:** $4.
|
|
33
|
-
- 👤 **Human dev:** ~$
|
|
33
|
+
- 🤖 **LLM usage:** $4.5000 (30 commits)
|
|
34
|
+
- 👤 **Human dev:** ~$898 (9.0h @ $100/h, 30min dedup)
|
|
34
35
|
|
|
35
36
|
Generated on 2026-04-20 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
|
|
36
37
|
|
|
37
38
|
---
|
|
38
39
|
|
|
39
|
-
    
|
|
40
41
|
|
|
41
42
|
Infrastructure migration and device deploy toolkit — VPS, Raspberry Pi kiosk, Podman Quadlet, k3s.
|
|
42
43
|
|
|
@@ -3,17 +3,17 @@
|
|
|
3
3
|
|
|
4
4
|
## AI Cost Tracking
|
|
5
5
|
|
|
6
|
-
    
|
|
7
|
+
  
|
|
8
8
|
|
|
9
|
-
- 🤖 **LLM usage:** $4.
|
|
10
|
-
- 👤 **Human dev:** ~$
|
|
9
|
+
- 🤖 **LLM usage:** $4.5000 (30 commits)
|
|
10
|
+
- 👤 **Human dev:** ~$898 (9.0h @ $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
|
-
    
|
|
17
17
|
|
|
18
18
|
Infrastructure migration and device deploy toolkit — VPS, Raspberry Pi kiosk, Podman Quadlet, k3s.
|
|
19
19
|
|
|
@@ -4,13 +4,14 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "redeploy"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.11"
|
|
8
8
|
description = "Infrastructure migration toolkit: detect → plan → apply"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
11
11
|
dependencies = [
|
|
12
12
|
"pydantic>=2.0",
|
|
13
13
|
"pyyaml>=6.0",
|
|
14
|
+
"markdown-it-py>=3.0",
|
|
14
15
|
"click>=8.0",
|
|
15
16
|
"loguru>=0.7",
|
|
16
17
|
"paramiko>=3.0",
|
|
@@ -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.11"
|
|
11
11
|
|
|
12
12
|
# ── Core models ───────────────────────────────────────────────────────────────
|
|
13
13
|
from .models import ( # noqa: F401
|
|
@@ -853,7 +853,7 @@ def run(ctx, spec_file, dry_run, plan_only, do_detect, plan_out, output, env_nam
|
|
|
853
853
|
redeploy run migration.yaml --detect --plan-out plan.yaml
|
|
854
854
|
"""
|
|
855
855
|
from rich.console import Console
|
|
856
|
-
from .models import
|
|
856
|
+
from .models import ProjectManifest
|
|
857
857
|
from .plan import Planner
|
|
858
858
|
|
|
859
859
|
console = Console()
|
|
@@ -868,7 +868,7 @@ def run(ctx, spec_file, dry_run, plan_only, do_detect, plan_out, output, env_nam
|
|
|
868
868
|
console.print("[dim] Create one with: redeploy init[/dim]")
|
|
869
869
|
sys.exit(1)
|
|
870
870
|
|
|
871
|
-
spec =
|
|
871
|
+
spec = _load_spec_or_exit(console, resolved_spec)
|
|
872
872
|
|
|
873
873
|
# overlay manifest values — env-specific or global
|
|
874
874
|
if manifest:
|
|
@@ -937,6 +937,16 @@ def _find_manifest_path() -> str:
|
|
|
937
937
|
return "redeploy.yaml"
|
|
938
938
|
|
|
939
939
|
|
|
940
|
+
def _load_spec_or_exit(console, path: str | Path):
|
|
941
|
+
from .spec_loader import SpecLoaderError, load_migration_spec
|
|
942
|
+
|
|
943
|
+
try:
|
|
944
|
+
return load_migration_spec(path)
|
|
945
|
+
except SpecLoaderError as exc:
|
|
946
|
+
console.print(f"[red]✗ {exc}[/red]")
|
|
947
|
+
sys.exit(1)
|
|
948
|
+
|
|
949
|
+
|
|
940
950
|
def _resolve_device(console, device_id: str) -> tuple:
|
|
941
951
|
"""Resolve device from registry or auto-probe. Returns (device, registry) or (None, None)."""
|
|
942
952
|
from .discovery import auto_probe
|
|
@@ -964,7 +974,7 @@ def _resolve_device(console, device_id: str) -> tuple:
|
|
|
964
974
|
|
|
965
975
|
def _load_spec_with_manifest(console, spec_file: str | None, dev) -> "MigrationSpec":
|
|
966
976
|
"""Load spec and apply manifest/device overlays."""
|
|
967
|
-
from .models import
|
|
977
|
+
from .models import ProjectManifest
|
|
968
978
|
|
|
969
979
|
manifest = ProjectManifest.find_and_load(Path.cwd())
|
|
970
980
|
resolved_spec = spec_file or (manifest.spec if manifest else "migration.yaml")
|
|
@@ -972,7 +982,7 @@ def _load_spec_with_manifest(console, spec_file: str | None, dev) -> "MigrationS
|
|
|
972
982
|
console.print(f"[red]✗ spec not found: {resolved_spec}[/red]")
|
|
973
983
|
sys.exit(1)
|
|
974
984
|
|
|
975
|
-
spec =
|
|
985
|
+
spec = _load_spec_or_exit(console, resolved_spec)
|
|
976
986
|
if manifest:
|
|
977
987
|
manifest.apply_to_spec(spec)
|
|
978
988
|
|
|
@@ -1145,7 +1155,7 @@ def status(spec_file):
|
|
|
1145
1155
|
"""
|
|
1146
1156
|
from rich.console import Console
|
|
1147
1157
|
from rich.table import Table
|
|
1148
|
-
from .models import
|
|
1158
|
+
from .models import ProjectManifest
|
|
1149
1159
|
|
|
1150
1160
|
console = Console()
|
|
1151
1161
|
|
|
@@ -1163,7 +1173,7 @@ def status(spec_file):
|
|
|
1163
1173
|
resolved = spec_file or (manifest.spec if manifest else "migration.yaml")
|
|
1164
1174
|
spec_path = Path(resolved)
|
|
1165
1175
|
if spec_path.exists():
|
|
1166
|
-
spec =
|
|
1176
|
+
spec = _load_spec_or_exit(console, spec_path)
|
|
1167
1177
|
if manifest:
|
|
1168
1178
|
manifest.apply_to_spec(spec)
|
|
1169
1179
|
console.print(f"\n[bold]{spec_path}[/bold] [dim]{spec.name}[/dim]")
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from .compiler import MarkpactCompileError, compile_markpact_document, compile_markpact_document_to_data
|
|
2
|
+
from .models import MarkpactBlock, MarkpactDocument
|
|
3
|
+
from .parser import MarkpactParseError, parse_markpact_file, parse_markpact_text
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"MarkpactBlock",
|
|
7
|
+
"MarkpactCompileError",
|
|
8
|
+
"MarkpactDocument",
|
|
9
|
+
"MarkpactParseError",
|
|
10
|
+
"compile_markpact_document",
|
|
11
|
+
"compile_markpact_document_to_data",
|
|
12
|
+
"parse_markpact_file",
|
|
13
|
+
"parse_markpact_text",
|
|
14
|
+
]
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import tomllib
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
from pydantic import ValidationError
|
|
10
|
+
|
|
11
|
+
from ..models import InfraSpec, MigrationSpec, MigrationStep
|
|
12
|
+
from ..steps import StepLibrary
|
|
13
|
+
from .models import MarkpactBlock, MarkpactDocument
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MarkpactCompileError(ValueError):
|
|
17
|
+
"""Raised when a markpact document cannot be compiled to MigrationSpec."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_SUPPORTED_BLOCK_KINDS = {"config", "steps"}
|
|
21
|
+
_SUPPORTED_FORMATS = {"yaml", "toml", "json"}
|
|
22
|
+
_ALLOWED_SPEC_KEYS = set(MigrationSpec.model_fields)
|
|
23
|
+
_ALLOWED_INFRA_KEYS = set(InfraSpec.model_fields)
|
|
24
|
+
_ALLOWED_STEP_KEYS = set(MigrationStep.model_fields) | {"insert_before"}
|
|
25
|
+
_COMMON_CONFIG_WRAPPERS = {"deployment", "migration", "spec"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def compile_markpact_document(document: MarkpactDocument) -> MigrationSpec:
|
|
29
|
+
data = compile_markpact_document_to_data(document)
|
|
30
|
+
try:
|
|
31
|
+
return MigrationSpec(**data)
|
|
32
|
+
except ValidationError as exc:
|
|
33
|
+
raise MarkpactCompileError(
|
|
34
|
+
f"{document.path}: compiled markpact document does not match MigrationSpec: {exc}"
|
|
35
|
+
) from exc
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def compile_markpact_document_to_data(document: MarkpactDocument) -> dict[str, Any]:
|
|
39
|
+
compiled: dict[str, Any] = {}
|
|
40
|
+
saw_config = False
|
|
41
|
+
|
|
42
|
+
for block in document.blocks:
|
|
43
|
+
if block.kind not in _SUPPORTED_BLOCK_KINDS:
|
|
44
|
+
raise MarkpactCompileError(
|
|
45
|
+
f"{document.path}:{block.start_line}: unsupported block kind 'markpact:{block.kind}'. "
|
|
46
|
+
"Phase 1 supports only markpact:config and markpact:steps."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
payload = _load_block_payload(document.path, block)
|
|
50
|
+
if block.kind == "config":
|
|
51
|
+
saw_config = True
|
|
52
|
+
config_payload = _normalize_config_payload(payload, document.path, block)
|
|
53
|
+
_deep_merge(compiled, config_payload)
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
steps = _extract_steps(payload, document.path, block)
|
|
57
|
+
compiled.setdefault("extra_steps", [])
|
|
58
|
+
compiled["extra_steps"].extend(steps)
|
|
59
|
+
|
|
60
|
+
if not saw_config:
|
|
61
|
+
raise MarkpactCompileError(
|
|
62
|
+
f"{document.path}: at least one markpact:config block is required."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
return compiled
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _load_block_payload(path: Path, block: MarkpactBlock) -> Any:
|
|
69
|
+
format_name = (block.format or "yaml").lower()
|
|
70
|
+
if format_name not in _SUPPORTED_FORMATS:
|
|
71
|
+
raise MarkpactCompileError(
|
|
72
|
+
f"{path}:{block.start_line}: unsupported payload format '{format_name}' for {block.label}."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
content = block.content.strip()
|
|
76
|
+
if not content:
|
|
77
|
+
return {}
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
if format_name == "yaml":
|
|
81
|
+
return yaml.safe_load(content) or {}
|
|
82
|
+
if format_name == "toml":
|
|
83
|
+
return tomllib.loads(content)
|
|
84
|
+
return json.loads(content)
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
raise MarkpactCompileError(
|
|
87
|
+
f"{path}:{block.start_line}: failed to parse {block.label}: {exc}"
|
|
88
|
+
) from exc
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _normalize_config_payload(payload: Any, path: Path, block: MarkpactBlock) -> dict[str, Any]:
|
|
92
|
+
if isinstance(payload, dict) and len(payload) == 1:
|
|
93
|
+
key = next(iter(payload))
|
|
94
|
+
if key in _COMMON_CONFIG_WRAPPERS and isinstance(payload[key], dict):
|
|
95
|
+
payload = payload[key]
|
|
96
|
+
|
|
97
|
+
if not isinstance(payload, dict):
|
|
98
|
+
raise MarkpactCompileError(
|
|
99
|
+
f"{path}:{block.start_line}: {block.label} must compile to a mapping."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
unknown_top = sorted(set(payload) - _ALLOWED_SPEC_KEYS)
|
|
103
|
+
if unknown_top:
|
|
104
|
+
raise MarkpactCompileError(
|
|
105
|
+
f"{path}:{block.start_line}: unsupported config keys: {', '.join(unknown_top)}"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
for section in ("source", "target"):
|
|
109
|
+
if section not in payload:
|
|
110
|
+
continue
|
|
111
|
+
if not isinstance(payload[section], dict):
|
|
112
|
+
raise MarkpactCompileError(
|
|
113
|
+
f"{path}:{block.start_line}: '{section}' must be a mapping."
|
|
114
|
+
)
|
|
115
|
+
unknown_nested = sorted(set(payload[section]) - _ALLOWED_INFRA_KEYS)
|
|
116
|
+
if unknown_nested:
|
|
117
|
+
raise MarkpactCompileError(
|
|
118
|
+
f"{path}:{block.start_line}: unsupported {section} keys: {', '.join(unknown_nested)}"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return payload
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _extract_steps(payload: Any, path: Path, block: MarkpactBlock) -> list[dict[str, Any]]:
|
|
125
|
+
if isinstance(payload, list):
|
|
126
|
+
steps = payload
|
|
127
|
+
elif isinstance(payload, dict) and "extra_steps" in payload:
|
|
128
|
+
steps = payload["extra_steps"]
|
|
129
|
+
else:
|
|
130
|
+
raise MarkpactCompileError(
|
|
131
|
+
f"{path}:{block.start_line}: {block.label} must define a list of steps or an extra_steps list."
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if not isinstance(steps, list):
|
|
135
|
+
raise MarkpactCompileError(
|
|
136
|
+
f"{path}:{block.start_line}: extra_steps must be a list."
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
validated: list[dict[str, Any]] = []
|
|
140
|
+
for index, raw in enumerate(steps, start=1):
|
|
141
|
+
if not isinstance(raw, dict):
|
|
142
|
+
raise MarkpactCompileError(
|
|
143
|
+
f"{path}:{block.start_line}: step #{index} in {block.label} must be a mapping."
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
unknown_keys = sorted(set(raw) - _ALLOWED_STEP_KEYS)
|
|
147
|
+
if unknown_keys:
|
|
148
|
+
raise MarkpactCompileError(
|
|
149
|
+
f"{path}:{block.start_line}: unsupported step keys in '{raw.get('id', f'#{index}')}'"
|
|
150
|
+
f": {', '.join(unknown_keys)}"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
raw_for_validation = dict(raw)
|
|
154
|
+
raw_for_validation.pop("insert_before", None)
|
|
155
|
+
try:
|
|
156
|
+
StepLibrary.resolve_from_spec(raw_for_validation)
|
|
157
|
+
except Exception as exc:
|
|
158
|
+
raise MarkpactCompileError(
|
|
159
|
+
f"{path}:{block.start_line}: step '{raw.get('id', f'#{index}')}' is not supported by the current runtime: {exc}"
|
|
160
|
+
) from exc
|
|
161
|
+
|
|
162
|
+
validated.append(dict(raw))
|
|
163
|
+
|
|
164
|
+
return validated
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _deep_merge(target: dict[str, Any], incoming: dict[str, Any]) -> None:
|
|
168
|
+
for key, value in incoming.items():
|
|
169
|
+
if key in target and isinstance(target[key], dict) and isinstance(value, dict):
|
|
170
|
+
_deep_merge(target[key], value)
|
|
171
|
+
continue
|
|
172
|
+
target[key] = value
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class MarkpactBlock:
|
|
9
|
+
kind: str
|
|
10
|
+
format: str | None
|
|
11
|
+
content: str
|
|
12
|
+
start_line: int
|
|
13
|
+
end_line: int
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def label(self) -> str:
|
|
17
|
+
fmt = self.format or "yaml"
|
|
18
|
+
return f"markpact:{self.kind} {fmt}".strip()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class MarkpactDocument:
|
|
23
|
+
path: Path
|
|
24
|
+
blocks: list[MarkpactBlock] = field(default_factory=list)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from markdown_it import MarkdownIt
|
|
6
|
+
|
|
7
|
+
from .models import MarkpactBlock, MarkpactDocument
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MarkpactParseError(ValueError):
|
|
11
|
+
"""Raised when a markdown markpact document cannot be parsed."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_markpact_file(path: str | Path) -> MarkpactDocument:
|
|
15
|
+
file_path = Path(path)
|
|
16
|
+
return parse_markpact_text(file_path.read_text(encoding="utf-8"), path=file_path)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_markpact_text(text: str, *, path: str | Path = "migration.md") -> MarkpactDocument:
|
|
20
|
+
source_path = Path(path)
|
|
21
|
+
parser = MarkdownIt("commonmark")
|
|
22
|
+
tokens = parser.parse(text)
|
|
23
|
+
blocks: list[MarkpactBlock] = []
|
|
24
|
+
|
|
25
|
+
for token in tokens:
|
|
26
|
+
if token.type != "fence":
|
|
27
|
+
continue
|
|
28
|
+
parsed = _parse_markpact_fence_info(token.info)
|
|
29
|
+
if parsed is None:
|
|
30
|
+
continue
|
|
31
|
+
|
|
32
|
+
kind, format_name = parsed
|
|
33
|
+
start_line = (token.map[0] + 1) if token.map else 1
|
|
34
|
+
end_line = token.map[1] if token.map else start_line
|
|
35
|
+
blocks.append(MarkpactBlock(
|
|
36
|
+
kind=kind,
|
|
37
|
+
format=format_name,
|
|
38
|
+
content=token.content,
|
|
39
|
+
start_line=start_line,
|
|
40
|
+
end_line=end_line,
|
|
41
|
+
))
|
|
42
|
+
|
|
43
|
+
if not blocks:
|
|
44
|
+
raise MarkpactParseError(
|
|
45
|
+
f"No markpact blocks found in {source_path}. Expected fenced blocks like ```markpact:config yaml."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
return MarkpactDocument(path=source_path, blocks=blocks)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parse_markpact_fence_info(info: str) -> tuple[str, str | None] | None:
|
|
52
|
+
tokens = [part for part in info.strip().split() if part]
|
|
53
|
+
if not tokens:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
kind: str | None = None
|
|
57
|
+
format_name: str | None = None
|
|
58
|
+
|
|
59
|
+
for token in tokens:
|
|
60
|
+
if token.startswith("markpact:"):
|
|
61
|
+
kind = token.split(":", 1)[1].strip().lower()
|
|
62
|
+
continue
|
|
63
|
+
if format_name is None and "=" not in token:
|
|
64
|
+
format_name = token.lower()
|
|
65
|
+
|
|
66
|
+
if not kind:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
if format_name is None and kind in {"config", "steps", "rollback"}:
|
|
70
|
+
format_name = "yaml"
|
|
71
|
+
|
|
72
|
+
return kind, format_name
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Load deployment specs from supported file formats."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .markpact import (
|
|
7
|
+
MarkpactCompileError,
|
|
8
|
+
MarkpactParseError,
|
|
9
|
+
compile_markpact_document,
|
|
10
|
+
parse_markpact_file,
|
|
11
|
+
)
|
|
12
|
+
from .models import MigrationSpec
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SpecLoaderError(ValueError):
|
|
16
|
+
"""Base error raised when a deployment spec cannot be loaded."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class UnsupportedSpecFormatError(SpecLoaderError):
|
|
20
|
+
"""Raised when the spec file uses an unsupported format."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_migration_spec(path: str | Path) -> MigrationSpec:
|
|
24
|
+
"""Load a deployment spec from disk.
|
|
25
|
+
|
|
26
|
+
Supported today:
|
|
27
|
+
- `.yaml`
|
|
28
|
+
- `.yml`
|
|
29
|
+
|
|
30
|
+
Supported markdown subset today:
|
|
31
|
+
- `.md` with markpact:config and markpact:steps blocks only
|
|
32
|
+
"""
|
|
33
|
+
spec_path = Path(path)
|
|
34
|
+
suffix = spec_path.suffix.lower()
|
|
35
|
+
|
|
36
|
+
if suffix in {"", ".yaml", ".yml"}:
|
|
37
|
+
return MigrationSpec.from_file(spec_path)
|
|
38
|
+
|
|
39
|
+
if suffix == ".md":
|
|
40
|
+
try:
|
|
41
|
+
return compile_markpact_document(parse_markpact_file(spec_path))
|
|
42
|
+
except (MarkpactParseError, MarkpactCompileError) as exc:
|
|
43
|
+
raise SpecLoaderError(str(exc)) from exc
|
|
44
|
+
|
|
45
|
+
raise UnsupportedSpecFormatError(
|
|
46
|
+
f"Unsupported spec format '{suffix}': use YAML (.yaml or .yml)."
|
|
47
|
+
)
|
|
@@ -32,6 +32,64 @@ def _migration_yaml(strategy="docker_full", host="local") -> str:
|
|
|
32
32
|
""")
|
|
33
33
|
|
|
34
34
|
|
|
35
|
+
def _migration_markdown_supported() -> str:
|
|
36
|
+
return textwrap.dedent(
|
|
37
|
+
"""\
|
|
38
|
+
# Markdown migration
|
|
39
|
+
|
|
40
|
+
```markpact:config yaml
|
|
41
|
+
name: markdown migration
|
|
42
|
+
description: migration loaded from markpact markdown
|
|
43
|
+
source:
|
|
44
|
+
strategy: docker_full
|
|
45
|
+
host: local
|
|
46
|
+
app: myapp
|
|
47
|
+
version: "1.0.0"
|
|
48
|
+
target:
|
|
49
|
+
strategy: docker_full
|
|
50
|
+
host: local
|
|
51
|
+
app: myapp
|
|
52
|
+
version: "1.0.1"
|
|
53
|
+
remote_dir: ~/myapp
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```markpact:steps yaml
|
|
57
|
+
extra_steps:
|
|
58
|
+
- id: sync_env
|
|
59
|
+
src: .env
|
|
60
|
+
dst: ~/myapp/.env
|
|
61
|
+
```
|
|
62
|
+
"""
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _migration_markdown_with_unsupported_block() -> str:
|
|
67
|
+
return textwrap.dedent(
|
|
68
|
+
"""\
|
|
69
|
+
# Markdown migration
|
|
70
|
+
|
|
71
|
+
```markpact:config yaml
|
|
72
|
+
name: markdown migration
|
|
73
|
+
source:
|
|
74
|
+
strategy: docker_full
|
|
75
|
+
host: local
|
|
76
|
+
app: myapp
|
|
77
|
+
version: "1.0.0"
|
|
78
|
+
target:
|
|
79
|
+
strategy: docker_full
|
|
80
|
+
host: local
|
|
81
|
+
app: myapp
|
|
82
|
+
version: "1.0.1"
|
|
83
|
+
remote_dir: ~/myapp
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```markpact:python
|
|
87
|
+
print("unsupported in phase 1")
|
|
88
|
+
```
|
|
89
|
+
"""
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
35
93
|
def _runner():
|
|
36
94
|
return CliRunner(mix_stderr=False)
|
|
37
95
|
|
|
@@ -101,6 +159,24 @@ class TestRunPlanOnly:
|
|
|
101
159
|
result = _runner().invoke(cli, ["run", str(spec), "--plan-only", "--env", "prod"])
|
|
102
160
|
assert result.exit_code == 0
|
|
103
161
|
|
|
162
|
+
def test_run_plan_only_supports_markdown_subset(self, tmp_path):
|
|
163
|
+
spec = tmp_path / "migration.md"
|
|
164
|
+
spec.write_text(_migration_markdown_supported(), encoding="utf-8")
|
|
165
|
+
|
|
166
|
+
result = _runner().invoke(cli, ["run", str(spec), "--plan-only"])
|
|
167
|
+
|
|
168
|
+
assert result.exit_code == 0, result.output
|
|
169
|
+
assert "markdown migration" in result.output
|
|
170
|
+
|
|
171
|
+
def test_run_plan_only_rejects_unsupported_markdown_block(self, tmp_path):
|
|
172
|
+
spec = tmp_path / "migration.md"
|
|
173
|
+
spec.write_text(_migration_markdown_with_unsupported_block(), encoding="utf-8")
|
|
174
|
+
|
|
175
|
+
result = _runner().invoke(cli, ["run", str(spec), "--plan-only"])
|
|
176
|
+
|
|
177
|
+
assert result.exit_code == 1
|
|
178
|
+
assert "unsupported block kind 'markpact:python'" in result.output
|
|
179
|
+
|
|
104
180
|
|
|
105
181
|
# ── redeploy status ───────────────────────────────────────────────────────────
|
|
106
182
|
|
|
@@ -123,6 +199,16 @@ class TestStatus:
|
|
|
123
199
|
assert result.exit_code == 0
|
|
124
200
|
assert "myapp" in result.output
|
|
125
201
|
|
|
202
|
+
def test_status_supports_markdown_subset(self, tmp_path):
|
|
203
|
+
spec = tmp_path / "migration.md"
|
|
204
|
+
spec.write_text(_migration_markdown_supported(), encoding="utf-8")
|
|
205
|
+
|
|
206
|
+
with patch("redeploy.models.ProjectManifest.find_and_load", return_value=None):
|
|
207
|
+
result = _runner().invoke(cli, ["status", str(spec)])
|
|
208
|
+
|
|
209
|
+
assert result.exit_code == 0, result.output
|
|
210
|
+
assert "markdown migration" in result.output
|
|
211
|
+
|
|
126
212
|
|
|
127
213
|
# ── redeploy init ─────────────────────────────────────────────────────────────
|
|
128
214
|
|
|
@@ -192,6 +278,18 @@ class TestDevices:
|
|
|
192
278
|
assert data[0]["id"] == "x@1"
|
|
193
279
|
|
|
194
280
|
|
|
281
|
+
class TestTarget:
|
|
282
|
+
def test_target_supports_markdown_subset(self, tmp_path):
|
|
283
|
+
spec = tmp_path / "migration.md"
|
|
284
|
+
spec.write_text(_migration_markdown_supported(), encoding="utf-8")
|
|
285
|
+
|
|
286
|
+
with patch("redeploy.cli._resolve_device", return_value=(None, None)):
|
|
287
|
+
result = _runner().invoke(cli, ["target", "pi@192.168.1.42", str(spec), "--plan-only"])
|
|
288
|
+
|
|
289
|
+
assert result.exit_code == 0, result.output
|
|
290
|
+
assert "target" in result.output.lower()
|
|
291
|
+
|
|
292
|
+
|
|
195
293
|
# ── redeploy apply ────────────────────────────────────────────────────────────
|
|
196
294
|
|
|
197
295
|
|