xenfra 0.4.3__py3-none-any.whl → 0.4.5__py3-none-any.whl
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.
- xenfra/blueprints/__init__.py +55 -0
- xenfra/blueprints/base.py +85 -0
- xenfra/blueprints/cache.py +286 -0
- xenfra/blueprints/dry_run.py +251 -0
- xenfra/blueprints/e2b.py +101 -0
- xenfra/blueprints/factory.py +113 -0
- xenfra/blueprints/railpack.py +319 -0
- xenfra/blueprints/validation.py +182 -0
- xenfra/commands/__init__.py +3 -3
- xenfra/commands/auth.py +144 -144
- xenfra/commands/auth_device.py +164 -164
- xenfra/commands/deployments.py +1358 -973
- xenfra/commands/intelligence.py +503 -412
- xenfra/commands/projects.py +204 -204
- xenfra/commands/security_cmd.py +233 -233
- xenfra/main.py +79 -75
- xenfra/utils/__init__.py +3 -3
- xenfra/utils/auth.py +374 -374
- xenfra/utils/codebase.py +169 -169
- xenfra/utils/config.py +459 -436
- xenfra/utils/errors.py +116 -116
- xenfra/utils/file_sync.py +286 -286
- xenfra/utils/security.py +336 -336
- xenfra/utils/validation.py +234 -234
- xenfra-0.4.5.dist-info/METADATA +113 -0
- xenfra-0.4.5.dist-info/RECORD +29 -0
- {xenfra-0.4.3.dist-info → xenfra-0.4.5.dist-info}/WHEEL +1 -1
- xenfra-0.4.3.dist-info/METADATA +0 -118
- xenfra-0.4.3.dist-info/RECORD +0 -21
- {xenfra-0.4.3.dist-info → xenfra-0.4.5.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Blueprint factory for framework detection and build plan generation.
|
|
3
|
+
|
|
4
|
+
Implements Railpack-first routing with 3-tier dry-run validation:
|
|
5
|
+
- Tier 1: Local validation (~100ms) - Syntax, imports, config
|
|
6
|
+
- Tier 2: Railpack plan (~1-2s) - Build plan generation
|
|
7
|
+
- Tier 3: E2B Sandbox (~30-60s) - Full build validation via Xenfra API
|
|
8
|
+
|
|
9
|
+
Tier 3 runs on Xenfra's servers - users don't need E2B installed.
|
|
10
|
+
|
|
11
|
+
Environment Variables:
|
|
12
|
+
- XENFRA_AI=off: Disable all AI features
|
|
13
|
+
- XENFRA_SANDBOX=force: Always run Tier 3 validation
|
|
14
|
+
- XENFRA_SANDBOX=skip: Skip Tier 3 validation
|
|
15
|
+
- XENFRA_SANDBOX=auto: Smart detection (default)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from .base import Blueprint, BuildPlan
|
|
19
|
+
from .factory import BlueprintFactory, get_factory, detect_project
|
|
20
|
+
from .railpack import RailpackBlueprint
|
|
21
|
+
from .dry_run import (
|
|
22
|
+
DryRunEngine,
|
|
23
|
+
DryRunResult,
|
|
24
|
+
run_dry_run,
|
|
25
|
+
quick_validate,
|
|
26
|
+
)
|
|
27
|
+
from .validation import ValidationResult, validate_project
|
|
28
|
+
from .e2b import E2BSandbox, SandboxResult, get_sandbox
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
# Base classes
|
|
32
|
+
"Blueprint",
|
|
33
|
+
"BuildPlan",
|
|
34
|
+
|
|
35
|
+
# Factory
|
|
36
|
+
"BlueprintFactory",
|
|
37
|
+
"get_factory",
|
|
38
|
+
"detect_project",
|
|
39
|
+
"RailpackBlueprint",
|
|
40
|
+
|
|
41
|
+
# 3-Tier Dry Run
|
|
42
|
+
"DryRunEngine",
|
|
43
|
+
"DryRunResult",
|
|
44
|
+
"run_dry_run",
|
|
45
|
+
"quick_validate",
|
|
46
|
+
|
|
47
|
+
# Tier 1
|
|
48
|
+
"ValidationResult",
|
|
49
|
+
"validate_project",
|
|
50
|
+
|
|
51
|
+
# Tier 3
|
|
52
|
+
"E2BSandbox",
|
|
53
|
+
"SandboxResult",
|
|
54
|
+
"get_sandbox",
|
|
55
|
+
]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base blueprint interface for build plan generation.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Optional
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class BuildPlan:
|
|
13
|
+
"""A build plan containing all information needed to build and deploy."""
|
|
14
|
+
|
|
15
|
+
framework: str
|
|
16
|
+
language: str
|
|
17
|
+
package_manager: str
|
|
18
|
+
dependency_file: str
|
|
19
|
+
build_command: Optional[str] = None
|
|
20
|
+
start_command: Optional[str] = None
|
|
21
|
+
port: int = 8000
|
|
22
|
+
dockerfile: Optional[str] = None
|
|
23
|
+
env_vars: dict = field(default_factory=dict)
|
|
24
|
+
detected_from: str = "unknown" # How the framework was detected
|
|
25
|
+
|
|
26
|
+
def to_dict(self) -> dict:
|
|
27
|
+
"""Convert build plan to dictionary."""
|
|
28
|
+
return {
|
|
29
|
+
"framework": self.framework,
|
|
30
|
+
"language": self.language,
|
|
31
|
+
"package_manager": self.package_manager,
|
|
32
|
+
"dependency_file": self.dependency_file,
|
|
33
|
+
"build_command": self.build_command,
|
|
34
|
+
"start_command": self.start_command,
|
|
35
|
+
"port": self.port,
|
|
36
|
+
"dockerfile": self.dockerfile,
|
|
37
|
+
"env_vars": self.env_vars,
|
|
38
|
+
"detected_from": self.detected_from,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Blueprint(ABC):
|
|
43
|
+
"""
|
|
44
|
+
Abstract base class for deployment blueprints.
|
|
45
|
+
|
|
46
|
+
Blueprints analyze project structure and generate build plans.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
name: str = "base"
|
|
50
|
+
languages: list[str] = []
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def detect(self, project_path: Path) -> bool:
|
|
54
|
+
"""
|
|
55
|
+
Detect if this blueprint applies to the given project.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
project_path: Path to the project directory
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
True if this blueprint can handle the project
|
|
62
|
+
"""
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
@abstractmethod
|
|
66
|
+
def generate_plan(self, project_path: Path) -> BuildPlan:
|
|
67
|
+
"""
|
|
68
|
+
Generate a build plan for the project.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
project_path: Path to the project directory
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
BuildPlan with all build/deployment information
|
|
75
|
+
"""
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
def get_priority(self) -> int:
|
|
79
|
+
"""
|
|
80
|
+
Get the priority of this blueprint (higher = tried first).
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Priority integer
|
|
84
|
+
"""
|
|
85
|
+
return 100
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Smart detection and caching for Tier 3 validation decisions.
|
|
3
|
+
|
|
4
|
+
Tracks build state to determine when full sandbox validation is needed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
from dataclasses import dataclass, asdict
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class BuildState:
|
|
18
|
+
"""Cached state of a successful build."""
|
|
19
|
+
project_name: str
|
|
20
|
+
framework: str
|
|
21
|
+
language: str
|
|
22
|
+
dependency_hash: str
|
|
23
|
+
config_hash: str
|
|
24
|
+
build_plan_hash: str
|
|
25
|
+
status: str # "success" or "failed"
|
|
26
|
+
timestamp: str
|
|
27
|
+
|
|
28
|
+
def to_dict(self) -> dict:
|
|
29
|
+
return asdict(self)
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_dict(cls, data: dict) -> "BuildState":
|
|
33
|
+
return cls(**data)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class BuildCache:
|
|
37
|
+
"""
|
|
38
|
+
Manages build state cache for smart detection.
|
|
39
|
+
|
|
40
|
+
Stores hashes of key files to detect changes that would
|
|
41
|
+
require re-validation in the sandbox.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
CACHE_DIR_NAME = ".xenfra"
|
|
45
|
+
CACHE_FILE_NAME = "build_state.json"
|
|
46
|
+
|
|
47
|
+
def __init__(self, project_path: Path):
|
|
48
|
+
self.project_path = project_path
|
|
49
|
+
self.cache_dir = project_path / self.CACHE_DIR_NAME
|
|
50
|
+
self.cache_file = self.cache_dir / self.CACHE_FILE_NAME
|
|
51
|
+
|
|
52
|
+
def get_cached_state(self) -> Optional[BuildState]:
|
|
53
|
+
"""
|
|
54
|
+
Get the cached build state if it exists.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
BuildState if cache exists, None otherwise
|
|
58
|
+
"""
|
|
59
|
+
if not self.cache_file.exists():
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
content = self.cache_file.read_text()
|
|
64
|
+
data = json.loads(content)
|
|
65
|
+
return BuildState.from_dict(data)
|
|
66
|
+
except (json.JSONDecodeError, KeyError, TypeError):
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
def save_state(self, state: BuildState):
|
|
70
|
+
"""
|
|
71
|
+
Save the build state to cache.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
state: BuildState to cache
|
|
75
|
+
"""
|
|
76
|
+
self.cache_dir.mkdir(exist_ok=True)
|
|
77
|
+
# Add to .gitignore if not already there
|
|
78
|
+
self._ensure_gitignore()
|
|
79
|
+
|
|
80
|
+
self.cache_file.write_text(json.dumps(state.to_dict(), indent=2))
|
|
81
|
+
|
|
82
|
+
def _ensure_gitignore(self):
|
|
83
|
+
"""Ensure .xenfra is in .gitignore."""
|
|
84
|
+
gitignore = self.project_path / ".gitignore"
|
|
85
|
+
entry = ".xenfra/"
|
|
86
|
+
|
|
87
|
+
if gitignore.exists():
|
|
88
|
+
content = gitignore.read_text()
|
|
89
|
+
if entry not in content:
|
|
90
|
+
with open(gitignore, "a") as f:
|
|
91
|
+
f.write(f"\n{entry}\n")
|
|
92
|
+
else:
|
|
93
|
+
gitignore.write_text(f"{entry}\n")
|
|
94
|
+
|
|
95
|
+
def compute_dependency_hash(self, plan: "BuildPlan") -> str:
|
|
96
|
+
"""
|
|
97
|
+
Compute hash of dependency files.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
plan: BuildPlan with dependency information
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Hash string of dependency files
|
|
104
|
+
"""
|
|
105
|
+
hasher = hashlib.sha256()
|
|
106
|
+
|
|
107
|
+
# Main dependency file
|
|
108
|
+
dep_file = self.project_path / plan.dependency_file
|
|
109
|
+
if dep_file.exists():
|
|
110
|
+
hasher.update(dep_file.read_bytes())
|
|
111
|
+
|
|
112
|
+
# Lock files
|
|
113
|
+
lock_files = {
|
|
114
|
+
"python": ["poetry.lock", "uv.lock", "Pipfile.lock"],
|
|
115
|
+
"node": ["package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb"],
|
|
116
|
+
"go": ["go.sum"],
|
|
117
|
+
"rust": ["Cargo.lock"],
|
|
118
|
+
"ruby": ["Gemfile.lock"],
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
for lock in lock_files.get(plan.language, []):
|
|
122
|
+
lock_path = self.project_path / lock
|
|
123
|
+
if lock_path.exists():
|
|
124
|
+
hasher.update(lock_path.read_bytes())
|
|
125
|
+
|
|
126
|
+
return hasher.hexdigest()[:16]
|
|
127
|
+
|
|
128
|
+
def compute_config_hash(self, plan: "BuildPlan") -> str:
|
|
129
|
+
"""
|
|
130
|
+
Compute hash of configuration files.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
plan: BuildPlan with configuration information
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Hash string of config files
|
|
137
|
+
"""
|
|
138
|
+
hasher = hashlib.sha256()
|
|
139
|
+
|
|
140
|
+
# xenfra.yaml
|
|
141
|
+
config_file = self.project_path / "xenfra.yaml"
|
|
142
|
+
if config_file.exists():
|
|
143
|
+
hasher.update(config_file.read_bytes())
|
|
144
|
+
|
|
145
|
+
# Dockerfile
|
|
146
|
+
dockerfile = self.project_path / "Dockerfile"
|
|
147
|
+
if dockerfile.exists():
|
|
148
|
+
hasher.update(dockerfile.read_bytes())
|
|
149
|
+
|
|
150
|
+
# Docker Compose
|
|
151
|
+
compose_files = ["docker-compose.yml", "docker-compose.yaml"]
|
|
152
|
+
for cf in compose_files:
|
|
153
|
+
cf_path = self.project_path / cf
|
|
154
|
+
if cf_path.exists():
|
|
155
|
+
hasher.update(cf_path.read_bytes())
|
|
156
|
+
|
|
157
|
+
return hasher.hexdigest()[:16]
|
|
158
|
+
|
|
159
|
+
def compute_build_plan_hash(self, plan: "BuildPlan") -> str:
|
|
160
|
+
"""
|
|
161
|
+
Compute hash of the build plan itself.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
plan: BuildPlan to hash
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
Hash string of build plan
|
|
168
|
+
"""
|
|
169
|
+
plan_dict = plan.to_dict()
|
|
170
|
+
plan_str = json.dumps(plan_dict, sort_keys=True)
|
|
171
|
+
return hashlib.sha256(plan_str.encode()).hexdigest()[:16]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class SmartDetector:
|
|
175
|
+
"""
|
|
176
|
+
Smart detection for Tier 3 (E2B) validation.
|
|
177
|
+
|
|
178
|
+
Determines when full sandbox validation is needed based on
|
|
179
|
+
changes to dependencies, config, or previous build failures.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
def __init__(self, project_path: Path):
|
|
183
|
+
self.project_path = project_path
|
|
184
|
+
self.cache = BuildCache(project_path)
|
|
185
|
+
|
|
186
|
+
def should_run_sandbox(
|
|
187
|
+
self,
|
|
188
|
+
plan: "BuildPlan",
|
|
189
|
+
force: bool = False
|
|
190
|
+
) -> tuple[bool, str]:
|
|
191
|
+
"""
|
|
192
|
+
Determine if Tier 3 (E2B) validation should run.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
plan: BuildPlan for the project
|
|
196
|
+
force: If True, always run sandbox
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Tuple of (should_run, reason)
|
|
200
|
+
"""
|
|
201
|
+
if force:
|
|
202
|
+
return True, "Forced by --force flag"
|
|
203
|
+
|
|
204
|
+
# Check for environment variable override
|
|
205
|
+
sandbox_mode = os.environ.get("XENFRA_SANDBOX", "auto").lower()
|
|
206
|
+
|
|
207
|
+
if sandbox_mode == "force":
|
|
208
|
+
return True, "Forced by XENFRA_SANDBOX=force"
|
|
209
|
+
|
|
210
|
+
if sandbox_mode == "skip":
|
|
211
|
+
return False, "Skipped by XENFRA_SANDBOX=skip"
|
|
212
|
+
|
|
213
|
+
# Get cached state
|
|
214
|
+
cached = self.cache.get_cached_state()
|
|
215
|
+
if not cached:
|
|
216
|
+
return True, "First deployment - validation required"
|
|
217
|
+
|
|
218
|
+
# Compute current hashes
|
|
219
|
+
current_dep_hash = self.cache.compute_dependency_hash(plan)
|
|
220
|
+
current_config_hash = self.cache.compute_config_hash(plan)
|
|
221
|
+
current_plan_hash = self.cache.compute_build_plan_hash(plan)
|
|
222
|
+
|
|
223
|
+
# Check for failures
|
|
224
|
+
if cached.status == "failed":
|
|
225
|
+
return True, "Previous build failed - re-validation required"
|
|
226
|
+
|
|
227
|
+
# Check for dependency changes
|
|
228
|
+
if current_dep_hash != cached.dependency_hash:
|
|
229
|
+
return True, "Dependencies changed - validation required"
|
|
230
|
+
|
|
231
|
+
# Check for config changes
|
|
232
|
+
if current_config_hash != cached.config_hash:
|
|
233
|
+
return True, "Build configuration changed - validation required"
|
|
234
|
+
|
|
235
|
+
# Check for framework changes
|
|
236
|
+
if plan.framework != cached.framework:
|
|
237
|
+
return True, "Framework changed - validation required"
|
|
238
|
+
|
|
239
|
+
# Check for build plan changes
|
|
240
|
+
if current_plan_hash != cached.build_plan_hash:
|
|
241
|
+
return True, "Build plan changed - validation required"
|
|
242
|
+
|
|
243
|
+
return False, "No significant changes - skipping sandbox"
|
|
244
|
+
|
|
245
|
+
def record_success(self, plan: "BuildPlan"):
|
|
246
|
+
"""
|
|
247
|
+
Record a successful build.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
plan: BuildPlan that succeeded
|
|
251
|
+
"""
|
|
252
|
+
state = BuildState(
|
|
253
|
+
project_name=self.project_path.name,
|
|
254
|
+
framework=plan.framework,
|
|
255
|
+
language=plan.language,
|
|
256
|
+
dependency_hash=self.cache.compute_dependency_hash(plan),
|
|
257
|
+
config_hash=self.cache.compute_config_hash(plan),
|
|
258
|
+
build_plan_hash=self.cache.compute_build_plan_hash(plan),
|
|
259
|
+
status="success",
|
|
260
|
+
timestamp=datetime.utcnow().isoformat()
|
|
261
|
+
)
|
|
262
|
+
self.cache.save_state(state)
|
|
263
|
+
|
|
264
|
+
def record_failure(self, plan: "BuildPlan"):
|
|
265
|
+
"""
|
|
266
|
+
Record a failed build.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
plan: BuildPlan that failed
|
|
270
|
+
"""
|
|
271
|
+
state = BuildState(
|
|
272
|
+
project_name=self.project_path.name,
|
|
273
|
+
framework=plan.framework,
|
|
274
|
+
language=plan.language,
|
|
275
|
+
dependency_hash=self.cache.compute_dependency_hash(plan),
|
|
276
|
+
config_hash=self.cache.compute_config_hash(plan),
|
|
277
|
+
build_plan_hash=self.cache.compute_build_plan_hash(plan),
|
|
278
|
+
status="failed",
|
|
279
|
+
timestamp=datetime.utcnow().isoformat()
|
|
280
|
+
)
|
|
281
|
+
self.cache.save_state(state)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def get_detector(project_path: Path) -> SmartDetector:
|
|
285
|
+
"""Get smart detector for a project."""
|
|
286
|
+
return SmartDetector(project_path)
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""
|
|
2
|
+
3-Tier Dry Run System - "Dry Run First" Architecture
|
|
3
|
+
|
|
4
|
+
Every deployment is validated through 3 tiers:
|
|
5
|
+
- Tier 1: Local validation (~100ms) - Syntax, imports, config
|
|
6
|
+
- Tier 2: Railpack plan (~1-2s) - Build plan generation
|
|
7
|
+
- Tier 3: E2B Sandbox (~30-60s) - Full build validation (smart detection)
|
|
8
|
+
|
|
9
|
+
This ensures deployments never fail due to avoidable errors.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from enum import Enum
|
|
16
|
+
|
|
17
|
+
from .base import BuildPlan
|
|
18
|
+
from .validation import validate_project, ValidationResult
|
|
19
|
+
from .e2b import get_sandbox, SandboxResult
|
|
20
|
+
from .cache import get_detector
|
|
21
|
+
from .factory import get_factory
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TierStatus(Enum):
|
|
25
|
+
"""Status of a tier validation."""
|
|
26
|
+
PENDING = "pending"
|
|
27
|
+
RUNNING = "running"
|
|
28
|
+
PASSED = "passed"
|
|
29
|
+
FAILED = "failed"
|
|
30
|
+
SKIPPED = "skipped"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class DryRunResult:
|
|
35
|
+
"""Complete result of dry-run validation."""
|
|
36
|
+
success: bool
|
|
37
|
+
tier1: ValidationResult
|
|
38
|
+
tier2: Optional[BuildPlan]
|
|
39
|
+
tier3: Optional[SandboxResult]
|
|
40
|
+
tier3_ran: bool
|
|
41
|
+
tier3_reason: str
|
|
42
|
+
errors: list[str] = field(default_factory=list)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def all_tiers_passed(self) -> bool:
|
|
46
|
+
"""Check if all executed tiers passed."""
|
|
47
|
+
if not self.tier1.success:
|
|
48
|
+
return False
|
|
49
|
+
if self.tier2 is None:
|
|
50
|
+
return False
|
|
51
|
+
if self.tier3_ran and (self.tier3 is None or not self.tier3.success):
|
|
52
|
+
return False
|
|
53
|
+
return True
|
|
54
|
+
|
|
55
|
+
def get_summary(self) -> str:
|
|
56
|
+
"""Get human-readable summary."""
|
|
57
|
+
lines = ["Dry Run Results:", ""]
|
|
58
|
+
|
|
59
|
+
# Tier 1
|
|
60
|
+
t1_status = "✅ PASSED" if self.tier1.success else "❌ FAILED"
|
|
61
|
+
lines.append(f"Tier 1 (Local): {t1_status}")
|
|
62
|
+
if self.tier1.errors:
|
|
63
|
+
for err in self.tier1.errors:
|
|
64
|
+
lines.append(f" - {err}")
|
|
65
|
+
|
|
66
|
+
# Tier 2
|
|
67
|
+
if self.tier2:
|
|
68
|
+
lines.append(f"Tier 2 (Railpack): ✅ PASSED - {self.tier2.framework}")
|
|
69
|
+
else:
|
|
70
|
+
lines.append("Tier 2 (Railpack): ❌ FAILED")
|
|
71
|
+
|
|
72
|
+
# Tier 3
|
|
73
|
+
if self.tier3_ran:
|
|
74
|
+
if self.tier3 and self.tier3.success:
|
|
75
|
+
lines.append(f"Tier 3 (Sandbox): ✅ PASSED ({self.tier3.build_time_seconds:.1f}s)")
|
|
76
|
+
else:
|
|
77
|
+
lines.append("Tier 3 (Sandbox): ❌ FAILED")
|
|
78
|
+
if self.tier3 and self.tier3.error_message:
|
|
79
|
+
lines.append(f" - {self.tier3.error_message}")
|
|
80
|
+
else:
|
|
81
|
+
lines.append(f"Tier 3 (Sandbox): ⏭️ SKIPPED - {self.tier3_reason}")
|
|
82
|
+
|
|
83
|
+
return "\n".join(lines)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class DryRunEngine:
|
|
87
|
+
"""
|
|
88
|
+
3-Tier Dry Run Engine.
|
|
89
|
+
|
|
90
|
+
Validates deployments through increasingly thorough checks,
|
|
91
|
+
ensuring builds succeed before touching real infrastructure.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(self, project_path: Path, api_client=None, file_manifest=None):
|
|
95
|
+
self.project_path = project_path
|
|
96
|
+
self.file_manifest = file_manifest
|
|
97
|
+
self.factory = get_factory()
|
|
98
|
+
self.sandbox = get_sandbox(api_client)
|
|
99
|
+
self.detector = get_detector(project_path)
|
|
100
|
+
|
|
101
|
+
def run(
|
|
102
|
+
self,
|
|
103
|
+
force_sandbox: bool = False,
|
|
104
|
+
skip_sandbox: bool = False
|
|
105
|
+
) -> DryRunResult:
|
|
106
|
+
"""
|
|
107
|
+
Run full dry-run validation.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
force_sandbox: Always run Tier 3 even if not needed
|
|
111
|
+
skip_sandbox: Never run Tier 3 (for quick iteration)
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
DryRunResult with all tier results
|
|
115
|
+
"""
|
|
116
|
+
errors = []
|
|
117
|
+
|
|
118
|
+
# ========== TIER 1: Local Validation ==========
|
|
119
|
+
tier1_result = validate_project(self.project_path)
|
|
120
|
+
|
|
121
|
+
if not tier1_result.success:
|
|
122
|
+
return DryRunResult(
|
|
123
|
+
success=False,
|
|
124
|
+
tier1=tier1_result,
|
|
125
|
+
tier2=None,
|
|
126
|
+
tier3=None,
|
|
127
|
+
tier3_ran=False,
|
|
128
|
+
tier3_reason="Skipped due to Tier 1 failures",
|
|
129
|
+
errors=tier1_result.errors
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# ========== TIER 2: Railpack Plan ==========
|
|
133
|
+
try:
|
|
134
|
+
tier2_plan = self.factory.get_build_plan(self.project_path)
|
|
135
|
+
except Exception as e:
|
|
136
|
+
errors.append(f"Railpack plan generation failed: {e}")
|
|
137
|
+
return DryRunResult(
|
|
138
|
+
success=False,
|
|
139
|
+
tier1=tier1_result,
|
|
140
|
+
tier2=None,
|
|
141
|
+
tier3=None,
|
|
142
|
+
tier3_ran=False,
|
|
143
|
+
tier3_reason="Skipped due to Tier 2 failure",
|
|
144
|
+
errors=errors
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# ========== TIER 3: E2B Sandbox ==========
|
|
148
|
+
tier3_result: Optional[SandboxResult] = None
|
|
149
|
+
tier3_ran = False
|
|
150
|
+
tier3_reason = ""
|
|
151
|
+
|
|
152
|
+
if skip_sandbox:
|
|
153
|
+
tier3_reason = "Skipped by user (--skip-sandbox)"
|
|
154
|
+
else:
|
|
155
|
+
# Check if we should run sandbox
|
|
156
|
+
should_run, reason = self.detector.should_run_sandbox(
|
|
157
|
+
tier2_plan,
|
|
158
|
+
force=force_sandbox
|
|
159
|
+
)
|
|
160
|
+
tier3_reason = reason
|
|
161
|
+
|
|
162
|
+
if should_run and self.sandbox.is_available:
|
|
163
|
+
tier3_ran = True
|
|
164
|
+
tier3_result = self.sandbox.validate_build(
|
|
165
|
+
self.project_path,
|
|
166
|
+
tier2_plan,
|
|
167
|
+
file_manifest=self.file_manifest or []
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Record result for smart detection
|
|
171
|
+
if tier3_result.success:
|
|
172
|
+
self.detector.record_success(tier2_plan)
|
|
173
|
+
else:
|
|
174
|
+
self.detector.record_failure(tier2_plan)
|
|
175
|
+
errors.append(
|
|
176
|
+
tier3_result.error_message or "Sandbox build failed"
|
|
177
|
+
)
|
|
178
|
+
elif should_run and not self.sandbox.is_available:
|
|
179
|
+
tier3_reason = "Sandbox unavailable (not authenticated)"
|
|
180
|
+
|
|
181
|
+
# Determine overall success
|
|
182
|
+
success = (
|
|
183
|
+
tier1_result.success and
|
|
184
|
+
tier2_plan is not None and
|
|
185
|
+
(not tier3_ran or (tier3_result and tier3_result.success))
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
return DryRunResult(
|
|
189
|
+
success=success,
|
|
190
|
+
tier1=tier1_result,
|
|
191
|
+
tier2=tier2_plan,
|
|
192
|
+
tier3=tier3_result,
|
|
193
|
+
tier3_ran=tier3_ran,
|
|
194
|
+
tier3_reason=tier3_reason,
|
|
195
|
+
errors=errors
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def quick_check(self) -> ValidationResult:
|
|
199
|
+
"""
|
|
200
|
+
Run only Tier 1 validation for quick feedback.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
ValidationResult from local validation
|
|
204
|
+
"""
|
|
205
|
+
return validate_project(self.project_path)
|
|
206
|
+
|
|
207
|
+
def generate_plan(self) -> BuildPlan:
|
|
208
|
+
"""
|
|
209
|
+
Generate build plan (Tier 2).
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
BuildPlan from Railpack analysis
|
|
213
|
+
"""
|
|
214
|
+
return self.factory.get_build_plan(self.project_path)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def run_dry_run(
|
|
218
|
+
project_path: Path,
|
|
219
|
+
force_sandbox: bool = False,
|
|
220
|
+
skip_sandbox: bool = False,
|
|
221
|
+
api_client=None,
|
|
222
|
+
file_manifest: list = None
|
|
223
|
+
) -> DryRunResult:
|
|
224
|
+
"""
|
|
225
|
+
Convenience function to run dry-run validation.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
project_path: Path to project directory
|
|
229
|
+
force_sandbox: Always run sandbox
|
|
230
|
+
skip_sandbox: Skip sandbox validation
|
|
231
|
+
api_client: Xenfra API client for Tier 3 sandbox
|
|
232
|
+
file_manifest: List of file info dicts for the project
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
DryRunResult with full validation results
|
|
236
|
+
"""
|
|
237
|
+
engine = DryRunEngine(project_path, api_client=api_client, file_manifest=file_manifest)
|
|
238
|
+
return engine.run(force_sandbox=force_sandbox, skip_sandbox=skip_sandbox)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def quick_validate(project_path: Path) -> ValidationResult:
|
|
242
|
+
"""
|
|
243
|
+
Quick validation (Tier 1 only).
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
project_path: Path to project directory
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
ValidationResult
|
|
250
|
+
"""
|
|
251
|
+
return validate_project(project_path)
|