Setting the file. One moment.
Di Snapshots · AWS Observability · aws/agent-toolkit-for-aws · Skills Docs
ContentsBack to the top of the page 69
Creating Amazon Aurora Db Cluster With Instances
92
Routing Traffic With Route53 And CloudFront
Resilience Program Design
Debugging Lambda Timeouts
Script
scripts/cloudwatch/ di_snapshots.py
Python · 303 lines · 13 KB
15 out of this entry script is what breaks the old `di_snapshot_tools -> di_snapshot_queries
16 -> di_snapshots` import cycle.
17
18 SENSITIVE DATA:
19 Snapshots can capture PII/secrets from live request args. The operations return JSON text;
20 they do NOT write files. When `--out FILE` is used for a large result, the file is written
21 with owner-only (0600) permissions. The skill body (SKILL.md) instructs the agent to parse
22 saved output with jq/python and not to retain it. Real captured snapshots are never committed
23 as test fixtures.
24
25 USAGE
26 python3 scripts/cloudwatch/di_snapshots.py --print-contract
27 python3 scripts/cloudwatch/di_snapshots.py sample --json-file args.json
28 python3 scripts/cloudwatch/di_snapshots.py sample --json - # read the JSON object from stdin
29 python3 scripts/cloudwatch/di_snapshots.py search --json-file args.json --out /tmp/snaps.json
30 """
31
32 from __future__ import annotations
33
34 import argparse
35 import json
36 import os
37 import stat
38 import sys
39 from pathlib import Path
40 from typing import Any, Dict
41
42 _HERE = Path( __file__ ).resolve().parent
43 if str ( _HERE ) not in sys.path:
44 sys.path.insert( 0 , str ( _HERE ))
45
46
47 # ── the 2-op contract: op name -> (vendored module, function) ───────────────────────────
48 _OPS = {
49 "sample" : ( "di_snapshot_tools" , "get_sample_snapshot_for_breakpoint" ),
50 "search" : ( "di_snapshot_tools" , "search_snapshots_for_status_event" ),
51 }
52
53
54 def _dispatch_table () -> Dict[ str , Any]:
55 """Build the op -> function dispatch table by binding each function reference directly.
56
57 NO dynamic dispatch: every function is named as a literal attribute on the freshly
58 imported module (``di_snapshot_tools.get_sample_snapshot_for_breakpoint``), never resolved
59 from a string via ``getattr``/``__import__``. The import stays inside the function because
60 ``di_snapshot_tools`` (via ``di_snapshot_queries``) imports ``botocore`` at module top;
61 keeping it lazy here lets a bare ``import di_snapshots`` stay free of a hard boto3
62 dependency (the build env omits boto3 and must still import this module). Note this does
63 NOT make ``--print-contract`` boto3-free: calling ``_resolve_tool`` runs this function and
64 triggers the lazy ``botocore`` import. (The old import cycle that also required this is
65 gone — the logs-client seam moved to ``di_logs_client``.)
66
67 ``_resolve_tool`` and the ``test_dispatch_table_keys_match_ops`` sync guard both key off
68 this table, so an op added to ``_OPS`` without a matching binding here fails loudly rather
69 than silently dropping from the contract.
70 """
71 import di_snapshot_tools
72
73 return {
74 "sample" : di_snapshot_tools.get_sample_snapshot_for_breakpoint,
75 "search" : di_snapshot_tools.search_snapshots_for_status_event,
76 }
77
78
79 def _resolve_tool (op: str ):
80 """Return the snapshot tool function for ``op`` from the explicit dispatch table.
81
82 Raises ``KeyError(op)`` for an unknown op (the table is the source of truth for which
83 ops are callable; it is kept in sync with ``_OPS`` by the dispatch sync-guard test).
84 """
85 return _dispatch_table()[op]
86
87
88 # Semantic hints layered onto the inspected signature in the emitted contract. Notably the
89 # service key matches di_instrumentation.py (both use `service`), so an args object can be
90 # carried between the two scripts without a key rename.
91 _ARG_HINTS = {
92 "service" : {
93 "note" : "service identifier; di_instrumentation.py uses the same key `service`" ,
94 },
95 "custom_filters" : {
96 "type" : "array of strings" ,
97 "note" : (
98 "JSON array of raw Logs Insights filter fragments, appended with `and`, "
99 'e.g. ["@message like /ORD-123/"]. A single bare string is also accepted '
100 "and treated as a one-element list."
101 ),
102 },
103 "start_time" : {
104 "note" : (
105 "optional ISO 8601 lower bound; pass with end_time to override the "
106 "status_timestamp-anchored window and sweep a wider span (both or neither)"
107 ),
108 },
109 "end_time" : {
110 "note" : "optional ISO 8601 upper bound; see start_time (both or neither)" ,
111 },
112 }
113
114
115 # The snapshot tools signal failure two ways, neither of which is an "ERROR:"-PREFIXED string
116 # for the dominant (AWS-side) case:
117 # 1. Deterministic INPUT failures (bad location_hash / timestamp / limit / unbalanced
118 # custom_filters) return a bare "ERROR: ..." string.
119 # 2. AWS-QUERY failures (log group missing, throttle, polling timeout, Failed/Cancelled)
120 # return a JSON string whose inner `status` field carries the failure — the string starts
121 # with "{", so a prefix check never catches it. _execute_cloudwatch_query emits status in
122 # {Error, Polling Timeout, Failed, Cancelled}; the renderers map those to an inner
123 # "status" of "ERROR"/"TIMEOUT" (or pass the raw status through). Only Complete/SUCCESS and
124 # an empty "no snapshots found" result are genuine successes.
125 # A CLI/CI caller must get a nonzero exit on either failure, so classify structurally.
126 _QUERY_FAILURE_STATUSES = {
127 "ERROR" ,
128 "TIMEOUT" ,
129 "POLLING TIMEOUT" ,
130 "FAILED" ,
131 "CANCELLED" ,
132 }
133
134
135 def _is_failure (result: object ) -> bool :
136 if not isinstance (result, str ):
137 return False
138 if result.lstrip().startswith( "ERROR" ):
139 return True # deterministic input failure
140 # AWS-query failure: inner status field in the returned JSON.
141 try :
142 data = json.loads(result)
143 except (json.JSONDecodeError, ValueError ):
144 return False
145 if isinstance (data, dict ):
146 status = str (data.get( "status" , "" )).strip().upper()
147 return status in _QUERY_FAILURE_STATUSES
148 return False
149
150
151 def _write_out (path: str , text: str ) -> None :
152 """Write result text to a file with owner-only (0600) permissions.
153
154 SECURITY: snapshots may contain PII/secrets; prefer an --out path on an encrypted volume
155 (see snapshot-parsing.md). The on-disk copy must be owner-only and must not be
156 redirected/exposed through a pre-planted path:
157 - O_NOFOLLOW: refuse to follow a symlink at `path` (an attacker-planted symlink in a
158 shared dir would otherwise leak the snapshot into / clobber the link target).
159 - O_EXCL semantics are too strict for a re-runnable CLI (would fail on a stale file), so
160 we instead fchmod the fd to 0600 explicitly AFTER open — this restricts both freshly
161 created files (regardless of umask) AND a pre-existing file whose mode was looser
162 (O_CREAT's mode arg is ignored when the file already exists).
163 """
164 # getattr here is a LITERAL capability probe (hardcoded name + 0 default), NOT dynamic
165 # dispatch: O_NOFOLLOW is absent on some platforms, so we read the constant if present and
166 # fall back to 0 (no-op flag) otherwise. No string-driven attribute/function dispatch.
167 flags = os. O_WRONLY | os. O_CREAT | os. O_TRUNC | getattr (os, "O_NOFOLLOW" , 0 )
168 fd = os.open(path, flags, stat. S_IRUSR | stat. S_IWUSR )
169 try :
170 os.fchmod(fd, stat. S_IRUSR | stat. S_IWUSR ) # 0600 even if the file pre-existed at 0644
171 os.write(fd, text.encode( "utf-8" ))
172 finally :
173 os.close(fd)
174
175
176 def _print_contract () -> int :
177 import inspect
178
179 contract: Dict[ str , Any] = {
180 "surface" : "public CloudWatch Logs Insights (/aws/service-events/ {service} )" ,
181 "encoding" : "python3 scripts/cloudwatch/di_snapshots.py <op> --json '{<args>}' [--out FILE]" ,
182 "region" : (
183 "pass --region, or set AWS_REGION/AWS_DEFAULT_REGION (default us-east-1); "
184 "use the same region the breakpoint was created in"
185 ),
186 "ops" : {},
187 }
188 for op in _OPS :
189 fn = _resolve_tool(op)
190 sig = inspect.signature(fn)
191 args: Dict[ str , Any] = {}
192 for name, p in sig.parameters.items():
193 required = p.default is inspect.Parameter.empty
194 args[name] = { "required" : required}
195 if not required and p.default is not None :
196 args[name][ "default" ] = p.default
197 if name in _ARG_HINTS :
198 args[name].update( _ARG_HINTS [name])
199 contract[ "ops" ][op] = { "args" : args}
200 print (json.dumps(contract, indent = 2 , default = str ))
201 return 0
202
203
204 def _read_payload (ap, json_text: str | None , json_file: str | None ) -> dict :
205 """Resolve the op's JSON-object argument from --json-file, --json - (stdin), or --json.
206
207 Preferring a file or stdin keeps caller/source-derived values off the shell command line.
208 `ap.error` exits 2 on any malformed input.
209 """
210 sources = [s for s in (json_text is not None , json_file is not None ) if s]
211 if len (sources) > 1 :
212 ap.error( "pass the arguments via exactly one of --json or --json-file" )
213 if json_file is not None :
214 try :
215 raw = sys.stdin.read() if json_file == "-" else Path(json_file).read_text( "utf-8" )
216 except OSError as exc:
217 ap.error( f "--json-file could not be read: { exc } " )
218 elif json_text is not None :
219 raw = sys.stdin.read() if json_text == "-" else json_text
220 else :
221 ap.error(
222 "the op's arguments are required (use --json-file PATH, --json -, or --json '{...}')"
223 )
224 try :
225 payload = json.loads(raw)
226 except json.JSONDecodeError as exc:
227 ap.error( f "arguments are not valid JSON: { exc } " )
228 if not isinstance (payload, dict ):
229 ap.error( "arguments must be a JSON object of the op's parameters" )
230 return payload
231
232
233 def main (argv: list[ str ] | None = None ) -> int :
234 ap = argparse.ArgumentParser(
235 prog = "di_snapshots.py" ,
236 description = "Host command for dynamic-instrumentation snapshot retrieval." ,
237 )
238 ap.add_argument( "op" , nargs = "?" , choices = sorted ( _OPS ), help = "snapshot operation" )
239 ap.add_argument(
240 "--json" ,
241 dest = "json_payload" ,
242 help = "JSON object of the op's arguments (use '-' for stdin; prefer --json-file)" ,
243 )
244 ap.add_argument(
245 "--json-file" ,
246 dest = "json_file" ,
247 help = "read the op's JSON arguments from PATH (or '-' for stdin) — keeps values off "
248 "the shell command line" ,
249 )
250 ap.add_argument(
251 "--out" ,
252 help = "write the result to FILE (0600 perms) instead of stdout — for large results "
253 "the agent will parse with jq/python (see SKILL.md). Snapshots may contain PII." ,
254 )
255 ap.add_argument(
256 "--region" ,
257 help = "AWS region to read snapshots from. Precedence: --region > AWS_REGION > "
258 "AWS_DEFAULT_REGION > us-east-1. Use the same region the breakpoint was created in. "
259 "AWS_PROFILE is used for credentials only; the profile's region is ignored." ,
260 )
261 ap.add_argument(
262 "--profile" ,
263 help = "AWS named profile for credentials (sets AWS_PROFILE for this call). If omitted, "
264 "the ambient default credential chain is used (env vars, shared profile, or IAM "
265 "role). Use the same account the breakpoint was created in. Prefer IAM roles or SSO "
266 "session credentials over long-lived access keys for these live-service operations." ,
267 )
268 ap.add_argument(
269 "--print-contract" ,
270 action = "store_true" ,
271 help = "print the canonical op + arg schema and exit" ,
272 )
273 args = ap.parse_args(argv)
274
275 if args.print_contract:
276 return _print_contract()
277 if not args.op:
278 ap.error( "an op is required (or use --print-contract)" )
279 # The --region flag is a thin front-end over the env-driven logs client: set AWS_REGION
280 # so _build_logs_client()'s build_client() picks it up.
281 if args.region:
282 os.environ[ "AWS_REGION" ] = args.region
283 if args.profile:
284 os.environ[ "AWS_PROFILE" ] = args.profile
285 payload = _read_payload(ap, args.json_payload, args.json_file)
286
287 fn = _resolve_tool(args.op)
288 try :
289 result = fn( ** payload)
290 except TypeError as exc:
291 print ( f "ERROR: invalid arguments for op ' { args.op } ': { exc } " , file = sys.stderr)
292 return 2
293
294 if args.out:
295 _write_out(args.out, result)
296 print ( f "wrote result to { args.out } (0600)" )
297 else :
298 print (result)
299 return 1 if _is_failure(result) else 0
300
301
302 if __name__ == "__main__" :
303 raise SystemExit (main())