ayasee 0.1.0rc2__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.
- ayasee/__init__.py +3 -0
- ayasee/approvals.py +117 -0
- ayasee/binding.py +335 -0
- ayasee/cascade.py +81 -0
- ayasee/changed.py +250 -0
- ayasee/changeset.py +215 -0
- ayasee/cli.py +76 -0
- ayasee/cockpit/__init__.py +0 -0
- ayasee/cockpit/collect.py +326 -0
- ayasee/cockpit/render_md.py +282 -0
- ayasee/config.py +403 -0
- ayasee/drift.py +253 -0
- ayasee/features.py +131 -0
- ayasee/findings.py +52 -0
- ayasee/github.py +590 -0
- ayasee/guard.py +159 -0
- ayasee/issues.py +179 -0
- ayasee/labels.py +6 -0
- ayasee/ledger.py +747 -0
- ayasee/maintip.py +43 -0
- ayasee/notify.py +174 -0
- ayasee/planjson.py +159 -0
- ayasee/prcomment.py +84 -0
- ayasee/risk.py +315 -0
- ayasee/rules/cloudflare.yaml +94 -0
- ayasee/rules/databricks-azure.yaml +85 -0
- ayasee/sarif.py +222 -0
- ayasee-0.1.0rc2.dist-info/METADATA +41 -0
- ayasee-0.1.0rc2.dist-info/RECORD +33 -0
- ayasee-0.1.0rc2.dist-info/WHEEL +4 -0
- ayasee-0.1.0rc2.dist-info/entry_points.txt +2 -0
- ayasee-0.1.0rc2.dist-info/licenses/LICENSE +202 -0
- ayasee-0.1.0rc2.dist-info/licenses/NOTICE +15 -0
ayasee/__init__.py
ADDED
ayasee/approvals.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""``ayasee approvals list``: who approved a run's deployment to an environment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from ayasee import cli
|
|
9
|
+
from ayasee.github import GitHub, GitHubError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def approvers(approvals: list[dict], env: str) -> list[str]:
|
|
13
|
+
"""Sorted distinct logins with an ``approved`` entry whose environments include ``env``.
|
|
14
|
+
|
|
15
|
+
The API has no attempt field, so approvals from earlier attempts of the same run may be
|
|
16
|
+
included: the result is a superset. Rejected entries are never listed.
|
|
17
|
+
"""
|
|
18
|
+
logins: set[str] = set()
|
|
19
|
+
for a in approvals:
|
|
20
|
+
if a.get("state") != "approved":
|
|
21
|
+
continue
|
|
22
|
+
names = {e.get("name") for e in a.get("environments") or [] if isinstance(e, dict)}
|
|
23
|
+
login = (a.get("user") or {}).get("login")
|
|
24
|
+
if env in names and isinstance(login, str) and login:
|
|
25
|
+
logins.add(login)
|
|
26
|
+
return sorted(logins)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def rejected(approvals: list[dict], env: str) -> bool:
|
|
30
|
+
"""True when some review of this run rejected ``env``."""
|
|
31
|
+
for a in approvals:
|
|
32
|
+
names = {e.get("name") for e in a.get("environments") or [] if isinstance(e, dict)}
|
|
33
|
+
if a.get("state") == "rejected" and env in names:
|
|
34
|
+
return True
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def job_never_ran(jobs: list[dict], name: str) -> bool | None:
|
|
39
|
+
"""Did the job called ``name`` (or ``<caller> / name``) execute no step at all? None when the
|
|
40
|
+
job is not in the list. A job that a reviewer rejected has no steps, or only skipped ones."""
|
|
41
|
+
for j in jobs:
|
|
42
|
+
full = str(j.get("name", ""))
|
|
43
|
+
if full == name or full.endswith(f" / {name}"):
|
|
44
|
+
steps = j.get("steps") or []
|
|
45
|
+
return all(
|
|
46
|
+
s.get("conclusion") == "skipped" or s.get("status") == "skipped" for s in steps
|
|
47
|
+
)
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _rejected(args: argparse.Namespace) -> int:
|
|
52
|
+
gh: GitHub | None = None
|
|
53
|
+
try:
|
|
54
|
+
gh = GitHub.from_env()
|
|
55
|
+
found = rejected(gh.run_approvals(args.repo, args.run_id), args.env)
|
|
56
|
+
if found and args.job:
|
|
57
|
+
# The approvals API has no attempt field: a rejection from an earlier attempt would
|
|
58
|
+
# still be listed after a re-run whose apply then failed. Only a job of the current
|
|
59
|
+
# attempt that executed nothing was stopped at the gate.
|
|
60
|
+
never_ran = job_never_ran(
|
|
61
|
+
gh.list_run_jobs(args.repo, args.run_id, latest=True), args.job
|
|
62
|
+
)
|
|
63
|
+
if never_ran is None:
|
|
64
|
+
print(f"approvals: error: no job {args.job} in the latest attempt", file=sys.stderr)
|
|
65
|
+
return 2
|
|
66
|
+
found = never_ran
|
|
67
|
+
except GitHubError as exc:
|
|
68
|
+
print(f"approvals: error: {exc}", file=sys.stderr)
|
|
69
|
+
return 2 # an API failure is neither "rejected" nor "not rejected"
|
|
70
|
+
except Exception as exc: # unexpected data: never report "not rejected" (exit 1) by mistake
|
|
71
|
+
print(f"approvals: error: unexpected {type(exc).__name__}", file=sys.stderr)
|
|
72
|
+
return 2
|
|
73
|
+
finally:
|
|
74
|
+
if gh is not None:
|
|
75
|
+
gh.close()
|
|
76
|
+
return 0 if found else 1
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _run(args: argparse.Namespace) -> int:
|
|
80
|
+
gh: GitHub | None = None
|
|
81
|
+
try:
|
|
82
|
+
gh = GitHub.from_env()
|
|
83
|
+
logins = approvers(gh.run_approvals(args.repo, args.run_id), args.env)
|
|
84
|
+
except GitHubError as exc:
|
|
85
|
+
print(f"approvals: error: {exc}", file=sys.stderr)
|
|
86
|
+
return 1
|
|
87
|
+
finally:
|
|
88
|
+
if gh is not None:
|
|
89
|
+
gh.close()
|
|
90
|
+
if not logins:
|
|
91
|
+
print(f"approvals: no approval for {args.env} on run {args.run_id}", file=sys.stderr)
|
|
92
|
+
return 1
|
|
93
|
+
print(",".join(logins))
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _setup(parser: argparse.ArgumentParser) -> None:
|
|
98
|
+
sub = parser.add_subparsers(dest="approvals_command", metavar="<action>", required=True)
|
|
99
|
+
p = sub.add_parser("list", help="print the comma-joined logins that approved an environment")
|
|
100
|
+
p.add_argument("--repo", required=True, help="owner/name")
|
|
101
|
+
p.add_argument("--run-id", required=True, type=int)
|
|
102
|
+
p.add_argument("--env", required=True)
|
|
103
|
+
p.set_defaults(handler=_run)
|
|
104
|
+
p = sub.add_parser(
|
|
105
|
+
"rejected", help="exit 0 if a review rejected the environment on this run, 1 if not"
|
|
106
|
+
)
|
|
107
|
+
p.add_argument("--repo", required=True, help="owner/name")
|
|
108
|
+
p.add_argument("--run-id", required=True, type=int)
|
|
109
|
+
p.add_argument("--env", required=True)
|
|
110
|
+
p.add_argument(
|
|
111
|
+
"--job",
|
|
112
|
+
help="also require that this job of the latest attempt ran no step (stopped at the gate)",
|
|
113
|
+
)
|
|
114
|
+
p.set_defaults(handler=_rejected)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
cli.register("approvals", _setup)
|
ayasee/binding.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Plan binding manifest (D1): prod applies only the exact approved plan files.
|
|
2
|
+
|
|
3
|
+
``make`` records SHA-256 hashes of each unit's plan file and provider lock file
|
|
4
|
+
plus the commit SHA and engine version. ``verify`` re-checks them before apply.
|
|
5
|
+
``verify-replan`` is the plain-Terraform fallback (D1(b)): a fresh plan must
|
|
6
|
+
match on ``(address, actions)`` and on a hash of the non-sensitive ``after``
|
|
7
|
+
values. Known limit: a change that only touches sensitive or unknown paths is
|
|
8
|
+
not detected by the values hash.
|
|
9
|
+
|
|
10
|
+
Nothing here ever prints plan values or file contents.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import functools
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Literal
|
|
24
|
+
|
|
25
|
+
from ayasee import cli
|
|
26
|
+
from ayasee.planjson import Action, PlanFormatError, ResourceChange, load_plan
|
|
27
|
+
|
|
28
|
+
SCHEMA = "ayasee.manifest/v1"
|
|
29
|
+
|
|
30
|
+
Reason = Literal["sha", "engine", "plan_hash", "lock_hash", "unit_set", "values"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BindingError(Exception):
|
|
34
|
+
"""The approved plan binding does not match what is about to be applied."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, reason: Reason, unit: str | None = None) -> None:
|
|
37
|
+
self.reason = reason
|
|
38
|
+
self.unit = unit
|
|
39
|
+
super().__init__(f"binding: {reason}" + (f" unit={unit}" if unit else ""))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class UnitBinding:
|
|
44
|
+
plan_sha256: str
|
|
45
|
+
lock_sha256: str
|
|
46
|
+
values_sha256: str | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class Manifest:
|
|
51
|
+
sha: str
|
|
52
|
+
env: str
|
|
53
|
+
engine: str
|
|
54
|
+
engine_version: str
|
|
55
|
+
units: dict[str, UnitBinding]
|
|
56
|
+
|
|
57
|
+
def to_json(self) -> str:
|
|
58
|
+
doc = {
|
|
59
|
+
"schema": SCHEMA,
|
|
60
|
+
"sha": self.sha,
|
|
61
|
+
"env": self.env,
|
|
62
|
+
"engine": self.engine,
|
|
63
|
+
"engine_version": self.engine_version,
|
|
64
|
+
"units": {
|
|
65
|
+
name: {
|
|
66
|
+
"plan_sha256": u.plan_sha256,
|
|
67
|
+
"lock_sha256": u.lock_sha256,
|
|
68
|
+
"values_sha256": u.values_sha256,
|
|
69
|
+
}
|
|
70
|
+
for name, u in sorted(self.units.items())
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
return json.dumps(doc, indent=2) + "\n"
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def from_json(cls, text: str) -> Manifest:
|
|
77
|
+
try:
|
|
78
|
+
doc = json.loads(text)
|
|
79
|
+
except json.JSONDecodeError:
|
|
80
|
+
raise ValueError("manifest is not valid JSON") from None
|
|
81
|
+
if not isinstance(doc, dict):
|
|
82
|
+
raise ValueError("manifest must be a JSON object")
|
|
83
|
+
if doc.get("schema") != SCHEMA:
|
|
84
|
+
raise ValueError(f"unsupported manifest schema (need {SCHEMA!r})")
|
|
85
|
+
try:
|
|
86
|
+
units = {
|
|
87
|
+
name: UnitBinding(u["plan_sha256"], u["lock_sha256"], u.get("values_sha256"))
|
|
88
|
+
for name, u in doc["units"].items()
|
|
89
|
+
}
|
|
90
|
+
m = cls(doc["sha"], doc["env"], doc["engine"], doc["engine_version"], units)
|
|
91
|
+
except (KeyError, TypeError, AttributeError):
|
|
92
|
+
raise ValueError("malformed manifest") from None
|
|
93
|
+
strings = [m.sha, m.env, m.engine, m.engine_version]
|
|
94
|
+
for u in units.values():
|
|
95
|
+
strings += [u.plan_sha256, u.lock_sha256]
|
|
96
|
+
if u.values_sha256 is not None:
|
|
97
|
+
strings.append(u.values_sha256)
|
|
98
|
+
if not all(isinstance(s, str) for s in strings):
|
|
99
|
+
raise ValueError("malformed manifest")
|
|
100
|
+
return m
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _file_sha(path: Path) -> str:
|
|
104
|
+
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _canonical(obj: Any) -> str:
|
|
108
|
+
return json.dumps(obj, sort_keys=True, separators=(",", ":"))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
_Redacted = tuple[Any, Any] # (value with redacted nodes as None, redaction mask)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _redact(value: Any, sens: Any, unk: Any) -> _Redacted:
|
|
115
|
+
"""Replace every sensitive/unknown node by ``None`` in place, keeping the shape.
|
|
116
|
+
|
|
117
|
+
The mask is the union of both markers as booleans: ``True`` for a redacted
|
|
118
|
+
subtree, a dict/list for partial redaction, ``False`` when nothing is marked.
|
|
119
|
+
Structure is preserved so reorders, insertions and removals stay visible.
|
|
120
|
+
"""
|
|
121
|
+
if sens is True or unk is True:
|
|
122
|
+
return None, True
|
|
123
|
+
if isinstance(value, dict) and (isinstance(sens, dict) or isinstance(unk, dict)):
|
|
124
|
+
s_map = sens if isinstance(sens, dict) else {}
|
|
125
|
+
u_map = unk if isinstance(unk, dict) else {}
|
|
126
|
+
out: dict[str, Any] = {}
|
|
127
|
+
mask: dict[str, Any] = {}
|
|
128
|
+
for k in sorted(set(value) | set(s_map) | set(u_map)):
|
|
129
|
+
v, m = _redact(value.get(k), s_map.get(k), u_map.get(k))
|
|
130
|
+
if k in value:
|
|
131
|
+
out[k] = v
|
|
132
|
+
if m:
|
|
133
|
+
mask[k] = m
|
|
134
|
+
return out, mask or False
|
|
135
|
+
if isinstance(value, list) and (isinstance(sens, list) or isinstance(unk, list)):
|
|
136
|
+
s_list = sens if isinstance(sens, list) else []
|
|
137
|
+
u_list = unk if isinstance(unk, list) else []
|
|
138
|
+
n = max(len(value), len(s_list), len(u_list))
|
|
139
|
+
pairs = [
|
|
140
|
+
_redact(
|
|
141
|
+
value[i] if i < len(value) else None,
|
|
142
|
+
s_list[i] if i < len(s_list) else None,
|
|
143
|
+
u_list[i] if i < len(u_list) else None,
|
|
144
|
+
)
|
|
145
|
+
for i in range(n)
|
|
146
|
+
]
|
|
147
|
+
masks = [m for _, m in pairs]
|
|
148
|
+
return [v for v, _ in pairs[: len(value)]], (masks if any(masks) else False)
|
|
149
|
+
return value, False
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def sanitized_after(rc: ResourceChange) -> _Redacted:
|
|
153
|
+
"""``(after with redacted nodes nulled, redaction mask)`` for one change."""
|
|
154
|
+
return _redact(rc.after, rc.after_sensitive, rc.after_unknown)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _values_sha(changes: list[ResourceChange]) -> str:
|
|
158
|
+
rows = []
|
|
159
|
+
for rc in changes:
|
|
160
|
+
if rc.mode == "managed" and not set(rc.actions) <= {Action.NOOP, Action.READ}:
|
|
161
|
+
after, mask = sanitized_after(rc)
|
|
162
|
+
rows.append([rc.address, [a.value for a in rc.actions], after, mask])
|
|
163
|
+
rows.sort(key=_canonical)
|
|
164
|
+
return hashlib.sha256(_canonical(rows).encode()).hexdigest()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def make_manifest(
|
|
168
|
+
sha: str,
|
|
169
|
+
env: str,
|
|
170
|
+
engine: str,
|
|
171
|
+
engine_version: str,
|
|
172
|
+
plan_files: dict[str, Path],
|
|
173
|
+
lock_files: dict[str, Path],
|
|
174
|
+
plan_json: dict[str, Path] | None = None,
|
|
175
|
+
) -> Manifest:
|
|
176
|
+
if not plan_files:
|
|
177
|
+
raise ValueError("no units to bind")
|
|
178
|
+
if set(plan_files) != set(lock_files):
|
|
179
|
+
raise ValueError("plan and lock files cover different units")
|
|
180
|
+
if plan_json is not None and set(plan_json) != set(plan_files):
|
|
181
|
+
raise ValueError("plan JSON files cover different units than the plans")
|
|
182
|
+
units = {
|
|
183
|
+
name: UnitBinding(
|
|
184
|
+
_file_sha(plan_files[name]),
|
|
185
|
+
_file_sha(lock_files[name]),
|
|
186
|
+
_values_sha(load_plan(plan_json[name])) if plan_json is not None else None,
|
|
187
|
+
)
|
|
188
|
+
for name in sorted(plan_files)
|
|
189
|
+
}
|
|
190
|
+
return Manifest(sha, env, engine, engine_version, units)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def verify(
|
|
194
|
+
manifest: Manifest,
|
|
195
|
+
*,
|
|
196
|
+
sha: str,
|
|
197
|
+
engine_version: str,
|
|
198
|
+
plan_files: dict[str, Path],
|
|
199
|
+
lock_files: dict[str, Path],
|
|
200
|
+
) -> None:
|
|
201
|
+
if not manifest.units or not plan_files or not lock_files:
|
|
202
|
+
raise ValueError("no units to verify")
|
|
203
|
+
if manifest.sha != sha:
|
|
204
|
+
raise BindingError("sha")
|
|
205
|
+
if manifest.engine_version != engine_version:
|
|
206
|
+
raise BindingError("engine")
|
|
207
|
+
if set(manifest.units) != set(plan_files) or set(manifest.units) != set(lock_files):
|
|
208
|
+
raise BindingError("unit_set")
|
|
209
|
+
for name in sorted(manifest.units):
|
|
210
|
+
bound = manifest.units[name]
|
|
211
|
+
if _file_sha(plan_files[name]) != bound.plan_sha256:
|
|
212
|
+
raise BindingError("plan_hash", name)
|
|
213
|
+
if _file_sha(lock_files[name]) != bound.lock_sha256:
|
|
214
|
+
raise BindingError("lock_hash", name)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def verify_replan(
|
|
218
|
+
manifest: Manifest, replan_json: dict[str, Path], *, sha: str, engine_version: str
|
|
219
|
+
) -> None:
|
|
220
|
+
"""D1(b): the fresh plan of ``sha`` with ``engine_version`` must match the manifest.
|
|
221
|
+
|
|
222
|
+
Same order as :func:`verify`: sha, engine, unit set, then the values hash per unit."""
|
|
223
|
+
if not manifest.units or not replan_json:
|
|
224
|
+
raise ValueError("no units to verify")
|
|
225
|
+
if manifest.sha != sha:
|
|
226
|
+
raise BindingError("sha")
|
|
227
|
+
if manifest.engine_version != engine_version:
|
|
228
|
+
raise BindingError("engine")
|
|
229
|
+
if set(manifest.units) != set(replan_json):
|
|
230
|
+
raise BindingError("unit_set")
|
|
231
|
+
for name in sorted(manifest.units):
|
|
232
|
+
expected = manifest.units[name].values_sha256
|
|
233
|
+
if expected is None or _values_sha(load_plan(replan_json[name])) != expected:
|
|
234
|
+
raise BindingError("values", name)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# --- CLI -----------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class _InputError(Exception):
|
|
241
|
+
pass
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _dir_files(directory: str, suffix: str) -> dict[str, Path]:
|
|
245
|
+
d = Path(directory)
|
|
246
|
+
files = sorted(d.glob(f"*{suffix}")) if d.is_dir() else []
|
|
247
|
+
if not files:
|
|
248
|
+
raise _InputError(f"no *{suffix} files found in {d}")
|
|
249
|
+
return {f.name[: -len(suffix)]: f for f in files}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _load_manifest(path: str) -> Manifest:
|
|
253
|
+
return Manifest.from_json(Path(path).read_text())
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _make(args: argparse.Namespace) -> int:
|
|
257
|
+
plans = _dir_files(args.plans, ".tfplan")
|
|
258
|
+
locks = _dir_files(args.locks, ".lock.hcl")
|
|
259
|
+
pjson = _dir_files(args.plan_json, ".json") if args.plan_json else None
|
|
260
|
+
m = make_manifest(args.sha, args.env, args.engine, args.engine_version, plans, locks, pjson)
|
|
261
|
+
Path(args.out).write_text(m.to_json())
|
|
262
|
+
return 0
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _verify(args: argparse.Namespace) -> int:
|
|
266
|
+
verify(
|
|
267
|
+
_load_manifest(args.manifest),
|
|
268
|
+
sha=args.sha,
|
|
269
|
+
engine_version=args.engine_version,
|
|
270
|
+
plan_files=_dir_files(args.plans, ".tfplan"),
|
|
271
|
+
lock_files=_dir_files(args.locks, ".lock.hcl"),
|
|
272
|
+
)
|
|
273
|
+
return 0
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _verify_replan(args: argparse.Namespace) -> int:
|
|
277
|
+
verify_replan(
|
|
278
|
+
_load_manifest(args.manifest),
|
|
279
|
+
_dir_files(args.plan_json, ".json"),
|
|
280
|
+
sha=args.sha,
|
|
281
|
+
engine_version=args.engine_version,
|
|
282
|
+
)
|
|
283
|
+
return 0
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _guarded(fn: Callable[[argparse.Namespace], int]) -> Callable[[argparse.Namespace], int]:
|
|
287
|
+
@functools.wraps(fn)
|
|
288
|
+
def run(args: argparse.Namespace) -> int:
|
|
289
|
+
try:
|
|
290
|
+
return fn(args)
|
|
291
|
+
except BindingError as exc:
|
|
292
|
+
print(str(exc), file=sys.stderr)
|
|
293
|
+
return 3
|
|
294
|
+
except (_InputError, ValueError, PlanFormatError, OSError) as exc:
|
|
295
|
+
# Messages come from our own checks; OSError text names a path, not contents.
|
|
296
|
+
print(f"ayasee: binding failed: {exc}", file=sys.stderr)
|
|
297
|
+
return 1
|
|
298
|
+
except Exception as exc: # fail closed; never echo the message (may hold values)
|
|
299
|
+
print(f"ayasee: binding failed: unexpected {type(exc).__name__}", file=sys.stderr)
|
|
300
|
+
return 1
|
|
301
|
+
|
|
302
|
+
return run
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _setup(parser: argparse.ArgumentParser) -> None:
|
|
306
|
+
sub = parser.add_subparsers(dest="binding_command", required=True, metavar="<action>")
|
|
307
|
+
|
|
308
|
+
p = sub.add_parser("make", help="write a manifest for the approved plans")
|
|
309
|
+
p.add_argument("--sha", required=True)
|
|
310
|
+
p.add_argument("--env", required=True)
|
|
311
|
+
p.add_argument("--engine", required=True, choices=["opentofu", "terraform"])
|
|
312
|
+
p.add_argument("--engine-version", required=True)
|
|
313
|
+
p.add_argument("--plans", required=True, help="directory of <unit>.tfplan")
|
|
314
|
+
p.add_argument("--locks", required=True, help="directory of <unit>.lock.hcl")
|
|
315
|
+
p.add_argument("--plan-json", help="directory of <unit>.json (D1(b) values hash)")
|
|
316
|
+
p.add_argument("--out", required=True)
|
|
317
|
+
p.set_defaults(handler=_guarded(_make))
|
|
318
|
+
|
|
319
|
+
p = sub.add_parser("verify", help="check plans, locks, SHA and engine against a manifest")
|
|
320
|
+
p.add_argument("--manifest", required=True)
|
|
321
|
+
p.add_argument("--sha", required=True)
|
|
322
|
+
p.add_argument("--engine-version", required=True)
|
|
323
|
+
p.add_argument("--plans", required=True)
|
|
324
|
+
p.add_argument("--locks", required=True)
|
|
325
|
+
p.set_defaults(handler=_guarded(_verify))
|
|
326
|
+
|
|
327
|
+
p = sub.add_parser("verify-replan", help="check a fresh plan JSON against a manifest (D1(b))")
|
|
328
|
+
p.add_argument("--manifest", required=True)
|
|
329
|
+
p.add_argument("--sha", required=True)
|
|
330
|
+
p.add_argument("--engine-version", required=True)
|
|
331
|
+
p.add_argument("--plan-json", required=True)
|
|
332
|
+
p.set_defaults(handler=_guarded(_verify_replan))
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
cli.register("binding", _setup)
|
ayasee/cascade.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""``ayasee cascade check``: refuse a saved plan whose dependents were planned against stale
|
|
2
|
+
upstream outputs (R52).
|
|
3
|
+
|
|
4
|
+
A unit that reads another unit's outputs (a Terragrunt ``dependency`` block) is planned with the
|
|
5
|
+
outputs the upstream has *before* its own apply. If both units change, the saved plan of the
|
|
6
|
+
dependent is wrong the moment the upstream is applied. same-job and breakglass modes avoid this by
|
|
7
|
+
re-planning each unit right before its apply; encrypted_plan and replan cannot, so they refuse.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from ayasee import cli
|
|
18
|
+
|
|
19
|
+
_QUIET = {("no-op",), ("read",)}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def changed_units(plan_json_dir: Path) -> set[str]:
|
|
23
|
+
"""Units whose plan JSON has at least one resource or output change other than no-op or
|
|
24
|
+
read (R72): a changed output is what a dependent reads, so it changes the dependent's inputs.
|
|
25
|
+
"""
|
|
26
|
+
out: set[str] = set()
|
|
27
|
+
for path in sorted(plan_json_dir.glob("*.json")):
|
|
28
|
+
doc = json.loads(path.read_text())
|
|
29
|
+
changes = [rc.get("change", {}) for rc in doc.get("resource_changes") or []]
|
|
30
|
+
changes += list((doc.get("output_changes") or {}).values())
|
|
31
|
+
if any(tuple(c.get("actions") or ()) not in _QUIET for c in changes):
|
|
32
|
+
out.add(path.stem)
|
|
33
|
+
return out
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def cascades(edges: dict[str, list[str]], changed: set[str]) -> list[tuple[str, str]]:
|
|
37
|
+
"""(dependent, upstream) pairs: a changed unit and a changed unit it depends on, directly or
|
|
38
|
+
through other units. ``edges`` maps a unit to the units whose outputs it reads."""
|
|
39
|
+
found: list[tuple[str, str]] = []
|
|
40
|
+
for unit in sorted(changed):
|
|
41
|
+
seen: set[str] = set()
|
|
42
|
+
stack = list(edges.get(unit, []))
|
|
43
|
+
while stack:
|
|
44
|
+
up = stack.pop()
|
|
45
|
+
if up in seen:
|
|
46
|
+
continue
|
|
47
|
+
seen.add(up)
|
|
48
|
+
stack.extend(edges.get(up, []))
|
|
49
|
+
found.extend((unit, up) for up in sorted((seen & changed) - {unit}))
|
|
50
|
+
return found
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _run(args: argparse.Namespace) -> int:
|
|
54
|
+
try:
|
|
55
|
+
edges = json.loads(Path(args.edges).read_text())
|
|
56
|
+
changed = changed_units(Path(args.plan_json))
|
|
57
|
+
except (OSError, ValueError, AttributeError) as exc:
|
|
58
|
+
print(f"cascade: error: {type(exc).__name__}", file=sys.stderr)
|
|
59
|
+
return 1
|
|
60
|
+
if not isinstance(edges, dict):
|
|
61
|
+
print("cascade: error: edges must be a JSON object", file=sys.stderr)
|
|
62
|
+
return 1
|
|
63
|
+
pairs = cascades(edges, changed)
|
|
64
|
+
for dependent, upstream in pairs:
|
|
65
|
+
print(
|
|
66
|
+
f"cascading change: {dependent} depends on {upstream}, which also changes; "
|
|
67
|
+
f"land {upstream} first",
|
|
68
|
+
file=sys.stderr,
|
|
69
|
+
)
|
|
70
|
+
return 1 if pairs else 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _setup(parser: argparse.ArgumentParser) -> None:
|
|
74
|
+
sub = parser.add_subparsers(dest="cascade_command", metavar="<action>", required=True)
|
|
75
|
+
p = sub.add_parser("check", help="exit 1 when a changed unit depends on another changed unit")
|
|
76
|
+
p.add_argument("--edges", required=True, help='JSON file {"unit": ["upstream", ...]}')
|
|
77
|
+
p.add_argument("--plan-json", required=True, help="directory of <unit>.json plans")
|
|
78
|
+
p.set_defaults(handler=_run)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
cli.register("cascade", _setup)
|