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.
Files changed (37) hide show
  1. deployforge/__init__.py +3 -0
  2. deployforge/__version__.py +3 -0
  3. deployforge/analyzer/__init__.py +23 -0
  4. deployforge/analyzer/backend.py +326 -0
  5. deployforge/analyzer/database.py +120 -0
  6. deployforge/analyzer/frontend.py +179 -0
  7. deployforge/analyzer/project.py +389 -0
  8. deployforge/analyzer/shared.py +109 -0
  9. deployforge/cli.py +825 -0
  10. deployforge/config.py +195 -0
  11. deployforge/deployment/__init__.py +19 -0
  12. deployforge/deployment/orchestrator.py +331 -0
  13. deployforge/deployment/planner.py +172 -0
  14. deployforge/deployment/verifier.py +65 -0
  15. deployforge/errors/__init__.py +53 -0
  16. deployforge/github/__init__.py +21 -0
  17. deployforge/github/integration.py +127 -0
  18. deployforge/integration/__init__.py +20 -0
  19. deployforge/integration/cors.py +30 -0
  20. deployforge/integration/environment.py +62 -0
  21. deployforge/integration/frontend_backend.py +39 -0
  22. deployforge/providers/__init__.py +32 -0
  23. deployforge/providers/base.py +151 -0
  24. deployforge/providers/render.py +218 -0
  25. deployforge/providers/vercel.py +205 -0
  26. deployforge/security/__init__.py +4 -0
  27. deployforge/security/gitignore.py +35 -0
  28. deployforge/security/scanner.py +125 -0
  29. deployforge/security/secrets.py +110 -0
  30. deployforge/ui/__init__.py +1 -0
  31. deployforge/ui/terminal.py +151 -0
  32. deployforge-0.1.0.dist-info/METADATA +218 -0
  33. deployforge-0.1.0.dist-info/RECORD +37 -0
  34. deployforge-0.1.0.dist-info/WHEEL +5 -0
  35. deployforge-0.1.0.dist-info/entry_points.txt +2 -0
  36. deployforge-0.1.0.dist-info/licenses/LICENSE +21 -0
  37. 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