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
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Deployment provider abstraction.
|
|
2
|
+
|
|
3
|
+
All cloud integrations go through :class:`DeploymentProvider`. Every backend
|
|
4
|
+
operation is performed through :class:`HttpClient` so the network layer can be
|
|
5
|
+
mocked cleanly in tests. Providers raise :class:`ProviderError` on failures.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import requests
|
|
16
|
+
|
|
17
|
+
from deployforge.errors import AuthenticationError, ProviderError
|
|
18
|
+
|
|
19
|
+
DEFAULT_TIMEOUT = 30.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HttpClient:
|
|
23
|
+
"""Thin wrapper around ``requests`` with typed error handling."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, timeout: float = DEFAULT_TIMEOUT) -> None:
|
|
26
|
+
self.timeout = timeout
|
|
27
|
+
|
|
28
|
+
def request(self, method: str, url: str, **kwargs: Any) -> Any:
|
|
29
|
+
"""Perform an HTTP request and return the parsed JSON body.
|
|
30
|
+
|
|
31
|
+
Raises :class:`ProviderError` for HTTP errors and transport failures.
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
response = requests.request(method, url, timeout=self.timeout, **kwargs)
|
|
35
|
+
except requests.RequestException as exc:
|
|
36
|
+
raise ProviderError(f"Network error while contacting {url}: {exc}") from exc
|
|
37
|
+
|
|
38
|
+
if response.status_code in (401, 403):
|
|
39
|
+
raise AuthenticationError(
|
|
40
|
+
f"Authentication failed for {url.split('/')[2]}. "
|
|
41
|
+
"Check your API token or run 'deployforge doctor'."
|
|
42
|
+
)
|
|
43
|
+
if response.status_code >= 400:
|
|
44
|
+
detail = _error_detail(response)
|
|
45
|
+
message = f"HTTP {response.status_code} from {url}: {detail}"
|
|
46
|
+
raise ProviderError(message, status_code=response.status_code)
|
|
47
|
+
if response.status_code == 204 or not response.text:
|
|
48
|
+
return None
|
|
49
|
+
try:
|
|
50
|
+
return response.json()
|
|
51
|
+
except ValueError as exc:
|
|
52
|
+
raise ProviderError(f"Invalid JSON response from {url}") from exc
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _error_detail(response: requests.Response) -> str:
|
|
56
|
+
try:
|
|
57
|
+
data = response.json()
|
|
58
|
+
except ValueError:
|
|
59
|
+
return response.text[:300]
|
|
60
|
+
if isinstance(data, dict):
|
|
61
|
+
message = data.get("message") or data.get("error") or data.get("detail")
|
|
62
|
+
if isinstance(message, str) and message:
|
|
63
|
+
return message[:300]
|
|
64
|
+
return response.text[:300]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class AuthInfo:
|
|
69
|
+
authenticated: bool
|
|
70
|
+
provider: str
|
|
71
|
+
account: str | None = None
|
|
72
|
+
source: str = "env"
|
|
73
|
+
|
|
74
|
+
def describe(self) -> str:
|
|
75
|
+
if self.authenticated:
|
|
76
|
+
return f"{self.provider} authenticated"
|
|
77
|
+
return f"{self.provider} not authenticated"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class DeploymentResult:
|
|
82
|
+
provider: str
|
|
83
|
+
project_name: str
|
|
84
|
+
service_id: str
|
|
85
|
+
url: str | None = None
|
|
86
|
+
status: str = "created"
|
|
87
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
88
|
+
|
|
89
|
+
def to_dict(self) -> dict[str, Any]:
|
|
90
|
+
return {
|
|
91
|
+
"provider": self.provider,
|
|
92
|
+
"project_name": self.project_name,
|
|
93
|
+
"service_id": self.service_id,
|
|
94
|
+
"url": self.url,
|
|
95
|
+
"status": self.status,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class DeployStatus:
|
|
101
|
+
provider: str
|
|
102
|
+
state: str
|
|
103
|
+
url: str | None
|
|
104
|
+
message: str | None = None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class DeploymentProvider(ABC):
|
|
108
|
+
"""Interface every deployment provider must implement."""
|
|
109
|
+
|
|
110
|
+
name: str = "abstract"
|
|
111
|
+
|
|
112
|
+
def __init__(self, client: HttpClient | None = None) -> None:
|
|
113
|
+
self.http = client or HttpClient()
|
|
114
|
+
|
|
115
|
+
@abstractmethod
|
|
116
|
+
def authenticate(self) -> AuthInfo: ...
|
|
117
|
+
|
|
118
|
+
@abstractmethod
|
|
119
|
+
def create_project(self, *, name: str, **kwargs: Any) -> DeploymentResult: ...
|
|
120
|
+
|
|
121
|
+
@abstractmethod
|
|
122
|
+
def deploy(self, project: DeploymentResult, *, ref: str | None = None) -> DeploymentResult: ...
|
|
123
|
+
|
|
124
|
+
@abstractmethod
|
|
125
|
+
def get_status(self, project: DeploymentResult) -> DeployStatus: ...
|
|
126
|
+
|
|
127
|
+
@abstractmethod
|
|
128
|
+
def get_url(self, project: DeploymentResult) -> str | None: ...
|
|
129
|
+
|
|
130
|
+
@abstractmethod
|
|
131
|
+
def get_logs(self, project: DeploymentResult) -> list[str]: ...
|
|
132
|
+
|
|
133
|
+
def set_env(self, project: DeploymentResult, key: str, value: str) -> None:
|
|
134
|
+
raise ProviderError(f"{self.name} does not support setting environment variables.")
|
|
135
|
+
|
|
136
|
+
def poll_until_ready(
|
|
137
|
+
self,
|
|
138
|
+
project: DeploymentResult,
|
|
139
|
+
timeout: int = 900,
|
|
140
|
+
interval: float = 8.0,
|
|
141
|
+
on_progress: Any | None = None,
|
|
142
|
+
) -> DeployStatus:
|
|
143
|
+
deadline = time.monotonic() + timeout
|
|
144
|
+
while time.monotonic() < deadline:
|
|
145
|
+
status = self.get_status(project)
|
|
146
|
+
if on_progress is not None:
|
|
147
|
+
on_progress(status)
|
|
148
|
+
if status.state in {"ready", "error", "canceled"}:
|
|
149
|
+
return status
|
|
150
|
+
time.sleep(interval)
|
|
151
|
+
return self.get_status(project)
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Render deployment provider.
|
|
2
|
+
|
|
3
|
+
Uses the documented Render REST API (https://api.render.com). Web services
|
|
4
|
+
are created and polled through official endpoints. Deploys with ``autoDeploy``
|
|
5
|
+
so future GitHub pushes redeploy automatically.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from deployforge.errors import ProviderError
|
|
13
|
+
from deployforge.providers.base import (
|
|
14
|
+
AuthInfo,
|
|
15
|
+
DeploymentProvider,
|
|
16
|
+
DeploymentResult,
|
|
17
|
+
DeployStatus,
|
|
18
|
+
HttpClient,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
API_BASE = "https://api.render.com/v1"
|
|
22
|
+
|
|
23
|
+
RENDER_ENV_MAP = {
|
|
24
|
+
"python": "python3",
|
|
25
|
+
"node": "node",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
_LIVE_STATES = {"Live", "live"}
|
|
29
|
+
_BUILD_STATES = {"Build in progress", "Provisioning", "Pending", "Scheduled", "Deploying"}
|
|
30
|
+
_ERROR_STATES = {"Deploy failed", "Build failed", "Canceled", "Canceled and rollback"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RenderProvider(DeploymentProvider):
|
|
34
|
+
name = "render"
|
|
35
|
+
|
|
36
|
+
def __init__(self, api_key: str | None, client: HttpClient | None = None) -> None:
|
|
37
|
+
super().__init__(client)
|
|
38
|
+
self.api_key = api_key
|
|
39
|
+
self._owner_id: str | None = None
|
|
40
|
+
|
|
41
|
+
def _headers(self) -> dict[str, str]:
|
|
42
|
+
if not self.api_key:
|
|
43
|
+
raise ProviderError(
|
|
44
|
+
"Render authentication is missing. Set RENDER_API_KEY or run 'deployforge config'."
|
|
45
|
+
)
|
|
46
|
+
return {"Authorization": f"Bearer {self.api_key}", "Accept": "application/json"}
|
|
47
|
+
|
|
48
|
+
def _get_owner_id(self) -> str:
|
|
49
|
+
if self._owner_id:
|
|
50
|
+
return self._owner_id
|
|
51
|
+
data = self.http.request("GET", f"{API_BASE}/owners", headers=self._headers())
|
|
52
|
+
if not isinstance(data, list) or not data:
|
|
53
|
+
raise ProviderError("Render returned no owners; check your API key.")
|
|
54
|
+
owner = data[0]
|
|
55
|
+
if not isinstance(owner, dict) or not owner.get("id"):
|
|
56
|
+
raise ProviderError("Render returned an unexpected owner object.")
|
|
57
|
+
self._owner_id = str(owner["id"])
|
|
58
|
+
return self._owner_id
|
|
59
|
+
|
|
60
|
+
def authenticate(self) -> AuthInfo:
|
|
61
|
+
if not self.api_key:
|
|
62
|
+
return AuthInfo(authenticated=False, provider="render")
|
|
63
|
+
try:
|
|
64
|
+
data = self.http.request("GET", f"{API_BASE}/owners", headers=self._headers())
|
|
65
|
+
except ProviderError:
|
|
66
|
+
return AuthInfo(authenticated=False, provider="render")
|
|
67
|
+
if isinstance(data, list) and data:
|
|
68
|
+
account = None
|
|
69
|
+
owner = data[0]
|
|
70
|
+
if isinstance(owner, dict) and isinstance(owner.get("name"), str):
|
|
71
|
+
account = owner["name"]
|
|
72
|
+
return AuthInfo(authenticated=True, provider="render", account=account)
|
|
73
|
+
return AuthInfo(authenticated=False, provider="render")
|
|
74
|
+
|
|
75
|
+
def create_project(
|
|
76
|
+
self,
|
|
77
|
+
*,
|
|
78
|
+
name: str,
|
|
79
|
+
repo: str | None = None,
|
|
80
|
+
runtime: str | None = None,
|
|
81
|
+
root_directory: str | None = None,
|
|
82
|
+
build_command: str | None = None,
|
|
83
|
+
start_command: str | None = None,
|
|
84
|
+
plan: str = "starter",
|
|
85
|
+
branch: str = "main",
|
|
86
|
+
health_check_path: str | None = None,
|
|
87
|
+
**kwargs: Any,
|
|
88
|
+
) -> DeploymentResult:
|
|
89
|
+
if not repo or not runtime:
|
|
90
|
+
raise ProviderError("Render requires a repository and runtime to deploy.")
|
|
91
|
+
owner_id = self._get_owner_id()
|
|
92
|
+
payload: dict[str, Any] = {
|
|
93
|
+
"type": "web_service",
|
|
94
|
+
"name": name,
|
|
95
|
+
"env": RENDER_ENV_MAP.get(runtime, runtime),
|
|
96
|
+
"plan": plan,
|
|
97
|
+
"repo": repo,
|
|
98
|
+
"branch": branch,
|
|
99
|
+
"autoDeploy": True,
|
|
100
|
+
}
|
|
101
|
+
if root_directory and root_directory not in (".", ""):
|
|
102
|
+
payload["rootDir"] = root_directory
|
|
103
|
+
if build_command:
|
|
104
|
+
payload["buildCommand"] = build_command
|
|
105
|
+
if start_command:
|
|
106
|
+
payload["startCommand"] = start_command
|
|
107
|
+
if health_check_path:
|
|
108
|
+
payload["healthCheckPath"] = health_check_path
|
|
109
|
+
data = self.http.request(
|
|
110
|
+
"POST",
|
|
111
|
+
f"{API_BASE}/services?ownerId={owner_id}",
|
|
112
|
+
headers=self._headers(),
|
|
113
|
+
json=payload,
|
|
114
|
+
)
|
|
115
|
+
if not isinstance(data, dict) or not data.get("id"):
|
|
116
|
+
raise ProviderError("Render did not return a service id.")
|
|
117
|
+
service_id = str(data["id"])
|
|
118
|
+
url = None
|
|
119
|
+
details = data.get("serviceDetails")
|
|
120
|
+
if isinstance(details, dict) and isinstance(details.get("url"), str):
|
|
121
|
+
url = details["url"]
|
|
122
|
+
return DeploymentResult(
|
|
123
|
+
provider="render",
|
|
124
|
+
project_name=str(data.get("name") or name),
|
|
125
|
+
service_id=service_id,
|
|
126
|
+
url=url,
|
|
127
|
+
status="created",
|
|
128
|
+
extra={"owner_id": owner_id},
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def set_env(self, project: DeploymentResult, key: str, value: str) -> None:
|
|
132
|
+
payload = {"envVars": [{"key": key, "value": value}]}
|
|
133
|
+
self.http.request(
|
|
134
|
+
"PUT",
|
|
135
|
+
f"{API_BASE}/services/{project.service_id}/env-vars",
|
|
136
|
+
headers=self._headers(),
|
|
137
|
+
json=payload,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def deploy(self, project: DeploymentResult, *, ref: str | None = None) -> DeploymentResult:
|
|
141
|
+
# Render deploys automatically on push when autoDeploy is enabled.
|
|
142
|
+
# Pushing the branch or triggering via service is handled by Render.
|
|
143
|
+
status = self.get_status(project)
|
|
144
|
+
return DeploymentResult(
|
|
145
|
+
provider="render",
|
|
146
|
+
project_name=project.project_name,
|
|
147
|
+
service_id=project.service_id,
|
|
148
|
+
url=status.url,
|
|
149
|
+
status=status.state,
|
|
150
|
+
extra=project.extra,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def get_status(self, project: DeploymentResult) -> DeployStatus:
|
|
154
|
+
data = self.http.request(
|
|
155
|
+
"GET",
|
|
156
|
+
f"{API_BASE}/services/{project.service_id}",
|
|
157
|
+
headers=self._headers(),
|
|
158
|
+
)
|
|
159
|
+
if not isinstance(data, dict):
|
|
160
|
+
raise ProviderError("Render returned an unexpected service response.")
|
|
161
|
+
url = None
|
|
162
|
+
details = data.get("serviceDetails")
|
|
163
|
+
if isinstance(details, dict) and isinstance(details.get("url"), str):
|
|
164
|
+
url = details["url"]
|
|
165
|
+
|
|
166
|
+
deploys = self.http.request(
|
|
167
|
+
"GET",
|
|
168
|
+
f"{API_BASE}/services/{project.service_id}/deploys",
|
|
169
|
+
headers=self._headers(),
|
|
170
|
+
)
|
|
171
|
+
latest_status = "Live"
|
|
172
|
+
if isinstance(deploys, list) and deploys:
|
|
173
|
+
first = deploys[0]
|
|
174
|
+
if isinstance(first, dict):
|
|
175
|
+
latest_status = str(first.get("status") or "Live")
|
|
176
|
+
|
|
177
|
+
if latest_status in _LIVE_STATES:
|
|
178
|
+
return DeployStatus(provider="render", state="ready", url=url)
|
|
179
|
+
if latest_status in _ERROR_STATES:
|
|
180
|
+
message = None
|
|
181
|
+
if isinstance(deploys, list) and deploys and isinstance(deploys[0], dict):
|
|
182
|
+
message = (
|
|
183
|
+
deploys[0].get("commit", {}).get("message")
|
|
184
|
+
if isinstance(deploys[0].get("commit"), dict)
|
|
185
|
+
else None
|
|
186
|
+
)
|
|
187
|
+
return DeployStatus(
|
|
188
|
+
provider="render", state="error", url=url, message=message or latest_status
|
|
189
|
+
)
|
|
190
|
+
return DeployStatus(provider="render", state="building", url=url)
|
|
191
|
+
|
|
192
|
+
def get_url(self, project: DeploymentResult) -> str | None:
|
|
193
|
+
return self.get_status(project).url
|
|
194
|
+
|
|
195
|
+
def get_logs(self, project: DeploymentResult) -> list[str]:
|
|
196
|
+
deploys = self.http.request(
|
|
197
|
+
"GET",
|
|
198
|
+
f"{API_BASE}/services/{project.service_id}/deploys",
|
|
199
|
+
headers=self._headers(),
|
|
200
|
+
)
|
|
201
|
+
logs: list[str] = []
|
|
202
|
+
if not isinstance(deploys, list):
|
|
203
|
+
return logs
|
|
204
|
+
for deploy in deploys[:10]:
|
|
205
|
+
if not isinstance(deploy, dict):
|
|
206
|
+
continue
|
|
207
|
+
commit = deploy.get("commit")
|
|
208
|
+
message = None
|
|
209
|
+
if isinstance(commit, dict):
|
|
210
|
+
message = commit.get("message")
|
|
211
|
+
ref = commit.get("id", "unknown")[:9]
|
|
212
|
+
else:
|
|
213
|
+
ref = "n/a"
|
|
214
|
+
line = f"[{deploy.get('status')}] ref {ref}"
|
|
215
|
+
if isinstance(message, str) and message:
|
|
216
|
+
line += f" — {message[:80]}"
|
|
217
|
+
logs.append(line)
|
|
218
|
+
return logs
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Vercel deployment provider.
|
|
2
|
+
|
|
3
|
+
Uses the documented Vercel REST API (https://api.vercel.com). Frameworks and
|
|
4
|
+
git repositories are connected through official project/deployment endpoints.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from deployforge.errors import ProviderError
|
|
12
|
+
from deployforge.providers.base import (
|
|
13
|
+
AuthInfo,
|
|
14
|
+
DeploymentProvider,
|
|
15
|
+
DeploymentResult,
|
|
16
|
+
DeployStatus,
|
|
17
|
+
HttpClient,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
API_BASE = "https://api.vercel.com"
|
|
21
|
+
|
|
22
|
+
# Vercel preset names for supported frameworks.
|
|
23
|
+
FRAMEWORK_PRESETS: dict[str, str | None] = {
|
|
24
|
+
"Next.js": "nextjs",
|
|
25
|
+
"React": "create-react-app",
|
|
26
|
+
"Vite": "vite",
|
|
27
|
+
"Vue": "vue",
|
|
28
|
+
"Nuxt": "nuxtjs",
|
|
29
|
+
"Angular": "angular",
|
|
30
|
+
"Astro": "astro",
|
|
31
|
+
"Svelte": "svelte",
|
|
32
|
+
"SvelteKit": "sveltekit",
|
|
33
|
+
"Static HTML": None,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
_STATE_READY = {"READY"}
|
|
37
|
+
_STATE_ERROR = {"ERROR", "ERROR_BUILD", "ERROR_DEPLOYMENT", "ERROR_INSPECTOR_BLOCKED"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class VercelProvider(DeploymentProvider):
|
|
41
|
+
name = "vercel"
|
|
42
|
+
|
|
43
|
+
def __init__(self, token: str | None, client: HttpClient | None = None) -> None:
|
|
44
|
+
super().__init__(client)
|
|
45
|
+
self.token = token
|
|
46
|
+
|
|
47
|
+
# -- HTTP helpers -------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
def _headers(self) -> dict[str, str]:
|
|
50
|
+
if not self.token:
|
|
51
|
+
raise ProviderError(
|
|
52
|
+
"Vercel authentication is missing. Set VERCEL_TOKEN or run 'deployforge config'."
|
|
53
|
+
)
|
|
54
|
+
return {
|
|
55
|
+
"Authorization": f"Bearer {self.token}",
|
|
56
|
+
"Accept": "application/json",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# -- DeploymentProvider -------------------------------------------------
|
|
60
|
+
|
|
61
|
+
def authenticate(self) -> AuthInfo:
|
|
62
|
+
if not self.token:
|
|
63
|
+
return AuthInfo(authenticated=False, provider="vercel")
|
|
64
|
+
data = self.http.request("GET", f"{API_BASE}/v2/user", headers=self._headers())
|
|
65
|
+
user = data.get("user") if isinstance(data, dict) else None
|
|
66
|
+
account = user.get("username") if isinstance(user, dict) else None
|
|
67
|
+
return AuthInfo(authenticated=True, provider="vercel", account=account)
|
|
68
|
+
|
|
69
|
+
def create_project(
|
|
70
|
+
self,
|
|
71
|
+
*,
|
|
72
|
+
name: str,
|
|
73
|
+
repo: str | None = None,
|
|
74
|
+
directory: str | None = None,
|
|
75
|
+
framework: str | None = None,
|
|
76
|
+
private: bool = True,
|
|
77
|
+
**kwargs: Any,
|
|
78
|
+
) -> DeploymentResult:
|
|
79
|
+
if not repo:
|
|
80
|
+
raise ProviderError("Vercel requires a GitHub repository to deploy.")
|
|
81
|
+
payload: dict[str, Any] = {
|
|
82
|
+
"name": name,
|
|
83
|
+
"gitRepository": {"type": "github", "repo": repo},
|
|
84
|
+
"public": not private,
|
|
85
|
+
}
|
|
86
|
+
preset = FRAMEWORK_PRESETS.get(framework or "", framework)
|
|
87
|
+
if preset:
|
|
88
|
+
payload["framework"] = preset
|
|
89
|
+
if directory and directory not in (".", ""):
|
|
90
|
+
payload["rootDirectory"] = directory
|
|
91
|
+
|
|
92
|
+
data = self.http.request(
|
|
93
|
+
"POST",
|
|
94
|
+
f"{API_BASE}/v10/projects",
|
|
95
|
+
headers=self._headers(),
|
|
96
|
+
json=payload,
|
|
97
|
+
)
|
|
98
|
+
if not isinstance(data, dict):
|
|
99
|
+
msg = "Vercel returned an unexpected response while creating the project."
|
|
100
|
+
raise ProviderError(msg)
|
|
101
|
+
project_id = str(data.get("id") or "")
|
|
102
|
+
project_name = str(data.get("name") or name)
|
|
103
|
+
if not project_id:
|
|
104
|
+
raise ProviderError("Vercel did not return a project id.")
|
|
105
|
+
return DeploymentResult(
|
|
106
|
+
provider="vercel",
|
|
107
|
+
project_name=project_name,
|
|
108
|
+
service_id=project_id,
|
|
109
|
+
status="created",
|
|
110
|
+
extra={"framework": framework},
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def set_env(self, project: DeploymentResult, key: str, value: str) -> None:
|
|
114
|
+
payload = {
|
|
115
|
+
"key": key,
|
|
116
|
+
"value": value,
|
|
117
|
+
"target": ["production", "preview", "development"],
|
|
118
|
+
"type": "encrypted",
|
|
119
|
+
}
|
|
120
|
+
self.http.request(
|
|
121
|
+
"POST",
|
|
122
|
+
f"{API_BASE}/v10/projects/{project.service_id}/env",
|
|
123
|
+
headers=self._headers(),
|
|
124
|
+
json=payload,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def deploy(self, project: DeploymentResult, *, ref: str | None = None) -> DeploymentResult:
|
|
128
|
+
payload: dict[str, Any] = {
|
|
129
|
+
"name": project.project_name,
|
|
130
|
+
"target": "production",
|
|
131
|
+
"gitSource": {"type": "github", "ref": ref or "main"},
|
|
132
|
+
}
|
|
133
|
+
try:
|
|
134
|
+
data = self.http.request(
|
|
135
|
+
"POST",
|
|
136
|
+
f"{API_BASE}/v13/deployments",
|
|
137
|
+
headers=self._headers(),
|
|
138
|
+
json=payload,
|
|
139
|
+
)
|
|
140
|
+
except ProviderError:
|
|
141
|
+
# With a git-connected project, a push triggers deployment
|
|
142
|
+
# automatically; we re-check the project for an active deployment.
|
|
143
|
+
return project
|
|
144
|
+
if not isinstance(data, dict):
|
|
145
|
+
return project
|
|
146
|
+
deployment_id = str(data.get("id") or "") or project.service_id
|
|
147
|
+
return DeploymentResult(
|
|
148
|
+
provider="vercel",
|
|
149
|
+
project_name=project.project_name,
|
|
150
|
+
service_id=deployment_id,
|
|
151
|
+
status="building",
|
|
152
|
+
extra=project.extra,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def get_status(self, project: DeploymentResult) -> DeployStatus:
|
|
156
|
+
data = self.http.request(
|
|
157
|
+
"GET",
|
|
158
|
+
f"{API_BASE}/v13/deployments/{project.service_id}",
|
|
159
|
+
headers=self._headers(),
|
|
160
|
+
)
|
|
161
|
+
if not isinstance(data, dict):
|
|
162
|
+
raise ProviderError("Vercel returned an unexpected deployment status.")
|
|
163
|
+
state = str(data.get("readyState") or data.get("state") or "QUEUED")
|
|
164
|
+
if state in _STATE_READY:
|
|
165
|
+
url = self._url_from_deployment(data)
|
|
166
|
+
return DeployStatus(provider="vercel", state="ready", url=url)
|
|
167
|
+
if state in _STATE_ERROR:
|
|
168
|
+
return DeployStatus(
|
|
169
|
+
provider="vercel",
|
|
170
|
+
state="error",
|
|
171
|
+
url=None,
|
|
172
|
+
message=f"Vercel deployment failed ({state}).",
|
|
173
|
+
)
|
|
174
|
+
return DeployStatus(provider="vercel", state="building", url=None)
|
|
175
|
+
|
|
176
|
+
@staticmethod
|
|
177
|
+
def _url_from_deployment(data: dict[str, Any]) -> str | None:
|
|
178
|
+
aliases = data.get("alias")
|
|
179
|
+
if isinstance(aliases, list) and aliases and isinstance(aliases[0], str):
|
|
180
|
+
return f"https://{aliases[0]}"
|
|
181
|
+
url = data.get("url")
|
|
182
|
+
if isinstance(url, str) and url:
|
|
183
|
+
return f"https://{url}"
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
def get_url(self, project: DeploymentResult) -> str | None:
|
|
187
|
+
status = self.get_status(project)
|
|
188
|
+
return status.url
|
|
189
|
+
|
|
190
|
+
def get_logs(self, project: DeploymentResult) -> list[str]:
|
|
191
|
+
data = self.http.request(
|
|
192
|
+
"GET",
|
|
193
|
+
f"{API_BASE}/v13/deployments/{project.service_id}",
|
|
194
|
+
headers=self._headers(),
|
|
195
|
+
)
|
|
196
|
+
if not isinstance(data, dict):
|
|
197
|
+
return []
|
|
198
|
+
logs: list[str] = []
|
|
199
|
+
if data.get("createdAt"):
|
|
200
|
+
logs.append(f"Deployment created (id {project.service_id})")
|
|
201
|
+
if data.get("readyState"):
|
|
202
|
+
logs.append(f"State: {data['readyState']}")
|
|
203
|
+
if data.get("errorMessage"):
|
|
204
|
+
logs.append(f"Error: {data['errorMessage']}")
|
|
205
|
+
return logs
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Gitignore validation.
|
|
2
|
+
|
|
3
|
+
Checks that a project's ``.gitignore`` covers the most sensitive file kinds so
|
|
4
|
+
the security gate can report drift before a deployment.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
REQUIRED_RULES = (
|
|
12
|
+
".env*",
|
|
13
|
+
"*.pem",
|
|
14
|
+
"*.key",
|
|
15
|
+
"credentials.json",
|
|
16
|
+
"service-account.json",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def validate_gitignore(root: Path) -> tuple[bool, list[str]]:
|
|
21
|
+
"""Return (ok, missing_rules) for the project's ``.gitignore``."""
|
|
22
|
+
gitignore = root / ".gitignore"
|
|
23
|
+
if not gitignore.exists():
|
|
24
|
+
return True, []
|
|
25
|
+
try:
|
|
26
|
+
lines = [line.strip() for line in gitignore.read_text(encoding="utf-8").splitlines()]
|
|
27
|
+
except OSError:
|
|
28
|
+
return True, []
|
|
29
|
+
present = {line for line in lines if line and not line.startswith("#")}
|
|
30
|
+
missing = [rule for rule in REQUIRED_RULES if not any(_matches(rule, line) for line in present)]
|
|
31
|
+
return not missing, missing
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _matches(rule: str, line: str) -> bool:
|
|
35
|
+
return line.endswith(rule) or line == rule
|