munim 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.
- munim/__init__.py +0 -0
- munim/adapters/__init__.py +3 -0
- munim/adapters/cloudflare.py +173 -0
- munim/adapters/resend.py +165 -0
- munim/adapters/vercel.py +169 -0
- munim/agent/__init__.py +0 -0
- munim/agent/across.py +81 -0
- munim/agent/launch.py +260 -0
- munim/agent/mail.py +161 -0
- munim/agent/mailplan.py +214 -0
- munim/agent/model.py +43 -0
- munim/agent/spf.py +67 -0
- munim/agent/within.py +78 -0
- munim/assets.py +115 -0
- munim/checks/__init__.py +1 -0
- munim/checks/dns.py +525 -0
- munim/cli.py +758 -0
- munim/connect/__init__.py +2 -0
- munim/connect/callback.py +108 -0
- munim/connect/oauth.py +238 -0
- munim/connect/token.py +24 -0
- munim/connected.py +69 -0
- munim/container.py +150 -0
- munim/doctor.py +191 -0
- munim/env.py +23 -0
- munim/migrate.py +47 -0
- munim/registry.py +177 -0
- munim/remote/__init__.py +4 -0
- munim/remote/accounts.py +26 -0
- munim/remote/discover.py +183 -0
- munim/remote/identity.py +68 -0
- munim/remote/servers.py +212 -0
- munim/remote/session.py +301 -0
- munim/remote/storage.py +164 -0
- munim/remote/toolsets.py +104 -0
- munim/report.py +140 -0
- munim/room/__init__.py +0 -0
- munim/room/server.py +215 -0
- munim/room/static/index.html +333 -0
- munim/room/static/reduce.mjs +104 -0
- munim/runlog.py +131 -0
- munim/server.py +416 -0
- munim-0.1.0.dist-info/METADATA +433 -0
- munim-0.1.0.dist-info/RECORD +47 -0
- munim-0.1.0.dist-info/WHEEL +4 -0
- munim-0.1.0.dist-info/entry_points.txt +4 -0
- munim-0.1.0.dist-info/licenses/LICENSE +21 -0
munim/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Cloudflare DNS: read, and write.
|
|
2
|
+
|
|
3
|
+
Every mutation here is **read-before-write, upserted on (type, name)**. That is
|
|
4
|
+
not tidiness. Re-running a launch that failed halfway would otherwise add a
|
|
5
|
+
second SPF record beside the first, and two SPF records means receivers ignore
|
|
6
|
+
both - the exact fault this product exists to catch. A tool that causes the bug
|
|
7
|
+
it reports is worse than no tool.
|
|
8
|
+
|
|
9
|
+
Writes are also idempotent for a second reason: a launch polls DNS and can
|
|
10
|
+
outlive one tool call, so it must be safe to resume.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
from munim.container import Container
|
|
16
|
+
from munim.runlog import RunLog
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CloudflareError(RuntimeError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Record:
|
|
25
|
+
id: str
|
|
26
|
+
type: str
|
|
27
|
+
name: str
|
|
28
|
+
content: str
|
|
29
|
+
ttl: int = 1
|
|
30
|
+
proxied: bool = False
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def from_api(cls, payload: dict) -> "Record":
|
|
34
|
+
return cls(id=payload["id"], type=payload["type"], name=payload["name"],
|
|
35
|
+
content=payload["content"], ttl=payload.get("ttl", 1),
|
|
36
|
+
proxied=payload.get("proxied", False))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _ok(payload: dict) -> dict:
|
|
40
|
+
if not payload.get("success", False):
|
|
41
|
+
errors = "; ".join(e.get("message", str(e)) for e in payload.get("errors", []))
|
|
42
|
+
raise CloudflareError(errors or "Cloudflare rejected the request")
|
|
43
|
+
return payload
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Cloudflare:
|
|
47
|
+
"""One client's Cloudflare account, reached through their container.
|
|
48
|
+
|
|
49
|
+
The container vends an authenticated HTTP client, so no token ever becomes
|
|
50
|
+
a value in this file (D6).
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
name = "cloudflare"
|
|
54
|
+
|
|
55
|
+
def __init__(self, container: Container, log: RunLog | None = None) -> None:
|
|
56
|
+
self._container = container
|
|
57
|
+
self._log = log
|
|
58
|
+
|
|
59
|
+
def _note(self, kind: str, text: str, **detail) -> None:
|
|
60
|
+
if self._log:
|
|
61
|
+
self._log.append(client=self._container.client, stage="dns",
|
|
62
|
+
kind=kind, human_text=text, detail=detail)
|
|
63
|
+
|
|
64
|
+
async def zone_id(self, domain: str) -> str:
|
|
65
|
+
async with self._container.http("cloudflare") as http:
|
|
66
|
+
payload = _ok((await http.get("/zones", params={"name": domain})).json())
|
|
67
|
+
zones = payload.get("result") or []
|
|
68
|
+
if not zones:
|
|
69
|
+
raise CloudflareError(
|
|
70
|
+
f"{domain} is not a zone in this client's Cloudflare account. "
|
|
71
|
+
"Either the nameservers are not delegated here, or the wrong "
|
|
72
|
+
"client was named."
|
|
73
|
+
)
|
|
74
|
+
return zones[0]["id"]
|
|
75
|
+
|
|
76
|
+
async def records(self, zone: str, *, type: str = "", name: str = "") -> list[Record]:
|
|
77
|
+
params = {"per_page": 100}
|
|
78
|
+
if type:
|
|
79
|
+
params["type"] = type
|
|
80
|
+
if name:
|
|
81
|
+
params["name"] = name
|
|
82
|
+
async with self._container.http("cloudflare") as http:
|
|
83
|
+
payload = _ok((await http.get(f"/zones/{zone}/dns_records", params=params)).json())
|
|
84
|
+
return [Record.from_api(r) for r in payload.get("result", [])]
|
|
85
|
+
|
|
86
|
+
async def upsert(self, zone: str, *, type: str, name: str, content: str,
|
|
87
|
+
ttl: int = 1, proxied: bool = False) -> tuple[Record, str]:
|
|
88
|
+
"""Create or update. Returns the record and what happened.
|
|
89
|
+
|
|
90
|
+
Read-before-write on (type, name): identical content is left alone, a
|
|
91
|
+
differing single record is updated in place, and only a genuinely new
|
|
92
|
+
(type, name) is created. Nothing is ever blindly appended.
|
|
93
|
+
"""
|
|
94
|
+
existing = [r for r in await self.records(zone, type=type, name=name)]
|
|
95
|
+
|
|
96
|
+
for record in existing:
|
|
97
|
+
if record.content == content:
|
|
98
|
+
self._note("observation", f"{type} record for {name} is already correct",
|
|
99
|
+
check="dns_write", action="unchanged", record=name)
|
|
100
|
+
return record, "unchanged"
|
|
101
|
+
|
|
102
|
+
body = {"type": type, "name": name, "content": content,
|
|
103
|
+
"ttl": ttl, "proxied": proxied}
|
|
104
|
+
|
|
105
|
+
if len(existing) == 1:
|
|
106
|
+
async with self._container.http("cloudflare") as http:
|
|
107
|
+
payload = _ok((await http.put(
|
|
108
|
+
f"/zones/{zone}/dns_records/{existing[0].id}", json=body)).json())
|
|
109
|
+
self._note("mutation", f"Updated the {type} record for {name}",
|
|
110
|
+
check="dns_write", action="updated", record=name)
|
|
111
|
+
return Record.from_api(payload["result"]), "updated"
|
|
112
|
+
|
|
113
|
+
if len(existing) > 1:
|
|
114
|
+
# Appending here is what creates the duplicate-SPF fault. Refuse and
|
|
115
|
+
# let the caller decide, because merging is a judgement call.
|
|
116
|
+
raise CloudflareError(
|
|
117
|
+
f"{len(existing)} {type} records already exist for {name}. "
|
|
118
|
+
"Adding another would leave several in place; decide how to "
|
|
119
|
+
"combine them before writing."
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
async with self._container.http("cloudflare") as http:
|
|
123
|
+
payload = _ok((await http.post(f"/zones/{zone}/dns_records", json=body)).json())
|
|
124
|
+
self._note("mutation", f"Created the {type} record for {name}",
|
|
125
|
+
check="dns_write", action="created", record=name)
|
|
126
|
+
return Record.from_api(payload["result"]), "created"
|
|
127
|
+
|
|
128
|
+
async def merge_spf(self, zone: str, domain: str, merged: str) -> tuple[Record, str]:
|
|
129
|
+
"""Replace every SPF record with one. The only safe way to end up with
|
|
130
|
+
a single policy when a domain already carries more than one.
|
|
131
|
+
|
|
132
|
+
Order matters, though not where it first appears to. A failed *delete*
|
|
133
|
+
is equally bad either way: two policies remain and receivers ignore
|
|
134
|
+
both. The orders differ when the *write* fails. Write first and nothing
|
|
135
|
+
has been removed yet, so the domain keeps every policy it had and mail
|
|
136
|
+
stays broken. Delete first and the leftovers are already gone, so what
|
|
137
|
+
remains is one intact policy: not the merge that was wanted, but a
|
|
138
|
+
working one. That is the most a partial write can be.
|
|
139
|
+
|
|
140
|
+
The read-back at the end is separate, and covers the case neither order
|
|
141
|
+
helps with: an API that answers 200 without changing anything. Two
|
|
142
|
+
successful responses are not evidence that one policy is left, and one
|
|
143
|
+
policy is the only thing this function promises.
|
|
144
|
+
"""
|
|
145
|
+
spf = [r for r in await self.records(zone, type="TXT", name=domain)
|
|
146
|
+
if r.content.lower().startswith("v=spf1")]
|
|
147
|
+
if not spf:
|
|
148
|
+
return await self.upsert(zone, type="TXT", name=domain, content=merged)
|
|
149
|
+
|
|
150
|
+
survivor, leftovers = spf[0], spf[1:]
|
|
151
|
+
async with self._container.http("cloudflare") as http:
|
|
152
|
+
for extra in leftovers:
|
|
153
|
+
_ok((await http.delete(f"/zones/{zone}/dns_records/{extra.id}")).json())
|
|
154
|
+
payload = _ok((await http.put(
|
|
155
|
+
f"/zones/{zone}/dns_records/{survivor.id}",
|
|
156
|
+
json={"type": "TXT", "name": domain, "content": merged, "ttl": 1})).json())
|
|
157
|
+
|
|
158
|
+
# Read back rather than trust the writes. A merge that silently left two
|
|
159
|
+
# policies is indistinguishable, from the caller's side, from one that
|
|
160
|
+
# worked, and the whole point of this function is that there is one.
|
|
161
|
+
remaining = [r for r in await self.records(zone, type="TXT", name=domain)
|
|
162
|
+
if r.content.lower().startswith("v=spf1")]
|
|
163
|
+
if len(remaining) != 1:
|
|
164
|
+
raise CloudflareError(
|
|
165
|
+
f"{domain} has {len(remaining)} sender policies after the merge, "
|
|
166
|
+
f"not one. Nothing further was written. The records now are: "
|
|
167
|
+
+ "; ".join(r.content for r in remaining)
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
self._note("mutation",
|
|
171
|
+
f"Merged {len(spf)} sender policies into one",
|
|
172
|
+
check="spf_single", action="merged", removed=len(leftovers))
|
|
173
|
+
return Record.from_api(payload["result"]), "merged"
|
munim/adapters/resend.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Resend: create a sending domain and hand back the records it needs.
|
|
2
|
+
|
|
3
|
+
This is one end of the handoff the whole project is about. Resend emits DKIM,
|
|
4
|
+
SPF and return-path records; they have to be written into Cloudflare, which is a
|
|
5
|
+
different company and a different login. Get the A record wrong and the site does
|
|
6
|
+
not load. Get these wrong and nothing breaks - the client's mail simply stops
|
|
7
|
+
arriving, and nobody notices for weeks.
|
|
8
|
+
|
|
9
|
+
Resend publishes no OAuth authorization endpoint, so it authenticates with an API
|
|
10
|
+
key. That is Resend offering nothing else, not a preference (see connect/oauth.py,
|
|
11
|
+
where it is deliberately absent from the provider table).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
from munim.container import Container
|
|
17
|
+
from munim.runlog import RunLog
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ResendError(RuntimeError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class DnsRecord:
|
|
26
|
+
"""A record Resend needs published, in the shape Cloudflare wants it.
|
|
27
|
+
|
|
28
|
+
Translating between the two vocabularies here is the point: Resend says
|
|
29
|
+
`record: "SPF", type: "TXT", name: "send"`, Cloudflare wants a type, a fully
|
|
30
|
+
qualified name and a content string. Doing it by hand is the copy-paste that
|
|
31
|
+
goes wrong.
|
|
32
|
+
"""
|
|
33
|
+
purpose: str # SPF, DKIM, or the tracking/return-path record
|
|
34
|
+
type: str # TXT, CNAME, MX
|
|
35
|
+
name: str # as Resend gives it, often relative
|
|
36
|
+
value: str
|
|
37
|
+
priority: int | None = None
|
|
38
|
+
status: str = "not_started"
|
|
39
|
+
|
|
40
|
+
def fqdn(self, domain: str) -> str:
|
|
41
|
+
"""Resend returns names relative to the domain; Cloudflare wants them
|
|
42
|
+
absolute. Getting this wrong publishes a record nobody can find."""
|
|
43
|
+
if not self.name or self.name in ("@", domain):
|
|
44
|
+
return domain
|
|
45
|
+
if self.name.endswith(domain):
|
|
46
|
+
return self.name
|
|
47
|
+
return f"{self.name}.{domain}"
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_api(cls, payload: dict) -> "DnsRecord":
|
|
51
|
+
return cls(
|
|
52
|
+
purpose=payload.get("record", "").upper(),
|
|
53
|
+
type=payload.get("type", "TXT").upper(),
|
|
54
|
+
name=payload.get("name", ""),
|
|
55
|
+
value=payload.get("value", ""),
|
|
56
|
+
priority=payload.get("priority"),
|
|
57
|
+
status=payload.get("status", "not_started"),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class Domain:
|
|
63
|
+
id: str
|
|
64
|
+
name: str
|
|
65
|
+
status: str
|
|
66
|
+
records: list[DnsRecord]
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def verified(self) -> bool:
|
|
70
|
+
return self.status == "verified"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Resend:
|
|
74
|
+
name = "resend"
|
|
75
|
+
|
|
76
|
+
def __init__(self, container: Container, log: RunLog | None = None) -> None:
|
|
77
|
+
self._container = container
|
|
78
|
+
self._log = log
|
|
79
|
+
|
|
80
|
+
def _note(self, kind: str, text: str, **detail) -> None:
|
|
81
|
+
if self._log:
|
|
82
|
+
self._log.append(client=self._container.client, stage="mail",
|
|
83
|
+
kind=kind, human_text=text, detail=detail)
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def _ok(response) -> dict:
|
|
87
|
+
if response.status_code >= 400:
|
|
88
|
+
body = {}
|
|
89
|
+
try:
|
|
90
|
+
body = response.json()
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
93
|
+
raise ResendError(body.get("message") or response.text[:160]
|
|
94
|
+
or f"Resend returned {response.status_code}")
|
|
95
|
+
return response.json()
|
|
96
|
+
|
|
97
|
+
async def domains(self) -> list[Domain]:
|
|
98
|
+
async with self._container.http("resend") as http:
|
|
99
|
+
payload = self._ok(await http.get("/domains"))
|
|
100
|
+
return [Domain(id=d["id"], name=d["name"], status=d.get("status", ""),
|
|
101
|
+
records=[DnsRecord.from_api(r) for r in d.get("records", [])])
|
|
102
|
+
for d in payload.get("data", [])]
|
|
103
|
+
|
|
104
|
+
async def find(self, domain: str) -> Domain | None:
|
|
105
|
+
for existing in await self.domains():
|
|
106
|
+
if existing.name.lower() == domain.lower():
|
|
107
|
+
return existing
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
async def ensure_domain(self, domain: str, region: str = "us-east-1") -> tuple[Domain, str]:
|
|
111
|
+
"""Create the sending domain, or return the one already there.
|
|
112
|
+
|
|
113
|
+
Idempotent for the same reason every mutation here is: a launch that
|
|
114
|
+
failed halfway and is re-run must not create a second sending domain and
|
|
115
|
+
a second set of DKIM keys, which would leave the client with records that
|
|
116
|
+
do not match the keys their mail is signed with.
|
|
117
|
+
"""
|
|
118
|
+
existing = await self.find(domain)
|
|
119
|
+
if existing is not None:
|
|
120
|
+
self._note("observation", f"{domain} is already set up for sending",
|
|
121
|
+
check="resend_domain", action="unchanged")
|
|
122
|
+
return existing, "unchanged"
|
|
123
|
+
|
|
124
|
+
async with self._container.http("resend") as http:
|
|
125
|
+
payload = self._ok(await http.post(
|
|
126
|
+
"/domains", json={"name": domain, "region": region}))
|
|
127
|
+
|
|
128
|
+
created = Domain(
|
|
129
|
+
id=payload["id"], name=payload["name"],
|
|
130
|
+
status=payload.get("status", "not_started"),
|
|
131
|
+
records=[DnsRecord.from_api(r) for r in payload.get("records", [])],
|
|
132
|
+
)
|
|
133
|
+
self._note("mutation",
|
|
134
|
+
f"Created the sending domain and got {len(created.records)} "
|
|
135
|
+
"records to publish",
|
|
136
|
+
check="resend_domain", action="created",
|
|
137
|
+
records=[r.purpose for r in created.records])
|
|
138
|
+
return created, "created"
|
|
139
|
+
|
|
140
|
+
async def verify(self, domain_id: str) -> str:
|
|
141
|
+
async with self._container.http("resend") as http:
|
|
142
|
+
payload = self._ok(await http.post(f"/domains/{domain_id}/verify"))
|
|
143
|
+
return payload.get("status", "unknown")
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def cloudflare_records(domain: Domain) -> list[dict]:
|
|
147
|
+
"""Translate Resend's records into what Cloudflare's API wants.
|
|
148
|
+
|
|
149
|
+
This function is the handoff. Everything it does by rule is what an
|
|
150
|
+
operator otherwise does by eye, between two browser tabs, once per
|
|
151
|
+
client - and it is where the mistake that breaks nothing visible gets
|
|
152
|
+
made.
|
|
153
|
+
"""
|
|
154
|
+
out = []
|
|
155
|
+
for record in domain.records:
|
|
156
|
+
entry = {
|
|
157
|
+
"type": record.type,
|
|
158
|
+
"name": record.fqdn(domain.name),
|
|
159
|
+
"content": record.value,
|
|
160
|
+
"purpose": record.purpose,
|
|
161
|
+
}
|
|
162
|
+
if record.priority is not None:
|
|
163
|
+
entry["priority"] = record.priority
|
|
164
|
+
out.append(entry)
|
|
165
|
+
return out
|
munim/adapters/vercel.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Vercel: what a project is doing, and what it is configured with.
|
|
2
|
+
|
|
3
|
+
Read-first on purpose. The two failures worth catching here are quiet ones:
|
|
4
|
+
|
|
5
|
+
- An environment variable set but never applied, because Vercel bakes
|
|
6
|
+
build-time values in and setting one changes nothing until a redeploy. The
|
|
7
|
+
dashboard shows the new value; the running site uses the old one.
|
|
8
|
+
- A variable set on Preview instead of Production. It works when you test it
|
|
9
|
+
and is missing when a customer arrives.
|
|
10
|
+
|
|
11
|
+
Both look correct in the dashboard, which is why nobody finds them by looking.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
|
|
17
|
+
from munim.checks.dns import CheckResult
|
|
18
|
+
from munim.container import Container
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class VercelError(RuntimeError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Deployment:
|
|
27
|
+
uid: str
|
|
28
|
+
state: str
|
|
29
|
+
url: str
|
|
30
|
+
created_at: datetime
|
|
31
|
+
target: str | None = None
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_api(cls, payload: dict) -> "Deployment":
|
|
35
|
+
return cls(
|
|
36
|
+
uid=payload.get("uid") or payload.get("id", ""),
|
|
37
|
+
state=payload.get("readyState") or payload.get("state", "UNKNOWN"),
|
|
38
|
+
url=payload.get("url", ""),
|
|
39
|
+
created_at=datetime.fromtimestamp(
|
|
40
|
+
payload.get("createdAt", 0) / 1000, tz=timezone.utc),
|
|
41
|
+
target=payload.get("target"),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class EnvVar:
|
|
47
|
+
key: str
|
|
48
|
+
targets: list[str]
|
|
49
|
+
created_at: datetime
|
|
50
|
+
# The value is deliberately absent. Reading configuration must not mean
|
|
51
|
+
# pulling every client's secrets into a coding agent's context (D6).
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Vercel:
|
|
55
|
+
"""One client's Vercel account, reached through their container."""
|
|
56
|
+
|
|
57
|
+
name = "vercel"
|
|
58
|
+
|
|
59
|
+
def __init__(self, container: Container, team_id: str = "") -> None:
|
|
60
|
+
self._container = container
|
|
61
|
+
self._team = team_id
|
|
62
|
+
|
|
63
|
+
def _params(self, **extra) -> dict:
|
|
64
|
+
params = {k: v for k, v in extra.items() if v not in (None, "")}
|
|
65
|
+
if self._team:
|
|
66
|
+
params["teamId"] = self._team
|
|
67
|
+
return params
|
|
68
|
+
|
|
69
|
+
async def _get(self, path: str, **params):
|
|
70
|
+
async with self._container.http("vercel") as http:
|
|
71
|
+
response = await http.get(path, params=self._params(**params))
|
|
72
|
+
if response.status_code >= 400:
|
|
73
|
+
raise VercelError(
|
|
74
|
+
f"Vercel returned {response.status_code} for {path}: "
|
|
75
|
+
f"{response.json().get('error', {}).get('message', response.text[:120])}"
|
|
76
|
+
)
|
|
77
|
+
return response.json()
|
|
78
|
+
|
|
79
|
+
async def projects(self) -> list[dict]:
|
|
80
|
+
payload = await self._get("/v9/projects", limit=100)
|
|
81
|
+
return [{"id": p["id"], "name": p["name"],
|
|
82
|
+
"framework": p.get("framework"),
|
|
83
|
+
"updated": p.get("updatedAt")}
|
|
84
|
+
for p in payload.get("projects", [])]
|
|
85
|
+
|
|
86
|
+
async def deployments(self, project: str, limit: int = 10) -> list[Deployment]:
|
|
87
|
+
payload = await self._get("/v6/deployments", projectId=project, limit=limit)
|
|
88
|
+
return [Deployment.from_api(d) for d in payload.get("deployments", [])]
|
|
89
|
+
|
|
90
|
+
async def env_vars(self, project: str) -> list[EnvVar]:
|
|
91
|
+
"""Names and scopes only; never values."""
|
|
92
|
+
payload = await self._get(f"/v9/projects/{project}/env")
|
|
93
|
+
return [
|
|
94
|
+
EnvVar(key=e["key"], targets=e.get("target", []),
|
|
95
|
+
created_at=datetime.fromtimestamp(
|
|
96
|
+
e.get("createdAt", 0) / 1000, tz=timezone.utc))
|
|
97
|
+
for e in payload.get("envs", [])
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
async def check_deploy_current(self, project: str) -> CheckResult:
|
|
101
|
+
"""Is the site people see the site you last built?"""
|
|
102
|
+
deployments = await self.deployments(project, limit=10)
|
|
103
|
+
production = [d for d in deployments if d.target == "production"]
|
|
104
|
+
if not production:
|
|
105
|
+
return CheckResult("deploy_current", "skip",
|
|
106
|
+
"No production deployment yet.", "")
|
|
107
|
+
latest = production[0]
|
|
108
|
+
if latest.state == "READY":
|
|
109
|
+
return CheckResult("deploy_current", "pass",
|
|
110
|
+
f"Production is {latest.state}, deployed "
|
|
111
|
+
f"{latest.created_at:%d %b %Y}.",
|
|
112
|
+
"The site your customers see is the latest version.",
|
|
113
|
+
evidence=latest.url)
|
|
114
|
+
failed = [d for d in production if d.state in ("ERROR", "CANCELED")]
|
|
115
|
+
return CheckResult(
|
|
116
|
+
"deploy_current", "fail",
|
|
117
|
+
f"Latest production deployment is {latest.state}; "
|
|
118
|
+
f"{len(failed)} of the last {len(production)} failed.",
|
|
119
|
+
"Your site is still showing an older version, because recent updates "
|
|
120
|
+
"did not go live.",
|
|
121
|
+
evidence=f"{latest.uid} {latest.state} {latest.created_at:%d %b %Y}",
|
|
122
|
+
detail={"state": latest.state, "failed": len(failed)})
|
|
123
|
+
|
|
124
|
+
async def check_env_applied(self, project: str) -> CheckResult:
|
|
125
|
+
"""A variable set after the last deploy has not taken effect.
|
|
126
|
+
|
|
127
|
+
Vercel bakes build-time values in, so changing one in the dashboard
|
|
128
|
+
changes nothing on the running site until a rebuild.
|
|
129
|
+
"""
|
|
130
|
+
env = await self.env_vars(project)
|
|
131
|
+
production = [e for e in env if "production" in e.targets]
|
|
132
|
+
if not production:
|
|
133
|
+
return CheckResult("env_applied", "skip", "No production variables.", "")
|
|
134
|
+
deployments = [d for d in await self.deployments(project, limit=10)
|
|
135
|
+
if d.target == "production" and d.state == "READY"]
|
|
136
|
+
if not deployments:
|
|
137
|
+
return CheckResult("env_applied", "skip", "No successful production build.", "")
|
|
138
|
+
|
|
139
|
+
last_build = deployments[0].created_at
|
|
140
|
+
stale = [e for e in production if e.created_at > last_build]
|
|
141
|
+
if not stale:
|
|
142
|
+
return CheckResult("env_applied", "pass",
|
|
143
|
+
"Every production variable predates the last build.",
|
|
144
|
+
"Your settings are live on the site.")
|
|
145
|
+
return CheckResult(
|
|
146
|
+
"env_applied", "fail",
|
|
147
|
+
f"{len(stale)} production variable(s) changed after the last build "
|
|
148
|
+
f"({', '.join(e.key for e in stale)}); Vercel bakes build-time values "
|
|
149
|
+
"in, so the running site still uses the old ones.",
|
|
150
|
+
"A setting was changed but never applied - your live site is still "
|
|
151
|
+
"using the previous value.",
|
|
152
|
+
detail={"stale": [e.key for e in stale]})
|
|
153
|
+
|
|
154
|
+
async def check_env_scoped(self, project: str) -> CheckResult:
|
|
155
|
+
"""A variable only on Preview works when you test and is missing live."""
|
|
156
|
+
env = await self.env_vars(project)
|
|
157
|
+
preview_only = [e for e in env
|
|
158
|
+
if "preview" in e.targets and "production" not in e.targets]
|
|
159
|
+
if not preview_only:
|
|
160
|
+
return CheckResult("env_scoped", "pass",
|
|
161
|
+
"No variables are preview-only.",
|
|
162
|
+
"Your settings apply to the live site, not just to tests.")
|
|
163
|
+
return CheckResult(
|
|
164
|
+
"env_scoped", "fail",
|
|
165
|
+
f"{len(preview_only)} variable(s) exist only on Preview: "
|
|
166
|
+
f"{', '.join(e.key for e in preview_only)}.",
|
|
167
|
+
"Something is configured for your test site but not the real one, so "
|
|
168
|
+
"it works when we check it and not when a customer arrives.",
|
|
169
|
+
detail={"preview_only": [e.key for e in preview_only]})
|
munim/agent/__init__.py
ADDED
|
File without changes
|
munim/agent/across.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""One question, answered across every client at once.
|
|
2
|
+
|
|
3
|
+
This is the capability D15 says nothing else does, and until now it was a
|
|
4
|
+
deterministic DNS sweep: useful, but it could only answer questions the check
|
|
5
|
+
catalogue already asked. With a session per client against the providers' own
|
|
6
|
+
MCP servers, the same shape answers questions nobody wrote a check for, because
|
|
7
|
+
the provider's own tools are there.
|
|
8
|
+
|
|
9
|
+
The safety property is structural, not instructed. Every toolset here is built
|
|
10
|
+
read-only, so a tool that changes anything is not present to be called. "Read
|
|
11
|
+
across, write within" (D5) stops depending on the model doing as it is told.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from strands import Agent
|
|
15
|
+
|
|
16
|
+
from munim.agent.model import build_model
|
|
17
|
+
from munim.remote.servers import SERVERS
|
|
18
|
+
from munim.remote.storage import KeychainTokenStorage
|
|
19
|
+
from munim.remote.toolsets import toolsets_for
|
|
20
|
+
|
|
21
|
+
SYSTEM = """You answer one question about several clients at once.
|
|
22
|
+
|
|
23
|
+
You have each client's own tools, named with that client's prefix. A tool named
|
|
24
|
+
acme_ltd_* acts on Acme Ltd's account and no other. Never assume two clients
|
|
25
|
+
share anything.
|
|
26
|
+
|
|
27
|
+
Every tool you have is read-only. If answering would require changing something,
|
|
28
|
+
say what would have to change and which client it belongs to. Do not claim you
|
|
29
|
+
changed it.
|
|
30
|
+
|
|
31
|
+
Name the client beside every fact. A finding without a client attached is
|
|
32
|
+
useless to someone who looks after a dozen of them.
|
|
33
|
+
|
|
34
|
+
Be brief. No preamble."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def connected_clients(clients, provider: str, backend=None) -> list:
|
|
38
|
+
"""Those with a session for this provider. Asking about the rest would open
|
|
39
|
+
a browser, which is not a thing a question gets to do.
|
|
40
|
+
|
|
41
|
+
Takes client records, not names: the session is filed under the identity,
|
|
42
|
+
and looking it up by label found nothing at all once the two were split.
|
|
43
|
+
"""
|
|
44
|
+
def has_session(client) -> bool:
|
|
45
|
+
key = getattr(client, "id", client)
|
|
46
|
+
store = (KeychainTokenStorage(key, provider, backend) if backend
|
|
47
|
+
else KeychainTokenStorage(key, provider))
|
|
48
|
+
return store._read("tokens") is not None
|
|
49
|
+
|
|
50
|
+
return [c for c in clients if has_session(c)]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def ask(question: str, clients: list[str], *, backend=None) -> str:
|
|
54
|
+
"""Answer `question` using every connected client's read-only tools."""
|
|
55
|
+
toolsets = []
|
|
56
|
+
reached: dict[str, list[str]] = {}
|
|
57
|
+
for provider in sorted(SERVERS):
|
|
58
|
+
present = connected_clients(clients, provider, backend)
|
|
59
|
+
if not present:
|
|
60
|
+
continue
|
|
61
|
+
reached[provider] = present
|
|
62
|
+
toolsets += toolsets_for(present, provider, backend=backend,
|
|
63
|
+
read_only=True)
|
|
64
|
+
|
|
65
|
+
if not toolsets:
|
|
66
|
+
return ("No client has a session with a provider yet, so there is "
|
|
67
|
+
"nothing to read across. Connect one with "
|
|
68
|
+
"`munim connect \"<client>\" cloudflare`.")
|
|
69
|
+
|
|
70
|
+
model, _ = build_model()
|
|
71
|
+
agent = Agent(model=model, tools=toolsets, system_prompt=SYSTEM,
|
|
72
|
+
callback_handler=None)
|
|
73
|
+
|
|
74
|
+
roster = "\n".join(
|
|
75
|
+
f"- {p}: {', '.join(getattr(c, 'name', str(c)) for c in cs)}"
|
|
76
|
+
for p, cs in sorted(reached.items()))
|
|
77
|
+
reply = await agent.invoke_async(
|
|
78
|
+
f"Clients and the providers each is connected to:\n{roster}\n\n"
|
|
79
|
+
f"Question: {question}"
|
|
80
|
+
)
|
|
81
|
+
return str(reply)
|