redeploy 0.2.10__tar.gz → 0.2.12__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.10 → redeploy-0.2.12}/PKG-INFO +7 -6
- {redeploy-0.2.10 → redeploy-0.2.12}/README.md +5 -5
- {redeploy-0.2.10 → redeploy-0.2.12}/pyproject.toml +2 -1
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/__init__.py +1 -1
- redeploy-0.2.12/redeploy/markpact/__init__.py +14 -0
- redeploy-0.2.12/redeploy/markpact/compiler.py +172 -0
- redeploy-0.2.12/redeploy/markpact/models.py +24 -0
- redeploy-0.2.12/redeploy/markpact/parser.py +72 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/spec_loader.py +12 -6
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_cli.py +78 -14
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_examples.py +21 -1
- redeploy-0.2.12/redeploy/tests/test_markpact_compiler.py +147 -0
- redeploy-0.2.12/redeploy/tests/test_markpact_parser.py +41 -0
- redeploy-0.2.12/redeploy/tests/test_spec_loader.py +105 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy.egg-info/PKG-INFO +7 -6
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy.egg-info/SOURCES.txt +6 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy.egg-info/requires.txt +1 -0
- redeploy-0.2.10/redeploy/tests/test_spec_loader.py +0 -44
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/apply/__init__.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/apply/executor.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/cli.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/data_sync.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/detect/__init__.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/detect/detector.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/detect/probes.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/detect/remote.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/detect/templates.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/detect/workflow.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/discovery.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/dsl/__init__.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/dsl/loader.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/dsl/parser.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/dsl_python/__init__.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/dsl_python/context.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/dsl_python/decorators.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/dsl_python/docker_steps.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/dsl_python/exceptions.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/dsl_python/runner.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/dsl_python/steps.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/fleet.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/iac/__init__.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/iac/base.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/iac/docker_compose.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/iac/parsers/__init__.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/iac/parsers/compose.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/iac/registry.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/models.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/observe.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/parse.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/patterns.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/plan/__init__.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/plan/planner.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/plugins/__init__.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/plugins/builtin/__init__.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/plugins/builtin/browser_reload.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/plugins/builtin/notify.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/plugins/builtin/systemd_reload.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/ssh.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/steps.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/__init__.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_data_sync.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_discovery.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_dsl.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_executor.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_fleet.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_git_integration.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_iac.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_manifest.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_models.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_observe.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_parse.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_patterns.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_planner_edge.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_plugins.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_probes.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_public_api.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_ssh.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_templates.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_verify.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_version.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_version_changelog.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_version_cli.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_version_manifest.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_version_sources.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_workflow.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/verify.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/version/__init__.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/version/bump.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/version/changelog.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/version/commits.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/version/diff.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/version/git_integration.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/version/git_transaction.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/version/manifest.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/version/sources/__init__.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/version/sources/base.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/version/sources/json_.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/version/sources/plain.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/version/sources/regex.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/version/sources/toml_.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/version/sources/yaml_.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/version/transaction.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/version.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy.egg-info/dependency_links.txt +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy.egg-info/entry_points.txt +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/redeploy.egg-info/top_level.txt +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/setup.cfg +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/tests/test_iac.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/tests/test_parse.py +0 -0
- {redeploy-0.2.10 → redeploy-0.2.12}/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.12
|
|
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.6500 (31 commits)
|
|
34
|
+
- 👤 **Human dev:** ~$917 (9.2h @ $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.6500 (31 commits)
|
|
10
|
+
- 👤 **Human dev:** ~$917 (9.2h @ $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.12"
|
|
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.12"
|
|
11
11
|
|
|
12
12
|
# ── Core models ───────────────────────────────────────────────────────────────
|
|
13
13
|
from .models import ( # noqa: F401
|
|
@@ -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
|
|
@@ -3,6 +3,12 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
|
|
6
|
+
from .markpact import (
|
|
7
|
+
MarkpactCompileError,
|
|
8
|
+
MarkpactParseError,
|
|
9
|
+
compile_markpact_document,
|
|
10
|
+
parse_markpact_file,
|
|
11
|
+
)
|
|
6
12
|
from .models import MigrationSpec
|
|
7
13
|
|
|
8
14
|
|
|
@@ -21,8 +27,8 @@ def load_migration_spec(path: str | Path) -> MigrationSpec:
|
|
|
21
27
|
- `.yaml`
|
|
22
28
|
- `.yml`
|
|
23
29
|
|
|
24
|
-
|
|
25
|
-
- `.md` markpact
|
|
30
|
+
Supported markdown subset today:
|
|
31
|
+
- `.md` with markpact:config and markpact:steps blocks only
|
|
26
32
|
"""
|
|
27
33
|
spec_path = Path(path)
|
|
28
34
|
suffix = spec_path.suffix.lower()
|
|
@@ -31,10 +37,10 @@ def load_migration_spec(path: str | Path) -> MigrationSpec:
|
|
|
31
37
|
return MigrationSpec.from_file(spec_path)
|
|
32
38
|
|
|
33
39
|
if suffix == ".md":
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
38
44
|
|
|
39
45
|
raise UnsupportedSpecFormatError(
|
|
40
46
|
f"Unsupported spec format '{suffix}': use YAML (.yaml or .yml)."
|
|
@@ -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,15 +159,23 @@ class TestRunPlanOnly:
|
|
|
101
159
|
result = _runner().invoke(cli, ["run", str(spec), "--plan-only", "--env", "prod"])
|
|
102
160
|
assert result.exit_code == 0
|
|
103
161
|
|
|
104
|
-
def
|
|
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):
|
|
105
172
|
spec = tmp_path / "migration.md"
|
|
106
|
-
spec.write_text(
|
|
173
|
+
spec.write_text(_migration_markdown_with_unsupported_block(), encoding="utf-8")
|
|
107
174
|
|
|
108
175
|
result = _runner().invoke(cli, ["run", str(spec), "--plan-only"])
|
|
109
176
|
|
|
110
177
|
assert result.exit_code == 1
|
|
111
|
-
assert "
|
|
112
|
-
assert "markdown/markpact specs are not implemented" in result.output
|
|
178
|
+
assert "unsupported block kind 'markpact:python'" in result.output
|
|
113
179
|
|
|
114
180
|
|
|
115
181
|
# ── redeploy status ───────────────────────────────────────────────────────────
|
|
@@ -133,16 +199,15 @@ class TestStatus:
|
|
|
133
199
|
assert result.exit_code == 0
|
|
134
200
|
assert "myapp" in result.output
|
|
135
201
|
|
|
136
|
-
def
|
|
202
|
+
def test_status_supports_markdown_subset(self, tmp_path):
|
|
137
203
|
spec = tmp_path / "migration.md"
|
|
138
|
-
spec.write_text(
|
|
204
|
+
spec.write_text(_migration_markdown_supported(), encoding="utf-8")
|
|
139
205
|
|
|
140
206
|
with patch("redeploy.models.ProjectManifest.find_and_load", return_value=None):
|
|
141
207
|
result = _runner().invoke(cli, ["status", str(spec)])
|
|
142
208
|
|
|
143
|
-
assert result.exit_code ==
|
|
144
|
-
assert "
|
|
145
|
-
assert "markdown/markpact specs are not implemented" in result.output
|
|
209
|
+
assert result.exit_code == 0, result.output
|
|
210
|
+
assert "markdown migration" in result.output
|
|
146
211
|
|
|
147
212
|
|
|
148
213
|
# ── redeploy init ─────────────────────────────────────────────────────────────
|
|
@@ -214,16 +279,15 @@ class TestDevices:
|
|
|
214
279
|
|
|
215
280
|
|
|
216
281
|
class TestTarget:
|
|
217
|
-
def
|
|
282
|
+
def test_target_supports_markdown_subset(self, tmp_path):
|
|
218
283
|
spec = tmp_path / "migration.md"
|
|
219
|
-
spec.write_text(
|
|
284
|
+
spec.write_text(_migration_markdown_supported(), encoding="utf-8")
|
|
220
285
|
|
|
221
286
|
with patch("redeploy.cli._resolve_device", return_value=(None, None)):
|
|
222
287
|
result = _runner().invoke(cli, ["target", "pi@192.168.1.42", str(spec), "--plan-only"])
|
|
223
288
|
|
|
224
|
-
assert result.exit_code ==
|
|
225
|
-
assert "
|
|
226
|
-
assert "markdown/markpact specs are not implemented" in result.output
|
|
289
|
+
assert result.exit_code == 0, result.output
|
|
290
|
+
assert "target" in result.output.lower()
|
|
227
291
|
|
|
228
292
|
|
|
229
293
|
# ── redeploy apply ────────────────────────────────────────────────────────────
|
|
@@ -13,6 +13,7 @@ import yaml
|
|
|
13
13
|
from redeploy.fleet import FleetConfig, Stage, DeviceExpectation
|
|
14
14
|
from redeploy.models import MigrationSpec, DeployStrategy, StepAction
|
|
15
15
|
from redeploy.plan.planner import Planner
|
|
16
|
+
from redeploy.spec_loader import load_migration_spec
|
|
16
17
|
from redeploy.steps import StepLibrary
|
|
17
18
|
|
|
18
19
|
EXAMPLES_DIR = Path(__file__).resolve().parents[2] / "examples"
|
|
@@ -23,13 +24,16 @@ MIGRATION_YAMLS = sorted(EXAMPLES_DIR.glob("yaml/*/migration.yaml"))
|
|
|
23
24
|
MULTIENV_YAMLS = sorted(EXAMPLES_DIR.glob("yaml/10-multienv/*.yaml"))
|
|
24
25
|
FLEET_YAMLS = sorted(EXAMPLES_DIR.glob("yaml/*/fleet.yaml"))
|
|
25
26
|
REDEPLOY_YAMLS = sorted(EXAMPLES_DIR.glob("yaml/*/redeploy.yaml"))
|
|
27
|
+
SUPPORTED_MARKDOWN_MIGRATIONS = [
|
|
28
|
+
EXAMPLES_DIR / "md" / "01-vps-version-bump" / "migration.md"
|
|
29
|
+
]
|
|
26
30
|
|
|
27
31
|
|
|
28
32
|
# ── helpers ───────────────────────────────────────────────────────────────────
|
|
29
33
|
|
|
30
34
|
|
|
31
35
|
def _load_spec(path: Path) -> MigrationSpec:
|
|
32
|
-
return
|
|
36
|
+
return load_migration_spec(path)
|
|
33
37
|
|
|
34
38
|
|
|
35
39
|
def _plan_from_spec(spec: MigrationSpec):
|
|
@@ -85,6 +89,22 @@ class TestMigrationYaml:
|
|
|
85
89
|
assert rdeploy.exists(), f"Missing redeploy.yaml in {yaml_path.parent.name}"
|
|
86
90
|
|
|
87
91
|
|
|
92
|
+
@pytest.mark.parametrize("md_path", SUPPORTED_MARKDOWN_MIGRATIONS, ids=lambda p: p.parent.name)
|
|
93
|
+
class TestSupportedMarkdownMigration:
|
|
94
|
+
def test_parses_without_error(self, md_path):
|
|
95
|
+
spec = _load_spec(md_path)
|
|
96
|
+
assert spec.name
|
|
97
|
+
|
|
98
|
+
def test_plan_generates_steps(self, md_path):
|
|
99
|
+
spec = _load_spec(md_path)
|
|
100
|
+
plan = _plan_from_spec(spec)
|
|
101
|
+
assert len(plan.steps) > 0, f"{md_path.parent.name}: plan has 0 steps"
|
|
102
|
+
|
|
103
|
+
def test_has_readme(self, md_path):
|
|
104
|
+
readme = md_path.parent / "README.md"
|
|
105
|
+
assert readme.exists(), f"Missing README.md in {md_path.parent.name}"
|
|
106
|
+
|
|
107
|
+
|
|
88
108
|
# ── redeploy.yaml manifest tests ──────────────────────────────────────────────
|
|
89
109
|
|
|
90
110
|
|