devguard 0.2.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.
- devguard/INTEGRATION_SUMMARY.md +121 -0
- devguard/__init__.py +3 -0
- devguard/__main__.py +6 -0
- devguard/checkers/__init__.py +41 -0
- devguard/checkers/api_usage.py +523 -0
- devguard/checkers/aws_cost.py +331 -0
- devguard/checkers/aws_iam.py +284 -0
- devguard/checkers/base.py +25 -0
- devguard/checkers/container.py +137 -0
- devguard/checkers/domain.py +189 -0
- devguard/checkers/firecrawl.py +117 -0
- devguard/checkers/fly.py +225 -0
- devguard/checkers/github.py +210 -0
- devguard/checkers/npm.py +327 -0
- devguard/checkers/npm_security.py +244 -0
- devguard/checkers/redteam.py +290 -0
- devguard/checkers/secret.py +279 -0
- devguard/checkers/swarm.py +376 -0
- devguard/checkers/tailscale.py +143 -0
- devguard/checkers/tailsnitch.py +303 -0
- devguard/checkers/tavily.py +179 -0
- devguard/checkers/vercel.py +192 -0
- devguard/cli.py +1510 -0
- devguard/cli_helpers.py +189 -0
- devguard/config.py +249 -0
- devguard/core.py +293 -0
- devguard/dashboard.py +715 -0
- devguard/discovery.py +363 -0
- devguard/http_client.py +142 -0
- devguard/llm_service.py +481 -0
- devguard/mcp_server.py +259 -0
- devguard/metrics.py +144 -0
- devguard/models.py +208 -0
- devguard/reporting.py +1571 -0
- devguard/sarif.py +295 -0
- devguard/scripts/ANALYSIS_SUMMARY.md +141 -0
- devguard/scripts/README.md +221 -0
- devguard/scripts/auto_fix_recommendations.py +145 -0
- devguard/scripts/generate_npmignore.py +175 -0
- devguard/scripts/generate_security_report.py +324 -0
- devguard/scripts/prepublish_check.sh +29 -0
- devguard/scripts/redteam_npm_packages.py +1262 -0
- devguard/scripts/review_all_repos.py +300 -0
- devguard/spec.py +617 -0
- devguard/sweeps/__init__.py +23 -0
- devguard/sweeps/ai_editor_config_audit.py +697 -0
- devguard/sweeps/cargo_publish_audit.py +655 -0
- devguard/sweeps/dependency_audit.py +419 -0
- devguard/sweeps/gitignore_audit.py +336 -0
- devguard/sweeps/local_dev.py +260 -0
- devguard/sweeps/local_dirty_worktree_secrets.py +521 -0
- devguard/sweeps/project_flaudit.py +636 -0
- devguard/sweeps/public_github_secrets.py +680 -0
- devguard/sweeps/publish_audit.py +478 -0
- devguard/sweeps/ssh_key_audit.py +327 -0
- devguard/utils.py +174 -0
- devguard-0.2.0.dist-info/METADATA +225 -0
- devguard-0.2.0.dist-info/RECORD +60 -0
- devguard-0.2.0.dist-info/WHEEL +4 -0
- devguard-0.2.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Container security checker."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import ClassVar
|
|
7
|
+
|
|
8
|
+
from devguard.checkers.base import BaseChecker
|
|
9
|
+
from devguard.models import CheckResult, Severity, Vulnerability
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ContainerChecker(BaseChecker):
|
|
15
|
+
"""Check Dockerfiles for security best practices."""
|
|
16
|
+
|
|
17
|
+
check_type: ClassVar[str] = "container"
|
|
18
|
+
|
|
19
|
+
# Simple regex-based rules
|
|
20
|
+
RULES = [
|
|
21
|
+
{
|
|
22
|
+
"id": "run-as-root",
|
|
23
|
+
"pattern": r"^USER\s+root",
|
|
24
|
+
"severity": Severity.HIGH,
|
|
25
|
+
"summary": "Running as root user",
|
|
26
|
+
"description": "The container explicitly switches to root user. Use a non-privileged user instead.",
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"id": "missing-user",
|
|
30
|
+
"severity": Severity.MEDIUM,
|
|
31
|
+
"summary": "No USER instruction",
|
|
32
|
+
"description": "Dockerfile does not switch to a non-privileged user. It will likely run as root.",
|
|
33
|
+
"check_func": lambda content: not re.search(r"^USER\s+", content, re.MULTILINE),
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"id": "latest-tag",
|
|
37
|
+
"pattern": r"^FROM\s+[^:]+:latest",
|
|
38
|
+
"severity": Severity.MEDIUM,
|
|
39
|
+
"summary": "Using 'latest' tag",
|
|
40
|
+
"description": "Using the 'latest' tag makes builds non-reproducible and can introduce unexpected breaking changes.",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"id": "add-usage",
|
|
44
|
+
"pattern": r"^ADD\s+",
|
|
45
|
+
"severity": Severity.LOW,
|
|
46
|
+
"summary": "Using ADD instruction",
|
|
47
|
+
"description": "Use COPY instead of ADD unless you specifically need to extract tarballs or fetch remote URLs.",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"id": "exposed-secrets",
|
|
51
|
+
"pattern": r"(API_KEY|SECRET|PASSWORD|TOKEN)\s*=",
|
|
52
|
+
"severity": Severity.CRITICAL,
|
|
53
|
+
"summary": "Potential secret in Dockerfile",
|
|
54
|
+
"description": "Found a potential secret/credential embedded directly in the Dockerfile.",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"id": "sudo-usage",
|
|
58
|
+
"pattern": r"sudo\s+",
|
|
59
|
+
"severity": Severity.HIGH,
|
|
60
|
+
"summary": "Using sudo",
|
|
61
|
+
"description": "Avoid installing or using sudo in containers. It increases the attack surface.",
|
|
62
|
+
},
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
async def check(self) -> CheckResult:
|
|
66
|
+
"""Check Dockerfiles."""
|
|
67
|
+
vulnerabilities: list[Vulnerability] = []
|
|
68
|
+
errors: list[str] = []
|
|
69
|
+
metadata: dict = {"files_scanned": []}
|
|
70
|
+
|
|
71
|
+
# Find Dockerfiles
|
|
72
|
+
dockerfiles = self._find_dockerfiles()
|
|
73
|
+
|
|
74
|
+
for df in dockerfiles:
|
|
75
|
+
try:
|
|
76
|
+
vulns = self._scan_dockerfile(df)
|
|
77
|
+
vulnerabilities.extend(vulns)
|
|
78
|
+
metadata["files_scanned"].append(str(df))
|
|
79
|
+
except Exception as e:
|
|
80
|
+
errors.append(f"Error scanning {df}: {str(e)}")
|
|
81
|
+
|
|
82
|
+
return CheckResult(
|
|
83
|
+
check_type=self.check_type,
|
|
84
|
+
success=len(vulnerabilities) == 0 and len(errors) == 0,
|
|
85
|
+
vulnerabilities=vulnerabilities,
|
|
86
|
+
errors=errors,
|
|
87
|
+
metadata=metadata,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def _find_dockerfiles(self) -> list[Path]:
|
|
91
|
+
"""Find Dockerfiles in current directory and subdirs."""
|
|
92
|
+
# Use simple recursion or glob
|
|
93
|
+
# We limit depth to avoid massive scans
|
|
94
|
+
base = Path.cwd()
|
|
95
|
+
found = []
|
|
96
|
+
|
|
97
|
+
# Check explicit "Dockerfile"
|
|
98
|
+
for path in base.rglob("Dockerfile*"):
|
|
99
|
+
if path.is_file() and "node_modules" not in str(path) and ".git" not in str(path):
|
|
100
|
+
found.append(path)
|
|
101
|
+
|
|
102
|
+
return found
|
|
103
|
+
|
|
104
|
+
def _scan_dockerfile(self, path: Path) -> list[Vulnerability]:
|
|
105
|
+
"""Scan a single Dockerfile."""
|
|
106
|
+
vulns = []
|
|
107
|
+
try:
|
|
108
|
+
content = path.read_text(encoding="utf-8")
|
|
109
|
+
except UnicodeDecodeError:
|
|
110
|
+
return []
|
|
111
|
+
|
|
112
|
+
for rule in self.RULES:
|
|
113
|
+
match = False
|
|
114
|
+
|
|
115
|
+
if "check_func" in rule:
|
|
116
|
+
if rule["check_func"](content): # type: ignore
|
|
117
|
+
match = True
|
|
118
|
+
elif "pattern" in rule:
|
|
119
|
+
if re.search(rule["pattern"], content, re.MULTILINE | re.IGNORECASE): # type: ignore
|
|
120
|
+
match = True
|
|
121
|
+
|
|
122
|
+
if match:
|
|
123
|
+
vulns.append(
|
|
124
|
+
Vulnerability(
|
|
125
|
+
package_name=f"Dockerfile:{path.name}",
|
|
126
|
+
package_version="N/A",
|
|
127
|
+
severity=rule["severity"], # type: ignore
|
|
128
|
+
summary=rule["summary"], # type: ignore
|
|
129
|
+
description=rule["description"], # type: ignore
|
|
130
|
+
source="devguard-container-check",
|
|
131
|
+
references=[
|
|
132
|
+
"https://docs.docker.com/develop/develop-images/dockerfile_best-practices/"
|
|
133
|
+
],
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
return vulns
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Domain and SSL certificate checker."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import socket
|
|
6
|
+
import ssl
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from devguard.checkers.base import BaseChecker
|
|
12
|
+
from devguard.models import CheckResult, CheckStatus, DeploymentStatus, Finding, Severity
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DomainChecker(BaseChecker):
|
|
18
|
+
"""Check domain health and SSL certificate expiry."""
|
|
19
|
+
|
|
20
|
+
check_type = "domain"
|
|
21
|
+
|
|
22
|
+
# SSL warning thresholds (days)
|
|
23
|
+
SSL_CRITICAL_DAYS = 7
|
|
24
|
+
SSL_WARNING_DAYS = 30
|
|
25
|
+
|
|
26
|
+
async def check(self) -> CheckResult:
|
|
27
|
+
"""Check all domains for health and SSL status."""
|
|
28
|
+
deployments: list[DeploymentStatus] = []
|
|
29
|
+
findings: list[Finding] = []
|
|
30
|
+
errors: list[str] = []
|
|
31
|
+
|
|
32
|
+
domains = self.settings.domains_to_monitor
|
|
33
|
+
if not domains:
|
|
34
|
+
return CheckResult(
|
|
35
|
+
check_type=self.check_type,
|
|
36
|
+
success=True,
|
|
37
|
+
metadata={"skipped": "no domains configured (set DOMAINS_TO_MONITOR)"},
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
for domain in domains:
|
|
41
|
+
try:
|
|
42
|
+
# Check SSL certificate
|
|
43
|
+
ssl_info = await self._check_ssl(domain)
|
|
44
|
+
|
|
45
|
+
if ssl_info.get("error"):
|
|
46
|
+
status = CheckStatus.UNHEALTHY
|
|
47
|
+
findings.append(
|
|
48
|
+
Finding(
|
|
49
|
+
severity=Severity.HIGH,
|
|
50
|
+
title=f"SSL check failed: {domain}",
|
|
51
|
+
description=ssl_info["error"],
|
|
52
|
+
resource=domain,
|
|
53
|
+
remediation="Check domain DNS and certificate configuration",
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
else:
|
|
57
|
+
days_until_expiry = ssl_info.get("days_until_expiry", 0)
|
|
58
|
+
|
|
59
|
+
if days_until_expiry <= self.SSL_CRITICAL_DAYS:
|
|
60
|
+
status = CheckStatus.UNHEALTHY
|
|
61
|
+
findings.append(
|
|
62
|
+
Finding(
|
|
63
|
+
severity=Severity.CRITICAL,
|
|
64
|
+
title=f"SSL certificate expiring soon: {domain}",
|
|
65
|
+
description=f"Certificate expires in {days_until_expiry} days",
|
|
66
|
+
resource=domain,
|
|
67
|
+
remediation="Renew SSL certificate immediately",
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
elif days_until_expiry <= self.SSL_WARNING_DAYS:
|
|
71
|
+
status = CheckStatus.HEALTHY
|
|
72
|
+
findings.append(
|
|
73
|
+
Finding(
|
|
74
|
+
severity=Severity.WARNING,
|
|
75
|
+
title=f"SSL certificate expiring: {domain}",
|
|
76
|
+
description=f"Certificate expires in {days_until_expiry} days",
|
|
77
|
+
resource=domain,
|
|
78
|
+
remediation="Plan SSL certificate renewal",
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
else:
|
|
82
|
+
status = CheckStatus.HEALTHY
|
|
83
|
+
|
|
84
|
+
deployments.append(
|
|
85
|
+
DeploymentStatus(
|
|
86
|
+
platform="domain",
|
|
87
|
+
project_name=domain,
|
|
88
|
+
deployment_id=domain,
|
|
89
|
+
status=status,
|
|
90
|
+
url=f"https://{domain}",
|
|
91
|
+
metadata={
|
|
92
|
+
"ssl_valid": not ssl_info.get("error"),
|
|
93
|
+
"ssl_expiry": ssl_info.get("expiry"),
|
|
94
|
+
"ssl_days_remaining": ssl_info.get("days_until_expiry"),
|
|
95
|
+
"ssl_issuer": ssl_info.get("issuer"),
|
|
96
|
+
},
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
except httpx.HTTPStatusError as e:
|
|
101
|
+
errors.append(
|
|
102
|
+
f"HTTP {e.response.status_code} checking {domain}: {e.response.text[:200]}"
|
|
103
|
+
)
|
|
104
|
+
except httpx.RequestError as e:
|
|
105
|
+
errors.append(f"Network error checking {domain}: {e}")
|
|
106
|
+
except TimeoutError:
|
|
107
|
+
errors.append(f"Timeout checking {domain}")
|
|
108
|
+
except Exception as e:
|
|
109
|
+
errors.append(f"Unexpected error checking {domain}: {e}")
|
|
110
|
+
|
|
111
|
+
all_healthy = all(d.status == CheckStatus.HEALTHY for d in deployments)
|
|
112
|
+
|
|
113
|
+
return CheckResult(
|
|
114
|
+
check_type=self.check_type,
|
|
115
|
+
success=len(errors) == 0 and all_healthy,
|
|
116
|
+
deployments=deployments,
|
|
117
|
+
findings=findings,
|
|
118
|
+
errors=errors,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
async def _check_ssl(self, domain: str) -> dict:
|
|
122
|
+
"""Check SSL certificate for a domain."""
|
|
123
|
+
try:
|
|
124
|
+
# Run in thread pool since ssl is blocking
|
|
125
|
+
loop = asyncio.get_event_loop()
|
|
126
|
+
return await loop.run_in_executor(None, self._get_ssl_info, domain)
|
|
127
|
+
except Exception as e:
|
|
128
|
+
return {"error": str(e)}
|
|
129
|
+
|
|
130
|
+
def _get_ssl_info(self, domain: str) -> dict:
|
|
131
|
+
"""Get SSL certificate information (blocking)."""
|
|
132
|
+
try:
|
|
133
|
+
# First check DNS resolution
|
|
134
|
+
try:
|
|
135
|
+
socket.gethostbyname(domain)
|
|
136
|
+
except socket.gaierror as e:
|
|
137
|
+
return {"error": f"DNS resolution failed for {domain}: {e}"}
|
|
138
|
+
|
|
139
|
+
# Try SSL connection
|
|
140
|
+
context = ssl.create_default_context()
|
|
141
|
+
# Allow more lenient SSL for checking (we're just checking expiry, not validating trust)
|
|
142
|
+
context.check_hostname = True
|
|
143
|
+
context.verify_mode = ssl.CERT_REQUIRED
|
|
144
|
+
|
|
145
|
+
with socket.create_connection((domain, 443), timeout=10) as sock:
|
|
146
|
+
with context.wrap_socket(sock, server_hostname=domain) as ssock:
|
|
147
|
+
cert = ssock.getpeercert()
|
|
148
|
+
|
|
149
|
+
# Parse expiry date
|
|
150
|
+
expiry_str = cert.get("notAfter", "")
|
|
151
|
+
# Format: 'Dec 25 23:59:59 2025 GMT'
|
|
152
|
+
expiry = datetime.strptime(expiry_str, "%b %d %H:%M:%S %Y %Z")
|
|
153
|
+
expiry = expiry.replace(tzinfo=UTC)
|
|
154
|
+
|
|
155
|
+
now = datetime.now(UTC)
|
|
156
|
+
days_until_expiry = (expiry - now).days
|
|
157
|
+
|
|
158
|
+
# Get issuer
|
|
159
|
+
issuer_dict = dict(x[0] for x in cert.get("issuer", []))
|
|
160
|
+
issuer = issuer_dict.get("organizationName", "Unknown")
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
"expiry": expiry.isoformat(),
|
|
164
|
+
"days_until_expiry": days_until_expiry,
|
|
165
|
+
"issuer": issuer,
|
|
166
|
+
}
|
|
167
|
+
except TimeoutError:
|
|
168
|
+
return {
|
|
169
|
+
"error": f"Connection timeout to {domain}:443 - domain may be down or behind firewall"
|
|
170
|
+
}
|
|
171
|
+
except socket.gaierror as e:
|
|
172
|
+
return {"error": f"DNS resolution failed for {domain}: {e}"}
|
|
173
|
+
except ssl.SSLError as e:
|
|
174
|
+
# More specific SSL error messages
|
|
175
|
+
error_msg = str(e)
|
|
176
|
+
if "certificate verify failed" in error_msg.lower():
|
|
177
|
+
return {"error": f"SSL certificate verification failed for {domain}: {e}"}
|
|
178
|
+
elif "handshake" in error_msg.lower():
|
|
179
|
+
return {"error": f"SSL handshake failed for {domain}: {e}"}
|
|
180
|
+
else:
|
|
181
|
+
return {"error": f"SSL error for {domain}: {e}"}
|
|
182
|
+
except ConnectionRefusedError:
|
|
183
|
+
return {"error": f"Connection refused to {domain}:443 - service may be down"}
|
|
184
|
+
except OSError as e:
|
|
185
|
+
if "Network is unreachable" in str(e):
|
|
186
|
+
return {"error": f"Network unreachable for {domain}: {e}"}
|
|
187
|
+
return {"error": f"Connection error for {domain}: {e}"}
|
|
188
|
+
except Exception as e:
|
|
189
|
+
return {"error": f"Failed to check {domain}: {e}"}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Firecrawl API usage checker."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from devguard.checkers.base import BaseChecker
|
|
8
|
+
from devguard.http_client import create_client, retry_with_backoff
|
|
9
|
+
from devguard.models import CheckResult, CostMetric
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FirecrawlChecker(BaseChecker):
|
|
15
|
+
"""Check Firecrawl API credit usage."""
|
|
16
|
+
|
|
17
|
+
check_type = "firecrawl"
|
|
18
|
+
|
|
19
|
+
async def check(self) -> CheckResult:
|
|
20
|
+
"""Check Firecrawl credit usage."""
|
|
21
|
+
errors: list[str] = []
|
|
22
|
+
|
|
23
|
+
if not self.settings.firecrawl_api_key:
|
|
24
|
+
return CheckResult(
|
|
25
|
+
check_type=self.check_type,
|
|
26
|
+
success=False,
|
|
27
|
+
deployments=[],
|
|
28
|
+
errors=["Firecrawl API key not configured"],
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Handle SecretStr
|
|
32
|
+
firecrawl_key = self.settings.firecrawl_api_key
|
|
33
|
+
if hasattr(firecrawl_key, "get_secret_value"):
|
|
34
|
+
firecrawl_key = firecrawl_key.get_secret_value()
|
|
35
|
+
|
|
36
|
+
headers = {
|
|
37
|
+
"Authorization": f"Bearer {firecrawl_key}",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
async with create_client() as client:
|
|
42
|
+
|
|
43
|
+
async def fetch_usage():
|
|
44
|
+
response = await client.get(
|
|
45
|
+
"https://api.firecrawl.dev/v2/team/credit-usage",
|
|
46
|
+
headers=headers,
|
|
47
|
+
timeout=10.0,
|
|
48
|
+
)
|
|
49
|
+
response.raise_for_status()
|
|
50
|
+
return response
|
|
51
|
+
|
|
52
|
+
response = await retry_with_backoff(fetch_usage, max_retries=3)
|
|
53
|
+
data = response.json()
|
|
54
|
+
|
|
55
|
+
# Extract usage data
|
|
56
|
+
usage_data = data.get("data", {})
|
|
57
|
+
remaining = usage_data.get("remaining_credits", 0)
|
|
58
|
+
plan_credits = usage_data.get("plan_credits", 0)
|
|
59
|
+
usage_percent = (
|
|
60
|
+
((plan_credits - remaining) / plan_credits * 100) if plan_credits > 0 else 0
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
metadata = {
|
|
64
|
+
"remaining_credits": remaining,
|
|
65
|
+
"plan_credits": plan_credits,
|
|
66
|
+
"usage_percent": round(usage_percent, 2),
|
|
67
|
+
"billing_period_start": usage_data.get("billing_period_start"),
|
|
68
|
+
"billing_period_end": usage_data.get("billing_period_end"),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# Create cost metric with estimated cost
|
|
72
|
+
# Firecrawl pricing: $0.005 per credit (standard plan)
|
|
73
|
+
# Hobby: $16/3000 = $0.0053, Standard: $83/100k = $0.00083
|
|
74
|
+
# Use $0.005 as standard estimate
|
|
75
|
+
credits_used = plan_credits - remaining
|
|
76
|
+
estimated_cost = max(0.0, credits_used * 0.005) if credits_used > 0 else 0.0
|
|
77
|
+
|
|
78
|
+
cost_metrics = [
|
|
79
|
+
CostMetric(
|
|
80
|
+
service="firecrawl",
|
|
81
|
+
period="billing_period",
|
|
82
|
+
amount=estimated_cost,
|
|
83
|
+
usage=float(credits_used),
|
|
84
|
+
limit=float(plan_credits),
|
|
85
|
+
usage_percent=round(usage_percent, 2),
|
|
86
|
+
metadata={
|
|
87
|
+
"unit": "credits",
|
|
88
|
+
"cost_per_credit": 0.005,
|
|
89
|
+
"estimated": True,
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
return CheckResult(
|
|
95
|
+
check_type=self.check_type,
|
|
96
|
+
success=True,
|
|
97
|
+
deployments=[],
|
|
98
|
+
errors=[],
|
|
99
|
+
cost_metrics=cost_metrics,
|
|
100
|
+
metadata=metadata,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
except httpx.HTTPStatusError as e:
|
|
104
|
+
status_code = e.response.status_code
|
|
105
|
+
error_text = e.response.text[:100]
|
|
106
|
+
errors.append(f"HTTP {status_code}: {error_text}")
|
|
107
|
+
except httpx.RequestError as e:
|
|
108
|
+
errors.append(f"Network error: {str(e)}")
|
|
109
|
+
except Exception as e:
|
|
110
|
+
errors.append(f"Unexpected error: {str(e)}")
|
|
111
|
+
|
|
112
|
+
return CheckResult(
|
|
113
|
+
check_type=self.check_type,
|
|
114
|
+
success=False,
|
|
115
|
+
deployments=[],
|
|
116
|
+
errors=errors,
|
|
117
|
+
)
|
devguard/checkers/fly.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Fly.io deployment status checker."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from devguard.checkers.base import BaseChecker
|
|
8
|
+
from devguard.http_client import create_client, retry_with_backoff
|
|
9
|
+
from devguard.models import CheckResult, CheckStatus, DeploymentStatus
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FlyChecker(BaseChecker):
|
|
15
|
+
"""Check Fly.io deployments for health status."""
|
|
16
|
+
|
|
17
|
+
check_type = "fly"
|
|
18
|
+
|
|
19
|
+
async def check(self) -> CheckResult:
|
|
20
|
+
"""Check Fly.io deployments."""
|
|
21
|
+
deployments: list[DeploymentStatus] = []
|
|
22
|
+
errors: list[str] = []
|
|
23
|
+
|
|
24
|
+
if not self.settings.fly_api_token:
|
|
25
|
+
return CheckResult(
|
|
26
|
+
check_type=self.check_type,
|
|
27
|
+
success=False,
|
|
28
|
+
deployments=[],
|
|
29
|
+
errors=["Fly.io API token not configured"],
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Handle SecretStr
|
|
33
|
+
fly_token = self.settings.fly_api_token
|
|
34
|
+
if hasattr(fly_token, "get_secret_value"):
|
|
35
|
+
fly_token = fly_token.get_secret_value()
|
|
36
|
+
|
|
37
|
+
headers = {
|
|
38
|
+
"Authorization": f"Bearer {fly_token}",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
async with create_client() as client:
|
|
43
|
+
# Get list of apps
|
|
44
|
+
apps = await self._get_apps(client, headers)
|
|
45
|
+
|
|
46
|
+
# Check status for each app
|
|
47
|
+
for app in apps:
|
|
48
|
+
try:
|
|
49
|
+
app_deployments = await self._get_app_status(client, headers, app)
|
|
50
|
+
deployments.extend(app_deployments)
|
|
51
|
+
except httpx.HTTPStatusError as e:
|
|
52
|
+
status_code = e.response.status_code
|
|
53
|
+
error_text = e.response.text[:100]
|
|
54
|
+
errors.append(
|
|
55
|
+
f"Error checking app {app}: HTTP {status_code} - {error_text}"
|
|
56
|
+
)
|
|
57
|
+
except httpx.RequestError as e:
|
|
58
|
+
errors.append(f"Error checking app {app}: Network error - {str(e)}")
|
|
59
|
+
except Exception as e:
|
|
60
|
+
errors.append(f"Error checking app {app}: {str(e)}")
|
|
61
|
+
|
|
62
|
+
except httpx.RequestError as e:
|
|
63
|
+
errors.append(f"Error connecting to Fly.io API: {str(e)}")
|
|
64
|
+
except Exception as e:
|
|
65
|
+
errors.append(f"Error checking Fly.io: {str(e)}")
|
|
66
|
+
|
|
67
|
+
return CheckResult(
|
|
68
|
+
check_type=self.check_type,
|
|
69
|
+
success=len(errors) == 0,
|
|
70
|
+
deployments=deployments,
|
|
71
|
+
errors=errors,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
async def _get_apps(self, client: httpx.AsyncClient, headers: dict) -> list[str]:
|
|
75
|
+
"""Get list of apps to monitor."""
|
|
76
|
+
if self.settings.fly_apps_to_monitor:
|
|
77
|
+
return self.settings.fly_apps_to_monitor
|
|
78
|
+
|
|
79
|
+
# Get all apps
|
|
80
|
+
try:
|
|
81
|
+
|
|
82
|
+
async def fetch_apps():
|
|
83
|
+
response = await client.get(
|
|
84
|
+
"https://api.machines.dev/v1/apps",
|
|
85
|
+
headers=headers,
|
|
86
|
+
)
|
|
87
|
+
response.raise_for_status()
|
|
88
|
+
return response
|
|
89
|
+
|
|
90
|
+
response = await retry_with_backoff(fetch_apps, max_retries=3)
|
|
91
|
+
data = response.json()
|
|
92
|
+
return [app.get("name", "") for app in data if app.get("name")]
|
|
93
|
+
except httpx.HTTPStatusError as e:
|
|
94
|
+
logger.warning(f"Failed to fetch Fly.io apps: HTTP {e.response.status_code}")
|
|
95
|
+
except httpx.RequestError as e:
|
|
96
|
+
logger.warning(f"Failed to fetch Fly.io apps: {str(e)}")
|
|
97
|
+
except Exception as e:
|
|
98
|
+
logger.warning(f"Unexpected error fetching Fly.io apps: {str(e)}")
|
|
99
|
+
|
|
100
|
+
return []
|
|
101
|
+
|
|
102
|
+
async def _get_app_status(
|
|
103
|
+
self, client: httpx.AsyncClient, headers: dict, app_name: str
|
|
104
|
+
) -> list[DeploymentStatus]:
|
|
105
|
+
"""Get status for a Fly.io app."""
|
|
106
|
+
deployments: list[DeploymentStatus] = []
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
# Get app status with retry
|
|
110
|
+
async def fetch_app():
|
|
111
|
+
response = await client.get(
|
|
112
|
+
f"https://api.machines.dev/v1/apps/{app_name}",
|
|
113
|
+
headers=headers,
|
|
114
|
+
)
|
|
115
|
+
response.raise_for_status()
|
|
116
|
+
return response
|
|
117
|
+
|
|
118
|
+
response = await retry_with_backoff(fetch_app, max_retries=3)
|
|
119
|
+
app_data = response.json()
|
|
120
|
+
|
|
121
|
+
# Get machines/instances with retry
|
|
122
|
+
async def fetch_machines():
|
|
123
|
+
response = await client.get(
|
|
124
|
+
f"https://api.machines.dev/v1/apps/{app_name}/machines",
|
|
125
|
+
headers=headers,
|
|
126
|
+
)
|
|
127
|
+
response.raise_for_status()
|
|
128
|
+
return response
|
|
129
|
+
|
|
130
|
+
machines = []
|
|
131
|
+
try:
|
|
132
|
+
machines_response = await retry_with_backoff(fetch_machines, max_retries=3)
|
|
133
|
+
machines_data = machines_response.json()
|
|
134
|
+
# API returns a list directly, not a dict with "machines" key
|
|
135
|
+
if isinstance(machines_data, list):
|
|
136
|
+
machines = machines_data
|
|
137
|
+
else:
|
|
138
|
+
machines = machines_data.get("machines", [])
|
|
139
|
+
except httpx.RequestError as e:
|
|
140
|
+
logger.warning(f"Failed to fetch machines for {app_name}: {str(e)}")
|
|
141
|
+
|
|
142
|
+
# Determine overall health based on machine states
|
|
143
|
+
status = CheckStatus.HEALTHY
|
|
144
|
+
status_reason = None
|
|
145
|
+
|
|
146
|
+
if not machines:
|
|
147
|
+
# App exists but has no machines - likely suspended or scaled to zero
|
|
148
|
+
# Check if app is explicitly suspended
|
|
149
|
+
app_state = app_data.get("state", "").lower()
|
|
150
|
+
if app_state == "suspended":
|
|
151
|
+
status = CheckStatus.UNKNOWN
|
|
152
|
+
status_reason = "App is suspended (intentional)"
|
|
153
|
+
else:
|
|
154
|
+
status = CheckStatus.UNKNOWN
|
|
155
|
+
status_reason = "No machines running (suspended or scaled to zero)"
|
|
156
|
+
else:
|
|
157
|
+
# Check machine states
|
|
158
|
+
running_count = 0
|
|
159
|
+
stopped_count = 0
|
|
160
|
+
failed_count = 0
|
|
161
|
+
|
|
162
|
+
for machine in machines:
|
|
163
|
+
machine_state = machine.get("state", "").lower()
|
|
164
|
+
if machine_state in ["started", "running"]:
|
|
165
|
+
running_count += 1
|
|
166
|
+
elif machine_state in ["stopped", "suspended"]:
|
|
167
|
+
stopped_count += 1
|
|
168
|
+
elif machine_state in ["destroyed", "failed"]:
|
|
169
|
+
failed_count += 1
|
|
170
|
+
|
|
171
|
+
if failed_count > 0:
|
|
172
|
+
status = CheckStatus.UNHEALTHY
|
|
173
|
+
status_reason = f"{failed_count} machine(s) failed"
|
|
174
|
+
elif running_count == 0 and stopped_count > 0:
|
|
175
|
+
status = CheckStatus.UNKNOWN
|
|
176
|
+
status_reason = f"All {stopped_count} machine(s) stopped"
|
|
177
|
+
elif running_count > 0:
|
|
178
|
+
status = CheckStatus.HEALTHY
|
|
179
|
+
status_reason = f"{running_count} machine(s) running"
|
|
180
|
+
|
|
181
|
+
# Get latest deployment
|
|
182
|
+
latest_deployment = None
|
|
183
|
+
if machines:
|
|
184
|
+
# Find the most recent machine
|
|
185
|
+
latest_machine = max(
|
|
186
|
+
machines,
|
|
187
|
+
key=lambda m: m.get("created_at", ""),
|
|
188
|
+
default=None,
|
|
189
|
+
)
|
|
190
|
+
if latest_machine:
|
|
191
|
+
latest_deployment = latest_machine.get("id")
|
|
192
|
+
|
|
193
|
+
url = app_data.get("hostname")
|
|
194
|
+
if not url:
|
|
195
|
+
# Fallback to standard Fly.io domain if hostname is missing
|
|
196
|
+
url = f"{app_name}.fly.dev"
|
|
197
|
+
|
|
198
|
+
if url and not url.startswith("http"):
|
|
199
|
+
url = f"https://{url}"
|
|
200
|
+
|
|
201
|
+
deployment = DeploymentStatus(
|
|
202
|
+
platform="fly",
|
|
203
|
+
project_name=app_name,
|
|
204
|
+
deployment_id=latest_deployment or app_name,
|
|
205
|
+
status=status,
|
|
206
|
+
url=url,
|
|
207
|
+
metadata={
|
|
208
|
+
"machines_count": len(machines),
|
|
209
|
+
"app_id": app_data.get("id"),
|
|
210
|
+
"status_reason": status_reason,
|
|
211
|
+
},
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
deployments.append(deployment)
|
|
215
|
+
|
|
216
|
+
except httpx.HTTPStatusError as e:
|
|
217
|
+
logger.warning(
|
|
218
|
+
f"Failed to get status for Fly.io app {app_name}: HTTP {e.response.status_code}"
|
|
219
|
+
)
|
|
220
|
+
except httpx.RequestError as e:
|
|
221
|
+
logger.warning(f"Network error checking Fly.io app {app_name}: {str(e)}")
|
|
222
|
+
except Exception as e:
|
|
223
|
+
logger.warning(f"Unexpected error checking Fly.io app {app_name}: {str(e)}")
|
|
224
|
+
|
|
225
|
+
return deployments
|