kdrift 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.
kdrift/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Kustomize manifest drift detection tool."""
2
+
3
+ import yaml
4
+
5
+ safe_loader: type = getattr(yaml, "CSafeLoader", yaml.SafeLoader)
kdrift/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m kdrift`."""
2
+
3
+ from kdrift.cli import main
4
+
5
+ main()
kdrift/cli.py ADDED
@@ -0,0 +1,188 @@
1
+ """CLI entrypoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import click
10
+ import structlog
11
+
12
+ from kdrift import config, git, models, pipeline
13
+ from kdrift import logging as kdrift_logging
14
+ from kdrift import watch as kdrift_watch
15
+
16
+ log: structlog.stdlib.BoundLogger = structlog.get_logger()
17
+
18
+
19
+ @click.group()
20
+ @click.option("--log-level", default="WARNING", help="Log level (DEBUG, INFO, WARNING, ERROR).")
21
+ @click.pass_context
22
+ def main(ctx: click.Context, log_level: str) -> None:
23
+ """Kustomize manifest drift detection tool."""
24
+ cfg = config.AppConfig()
25
+ kdrift_logging.configure_logging(log_level=log_level, log_format=cfg.log_format)
26
+
27
+ ctx.ensure_object(dict)
28
+ ctx.obj["config"] = cfg
29
+ ctx.obj["log_level"] = log_level
30
+
31
+
32
+ def _parse_ref_range(ref: str) -> tuple[str, str | None]:
33
+ """Parse a ref or ref range (A..B) into (base_ref, target_ref)."""
34
+ if ".." in ref:
35
+ parts = ref.split("..", 1)
36
+ if not parts[0] or not parts[1]:
37
+ msg = f"Invalid ref range '{ref}': both sides of '..' are required"
38
+ raise click.BadParameter(msg, param_hint="'--ref'")
39
+ return parts[0], parts[1]
40
+ return ref, None
41
+
42
+
43
+ @main.command()
44
+ @click.argument("paths", nargs=-1, type=click.Path(exists=False))
45
+ @click.option("--repo", "-C", "repo_path", type=click.Path(exists=True), default=None, help="Repository root.")
46
+ @click.option("--ref", default="HEAD", help="Git ref for baseline, or A..B for two-ref comparison.")
47
+ @click.option("--overlay", type=click.Path(), default=None, help="Diff only this overlay.")
48
+ @click.option(
49
+ "--format",
50
+ "output_format",
51
+ type=click.Choice(["unified", "json"]),
52
+ default="unified",
53
+ help="Output format.",
54
+ )
55
+ @click.option("--watch", "watch_mode", is_flag=True, help="Watch for changes and re-diff continuously.")
56
+ @click.option("--check", is_flag=True, help="Exit non-zero if any overlay has drift (CI/pre-commit mode).")
57
+ @click.pass_context
58
+ def diff(
59
+ ctx: click.Context,
60
+ paths: tuple[str, ...],
61
+ repo_path: str | None,
62
+ ref: str,
63
+ overlay: str | None,
64
+ output_format: str,
65
+ watch_mode: bool,
66
+ check: bool,
67
+ ) -> None:
68
+ """Diff kustomize overlays against a baseline ref."""
69
+ start = Path(repo_path) if repo_path else (Path(paths[0]).resolve() if paths else None)
70
+ try:
71
+ repo_root = git.find_repo_root(start)
72
+ except git.GitError as e:
73
+ click.echo(f"Error: {e}", err=True)
74
+ sys.exit(1)
75
+
76
+ if not git.has_commits(repo_root):
77
+ click.echo("Error: repository has no commits yet", err=True)
78
+ sys.exit(1)
79
+
80
+ base_ref, target_ref = _parse_ref_range(ref)
81
+
82
+ proj_config = config.load_project_config(repo_root)
83
+ path_list = [Path(p) for p in paths] if paths else None
84
+
85
+ if watch_mode:
86
+ if target_ref is not None:
87
+ click.echo("Error: --watch is not supported with ref ranges (A..B)", err=True)
88
+ sys.exit(1)
89
+ kdrift_watch.watch(
90
+ repo_root=repo_root,
91
+ ref=base_ref,
92
+ paths=path_list,
93
+ output_format=output_format,
94
+ kustomize_args=proj_config.kustomize_args,
95
+ )
96
+ return
97
+
98
+ overlay_path = Path(overlay) if overlay else None
99
+
100
+ try:
101
+ result = pipeline.run_diff(
102
+ repo_root=repo_root,
103
+ ref=base_ref,
104
+ paths=path_list,
105
+ overlay_filter=overlay_path,
106
+ kustomize_args=proj_config.kustomize_args,
107
+ target_ref=target_ref,
108
+ )
109
+ except Exception as e:
110
+ click.echo(f"Error: {e}", err=True)
111
+ sys.exit(1)
112
+
113
+ if output_format == "json":
114
+ _print_json(result)
115
+ else:
116
+ _print_unified(result)
117
+
118
+ if result.has_errors:
119
+ sys.exit(1)
120
+ if check and result.has_changes:
121
+ sys.exit(1)
122
+ if not result.has_changes:
123
+ sys.exit(0)
124
+
125
+
126
+ @main.command()
127
+ @click.option("--debug", is_flag=True, help="Enable debug logging and file log (~/.cache/kdrift/kdrift.log).")
128
+ @click.pass_context
129
+ def mcp(ctx: click.Context, debug: bool) -> None:
130
+ """Start the MCP server for AI agent integration."""
131
+ log_level = "DEBUG" if debug else ctx.obj["log_level"]
132
+ kdrift_logging.configure_logging(log_level=log_level, stream="stderr", log_file=debug)
133
+ from kdrift import mcp_server
134
+
135
+ mcp_server.run_mcp_server()
136
+
137
+
138
+ @main.command()
139
+ @click.option("--debug", is_flag=True, help="Enable debug logging and file log (~/.cache/kdrift/kdrift.log).")
140
+ @click.pass_context
141
+ def lsp(ctx: click.Context, debug: bool) -> None:
142
+ """Start the LSP server for IDE integration."""
143
+ log_level = "DEBUG" if debug else ctx.obj["log_level"]
144
+ kdrift_logging.configure_logging(log_level=log_level, stream="stderr", log_file=debug)
145
+ from kdrift import lsp_server
146
+
147
+ lsp_server.run_lsp_server()
148
+
149
+
150
+ def _print_json(result: models.DiffResult) -> None:
151
+ """Print structured JSON output."""
152
+ output = json.loads(result.model_dump_json())
153
+ click.echo(json.dumps(output, indent=2))
154
+
155
+
156
+ def _print_unified(result: models.DiffResult) -> None:
157
+ """Print unified diff output."""
158
+ if not result.has_changes and not result.has_errors:
159
+ return
160
+
161
+ for overlay_result in result.overlays:
162
+ if overlay_result.has_error:
163
+ click.echo(f"ERROR [{overlay_result.path}]: {overlay_result.error}", err=True)
164
+ continue
165
+
166
+ if not overlay_result.has_changes:
167
+ continue
168
+
169
+ click.echo(f"=== {overlay_result.path} ===")
170
+ for change in overlay_result.changes:
171
+ status_marker = {
172
+ models.DiffStatus.ADDED: "[NEW]",
173
+ models.DiffStatus.REMOVED: "[DEL]",
174
+ models.DiffStatus.MODIFIED: "",
175
+ }[change.status]
176
+
177
+ rid = change.resource_id
178
+ header = f"{rid.gvk} {rid.namespace}/{rid.name}" if rid.namespace else f"{rid.gvk} {rid.name}"
179
+ if status_marker:
180
+ header = f"{status_marker} {header}"
181
+
182
+ click.echo(f"\n--- {header} ---")
183
+ if change.diff_text:
184
+ click.echo(change.diff_text)
185
+ click.echo()
186
+
187
+ for error in result.errors:
188
+ click.echo(f"ERROR: {error}", err=True)
kdrift/config.py ADDED
@@ -0,0 +1,106 @@
1
+ """Configuration hierarchy: .kdrift.yaml (project > org > user)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ import pydantic
9
+ import pydantic_settings
10
+ import yaml
11
+
12
+ from kdrift import safe_loader
13
+
14
+
15
+ class AppConfig(pydantic_settings.BaseSettings):
16
+ """Application configuration loaded from environment variables."""
17
+
18
+ model_config = pydantic_settings.SettingsConfigDict(
19
+ env_prefix="KDRIFT_",
20
+ frozen=True,
21
+ )
22
+
23
+ log_level: str = "INFO"
24
+ log_format: str = "json"
25
+ external_diff: str | None = None
26
+
27
+
28
+ class ProjectConfig(pydantic.BaseModel):
29
+ """Configuration from .kdrift.yaml files."""
30
+
31
+ model_config = pydantic.ConfigDict(frozen=True)
32
+
33
+ kustomize_args: list[str] = pydantic.Field(
34
+ default_factory=lambda: [
35
+ "--enable-helm",
36
+ "--load-restrictor",
37
+ "LoadRestrictionsNone",
38
+ ]
39
+ )
40
+ kustomize_binary: str | None = None
41
+
42
+
43
+ def load_project_config(start_dir: Path | None = None) -> ProjectConfig:
44
+ """Load project config by walking upward from start_dir.
45
+
46
+ Searches for .kdrift.yaml files from start_dir up to filesystem root,
47
+ then checks the user-level XDG config. More specific files override
48
+ less specific ones (per key).
49
+ """
50
+ configs: list[dict[str, object]] = []
51
+
52
+ user_config = _user_config_path()
53
+ if user_config.is_file():
54
+ data = _load_yaml(user_config)
55
+ if data:
56
+ configs.append(data)
57
+
58
+ start = start_dir or Path.cwd()
59
+ path_configs = _walk_up_configs(start)
60
+ configs.extend(reversed(path_configs))
61
+
62
+ if not configs:
63
+ return ProjectConfig()
64
+
65
+ merged: dict[str, object] = {}
66
+ for cfg in configs:
67
+ merged.update(cfg)
68
+
69
+ return ProjectConfig.model_validate(merged)
70
+
71
+
72
+ def _walk_up_configs(start: Path) -> list[dict[str, object]]:
73
+ """Walk upward from start collecting .kdrift.yaml files (most specific first)."""
74
+ configs: list[dict[str, object]] = []
75
+ current = start.resolve()
76
+
77
+ while True:
78
+ cfg_file = current / ".kdrift.yaml"
79
+ if cfg_file.is_file():
80
+ data = _load_yaml(cfg_file)
81
+ if data:
82
+ configs.append(data)
83
+ parent = current.parent
84
+ if parent == current:
85
+ break
86
+ current = parent
87
+
88
+ return configs
89
+
90
+
91
+ def _user_config_path() -> Path:
92
+ """Get the user-level config path (XDG_CONFIG_HOME/kdrift/config.yaml)."""
93
+ xdg = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))
94
+ return Path(xdg) / "kdrift" / "config.yaml"
95
+
96
+
97
+ def _load_yaml(path: Path) -> dict[str, object] | None:
98
+ """Load a YAML file, returning None on error."""
99
+ try:
100
+ with path.open() as f:
101
+ data = yaml.load(f, Loader=safe_loader)
102
+ if isinstance(data, dict):
103
+ return data
104
+ except (yaml.YAMLError, OSError):
105
+ pass
106
+ return None
kdrift/diff.py ADDED
@@ -0,0 +1,232 @@
1
+ """Per-resource structured diffs with two-phase matching.
2
+
3
+ Phase 1: exact match by GVK + namespace + name.
4
+ Phase 2: generator-aware matching for configMapGenerator/secretGenerator
5
+ hash-suffixed names, using longest-name-first ordering to prevent false
6
+ matches (e.g., dex-config-abc12 matching generator `dex` instead of
7
+ `dex-config`).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import difflib
13
+ import re
14
+ from pathlib import Path
15
+
16
+ import yaml
17
+
18
+ from kdrift import models, safe_loader
19
+
20
+ _KUSTOMIZE_HASH_CHARS = "bcdfghjklmnpqrstvwxz2456789"
21
+ _HASH_SUFFIX_RE = re.compile(rf"^(.+)-[{_KUSTOMIZE_HASH_CHARS}]{{5,10}}$")
22
+
23
+
24
+ def diff_rendered(
25
+ baseline: str,
26
+ candidate: str,
27
+ overlay_path: Path,
28
+ ) -> models.OverlayResult:
29
+ """Diff two rendered YAML strings and produce per-resource changes.
30
+
31
+ Args:
32
+ baseline: Rendered YAML from the baseline ref.
33
+ candidate: Rendered YAML from the working tree.
34
+ overlay_path: Path to the overlay (for identification).
35
+
36
+ Returns:
37
+ OverlayResult with per-resource changes.
38
+ """
39
+ baseline_resources = _parse_resources(baseline)
40
+ candidate_resources = _parse_resources(candidate)
41
+
42
+ changes = _match_and_diff(baseline_resources, candidate_resources)
43
+ return models.OverlayResult(path=overlay_path, changes=changes)
44
+
45
+
46
+ def _parse_resources(rendered: str) -> dict[models.ResourceId, str]:
47
+ """Split rendered YAML into individual resources keyed by identity."""
48
+ resources: dict[models.ResourceId, str] = {}
49
+ if not rendered.strip():
50
+ return resources
51
+
52
+ docs = rendered.split("\n---")
53
+ for raw_doc in docs:
54
+ doc = raw_doc.strip()
55
+ if not doc or doc == "---":
56
+ continue
57
+
58
+ try:
59
+ parsed = yaml.load(doc, Loader=safe_loader)
60
+ except yaml.YAMLError:
61
+ continue
62
+
63
+ if not isinstance(parsed, dict) or "kind" not in parsed:
64
+ continue
65
+
66
+ resource_id = models.ResourceId.from_manifest(parsed)
67
+ resources[resource_id] = doc
68
+
69
+ return resources
70
+
71
+
72
+ def _match_and_diff(
73
+ baseline: dict[models.ResourceId, str],
74
+ candidate: dict[models.ResourceId, str],
75
+ ) -> list[models.ResourceChange]:
76
+ """Two-phase resource matching and diffing."""
77
+ changes: list[models.ResourceChange] = []
78
+ matched_baseline: set[models.ResourceId] = set()
79
+ matched_candidate: set[models.ResourceId] = set()
80
+
81
+ for rid, candidate_yaml in candidate.items():
82
+ if rid in baseline:
83
+ matched_baseline.add(rid)
84
+ matched_candidate.add(rid)
85
+ diff_text = _unified_diff(baseline[rid], candidate_yaml, rid)
86
+ if diff_text:
87
+ added, removed = _count_diff_lines(diff_text)
88
+ changes.append(
89
+ models.ResourceChange(
90
+ resource_id=rid,
91
+ status=models.DiffStatus.MODIFIED,
92
+ diff_text=diff_text,
93
+ lines_added=added,
94
+ lines_removed=removed,
95
+ )
96
+ )
97
+
98
+ unmatched_baseline = {rid: y for rid, y in baseline.items() if rid not in matched_baseline}
99
+ unmatched_candidate = {rid: y for rid, y in candidate.items() if rid not in matched_candidate}
100
+
101
+ if unmatched_baseline and unmatched_candidate:
102
+ gen_matches = _generator_aware_match(unmatched_baseline, unmatched_candidate)
103
+ for b_rid, c_rid in gen_matches:
104
+ matched_baseline.add(b_rid)
105
+ matched_candidate.add(c_rid)
106
+ diff_text = _unified_diff(baseline[b_rid], candidate[c_rid], c_rid)
107
+ if diff_text:
108
+ added, removed = _count_diff_lines(diff_text)
109
+ changes.append(
110
+ models.ResourceChange(
111
+ resource_id=c_rid,
112
+ status=models.DiffStatus.MODIFIED,
113
+ diff_text=diff_text,
114
+ lines_added=added,
115
+ lines_removed=removed,
116
+ )
117
+ )
118
+
119
+ for rid in sorted(unmatched_candidate.keys() - matched_candidate, key=lambda r: r.name):
120
+ changes.append(
121
+ models.ResourceChange(
122
+ resource_id=rid,
123
+ status=models.DiffStatus.ADDED,
124
+ diff_text=_added_diff(candidate[rid], rid),
125
+ lines_added=len(candidate[rid].splitlines()),
126
+ )
127
+ )
128
+
129
+ for rid in sorted(unmatched_baseline.keys() - matched_baseline, key=lambda r: r.name):
130
+ changes.append(
131
+ models.ResourceChange(
132
+ resource_id=rid,
133
+ status=models.DiffStatus.REMOVED,
134
+ diff_text=_removed_diff(baseline[rid], rid),
135
+ lines_removed=len(baseline[rid].splitlines()),
136
+ )
137
+ )
138
+
139
+ return changes
140
+
141
+
142
+ def _generator_aware_match(
143
+ baseline: dict[models.ResourceId, str],
144
+ candidate: dict[models.ResourceId, str],
145
+ ) -> list[tuple[models.ResourceId, models.ResourceId]]:
146
+ """Phase 2: match resources with hash-suffixed names.
147
+
148
+ Sorts by name length (longest first) to prevent short generator
149
+ names from stealing matches. E.g., generator `dex-config` should
150
+ match `dex-config-abc12` before generator `dex` gets a chance.
151
+ """
152
+ matches: list[tuple[models.ResourceId, models.ResourceId]] = []
153
+ used_candidate: set[models.ResourceId] = set()
154
+
155
+ baseline_sorted = sorted(baseline.keys(), key=lambda r: len(r.name), reverse=True)
156
+
157
+ for b_rid in baseline_sorted:
158
+ b_base = _strip_hash_suffix(b_rid.name)
159
+ for c_rid in candidate:
160
+ if c_rid in used_candidate:
161
+ continue
162
+ if c_rid.group != b_rid.group or c_rid.version != b_rid.version:
163
+ continue
164
+ if c_rid.kind != b_rid.kind or c_rid.namespace != b_rid.namespace:
165
+ continue
166
+ c_base = _strip_hash_suffix(c_rid.name)
167
+ if b_base == c_base:
168
+ matches.append((b_rid, c_rid))
169
+ used_candidate.add(c_rid)
170
+ break
171
+
172
+ return matches
173
+
174
+
175
+ def _strip_hash_suffix(name: str) -> str:
176
+ """Strip a kustomize hash suffix from a resource name."""
177
+ match = _HASH_SUFFIX_RE.match(name)
178
+ return match.group(1) if match else name
179
+
180
+
181
+ def _unified_diff(baseline_yaml: str, candidate_yaml: str, rid: models.ResourceId) -> str:
182
+ """Produce a unified diff between two YAML strings."""
183
+ if baseline_yaml and not baseline_yaml.endswith("\n"):
184
+ baseline_yaml += "\n"
185
+ if candidate_yaml and not candidate_yaml.endswith("\n"):
186
+ candidate_yaml += "\n"
187
+ baseline_lines = baseline_yaml.splitlines(keepends=True)
188
+ candidate_lines = candidate_yaml.splitlines(keepends=True)
189
+
190
+ diff = difflib.unified_diff(
191
+ baseline_lines,
192
+ candidate_lines,
193
+ fromfile=f"baseline/{rid.gvk}/{rid.namespace}/{rid.name}",
194
+ tofile=f"candidate/{rid.gvk}/{rid.namespace}/{rid.name}",
195
+ )
196
+ return "".join(diff)
197
+
198
+
199
+ def _added_diff(yaml_text: str, rid: models.ResourceId) -> str:
200
+ """Produce a diff showing a newly added resource."""
201
+ lines = yaml_text.splitlines(keepends=True)
202
+ diff = difflib.unified_diff(
203
+ [],
204
+ lines,
205
+ fromfile="/dev/null",
206
+ tofile=f"candidate/{rid.gvk}/{rid.namespace}/{rid.name}",
207
+ )
208
+ return "".join(diff)
209
+
210
+
211
+ def _removed_diff(yaml_text: str, rid: models.ResourceId) -> str:
212
+ """Produce a diff showing a removed resource."""
213
+ lines = yaml_text.splitlines(keepends=True)
214
+ diff = difflib.unified_diff(
215
+ lines,
216
+ [],
217
+ fromfile=f"baseline/{rid.gvk}/{rid.namespace}/{rid.name}",
218
+ tofile="/dev/null",
219
+ )
220
+ return "".join(diff)
221
+
222
+
223
+ def _count_diff_lines(diff_text: str) -> tuple[int, int]:
224
+ """Count added and removed lines in a unified diff."""
225
+ added = 0
226
+ removed = 0
227
+ for line in diff_text.splitlines():
228
+ if line.startswith("+") and not line.startswith("+++"):
229
+ added += 1
230
+ elif line.startswith("-") and not line.startswith("---"):
231
+ removed += 1
232
+ return added, removed