deployforge 0.1.0__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.
- deployforge/__init__.py +3 -0
- deployforge/__version__.py +3 -0
- deployforge/analyzer/__init__.py +23 -0
- deployforge/analyzer/backend.py +326 -0
- deployforge/analyzer/database.py +120 -0
- deployforge/analyzer/frontend.py +179 -0
- deployforge/analyzer/project.py +389 -0
- deployforge/analyzer/shared.py +109 -0
- deployforge/cli.py +825 -0
- deployforge/config.py +195 -0
- deployforge/deployment/__init__.py +19 -0
- deployforge/deployment/orchestrator.py +331 -0
- deployforge/deployment/planner.py +172 -0
- deployforge/deployment/verifier.py +65 -0
- deployforge/errors/__init__.py +53 -0
- deployforge/github/__init__.py +21 -0
- deployforge/github/integration.py +127 -0
- deployforge/integration/__init__.py +20 -0
- deployforge/integration/cors.py +30 -0
- deployforge/integration/environment.py +62 -0
- deployforge/integration/frontend_backend.py +39 -0
- deployforge/providers/__init__.py +32 -0
- deployforge/providers/base.py +151 -0
- deployforge/providers/render.py +218 -0
- deployforge/providers/vercel.py +205 -0
- deployforge/security/__init__.py +4 -0
- deployforge/security/gitignore.py +35 -0
- deployforge/security/scanner.py +125 -0
- deployforge/security/secrets.py +110 -0
- deployforge/ui/__init__.py +1 -0
- deployforge/ui/terminal.py +151 -0
- deployforge-0.1.0.dist-info/METADATA +218 -0
- deployforge-0.1.0.dist-info/RECORD +37 -0
- deployforge-0.1.0.dist-info/WHEEL +5 -0
- deployforge-0.1.0.dist-info/entry_points.txt +2 -0
- deployforge-0.1.0.dist-info/licenses/LICENSE +21 -0
- deployforge-0.1.0.dist-info/top_level.txt +1 -0
deployforge/config.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Configuration management for DeployForge.
|
|
2
|
+
|
|
3
|
+
Two layers:
|
|
4
|
+
|
|
5
|
+
* **Project configuration** — ``.deployforge/config.yml`` inside the project.
|
|
6
|
+
Never contains secrets.
|
|
7
|
+
* **User configuration** — stored in the platform user config directory
|
|
8
|
+
(e.g. ``~/.config/deployforge/config.yml``). Provider credentials are kept
|
|
9
|
+
in the OS keyring, never in the config file.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
from dataclasses import asdict, dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import yaml
|
|
21
|
+
from platformdirs import user_config_dir
|
|
22
|
+
|
|
23
|
+
from deployforge.errors import ConfigError
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger("deployforge")
|
|
26
|
+
|
|
27
|
+
PROJECT_CONFIG_DIR = ".deployforge"
|
|
28
|
+
PROJECT_CONFIG_FILE = "config.yml"
|
|
29
|
+
|
|
30
|
+
DEFAULT_BRANCH = "main"
|
|
31
|
+
DEFAULT_FRONTEND_PROVIDER = "vercel"
|
|
32
|
+
DEFAULT_BACKEND_PROVIDER = "render"
|
|
33
|
+
DEFAULT_PLAN = "starter"
|
|
34
|
+
|
|
35
|
+
VERCEL_TOKEN_ENV_NAMES = ("DEPLOYFORGE_VERCEL_TOKEN", "VERCEL_TOKEN")
|
|
36
|
+
RENDER_KEY_ENV_NAMES = ("DEPLOYFORGE_RENDER_API_KEY", "RENDER_API_KEY")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ProjectConfig:
|
|
41
|
+
"""Validated rich representation of ``.deployforge/config.yml``."""
|
|
42
|
+
|
|
43
|
+
version: int = 1
|
|
44
|
+
name: str | None = None
|
|
45
|
+
github_repository: str | None = None
|
|
46
|
+
branch: str = DEFAULT_BRANCH
|
|
47
|
+
frontend_provider: str = DEFAULT_FRONTEND_PROVIDER
|
|
48
|
+
frontend_directory: str | None = None
|
|
49
|
+
backend_provider: str = DEFAULT_BACKEND_PROVIDER
|
|
50
|
+
backend_directory: str | None = None
|
|
51
|
+
auto_connect: bool = True
|
|
52
|
+
security_enabled: bool = True
|
|
53
|
+
block_on_secrets: bool = True
|
|
54
|
+
plan: str = DEFAULT_PLAN
|
|
55
|
+
|
|
56
|
+
def public(self) -> dict[str, Any]:
|
|
57
|
+
return {k: v for k, v in asdict(self).items()}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def default_config_path() -> Path:
|
|
61
|
+
return Path(user_config_dir("deployforge", "deployforge")) / "config.yml"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def default_data_dir() -> Path:
|
|
65
|
+
from platformdirs import user_data_dir
|
|
66
|
+
|
|
67
|
+
return Path(user_data_dir("deployforge", "deployforge"))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def project_config_path(project: Path) -> Path:
|
|
71
|
+
return project / PROJECT_CONFIG_DIR / PROJECT_CONFIG_FILE
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _load_yml(path: Path) -> dict[str, Any]:
|
|
75
|
+
if not path.exists():
|
|
76
|
+
return {}
|
|
77
|
+
try:
|
|
78
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
79
|
+
data = yaml.safe_load(fh) or {}
|
|
80
|
+
except (OSError, yaml.YAMLError) as exc:
|
|
81
|
+
raise ConfigError(f"Unable to read configuration file {path}: {exc}") from exc
|
|
82
|
+
if not isinstance(data, dict):
|
|
83
|
+
raise ConfigError(f"Invalid configuration file {path}: expected a mapping.")
|
|
84
|
+
return data
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _filter_known(data: dict[str, Any], cls: type[Any]) -> dict[str, Any]:
|
|
88
|
+
fields = set(cls.__dataclass_fields__) # type: ignore[attr-defined]
|
|
89
|
+
return {k: v for k, v in data.items() if k in fields and v is not None}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ProjectConfigManager:
|
|
93
|
+
"""Reads and writes ``.deployforge/config.yml`` for a project."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, project: Path) -> None:
|
|
96
|
+
self.project = project
|
|
97
|
+
self.path = project_config_path(project)
|
|
98
|
+
|
|
99
|
+
def load(self) -> ProjectConfig:
|
|
100
|
+
data = _load_yml(self.path)
|
|
101
|
+
config = ProjectConfig()
|
|
102
|
+
for key, value in _filter_known(data, ProjectConfig).items():
|
|
103
|
+
setattr(config, key, value)
|
|
104
|
+
return config
|
|
105
|
+
|
|
106
|
+
def save(self, config: ProjectConfig) -> None:
|
|
107
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
data = {k: v for k, v in config.public().items() if v is not None}
|
|
109
|
+
try:
|
|
110
|
+
with self.path.open("w", encoding="utf-8") as fh:
|
|
111
|
+
yaml.safe_dump(data, fh, sort_keys=False)
|
|
112
|
+
except OSError as exc:
|
|
113
|
+
raise ConfigError(f"Unable to write configuration file {self.path}: {exc}") from exc
|
|
114
|
+
|
|
115
|
+
def update(self, **kwargs: Any) -> ProjectConfig:
|
|
116
|
+
config = self.load()
|
|
117
|
+
for key, value in kwargs.items():
|
|
118
|
+
if not hasattr(config, key):
|
|
119
|
+
raise ConfigError(f"Unknown configuration option: {key}")
|
|
120
|
+
setattr(config, key, value)
|
|
121
|
+
self.save(config)
|
|
122
|
+
return config
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class KeyringSecrets:
|
|
126
|
+
"""OS-keyring backed credential storage (never inside a project)."""
|
|
127
|
+
|
|
128
|
+
SERVICE = "deployforge"
|
|
129
|
+
|
|
130
|
+
def __init__(self, keyring_backend: Any | None = None) -> None:
|
|
131
|
+
self.keyring = keyring_backend if keyring_backend is not None else self._load_keyring()
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def _load_keyring() -> Any:
|
|
135
|
+
try:
|
|
136
|
+
import keyring
|
|
137
|
+
except Exception:
|
|
138
|
+
return None
|
|
139
|
+
try:
|
|
140
|
+
return keyring.get_keyring()
|
|
141
|
+
except Exception:
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
def _store(self, key: str, value: str) -> bool:
|
|
145
|
+
if self.keyring is None:
|
|
146
|
+
return False
|
|
147
|
+
try:
|
|
148
|
+
self.keyring.set_password(self.SERVICE, key, value)
|
|
149
|
+
return True
|
|
150
|
+
except Exception:
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
def set_vercel_token(self, token: str) -> bool:
|
|
154
|
+
return self._store("vercel_token", token)
|
|
155
|
+
|
|
156
|
+
def set_render_api_key(self, key: str) -> bool:
|
|
157
|
+
return self._store("render_api_key", key)
|
|
158
|
+
|
|
159
|
+
def get_vercel_token(self) -> str | None:
|
|
160
|
+
return self._get("vercel_token")
|
|
161
|
+
|
|
162
|
+
def get_render_api_key(self) -> str | None:
|
|
163
|
+
return self._get("render_api_key")
|
|
164
|
+
|
|
165
|
+
def _get(self, key: str) -> str | None:
|
|
166
|
+
if self.keyring is None:
|
|
167
|
+
return None
|
|
168
|
+
try:
|
|
169
|
+
return self.keyring.get_password(self.SERVICE, key) or None
|
|
170
|
+
except Exception:
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
def clear(self, key: str) -> None:
|
|
174
|
+
if self.keyring is None:
|
|
175
|
+
return
|
|
176
|
+
try:
|
|
177
|
+
self.keyring.delete_password(self.SERVICE, key)
|
|
178
|
+
except Exception:
|
|
179
|
+
logger.debug("No stored credential to delete for %s", key)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _first_env(names: tuple[str, ...]) -> str | None:
|
|
183
|
+
for name in names:
|
|
184
|
+
value = os.environ.get(name)
|
|
185
|
+
if value and value.strip():
|
|
186
|
+
return value.strip()
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def resolve_vercel_token(secrets: KeyringSecrets | None = None) -> str | None:
|
|
191
|
+
return _first_env(VERCEL_TOKEN_ENV_NAMES) or (secrets.get_vercel_token() if secrets else None)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def resolve_render_api_key(secrets: KeyringSecrets | None = None) -> str | None:
|
|
195
|
+
return _first_env(RENDER_KEY_ENV_NAMES) or (secrets.get_render_api_key() if secrets else None)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from deployforge.deployment.orchestrator import DeployOutcome, run_deploy
|
|
2
|
+
from deployforge.deployment.planner import DeploymentPlan, build_plan
|
|
3
|
+
from deployforge.deployment.verifier import (
|
|
4
|
+
VerificationReport,
|
|
5
|
+
VerificationResult,
|
|
6
|
+
check_url,
|
|
7
|
+
verify_endpoints,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"DeployOutcome",
|
|
12
|
+
"DeploymentPlan",
|
|
13
|
+
"VerificationReport",
|
|
14
|
+
"VerificationResult",
|
|
15
|
+
"build_plan",
|
|
16
|
+
"check_url",
|
|
17
|
+
"run_deploy",
|
|
18
|
+
"verify_endpoints",
|
|
19
|
+
]
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Deployment orchestration.
|
|
2
|
+
|
|
3
|
+
Runs the ordered deployment pipeline:
|
|
4
|
+
|
|
5
|
+
analyze → security → backend → frontend env → frontend → backend CORS → verify
|
|
6
|
+
|
|
7
|
+
Partial failures are reported accurately instead of claiming success.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from deployforge.analyzer.project import ProjectAnalysis
|
|
20
|
+
from deployforge.config import default_data_dir
|
|
21
|
+
from deployforge.deployment.planner import DeploymentPlan
|
|
22
|
+
from deployforge.deployment.verifier import VerificationReport, verify_endpoints
|
|
23
|
+
from deployforge.errors import (
|
|
24
|
+
AuthenticationError,
|
|
25
|
+
DeploymentError,
|
|
26
|
+
PartialDeploymentError,
|
|
27
|
+
ProjectError,
|
|
28
|
+
ProviderError,
|
|
29
|
+
SecurityError,
|
|
30
|
+
)
|
|
31
|
+
from deployforge.providers.base import DeploymentProvider, DeploymentResult
|
|
32
|
+
from deployforge.security import scan_project
|
|
33
|
+
|
|
34
|
+
StepCallback = Callable[[str], None]
|
|
35
|
+
ConfirmCallback = Callable[[str], bool] | None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class DeployOutcome:
|
|
40
|
+
dry_run: bool = False
|
|
41
|
+
frontend: DeploymentResult | None = None
|
|
42
|
+
backend: DeploymentResult | None = None
|
|
43
|
+
frontend_url: str | None = None
|
|
44
|
+
backend_url: str | None = None
|
|
45
|
+
env_vars_set: list[tuple[str, str]] = field(default_factory=list)
|
|
46
|
+
verification: VerificationReport | None = None
|
|
47
|
+
warnings: list[str] = field(default_factory=list)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _record(outcome: DeployOutcome) -> None:
|
|
51
|
+
"""Append to the local deployment log (never stores secrets)."""
|
|
52
|
+
try:
|
|
53
|
+
log_dir = default_data_dir()
|
|
54
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
log_path = log_dir / "deployments.jsonl"
|
|
56
|
+
entry: dict[str, Any] = {
|
|
57
|
+
"time": int(time.time()),
|
|
58
|
+
"dry_run": outcome.dry_run,
|
|
59
|
+
"frontend_url": outcome.frontend_url,
|
|
60
|
+
"backend_url": outcome.backend_url,
|
|
61
|
+
}
|
|
62
|
+
for kind, result in (("frontend", outcome.frontend), ("backend", outcome.backend)):
|
|
63
|
+
if result is not None:
|
|
64
|
+
entry[f"{kind}_provider"] = result.provider
|
|
65
|
+
entry[f"{kind}_id"] = result.service_id
|
|
66
|
+
entry[f"{kind}_name"] = result.project_name
|
|
67
|
+
with log_path.open("a", encoding="utf-8") as fh:
|
|
68
|
+
fh.write(json.dumps(entry) + "\n")
|
|
69
|
+
except OSError:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _require_auth(provider: DeploymentProvider, name: str) -> None:
|
|
74
|
+
auth = provider.authenticate()
|
|
75
|
+
if not auth.authenticated:
|
|
76
|
+
raise AuthenticationError(
|
|
77
|
+
f"{name} authentication is not configured. Set the provider API token "
|
|
78
|
+
"or run 'deployforge config' to store it securely."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _repo_slug(analysis: ProjectAnalysis) -> str:
|
|
83
|
+
slug = analysis.repo.slug()
|
|
84
|
+
if not slug:
|
|
85
|
+
raise ProjectError(
|
|
86
|
+
"No GitHub repository detected.\n\n"
|
|
87
|
+
"DeployForge works best with PushForge.\n\n"
|
|
88
|
+
"You can run:\n\n"
|
|
89
|
+
" pushforge\n\n"
|
|
90
|
+
"Then return here and run:\n\n"
|
|
91
|
+
" deployforge"
|
|
92
|
+
)
|
|
93
|
+
return slug
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _relative_dir(root: Path, directory: Path) -> str | None:
|
|
97
|
+
try:
|
|
98
|
+
rel = directory.relative_to(root)
|
|
99
|
+
except ValueError:
|
|
100
|
+
return None
|
|
101
|
+
if rel == Path("."):
|
|
102
|
+
return None
|
|
103
|
+
return rel.as_posix()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _deploy_backend(
|
|
107
|
+
analysis: ProjectAnalysis,
|
|
108
|
+
plan: DeploymentPlan,
|
|
109
|
+
providers: dict[str, DeploymentProvider],
|
|
110
|
+
confirm: ConfirmCallback,
|
|
111
|
+
non_interactive: bool,
|
|
112
|
+
on_step: StepCallback,
|
|
113
|
+
timeout: int,
|
|
114
|
+
plan_name: str,
|
|
115
|
+
) -> DeploymentResult | None:
|
|
116
|
+
backend = analysis.backend
|
|
117
|
+
if backend is None:
|
|
118
|
+
return None
|
|
119
|
+
on_step(f"Backend: {backend.framework} → {backend.provider}")
|
|
120
|
+
|
|
121
|
+
provider = providers[backend.provider]
|
|
122
|
+
label = next(s.label for s in plan.services if s.kind == "backend")
|
|
123
|
+
_require_auth(provider, backend.provider.title())
|
|
124
|
+
|
|
125
|
+
create: dict[str, Any] = dict(
|
|
126
|
+
name=label,
|
|
127
|
+
repo=_repo_slug(analysis),
|
|
128
|
+
runtime=backend.language or "python",
|
|
129
|
+
root_directory=_relative_dir(analysis.root, backend.directory),
|
|
130
|
+
build_command=backend.build_command,
|
|
131
|
+
start_command=backend.start_command,
|
|
132
|
+
health_check_path=backend.health_path,
|
|
133
|
+
branch=analysis.repo.branch or "main",
|
|
134
|
+
plan=plan_name,
|
|
135
|
+
)
|
|
136
|
+
on_step("Creating backend service…")
|
|
137
|
+
result = provider.create_project(**create)
|
|
138
|
+
on_step("Deploying backend…")
|
|
139
|
+
result = provider.deploy(result, ref=analysis.repo.branch or "main")
|
|
140
|
+
status = provider.poll_until_ready(
|
|
141
|
+
result, timeout=timeout, on_progress=lambda s: on_step(f"Backend status: {s.state}")
|
|
142
|
+
)
|
|
143
|
+
if status.state in {"error", "canceled"}:
|
|
144
|
+
raise DeploymentError(f"Backend deployment failed: {status.message or 'unknown error'}")
|
|
145
|
+
if status.url:
|
|
146
|
+
result.url = status.url
|
|
147
|
+
result.status = "ready"
|
|
148
|
+
return result
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _deploy_frontend(
|
|
152
|
+
analysis: ProjectAnalysis,
|
|
153
|
+
plan: DeploymentPlan,
|
|
154
|
+
providers: dict[str, DeploymentProvider],
|
|
155
|
+
confirm: ConfirmCallback,
|
|
156
|
+
non_interactive: bool,
|
|
157
|
+
on_step: StepCallback,
|
|
158
|
+
timeout: int,
|
|
159
|
+
backend_url: str | None,
|
|
160
|
+
) -> tuple[DeploymentResult | None, list[tuple[str, str]]]:
|
|
161
|
+
frontend = analysis.frontend
|
|
162
|
+
if frontend is None:
|
|
163
|
+
return None, []
|
|
164
|
+
on_step(f"Frontend: {frontend.framework} → {frontend.provider}")
|
|
165
|
+
|
|
166
|
+
provider = providers[frontend.provider]
|
|
167
|
+
_require_auth(provider, frontend.provider.title())
|
|
168
|
+
|
|
169
|
+
frontend_result = provider.create_project(
|
|
170
|
+
name=analysis.name,
|
|
171
|
+
repo=_repo_slug(analysis),
|
|
172
|
+
directory=_relative_dir(analysis.root, frontend.directory),
|
|
173
|
+
framework=frontend.framework,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
envs: list[tuple[str, str]] = []
|
|
177
|
+
if backend_url:
|
|
178
|
+
api_var = next((e.key for e in plan.env_vars if e.applies_to == "frontend"), None)
|
|
179
|
+
if api_var:
|
|
180
|
+
message = f"Set {api_var}={backend_url} on {frontend.provider}?"
|
|
181
|
+
if non_interactive or (confirm and confirm(message)):
|
|
182
|
+
on_step(f"Configuring environment: {api_var}")
|
|
183
|
+
provider.set_env(frontend_result, api_var, backend_url)
|
|
184
|
+
envs = [(api_var, backend_url)]
|
|
185
|
+
|
|
186
|
+
on_step("Deploying frontend…")
|
|
187
|
+
frontend_result = provider.deploy(frontend_result, ref=analysis.repo.branch or "main")
|
|
188
|
+
status = provider.poll_until_ready(
|
|
189
|
+
frontend_result,
|
|
190
|
+
timeout=timeout,
|
|
191
|
+
on_progress=lambda s: on_step(f"Frontend status: {s.state}"),
|
|
192
|
+
)
|
|
193
|
+
if status.state in {"error", "canceled"}:
|
|
194
|
+
raise DeploymentError(f"Frontend deployment failed: {status.message or 'unknown error'}")
|
|
195
|
+
if status.url:
|
|
196
|
+
frontend_result.url = status.url
|
|
197
|
+
frontend_result.status = "ready"
|
|
198
|
+
return frontend_result, envs
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _set_backend_cors(
|
|
202
|
+
analysis: ProjectAnalysis,
|
|
203
|
+
plan: DeploymentPlan,
|
|
204
|
+
providers: dict[str, DeploymentProvider],
|
|
205
|
+
backend: DeploymentResult,
|
|
206
|
+
frontend_url: str,
|
|
207
|
+
on_step: StepCallback,
|
|
208
|
+
) -> bool:
|
|
209
|
+
cors_var = next((e.key for e in plan.env_vars if e.applies_to == "backend"), None)
|
|
210
|
+
if not cors_var or analysis.backend is None:
|
|
211
|
+
return False
|
|
212
|
+
provider = providers[analysis.backend.provider]
|
|
213
|
+
on_step(f"Configuring backend CORS: {cors_var}")
|
|
214
|
+
provider.set_env(backend, cors_var, frontend_url)
|
|
215
|
+
return True
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def run_deploy(
|
|
219
|
+
analysis: ProjectAnalysis,
|
|
220
|
+
plan: DeploymentPlan,
|
|
221
|
+
providers: dict[str, DeploymentProvider],
|
|
222
|
+
*,
|
|
223
|
+
security_enabled: bool = True,
|
|
224
|
+
block_on_secrets: bool = True,
|
|
225
|
+
security_level: str = "normal",
|
|
226
|
+
dry_run: bool = False,
|
|
227
|
+
non_interactive: bool = False,
|
|
228
|
+
confirm: ConfirmCallback = None,
|
|
229
|
+
on_step: StepCallback = lambda _m: None,
|
|
230
|
+
timeout: int = 900,
|
|
231
|
+
plan_name: str = "starter",
|
|
232
|
+
) -> DeployOutcome:
|
|
233
|
+
"""Execute the deployment pipeline for an analyzed project."""
|
|
234
|
+
outcome = DeployOutcome(dry_run=dry_run, warnings=[])
|
|
235
|
+
|
|
236
|
+
if security_enabled:
|
|
237
|
+
on_step("Running security preflight…")
|
|
238
|
+
report = scan_project(analysis.root, level=security_level)
|
|
239
|
+
if report.high_confidence and block_on_secrets and not dry_run:
|
|
240
|
+
files = ", ".join(sorted({f.file for f in report.findings}))
|
|
241
|
+
raise SecurityError(
|
|
242
|
+
"Security preflight blocked this deployment.\n"
|
|
243
|
+
f"Potential secret(s) detected in: {files}\n\n"
|
|
244
|
+
"DeployForge will not deploy projects that leak credentials.\n"
|
|
245
|
+
"Fix the issue and retry."
|
|
246
|
+
)
|
|
247
|
+
if not report.high_confidence:
|
|
248
|
+
on_step(f"Security preflight passed ({len(report.findings)} low-confidence findings)")
|
|
249
|
+
|
|
250
|
+
if dry_run:
|
|
251
|
+
return outcome
|
|
252
|
+
|
|
253
|
+
succeeded: list[str] = []
|
|
254
|
+
backend: DeploymentResult | None = None
|
|
255
|
+
frontend: DeploymentResult | None = None
|
|
256
|
+
envs: list[tuple[str, str]] = []
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
backend = _deploy_backend(
|
|
260
|
+
analysis,
|
|
261
|
+
plan,
|
|
262
|
+
providers,
|
|
263
|
+
confirm,
|
|
264
|
+
non_interactive,
|
|
265
|
+
on_step,
|
|
266
|
+
timeout,
|
|
267
|
+
plan_name,
|
|
268
|
+
)
|
|
269
|
+
if backend:
|
|
270
|
+
succeeded.append("backend")
|
|
271
|
+
outcome.backend = backend
|
|
272
|
+
outcome.backend_url = backend.url
|
|
273
|
+
|
|
274
|
+
frontend, envs = _deploy_frontend(
|
|
275
|
+
analysis,
|
|
276
|
+
plan,
|
|
277
|
+
providers,
|
|
278
|
+
confirm,
|
|
279
|
+
non_interactive,
|
|
280
|
+
on_step,
|
|
281
|
+
timeout,
|
|
282
|
+
outcome.backend_url,
|
|
283
|
+
)
|
|
284
|
+
if frontend:
|
|
285
|
+
succeeded.append("frontend")
|
|
286
|
+
outcome.frontend = frontend
|
|
287
|
+
outcome.frontend_url = frontend.url
|
|
288
|
+
|
|
289
|
+
outcome.env_vars_set = envs
|
|
290
|
+
|
|
291
|
+
if (
|
|
292
|
+
backend
|
|
293
|
+
and outcome.frontend_url
|
|
294
|
+
and _set_backend_cors(
|
|
295
|
+
analysis,
|
|
296
|
+
plan,
|
|
297
|
+
providers,
|
|
298
|
+
backend,
|
|
299
|
+
outcome.frontend_url,
|
|
300
|
+
on_step,
|
|
301
|
+
)
|
|
302
|
+
):
|
|
303
|
+
succeeded.append("cors")
|
|
304
|
+
|
|
305
|
+
outcomes: list[tuple[str, str, str | None]] = []
|
|
306
|
+
if outcome.frontend_url:
|
|
307
|
+
outcomes.append(("Frontend", outcome.frontend_url, None))
|
|
308
|
+
if outcome.backend_url:
|
|
309
|
+
health = analysis.backend.health_path if analysis.backend else None
|
|
310
|
+
outcomes.append(("Backend", outcome.backend_url, health))
|
|
311
|
+
if outcomes:
|
|
312
|
+
on_step("Verifying deployments…")
|
|
313
|
+
outcome.verification = verify_endpoints(outcomes)
|
|
314
|
+
|
|
315
|
+
_record(outcome)
|
|
316
|
+
return outcome
|
|
317
|
+
|
|
318
|
+
except (DeploymentError, ProviderError, AuthenticationError, ProjectError) as exc:
|
|
319
|
+
if succeeded:
|
|
320
|
+
message = "PARTIAL DEPLOYMENT\n\n"
|
|
321
|
+
if "backend" in succeeded:
|
|
322
|
+
message += f"✓ Backend deployed\n {outcome.backend_url}\n\n"
|
|
323
|
+
if "frontend" in succeeded:
|
|
324
|
+
message += f"✓ Frontend deployed\n {outcome.frontend_url}\n\n"
|
|
325
|
+
message += f"✗ {exc}\n\n"
|
|
326
|
+
message += "Remaining services stay untouched.\n"
|
|
327
|
+
message += "Run 'deployforge logs' for details."
|
|
328
|
+
_record(outcome)
|
|
329
|
+
raise PartialDeploymentError(message, succeeded) from exc
|
|
330
|
+
_record(outcome)
|
|
331
|
+
raise
|