Setting the file. One moment.
Cleanup Dependencies · Foundry Iq · microsoft/azure-skills · Skills Docs
ContentsBack to the top of the page File Cu Canary
— line 423
This file
Number 10.25
Position 25 of 77
Type Python
Size 28 KB
Lines 435 helpers/ _cleanup_dependencies.py
Python · 435 lines · 28 KB
from
_common
import
HelperFailure, digest, odata_name, require_allowed_fields, sdk_error_metadata
12
13
14 COLLECTIONS = { "index" : "indexes" , "indexer" : "indexers" , "skillset" : "skillsets" , "datasource" : "datasources" }
15 SHA = re.compile( r "sha256: [ a-f0-9 ] {64} \Z " )
16
17
18 def fail (code, message):
19 return HelperFailure(code, message, blocked_at = "cleanup-dependencies" )
20
21
22 def limits (value = None ):
23 value = { "max_pages" : 20 , "max_resources" : 500 } if value is None else value
24 if not isinstance (value, dict ) or set (value) != { "max_pages" , "max_resources" }:
25 raise fail( "input-schema-invalid" , "Inventory limits require max_pages and max_resources." )
26 if any ( type (value[key]) is not int or not 1 <= value[key] <= maximum
27 for key, maximum in (( "max_pages" , 100 ), ( "max_resources" , 5000 ))):
28 raise fail( "input-schema-invalid" , "Inventory limits exceed supported bounded discovery." )
29 return dict (value)
30
31
32 def generated (current):
33 kind = current.get( "kind" )
34 if kind not in ( "file" , "azureBlob" ):
35 raise fail( "source-cleanup-kind-unsupported" , "Only File and Blob/ADLS generated ownership is supported." )
36 parameters = current.get( "fileParameters" if kind == "file" else "azureBlobParameters" )
37 raw = parameters.get( "createdResources" ) if isinstance (parameters, dict ) else None
38 required = { "index" } if kind == "file" else set ( COLLECTIONS )
39 if not isinstance (raw, dict ):
40 raise fail( "generated-ownership-unproven" , "The exact source must expose its generated resource identities." )
41 names = {( "datasource" if key == "dataSourceConnection" else key): value for key, value in raw.items()}
42 if set (names) != required or len (names) != len (raw):
43 raise fail( "generated-ownership-unproven" , "Unexpected, missing or colliding generated child identities." )
44 for name in names.values():
45 odata_name(name)
46 return names
47
48
49 def validate_guard (plan):
50 guard = plan.get( "dependency_guard" )
51 if guard is None :
52 return
53 if not isinstance (guard, dict ) or plan.get( "operation" ) != "delete" :
54 raise fail( "input-schema-invalid" , "Dependency guards belong only to cleanup deletion plans." )
55 kind = guard.get( "kind" )
56 if kind == "search-source" and plan.get( "resource_type" ) == "knowledge-source" :
57 fields, records, record_fields = (
58 { "kind" , "limits" , "generated" , "inventory_digest" },
59 guard.get( "generated" ), { "type" , "name" , "etag" , "definition_digest" },
60 )
61 if not isinstance (guard.get( "inventory_digest" ), str ) or not SHA .fullmatch(guard[ "inventory_digest" ]):
62 raise fail( "input-schema-invalid" , "A complete scoped inventory digest is required." )
63 elif kind == "prompt-connection" and isinstance (plan.get( "connection" ), dict ):
64 fields, records, record_fields = (
65 { "kind" , "limits" , "versions" , "toolboxes" }, guard.get( "versions" ), { "name" , "version" , "definition_digest" },
66 )
67 if not isinstance (guard.get( "toolboxes" ), list ):
68 raise fail( "input-schema-invalid" , "Complete toolbox version snapshots are required." )
69 records = records + guard[ "toolboxes" ] if isinstance (records, list ) else records
70 else :
71 raise fail( "input-schema-invalid" , "Dependency guard and cleanup target must agree." )
72 require_allowed_fields(guard, fields, label = "Cleanup dependency guard" )
73 if set (guard) != fields or not isinstance (records, list ):
74 raise fail( "input-schema-invalid" , "The dependency snapshot must be complete." )
75 bounds = limits(guard[ "limits" ])
76 if len (records) > bounds[ "max_resources" ]:
77 raise fail( "input-schema-invalid" , "The dependency snapshot exceeds approved limits." )
78 identities = set ()
79 for record_index, record in enumerate (records):
80 if not isinstance (record, dict ) or set (record) != record_fields:
81 raise fail( "input-schema-invalid" , "Dependency snapshot records are closed objects." )
82 if any ( not isinstance (value, str ) or not value.strip() for value in record.values()):
83 raise fail( "input-schema-invalid" , "Dependency snapshots require exact nonempty identities and versions." )
84 if not SHA .fullmatch(record[ "definition_digest" ]):
85 raise fail( "input-schema-invalid" , "Dependency snapshots require canonical definition digests." )
86 domain = "toolbox" if kind == "prompt-connection" and record_index >= len (guard[ "versions" ]) else "agent"
87 identity = (domain, record.get( "type" , record.get( "version" )), record[ "name" ])
88 if identity in identities:
89 raise fail( "input-schema-invalid" , "Duplicate dependency snapshot identity." )
90 identities.add(identity)
91 if kind == "search-source" :
92 kinds = {record[ "type" ] for record in records}
93 if kinds not in ({ "index" }, set ( COLLECTIONS )) or len (kinds) != len (records):
94 raise fail( "input-schema-invalid" , "Generated snapshots must identify one complete supported cascade." )
95
96
97 def _list_search (plan, collection, token, transport, bounds):
98 base = f " { plan[ 'endpoint' ].rstrip( '/' ) } / { collection } "
99 url = base + "?" + urlencode({ "api-version" : plan[ "api_version" ]})
100 seen, names, records = set (), set (), []
101 for _ in range (bounds[ "max_pages" ]):
102 if url in seen:
103 raise fail( "dependency-inventory-partial" , "A continuation cycle prevents complete dependency discovery." )
104 seen.add(url)
105 response = transport( "GET" , url, token)
106 body = response.body
107 if response.status != 200 :
108 raise HelperFailure( "dependency-inventory-failed" , "Scoped collection GET failed; consumer absence is unproven." ,
109 blocked_at = "cleanup-dependencies" , status = response.status, request_id = response.request_id)
110 if not isinstance (body, dict ) or not isinstance (body.get( "value" ), list ):
111 raise fail( "dependency-inventory-invalid" , "Scoped collection GET did not return a complete JSON page." )
112 if set (body) - { "value" , "@odata.nextLink" , "@odata.context" }:
113 raise fail( "dependency-inventory-partial" , "An unsupported pagination contract cannot prove absence." )
114 for item in body[ "value" ]:
115 if not isinstance (item, dict ):
116 raise fail( "dependency-inventory-invalid" , "A dependency inventory item is not an object." )
117 name = item.get( "name" )
118 odata_name(name)
119 if name in names:
120 raise fail( "dependency-inventory-changed" , "Duplicate identities across inventory pages require fresh planning." )
121 names.add(name)
122 records.append(item)
123 if len (records) > bounds[ "max_resources" ]:
124 raise fail( "dependency-inventory-limit" , "Complete scoped discovery exceeds the approved object bound." )
125 next_link = body.get( "@odata.nextLink" )
126 if next_link is None :
127 return sorted (records, key =lambda item: item[ "name" ])
128 if len (records) >= bounds[ "max_resources" ]:
129 raise fail( "dependency-inventory-limit" , "More inventory pages exceed the approved object bound." )
130 if not isinstance (next_link, str ) or not next_link:
131 raise fail( "dependency-inventory-partial" , "Malformed continuation is not an empty final page." )
132 candidate = urljoin(base, next_link)
133 parsed, expected = urlsplit(candidate), urlsplit(base)
134 query = parse_qs(parsed.query, keep_blank_values = True )
135 if (
136 parsed.scheme != expected.scheme or parsed.netloc != expected.netloc or parsed.path != expected.path
137 or parsed.fragment or parsed.username or parsed.password
138 or query.get( "api-version" ) != [plan[ "api_version" ]]
139 or set (query) - { "api-version" , "$skiptoken" , "service" , "pageSize" , "search" , "searchType" }
140 or query.get( "search" , [ "" ]) != [ "" ]
141 or ( "service" in query and query[ "service" ][ 0 ].casefold()
142 not in {expected.hostname.casefold(), expected.hostname.split( "." )[ 0 ].casefold()})
143 or any ( len (values) != 1 for values in query.values())
144 ):
145 raise fail( "dependency-inventory-scope" , "Continuation changed the selected service, collection, API or filter." )
146 url = candidate
147 raise fail( "dependency-inventory-limit" , "Complete dependency discovery exceeds the approved page bound." )
148
149
150 def _projections (skillset):
151 projections = skillset.get( "indexProjections" )
152 if projections is None :
153 return []
154 if not isinstance (projections, dict ) or not isinstance (projections.get( "selectors" ), list ):
155 raise fail( "dependency-inventory-invalid" , "Skillset projection consumers are incomplete." )
156 names = []
157 for selector in projections[ "selectors" ]:
158 if not isinstance (selector, dict ):
159 raise fail( "dependency-inventory-invalid" , "A skillset projection selector is incomplete." )
160 name = selector.get( "targetIndexName" )
161 odata_name(name)
162 names.append(name)
163 return names
164
165
166 def search_snapshot (plan, current, token, * , transport, bounds):
167 try :
168 from . import search_reconcile
169 except ImportError :
170 import search_reconcile
171 names = generated(current)
172 inventories = {}
173 bases = _list_search(plan, "knowledgebases" , token, transport, bounds)
174 inventories[ "knowledgebases" ] = bases
175 for base in bases:
176 sources = base.get( "knowledgeSources" )
177 if not isinstance (sources, list ) or any ( not isinstance (item, dict ) or not isinstance (item.get( "name" ), str ) for item in sources):
178 raise fail( "dependency-inventory-invalid" , "Every KB definition must expose its complete source references." )
179 if any (item[ "name" ] == plan[ "name" ] for item in sources):
180 raise HelperFailure(
181 "source-in-use" , "A retained KB still references this source; separately plan that KB first, never detach it." ,
182 blocked_at = "cleanup-dependencies" , warnings = [ "referencing-kb:" + base[ "name" ]],
183 )
184 sources = _list_search(plan, "knowledgesources" , token, transport, bounds)
185 inventories[ "knowledgesources" ] = sources
186 if sum (item[ "name" ] == plan[ "name" ] for item in sources) != 1 :
187 raise fail( "dependency-inventory-changed" , "The selected source disappeared from the complete service inventory." )
188 for source in sources:
189 if source[ "name" ] == plan[ "name" ]:
190 if generated(source) != names or source.get( "@odata.etag" ) != current.get( "@odata.etag" ):
191 raise fail( "definition-drift" , "Source inventory and exact readback disagree." )
192 continue
193 if source.get( "kind" ) == "searchIndex" :
194 parameters = source.get( "searchIndexParameters" )
195 index = parameters.get( "searchIndexName" ) if isinstance (parameters, dict ) else None
196 if not isinstance (index, str ):
197 raise fail( "dependency-inventory-invalid" , "An existing-index source has unknown target identity." )
198 shared = index == names[ "index" ]
199 elif source.get( "kind" ) in ( "file" , "azureBlob" ):
200 other = generated(source)
201 shared = any (other.get(kind) == name for kind, name in names.items())
202 else :
203 raise fail( "dependency-consumer-opaque" , "An unsupported source kind prevents complete generated-index consumer proof." )
204 if shared:
205 raise fail( "generated-resource-shared" , "Another knowledge source references a generated cleanup resource." )
206 indexers = _list_search(plan, "indexers" , token, transport, bounds)
207 inventories[ "indexers" ] = indexers
208 for indexer in indexers:
209 if not all ( isinstance (indexer.get(key), str ) for key in ( "dataSourceName" , "targetIndexName" )):
210 raise fail( "dependency-inventory-invalid" , "Indexer dependency identities are incomplete." )
211 if indexer.get( "skillsetName" ) is not None and not isinstance (indexer[ "skillsetName" ], str ):
212 raise fail( "dependency-inventory-invalid" , "Indexer skillset identity is malformed." )
213 if indexer[ "name" ] == names.get( "indexer" ):
214 if any (indexer.get(field) != names[kind] for field, kind in (
215 ( "dataSourceName" , "datasource" ), ( "targetIndexName" , "index" ), ( "skillsetName" , "skillset" ),
216 )):
217 raise fail( "definition-drift" , "The generated indexer no longer binds the exact owned pipeline." )
218 elif any (indexer.get(field) == names[kind] for field, kind in (
219 ( "dataSourceName" , "datasource" ), ( "targetIndexName" , "index" ), ( "skillsetName" , "skillset" ),
220 ) if kind in names):
221 raise fail( "generated-resource-shared" , "Another indexer consumes a generated cleanup resource." )
222 skillsets = _list_search(plan, "skillsets" , token, transport, bounds)
223 inventories[ "skillsets" ] = skillsets
224 for skillset in skillsets:
225 if skillset[ "name" ] == names.get( "skillset" ):
226 if any (index != names[ "index" ] for index in _projections(skillset)):
227 raise fail( "generated-resource-shared" , "The generated skillset also serves an outside-plan index." )
228 continue
229 skills = skillset.get( "skills" )
230 if not isinstance (skills, list ) or any ( not isinstance (skill, dict ) or not isinstance (skill.get( "@odata.type" ), str ) for skill in skills):
231 raise fail( "dependency-inventory-invalid" , "Skillset consumers are incomplete." )
232 if any ( ".Custom." in skill[ "@odata.type" ] for skill in skills):
233 raise fail( "dependency-consumer-opaque" , "Custom skill code has no complete native generated-resource consumer contract." )
234 if names[ "index" ] in _projections(skillset):
235 raise fail( "generated-resource-shared" , "Another skillset projects into the generated index." )
236 snapshots = []
237 for kind, name in sorted (names.items()):
238 url = f " { plan[ 'endpoint' ].rstrip( '/' ) } / { COLLECTIONS [kind] } (' { odata_name(name) } ')?api-version= { plan[ 'api_version' ] } "
239 child, _ = search_reconcile.read_resource(url, token, transport = transport)
240 if child is None :
241 raise fail( "generated-resource-missing" , "A generated child is missing; source absence alone cannot prove the remaining cascade." )
242 etag = child.get( "@odata.etag" )
243 if child.get( "name" ) != name or not isinstance (etag, str ) or not etag.strip():
244 raise fail( "generated-version-unavailable" , "Every generated object requires an exact name and fresh ETag." )
245 snapshots.append({ "type" : kind, "name" : name, "etag" : etag, "definition_digest" : digest(child)})
246 refreshed, _ = search_reconcile.read_resource(search_reconcile.resource_url(plan), token, transport = transport)
247 if (refreshed is None or search_reconcile._definition(refreshed) != search_reconcile._definition(current)
248 or refreshed.get( "@odata.etag" ) != current.get( "@odata.etag" ) or generated(refreshed) != names):
249 raise fail( "definition-drift" , "The source changed during dependency discovery." )
250 inventory_state = {
251 kind: [
252 { "name" : item[ "name" ], "etag" : item.get( "@odata.etag" ),
253 "definition" : search_reconcile._definition(item),
254 "generated" : generated(item) if kind == "knowledgesources" and item.get( "kind" ) in ( "file" , "azureBlob" ) else None }
255 for item in items
256 ]
257 for kind, items in inventories.items()
258 }
259 return { "kind" : "search-source" , "limits" : bounds, "generated" : snapshots, "inventory_digest" : digest(inventory_state)}
260
261
262 def verify_search (plan, current, token, * , transport):
263 guard = plan[ "dependency_guard" ]
264 if search_snapshot(plan, current, token, transport = transport, bounds = guard[ "limits" ]) != guard:
265 raise fail( "dependency-drift" , "A generated ETag/definition or scoped dependency inventory changed after approval." )
266
267
268 def _paged (items, bounds):
269 if not callable ( getattr (items, "by_page" , None )):
270 raise fail( "connection-consumer-api-unavailable" , "SDK ItemPaged.by_page is required to prove complete project inventory." )
271 pages = iter (items.by_page())
272 if not hasattr (pages, "continuation_token" ):
273 raise fail( "connection-consumer-api-unavailable" , "SDK page continuation state is required for bounded complete discovery." )
274 count = 0
275 for _ in range (bounds[ "max_pages" ]):
276 try :
277 page = next (pages)
278 except StopIteration :
279 return
280 for item in page:
281 count += 1
282 if count > bounds[ "max_resources" ]:
283 raise fail( "dependency-inventory-limit" , "Project inventory exceeds the approved object bound." )
284 yield item
285 if pages.continuation_token is None :
286 return
287 if count >= bounds[ "max_resources" ]:
288 raise fail( "dependency-inventory-limit" , "More project pages exceed the approved object bound." )
289 raise fail( "dependency-inventory-limit" , "Project inventory exceeds the approved page bound." )
290
291
292 def _connection_use (definition, plan, connection):
293 if definition.get( "kind" ) != "prompt" :
294 raise fail( "connection-consumer-opaque" , "Project inventory contains Hosted/workflow/external consumers; their runtime connection use is not enumerable here." )
295 if set (definition) - { "rai_config" , "kind" , "model" , "instructions" , "temperature" , "top_p" , "reasoning" ,
296 "tools" , "tool_choice" , "text" , "structured_inputs" }:
297 raise fail( "connection-consumer-opaque" , "Unknown Prompt definition fields prevent complete consumer interpretation." )
298 tools = definition.get( "tools" )
299 if not isinstance (tools, list ):
300 raise fail( "connection-consumer-opaque" , "A Prompt version has no complete tool definition." )
301 expected = f " { plan[ 'project_resource_id' ] } /connections/ { connection[ 'name' ] } " .casefold()
302 uses = False
303 for tool in tools:
304 if not isinstance (tool, dict ) or tool.get( "type" ) != "mcp" :
305 raise fail( "connection-consumer-opaque" , "A non-MCP tool needs a verified native connection-consumer contract." )
306 if ( set (tool) - { "type" , "name" , "description" , "tool_configs" , "server_label" , "server_url" , "connector_id" ,
307 "authorization" , "server_description" , "headers" , "allowed_tools" , "require_approval" ,
308 "defer_loading" , "project_connection_id" } or tool.get( "connector_id" ) is not None ):
309 raise fail( "connection-consumer-opaque" , "Unknown tool fields or connector resolution cannot prove project-connection absence." )
310 configurations = tool.get( "tool_configs" )
311 if configurations is not None and (
312 not isinstance (configurations, dict ) or any (
313 not isinstance (value, dict ) or set (value) - { "pin" , "additional_search_text" }
314 or (value.get( "pin" ) is not None and type (value[ "pin" ]) is not bool )
315 or (value.get( "additional_search_text" ) is not None and not isinstance (value[ "additional_search_text" ], str ))
316 for value in configurations.values()
317 )
318 ):
319 raise fail( "connection-consumer-opaque" , "Tool configuration is not the documented visibility/search-only contract." )
320 reference = tool.get( "project_connection_id" )
321 if reference is None :
322 if not isinstance (tool.get( "server_url" ), str ):
323 raise fail( "connection-consumer-opaque" , "An MCP tool lacks an explicit connection or server target." )
324 continue
325 if ( not isinstance (reference, str ) or not reference.strip() or reference != reference.strip()
326 or any (character in reference for character in ( "%" , "?" , "#" , "{" , "}" , " \\ " , " \r " , " \n " , " \t " ))):
327 raise fail( "connection-consumer-opaque" , "An MCP connection reference is malformed." )
328 if "/" in reference:
329 try :
330 from .prompt_connect import PROJECT_ID
331 except ImportError :
332 from prompt_connect import PROJECT_ID
333 project, _, leaf = reference.rstrip( "/" ).casefold().rpartition( "/connections/" )
334 if PROJECT_ID .fullmatch(project) is None or not leaf or "/" in leaf:
335 raise fail( "connection-consumer-opaque" , "An MCP reference is neither a project connection name nor an exact ARM ID." )
336 uses |= reference.rstrip( "/" ).casefold() == expected
337 else :
338 uses |= reference.casefold() == connection[ "name" ].casefold()
339 return uses
340
341
342 def prompt_snapshot (plan, current, * , sdk_loader, bounds, allow_selected = True ):
343 properties = current.get( "properties" )
344 if not isinstance (properties, dict ) or properties.get( "isSharedToAll" ) is not False or properties.get( "sharedUserList" ) not in ( None , []):
345 raise fail( "connection-sharing-unverified" , "Shared/unknown connection scope requires account/external consumer enumeration, not project-only inventory." )
346 AIProjectClient, _, _, _, extras = sdk_loader()
347 AzureCliCredential, AzureError = extras
348 client = AIProjectClient( endpoint = plan[ "project_endpoint" ], credential = AzureCliCredential())
349 selected = plan.get( "agent" )
350 selected_id = (selected[ "name" ], selected[ "version" ]) if selected else None
351 versions, toolboxes, names, identities = [], [], set (), set ()
352 try :
353 if "include_drafts" not in inspect.signature(client.agents.list_versions).parameters:
354 raise fail( "connection-consumer-api-unavailable" , "agents.list_versions(include_drafts=True) is required; this SDK cannot prove draft-consumer absence." )
355 for agent in _paged(client.agents.list(), bounds):
356 name = getattr (agent, "name" , None )
357 if not isinstance (name, str ) or not name or name in names:
358 raise fail( "dependency-inventory-changed" , "Project agent inventory contains missing or duplicate identities." )
359 names.add(name)
360 for item in _paged(client.agents.list_versions( agent_name = name, include_drafts = True ), bounds):
361 version = str ( getattr (item, "version" , "" ))
362 identity = (name, version)
363 if not re.fullmatch( r " [ 1-9 ][ 0-9 ] * " , version) or identity in identities:
364 raise fail( "dependency-inventory-changed" , "Version inventory contains missing or duplicate identities." )
365 identities.add(identity)
366 if len (identities) > bounds[ "max_resources" ]:
367 raise fail( "dependency-inventory-limit" , "Total project versions exceed the approved object bound." )
368 observed = client.agents.get_version( agent_name = name, agent_version = version)
369 definition = observed.definition.as_dict()
370 if observed.name != name or str (observed.version) != version or not isinstance (definition, dict ):
371 raise fail( "dependency-inventory-changed" , "Exact version readback disagrees with project inventory." )
372 uses = _connection_use(definition, plan, plan[ "connection" ])
373 if identity == selected_id and digest(definition) != selected[ "owned_definition_digest" ]:
374 raise fail( "definition-drift" , "The explicitly selected version changed during consumer discovery." )
375 if identity == selected_id and not allow_selected:
376 raise fail( "agent-absence-unverified" , "The selected version must be absent before connection deletion." )
377 if uses and identity != selected_id:
378 raise HelperFailure(
379 "connection-shared-consumer" , "A retained agent version consumes this connection." ,
380 blocked_at = "cleanup-dependencies" , warnings = [ f "consumer: { name } : { version } " ],
381 )
382 versions.append({ "name" : name, "version" : version, "definition_digest" : digest(definition)})
383 names, identities = set (), set ()
384 for toolbox in _paged(client.toolboxes.list(), bounds):
385 name = getattr (toolbox, "name" , None )
386 if not isinstance (name, str ) or not name.strip() or name in names:
387 raise fail( "dependency-inventory-changed" , "Toolbox inventory contains missing or duplicate names." )
388 names.add(name)
389 for item in _paged(client.toolboxes.list_versions( name = name), bounds):
390 version = getattr (item, "version" , None )
391 identity = (name, version)
392 if not isinstance (version, str ) or not version.strip() or identity in identities:
393 raise fail( "dependency-inventory-changed" , "Toolbox version inventory is incomplete or duplicated." )
394 identities.add(identity)
395 if len (versions) + len (identities) > bounds[ "max_resources" ]:
396 raise fail( "dependency-inventory-limit" , "Combined agent/toolbox versions exceed approved bounds." )
397 observed = client.toolboxes.get_version( name = name, version = version)
398 definition = observed.as_dict()
399 if ( not isinstance (definition, dict ) or definition.get( "name" ) != name
400 or definition.get( "version" ) != version):
401 raise fail( "dependency-inventory-changed" , "Exact toolbox version disagrees with inventory." )
402 if ( set (definition) - { "metadata" , "id" , "name" , "version" , "description" , "created_at" , "tools" , "skills" , "policies" }
403 or definition.get( "skills" ) not in ( None , []) or definition.get( "policies" ) not in ( None , {})):
404 raise fail( "connection-consumer-opaque" , "Toolbox skills/policies have no complete connection-consumer contract." )
405 if _connection_use({ "kind" : "prompt" , "tools" : definition.get( "tools" )}, plan, plan[ "connection" ]):
406 raise HelperFailure(
407 "connection-shared-consumer" , "A retained toolbox version consumes this connection." ,
408 blocked_at = "cleanup-dependencies" , warnings = [ f "toolbox-consumer: { name } : { version } " ],
409 )
410 toolboxes.append({ "name" : name, "version" : version, "definition_digest" : digest(definition)})
411 except AzureError as exc:
412 raise HelperFailure( message = "Complete project agent/version GET inventory failed; absence is unproven." ,
413 blocked_at = "cleanup-dependencies" , ** sdk_error_metadata(exc, "connection-inventory-failed" )) from exc
414 except ( AttributeError , TypeError , ValueError ) as exc:
415 raise fail( "connection-consumer-api-unavailable" , "The installed SDK lacks complete typed agent/version inventory readback." ) from exc
416 finally :
417 client.close()
418 return { "kind" : "prompt-connection" , "limits" : bounds,
419 "versions" : sorted (versions, key =lambda item: (item[ "name" ], item[ "version" ])),
420 "toolboxes" : sorted (toolboxes, key =lambda item: (item[ "name" ], item[ "version" ]))}
421
422
423 def verify_prompt (plan, current, * , sdk_loader, allow_selected = True ):
424 connection = plan[ "connection" ]
425 if (digest(current) != connection[ "owned_definition_digest" ]
426 or (current.get( "etag" ) or current.get( "@odata.etag" )) != connection[ "expected_etag" ]):
427 raise fail( "definition-drift" , "The connection definition or ETag changed after cleanup approval." )
428 guard = plan[ "dependency_guard" ]
429 actual = prompt_snapshot(plan, current, sdk_loader = sdk_loader, bounds = guard[ "limits" ], allow_selected = allow_selected)
430 expected_versions = guard[ "versions" ]
431 selected = plan.get( "agent" )
432 if selected and not any (item[ "name" ] == selected[ "name" ] and item[ "version" ] == selected[ "version" ] for item in actual[ "versions" ]):
433 expected_versions = [item for item in expected_versions if (item[ "name" ], item[ "version" ]) != (selected[ "name" ], selected[ "version" ])]
434 if actual[ "versions" ] != expected_versions or actual[ "toolboxes" ] != guard[ "toolboxes" ]:
435 raise fail( "dependency-drift" , "Protected project agent/version definitions changed after cleanup approval." )