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.
Files changed (60) hide show
  1. devguard/INTEGRATION_SUMMARY.md +121 -0
  2. devguard/__init__.py +3 -0
  3. devguard/__main__.py +6 -0
  4. devguard/checkers/__init__.py +41 -0
  5. devguard/checkers/api_usage.py +523 -0
  6. devguard/checkers/aws_cost.py +331 -0
  7. devguard/checkers/aws_iam.py +284 -0
  8. devguard/checkers/base.py +25 -0
  9. devguard/checkers/container.py +137 -0
  10. devguard/checkers/domain.py +189 -0
  11. devguard/checkers/firecrawl.py +117 -0
  12. devguard/checkers/fly.py +225 -0
  13. devguard/checkers/github.py +210 -0
  14. devguard/checkers/npm.py +327 -0
  15. devguard/checkers/npm_security.py +244 -0
  16. devguard/checkers/redteam.py +290 -0
  17. devguard/checkers/secret.py +279 -0
  18. devguard/checkers/swarm.py +376 -0
  19. devguard/checkers/tailscale.py +143 -0
  20. devguard/checkers/tailsnitch.py +303 -0
  21. devguard/checkers/tavily.py +179 -0
  22. devguard/checkers/vercel.py +192 -0
  23. devguard/cli.py +1510 -0
  24. devguard/cli_helpers.py +189 -0
  25. devguard/config.py +249 -0
  26. devguard/core.py +293 -0
  27. devguard/dashboard.py +715 -0
  28. devguard/discovery.py +363 -0
  29. devguard/http_client.py +142 -0
  30. devguard/llm_service.py +481 -0
  31. devguard/mcp_server.py +259 -0
  32. devguard/metrics.py +144 -0
  33. devguard/models.py +208 -0
  34. devguard/reporting.py +1571 -0
  35. devguard/sarif.py +295 -0
  36. devguard/scripts/ANALYSIS_SUMMARY.md +141 -0
  37. devguard/scripts/README.md +221 -0
  38. devguard/scripts/auto_fix_recommendations.py +145 -0
  39. devguard/scripts/generate_npmignore.py +175 -0
  40. devguard/scripts/generate_security_report.py +324 -0
  41. devguard/scripts/prepublish_check.sh +29 -0
  42. devguard/scripts/redteam_npm_packages.py +1262 -0
  43. devguard/scripts/review_all_repos.py +300 -0
  44. devguard/spec.py +617 -0
  45. devguard/sweeps/__init__.py +23 -0
  46. devguard/sweeps/ai_editor_config_audit.py +697 -0
  47. devguard/sweeps/cargo_publish_audit.py +655 -0
  48. devguard/sweeps/dependency_audit.py +419 -0
  49. devguard/sweeps/gitignore_audit.py +336 -0
  50. devguard/sweeps/local_dev.py +260 -0
  51. devguard/sweeps/local_dirty_worktree_secrets.py +521 -0
  52. devguard/sweeps/project_flaudit.py +636 -0
  53. devguard/sweeps/public_github_secrets.py +680 -0
  54. devguard/sweeps/publish_audit.py +478 -0
  55. devguard/sweeps/ssh_key_audit.py +327 -0
  56. devguard/utils.py +174 -0
  57. devguard-0.2.0.dist-info/METADATA +225 -0
  58. devguard-0.2.0.dist-info/RECORD +60 -0
  59. devguard-0.2.0.dist-info/WHEEL +4 -0
  60. 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
+ )
@@ -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