Setting the file. One moment.
Blob Source · Foundry Iq · microsoft/azure-skills · Skills Docs
ContentsBack to the top of the page File Cu Canary
304
def load_receipts
— line 304
This file
Number 10.36
Position 36 of 77
Type Python
Size 75 KB
Lines 1,346 helpers/ blob_source.py
Python · 1,346 lines · 75 KB
12 from pathlib import Path
13 from typing import Any, Callable
14 from urllib.parse import urlencode
15
16 try :
17 from ._progress import Progress, add_progress_argument, reporting
18 from . import blob_inventory, search_reconcile, source_vector, cu_ingestion_auth
19 from ._common import (
20 SEARCH_AUDIENCE , HelperFailure, TokenProvider, Transport, azure_cli_token,
21 blocked_result, canonical_bytes, digest, emit_result, http_request, load_approved_input, HttpResult,
22 reject_secrets, require_allowed_fields,
23 )
24 except ImportError :
25 from _progress import Progress, add_progress_argument, reporting
26 import blob_inventory # type: ignore[no-redef]
27 import search_reconcile # type: ignore[no-redef]
28 import source_vector # type: ignore[no-redef]
29 import cu_ingestion_auth
30 from _common import ( # type: ignore[no-redef]
31 SEARCH_AUDIENCE , HelperFailure, TokenProvider, Transport, azure_cli_token,
32 blocked_result, canonical_bytes, digest, emit_result, http_request, load_approved_input, HttpResult,
33 reject_secrets, require_allowed_fields,
34 )
35
36
37 SNAPSHOT_WARNING = (
38 "Before/after inventories detect observed drift, not an atomic Storage snapshot "
39 "or a source lock. Scheduled sources can change after this run."
40 )
41 RETRY_STATUS = { 408 , 429 , 500 , 502 , 503 , 504 }
42
43
44 def validate_content_understanding (value: Any, * , enabled: bool ) -> dict[ str , Any] | None :
45 if not enabled:
46 if value is not None :
47 raise _failure( "cu-choice-conflict" , "Minimal extraction must omit Content Understanding choices." )
48 return None
49 if not isinstance (value, dict ):
50 raise _failure( "cu-prerequisite-missing" , "Standard extraction requires an existing CU-capable AIServices account." )
51 require_allowed_fields(value, { "endpoint" , "auth" , "prerequisites" }, label = "Content Understanding choices" )
52 endpoint = value.get( "endpoint" )
53 if (
54 not isinstance (endpoint, str )
55 or re.fullmatch( r "https:// [ a-z0-9 ][ a-z0-9- ] {0,62} \. services \. ai \. azure \. com/ ? " , endpoint) is None
56 or value.get( "auth" ) != "system-assigned"
57 ):
58 raise _failure( "cu-auth-unsupported" , "Select an exact AIServices services.ai.azure.com endpoint and existing Search system-assigned identity; no keys." )
59 prerequisites = value.get( "prerequisites" )
60 if not isinstance (prerequisites, dict ):
61 raise _failure( "cu-prerequisite-missing" , "Supply current CU resource, configuration, identity and network evidence references." )
62 fields = { "resource" , "configuration" , "identity" , "network" }
63 require_allowed_fields(prerequisites, fields, label = "Content Understanding prerequisites" )
64 if any ( not isinstance (prerequisites.get(key), str ) or not prerequisites[key].strip()
65 or len (prerequisites[key]) > 4096 for key in fields):
66 raise _failure( "cu-prerequisite-missing" , "Owner-verified CU capability/region, configuration, Search role and reachability evidence is required." )
67 return copy.deepcopy(value)
68
69
70 def verify_content_understanding_readback (choice: dict[ str , Any] | None , current: Any) -> None :
71 if choice is None or current is None :
72 return
73 parameters = current.get( "azureBlobParameters" ) if isinstance (current, dict ) else None
74 ingestion = parameters.get( "ingestionParameters" ) if isinstance (parameters, dict ) else None
75 ai = ingestion.get( "aiServices" ) if isinstance (ingestion, dict ) else None
76 if (
77 not isinstance (ai, dict ) or ingestion.get( "contentExtractionMode" ) != "standard"
78 or not isinstance (ai.get( "uri" ), str )
79 or ai[ "uri" ].rstrip( "/" ) != choice[ "endpoint" ].rstrip( "/" )
80 ):
81 raise _failure( "cu-readback-mismatch" , "Observed standard extraction and CU endpoint must match the selected configuration." )
82 key = ai.get( "apiKey" )
83 if (key is not None and not ( isinstance (key, str ) and key == "" )
84 or ingestion.get( "identity" ) is not None ):
85 raise _failure( "cu-auth-conflict" , "Observed CU authentication does not prove system-assigned mode; credential details withheld." )
86
87
88 def generated_resources (current: dict[ str , Any], * , strict: bool = False ) -> list[dict[ str , Any]]:
89 parameters = current.get( "azureBlobParameters" )
90 created = parameters.get( "createdResources" ) if isinstance (parameters, dict ) else None
91 generated = []
92 if isinstance (created, dict ):
93 for kind, name in created.items():
94 if (
95 kind in { "datasource" , "dataSourceConnection" , "indexer" , "skillset" , "index" }
96 and isinstance (name, str ) and re.fullmatch( r " [ a-zA-Z0-9 ][ a-zA-Z0-9_- ] {0,127} " , name)
97 ):
98 generated.append({
99 "type" : "datasource" if kind == "dataSourceConnection" else kind,
100 "name" : name, "service_managed" : True ,
101 })
102 generated.sort( key =lambda item: (item[ "type" ], item[ "name" ]))
103 if strict and (
104 not isinstance (created, dict ) or len (created) != 4 or len (generated) != 4
105 or {item[ "type" ] for item in generated} != { "datasource" , "indexer" , "skillset" , "index" }
106 ):
107 raise _failure( "generated-resources-unverified" , "Exact service-generated identities are required for reuse." )
108 return generated
109
110
111 def _read_intent (path: Path) -> dict[ str , Any]:
112 try :
113 document = json.loads(path.read_text( encoding = "utf-8" ))
114 except ( OSError , UnicodeError , json.JSONDecodeError) as exc:
115 raise _failure( "input-unreadable" , "Input must be readable UTF-8 JSON." ) from exc
116 if not isinstance (document, dict ):
117 raise _failure( "input-schema-invalid" , "Input must be an object." )
118 return document
119
120
121 def _receipt_paths (request: dict[ str , Any]) -> tuple[ str , str ] | None :
122 paths = [request.get(field) for field in ( "reuse_input_file" , "reuse_result_file" )]
123 if paths == [ None , None ]:
124 return None
125 if not all ( isinstance (path, str ) and path.strip() for path in paths):
126 raise _failure( "reuse-evidence-invalid" , "Select both retained approved input and creation result files." )
127 return paths[ 0 ], paths[ 1 ]
128
129
130 def _reuse_receipts (paths: tuple[ str , str ] | None ) -> tuple[dict[ str , Any], dict[ str , Any]] | None :
131 if paths is None :
132 return None
133 try :
134 _, prior, fingerprint = load_approved_input(Path(paths[ 0 ]))
135 except UnicodeError as exc:
136 raise _failure( "input-unreadable" , "Retained input must be readable UTF-8 JSON." ) from exc
137 _validate_plan(prior)
138 result = _read_intent(Path(paths[ 1 ]))
139 _validate_reuse_receipts(prior, fingerprint, result)
140 return prior, result
141
142
143 def _validate_reuse_receipts (prior, fingerprint, result):
144 reject_secrets(result)
145 if not all ( isinstance (result.get(field), dict ) for field in (
146 "approved_plan" , "source_evidence" , "resources" , "verification" , "source"
147 )):
148 raise _failure( "reuse-evidence-invalid" , "Retained creation result sections must be objects." )
149 if (
150 prior[ "source" ][ "action" ] != "create" or result.get( "status" ) != "completed"
151 or result[ "approved_plan" ].get( "confirmed" ) is not True
152 or result.get( "approved_plan" ) != { "confirmed" : True , "fingerprint" : fingerprint}
153 or result.get( "source_evidence" , {}).get( "boundary" ) != prior[ "boundary" ]
154 or result.get( "source_evidence" , {}).get( "inventory_digest" ) != prior[ "inventory_digest" ]
155 ):
156 raise _failure( "reuse-evidence-invalid" , "Retained records must prove the exact approved source creation." )
157
158
159 def _connection_binding (connection, boundary):
160 if connection is None or isinstance (connection, str ) and connection.lower() in ( "" , "<redacted>" ):
161 return "concealed"
162 if isinstance (connection, str ) and connection.startswith( "ResourceId=" ):
163 if connection.removesuffix( ";" ) != f "ResourceId= { boundary[ 'storage_id' ] } " :
164 raise _failure( "boundary-mismatch" , "Observed connection targets a different Storage account." )
165 return "visible"
166 raise _failure( "source-binding-conflict" , "Observed credentials are not the selected keyless binding or a recognized concealed value; details withheld." )
167
168
169 def _verify_storage_binding (
170 source: dict[ str , Any], boundary: dict[ str , Any], current: dict[ str , Any],
171 generated: list[dict[ str , Any]],
172 load_receipts: Callable[[], tuple[dict[ str , Any], dict[ str , Any]] | None ],
173 ) -> None :
174 connection = current[ "azureBlobParameters" ].get( "connectionString" )
175 if _connection_binding(connection, boundary) == "visible" :
176 return
177 receipts = load_receipts()
178 if receipts is None :
179 raise _failure(
180 "source-binding-unverified" ,
181 "Source account binding is unknown: GET the exact source definition or select its retained approved input/successful creation result pair. Redaction is not a mismatch; snippets are not binding proof." ,
182 )
183 _verify_creation_binding(source, boundary, current, generated, receipts)
184
185
186 def _verify_creation_binding (
187 source: dict[ str , Any], boundary: dict[ str , Any], current: dict[ str , Any],
188 generated: list[dict[ str , Any]], receipts: tuple[dict[ str , Any], dict[ str , Any]],
189 ) -> None :
190 prior, result = receipts
191 observed = {
192 "type" : "knowledge-source" , "name" : source[ "name" ], "etag" : current[ "@odata.etag" ],
193 "definition_digest" : digest(search_reconcile._definition(current)),
194 }
195 if (
196 prior[ "boundary" ] != boundary
197 or any (prior[ "source" ].get(field) != source[field] for field in ( "endpoint" , "name" , "api_version" ))
198 or not search_reconcile.definitions_match(prior[ "source" ][ "desired" ], current)
199 or result.get( "resources" , {}).get( "created" ) != [observed]
200 or result.get( "verification" , {}).get( "readback" ) != observed
201 or result.get( "source" , {}).get( "generated" ) != generated
202 ):
203 raise _failure( "source-binding-unverified" , "Retained creation evidence does not match the fresh source identity/ETag." )
204
205
206 def plan_source (
207 request: dict[ str , Any], * ,
208 token_provider: TokenProvider = azure_cli_token, transport: Transport = http_request,
209 storage_transport: Transport = http_request, monotonic: Callable[[], float ] = time.monotonic,
210 ) -> dict[ str , Any]:
211 if not isinstance (request, dict ):
212 raise _failure( "input-schema-invalid" , "Planning input must be an object." )
213 reject_secrets(request)
214 require_allowed_fields(
215 request,
216 { "schema_version" , "endpoint" , "name" , "owner" , "storage_id" , "container" , "prefix" , "is_adls" ,
217 "api_version" , "processing" , "network_access" , "identity" , "permission_options" ,
218 "ingestion_schedule" , "description" , "rbac" , "network" , "inventory_limits" , "poll" ,
219 "reuse_input_file" , "reuse_result_file" , "embedding" , "content_understanding" },
220 label = "Blob planning input" ,
221 )
222 try :
223 json.dumps(request, ensure_ascii = False , allow_nan = False ).encode( "utf-8" )
224 except ( UnicodeError , TypeError , ValueError ) as exc:
225 raise _failure( "input-schema-invalid" , "Planning choices must be valid UTF-8 JSON." ) from exc
226 if request.get( "schema_version" ) != "1.0" or not isinstance (request.get( "owner" ), str ) or not request[ "owner" ].strip():
227 raise _failure( "input-schema-invalid" , "schema_version 1.0 and an explicit owner are required." )
228 boundary = blob_inventory.validate_boundary({
229 field: request.get(field) for field in ( "storage_id" , "container" , "prefix" , "is_adls" )
230 })
231 limits = copy.deepcopy(blob_inventory.validate_limits(request.get( "inventory_limits" )))
232 poll = copy.deepcopy(_poll_limits(request.get( "poll" )))
233 if (
234 request.get( "processing" ) not in ( "minimal-lexical" , "minimal-vector" , "standard-cu" ) or request.get( "network_access" ) != "public"
235 or request.get( "identity" ) != "system-assigned" or request.get( "permission_options" ) != []
236 or "ingestion_schedule" not in request or request[ "ingestion_schedule" ] is not None
237 or not isinstance (request.get( "api_version" ), str )
238 or request[ "api_version" ] not in { "2026-04-01" , "2026-08-01-preview" }
239 or boundary[ "is_adls" ] and request[ "api_version" ] != "2026-08-01-preview"
240 ):
241 raise _failure(
242 "planning-processing-unsupported" ,
243 "This planner supports Blob 2026-04-01/2026-08-01-preview and ADLS 2026-08-01-preview: "
244 "Internal presets: minimal-lexical = minimal extraction without vectors; "
245 "minimal-vector = minimal extraction with embeddings; standard-cu = standard CU with optional embeddings. "
246 "These are not API enums or KB reasoning modes. Requires public, system-assigned, no permissions/schedule. "
247 "Other requested versions/features need a compatible owner; never change them silently." ,
248 )
249 embedding = source_vector.validate_choice(
250 request.get( "embedding" ), enabled = (
251 request[ "processing" ] == "minimal-vector"
252 or request[ "processing" ] == "standard-cu" and request.get( "embedding" ) is not None
253 ),
254 api_version = request[ "api_version" ],
255 )
256 cu = validate_content_understanding(
257 request.get( "content_understanding" ), enabled = request[ "processing" ] == "standard-cu" ,
258 )
259 if "description" not in request or not (
260 request[ "description" ] is None or isinstance (request[ "description" ], str )
261 ):
262 raise _failure( "input-schema-invalid" , "description must be explicit text or null." )
263 rbac, network = request.get( "rbac" ), request.get( "network" )
264 if (
265 not isinstance (rbac, dict ) or not isinstance (rbac.get( "assignments" ), list )
266 or not rbac[ "assignments" ] or not all ( isinstance (item, dict ) and item for item in rbac[ "assignments" ])
267 or not isinstance (network, dict ) or network.get( "posture" ) != "public"
268 or not isinstance (network.get( "evidence" ), str ) or not network[ "evidence" ].strip()
269 ):
270 raise _failure( "planning-evidence-missing" , "Supply observed RBAC assignments and public network evidence; the owner verifies effective access and policy." )
271 ingestion = {
272 "contentExtractionMode" : "minimal" , "disableImageVerbalization" : True ,
273 "identity" : None , "ingestionSchedule" : None ,
274 }
275 if request[ "api_version" ].endswith( "-preview" ):
276 ingestion.update( networkAccessMode = "public" , ingestionPermissionOptions = [])
277 if embedding is not None :
278 ingestion[ "embeddingModel" ] = source_vector.model_definition(embedding)
279 if cu is not None :
280 ingestion.update( contentExtractionMode = "standard" , aiServices = { "uri" : cu[ "endpoint" ].rstrip( "/" )})
281 source = {
282 "operation" : "reconcile" , "resource_type" : "knowledge-source" ,
283 "outcome" : "create-blob-knowledge-source" ,
284 "endpoint" : request.get( "endpoint" ), "name" : request.get( "name" ),
285 "api_version" : request[ "api_version" ], "action" : "create" ,
286 "owner" : request[ "owner" ], "cleanup_approved" : False ,
287 "rbac" : copy.deepcopy(rbac), "network" : copy.deepcopy(network),
288 "desired" : {
289 "name" : request.get( "name" ), "kind" : "azureBlob" , "description" : request[ "description" ],
290 "azureBlobParameters" : {
291 "connectionString" : f "ResourceId= { boundary[ 'storage_id' ] } " ,
292 "containerName" : boundary[ "container" ], "folderPath" : boundary[ "prefix" ] or None ,
293 "isADLSGen2" : boundary[ "is_adls" ], "ingestionParameters" : ingestion,
294 },
295 },
296 }
297 # Validate the shared control sections and exact address before any authentication.
298 for section, fields in ((rbac, { "assignments" }), (network, { "posture" , "evidence" })):
299 require_allowed_fields(section, fields, label = "planning controls" )
300 url = search_reconcile.resource_url(source)
301 receipt_paths = _receipt_paths(request)
302 receipts = None
303
304 def load_receipts () -> tuple[dict[ str , Any], dict[ str , Any]] | None :
305 nonlocal receipts
306 if receipts is None :
307 receipts = _reuse_receipts(receipt_paths)
308 return receipts
309
310 deadline = monotonic() + limits[ "deadline_seconds" ]
311
312 def read_search () -> tuple[dict[ str , Any] | None , str | None ]:
313 remaining = deadline - monotonic()
314 if remaining <= 0 :
315 raise _failure( "planning-deadline-exceeded" , "Planning read deadline elapsed." )
316
317 def bounded (method: str , target: str , token: str , ** kwargs: Any) -> Any:
318 if method != "GET" or target != url:
319 raise _failure( "planning-write-forbidden" , "Planning reads only the exact Search source." )
320 remaining = deadline - monotonic()
321 if remaining <= 0 :
322 raise _failure( "planning-deadline-exceeded" , "Planning read deadline elapsed during authentication." )
323 return transport(
324 method, target, token, timeout = min ( 30 , remaining), follow_redirects = False ,
325 max_response_bytes = 8 * 1024 * 1024 , response_deadline = time.monotonic() + remaining,
326 )
327
328 current, request_id = search_reconcile.read_resource(url, token_provider( SEARCH_AUDIENCE ), transport = bounded)
329 try :
330 source_vector.verify_source_readback(embedding, current)
331 verify_content_understanding_readback(cu, current)
332 except HelperFailure as failure:
333 failure.request_id = request_id
334 raise
335 return current, request_id
336
337 current, first_id = read_search()
338 generated = []
339 if current is not None :
340 if not search_reconcile.definitions_match(source[ "desired" ], current):
341 raise _failure( "definition-conflict" , "The exact source has a different definition; do not overwrite, suffix or repair it." )
342 if not isinstance (current.get( "@odata.etag" ), str ) or not current[ "@odata.etag" ]:
343 raise _failure( "definition-evidence-missing" , "Exact reuse requires the current source ETag." )
344 generated = generated_resources(current, strict = True )
345 _verify_storage_binding(source, boundary, current, generated, load_receipts)
346 snapshot = blob_inventory.discover(
347 boundary, limits, token_provider = token_provider, transport = storage_transport,
348 monotonic = monotonic, deadline = deadline,
349 )
350 refreshed, last_id = read_search()
351 if (current is None ) != (refreshed is None ) or current is not None and (
352 refreshed.get( "@odata.etag" ) != current[ "@odata.etag" ]
353 or not search_reconcile.definitions_match(source[ "desired" ], refreshed)
354 or generated_resources(refreshed, strict = True ) != generated
355 ):
356 raise _failure( "definition-drift" , "Source identity/ETag or generated resources changed during Storage observation." )
357 if refreshed is not None :
358 _verify_storage_binding(source, boundary, refreshed, generated, load_receipts)
359 source.update( action = "reuse" , expected_etag = refreshed[ "@odata.etag" ])
360 if monotonic() >= deadline:
361 raise _failure( "planning-deadline-exceeded" , "Planning read deadline elapsed during Search readback." )
362 evidence = { "verified" : True , "inventory_digest" : snapshot[ "inventory_digest" ]}
363 if boundary[ "is_adls" ]:
364 evidence.update( path_verified = True , acl_verified = True )
365 source[ "source_evidence" ] = evidence
366 plan = {
367 "operation" : "reconcile-and-monitor" , "owner" : request[ "owner" ], "cleanup_approved" : False ,
368 "boundary" : boundary, "inventory_digest" : snapshot[ "inventory_digest" ],
369 "inventory_limits" : limits, "poll" : poll, "source" : source,
370 }
371 if embedding is not None :
372 plan[ "embedding" ] = embedding
373 if cu is not None :
374 plan[ "content_understanding" ] = cu
375 plan[ "cu_plan_version" ] = "1.0"
376 if current is not None :
377 plan[ "expected_generated" ] = generated
378 else :
379 plan[ "expected_source_absent" ] = True
380 _validate_plan(plan)
381 fingerprint = digest(plan)
382 mutation = source[ "action" ] == "create"
383 return {
384 "status" : "planned" , "outcome" : "create-blob-knowledge-source" ,
385 "plan_fingerprint" : fingerprint,
386 "execution_input" : {
387 "schema_version" : "1.0" , "plan" : plan,
388 "approval" : { "confirmed" : False , "fingerprint" : fingerprint},
389 },
390 "approval_summary" : {
391 "target" : { "endpoint" : source[ "endpoint" ], "name" : source[ "name" ], "api_version" : source[ "api_version" ]},
392 "storage_account" : boundary[ "storage_id" ].rsplit( "/" , 1 )[ 1 ],
393 "source_kind" : "adls-gen2" if boundary[ "is_adls" ] else "azure-blob" ,
394 "scope" : "selected folder/directory" if boundary[ "prefix" ] else "explicit container/filesystem root" ,
395 "object_count" : len (snapshot[ "objects" ]), "total_bytes" : sum (item[ "size" ] for item in snapshot[ "objects" ]),
396 "adls_paths_observed" : len (snapshot[ "adls_paths" ]),
397 "processing" : (
398 f "standard Content Understanding extraction { 'with selected embeddings' if embedding else 'without source vectors' } ; no image verbalization, chat, permission ingestion or schedule"
399 if cu else
400 "minimal extraction with embeddings; no image verbalization, permission ingestion or schedule"
401 if embedding else "minimal lexical; image verbalization disabled; no models, permission ingestion or schedule"
402 ),
403 ** ({ "embedding" : source_vector.summary(embedding)} if embedding else {}),
404 ** ({ "content_understanding" : {
405 "purpose" : "Standard document extraction only; not source vectorization or KB answer synthesis." ,
406 "endpoint" : cu[ "endpoint" ].rstrip( "/" ), "auth" : cu[ "auth" ],
407 "authentication" : cu_ingestion_auth.approval_summary(
408 "adlsGen2" if request[ "is_adls" ] else "azureBlob" , cu[ "auth" ], creating = mutation,
409 ),
410 "kb_reasoning" : "Unchanged; KB chat requires separate selection, access and approval." ,
411 "cost_and_data" : "Creation sends selected documents to billable CU processing (no free document allowance); generated Search content is retained. Cross-region processing may apply. Selected source embeddings have separate costs." ,
412 "source_vectorization" : "azureOpenAI" if embedding else "none" ,
413 "prerequisites" : "Owner-verified references only, not effective access or successful processing proof. No local auth, role, model or defaults changes." ,
414 }} if cu else {}),
415 "network" : "public; supplied access evidence remains owner-verified" ,
416 "supplied_role_assignments" : len (rbac[ "assignments" ]),
417 "source_action" : source[ "action" ], "execution_required" : mutation,
418 "mutation_approval_required" : mutation,
419 "ownership" : "Only a newly created source and its service-generated children become run-owned; existing Search/Storage/objects/roles remain shared." ,
420 "verification" : "Fresh identity/inventory and ADLS owner/ACL metadata only; effective Search access, ingestion readiness and retrieval are unverified." ,
421 "cost_and_retention" : "Creation starts indexing and retains generated Search resources; existing charges/schedules continue on reuse." ,
422 "cleanup" : "Separate run-owned source cleanup only; never delete Storage objects or shared resources." ,
423 "next_step" : (
424 "Owner refreshes identity, RBAC, network, source state and cost/data consent, then approves these changes before applying the unchanged private artifact."
425 if mutation else "Reuse without mutation approval or executor invocation. Refresh discovery before later use; evidence is not future consent."
426 ),
427 },
428 "read_only_evidence" : { "request_ids" : [item for item in [first_id, * snapshot[ "request_ids" ], last_id] if item]},
429 "writes_performed" : [], "warnings" : [ SNAPSHOT_WARNING ],
430 }
431
432
433 def _failure (code: str , message: str , * , request_id: str | None = None ) -> HelperFailure:
434 return HelperFailure(code, message, blocked_at = "verification" , request_id = request_id)
435
436
437 def _timestamp (value: Any) -> datetime:
438 if not isinstance (value, str ):
439 raise _failure( "ingestion-status-invalid" , "Synchronization timestamp is missing." )
440 try :
441 parsed = datetime.fromisoformat(value.replace( "Z" , "+00:00" ))
442 except ValueError as exc:
443 raise _failure( "ingestion-status-invalid" , "Synchronization timestamp is invalid." ) from exc
444 if parsed.tzinfo is None :
445 raise _failure( "ingestion-status-invalid" , "Synchronization timestamp needs a time zone." )
446 return parsed
447
448
449 def _poll_limits (value: Any) -> dict[ str , int ]:
450 if not isinstance (value, dict ):
451 raise _failure( "input-schema-invalid" , "poll must be an object." )
452 require_allowed_fields(
453 value, { "deadline_seconds" , "max_requests" , "interval_seconds" }, label = "poll"
454 )
455 for field, maximum in (
456 ( "deadline_seconds" , 3600 ), ( "max_requests" , 1000 ), ( "interval_seconds" , 60 )
457 ):
458 if type (value.get(field)) is not int or not 1 <= value[field] <= maximum:
459 raise _failure( "input-schema-invalid" , "Polling limits must be bounded positive integers." )
460 return value
461
462
463 def _validate_plan (plan: dict[ str , Any]) -> tuple[dict[ str , Any], dict[ str , Any]]:
464 reject_secrets(plan)
465 require_allowed_fields(
466 plan,
467 { "operation" , "owner" , "cleanup_approved" , "source" , "boundary" , "inventory_digest" ,
468 "inventory_limits" , "poll" , "expected_generated" , "expected_source_absent" , "embedding" ,
469 "content_understanding" , "cu_plan_version" },
470 label = "Blob source plan" ,
471 )
472 if plan.get( "operation" ) != "reconcile-and-monitor" or plan.get( "cleanup_approved" ) is not False :
473 raise _failure( "operation-invalid" , "Blob application requires reconcile-and-monitor without cleanup." )
474 source = plan.get( "source" )
475 if not isinstance (source, dict ) or not isinstance (plan.get( "owner" ), str ) or not plan[ "owner" ]:
476 raise _failure( "input-schema-invalid" , "A source plan and owner are required." )
477 if (
478 source.get( "operation" ) != "reconcile"
479 or source.get( "resource_type" ) != "knowledge-source"
480 or source.get( "action" ) not in ( "create" , "reuse" )
481 or not isinstance (source.get( "api_version" ), str )
482 or source.get( "owner" ) != plan[ "owner" ]
483 or not isinstance (source.get( "desired" ), dict )
484 or source[ "desired" ].get( "kind" ) != "azureBlob"
485 or source.get( "ai_services_api_key_environment" ) is not None
486 or source.get( "ai_services_key_acquisition" ) is not None
487 ):
488 raise _failure( "step-contract-mismatch" , "Only same-owner Blob creation or exact reuse is supported." )
489 search_reconcile._validate_plan(source)
490 search_reconcile._resource_url(source)
491 boundary = blob_inventory.validate_boundary(plan.get( "boundary" ))
492 blob_inventory.validate_limits(plan.get( "inventory_limits" ))
493 _poll_limits(plan.get( "poll" ))
494 if "expected_source_absent" in plan and (
495 plan[ "expected_source_absent" ] is not True or source[ "action" ] != "create"
496 or source.get( "expected_etag" ) is not None
497 ):
498 raise _failure( "step-contract-mismatch" , "Expected absence is only valid for a creation plan." )
499 if "expected_generated" in plan and (
500 source[ "action" ] != "reuse"
501 or not isinstance (plan[ "expected_generated" ], list )
502 or len (plan[ "expected_generated" ]) != 4
503 or any ( not isinstance (item, dict ) or set (item) != { "type" , "name" , "service_managed" }
504 or item[ "service_managed" ] is not True
505 or not isinstance (item[ "type" ], str )
506 or not isinstance (item[ "name" ], str )
507 or re.fullmatch( r " [ a-zA-Z0-9 ][ a-zA-Z0-9_- ] {0,127} " , item[ "name" ]) is None
508 for item in plan[ "expected_generated" ])
509 or {item[ "type" ] for item in plan[ "expected_generated" ]} != { "datasource" , "indexer" , "skillset" , "index" }
510 ):
511 raise _failure( "generated-resources-unverified" , "Expected generated identities require a complete reuse plan." )
512 parameters = source[ "desired" ][ "azureBlobParameters" ]
513 if (
514 parameters[ "connectionString" ].removesuffix( ";" ) != f "ResourceId= { boundary[ 'storage_id' ] } "
515 or parameters[ "containerName" ] != boundary[ "container" ]
516 or parameters[ "folderPath" ] != (boundary[ "prefix" ] or None )
517 or parameters[ "isADLSGen2" ] != boundary[ "is_adls" ]
518 or "createdResources" in parameters
519 or source[ "source_evidence" ][ "inventory_digest" ] != plan.get( "inventory_digest" )
520 ):
521 raise _failure( "boundary-mismatch" , "Source definition must bind the exact discovered boundary and inventory." )
522 if (
523 not isinstance (plan.get( "inventory_digest" ), str )
524 or search_reconcile. SHA256 .fullmatch(plan[ "inventory_digest" ]) is None
525 ):
526 raise _failure( "source-evidence-invalid" , "The approved inventory digest is required." )
527 ingestion = parameters.get( "ingestionParameters" )
528 if not isinstance (ingestion, dict ) or ingestion.get( "assetStore" ) is not None :
529 raise _failure(
530 "source-write-forbidden" ,
531 "Ingestion parameters are required; asset-store writes are outside this read-only-source workflow." ,
532 )
533 source_vector.validate_plan_choice(plan)
534 standard = ingestion.get( "contentExtractionMode" ) == "standard"
535 if "cu_plan_version" in plan and (plan[ "cu_plan_version" ] != "1.0" or not standard):
536 raise _failure( "cu-plan-version-invalid" , "CU planner artifacts require version 1.0 and standard extraction." )
537 # Absence/generated guards predate CU planning and remain valid on legacy
538 # wire-only artifacts. Only CU-specific provenance selects the new contract.
539 planner_cu = standard and any (field in plan for field in (
540 "cu_plan_version" , "embedding" , "content_understanding" ,
541 ))
542 cu = validate_content_understanding(
543 plan.get( "content_understanding" ), enabled = planner_cu,
544 )
545 if cu is not None :
546 expected = {
547 "contentExtractionMode" : "standard" , "disableImageVerbalization" : True ,
548 "identity" : None , "ingestionSchedule" : None ,
549 "aiServices" : { "uri" : cu[ "endpoint" ].rstrip( "/" )},
550 }
551 if "embedding" in plan:
552 expected[ "embeddingModel" ] = source_vector.model_definition(plan[ "embedding" ])
553 if source[ "api_version" ].endswith( "-preview" ):
554 expected.update( networkAccessMode = "public" , ingestionPermissionOptions = [])
555 if ingestion != expected or boundary[ "is_adls" ] and source[ "api_version" ] != "2026-08-01-preview" :
556 raise _failure( "cu-plan-mismatch" , "Standard CU requires the unchanged keyless public definition, matching optional embeddings and no chat, asset store, permissions or schedule." )
557 return source, boundary
558
559
560 def _first_error (state: dict[ str , Any]) -> dict[ str , Any] | None :
561 errors = state.get( "errors" )
562 if errors is None or errors == []:
563 return None
564 if not isinstance (errors, list ) or any ( not isinstance (error, dict ) for error in errors):
565 raise _failure( "ingestion-status-invalid" , "Ingestion errors have an invalid shape." )
566 error = errors[ 0 ]
567 # Document errors can contain content, SAS URLs, and credentials in free text.
568 return {
569 "status" : error.get( "statusCode" ) if type (error.get( "statusCode" )) is int else None ,
570 "message" : "Document-level ingestion error; sensitive service text withheld." ,
571 "diagnostic_digest" : digest(error),
572 }
573
574
575 def _synchronization_state (state, request_id = None ):
576 if state is None :
577 return None
578 if not isinstance (state, dict ):
579 raise _failure( "ingestion-status-invalid" , "Synchronization state must be an object." , request_id = request_id)
580 return dict (state)
581
582
583 def _retry_after (value, * , now = None ):
584 now = now or datetime.now(timezone.utc)
585 kind = getattr (value, "kind" , None )
586 try :
587 if kind is not None :
588 if kind == "overlong" :
589 return math.inf
590 payload = getattr (value, "value" , None )
591 if ( not isinstance (kind, str ) or kind not in { "seconds" , "date" , "date-rfc850" }
592 or type (payload) not in ( int , float )
593 or ( type (payload) is float and not math.isfinite(payload))):
594 return None
595 if kind == "seconds" :
596 return payload if payload >= 0 and payload == int (payload) else None
597 parsed = datetime.fromtimestamp(payload, timezone.utc)
598 obsolete_date = kind == "date-rfc850"
599 else :
600 if not isinstance (value, str ) or len (value) > 128 :
601 return None
602 if value.isascii() and value.isdigit() and len (value) <= 10 :
603 return int (value)
604 parsed = parsedate_to_datetime(value)
605 obsolete_date = bool (re.fullmatch( r " [ A-Za-z ] + , \d {2} - [ A-Za-z ] {3} - \d {2} \d {2} : \d {2} : \d {2} GMT" , value))
606 if obsolete_date:
607 year = now.year // 100 * 100 + parsed.year % 100
608 if year > now.year + 50 :
609 year -= 100
610 parsed = parsed.replace( year = year)
611 if parsed.tzinfo is not None :
612 return max ( 0 , (parsed - now).total_seconds())
613 except ( ValueError , OverflowError , TypeError , OSError ):
614 pass
615 return None
616
617
618 def _execution_progress (body, not_before):
619 if ( not isinstance (body, dict ) or not isinstance (body.get( "status" ), str )
620 or body[ "status" ] not in { "running" , "error" , "unknown" }):
621 raise _failure( "indexer-status-invalid" , "Indexer status must be an object." )
622 if body[ "status" ] == "error" :
623 raise _failure( "indexer-status-error" , "Indexer availability reports an error; execution success is not inferred." )
624 run = body.get( "lastResult" )
625 if run is None :
626 return None
627 if not isinstance (run, dict ):
628 raise _failure( "indexer-status-invalid" , "Latest indexer execution must be an object." )
629 start = _timestamp(run.get( "startTime" ))
630 if start < not_before:
631 return None
632 status = run.get( "status" )
633 if not isinstance (status, str ) or status not in { "inProgress" , "success" , "transientFailure" , "persistentFailure" , "reset" }:
634 raise _failure( "indexer-status-invalid" , "Unknown latest execution status; top-level running is not execution proof." )
635 result = { "startTime" : run[ "startTime" ], "run_status" : status}
636 if run.get( "endTime" ) is not None :
637 if _timestamp(run[ "endTime" ]) < start:
638 raise _failure( "indexer-status-invalid" , "Indexer execution interval is invalid." )
639 result[ "endTime" ] = run[ "endTime" ]
640 for field, target in (( "itemsProcessed" , "items_attempted" ), ( "itemsFailed" , "items_failed" )):
641 if field in run:
642 if type (run[field]) is not int or run[field] < 0 :
643 raise _failure( "indexer-status-invalid" , "Indexer execution counters are invalid." )
644 result[target] = run[field]
645 if result.get( "items_failed" , 0 ) > result.get( "items_attempted" , result.get( "items_failed" , 0 )):
646 raise _failure( "indexer-status-invalid" , "Failed item count exceeds attempted items." )
647 errors = run.get( "errors" , [])
648 if errors is not None and ( not isinstance (errors, list ) or any ( not isinstance (item, dict ) for item in errors)):
649 raise _failure( "indexer-status-invalid" , "Indexer execution errors are malformed." )
650 result[ "error_count" ] = len (errors or [])
651 return result
652
653
654 def _execution_completed_by (execution, end, not_before):
655 required = { "startTime" , "endTime" , "run_status" , "error_count" }
656 if ( not isinstance (execution, dict ) or not required <= execution.keys()
657 or execution.keys() - required - { "items_attempted" , "items_failed" }
658 or execution[ "run_status" ] != "success"
659 or type (execution[ "error_count" ]) is not int or execution[ "error_count" ] != 0
660 or any ( type (execution[key]) is not int or execution[key] < 0
661 for key in ( "items_attempted" , "items_failed" ) if key in execution)
662 or execution.get( "items_failed" , 0 )):
663 return False
664 try :
665 return not_before <= _timestamp(execution[ "startTime" ]) <= _timestamp(execution[ "endTime" ]) <= end
666 except HelperFailure:
667 return False
668
669
670 @reporting ( "blob-monitor" )
671 def monitor (
672 source: dict[ str , Any],
673 * ,
674 not_before: datetime,
675 limits: dict[ str , int ],
676 token_provider: TokenProvider,
677 transport: Transport,
678 require_new_cycle: bool = False ,
679 excluded_cycle: list[ str ] | None = None ,
680 monotonic: Callable[[], float ] = time.monotonic,
681 sleep: Callable[[ float ], None ] = time.sleep,
682 progress: Progress | None = None ,
683 cancelled: Callable[[], bool ] = lambda : False ,
684 indexer_name: str | None = None ,
685 ) -> dict[ str , Any]:
686 _poll_limits(limits)
687 progress.update( "ingestion-cycle" )
688 excluded = tuple (_timestamp(value) for value in excluded_cycle) if excluded_cycle is not None else None
689 url = (
690 f " { source[ 'endpoint' ].rstrip( '/' ) } /knowledgesources(' { search_reconcile._odata_name(source[ 'name' ]) } ')/status?"
691 + urlencode({ "api-version" : source[ "api_version" ]})
692 )
693 token = token_provider( SEARCH_AUDIENCE )
694 started = monotonic()
695 deadline = started + limits[ "deadline_seconds" ]
696 request_ids: list[ str ] = []
697 first_error: dict[ str , Any] | None = None
698 first_retry: dict[ str , Any] | None = None
699 observed = "no-completed-cycle"
700 initial_cycle: tuple[Any, Any] | None = None
701 first_response = True
702 checks = 0
703 delay = limits[ "interval_seconds" ]
704 latest = {}
705 previous = None
706 previous_execution = None
707 execution_guard = None
708 pause_reason = "request-limit"
709 newest_run = None
710 synchronization_status = "not-reported"
711
712 def report (phase, next_check = None ):
713 value = {
714 "schema_version" : "1.0" , "phase" : phase,
715 "run_start" : _timestamp(latest[ "startTime" ]).isoformat() if latest.get( "startTime" ) else None ,
716 "processed" : latest.get( "itemsUpdatesProcessed" ), "failed" : latest.get( "itemsUpdatesFailed" ),
717 "skipped" : latest.get( "itemsSkipped" ), "unit" : "item-updates" ,
718 "total" : None , "remaining" : None , "denominator" : "not-comparable-to-files" ,
719 "synchronization_status" : synchronization_status,
720 "elapsed_seconds" : max ( 0 , monotonic() - started), "next_check_seconds" : next_check,
721 }
722 progress.counts[ "status_checks" ] = checks
723 progress.blob_update(value)
724 return value
725
726 def failure_result ( * args):
727 result = _readiness_failure( * args)
728 result[ "progress" ] = report( "failed" )
729 result[ "watch" ] = { "schema_version" : "1.0" , "state" : "blocked" , "reason" : "evidence-failure" ,
730 "elapsed_seconds" : max ( 0 , monotonic() - started),
731 "status_checks" : checks, "latest" : copy.deepcopy(latest)}
732 return result
733
734 def read_status (target):
735 nonlocal checks, retry_after
736 remaining = deadline - monotonic()
737 if remaining <= 0 :
738 raise _failure( "ingestion-watch-expired" , "The client watch window elapsed before this read." )
739 checks += 1
740 response = transport(
741 "GET" , target, token, timeout = min ( 30 , remaining),
742 max_response_bytes = 8 * 1024 * 1024 , follow_redirects = False ,
743 response_deadline = monotonic() + min ( 30 , remaining),
744 )
745 if response.request_id:
746 request_ids.append(response.request_id)
747 if response.status != 200 :
748 value = getattr (response, "retry_after" , None )
749 if value is None :
750 value = next ((v for k, v in response.headers.items() if k.lower() == "retry-after" ), None )
751 retry_after = _retry_after(value)
752 raise HelperFailure( "ingestion-inaccessible" , "Status read is inaccessible." ,
753 blocked_at = "verification" , status = response.status, request_id = response.request_id)
754 return response
755
756 while checks < limits[ "max_requests" ]:
757 remaining = deadline - monotonic()
758 if cancelled():
759 pause_reason = "cancelled"
760 break
761 if remaining <= 0 :
762 pause_reason = "deadline"
763 break
764 retry_after = None
765 throttled = False
766 try :
767 response = read_status(url)
768 except KeyboardInterrupt :
769 pause_reason = "cancelled"
770 break
771 except HelperFailure as failure:
772 if failure.request_id and failure.request_id not in request_ids:
773 request_ids.append(failure.request_id)
774 if failure.code == "response-deadline-exceeded" :
775 first_retry = first_retry or { "code" : failure.code, "status" : failure.http_status,
776 "request_id" : failure.request_id}
777 if monotonic() >= deadline:
778 pause_reason = "deadline"
779 break
780 if failure.code == "ingestion-watch-expired" :
781 pause_reason = "deadline"
782 break
783 if retry_after is None :
784 retry_after = _retry_after(failure.retry_after)
785 if (failure.http_status not in RETRY_STATUS
786 and failure.code not in { "azure-response-ambiguous" , "response-deadline-exceeded" }):
787 return failure_result( "ingestion-inaccessible" , failure.request_id, request_ids,
788 first_error, first_retry, failure.http_status)
789 if first_retry is None :
790 first_retry = { "code" : failure.code, "status" : failure.http_status,
791 "request_id" : failure.request_id}
792 throttled = failure.http_status == 429
793 delay = min ( 60 , max (limits[ "interval_seconds" ], delay * 2 ))
794 else :
795 if monotonic() >= deadline:
796 pause_reason = "deadline"
797 break
798 body = response.body
799 if not isinstance (body, dict ) or body.get( "kind" ) != "azureBlob" :
800 raise _failure( "ingestion-status-invalid" , "Expected azureBlob status." , request_id = response.request_id)
801 synchronization_status = body.get( "synchronizationStatus" , "not-reported" )
802 if synchronization_status not in ( "not-reported" , "active" , "creating" , "deleting" ):
803 raise _failure( "ingestion-status-invalid" , "Unknown knowledge-source synchronization availability." ,
804 request_id = response.request_id)
805 current = _synchronization_state(body.get( "currentSynchronizationState" ), response.request_id)
806 last = _synchronization_state(body.get( "lastSynchronizationState" ), response.request_id)
807 for state in (current, last):
808 if state is not None :
809 _timestamp(state.get( "startTime" ))
810 _first_error(state)
811 state = current or last
812 start = _timestamp(state[ "startTime" ]) if state else None
813 if start is not None and start >= not_before and (newest_run is None or start >= newest_run):
814 newest_run = start
815 error = _first_error(state)
816 if first_error is None and error:
817 first_error = { ** error, "request_id" : response.request_id}
818 latest = { "startTime" : state[ "startTime" ],
819 "state" : "in-progress" if current else "completed-cycle-observed" }
820 for field in ( "itemsUpdatesProcessed" , "itemsUpdatesFailed" , "itemsSkipped" ):
821 if field in state:
822 if type (state[field]) is not int or state[field] < 0 :
823 raise _failure( "ingestion-status-invalid" , "Observed synchronization counters are invalid." ,
824 request_id = response.request_id)
825 latest[field] = state[field]
826 if current:
827 observed = "in-progress"
828 signature = digest(latest)
829 delay = (limits[ "interval_seconds" ] if signature != previous
830 else min ( 60 , delay * 2 ))
831 previous = signature
832 else :
833 latest = {}
834 observed = "stale-success-or-unrelated-cycle" if state else "no-completed-cycle"
835 signature = digest({})
836 delay = limits[ "interval_seconds" ] if signature != previous else min ( 60 , delay * 2 )
837 previous = signature
838 if execution_guard is not None :
839 latest[ "indexer_execution" ] = copy.deepcopy(execution_guard)
840 if first_response and require_new_cycle and last:
841 initial_cycle = (last.get( "startTime" ), last.get( "endTime" ))
842 first_response = False
843 if (
844 last and last.get( "endTime" ) is not None
845 and _timestamp(last.get( "startTime" )) >= max (not_before, newest_run or not_before)
846 and (last.get( "startTime" ), last.get( "endTime" )) != initial_cycle
847 and (excluded is None or (_timestamp(last.get( "startTime" )), _timestamp(last.get( "endTime" ))) != excluded)
848 ):
849 status = last.get( "status" )
850 if status is not None and not isinstance (status, str ):
851 raise _failure( "ingestion-status-invalid" , "Synchronization status must be a string when present." )
852 if status in { "failure" , "partialSuccess" }:
853 return failure_result( f "ingestion- { status } " , response.request_id,
854 request_ids, first_error, first_retry)
855 if status in { None , "success" }:
856 start = _timestamp(last.get( "startTime" ))
857 end = _timestamp(last.get( "endTime" ))
858 failed = last.get( "itemsUpdatesFailed" )
859 processed = last.get( "itemsUpdatesProcessed" )
860 skipped = last.get( "itemsSkipped" )
861 if end < start or any ( type (count) is not int or count < 0 for count in (failed, processed, skipped)):
862 raise _failure( "ingestion-status-invalid" , "Completed synchronization counters or interval are invalid." )
863 if failed or first_error:
864 return failure_result( "ingestion-partialSuccess" , response.request_id,
865 request_ids, first_error, first_retry)
866 if ( not current and synchronization_status in ( "active" , "not-reported" )
867 and (execution_guard is None
868 or _execution_completed_by(execution_guard, end, not_before))):
869 return {
870 "status" : "verified" ,
871 "progress" : report( "completed" ),
872 "synchronization" : {
873 field: last[field] for field in
874 ( "startTime" , "endTime" , "itemsUpdatesProcessed" , "itemsUpdatesFailed" , "itemsSkipped" )
875 },
876 "not_before" : not_before.isoformat(),
877 "request_ids" : request_ids,
878 "first_retry" : first_retry,
879 "watch" : { "schema_version" : "1.0" , "state" : "completed" ,
880 "elapsed_seconds" : max ( 0 , monotonic() - started),
881 "status_checks" : checks, "latest" : latest},
882 }
883 else :
884 raise _failure( "ingestion-status-invalid" , "Unknown synchronization completion status." )
885 elif last and not current:
886 observed = "stale-success-or-unrelated-cycle"
887 if indexer_name and checks < limits[ "max_requests" ] and monotonic() < deadline and not cancelled():
888 target = ( f " { source[ 'endpoint' ].rstrip( '/' ) } /indexers(' { search_reconcile._odata_name(indexer_name) } ')/status?"
889 + urlencode({ "api-version" : source[ "api_version" ]}))
890 run_response = None
891 try :
892 run_response = read_status(target)
893 cutoff = max (not_before, newest_run or not_before)
894 if execution_guard is not None :
895 cutoff = min (cutoff, _timestamp(execution_guard[ "startTime" ]))
896 execution = _execution_progress(run_response.body, cutoff)
897 if (execution is not None and execution_guard is not None
898 and _timestamp(execution[ "startTime" ]) < _timestamp(execution_guard[ "startTime" ])):
899 execution = None
900 if execution is not None :
901 if execution_guard is not None or execution[ "run_status" ] == "inProgress" :
902 execution_guard = copy.deepcopy(execution)
903 execution_signature = digest(execution)
904 if execution_signature != previous_execution:
905 delay = limits[ "interval_seconds" ]
906 previous_execution = execution_signature
907 latest[ "indexer_execution" ] = execution
908 if (execution.get( "items_failed" , 0 ) or execution[ "error_count" ]
909 or execution[ "run_status" ] in { "transientFailure" , "persistentFailure" }):
910 first_error = first_error or {
911 "status" : None , "message" : "Latest relevant indexer execution reports failures; details withheld." ,
912 "request_id" : run_response.request_id,
913 }
914 except KeyboardInterrupt :
915 pause_reason = "cancelled"
916 break
917 except HelperFailure as failure:
918 if failure.request_id is None and run_response is not None :
919 failure.request_id = run_response.request_id
920 if failure.request_id and failure.request_id not in request_ids:
921 request_ids.append(failure.request_id)
922 if failure.code == "response-deadline-exceeded" :
923 first_retry = first_retry or { "code" : failure.code, "status" : failure.http_status,
924 "request_id" : failure.request_id}
925 if monotonic() >= deadline:
926 pause_reason = "deadline"
927 break
928 if failure.code == "ingestion-watch-expired" :
929 pause_reason = "deadline"
930 break
931 if (failure.http_status not in RETRY_STATUS
932 and failure.code not in { "azure-response-ambiguous" , "response-deadline-exceeded" }):
933 code = failure.code if failure.code in { "indexer-status-invalid" , "indexer-status-error" } else "indexer-status-unverified"
934 return failure_result(code, failure.request_id, request_ids,
935 first_error, first_retry, failure.http_status)
936 first_retry = first_retry or { "code" : failure.code, "status" : failure.http_status,
937 "request_id" : failure.request_id}
938 throttled = failure.http_status == 429
939 if retry_after is None :
940 retry_after = _retry_after(failure.retry_after)
941 delay = min ( 60 , delay * 2 )
942 remaining = deadline - monotonic()
943 if cancelled():
944 pause_reason = "cancelled"
945 break
946 if retry_after is not None and retry_after > min ( 60 , remaining):
947 report( "throttled" if throttled else "waiting" )
948 pause_reason = "retry-after"
949 break
950 if remaining > 0 and checks < limits[ "max_requests" ]:
951 try :
952 wait = min ( max (delay, retry_after or 0 ), remaining)
953 report( "throttled" if throttled else
954 "ingesting" if observed == "in-progress" else "waiting" , wait)
955 sleep(wait)
956 except KeyboardInterrupt :
957 pause_reason = "cancelled"
958 break
959 if monotonic() >= deadline:
960 pause_reason = "deadline"
961 result = _readiness_failure(
962 "ingestion-timeout" , request_ids[ - 1 ] if request_ids else None ,
963 request_ids, first_error, first_retry,
964 )
965 result[ "observed" ] = observed
966 result[ "watch" ] = { "schema_version" : "1.0" , "state" : "paused" , "reason" : pause_reason,
967 "elapsed_seconds" : max ( 0 , monotonic() - started),
968 "status_checks" : checks, "latest" : latest,
969 "service_execution" : "not-modified" }
970 if first_error or latest.get( "itemsUpdatesFailed" ):
971 result[ "code" ] = "ingestion-partialSuccess"
972 result[ "progress" ] = report( "failed" if result[ "code" ] == "ingestion-partialSuccess" else "paused" )
973 result[ "safe_next_decision" ] = (
974 "Service reports item failures; retain the partial work and inspect exact private error evidence read-only. "
975 "A new watch does not repair failures; do not replay writes or default to cleanup."
976 ) if result[ "code" ] == "ingestion-partialSuccess" else (
977 "Client watch paused; this is not an ingestion failure. Resume GET-only with the unchanged input "
978 "and readiness receipt using blob_recheck.py --input <original-input> --receipt <checkpoint>. "
979 "Do not recreate, run/reset, delete, or infer corpus completion."
980 )
981 return result
982
983
984 def _readiness_failure (
985 code: str , request_id: str | None , request_ids: list[ str ],
986 first_error: dict[ str , Any] | None , first_retry: dict[ str , Any] | None ,
987 status: int | None = None ,
988 ) -> dict[ str , Any]:
989 return {
990 "status" : "unverified" , "code" : code, "request_id" : request_id,
991 "http_status" : status, "request_ids" : request_ids,
992 "first_error" : first_error, "first_retry" : first_retry,
993 }
994
995
996 @reporting ( "blob-source" )
997 def execute (
998 document: dict[ str , Any],
999 * ,
1000 token_provider: TokenProvider = azure_cli_token,
1001 transport: Transport = http_request,
1002 storage_transport: Transport = http_request,
1003 now: Callable[[], datetime] = lambda : datetime.now(timezone.utc),
1004 monotonic: Callable[[], float ] = time.monotonic,
1005 sleep: Callable[[ float ], None ] = time.sleep,
1006 checkpoint: Any = None ,
1007 progress: Progress | None = None ,
1008 ) -> dict[ str , Any]:
1009 progress.update( "validation" )
1010 reject_secrets(document)
1011 require_allowed_fields(document, { "schema_version" , "plan" , "approval" , "_computed_fingerprint" },
1012 label = "input envelope" )
1013 plan = document.get( "plan" )
1014 approval = document.get( "approval" )
1015 if document.get( "schema_version" ) != "1.0" or not isinstance (plan, dict ) or not isinstance (approval, dict ):
1016 raise _failure( "input-schema-invalid" , "A typed user-approved envelope is required." )
1017 require_allowed_fields(approval, { "confirmed" , "fingerprint" }, label = "approval" )
1018 fingerprint = digest(plan)
1019 if approval.get( "confirmed" ) is not True or approval.get( "fingerprint" ) != fingerprint:
1020 raise _failure( "approval-mismatch" , "The exact recomputed plan fingerprint must be approved." )
1021 source, boundary = _validate_plan(plan)
1022 progress.update( "blob-inventory" )
1023 before = blob_inventory.discover(
1024 boundary, plan[ "inventory_limits" ], token_provider = token_provider, transport = storage_transport,
1025 )
1026 if before[ "inventory_digest" ] != plan[ "inventory_digest" ]:
1027 raise _failure( "source-drift" , "Current source evidence differs from approval; reconfirmation is required." )
1028 not_before = now()
1029 generated: list[dict[ str , Any]] = []
1030 write_generated: list[dict[ str , Any]] = []
1031 initial_read = True
1032 creation_etag = None
1033 embedding_transport = source_vector.guard_readback_transport(plan, transport)
1034
1035 def reconcile_transport (method: str , url: str , token: str , ** kwargs: Any) -> Any:
1036 nonlocal initial_read
1037 first_read = method == "GET" and initial_read
1038 if first_read:
1039 initial_read = False
1040 kwargs.update( max_response_bytes = 8 * 1024 * 1024 , follow_redirects = False )
1041 try :
1042 response = embedding_transport(method, url, token, ** kwargs)
1043 except HelperFailure as failure:
1044 if (
1045 first_read and failure.http_status == 404 and source[ "action" ] == "create"
1046 and source.get( "expected_etag" ) is not None
1047 ):
1048 raise _failure(
1049 "definition-drift" , "The source bound by the approved ETag is absent; rebuild the plan." ,
1050 request_id = failure.request_id,
1051 ) from failure
1052 raise
1053 if method == "GET" and response.status == 200 and isinstance (response.body, dict ):
1054 etag = search_reconcile.resolve_etag(search_reconcile.response_etags(response), response.request_id)
1055 if etag is not None :
1056 response = HttpResult(response.status, { ** response.body, "@odata.etag" : etag},
1057 response.headers, response.etag_values)
1058 if creation_etag is not None and etag != creation_etag:
1059 raise _failure( "definition-drift" , "Source version differs from the acknowledged create response." ,
1060 request_id = response.request_id)
1061 if isinstance (response.body, dict ):
1062 if method == "GET" and response.status == 200 :
1063 parameters = response.body.get( "azureBlobParameters" )
1064 if isinstance (parameters, dict ):
1065 try :
1066 _connection_binding(parameters.get( "connectionString" ), boundary)
1067 except HelperFailure as failure:
1068 failure.request_id = response.request_id
1069 raise
1070 if first_read and response.status == 200 :
1071 if plan.get( "expected_source_absent" ):
1072 raise _failure( "definition-drift" , "A source appeared after planning; rebuild the plan." , request_id = response.request_id)
1073 if source.get( "expected_etag" ) is not None and response.body.get( "@odata.etag" ) != source[ "expected_etag" ]:
1074 raise _failure( "definition-drift" , "Source ETag differs from the approved readback." , request_id = response.request_id)
1075 if method == "GET" and response.status == 200 and (
1076 plan.get( "expected_source_absent" ) or "expected_generated" in plan
1077 ):
1078 if not isinstance (response.body.get( "@odata.etag" ), str ) or not response.body[ "@odata.etag" ]:
1079 raise _failure( "definition-evidence-missing" , "Source readback must include its ETag." , request_id = response.request_id)
1080 parameters = response.body.get( "azureBlobParameters" )
1081 connection = parameters.get( "connectionString" ) if isinstance (parameters, dict ) else None
1082 if isinstance (connection, str ) and connection.startswith( "ResourceId=" ):
1083 if connection.removesuffix( ";" ) != f "ResourceId= { boundary[ 'storage_id' ] } " :
1084 raise _failure( "boundary-mismatch" , "Readback targets a different Storage account." , request_id = response.request_id)
1085 elif plan.get( "expected_source_absent" ) and (
1086 not creation_etag or response.body.get( "@odata.etag" ) != creation_etag
1087 ):
1088 raise _failure( "source-binding-unverified" , "Redacted creation readback lacks a matching acknowledged PUT ETag." , request_id = response.request_id)
1089 generated.clear()
1090 generated.extend(generated_resources(response.body))
1091 if method == "GET" and "expected_generated" in plan and generated != plan[ "expected_generated" ]:
1092 raise _failure( "definition-drift" , "Generated identities differ from the approved reuse plan." , request_id = response.request_id)
1093 if method == "PUT" and response.status in { 200 , 201 }:
1094 write_generated[:] = generated
1095 return response
1096
1097 def created ( * , response, url, body, headers):
1098 nonlocal creation_etag
1099 if (source[ "action" ] != "create" or url != search_reconcile.resource_url(source)
1100 or headers.get( "If-None-Match" ) != "*" or "If-Match" in headers
1101 or body != canonical_bytes(source[ "desired" ])):
1102 raise _failure( "recheck-ownership-unproven" , "Creation acknowledgement must bind the exact approved conditional wire." )
1103 if checkpoint is not None :
1104 checkpoint.acknowledge(plan, response, not_before, url = url, body = body, headers = headers)
1105 creation_etag = search_reconcile.resolve_etag(search_reconcile.response_etags(response), response.request_id)
1106 if creation_etag is None :
1107 raise _failure( "creation-version-unproven" , "Successful create returned no ETag in body or HTTP headers; a later GET cannot prove its version." ,
1108 request_id = response.request_id)
1109
1110 progress.update( "source-reconciliation" )
1111 try :
1112 result = search_reconcile.execute(
1113 { "plan" : source, "_computed_fingerprint" : fingerprint},
1114 token_provider = token_provider, transport = reconcile_transport, on_created = created,
1115 )
1116 except HelperFailure as failure:
1117 if failure.writes:
1118 failure.resources_remaining.extend(write_generated or generated)
1119 elif failure.partial:
1120 failure.resources_reused.extend(generated)
1121 if checkpoint is not None and failure.writes:
1122 result = blocked_result(failure, outcome = "create-blob-knowledge-source" ,
1123 fingerprint = fingerprint, owner = plan[ "owner" ])
1124 result.update( readiness = { "status" : "unverified" }, knowledge_base = "not-verified" , retrieval = "unverified" )
1125 return _checkpoint_result(result, checkpoint)
1126 raise
1127 writes = [
1128 { "action" : "created" , "type" : item[ "type" ], "name" : item[ "name" ]}
1129 for item in result[ "resources" ][ "created" ]
1130 ]
1131 readiness: dict[ str , Any] = { "status" : "unverified" }
1132 after = None
1133 owned_generated = list (generated or write_generated) if writes else []
1134 try :
1135 expected_generated = list (generated)
1136 if checkpoint is not None :
1137 if source[ "action" ] == "create" and (
1138 not creation_etag or creation_etag != result[ "verification" ][ "readback" ][ "etag" ]
1139 or write_generated and generated != write_generated
1140 ):
1141 raise _failure( "recheck-ownership-unproven" , "Checkpoint requires an acknowledged PUT ETag and unchanged generated identities." )
1142 checkpoint.persist(plan, result, generated, not_before,
1143 token_provider = token_provider, transport = transport)
1144 readiness = monitor(
1145 source, not_before = not_before, limits = plan[ "poll" ], token_provider = token_provider,
1146 transport = transport, require_new_cycle =not bool (writes) and checkpoint is None ,
1147 excluded_cycle = checkpoint.excluded_cycle if checkpoint is not None else None ,
1148 monotonic = monotonic, sleep = sleep,
1149 progress = progress,
1150 indexer_name = next ((item[ "name" ] for item in generated if item[ "type" ] == "indexer" ), None ),
1151 )
1152 if readiness[ "status" ] != "verified" :
1153 raise HelperFailure(
1154 readiness[ "code" ], "Source reconciliation completed but ingestion readiness is unverified." ,
1155 blocked_at = "verification" , request_id = readiness[ "request_id" ],
1156 status = readiness[ "http_status" ],
1157 )
1158 progress.update( "blob-readback" )
1159 after = blob_inventory.discover(
1160 boundary, plan[ "inventory_limits" ], token_provider = token_provider, transport = storage_transport,
1161 )
1162 if after[ "inventory_digest" ] != before[ "inventory_digest" ]:
1163 raise _failure( "source-drift" , "Source changed during ingestion; reconfirmation is required." )
1164 progress.update( "source-readback" )
1165 current, request_id = search_reconcile._get(
1166 search_reconcile._resource_url(source), token_provider( SEARCH_AUDIENCE ), transport = reconcile_transport,
1167 )
1168 if (
1169 current is None
1170 or search_reconcile._definition(current) != search_reconcile._definition(source[ "desired" ])
1171 or current.get( "@odata.etag" ) != result[ "verification" ][ "readback" ][ "etag" ]
1172 or generated != expected_generated
1173 ):
1174 raise _failure( "definition-drift" , "Source definition or ETag changed while monitoring." , request_id = request_id)
1175 if request_id:
1176 result[ "verification" ][ "request_ids" ].append(request_id)
1177 if len (generated) != 4 or {item[ "type" ] for item in generated} != { "datasource" , "indexer" , "skillset" , "index" }:
1178 raise _failure( "generated-resources-unverified" , "Exact service-generated resource identities could not be read back." )
1179 if checkpoint is not None :
1180 checkpoint.verify(plan, token_provider = token_provider, transport = transport)
1181 except HelperFailure as failure:
1182 confirmed = copy.deepcopy(result)
1183 result = blocked_result(
1184 HelperFailure(
1185 failure.code, failure.message, blocked_at = failure.blocked_at, writes = writes,
1186 resources_remaining = result[ "ownership" ][ "run_owned" ] + owned_generated,
1187 request_id = failure.request_id, status = failure.http_status, partial = bool (writes),
1188 ),
1189 outcome = "create-blob-knowledge-source" , fingerprint = fingerprint, owner = plan[ "owner" ],
1190 )
1191 result[ "reconciliation" ] = "completed"
1192 result[ "writes_performed" ] = writes
1193 result[ "read_only_evidence" ] = {
1194 "inventory_request_ids" : before[ "request_ids" ] + (after[ "request_ids" ] if after else []),
1195 "configuration_request_ids" : checkpoint.request_ids if checkpoint is not None else [],
1196 }
1197 result[ "confirmed_reconciliation" ] = {
1198 key: confirmed[key] for key in ( "resources" , "verification" , "ownership" )
1199 }
1200 result[ "readiness" ] = { ** readiness, "status" : "unverified" }
1201 if readiness.get( "watch" , {}).get( "state" ) == "paused" :
1202 result[ "safe_next_decision" ] = readiness[ "safe_next_decision" ]
1203 if not writes:
1204 result[ "ownership" ][ "reused_not_owned" ] = [
1205 { "type" : "knowledge-source" , "name" : source[ "name" ]}, * generated
1206 ]
1207 else :
1208 result[ "outcome" ] = "create-blob-knowledge-source"
1209 result[ "readiness" ] = readiness
1210 result[ "verification" ][ "source_digest" ] = after[ "inventory_digest" ]
1211 result[ "source" ] = {
1212 "type" : "knowledge-source" , "name" : source[ "name" ],
1213 "generated" : owned_generated if writes else generated,
1214 "observed_generated" : generated,
1215 }
1216 result[ "writes_performed" ] = writes
1217 result[ "source_evidence" ] = {
1218 "inventory_digest" : before[ "inventory_digest" ], "boundary" : boundary,
1219 "operator_reachability" : "verified" , "managed_ingestion_reachability" : result[ "readiness" ][ "status" ],
1220 }
1221 result.setdefault( "warnings" , []).append( SNAPSHOT_WARNING )
1222 return _checkpoint_result(result, checkpoint)
1223
1224
1225 def _checkpoint_result (result, checkpoint):
1226 if "completed_writes" in result:
1227 result[ "writes_performed" ] = copy.deepcopy(result[ "completed_writes" ])
1228 if checkpoint is not None :
1229 result[ "warnings" ].extend(checkpoint.recovery_warnings)
1230 result[ "creation_acknowledgement" ] = checkpoint.write_observation or {
1231 "schema_version" : "1.0" , "state" : "unavailable" ,
1232 }
1233 result[ "indexer_diagnostics" ] = [
1234 item for item in checkpoint.diagnostics
1235 if not item[ "field" ].startswith(( "datasource." , "indexer." , "skillset." , "index." ))
1236 ]
1237 result[ "generated_diagnostics" ] = checkpoint.diagnostics
1238 result[ "indexer_observations" ] = checkpoint.observations
1239 result[ "datasource_binding_observations" ] = checkpoint.binding_observations
1240 for item in checkpoint.diagnostics:
1241 if item[ "severity" ] == "warning" and item[ "message" ] not in result.setdefault( "warnings" , []):
1242 result[ "warnings" ].append(item[ "message" ])
1243 result[ "recheck_checkpoint" ] = checkpoint.summary or { "status" : "unavailable" }
1244 result[ "recheck_write_acknowledgement" ] = checkpoint.write_acknowledgement or { "status" : "unavailable" }
1245 result[ "recheck_acknowledgement" ] = checkpoint.acknowledgement or checkpoint.write_acknowledgement or { "status" : "unavailable" }
1246 if checkpoint.summary is None :
1247 result[ "safe_next_decision" ] = "Preserve the first failure and resources. Use blob_recheck.py --recover with unchanged approved input and the retained acknowledgement, then --input/--receipt. Missing/conflicting write ETags or absent private evidence remain blockers; never borrow a GET version, replay creation or default to cleanup."
1248 try :
1249 checkpoint.finish(result)
1250 except HelperFailure as failure:
1251 if result[ "status" ] == "completed" :
1252 original = result
1253 result = blocked_result(
1254 HelperFailure(failure.code, failure.message, blocked_at = "evidence-retention" ,
1255 writes = original[ "writes_performed" ],
1256 resources_remaining = original[ "ownership" ][ "run_owned" ],
1257 partial = bool (original[ "writes_performed" ])),
1258 outcome = original[ "outcome" ], fingerprint = checkpoint.plan_digest,
1259 )
1260 result[ "confirmed_reconciliation" ] = original
1261 result[ "writes_performed" ] = original[ "writes_performed" ]
1262 result[ "result_evidence" ] = { "status" : "unavailable" , "code" : failure.code}
1263 result.setdefault( "warnings" , []).append( "Final private evidence retention failed; preserve the native result and first failure. No retry was performed." )
1264 return result
1265
1266
1267 def main (argv: list[ str ] | None = None ) -> int :
1268 try :
1269 from .private_artifacts import add_execution_output_argument, emit_plan_result, validate_execution_output_mode
1270 except ImportError :
1271 from private_artifacts import add_execution_output_argument, emit_plan_result, validate_execution_output_mode
1272 parser = argparse.ArgumentParser()
1273 modes = parser.add_mutually_exclusive_group( required = True )
1274 modes.add_argument( "--discover" , type = Path)
1275 modes.add_argument( "--plan" , type = Path)
1276 add_execution_output_argument(parser)
1277 modes.add_argument( "--input" , type = Path)
1278 parser.add_argument( "--receipt-dir" , type = Path)
1279 parser.add_argument( "--compact" , action = "store_true" )
1280 add_progress_argument(parser)
1281 args = parser.parse_args(argv)
1282 if args.compact and ( not args.input or not args.receipt_dir):
1283 parser.error( "--compact requires --input and --receipt-dir to retain full private evidence." )
1284 fingerprint = None
1285 owner = None
1286 progress = Progress( "blob-source" , enabled = args.progress) if args.input else None
1287 execution_started = False
1288 try :
1289 validate_execution_output_mode(args)
1290 if progress is not None :
1291 progress.update( "validation" )
1292 if args.receipt_dir and not args.input:
1293 raise _failure( "input-schema-invalid" , "--receipt-dir requires --input; read-only reuse capture uses blob_recheck.py." )
1294 if args.plan:
1295 result = plan_source(_read_intent(args.plan))
1296 emit_plan_result(result, args.execution_output, preserve_unapproved_input = result[ "status" ] == "planned" )
1297 return 0 if result[ "status" ] == "planned" else 2
1298 elif args.discover:
1299 try :
1300 document = json.loads(args.discover.read_text( encoding = "utf-8" ))
1301 except ( OSError , UnicodeError , json.JSONDecodeError) as exc:
1302 raise _failure( "input-unreadable" , "Discovery input must be readable UTF-8 JSON." ) from exc
1303 if not isinstance (document, dict ):
1304 raise _failure( "input-schema-invalid" , "Discovery input must be an object." )
1305 reject_secrets(document)
1306 require_allowed_fields(document, { "boundary" , "inventory_limits" }, label = "discovery input" )
1307 result = blob_inventory.discover(document.get( "boundary" ), document.get( "inventory_limits" ))
1308 else :
1309 document, plan, fingerprint = load_approved_input(args.input)
1310 owner = plan.get( "owner" )
1311 checkpoint = None
1312 if args.receipt_dir:
1313 try :
1314 from . import blob_recheck
1315 except ImportError :
1316 import blob_recheck
1317 checkpoint = blob_recheck.Checkpoint(args.receipt_dir, plan)
1318 execution_started = True
1319 if checkpoint is not None :
1320 result = execute(document, checkpoint = checkpoint, progress = progress)
1321 else :
1322 result = execute(document, progress = progress)
1323 except HelperFailure as failure:
1324 if progress is not None and not execution_started:
1325 progress.finish( failure = failure)
1326 result = blocked_result(failure, outcome = "blob-source-lifecycle" , fingerprint = fingerprint, owner = owner)
1327 if failure.partial and not failure.writes:
1328 result[ "safe_next_decision" ] = "Original create outcome is unproven. Preserve the mutation error and inspect observed resources read-only; no creation ownership, write replay or cleanup is authorized."
1329 if args.compact:
1330 try :
1331 try :
1332 from . import blob_recheck
1333 except ImportError :
1334 import blob_recheck
1335 result = blob_recheck.compact_result(result, args.receipt_dir)
1336 except HelperFailure as failure:
1337 result.setdefault( "warnings" , []).append( "compact-evidence-persistence-failed: full native result retained in output." )
1338 result[ "presentation_failure" ] = { "code" : failure.code}
1339 emit_result(result)
1340 return 3 if result[ "status" ] == "partial" else 2
1341 emit_result(result, preserve_unapproved_input = result[ "status" ] == "planned" )
1342 return { "completed" : 0 , "discovered" : 0 , "planned" : 0 , "blocked" : 2 , "partial" : 3 }[result[ "status" ]]
1343
1344
1345 if __name__ == "__main__" :
1346 sys.exit(main())