Skip to content

Commit ef1bbed

Browse files
Fix(cv_deploy): Properly clean-up unused AVD-managed container assignments (#6650)
Co-authored-by: Guillaume Mulocher <gmulocher@arista.com>
1 parent 753c30e commit ef1bbed

4 files changed

Lines changed: 72 additions & 39 deletions

File tree

ansible_collections/arista/avd/plugins/action/cv_workflow.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ async def deploy(self, validated_args: dict, result: dict) -> dict:
237237
result_object.deployed_interface_tags,
238238
result_object.deployed_cv_pathfinder_metadata,
239239
result_object.removed_configs,
240-
result_object.removed_static_config_root_containers,
240+
result_object.removed_static_config_containers,
241241
result_object.removed_static_config_configlets,
242242
result_object.removed_device_tags,
243243
result_object.removed_interface_tags,

python-avd/pyavd/_cv/workflows/deploy_static_config_studio_manifest_to_cv.py

Lines changed: 21 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,8 @@ async def deploy_static_config_studio_manifest_to_cv(manifest: AvdManifest, depl
4444

4545
# Perform synchronization tasks.
4646
await _sync_configlets(cv_manifest=cv_manifest, deployment_result=deployment_result, cv_client=cv_client)
47-
existing_containers_by_id = await _sync_containers(cv_manifest=cv_manifest, deployment_result=deployment_result, cv_client=cv_client)
48-
await _sync_studio_roots(
49-
cv_manifest=cv_manifest,
50-
deployment_result=deployment_result,
51-
cv_client=cv_client,
52-
existing_containers_by_id=existing_containers_by_id,
53-
)
47+
await _sync_containers(cv_manifest=cv_manifest, deployment_result=deployment_result, cv_client=cv_client)
48+
await _sync_studio_roots(cv_manifest=cv_manifest, deployment_result=deployment_result, cv_client=cv_client)
5449

5550
# Done.
5651
LOGGER.info("deploy_static_config_studio_manifest_to_cv: Completed manifest deployment for workspace '%s'.", workspace_id)
@@ -83,8 +78,21 @@ async def _sync_containers(cv_manifest: CVManifest, deployment_result: DeployToC
8378
else:
8479
LOGGER.info("deploy_static_config_studio_manifest_to_cv: No container creations or updates are needed.")
8580

86-
# Return all existing containers from CloudVision to be further processed if needed.
87-
return existing_containers_by_id
81+
# Delete unused AVD-managed containers.
82+
desired_container_ids = {container.id for container in cv_manifest.containers}
83+
containers_to_delete = {
84+
container_id: cast("str", container.display_name)
85+
for container_id, container in existing_containers_by_id.items()
86+
if container_id.startswith(AVD_ENTITY_PREFIX) and container_id not in desired_container_ids
87+
}
88+
89+
if containers_to_delete:
90+
LOGGER.info("deploy_static_config_studio_manifest_to_cv: Removing %d AVD-managed containers which are no longer used.", len(containers_to_delete))
91+
deployment_result.removed_static_config_containers.extend(containers_to_delete.values())
92+
# TODO: Build a 'delete_configlet_containers' gRPC API
93+
await gather(*[cv_client.delete_configlet_container(workspace_id=workspace_id, assignment_id=container_id) for container_id in containers_to_delete])
94+
else:
95+
LOGGER.info("deploy_static_config_studio_manifest_to_cv: No AVD-managed container deletions are needed.")
8896

8997

9098
async def _sync_configlets(cv_manifest: CVManifest, deployment_result: DeployToCvResult, cv_client: CVClient) -> None:
@@ -117,11 +125,9 @@ async def _sync_configlets(cv_manifest: CVManifest, deployment_result: DeployToC
117125
LOGGER.info("deploy_static_config_studio_manifest_to_cv: No AVD-managed configlet deletions are needed.")
118126

119127

120-
async def _sync_studio_roots(
121-
cv_manifest: CVManifest, deployment_result: DeployToCvResult, cv_client: CVClient, existing_containers_by_id: dict[str, ConfigletAssignment]
122-
) -> None:
128+
async def _sync_studio_roots(cv_manifest: CVManifest, deployment_result: DeployToCvResult, cv_client: CVClient) -> None:
123129
"""
124-
Synchronize Studio root containers. Update root container assignments and delete unused AVD-managed ones.
130+
Synchronize Studio root containers. Update root container assignments.
125131
126132
Note:
127133
During an update, this function reorders root containers. All AVD-managed
@@ -139,17 +145,14 @@ async def _sync_studio_roots(
139145
default_value=[],
140146
)
141147

142-
# Calculate which desired roots are missing and which existing AVD-managed roots are stale.
148+
# Calculate which desired roots are missing.
143149
desired_root_ids = [container.id for container in cv_manifest.containers if container.is_root]
144150
desired_root_ids_set = set(desired_root_ids)
145151
existing_root_ids_set = set(existing_root_ids)
146152
missing_ids = desired_root_ids_set - existing_root_ids_set
147-
stale_avd_ids = {
148-
container_id for container_id in existing_root_ids_set if container_id.startswith(AVD_ENTITY_PREFIX) and container_id not in desired_root_ids_set
149-
}
150153

151154
# Update the Studio root container list if necessary, preserving any manually added (non-AVD) root containers.
152-
if missing_ids or stale_avd_ids:
155+
if missing_ids:
153156
LOGGER.info("deploy_static_config_studio_manifest_to_cv: Updating Studio root container assignment list...")
154157
manual_ids = [container_id for container_id in existing_root_ids if not container_id.startswith(AVD_ENTITY_PREFIX)]
155158
new_ordered_ids = desired_root_ids + manual_ids
@@ -162,19 +165,3 @@ async def _sync_studio_roots(
162165
)
163166
else:
164167
LOGGER.info("deploy_static_config_studio_manifest_to_cv: Studio root container assignments are already in the desired state.")
165-
166-
# Delete stale AVD-managed root containers that are no longer needed.
167-
if stale_avd_ids:
168-
LOGGER.info("deploy_static_config_studio_manifest_to_cv: Removing %d stale AVD-managed root containers...", len(stale_avd_ids))
169-
deployment_result.removed_static_config_root_containers.extend(
170-
[
171-
cast("str", existing_container.display_name)
172-
for container_id in stale_avd_ids
173-
if (existing_container := existing_containers_by_id.get(container_id)) is not None
174-
]
175-
)
176-
177-
# TODO: Build a 'delete_configlet_containers' gRPC API
178-
await gather(*[cv_client.delete_configlet_container(workspace_id=workspace_id, assignment_id=container_id) for container_id in stale_avd_ids])
179-
else:
180-
LOGGER.info("deploy_static_config_studio_manifest_to_cv: No AVD-managed root container deletions are needed.")

python-avd/pyavd/_cv/workflows/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ class DeployToCvResult:
187187
skipped_interface_tags: list[CVInterfaceTag] = field(default_factory=list)
188188
skipped_cv_pathfinder_metadata: list[CVPathfinderMetadata] = field(default_factory=list)
189189
removed_configs: list[str] = field(default_factory=list)
190-
removed_static_config_root_containers: list[str] = field(default_factory=list)
190+
removed_static_config_containers: list[str] = field(default_factory=list)
191191
removed_static_config_configlets: list[str] = field(default_factory=list)
192192
removed_device_tags: list[CVDeviceTag] = field(default_factory=list)
193193
removed_interface_tags: list[CVInterfaceTag] = field(default_factory=list)

python-avd/tests/pyavd/cv/workflows/test_deploy_static_config_studio_manifest_to_cv.py

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ async def test_initial_deployment(self, mock_cv_client: MagicMock, avd_initial_m
9898
assert len(deployment_result.deployed_static_config_containers) == 3
9999
assert not deployment_result.skipped_static_config_containers
100100
assert not deployment_result.removed_static_config_configlets
101-
assert not deployment_result.removed_static_config_root_containers
101+
assert not deployment_result.removed_static_config_containers
102102

103103
async def test_no_changes_run(self, mock_cv_client: MagicMock, avd_initial_manifest: AvdManifest, deployment_result: DeployToCvResult) -> None:
104104
"""Test a subsequent run where the AVD manifest has not changed."""
@@ -228,7 +228,7 @@ async def test_updates_and_removals(self, mock_cv_client: MagicMock, deployment_
228228
assert len(deployment_result.deployed_static_config_containers) == 2
229229
assert len(deployment_result.skipped_static_config_containers) == 2 # ROOT and CNT_LEAF2 were skipped
230230
assert deployment_result.removed_static_config_configlets == ["CF_UNUSED"]
231-
assert deployment_result.removed_static_config_root_containers == ["UNUSED_ROOT"]
231+
assert deployment_result.removed_static_config_containers == ["UNUSED_ROOT"]
232232

233233
async def test_root_container_reordering_and_manual_preservation(self, mock_cv_client: MagicMock, deployment_result: DeployToCvResult) -> None:
234234
"""Test reordering AVD root containers, deleting a stale one and preserving a manually-added root container."""
@@ -277,4 +277,50 @@ async def test_root_container_reordering_and_manual_preservation(self, mock_cv_c
277277
# Verify deployment result object.
278278
assert len(deployment_result.deployed_static_config_containers) == 1
279279
assert len(deployment_result.skipped_static_config_containers) == 1 # AVD_ROOT2 was skipped
280-
assert deployment_result.removed_static_config_root_containers == ["AVD_ROOT1"]
280+
assert deployment_result.removed_static_config_containers == ["AVD_ROOT1"]
281+
282+
async def test_non_root_container_deletion(self, mock_cv_client: MagicMock, deployment_result: DeployToCvResult) -> None:
283+
"""Test that non-root (child) containers are properly deleted when removed from the manifest."""
284+
# Initial state on CV.
285+
cf_leaf1_id = generate_id("CF_LEAF1")
286+
root_id, cnt_leaf1_id, cnt_leaf2_id = generate_id("ROOT"), generate_id("ROOT/CNT_LEAF1"), generate_id("ROOT/CNT_LEAF2")
287+
288+
existing_containers = [
289+
create_grpc_container(container_id=root_id, name="ROOT", description="Root container", query="device:*", child_ids=[cnt_leaf1_id, cnt_leaf2_id]),
290+
create_grpc_container(
291+
container_id=cnt_leaf1_id, name="CNT_LEAF1", description="LEAF1 container", query="device:LEAF1", configlet_ids=[cf_leaf1_id]
292+
),
293+
create_grpc_container(container_id=cnt_leaf2_id, name="CNT_LEAF2", description="LEAF2 container", query="device:LEAF2"),
294+
]
295+
296+
mock_cv_client.get_configlet_containers.return_value = existing_containers
297+
mock_cv_client.get_configlets.return_value = []
298+
mock_cv_client.get_studio_inputs_with_path.return_value = [root_id]
299+
300+
# New desired state from AVD: ROOT with only CNT_LEAF1, CNT_LEAF2 is removed from the manifest.
301+
cfl1 = AvdConfiglet(name="CF_LEAF1", file=Path("/path/to/cfl1.cfg"))
302+
cnt_leaf1 = AvdContainer(name="CNT_LEAF1", tag_query="device:LEAF1", description="LEAF1 container", configlets=(cfl1.name,))
303+
root_container = AvdContainer(name="ROOT", tag_query="device:*", description="Root container", sub_containers=(cnt_leaf1,))
304+
305+
updated_manifest = AvdManifest(configlets=(cfl1,), containers=(root_container,))
306+
307+
await deploy_static_config_studio_manifest_to_cv(updated_manifest, deployment_result, mock_cv_client)
308+
309+
# Verify the non-root child container was deleted.
310+
mock_cv_client.delete_configlet_container.assert_called_once_with(workspace_id=deployment_result.workspace.id, assignment_id=cnt_leaf2_id)
311+
312+
# Verify ROOT container was updated because child_ids changed.
313+
mock_cv_client.set_configlet_containers.assert_called_once()
314+
pushed_containers = mock_cv_client.set_configlet_containers.call_args[1]["containers"]
315+
assert len(pushed_containers) == 1
316+
assert pushed_containers[0][1] == "ROOT"
317+
318+
# Studio roots should NOT be updated as they haven't changed.
319+
mock_cv_client.set_studio_inputs.assert_not_called()
320+
321+
# Verify deployment result object.
322+
assert len(deployment_result.deployed_static_config_configlets) == 1
323+
assert len(deployment_result.deployed_static_config_containers) == 1 # ROOT was updated
324+
assert len(deployment_result.skipped_static_config_containers) == 1 # CNT_LEAF1 was skipped
325+
assert deployment_result.removed_static_config_containers == ["CNT_LEAF2"]
326+
assert not deployment_result.removed_static_config_configlets

0 commit comments

Comments
 (0)