tfscout 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.
- tfscout/__init__.py +3 -0
- tfscout/__main__.py +3 -0
- tfscout/analyze.py +239 -0
- tfscout/cli.py +182 -0
- tfscout/diff.py +113 -0
- tfscout/github.py +130 -0
- tfscout/llm.py +207 -0
- tfscout/plan.py +142 -0
- tfscout/redact.py +68 -0
- tfscout/render.py +149 -0
- tfscout/rules.py +444 -0
- tfscout-0.1.0.dist-info/METADATA +194 -0
- tfscout-0.1.0.dist-info/RECORD +16 -0
- tfscout-0.1.0.dist-info/WHEEL +4 -0
- tfscout-0.1.0.dist-info/entry_points.txt +2 -0
- tfscout-0.1.0.dist-info/licenses/LICENSE +21 -0
tfscout/__init__.py
ADDED
tfscout/__main__.py
ADDED
tfscout/analyze.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Orchestration: plan -> redaction -> rules -> (optional) LLM -> report."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from tfscout.diff import diff
|
|
12
|
+
from tfscout.llm import LLMClient, LLMError
|
|
13
|
+
from tfscout.plan import Change, Plan
|
|
14
|
+
from tfscout.redact import redact_change
|
|
15
|
+
from tfscout.rules import (
|
|
16
|
+
DRIFT_CLASSES,
|
|
17
|
+
SEVERITIES,
|
|
18
|
+
DriftItem,
|
|
19
|
+
Finding,
|
|
20
|
+
drift_rules,
|
|
21
|
+
review_rules,
|
|
22
|
+
sev_rank,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
MAX_RESOURCES = 150
|
|
26
|
+
MAX_DIFFS_PER_RESOURCE = 40
|
|
27
|
+
CATEGORIES = {"security", "data-loss", "availability", "cost", "compliance", "other"}
|
|
28
|
+
|
|
29
|
+
REVIEW_SYSTEM = """\
|
|
30
|
+
You are tfscout, a senior platform engineer reviewing a Terraform plan attached to a pull request.
|
|
31
|
+
Input: resource changes (secrets are already redacted as [REDACTED]) and findings produced by deterministic rules.
|
|
32
|
+
|
|
33
|
+
Tasks:
|
|
34
|
+
1. "summary": 2-5 short markdown bullet lines describing WHAT this change does and its blast radius, in plain English.
|
|
35
|
+
2. "findings": ONLY risks the rules did not already report: downtime, data loss, security, cost,
|
|
36
|
+
compliance, surprising side-effects (e.g. a renamed resource causing destroy/create, forced replacement
|
|
37
|
+
attributes, changes to shared infrastructure). Never repeat a rule finding. Never invent problems:
|
|
38
|
+
if nothing else is notable return an empty list. Be concrete and reference attribute paths.
|
|
39
|
+
|
|
40
|
+
Respond with a single JSON object and nothing else:
|
|
41
|
+
{"summary": "- ...\\n- ...", "findings": [{"severity": "info|low|medium|high|critical",
|
|
42
|
+
"category": "security|data-loss|availability|cost|compliance|other", "resource": "<resource address>",
|
|
43
|
+
"title": "<short>", "detail": "<why>", "recommendation": "<what to do>"}]}
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
DRIFT_SYSTEM = """\
|
|
47
|
+
You are tfscout, a senior platform engineer analysing Terraform DRIFT: differences between the Terraform state
|
|
48
|
+
and real infrastructure that were made outside Terraform. Secrets are redacted as [REDACTED].
|
|
49
|
+
Each item has a preliminary classification from rules: noise | manual-change | risky.
|
|
50
|
+
If "classification_is_final" is true, the rules proved it from the Terraform state: keep that classification
|
|
51
|
+
and only explain it.
|
|
52
|
+
|
|
53
|
+
For every item return:
|
|
54
|
+
- "classification": noise (volatile/auto-managed values), manual-change (someone changed it by hand,
|
|
55
|
+
probably a hotfix that should be codified), or risky (weakens security, availability or compliance,
|
|
56
|
+
or looks unauthorised).
|
|
57
|
+
- "explanation": 1-2 sentences on what changed and the likely cause.
|
|
58
|
+
- "recommendation": either codify the change in Terraform (show the HCL attribute to set) or revert it by
|
|
59
|
+
running apply, or ignore it via lifecycle.ignore_changes for true noise.
|
|
60
|
+
Also give an overall "summary" (2-4 markdown bullet lines).
|
|
61
|
+
|
|
62
|
+
Respond with a single JSON object and nothing else:
|
|
63
|
+
{"summary": "- ...", "items": [{"resource": "<address>", "classification": "noise|manual-change|risky",
|
|
64
|
+
"explanation": "...", "recommendation": "..."}]}
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _warn(msg: str) -> None:
|
|
69
|
+
print(f"tfscout: warning: {msg}", file=sys.stderr)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _diff_payload(c: Change) -> list[dict[str, Any]]:
|
|
73
|
+
ds = diff(c.before, c.after, c.after_unknown)
|
|
74
|
+
out = [{"path": d.path, "before": d.before, "after": d.after} for d in ds[:MAX_DIFFS_PER_RESOURCE]]
|
|
75
|
+
if len(ds) > MAX_DIFFS_PER_RESOURCE:
|
|
76
|
+
out.append({"path": f"... {len(ds) - MAX_DIFFS_PER_RESOURCE} more attributes omitted"})
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _finding_dict(f: Finding) -> dict[str, str]:
|
|
81
|
+
return {"severity": f.severity, "category": f.category, "resource": f.resource, "title": f.title}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# --------------------------------------------------------------------------- review
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class ReviewReport:
|
|
89
|
+
summary: str
|
|
90
|
+
counts: dict[str, int]
|
|
91
|
+
findings: list[Finding]
|
|
92
|
+
changes: list[Change]
|
|
93
|
+
ai_used: bool = False
|
|
94
|
+
ai_error: str = ""
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def max_severity(self) -> str:
|
|
98
|
+
return max((f.severity for f in self.findings), key=sev_rank, default="none")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _count_actions(changes: list[Change]) -> dict[str, int]:
|
|
102
|
+
counts = {"create": 0, "update": 0, "replace": 0, "delete": 0}
|
|
103
|
+
for c in changes:
|
|
104
|
+
counts[c.action] = counts.get(c.action, 0) + 1
|
|
105
|
+
return counts
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _parse_ai_findings(raw: Any, known: set[str]) -> list[Finding]:
|
|
109
|
+
out = []
|
|
110
|
+
for item in raw if isinstance(raw, list) else []:
|
|
111
|
+
if not isinstance(item, dict) or not item.get("title"):
|
|
112
|
+
continue
|
|
113
|
+
sev = str(item.get("severity", "info")).lower()
|
|
114
|
+
cat = str(item.get("category", "other")).lower()
|
|
115
|
+
res = str(item.get("resource", ""))
|
|
116
|
+
out.append(
|
|
117
|
+
Finding(
|
|
118
|
+
severity=sev if sev in SEVERITIES else "info",
|
|
119
|
+
category=cat if cat in CATEGORIES else "other",
|
|
120
|
+
resource=res if res in known else (res or "(plan)"),
|
|
121
|
+
title=str(item["title"])[:200],
|
|
122
|
+
detail=str(item.get("detail", ""))[:1000],
|
|
123
|
+
recommendation=str(item.get("recommendation", ""))[:1000],
|
|
124
|
+
source="ai",
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
return out
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def run_review(plan: Plan, client: LLMClient | None) -> ReviewReport:
|
|
131
|
+
changes = [redact_change(c) for c in plan.changes]
|
|
132
|
+
findings = review_rules(changes)
|
|
133
|
+
report = ReviewReport(summary="", counts=_count_actions(changes), findings=findings, changes=changes)
|
|
134
|
+
if not changes or client is None:
|
|
135
|
+
return report
|
|
136
|
+
|
|
137
|
+
payload = {
|
|
138
|
+
"terraform_version": plan.terraform_version,
|
|
139
|
+
"counts": report.counts,
|
|
140
|
+
"changes": [
|
|
141
|
+
{
|
|
142
|
+
"address": c.address,
|
|
143
|
+
"type": c.type,
|
|
144
|
+
"action": c.action,
|
|
145
|
+
"replace_paths": c.replace_paths,
|
|
146
|
+
"diff": _diff_payload(c),
|
|
147
|
+
}
|
|
148
|
+
for c in changes[:MAX_RESOURCES]
|
|
149
|
+
],
|
|
150
|
+
"omitted_changes": max(0, len(changes) - MAX_RESOURCES),
|
|
151
|
+
"rule_findings": [_finding_dict(f) for f in findings],
|
|
152
|
+
}
|
|
153
|
+
try:
|
|
154
|
+
resp = client.complete_json(REVIEW_SYSTEM, json.dumps(payload, default=str))
|
|
155
|
+
except LLMError as e:
|
|
156
|
+
_warn(f"AI analysis skipped: {e}")
|
|
157
|
+
report.ai_error = str(e)
|
|
158
|
+
return report
|
|
159
|
+
|
|
160
|
+
report.ai_used = True
|
|
161
|
+
report.summary = str(resp.get("summary", "")).strip()
|
|
162
|
+
seen = {(f.resource, f.category) for f in findings}
|
|
163
|
+
for f in _parse_ai_findings(resp.get("findings"), {c.address for c in changes}):
|
|
164
|
+
if (f.resource, f.category) not in seen:
|
|
165
|
+
report.findings.append(f)
|
|
166
|
+
seen.add((f.resource, f.category))
|
|
167
|
+
report.findings.sort(key=lambda f: (-sev_rank(f.severity), f.source != "rule", f.resource))
|
|
168
|
+
return report
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# --------------------------------------------------------------------------- drift
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass
|
|
175
|
+
class DriftReport:
|
|
176
|
+
items: list[DriftItem] = field(default_factory=list)
|
|
177
|
+
summary: str = ""
|
|
178
|
+
ai_used: bool = False
|
|
179
|
+
ai_error: str = ""
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def actionable(self) -> list[DriftItem]:
|
|
183
|
+
return [i for i in self.items if i.classification != "noise"]
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def fingerprint(self) -> str:
|
|
187
|
+
"""Stable id of the actionable drift, so repeated runs don't spam new issues."""
|
|
188
|
+
key = sorted(
|
|
189
|
+
(i.resource, i.action, sorted((d.path, json.dumps(d.after, default=str)) for d in i.diffs))
|
|
190
|
+
for i in self.actionable
|
|
191
|
+
)
|
|
192
|
+
return hashlib.sha256(json.dumps(key).encode()).hexdigest()[:12] if key else ""
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def run_drift(plan: Plan, client: LLMClient | None) -> DriftReport:
|
|
196
|
+
report = DriftReport(items=drift_rules([redact_change(c) for c in plan.drift], plan.prior_resources))
|
|
197
|
+
if not report.items or client is None:
|
|
198
|
+
return report
|
|
199
|
+
|
|
200
|
+
payload = {
|
|
201
|
+
"items": [
|
|
202
|
+
{
|
|
203
|
+
"resource": i.resource,
|
|
204
|
+
"type": i.type,
|
|
205
|
+
"action": i.action,
|
|
206
|
+
"preliminary_classification": i.classification,
|
|
207
|
+
"classification_is_final": i.locked,
|
|
208
|
+
"reason": i.reason,
|
|
209
|
+
"diff": [{"path": d.path, "state": d.before, "real": d.after} for d in i.diffs][
|
|
210
|
+
:MAX_DIFFS_PER_RESOURCE
|
|
211
|
+
],
|
|
212
|
+
}
|
|
213
|
+
for i in report.items[:MAX_RESOURCES]
|
|
214
|
+
]
|
|
215
|
+
}
|
|
216
|
+
try:
|
|
217
|
+
resp = client.complete_json(DRIFT_SYSTEM, json.dumps(payload, default=str))
|
|
218
|
+
except LLMError as e:
|
|
219
|
+
_warn(f"AI analysis skipped: {e}")
|
|
220
|
+
report.ai_error = str(e)
|
|
221
|
+
return report
|
|
222
|
+
|
|
223
|
+
report.ai_used = True
|
|
224
|
+
report.summary = str(resp.get("summary", "")).strip()
|
|
225
|
+
by_res = {i.resource: i for i in report.items}
|
|
226
|
+
for ai in resp.get("items") or []:
|
|
227
|
+
if not isinstance(ai, dict) or ai.get("resource") not in by_res:
|
|
228
|
+
continue
|
|
229
|
+
item = by_res[ai["resource"]]
|
|
230
|
+
cls = str(ai.get("classification", "")).lower()
|
|
231
|
+
# Rules are security-first: the model may escalate but never downgrade a risky item,
|
|
232
|
+
# and never overrides a classification proven from state.
|
|
233
|
+
if cls in DRIFT_CLASSES and item.classification != "risky" and not item.locked:
|
|
234
|
+
item.classification = cls
|
|
235
|
+
item.explanation = str(ai.get("explanation", ""))[:1000]
|
|
236
|
+
item.recommendation = str(ai.get("recommendation", ""))[:1500]
|
|
237
|
+
order = {k: i for i, k in enumerate(reversed(DRIFT_CLASSES))}
|
|
238
|
+
report.items.sort(key=lambda i: (order[i.classification], i.resource))
|
|
239
|
+
return report
|
tfscout/cli.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Command line interface: `tfscout review` and `tfscout drift`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from tfscout import __version__
|
|
11
|
+
from tfscout.analyze import run_drift, run_review
|
|
12
|
+
from tfscout.github import GitHub, GitHubError, pr_number_from_event
|
|
13
|
+
from tfscout.llm import PROVIDERS, LLMError, make_client
|
|
14
|
+
from tfscout.plan import PlanError, load_plan
|
|
15
|
+
from tfscout.render import (
|
|
16
|
+
drift_json,
|
|
17
|
+
drift_marker,
|
|
18
|
+
render_drift_md,
|
|
19
|
+
render_review_md,
|
|
20
|
+
review_json,
|
|
21
|
+
review_marker,
|
|
22
|
+
)
|
|
23
|
+
from tfscout.rules import SEVERITIES, sev_rank
|
|
24
|
+
|
|
25
|
+
EXIT_FAIL_ON = 3
|
|
26
|
+
EXIT_DRIFT = 2
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _common(p: argparse.ArgumentParser) -> None:
|
|
30
|
+
p.add_argument("plan", help="plan file: JSON from `terraform show -json`, or a binary plan (-out=...)")
|
|
31
|
+
p.add_argument("--name", default="", help="label for this root module (e.g. 'prod/us-east-1')")
|
|
32
|
+
p.add_argument(
|
|
33
|
+
"--provider",
|
|
34
|
+
default=os.environ.get("TFSCOUT_PROVIDER", "none"),
|
|
35
|
+
choices=PROVIDERS,
|
|
36
|
+
help="LLM provider; 'none' = deterministic rules only (default: $TFSCOUT_PROVIDER or none)",
|
|
37
|
+
)
|
|
38
|
+
p.add_argument("--model", default=os.environ.get("TFSCOUT_MODEL", ""), help="override the model name")
|
|
39
|
+
p.add_argument(
|
|
40
|
+
"--base-url", default=os.environ.get("TFSCOUT_BASE_URL", ""), help="override the OpenAI-compatible endpoint"
|
|
41
|
+
)
|
|
42
|
+
p.add_argument("--api-key", default="", help="API key (prefer env TFSCOUT_API_KEY or provider env var)")
|
|
43
|
+
p.add_argument(
|
|
44
|
+
"--timeout",
|
|
45
|
+
type=float,
|
|
46
|
+
default=float(os.environ.get("TFSCOUT_TIMEOUT", "90")),
|
|
47
|
+
help="seconds to wait for the LLM per attempt (default: 90)",
|
|
48
|
+
)
|
|
49
|
+
p.add_argument(
|
|
50
|
+
"--reasoning-effort",
|
|
51
|
+
default=os.environ.get("TFSCOUT_REASONING_EFFORT", ""),
|
|
52
|
+
help="reasoning_effort for thinking models: minimal|low|medium|high (gemini default: low)",
|
|
53
|
+
)
|
|
54
|
+
p.add_argument("--format", choices=["markdown", "json"], default="markdown")
|
|
55
|
+
p.add_argument("--output", "-o", default="", help="write the report to a file instead of stdout")
|
|
56
|
+
p.add_argument("--terraform-bin", default="terraform", help="terraform/tofu binary for binary plans")
|
|
57
|
+
p.add_argument("--chdir", default=None, help="working dir for `terraform show` on binary plans")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
61
|
+
parser = argparse.ArgumentParser(
|
|
62
|
+
prog="tfscout", description="AI-assisted Terraform plan review and drift detection."
|
|
63
|
+
)
|
|
64
|
+
parser.add_argument("--version", action="version", version=f"tfscout {__version__}")
|
|
65
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
66
|
+
|
|
67
|
+
r = sub.add_parser("review", help="review a plan: risks, security, data loss")
|
|
68
|
+
_common(r)
|
|
69
|
+
r.add_argument("--github-comment", action="store_true", help="create/update a PR comment")
|
|
70
|
+
r.add_argument("--pr", type=int, default=None, help="PR number (default: from GITHUB_EVENT_PATH)")
|
|
71
|
+
r.add_argument(
|
|
72
|
+
"--fail-on",
|
|
73
|
+
choices=["none", *SEVERITIES],
|
|
74
|
+
default="none",
|
|
75
|
+
help=f"exit {EXIT_FAIL_ON} if a finding has at least this severity",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
d = sub.add_parser("drift", help="analyse drift from `terraform plan -refresh-only`")
|
|
79
|
+
_common(d)
|
|
80
|
+
d.add_argument("--github-issue", action="store_true", help="create/update/close a drift issue")
|
|
81
|
+
d.add_argument("--exit-code", action="store_true", help=f"exit {EXIT_DRIFT} when actionable drift is found")
|
|
82
|
+
return parser
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _emit(text: str, output: str) -> None:
|
|
86
|
+
if output:
|
|
87
|
+
Path(output).write_text(text + "\n", encoding="utf-8")
|
|
88
|
+
else:
|
|
89
|
+
print(text)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _gh_output(**values: str) -> None:
|
|
93
|
+
path = os.environ.get("GITHUB_OUTPUT")
|
|
94
|
+
if path:
|
|
95
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
96
|
+
for k, v in values.items():
|
|
97
|
+
f.write(f"{k}={v}\n")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _gh_summary(markdown: str) -> None:
|
|
101
|
+
path = os.environ.get("GITHUB_STEP_SUMMARY")
|
|
102
|
+
if path:
|
|
103
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
104
|
+
f.write(markdown + "\n\n")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _cmd_review(args: argparse.Namespace, plan, client) -> int:
|
|
108
|
+
report = run_review(plan, client)
|
|
109
|
+
md = render_review_md(report, args.name)
|
|
110
|
+
_emit(md if args.format == "markdown" else review_json(report), args.output)
|
|
111
|
+
_gh_summary(md)
|
|
112
|
+
_gh_output(**{"max-severity": report.max_severity, "findings-count": str(len(report.findings))})
|
|
113
|
+
|
|
114
|
+
if args.github_comment:
|
|
115
|
+
pr = args.pr or pr_number_from_event()
|
|
116
|
+
if not pr:
|
|
117
|
+
print("tfscout: --github-comment needs a pull_request event or --pr", file=sys.stderr)
|
|
118
|
+
else:
|
|
119
|
+
try:
|
|
120
|
+
url = GitHub.from_env().upsert_pr_comment(pr, review_marker(args.name or "default"), md)
|
|
121
|
+
print(f"tfscout: PR comment: {url}", file=sys.stderr)
|
|
122
|
+
except GitHubError as e:
|
|
123
|
+
print(f"tfscout: failed to comment on PR: {e}", file=sys.stderr)
|
|
124
|
+
return 1
|
|
125
|
+
|
|
126
|
+
if args.fail_on != "none" and report.findings and sev_rank(report.max_severity) >= sev_rank(args.fail_on):
|
|
127
|
+
print(f"tfscout: findings at or above '{args.fail_on}' severity", file=sys.stderr)
|
|
128
|
+
return EXIT_FAIL_ON
|
|
129
|
+
return 0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _cmd_drift(args: argparse.Namespace, plan, client) -> int:
|
|
133
|
+
report = run_drift(plan, client)
|
|
134
|
+
md = render_drift_md(report, args.name)
|
|
135
|
+
_emit(md if args.format == "markdown" else drift_json(report), args.output)
|
|
136
|
+
_gh_summary(md)
|
|
137
|
+
has_drift = bool(report.actionable)
|
|
138
|
+
_gh_output(
|
|
139
|
+
**{
|
|
140
|
+
"drift-detected": str(has_drift).lower(),
|
|
141
|
+
"fingerprint": report.fingerprint,
|
|
142
|
+
"findings-count": str(len(report.actionable)),
|
|
143
|
+
}
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if args.github_issue:
|
|
147
|
+
title = f"Terraform drift detected{f': {args.name}' if args.name else ''}"
|
|
148
|
+
try:
|
|
149
|
+
action, url = GitHub.from_env().sync_drift_issue(
|
|
150
|
+
drift_marker(args.name or "default"), title, md, report.fingerprint
|
|
151
|
+
)
|
|
152
|
+
print(f"tfscout: drift issue {action}{f': {url}' if url else ''}", file=sys.stderr)
|
|
153
|
+
except GitHubError as e:
|
|
154
|
+
print(f"tfscout: failed to sync drift issue: {e}", file=sys.stderr)
|
|
155
|
+
return 1
|
|
156
|
+
|
|
157
|
+
return EXIT_DRIFT if args.exit_code and has_drift else 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def main(argv: list[str] | None = None) -> int:
|
|
161
|
+
args = build_parser().parse_args(argv)
|
|
162
|
+
try:
|
|
163
|
+
plan = load_plan(args.plan, args.terraform_bin, args.chdir)
|
|
164
|
+
except PlanError as e:
|
|
165
|
+
print(f"tfscout: error: {e}", file=sys.stderr)
|
|
166
|
+
return 1
|
|
167
|
+
try:
|
|
168
|
+
client = make_client(
|
|
169
|
+
args.provider,
|
|
170
|
+
args.model,
|
|
171
|
+
args.base_url,
|
|
172
|
+
args.api_key,
|
|
173
|
+
timeout=args.timeout,
|
|
174
|
+
reasoning_effort=args.reasoning_effort,
|
|
175
|
+
)
|
|
176
|
+
except LLMError as e:
|
|
177
|
+
# e.g. secrets are not available to pull requests from forks: still produce the rules-only report
|
|
178
|
+
print(f"tfscout: warning: AI disabled: {e}", file=sys.stderr)
|
|
179
|
+
client = None
|
|
180
|
+
if args.command == "review":
|
|
181
|
+
return _cmd_review(args, plan, client)
|
|
182
|
+
return _cmd_drift(args, plan, client)
|
tfscout/diff.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Attribute-level diff between `before` and `after` of a resource change."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
UNKNOWN = "(known after apply)"
|
|
10
|
+
_MISSING = object()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class AttrDiff:
|
|
15
|
+
path: str
|
|
16
|
+
before: Any
|
|
17
|
+
after: Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _flatten(value: Any, prefix: str, out: dict[str, Any]) -> None:
|
|
21
|
+
if isinstance(value, dict) and value:
|
|
22
|
+
for k, v in value.items():
|
|
23
|
+
_flatten(v, f"{prefix}.{k}" if prefix else str(k), out)
|
|
24
|
+
elif isinstance(value, list) and value and any(isinstance(v, (dict, list)) for v in value):
|
|
25
|
+
for i, v in enumerate(value):
|
|
26
|
+
_flatten(v, f"{prefix}[{i}]", out)
|
|
27
|
+
else:
|
|
28
|
+
out[prefix] = value
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def flatten(value: Any) -> dict[str, Any]:
|
|
32
|
+
out: dict[str, Any] = {}
|
|
33
|
+
if value is not None:
|
|
34
|
+
_flatten(value, "", out)
|
|
35
|
+
return out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _unknown_paths(after_unknown: Any) -> set[str]:
|
|
39
|
+
return {p for p, v in flatten(after_unknown).items() if v is True}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _short(v: Any, limit: int) -> Any:
|
|
43
|
+
if isinstance(v, (dict, list)):
|
|
44
|
+
v = json.dumps(v, sort_keys=True)
|
|
45
|
+
if isinstance(v, str) and len(v) > limit:
|
|
46
|
+
return v[:limit] + f"...(+{len(v) - limit} chars)"
|
|
47
|
+
return v
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _is_object_list(v: Any) -> bool:
|
|
51
|
+
return isinstance(v, list) and bool(v) and all(isinstance(x, dict) for x in v)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _canon(v: Any) -> str:
|
|
55
|
+
return json.dumps(v, sort_keys=True, default=str)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _split_object_lists(before: Any, after: Any, prefix: str, extra: list[AttrDiff], sets: set[str]) -> tuple:
|
|
59
|
+
"""Diff lists of objects (e.g. SG ingress, nested blocks) as sets, not by index.
|
|
60
|
+
|
|
61
|
+
Matching elements are dropped, the rest are reported as `path[-]` (removed) / `path[+]` (added),
|
|
62
|
+
so adding one rule doesn't show up as every rule after it "changing".
|
|
63
|
+
"""
|
|
64
|
+
if not (isinstance(before, dict) and isinstance(after, dict)):
|
|
65
|
+
return before, after
|
|
66
|
+
nb: dict[str, Any] = {}
|
|
67
|
+
na: dict[str, Any] = {}
|
|
68
|
+
for k in before.keys() | after.keys():
|
|
69
|
+
path = f"{prefix}.{k}" if prefix else str(k)
|
|
70
|
+
vb, va = before.get(k, _MISSING), after.get(k, _MISSING)
|
|
71
|
+
sides_ok = all(x is _MISSING or x is None or isinstance(x, list) for x in (vb, va))
|
|
72
|
+
if sides_ok and (_is_object_list(vb) or _is_object_list(va)):
|
|
73
|
+
sets.add(path)
|
|
74
|
+
lb = vb if isinstance(vb, list) else []
|
|
75
|
+
la = va if isinstance(va, list) else []
|
|
76
|
+
kb, ka = [_canon(x) for x in lb], [_canon(x) for x in la]
|
|
77
|
+
extra += [AttrDiff(f"{path}[-]", x, None) for x, c in zip(lb, kb, strict=True) if c not in ka]
|
|
78
|
+
extra += [AttrDiff(f"{path}[+]", None, x) for x, c in zip(la, ka, strict=True) if c not in kb]
|
|
79
|
+
continue
|
|
80
|
+
rb, ra = _split_object_lists(vb, va, path, extra, sets)
|
|
81
|
+
if vb is not _MISSING:
|
|
82
|
+
nb[k] = rb
|
|
83
|
+
if va is not _MISSING:
|
|
84
|
+
na[k] = ra
|
|
85
|
+
return nb, na
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def diff(before: Any, after: Any, after_unknown: Any = None, value_limit: int = 200) -> list[AttrDiff]:
|
|
89
|
+
extra: list[AttrDiff] = []
|
|
90
|
+
sets: set[str] = set()
|
|
91
|
+
before, after = _split_object_lists(before, after, "", extra, sets)
|
|
92
|
+
b, a = flatten(before), flatten(after)
|
|
93
|
+
unknown = {p for p in _unknown_paths(after_unknown) if not any(p.startswith(s + "[") for s in sets)}
|
|
94
|
+
result: list[AttrDiff] = [
|
|
95
|
+
AttrDiff(d.path, _short(d.before, value_limit), _short(d.after, value_limit)) for d in extra
|
|
96
|
+
]
|
|
97
|
+
for path in sorted(set(b) | set(a) | unknown):
|
|
98
|
+
bv = b.get(path, _MISSING)
|
|
99
|
+
av = UNKNOWN if path in unknown else a.get(path, _MISSING)
|
|
100
|
+
if bv == av:
|
|
101
|
+
continue
|
|
102
|
+
# Treat null / empty / missing as equivalent to avoid noisy diffs
|
|
103
|
+
if bv in (_MISSING, None, "", [], {}) and av in (_MISSING, None, "", [], {}):
|
|
104
|
+
continue
|
|
105
|
+
result.append(
|
|
106
|
+
AttrDiff(
|
|
107
|
+
path=path,
|
|
108
|
+
before=None if bv is _MISSING else _short(bv, value_limit),
|
|
109
|
+
after=None if av is _MISSING else _short(av, value_limit),
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
result.sort(key=lambda d: d.path)
|
|
113
|
+
return result
|
tfscout/github.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Tiny GitHub REST client (stdlib only) for PR comments and drift issues."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import urllib.request
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
MAX_BODY = 65000
|
|
14
|
+
DRIFT_LABEL = "tfscout-drift"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GitHubError(Exception):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _truncate(body: str) -> str:
|
|
22
|
+
if len(body) <= MAX_BODY:
|
|
23
|
+
return body
|
|
24
|
+
return body[: MAX_BODY - 200] + "\n\nā¦\n\n_Report truncated (GitHub size limit). See the workflow artifact._"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class GitHub:
|
|
29
|
+
token: str
|
|
30
|
+
repo: str
|
|
31
|
+
api_url: str = "https://api.github.com"
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_env(cls, token: str = "") -> GitHub:
|
|
35
|
+
token = token or os.environ.get("GITHUB_TOKEN", "")
|
|
36
|
+
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
|
37
|
+
if not token or not repo:
|
|
38
|
+
raise GitHubError("GITHUB_TOKEN and GITHUB_REPOSITORY must be set")
|
|
39
|
+
return cls(token, repo, os.environ.get("GITHUB_API_URL", "https://api.github.com"))
|
|
40
|
+
|
|
41
|
+
def _req(self, method: str, path: str, body: Any = None, params: dict[str, Any] | None = None) -> Any:
|
|
42
|
+
url = f"{self.api_url.rstrip('/')}{path}"
|
|
43
|
+
if params:
|
|
44
|
+
url += "?" + urllib.parse.urlencode(params)
|
|
45
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
46
|
+
req = urllib.request.Request(
|
|
47
|
+
url,
|
|
48
|
+
data=data,
|
|
49
|
+
method=method,
|
|
50
|
+
headers={
|
|
51
|
+
"Authorization": f"Bearer {self.token}",
|
|
52
|
+
"Accept": "application/vnd.github+json",
|
|
53
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
54
|
+
"User-Agent": "tfscout",
|
|
55
|
+
**({"Content-Type": "application/json"} if data else {}),
|
|
56
|
+
},
|
|
57
|
+
)
|
|
58
|
+
try:
|
|
59
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
60
|
+
raw = resp.read()
|
|
61
|
+
return json.loads(raw) if raw else None
|
|
62
|
+
except urllib.error.HTTPError as e:
|
|
63
|
+
raise GitHubError(f"{method} {path}: HTTP {e.code} {e.read().decode(errors='replace')[:300]}") from e
|
|
64
|
+
|
|
65
|
+
def _paginate(self, path: str, params: dict[str, Any] | None = None, limit: int = 10):
|
|
66
|
+
for page in range(1, limit + 1):
|
|
67
|
+
items = self._req("GET", path, params={**(params or {}), "per_page": 100, "page": page})
|
|
68
|
+
if not items:
|
|
69
|
+
return
|
|
70
|
+
yield from items
|
|
71
|
+
if len(items) < 100:
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
# ------------------------------------------------------------------ PR comments
|
|
75
|
+
|
|
76
|
+
def upsert_pr_comment(self, pr: int, marker: str, body: str) -> str:
|
|
77
|
+
body = _truncate(body)
|
|
78
|
+
for c in self._paginate(f"/repos/{self.repo}/issues/{pr}/comments"):
|
|
79
|
+
if marker in (c.get("body") or ""):
|
|
80
|
+
self._req("PATCH", f"/repos/{self.repo}/issues/comments/{c['id']}", {"body": body})
|
|
81
|
+
return c["html_url"]
|
|
82
|
+
return self._req("POST", f"/repos/{self.repo}/issues/{pr}/comments", {"body": body})["html_url"]
|
|
83
|
+
|
|
84
|
+
# ------------------------------------------------------------------ drift issues
|
|
85
|
+
|
|
86
|
+
def _find_issue(self, marker: str) -> dict[str, Any] | None:
|
|
87
|
+
params = {"state": "open", "labels": DRIFT_LABEL}
|
|
88
|
+
for issue in self._paginate(f"/repos/{self.repo}/issues", params):
|
|
89
|
+
if "pull_request" not in issue and marker in (issue.get("body") or ""):
|
|
90
|
+
return issue
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
def sync_drift_issue(self, marker: str, title: str, body: str, fingerprint: str) -> tuple[str, str]:
|
|
94
|
+
"""Create/update/close the drift issue. Returns (action, url)."""
|
|
95
|
+
issue = self._find_issue(marker)
|
|
96
|
+
base = f"/repos/{self.repo}/issues"
|
|
97
|
+
if not fingerprint:
|
|
98
|
+
if issue:
|
|
99
|
+
self._req(
|
|
100
|
+
"POST",
|
|
101
|
+
f"{base}/{issue['number']}/comments",
|
|
102
|
+
{"body": "ā
Drift resolved ā infrastructure matches Terraform state again."},
|
|
103
|
+
)
|
|
104
|
+
self._req("PATCH", f"{base}/{issue['number']}", {"state": "closed", "state_reason": "completed"})
|
|
105
|
+
return "closed", issue["html_url"]
|
|
106
|
+
return "none", ""
|
|
107
|
+
body = _truncate(body)
|
|
108
|
+
if issue:
|
|
109
|
+
if f"`{fingerprint}`" in (issue.get("body") or ""):
|
|
110
|
+
return "unchanged", issue["html_url"]
|
|
111
|
+
self._req("PATCH", f"{base}/{issue['number']}", {"title": title, "body": body})
|
|
112
|
+
self._req(
|
|
113
|
+
"POST",
|
|
114
|
+
f"{base}/{issue['number']}/comments",
|
|
115
|
+
{"body": f"š Drift changed (fingerprint `{fingerprint}`). Report updated above."},
|
|
116
|
+
)
|
|
117
|
+
return "updated", issue["html_url"]
|
|
118
|
+
created = self._req("POST", base, {"title": title, "body": body, "labels": [DRIFT_LABEL]})
|
|
119
|
+
return "created", created["html_url"]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def pr_number_from_event() -> int | None:
|
|
123
|
+
path = os.environ.get("GITHUB_EVENT_PATH")
|
|
124
|
+
if not path or not os.path.isfile(path):
|
|
125
|
+
return None
|
|
126
|
+
with open(path, encoding="utf-8") as f:
|
|
127
|
+
event = json.load(f)
|
|
128
|
+
pr = event.get("pull_request") or {}
|
|
129
|
+
num = pr.get("number") or (event.get("issue") or {}).get("number")
|
|
130
|
+
return int(num) if num else None
|