lambda-watcher 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.
- lambda_watcher/__init__.py +4 -0
- lambda_watcher/__main__.py +4 -0
- lambda_watcher/analysis/__init__.py +115 -0
- lambda_watcher/analysis/deps.py +291 -0
- lambda_watcher/analysis/envvars.py +80 -0
- lambda_watcher/analysis/handler.py +111 -0
- lambda_watcher/analysis/inventory.py +118 -0
- lambda_watcher/analysis/runtime.py +117 -0
- lambda_watcher/analysis/secrets.py +178 -0
- lambda_watcher/analysis/services.py +76 -0
- lambda_watcher/cli.py +1406 -0
- lambda_watcher/config.py +324 -0
- lambda_watcher/db.py +466 -0
- lambda_watcher/diffing/__init__.py +14 -0
- lambda_watcher/diffing/build.py +51 -0
- lambda_watcher/diffing/compare.py +525 -0
- lambda_watcher/diffing/highlight.py +312 -0
- lambda_watcher/diffing/icons.py +132 -0
- lambda_watcher/diffing/intraline.py +162 -0
- lambda_watcher/diffing/render_html.py +697 -0
- lambda_watcher/diffing/render_text.py +198 -0
- lambda_watcher/extract.py +227 -0
- lambda_watcher/gitmirror.py +151 -0
- lambda_watcher/identify.py +201 -0
- lambda_watcher/ingest.py +480 -0
- lambda_watcher/notify.py +59 -0
- lambda_watcher/reindex.py +158 -0
- lambda_watcher/service.py +553 -0
- lambda_watcher/store.py +209 -0
- lambda_watcher/templates.py +124 -0
- lambda_watcher/utils.py +314 -0
- lambda_watcher/watcher.py +241 -0
- lambda_watcher-0.1.0.dist-info/METADATA +409 -0
- lambda_watcher-0.1.0.dist-info/RECORD +38 -0
- lambda_watcher-0.1.0.dist-info/WHEEL +5 -0
- lambda_watcher-0.1.0.dist-info/entry_points.txt +3 -0
- lambda_watcher-0.1.0.dist-info/licenses/LICENSE +201 -0
- lambda_watcher-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Terminal rendering of a version diff, using rich."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.console import Console, Group
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
from rich.rule import Rule
|
|
8
|
+
from rich.syntax import Syntax
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
from ..utils import human_size, rename_label, signed
|
|
13
|
+
from .compare import VersionDiff
|
|
14
|
+
|
|
15
|
+
_KIND_STYLE = {
|
|
16
|
+
"added": "green",
|
|
17
|
+
"removed": "red",
|
|
18
|
+
"modified": "yellow",
|
|
19
|
+
"renamed": "cyan",
|
|
20
|
+
"mode-changed": "magenta",
|
|
21
|
+
}
|
|
22
|
+
_SEVERITY_STYLE = {"high": "bold red", "medium": "yellow", "low": "dim"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _label(change) -> str:
|
|
26
|
+
"""How one file is named in a listing.
|
|
27
|
+
|
|
28
|
+
A rename writes the two paths as one, with the part that moved in braces —
|
|
29
|
+
the alternative is 90 characters of identical path twice over, wrapped
|
|
30
|
+
across two rows, for a version number that changed in the middle.
|
|
31
|
+
"""
|
|
32
|
+
if change.kind != "renamed" or not change.old_path:
|
|
33
|
+
return change.path
|
|
34
|
+
head, was, now, tail = rename_label(change.old_path, change.path)
|
|
35
|
+
return f"{head}{{{was} \u2192 {now}}}{tail}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _stat_line(diff: VersionDiff) -> Text:
|
|
39
|
+
counts = diff.counts()
|
|
40
|
+
text = Text()
|
|
41
|
+
for kind in ("added", "removed", "modified", "renamed"):
|
|
42
|
+
if counts.get(kind):
|
|
43
|
+
text.append(f"{counts[kind]} {kind} ", style=_KIND_STYLE[kind])
|
|
44
|
+
if diff.vendor_files_changed:
|
|
45
|
+
text.append(f"{diff.vendor_files_changed} vendored (hidden) ", style="dim")
|
|
46
|
+
if diff.diffs_computed:
|
|
47
|
+
text.append(f"+{diff.total_added_lines}", style="green")
|
|
48
|
+
text.append(" / ")
|
|
49
|
+
text.append(f"-{diff.total_removed_lines}", style="red")
|
|
50
|
+
text.append(" lines")
|
|
51
|
+
else:
|
|
52
|
+
text.append("line counts skipped (--no-patch)", style="dim")
|
|
53
|
+
return text
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def render_summary(console: Console, diff: VersionDiff) -> None:
|
|
57
|
+
header = Text()
|
|
58
|
+
header.append(diff.function_name, style="bold")
|
|
59
|
+
header.append(f" v{diff.a_seq:04d} → v{diff.b_seq:04d}", style="bold cyan")
|
|
60
|
+
console.print(Panel(Group(header, _stat_line(diff)), border_style="cyan", expand=False))
|
|
61
|
+
|
|
62
|
+
if diff.runtime_change:
|
|
63
|
+
console.print(f" [bold]runtime[/bold] {diff.runtime_change[0]} → {diff.runtime_change[1]}")
|
|
64
|
+
if diff.handler_change:
|
|
65
|
+
before, after = diff.handler_change
|
|
66
|
+
console.print(f" [bold]handler[/bold] {before or '?'} → {after or '?'}")
|
|
67
|
+
|
|
68
|
+
size_a = diff.a_meta.get("total_size", 0)
|
|
69
|
+
size_b = diff.b_meta.get("total_size", 0)
|
|
70
|
+
if size_a or size_b:
|
|
71
|
+
console.print(
|
|
72
|
+
f" [bold]size[/bold] {human_size(size_a)} → {human_size(size_b)} "
|
|
73
|
+
f"([{'green' if size_b <= size_a else 'yellow'}]{signed(size_b - size_a)} B[/])"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def render_dependencies(console: Console, diff: VersionDiff) -> None:
|
|
78
|
+
if not diff.deps:
|
|
79
|
+
return
|
|
80
|
+
table = Table(title="Dependencies", title_justify="left", header_style="bold", box=None,
|
|
81
|
+
padding=(0, 2, 0, 0))
|
|
82
|
+
table.add_column("")
|
|
83
|
+
table.add_column("manager", style="dim")
|
|
84
|
+
table.add_column("package")
|
|
85
|
+
table.add_column("from", style="red")
|
|
86
|
+
table.add_column("to", style="green")
|
|
87
|
+
table.add_column("origin", style="dim")
|
|
88
|
+
|
|
89
|
+
marks = {"added": ("+", "green"), "removed": ("−", "red"), "changed": ("~", "yellow")}
|
|
90
|
+
for change in diff.deps:
|
|
91
|
+
mark, style = marks[change.kind]
|
|
92
|
+
table.add_row(
|
|
93
|
+
Text(mark, style=style),
|
|
94
|
+
change.manager,
|
|
95
|
+
change.name,
|
|
96
|
+
change.old_version or "—",
|
|
97
|
+
change.new_version or "—",
|
|
98
|
+
"declared" if change.is_declared else "installed",
|
|
99
|
+
)
|
|
100
|
+
console.print()
|
|
101
|
+
console.print(table)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def render_context(console: Console, diff: VersionDiff) -> None:
|
|
105
|
+
rows: list[tuple[str, str, str]] = []
|
|
106
|
+
if diff.env_added:
|
|
107
|
+
rows.append(("Env vars added", ", ".join(diff.env_added), "green"))
|
|
108
|
+
if diff.env_removed:
|
|
109
|
+
rows.append(("Env vars removed", ", ".join(diff.env_removed), "red"))
|
|
110
|
+
if diff.services_added:
|
|
111
|
+
rows.append(("AWS services added", ", ".join(diff.services_added), "green"))
|
|
112
|
+
if diff.services_removed:
|
|
113
|
+
rows.append(("AWS services removed", ", ".join(diff.services_removed), "red"))
|
|
114
|
+
if not rows:
|
|
115
|
+
return
|
|
116
|
+
console.print()
|
|
117
|
+
for label, value, style in rows:
|
|
118
|
+
console.print(f" [bold]{label}:[/bold] [{style}]{value}[/{style}]")
|
|
119
|
+
if diff.env_added:
|
|
120
|
+
console.print(
|
|
121
|
+
" [dim]↑ these need to exist in the function's environment configuration[/dim]"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def render_findings(console: Console, diff: VersionDiff) -> None:
|
|
126
|
+
if not diff.findings_new and not diff.findings_fixed:
|
|
127
|
+
return
|
|
128
|
+
console.print()
|
|
129
|
+
if diff.findings_new:
|
|
130
|
+
table = Table(title="New findings", title_justify="left", title_style="bold red",
|
|
131
|
+
header_style="bold", box=None, padding=(0, 2, 0, 0), show_header=False)
|
|
132
|
+
table.add_column("", justify="right")
|
|
133
|
+
table.add_column("kind")
|
|
134
|
+
table.add_column("where", style="dim")
|
|
135
|
+
table.add_column("detail", style="dim")
|
|
136
|
+
for finding in diff.findings_new[:25]:
|
|
137
|
+
table.add_row(
|
|
138
|
+
Text(finding["severity"], style=_SEVERITY_STYLE.get(finding["severity"], "")),
|
|
139
|
+
finding["kind"],
|
|
140
|
+
f"{finding['path']}:{finding['line']}",
|
|
141
|
+
finding["detail"],
|
|
142
|
+
)
|
|
143
|
+
console.print(table)
|
|
144
|
+
if diff.findings_fixed:
|
|
145
|
+
console.print(f"[green]Resolved findings:[/green] {len(diff.findings_fixed)}")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def render_files(console: Console, diff: VersionDiff, show_diffs: bool = True,
|
|
149
|
+
max_files: int = 200) -> None:
|
|
150
|
+
if not diff.files:
|
|
151
|
+
console.print("\n[dim]No file-level changes.[/dim]")
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
console.print()
|
|
155
|
+
table = Table(title="Files", title_justify="left", header_style="bold", box=None,
|
|
156
|
+
padding=(0, 2, 0, 0))
|
|
157
|
+
table.add_column("")
|
|
158
|
+
table.add_column("path")
|
|
159
|
+
table.add_column("+", justify="right", style="green")
|
|
160
|
+
table.add_column("−", justify="right", style="red")
|
|
161
|
+
table.add_column("size", justify="right", style="dim")
|
|
162
|
+
|
|
163
|
+
marks = {"added": "+", "removed": "−", "modified": "~", "renamed": "→", "mode-changed": "m"}
|
|
164
|
+
for change in diff.files[:max_files]:
|
|
165
|
+
table.add_row(
|
|
166
|
+
Text(marks.get(change.kind, "?"), style=_KIND_STYLE.get(change.kind, "")),
|
|
167
|
+
Text(_label(change), style="dim" if change.is_vendor else ""),
|
|
168
|
+
str(change.added_lines or ""),
|
|
169
|
+
str(change.removed_lines or ""),
|
|
170
|
+
signed(change.size_delta) if change.size_delta else "",
|
|
171
|
+
)
|
|
172
|
+
console.print(table)
|
|
173
|
+
if len(diff.files) > max_files:
|
|
174
|
+
console.print(f"[dim]… and {len(diff.files) - max_files} more files[/dim]")
|
|
175
|
+
|
|
176
|
+
if not show_diffs:
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
for change in diff.files:
|
|
180
|
+
if not change.diff_lines:
|
|
181
|
+
continue
|
|
182
|
+
console.print()
|
|
183
|
+
console.print(Rule(f"[bold]{_label(change)}[/bold]",
|
|
184
|
+
style=_KIND_STYLE.get(change.kind, "white")))
|
|
185
|
+
body = "\n".join(change.diff_lines)
|
|
186
|
+
console.print(Syntax(body, "diff", theme="ansi_dark", word_wrap=False, background_color="default"))
|
|
187
|
+
if change.truncated:
|
|
188
|
+
console.print("[dim]… diff truncated (raise diff.max_diff_lines to see more)[/dim]")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def render(console: Console, diff: VersionDiff, show_diffs: bool = True) -> None:
|
|
192
|
+
render_summary(console, diff)
|
|
193
|
+
render_dependencies(console, diff)
|
|
194
|
+
render_context(console, diff)
|
|
195
|
+
render_findings(console, diff)
|
|
196
|
+
render_files(console, diff, show_diffs=show_diffs)
|
|
197
|
+
if diff.is_empty:
|
|
198
|
+
console.print("\n[green]These two versions are identical.[/green]")
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Safe extraction of Lambda deployment zips.
|
|
2
|
+
|
|
3
|
+
Deployment packages are downloaded from a trusted account, but they are still
|
|
4
|
+
archives from the internet: this module refuses path traversal, absolute paths,
|
|
5
|
+
symlinks pointing outside the tree, and archives that would explode on disk.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import stat
|
|
12
|
+
import zipfile
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path, PurePosixPath
|
|
15
|
+
|
|
16
|
+
from .utils import LOG, rmtree
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ExtractError(RuntimeError):
|
|
20
|
+
"""The archive could not be safely extracted."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class ExtractResult:
|
|
25
|
+
dest: Path
|
|
26
|
+
file_count: int = 0
|
|
27
|
+
dir_count: int = 0
|
|
28
|
+
total_uncompressed: int = 0
|
|
29
|
+
total_compressed: int = 0
|
|
30
|
+
skipped: list[str] = field(default_factory=list)
|
|
31
|
+
top_level: list[str] = field(default_factory=list)
|
|
32
|
+
is_encrypted: bool = False
|
|
33
|
+
#: Name of the single wrapping directory that was lifted away, if any.
|
|
34
|
+
wrapper_dir: str | None = None
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def compression_ratio(self) -> float:
|
|
38
|
+
if not self.total_compressed:
|
|
39
|
+
return 0.0
|
|
40
|
+
return self.total_uncompressed / self.total_compressed
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _is_within(base: Path, target: Path) -> bool:
|
|
44
|
+
try:
|
|
45
|
+
target.resolve().relative_to(base.resolve())
|
|
46
|
+
return True
|
|
47
|
+
except ValueError:
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def safe_member_path(base: Path, name: str) -> Path | None:
|
|
52
|
+
"""Resolve an archive member to a path inside ``base``, or None if unsafe."""
|
|
53
|
+
if not name or name.startswith("/") or name.startswith("\\"):
|
|
54
|
+
return None
|
|
55
|
+
# Windows-created archives sometimes use backslashes as separators.
|
|
56
|
+
normalised = name.replace("\\", "/")
|
|
57
|
+
pure = PurePosixPath(normalised)
|
|
58
|
+
if pure.is_absolute() or any(part == ".." for part in pure.parts):
|
|
59
|
+
return None
|
|
60
|
+
# Drive letters (C:/...) are traversal on Windows.
|
|
61
|
+
if len(pure.parts) and ":" in pure.parts[0]:
|
|
62
|
+
return None
|
|
63
|
+
target = base / Path(*pure.parts)
|
|
64
|
+
if not _is_within(base, target):
|
|
65
|
+
return None
|
|
66
|
+
return target
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def peek_top_level(zip_path: Path) -> list[str]:
|
|
70
|
+
"""Top-level entries of a zip without extracting it."""
|
|
71
|
+
try:
|
|
72
|
+
with zipfile.ZipFile(zip_path) as zf:
|
|
73
|
+
names = zf.namelist()
|
|
74
|
+
except (zipfile.BadZipFile, OSError):
|
|
75
|
+
return []
|
|
76
|
+
tops: list[str] = []
|
|
77
|
+
for name in names:
|
|
78
|
+
head = name.replace("\\", "/").split("/")[0]
|
|
79
|
+
if head and head not in tops and not head.startswith("__MACOSX"):
|
|
80
|
+
tops.append(head)
|
|
81
|
+
return tops
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def strip_wrapper_dir(dest: Path) -> str | None:
|
|
85
|
+
"""Lift a lone wrapping directory's contents up into ``dest``.
|
|
86
|
+
|
|
87
|
+
GitHub - and npm, and anything built by ``git archive`` - wraps the whole
|
|
88
|
+
tree in one directory named after the ref: ``myrepo-main/``,
|
|
89
|
+
``myrepo-1.2.3/``, ``myrepo-a1b2c3d/``. That name changes with every
|
|
90
|
+
download, and both the tree hash and the file diff key off paths, so
|
|
91
|
+
without this a re-download of the same project reads as "every file
|
|
92
|
+
removed, every file added" - and never as ``unchanged``.
|
|
93
|
+
|
|
94
|
+
Exactly one level is ever removed. A package whose real layout is a single
|
|
95
|
+
``src/`` directory keeps it, because collapsing further would start
|
|
96
|
+
discarding structure the archive actually meant.
|
|
97
|
+
|
|
98
|
+
Returns the name of the directory that was removed, or None if the tree was
|
|
99
|
+
left alone.
|
|
100
|
+
"""
|
|
101
|
+
try:
|
|
102
|
+
entries = list(dest.iterdir())
|
|
103
|
+
except OSError:
|
|
104
|
+
return None
|
|
105
|
+
if len(entries) != 1:
|
|
106
|
+
return None
|
|
107
|
+
wrapper = entries[0]
|
|
108
|
+
if wrapper.is_symlink() or not wrapper.is_dir():
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
# Three renames rather than a move per child: the wrapper steps out to a
|
|
112
|
+
# sibling, the emptied dest goes away, and the wrapper takes its place.
|
|
113
|
+
# Cost is the same whether the tree holds ten files or ten thousand.
|
|
114
|
+
staged = dest.parent / f"{dest.name}.unwrapped"
|
|
115
|
+
if staged.exists():
|
|
116
|
+
rmtree(staged)
|
|
117
|
+
try:
|
|
118
|
+
wrapper.rename(staged)
|
|
119
|
+
dest.rmdir()
|
|
120
|
+
staged.rename(dest)
|
|
121
|
+
except OSError as exc:
|
|
122
|
+
LOG.warning("could not unwrap %s, keeping the tree as extracted: %s", wrapper.name, exc)
|
|
123
|
+
# Undo whichever half of the swap went through.
|
|
124
|
+
if staged.exists():
|
|
125
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
try:
|
|
127
|
+
staged.rename(dest / wrapper.name)
|
|
128
|
+
except OSError:
|
|
129
|
+
LOG.error("left an unwrapped tree at %s", staged)
|
|
130
|
+
return None
|
|
131
|
+
return wrapper.name
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def extract_zip(
|
|
135
|
+
zip_path: Path,
|
|
136
|
+
dest: Path,
|
|
137
|
+
max_uncompressed_bytes: int = 2 * 1024**3,
|
|
138
|
+
max_files: int = 200_000,
|
|
139
|
+
strip_wrapper: bool = True,
|
|
140
|
+
) -> ExtractResult:
|
|
141
|
+
"""Extract ``zip_path`` into ``dest``, enforcing the safety limits."""
|
|
142
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
result = ExtractResult(dest=dest)
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
zf = zipfile.ZipFile(zip_path)
|
|
147
|
+
except zipfile.BadZipFile as exc:
|
|
148
|
+
raise ExtractError(f"not a valid zip archive: {exc}") from exc
|
|
149
|
+
except OSError as exc:
|
|
150
|
+
raise ExtractError(f"could not open archive: {exc}") from exc
|
|
151
|
+
|
|
152
|
+
with zf:
|
|
153
|
+
infos = zf.infolist()
|
|
154
|
+
if len(infos) > max_files:
|
|
155
|
+
raise ExtractError(f"archive has {len(infos)} entries (limit {max_files})")
|
|
156
|
+
planned = sum(i.file_size for i in infos)
|
|
157
|
+
if planned > max_uncompressed_bytes:
|
|
158
|
+
raise ExtractError(
|
|
159
|
+
f"archive expands to {planned / 1024 ** 2:.0f} MB "
|
|
160
|
+
f"(limit {max_uncompressed_bytes / 1024 ** 2:.0f} MB)"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
written = 0
|
|
164
|
+
for info in infos:
|
|
165
|
+
name = info.filename
|
|
166
|
+
if name.startswith("__MACOSX/") or PurePosixPath(name).name == ".DS_Store":
|
|
167
|
+
result.skipped.append(name)
|
|
168
|
+
continue
|
|
169
|
+
if info.flag_bits & 0x1:
|
|
170
|
+
result.is_encrypted = True
|
|
171
|
+
raise ExtractError("archive is password protected")
|
|
172
|
+
|
|
173
|
+
target = safe_member_path(dest, name)
|
|
174
|
+
if target is None:
|
|
175
|
+
LOG.warning("skipping unsafe archive member %r in %s", name, zip_path.name)
|
|
176
|
+
result.skipped.append(name)
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
mode = info.external_attr >> 16
|
|
180
|
+
if stat.S_ISLNK(mode):
|
|
181
|
+
# Symlinks in a deployment package are almost always vendored
|
|
182
|
+
# binaries; store the link text as a regular file so the tree
|
|
183
|
+
# stays self-contained and cannot escape the store.
|
|
184
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
185
|
+
link_target = zf.read(info).decode("utf-8", "replace")
|
|
186
|
+
target.write_text(link_target, encoding="utf-8")
|
|
187
|
+
result.file_count += 1
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
if info.is_dir():
|
|
191
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
result.dir_count += 1
|
|
193
|
+
continue
|
|
194
|
+
|
|
195
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
with zf.open(info) as src, target.open("wb") as out:
|
|
197
|
+
remaining = max_uncompressed_bytes - written
|
|
198
|
+
chunk_size = 1 << 20
|
|
199
|
+
while True:
|
|
200
|
+
chunk = src.read(chunk_size)
|
|
201
|
+
if not chunk:
|
|
202
|
+
break
|
|
203
|
+
remaining -= len(chunk)
|
|
204
|
+
if remaining < 0:
|
|
205
|
+
raise ExtractError("archive exceeded the uncompressed size limit")
|
|
206
|
+
out.write(chunk)
|
|
207
|
+
written += len(chunk)
|
|
208
|
+
|
|
209
|
+
result.file_count += 1
|
|
210
|
+
result.total_uncompressed += info.file_size
|
|
211
|
+
result.total_compressed += info.compress_size
|
|
212
|
+
|
|
213
|
+
# Preserve the executable bit; Lambda custom runtimes rely on it.
|
|
214
|
+
if mode and (mode & 0o111):
|
|
215
|
+
try:
|
|
216
|
+
os.chmod(target, (target.stat().st_mode | 0o111) & 0o777)
|
|
217
|
+
except OSError:
|
|
218
|
+
pass
|
|
219
|
+
|
|
220
|
+
if strip_wrapper:
|
|
221
|
+
result.wrapper_dir = strip_wrapper_dir(dest)
|
|
222
|
+
|
|
223
|
+
# Recorded after unwrapping: this is the tree everything downstream sees.
|
|
224
|
+
result.top_level = sorted(
|
|
225
|
+
{p.name for p in dest.iterdir()} if dest.exists() else set()
|
|
226
|
+
)
|
|
227
|
+
return result
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Optional per-function git repository, one commit per archived version.
|
|
2
|
+
|
|
3
|
+
This is the shortest path to a review workflow you already know: every version
|
|
4
|
+
is a commit tagged ``v0007``, so ``git diff v0002 v0010``, ``git log -p``, VS
|
|
5
|
+
Code's diff viewer and any git GUI all work on a single function's history
|
|
6
|
+
without ten unrelated Lambdas mixed into the same repo. It lives at
|
|
7
|
+
``functions/<slug>/repo/``, and ``lambda-watcher open`` hands that folder to an
|
|
8
|
+
editor.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from .config import GitMirrorConfig
|
|
19
|
+
from .utils import LOG, matches_any, rmtree
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class GitUnavailable(RuntimeError):
|
|
23
|
+
"""git is not installed or not usable."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class MirrorResult:
|
|
28
|
+
repo: Path
|
|
29
|
+
commit: str | None
|
|
30
|
+
tag: str | None
|
|
31
|
+
created_repo: bool = False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def git_available() -> bool:
|
|
35
|
+
return shutil.which("git") is not None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _run(repo: Path, *args: str, check: bool = True, env_extra: dict[str, str] | None = None) -> str:
|
|
39
|
+
import os
|
|
40
|
+
|
|
41
|
+
env = os.environ.copy()
|
|
42
|
+
# Keep the mirror hermetic: no user hooks, no global config surprises.
|
|
43
|
+
env.update({"GIT_CONFIG_NOSYSTEM": "1", "GIT_TERMINAL_PROMPT": "0"})
|
|
44
|
+
if env_extra:
|
|
45
|
+
env.update(env_extra)
|
|
46
|
+
proc = subprocess.run(
|
|
47
|
+
["git", *args],
|
|
48
|
+
cwd=str(repo),
|
|
49
|
+
capture_output=True,
|
|
50
|
+
text=True,
|
|
51
|
+
env=env,
|
|
52
|
+
timeout=300,
|
|
53
|
+
)
|
|
54
|
+
if check and proc.returncode != 0:
|
|
55
|
+
raise RuntimeError(f"git {' '.join(args)} failed: {proc.stderr.strip() or proc.stdout.strip()}")
|
|
56
|
+
return proc.stdout.strip()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def ensure_repo(repo: Path, cfg: GitMirrorConfig) -> bool:
|
|
60
|
+
"""Create the mirror repo if needed. Returns True when it was created."""
|
|
61
|
+
if not git_available():
|
|
62
|
+
raise GitUnavailable("git executable not found on PATH")
|
|
63
|
+
created = False
|
|
64
|
+
if not (repo / ".git").exists():
|
|
65
|
+
repo.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
_run(repo, "init", "-q", "-b", "main")
|
|
67
|
+
_run(repo, "config", "user.name", cfg.author_name)
|
|
68
|
+
_run(repo, "config", "user.email", cfg.author_email)
|
|
69
|
+
_run(repo, "config", "core.autocrlf", "false")
|
|
70
|
+
# Deployment packages contain binaries; keep git from mangling them.
|
|
71
|
+
(repo / ".gitattributes").write_text("* -text\n", encoding="utf-8")
|
|
72
|
+
created = True
|
|
73
|
+
return created
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _clear_worktree(repo: Path) -> None:
|
|
77
|
+
for entry in repo.iterdir():
|
|
78
|
+
if entry.name == ".git":
|
|
79
|
+
continue
|
|
80
|
+
if entry.is_dir():
|
|
81
|
+
rmtree(entry)
|
|
82
|
+
else:
|
|
83
|
+
try:
|
|
84
|
+
entry.unlink()
|
|
85
|
+
except OSError:
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _copy_tree(src: Path, repo: Path, vendor_globs: list[str], include_vendor: bool) -> int:
|
|
90
|
+
copied = 0
|
|
91
|
+
for path in src.rglob("*"):
|
|
92
|
+
if path.is_dir():
|
|
93
|
+
continue
|
|
94
|
+
rel = path.relative_to(src).as_posix()
|
|
95
|
+
if rel.startswith(".git/") or rel == ".git":
|
|
96
|
+
continue
|
|
97
|
+
if not include_vendor and matches_any(rel, vendor_globs):
|
|
98
|
+
continue
|
|
99
|
+
target = repo / rel
|
|
100
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
try:
|
|
102
|
+
shutil.copy2(path, target)
|
|
103
|
+
copied += 1
|
|
104
|
+
except OSError as exc:
|
|
105
|
+
LOG.debug("git mirror skipped %s: %s", rel, exc)
|
|
106
|
+
return copied
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def commit_version(
|
|
110
|
+
repo: Path,
|
|
111
|
+
code_dir: Path,
|
|
112
|
+
cfg: GitMirrorConfig,
|
|
113
|
+
seq: int,
|
|
114
|
+
message: str,
|
|
115
|
+
when_iso: str | None = None,
|
|
116
|
+
vendor_globs: list[str] | None = None,
|
|
117
|
+
) -> MirrorResult:
|
|
118
|
+
"""Replace the worktree with ``code_dir`` and commit it as version ``seq``."""
|
|
119
|
+
created = ensure_repo(repo, cfg)
|
|
120
|
+
_clear_worktree(repo)
|
|
121
|
+
# .gitattributes is part of the repo, not of any version; restore it.
|
|
122
|
+
(repo / ".gitattributes").write_text("* -text\n", encoding="utf-8")
|
|
123
|
+
_copy_tree(code_dir, repo, vendor_globs or [], cfg.include_vendor)
|
|
124
|
+
|
|
125
|
+
_run(repo, "add", "-A")
|
|
126
|
+
status = _run(repo, "status", "--porcelain")
|
|
127
|
+
tag = f"{cfg.tag_prefix}{seq:04d}"
|
|
128
|
+
if not status:
|
|
129
|
+
# Identical content: still tag it so `git diff v2 v10` never 404s.
|
|
130
|
+
try:
|
|
131
|
+
head = _run(repo, "rev-parse", "HEAD")
|
|
132
|
+
_run(repo, "tag", "-f", tag, head, check=False)
|
|
133
|
+
return MirrorResult(repo, head, tag, created)
|
|
134
|
+
except RuntimeError:
|
|
135
|
+
return MirrorResult(repo, None, None, created)
|
|
136
|
+
|
|
137
|
+
env_extra = {}
|
|
138
|
+
if when_iso:
|
|
139
|
+
env_extra = {"GIT_AUTHOR_DATE": when_iso, "GIT_COMMITTER_DATE": when_iso}
|
|
140
|
+
_run(repo, "-c", f"user.name={cfg.author_name}", "-c", f"user.email={cfg.author_email}",
|
|
141
|
+
"commit", "-q", "-m", message, env_extra=env_extra)
|
|
142
|
+
head = _run(repo, "rev-parse", "HEAD")
|
|
143
|
+
_run(repo, "tag", "-f", tag, head, check=False)
|
|
144
|
+
return MirrorResult(repo, head, tag, created)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def diff(repo: Path, tag_a: str, tag_b: str, extra_args: list[str] | None = None) -> str:
|
|
148
|
+
args = ["diff", tag_a, tag_b]
|
|
149
|
+
if extra_args:
|
|
150
|
+
args = ["diff", *extra_args, tag_a, tag_b]
|
|
151
|
+
return _run(repo, *args, check=False)
|