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 ADDED
@@ -0,0 +1,3 @@
1
+ """ayasee: staged, gated IaC promotion on GitHub Actions."""
2
+
3
+ __version__ = "0.1.0-rc.2"
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)