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 +5 -0
- kdrift/__main__.py +5 -0
- kdrift/cli.py +188 -0
- kdrift/config.py +106 -0
- kdrift/diff.py +232 -0
- kdrift/discover.py +317 -0
- kdrift/git.py +184 -0
- kdrift/logging.py +82 -0
- kdrift/lsp_server.py +576 -0
- kdrift/mcp_server.py +165 -0
- kdrift/models.py +145 -0
- kdrift/pipeline.py +223 -0
- kdrift/py.typed +0 -0
- kdrift/render.py +182 -0
- kdrift/watch.py +163 -0
- kdrift-0.1.0.dist-info/METADATA +13 -0
- kdrift-0.1.0.dist-info/RECORD +19 -0
- kdrift-0.1.0.dist-info/WHEEL +4 -0
- kdrift-0.1.0.dist-info/entry_points.txt +2 -0
kdrift/__init__.py
ADDED
kdrift/__main__.py
ADDED
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
|