fixdoc 0.0.1__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.
fixdoc/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """FixDoc - Capture and search infrastructure fixes for SRE/Devops engineers."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .models import Fix
6
+ from .storage import FixRepository
7
+
8
+ __all__ = ["Fix", "FixRepository"]
fixdoc/cli.py ADDED
@@ -0,0 +1,26 @@
1
+ """CLI assembly for fixdoc."""
2
+
3
+ import click
4
+
5
+ from .commands import capture, search, show, analyze, list_fixes, stats, delete, edit, sync
6
+
7
+
8
+ def create_cli() -> click.Group:
9
+
10
+ @click.group()
11
+ @click.version_option(version="0.1.0", prog_name="fixdoc")
12
+ def cli():
13
+ pass
14
+
15
+ # group commands
16
+ cli.add_command(capture)
17
+ cli.add_command(search)
18
+ cli.add_command(show)
19
+ cli.add_command(analyze)
20
+ cli.add_command(list_fixes)
21
+ cli.add_command(stats)
22
+ cli.add_command(delete)
23
+ cli.add_command(edit)
24
+ cli.add_command(sync)
25
+
26
+ return cli
@@ -0,0 +1,11 @@
1
+ """CLI commands for fixdoc."""
2
+
3
+ from .capture import capture
4
+ from .search import search, show
5
+ from .analyze import analyze
6
+ from .manage import list_fixes, stats
7
+ from .delete import delete
8
+ from .edit import edit
9
+ from .sync import sync
10
+
11
+ __all__ = ["capture", "search", "show", "analyze", "list_fixes", "stats", "delete", "edit", "sync"]
@@ -0,0 +1,313 @@
1
+ """Analyze command for fixdoc CLI."""
2
+
3
+ import json
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import click
9
+
10
+ from ..models import Fix
11
+ from ..storage import FixRepository
12
+ from ..parsers.base import CloudProvider
13
+
14
+
15
+ @dataclass
16
+ class AnalysisMatch:
17
+ """Represents a potential issue found during terraform plan analysis."""
18
+
19
+ resource_address: str
20
+ resource_type: str
21
+ related_fix: Fix
22
+ cloud_provider: CloudProvider = CloudProvider.UNKNOWN
23
+
24
+ def format_warning(self) -> str:
25
+ """Format as a warning message for CLI output."""
26
+ short_id = self.related_fix.id[:8]
27
+ issue = self.related_fix.issue
28
+ resolution = self.related_fix.resolution
29
+
30
+ issue_preview = issue[:80] + "..." if len(issue) > 80 else issue
31
+ resolution_preview = resolution[:80] + "..." if len(resolution) > 80 else resolution
32
+
33
+ lines = [
34
+ f"⚠ {self.resource_address} may relate to FIX-{short_id}",
35
+ f" Previous issue: {issue_preview}",
36
+ f" Resolution: {resolution_preview}",
37
+ ]
38
+
39
+ if self.related_fix.tags:
40
+ lines.append(f" Tags: {self.related_fix.tags}")
41
+
42
+ return "\n".join(lines)
43
+
44
+
45
+ @dataclass
46
+ class PlanResource:
47
+ """Represents a resource in a Terraform plan."""
48
+
49
+ address: str
50
+ resource_type: str
51
+ name: str
52
+ cloud_provider: CloudProvider
53
+ action: str # create, update, delete, no-op
54
+ module_path: Optional[str] = None
55
+ values: dict = field(default_factory=dict)
56
+
57
+
58
+ class TerraformAnalyzer:
59
+ """Analyzes terraform plan JSON output against known fixes."""
60
+
61
+ def __init__(self, repo: Optional[FixRepository] = None):
62
+ self.repo = repo or FixRepository()
63
+
64
+ def load_plan(self, plan_path: Path) -> dict:
65
+ """Load and parse a terraform plan JSON file."""
66
+ with open(plan_path, "r") as f:
67
+ return json.load(f)
68
+
69
+ def detect_cloud_provider(self, resource_type: str, provider_name: str = "") -> CloudProvider:
70
+ """Detect cloud provider from resource type or provider name."""
71
+ resource_lower = resource_type.lower()
72
+ provider_lower = provider_name.lower()
73
+
74
+ if resource_lower.startswith("aws_") or "hashicorp/aws" in provider_lower:
75
+ return CloudProvider.AWS
76
+ elif resource_lower.startswith("azurerm_") or "hashicorp/azurerm" in provider_lower:
77
+ return CloudProvider.AZURE
78
+ elif resource_lower.startswith("google_") or "hashicorp/google" in provider_lower:
79
+ return CloudProvider.GCP
80
+
81
+ return CloudProvider.UNKNOWN
82
+
83
+ def extract_resources(self, plan: dict) -> list[PlanResource]:
84
+ """Extract all resources from a Terraform plan with full metadata."""
85
+ resources = []
86
+
87
+ # Extract from resource_changes (most reliable for planned changes)
88
+ for change in plan.get("resource_changes", []):
89
+ address = change.get("address", "")
90
+ resource_type = change.get("type", "")
91
+ name = change.get("name", "")
92
+ provider_name = change.get("provider_name", "")
93
+
94
+ if not resource_type:
95
+ continue
96
+
97
+ # Determine action
98
+ actions = change.get("change", {}).get("actions", [])
99
+ if "create" in actions:
100
+ action = "create"
101
+ elif "delete" in actions:
102
+ action = "delete"
103
+ elif "update" in actions:
104
+ action = "update"
105
+ else:
106
+ action = "no-op"
107
+
108
+ # Extract module path if present
109
+ module_path = None
110
+ if address.startswith("module."):
111
+ parts = address.split(".")
112
+ module_parts = []
113
+ for i, part in enumerate(parts):
114
+ if part == "module" and i + 1 < len(parts):
115
+ module_parts.append(f"module.{parts[i + 1]}")
116
+ module_path = ".".join(module_parts) if module_parts else None
117
+
118
+ # Get planned values
119
+ values = change.get("change", {}).get("after", {}) or {}
120
+
121
+ resources.append(PlanResource(
122
+ address=address,
123
+ resource_type=resource_type,
124
+ name=name,
125
+ cloud_provider=self.detect_cloud_provider(resource_type, provider_name),
126
+ action=action,
127
+ module_path=module_path,
128
+ values=values,
129
+ ))
130
+
131
+ # Also check planned_values for additional resources
132
+ self._extract_from_planned_values(plan.get("planned_values", {}), resources)
133
+
134
+ # Deduplicate by address
135
+ seen = set()
136
+ unique = []
137
+ for r in resources:
138
+ if r.address not in seen:
139
+ seen.add(r.address)
140
+ unique.append(r)
141
+
142
+ return unique
143
+
144
+ def _extract_from_planned_values(self, planned_values: dict, resources: list[PlanResource]):
145
+ """Extract resources from planned_values section."""
146
+ existing_addresses = {r.address for r in resources}
147
+
148
+ def process_module(module: dict, prefix: str = ""):
149
+ # Process resources in this module
150
+ for resource in module.get("resources", []):
151
+ address = resource.get("address", "")
152
+ if address in existing_addresses:
153
+ continue
154
+
155
+ resource_type = resource.get("type", "")
156
+ if not resource_type:
157
+ continue
158
+
159
+ provider_name = resource.get("provider_name", "")
160
+
161
+ resources.append(PlanResource(
162
+ address=address,
163
+ resource_type=resource_type,
164
+ name=resource.get("name", ""),
165
+ cloud_provider=self.detect_cloud_provider(resource_type, provider_name),
166
+ action="unknown",
167
+ module_path=prefix or None,
168
+ values=resource.get("values", {}),
169
+ ))
170
+ existing_addresses.add(address)
171
+
172
+ # Process child modules
173
+ for child in module.get("child_modules", []):
174
+ child_address = child.get("address", "")
175
+ process_module(child, child_address)
176
+
177
+ root = planned_values.get("root_module", {})
178
+ process_module(root)
179
+
180
+ def extract_resource_types(self, plan: dict) -> list[tuple[str, str]]:
181
+ """Extract (resource_address, resource_type) tuples from a plan.
182
+
183
+ This is a simplified version for backward compatibility.
184
+ """
185
+ resources = self.extract_resources(plan)
186
+ return [(r.address, r.resource_type) for r in resources]
187
+
188
+ def analyze(self, plan_path: Path) -> list[AnalysisMatch]:
189
+ """Analyze a terraform plan for potential issues based on past fixes."""
190
+ plan = self.load_plan(plan_path)
191
+ resources = self.extract_resources(plan)
192
+ matches = []
193
+
194
+ for resource in resources:
195
+ for fix in self.repo.find_by_resource_type(resource.resource_type):
196
+ matches.append(
197
+ AnalysisMatch(
198
+ resource_address=resource.address,
199
+ resource_type=resource.resource_type,
200
+ related_fix=fix,
201
+ cloud_provider=resource.cloud_provider,
202
+ )
203
+ )
204
+
205
+ return matches
206
+
207
+ def analyze_and_format(self, plan_path: Path) -> str:
208
+ """Analyze a plan and return formatted output."""
209
+ matches = self.analyze(plan_path)
210
+
211
+ if not matches:
212
+ return "No known issues found for resources in this plan."
213
+
214
+ # Group by cloud provider
215
+ by_provider = {}
216
+ for match in matches:
217
+ provider = match.cloud_provider.value
218
+ by_provider.setdefault(provider, []).append(match)
219
+
220
+ lines = [
221
+ f"Found {len(matches)} potential issue(s) based on your fix history:",
222
+ "",
223
+ ]
224
+
225
+ for provider, provider_matches in by_provider.items():
226
+ if provider != "unknown":
227
+ lines.append(f"── {provider.upper()} ──")
228
+ lines.append("")
229
+
230
+ for match in provider_matches:
231
+ lines.append(match.format_warning())
232
+ lines.append("")
233
+
234
+ lines.append("Run `fixdoc show <fix-id>` for full details on any fix.")
235
+ return "\n".join(lines)
236
+
237
+ def get_plan_summary(self, plan_path: Path) -> dict:
238
+ """Get a summary of the plan resources by cloud provider and action."""
239
+ plan = self.load_plan(plan_path)
240
+ resources = self.extract_resources(plan)
241
+
242
+ summary = {
243
+ "total": len(resources),
244
+ "by_provider": {},
245
+ "by_action": {},
246
+ "by_type": {},
247
+ }
248
+
249
+ for r in resources:
250
+ provider = r.cloud_provider.value
251
+ summary["by_provider"][provider] = summary["by_provider"].get(provider, 0) + 1
252
+ summary["by_action"][r.action] = summary["by_action"].get(r.action, 0) + 1
253
+ summary["by_type"][r.resource_type] = summary["by_type"].get(r.resource_type, 0) + 1
254
+
255
+ return summary
256
+
257
+
258
+ @click.command()
259
+ @click.argument("plan_file", type=click.Path(exists=True))
260
+ @click.option("--summary", "-s", is_flag=True, help="Show plan summary instead of analysis")
261
+ @click.option("--verbose", "-v", is_flag=True, help="Show detailed output")
262
+ def analyze(plan_file: str, summary: bool, verbose: bool):
263
+ """
264
+ Analyze a terraform plan for issues.
265
+
266
+ \b
267
+ Usage:
268
+ terraform plan -out=plan.tfplan
269
+ terraform show -json plan.tfplan > plan.json
270
+ fixdoc analyze plan.json
271
+
272
+ \b
273
+ Options:
274
+ --summary Show resource summary by provider/action
275
+ --verbose Show detailed match information
276
+ """
277
+ analyzer = TerraformAnalyzer()
278
+ plan_path = Path(plan_file)
279
+
280
+ try:
281
+ if summary:
282
+ # Show plan summary
283
+ plan_summary = analyzer.get_plan_summary(plan_path)
284
+ click.echo(f"Plan Summary: {plan_summary['total']} resources\n")
285
+
286
+ if plan_summary['by_provider']:
287
+ click.echo("By Provider:")
288
+ for provider, count in sorted(plan_summary['by_provider'].items()):
289
+ click.echo(f" {provider}: {count}")
290
+ click.echo()
291
+
292
+ if plan_summary['by_action']:
293
+ click.echo("By Action:")
294
+ for action, count in sorted(plan_summary['by_action'].items()):
295
+ click.echo(f" {action}: {count}")
296
+ click.echo()
297
+
298
+ if verbose and plan_summary['by_type']:
299
+ click.echo("By Resource Type:")
300
+ for rtype, count in sorted(plan_summary['by_type'].items(), key=lambda x: -x[1]):
301
+ click.echo(f" {rtype}: {count}")
302
+ else:
303
+ # Show analysis
304
+ output = analyzer.analyze_and_format(plan_path)
305
+ click.echo(output)
306
+
307
+ except json.JSONDecodeError:
308
+ click.echo(f"Error: {plan_file} is not valid JSON", err=True)
309
+ click.echo("Make sure to use: terraform show -json plan.tfplan > plan.json", err=True)
310
+ raise SystemExit(1)
311
+ except Exception as e:
312
+ click.echo(f"Error analyzing plan: {e}", err=True)
313
+ raise SystemExit(1)
@@ -0,0 +1,109 @@
1
+ """Capture command for fixdoc CLI."""
2
+
3
+ import sys
4
+ import os
5
+ from typing import Optional
6
+
7
+ import click
8
+
9
+ from ..config import ConfigManager
10
+ from ..models import Fix
11
+ from ..storage import FixRepository
12
+ from .capture_handlers import (
13
+ handle_piped_input,
14
+ handle_quick_capture,
15
+ handle_interactive_capture,
16
+ )
17
+
18
+
19
+ def get_repo() -> FixRepository:
20
+ """Get the fix repository instance."""
21
+ return FixRepository()
22
+
23
+
24
+ def _reopen_stdin_from_terminal() -> bool:
25
+ """Reopen stdin from terminal after reading piped input. Returns True if successful."""
26
+ try:
27
+ # Unix/Mac
28
+ if os.path.exists('/dev/tty'):
29
+ sys.stdin = open('/dev/tty', 'r')
30
+ return True
31
+ except OSError:
32
+ pass
33
+
34
+ try:
35
+ # Windows
36
+ if sys.platform == 'win32':
37
+ sys.stdin = open('CON', 'r')
38
+ return True
39
+ except OSError:
40
+ pass
41
+
42
+ return False
43
+
44
+
45
+ @click.command()
46
+ @click.option(
47
+ "--quick", "-q", type=str, default=None,
48
+ help="Quick capture: 'issue | resolution'",
49
+ )
50
+ @click.option(
51
+ "--tags", "-t", type=str, default=None,
52
+ help="Tags (comma-separated)",
53
+ )
54
+ def capture(quick: Optional[str], tags: Optional[str]):
55
+ """
56
+ Capture a new fix.
57
+
58
+ \b
59
+ Pipe terraform errors:
60
+ terraform apply 2>&1 | fixdoc capture
61
+
62
+ \b
63
+ Interactive:
64
+ fixdoc capture
65
+
66
+ \b
67
+ Quick:
68
+ fixdoc capture -q "issue | resolution" -t storage,rbac
69
+ """
70
+ repo = get_repo()
71
+
72
+ # Check for piped input
73
+ if not sys.stdin.isatty():
74
+ fix = _handle_piped_input(tags)
75
+ elif quick:
76
+ fix = handle_quick_capture(quick, tags)
77
+ else:
78
+ fix = handle_interactive_capture(tags)
79
+
80
+ if fix:
81
+ # Set author from config if available
82
+ config = ConfigManager().load()
83
+ if config.user.name and not fix.author:
84
+ fix.author = config.user.name
85
+ fix.author_email = config.user.email
86
+
87
+ saved = repo.save(fix)
88
+ click.echo(f"\n----- Fix captured: {saved.id[:8]}")
89
+ click.echo(f" Markdown: ~/.fixdoc/docs/{saved.id}.md")
90
+
91
+
92
+ def _handle_piped_input(tags: Optional[str]) -> Optional[Fix]:
93
+ """Route piped input to appropriate handler using unified parser."""
94
+ # Read all piped input first
95
+ piped_input = sys.stdin.read()
96
+
97
+ if not piped_input.strip():
98
+ click.echo("No input received.", err=True)
99
+ return None
100
+
101
+ # Reopen stdin from terminal so we can prompt user
102
+ if not _reopen_stdin_from_terminal():
103
+ click.echo("Error: Cannot prompt for input in this environment.", err=True)
104
+ click.echo("Use quick mode instead:", err=True)
105
+ click.echo(" fixdoc capture -q 'issue | resolution' -t tags", err=True)
106
+ return None
107
+
108
+ # Use unified handler that auto-detects Terraform, K8s, Helm, etc.
109
+ return handle_piped_input(piped_input, tags)