wxcli 1.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wxcli/__init__.py +4 -0
- wxcli/_playbook/.claude/agents/migration-advisor.md +239 -0
- wxcli/_playbook/.claude/agents/wxc-calling-builder.md +946 -0
- wxcli/_playbook/.claude/rules/cleanup.md +29 -0
- wxcli/_playbook/.claude/rules/cucm-migration.md +58 -0
- wxcli/_playbook/.claude/rules/org-health.md +14 -0
- wxcli/_playbook/.claude/settings.json +8 -0
- wxcli/_playbook/.claude/skills/audit-compliance/SKILL.md +515 -0
- wxcli/_playbook/.claude/skills/call-control/SKILL.md +747 -0
- wxcli/_playbook/.claude/skills/configure-features/SKILL.md +623 -0
- wxcli/_playbook/.claude/skills/configure-routing/SKILL.md +627 -0
- wxcli/_playbook/.claude/skills/contact-center/SKILL.md +1181 -0
- wxcli/_playbook/.claude/skills/cucm-migrate/SKILL.md +820 -0
- wxcli/_playbook/.claude/skills/customer-assist/SKILL.md +443 -0
- wxcli/_playbook/.claude/skills/device-platform/SKILL.md +407 -0
- wxcli/_playbook/.claude/skills/manage-call-settings/SKILL.md +757 -0
- wxcli/_playbook/.claude/skills/manage-devices/SKILL.md +790 -0
- wxcli/_playbook/.claude/skills/manage-identity/SKILL.md +638 -0
- wxcli/_playbook/.claude/skills/manage-licensing/SKILL.md +528 -0
- wxcli/_playbook/.claude/skills/manage-meetings/SKILL.md +708 -0
- wxcli/_playbook/.claude/skills/messaging-bots/SKILL.md +365 -0
- wxcli/_playbook/.claude/skills/messaging-spaces/SKILL.md +520 -0
- wxcli/_playbook/.claude/skills/org-health/SKILL.md +159 -0
- wxcli/_playbook/.claude/skills/provision-calling/SKILL.md +421 -0
- wxcli/_playbook/.claude/skills/query-live/SKILL.md +183 -0
- wxcli/_playbook/.claude/skills/query-live/domains/call-flow-trace.md +223 -0
- wxcli/_playbook/.claude/skills/query-live/domains/call-history.md +44 -0
- wxcli/_playbook/.claude/skills/query-live/domains/features.md +169 -0
- wxcli/_playbook/.claude/skills/query-live/domains/numbers.md +93 -0
- wxcli/_playbook/.claude/skills/query-live/domains/people-and-settings.md +146 -0
- wxcli/_playbook/.claude/skills/query-live/domains/routing.md +124 -0
- wxcli/_playbook/.claude/skills/reporting/SKILL.md +501 -0
- wxcli/_playbook/.claude/skills/reporting/references/cdr-recipes.md +1628 -0
- wxcli/_playbook/.claude/skills/reporting-cc/SKILL.md +251 -0
- wxcli/_playbook/.claude/skills/reporting-meetings/SKILL.md +290 -0
- wxcli/_playbook/.claude/skills/teardown/SKILL.md +214 -0
- wxcli/_playbook/.claude/skills/video-mesh/SKILL.md +379 -0
- wxcli/_playbook/.claude/skills/wxc-calling-debug/SKILL.md +475 -0
- wxcli/_playbook/CLAUDE.md +373 -0
- wxcli/_playbook/docs/reference/CLAUDE.md +25 -0
- wxcli/_playbook/docs/reference/admin-apps-data.md +643 -0
- wxcli/_playbook/docs/reference/admin-audit-security.md +322 -0
- wxcli/_playbook/docs/reference/admin-hybrid.md +383 -0
- wxcli/_playbook/docs/reference/admin-identity-scim.md +1007 -0
- wxcli/_playbook/docs/reference/admin-licensing.md +414 -0
- wxcli/_playbook/docs/reference/admin-org-management.md +586 -0
- wxcli/_playbook/docs/reference/admin-partner.md +357 -0
- wxcli/_playbook/docs/reference/archive/wxc-sdk-patterns.md +1229 -0
- wxcli/_playbook/docs/reference/archive/wxcadm-advanced.md +835 -0
- wxcli/_playbook/docs/reference/archive/wxcadm-core.md +743 -0
- wxcli/_playbook/docs/reference/archive/wxcadm-devices-workspaces.md +1305 -0
- wxcli/_playbook/docs/reference/archive/wxcadm-features.md +849 -0
- wxcli/_playbook/docs/reference/archive/wxcadm-locations.md +938 -0
- wxcli/_playbook/docs/reference/archive/wxcadm-person.md +1255 -0
- wxcli/_playbook/docs/reference/archive/wxcadm-routing.md +1262 -0
- wxcli/_playbook/docs/reference/authentication.md +965 -0
- wxcli/_playbook/docs/reference/call-control.md +1205 -0
- wxcli/_playbook/docs/reference/call-features-additional.md +2541 -0
- wxcli/_playbook/docs/reference/call-features-major.md +1320 -0
- wxcli/_playbook/docs/reference/call-routing.md +2304 -0
- wxcli/_playbook/docs/reference/contact-center-analytics.md +1049 -0
- wxcli/_playbook/docs/reference/contact-center-core.md +1614 -0
- wxcli/_playbook/docs/reference/contact-center-journey.md +457 -0
- wxcli/_playbook/docs/reference/contact-center-routing.md +1138 -0
- wxcli/_playbook/docs/reference/devices-core.md +1869 -0
- wxcli/_playbook/docs/reference/devices-dect.md +1150 -0
- wxcli/_playbook/docs/reference/devices-platform.md +467 -0
- wxcli/_playbook/docs/reference/devices-workspaces.md +1426 -0
- wxcli/_playbook/docs/reference/emergency-services.md +1122 -0
- wxcli/_playbook/docs/reference/location-calling-core.md +2037 -0
- wxcli/_playbook/docs/reference/location-calling-media.md +1334 -0
- wxcli/_playbook/docs/reference/location-recording-advanced.md +1482 -0
- wxcli/_playbook/docs/reference/meetings-content.md +473 -0
- wxcli/_playbook/docs/reference/meetings-core.md +903 -0
- wxcli/_playbook/docs/reference/meetings-infrastructure.md +899 -0
- wxcli/_playbook/docs/reference/meetings-settings.md +850 -0
- wxcli/_playbook/docs/reference/messaging-bots.md +1091 -0
- wxcli/_playbook/docs/reference/messaging-spaces.md +695 -0
- wxcli/_playbook/docs/reference/person-call-settings-behavior.md +1639 -0
- wxcli/_playbook/docs/reference/person-call-settings-handling.md +1394 -0
- wxcli/_playbook/docs/reference/person-call-settings-media.md +1898 -0
- wxcli/_playbook/docs/reference/person-call-settings-permissions.md +1329 -0
- wxcli/_playbook/docs/reference/provisioning.md +1366 -0
- wxcli/_playbook/docs/reference/reporting-analytics.md +1318 -0
- wxcli/_playbook/docs/reference/self-service-call-settings.md +1020 -0
- wxcli/_playbook/docs/reference/virtual-lines.md +1132 -0
- wxcli/_playbook/docs/reference/webhooks-events.md +1444 -0
- wxcli/_playbook/docs/reference/wxcadm-xsi-realtime.md +953 -0
- wxcli/_version.py +24 -0
- wxcli/auth.py +137 -0
- wxcli/commands/__init__.py +0 -0
- wxcli/commands/_registry.py +181 -0
- wxcli/commands/activation_email.py +103 -0
- wxcli/commands/admin_recordings.py +585 -0
- wxcli/commands/analytics.py +105 -0
- wxcli/commands/announcement_playlists.py +219 -0
- wxcli/commands/announcements.py +519 -0
- wxcli/commands/archive_users.py +36 -0
- wxcli/commands/attachment_actions.py +60 -0
- wxcli/commands/audit_events.py +83 -0
- wxcli/commands/authorizations.py +113 -0
- wxcli/commands/auto_attendant.py +642 -0
- wxcli/commands/broadworks_billing_reports.py +126 -0
- wxcli/commands/broadworks_enterprises.py +160 -0
- wxcli/commands/broadworks_subscribers.py +319 -0
- wxcli/commands/broadworks_workspaces.py +108 -0
- wxcli/commands/call_controls.py +1304 -0
- wxcli/commands/call_park.py +494 -0
- wxcli/commands/call_pickup.py +229 -0
- wxcli/commands/call_queue.py +1878 -0
- wxcli/commands/call_recording.py +707 -0
- wxcli/commands/call_routing.py +1610 -0
- wxcli/commands/call_settings_for_me_phase_5.py +212 -0
- wxcli/commands/caller_reputation.py +169 -0
- wxcli/commands/calling_service.py +305 -0
- wxcli/commands/cc_address_book.py +770 -0
- wxcli/commands/cc_agent_greetings.py +242 -0
- wxcli/commands/cc_agent_summaries.py +39 -0
- wxcli/commands/cc_agent_wellbeing.py +216 -0
- wxcli/commands/cc_agents.py +425 -0
- wxcli/commands/cc_ai_assistant.py +55 -0
- wxcli/commands/cc_ai_feature.py +294 -0
- wxcli/commands/cc_audio_files.py +149 -0
- wxcli/commands/cc_auto_csat.py +311 -0
- wxcli/commands/cc_aux_code.py +458 -0
- wxcli/commands/cc_business_hour.py +353 -0
- wxcli/commands/cc_call_monitoring.py +228 -0
- wxcli/commands/cc_callbacks.py +194 -0
- wxcli/commands/cc_campaign.py +212 -0
- wxcli/commands/cc_captures.py +39 -0
- wxcli/commands/cc_contact_list.py +169 -0
- wxcli/commands/cc_contact_number.py +295 -0
- wxcli/commands/cc_data_sources.py +235 -0
- wxcli/commands/cc_desktop_layout.py +380 -0
- wxcli/commands/cc_desktop_profile.py +322 -0
- wxcli/commands/cc_dial_number.py +511 -0
- wxcli/commands/cc_dial_plan.py +347 -0
- wxcli/commands/cc_dnc.py +101 -0
- wxcli/commands/cc_entry_point.py +545 -0
- wxcli/commands/cc_ewt.py +51 -0
- wxcli/commands/cc_flow.py +477 -0
- wxcli/commands/cc_global_vars.py +430 -0
- wxcli/commands/cc_holiday_list.py +335 -0
- wxcli/commands/cc_journey.py +1261 -0
- wxcli/commands/cc_legacy_flows.py +49 -0
- wxcli/commands/cc_multimedia_profile.py +436 -0
- wxcli/commands/cc_notification.py +51 -0
- wxcli/commands/cc_outdial_ani.py +604 -0
- wxcli/commands/cc_overrides.py +341 -0
- wxcli/commands/cc_queue.py +1083 -0
- wxcli/commands/cc_queue_stats.py +55 -0
- wxcli/commands/cc_realtime.py +51 -0
- wxcli/commands/cc_resource_collection.py +312 -0
- wxcli/commands/cc_search.py +46 -0
- wxcli/commands/cc_site.py +400 -0
- wxcli/commands/cc_skill.py +350 -0
- wxcli/commands/cc_skill_profile.py +289 -0
- wxcli/commands/cc_subscriptions.py +426 -0
- wxcli/commands/cc_summaries.py +130 -0
- wxcli/commands/cc_tasks.py +844 -0
- wxcli/commands/cc_team.py +325 -0
- wxcli/commands/cc_user_profiles.py +349 -0
- wxcli/commands/cc_users.py +701 -0
- wxcli/commands/cc_work_types.py +363 -0
- wxcli/commands/cdr.py +89 -0
- wxcli/commands/classifications.py +38 -0
- wxcli/commands/cleanup.py +1427 -0
- wxcli/commands/client_settings.py +72 -0
- wxcli/commands/conference.py +328 -0
- wxcli/commands/configure.py +72 -0
- wxcli/commands/converged_recordings.py +347 -0
- wxcli/commands/converged_recordings_export.py +310 -0
- wxcli/commands/cq_playlists.py +42 -0
- wxcli/commands/cucm.py +2794 -0
- wxcli/commands/cucm_config.py +129 -0
- wxcli/commands/customer_assist.py +373 -0
- wxcli/commands/data_sources.py +227 -0
- wxcli/commands/dect_devices.py +787 -0
- wxcli/commands/device_configurations.py +77 -0
- wxcli/commands/device_dynamic_settings.py +320 -0
- wxcli/commands/device_settings.py +1489 -0
- wxcli/commands/devices.py +280 -0
- wxcli/commands/domains.py +142 -0
- wxcli/commands/ecm.py +178 -0
- wxcli/commands/emergency_services.py +827 -0
- wxcli/commands/events.py +83 -0
- wxcli/commands/external_voicemail.py +52 -0
- wxcli/commands/groups.py +206 -0
- wxcli/commands/guest_management.py +76 -0
- wxcli/commands/hds.py +340 -0
- wxcli/commands/hot_desk.py +67 -0
- wxcli/commands/hot_desking_members.py +118 -0
- wxcli/commands/hot_desking_portal.py +124 -0
- wxcli/commands/hunt_group.py +577 -0
- wxcli/commands/hybrid_clusters.py +71 -0
- wxcli/commands/hybrid_connectors.py +67 -0
- wxcli/commands/identity_org.py +159 -0
- wxcli/commands/init_playbook.py +155 -0
- wxcli/commands/licenses.py +106 -0
- wxcli/commands/live_monitoring.py +40 -0
- wxcli/commands/location_call_handling.py +577 -0
- wxcli/commands/location_schedules.py +341 -0
- wxcli/commands/location_settings.py +1297 -0
- wxcli/commands/location_voicemail.py +494 -0
- wxcli/commands/locations.py +336 -0
- wxcli/commands/meeting_captions.py +110 -0
- wxcli/commands/meeting_chats.py +63 -0
- wxcli/commands/meeting_invitees.py +234 -0
- wxcli/commands/meeting_messages.py +28 -0
- wxcli/commands/meeting_participants.py +278 -0
- wxcli/commands/meeting_polls.py +111 -0
- wxcli/commands/meeting_preferences.py +515 -0
- wxcli/commands/meeting_qa.py +78 -0
- wxcli/commands/meeting_qualities.py +43 -0
- wxcli/commands/meeting_reports.py +101 -0
- wxcli/commands/meeting_session_types.py +105 -0
- wxcli/commands/meeting_site.py +57 -0
- wxcli/commands/meeting_slido.py +44 -0
- wxcli/commands/meeting_summaries.py +92 -0
- wxcli/commands/meeting_tracking_codes.py +233 -0
- wxcli/commands/meeting_transcripts.py +230 -0
- wxcli/commands/meetings.py +1997 -0
- wxcli/commands/memberships.py +164 -0
- wxcli/commands/messages.py +213 -0
- wxcli/commands/mode_management.py +244 -0
- wxcli/commands/my_call_settings.py +3577 -0
- wxcli/commands/numbers.py +405 -0
- wxcli/commands/operating_modes.py +410 -0
- wxcli/commands/org_contacts.py +284 -0
- wxcli/commands/org_health_cli.py +55 -0
- wxcli/commands/org_settings.py +71 -0
- wxcli/commands/organizations.py +83 -0
- wxcli/commands/paging_group.py +257 -0
- wxcli/commands/partner_admins.py +141 -0
- wxcli/commands/partner_reports.py +188 -0
- wxcli/commands/partner_tags.py +216 -0
- wxcli/commands/people.py +279 -0
- wxcli/commands/person_call_settings.py +71 -0
- wxcli/commands/pstn.py +280 -0
- wxcli/commands/recording_report.py +154 -0
- wxcli/commands/report_templates.py +38 -0
- wxcli/commands/reports.py +144 -0
- wxcli/commands/resource_group_memberships.py +160 -0
- wxcli/commands/resource_groups.py +67 -0
- wxcli/commands/roles.py +63 -0
- wxcli/commands/room_tabs.py +160 -0
- wxcli/commands/rooms.py +225 -0
- wxcli/commands/scim_bulk.py +46 -0
- wxcli/commands/scim_groups.py +246 -0
- wxcli/commands/scim_schemas.py +82 -0
- wxcli/commands/scim_users.py +283 -0
- wxcli/commands/security_audit.py +54 -0
- wxcli/commands/service_apps.py +51 -0
- wxcli/commands/single_number_reach.py +209 -0
- wxcli/commands/team_memberships.py +155 -0
- wxcli/commands/teams.py +153 -0
- wxcli/commands/ucm_profile.py +42 -0
- wxcli/commands/update.py +219 -0
- wxcli/commands/user_settings.py +5116 -0
- wxcli/commands/video_mesh.py +1090 -0
- wxcli/commands/virtual_extensions.py +502 -0
- wxcli/commands/virtual_line_settings.py +2072 -0
- wxcli/commands/webhooks.py +176 -0
- wxcli/commands/wholesale_billing_reports.py +147 -0
- wxcli/commands/wholesale_provisioning.py +523 -0
- wxcli/commands/workspace_locations.py +339 -0
- wxcli/commands/workspace_metrics.py +103 -0
- wxcli/commands/workspace_personalization.py +70 -0
- wxcli/commands/workspace_settings.py +3347 -0
- wxcli/commands/workspaces.py +279 -0
- wxcli/commands/xapi.py +114 -0
- wxcli/config.py +139 -0
- wxcli/errors.py +77 -0
- wxcli/main.py +210 -0
- wxcli/migration/CLAUDE.md +106 -0
- wxcli/migration/__init__.py +1 -0
- wxcli/migration/advisory/CLAUDE.md +196 -0
- wxcli/migration/advisory/__init__.py +46 -0
- wxcli/migration/advisory/advisor.py +107 -0
- wxcli/migration/advisory/advisory_patterns.py +2552 -0
- wxcli/migration/advisory/recommendation_rules.py +835 -0
- wxcli/migration/cucm/CLAUDE.md +74 -0
- wxcli/migration/cucm/__init__.py +5 -0
- wxcli/migration/cucm/connection.py +265 -0
- wxcli/migration/cucm/discovery.py +225 -0
- wxcli/migration/cucm/extractors/__init__.py +1 -0
- wxcli/migration/cucm/extractors/announcements.py +92 -0
- wxcli/migration/cucm/extractors/base.py +90 -0
- wxcli/migration/cucm/extractors/device_profiles.py +98 -0
- wxcli/migration/cucm/extractors/devices.py +193 -0
- wxcli/migration/cucm/extractors/e911.py +103 -0
- wxcli/migration/cucm/extractors/features.py +309 -0
- wxcli/migration/cucm/extractors/helpers.py +73 -0
- wxcli/migration/cucm/extractors/informational.py +295 -0
- wxcli/migration/cucm/extractors/locations.py +221 -0
- wxcli/migration/cucm/extractors/moh.py +100 -0
- wxcli/migration/cucm/extractors/remote_destinations.py +96 -0
- wxcli/migration/cucm/extractors/routing.py +493 -0
- wxcli/migration/cucm/extractors/shared_lines.py +146 -0
- wxcli/migration/cucm/extractors/templates.py +229 -0
- wxcli/migration/cucm/extractors/tier4.py +173 -0
- wxcli/migration/cucm/extractors/users.py +240 -0
- wxcli/migration/cucm/extractors/voicemail.py +243 -0
- wxcli/migration/cucm/extractors/workspaces.py +88 -0
- wxcli/migration/cucm/unity_connection.py +304 -0
- wxcli/migration/execute/CLAUDE.md +372 -0
- wxcli/migration/execute/__init__.py +303 -0
- wxcli/migration/execute/batch.py +277 -0
- wxcli/migration/execute/dependency.py +483 -0
- wxcli/migration/execute/engine.py +964 -0
- wxcli/migration/execute/handlers.py +2232 -0
- wxcli/migration/execute/planner.py +1939 -0
- wxcli/migration/execute/runtime.py +571 -0
- wxcli/migration/export/CLAUDE.md +49 -0
- wxcli/migration/export/__init__.py +8 -0
- wxcli/migration/export/csv_export.py +126 -0
- wxcli/migration/export/deployment_plan.py +479 -0
- wxcli/migration/export/json_export.py +60 -0
- wxcli/migration/models.py +941 -0
- wxcli/migration/phone_models.py +50 -0
- wxcli/migration/preflight/CLAUDE.md +52 -0
- wxcli/migration/preflight/__init__.py +89 -0
- wxcli/migration/preflight/checks.py +911 -0
- wxcli/migration/preflight/runner.py +293 -0
- wxcli/migration/rate_limiter.py +144 -0
- wxcli/migration/report/CLAUDE.md +245 -0
- wxcli/migration/report/__init__.py +7 -0
- wxcli/migration/report/appendix.py +2437 -0
- wxcli/migration/report/assembler.py +183 -0
- wxcli/migration/report/charts.py +263 -0
- wxcli/migration/report/executive.py +776 -0
- wxcli/migration/report/explainer.py +704 -0
- wxcli/migration/report/helpers.py +74 -0
- wxcli/migration/report/ingest.py +152 -0
- wxcli/migration/report/notice_templates.py +192 -0
- wxcli/migration/report/score.py +375 -0
- wxcli/migration/report/styles.py +1052 -0
- wxcli/migration/report/user_diff.py +1052 -0
- wxcli/migration/report/user_notice.py +425 -0
- wxcli/migration/state.py +139 -0
- wxcli/migration/store.py +860 -0
- wxcli/migration/transform/CLAUDE.md +107 -0
- wxcli/migration/transform/__init__.py +12 -0
- wxcli/migration/transform/analysis_pipeline.py +453 -0
- wxcli/migration/transform/analyzers/__init__.py +142 -0
- wxcli/migration/transform/analyzers/css_permission.py +189 -0
- wxcli/migration/transform/analyzers/css_routing.py +335 -0
- wxcli/migration/transform/analyzers/device_compatibility.py +122 -0
- wxcli/migration/transform/analyzers/dn_ambiguity.py +131 -0
- wxcli/migration/transform/analyzers/duplicate_user.py +221 -0
- wxcli/migration/transform/analyzers/extension_conflict.py +173 -0
- wxcli/migration/transform/analyzers/feature_approximation.py +227 -0
- wxcli/migration/transform/analyzers/layout_overflow.py +174 -0
- wxcli/migration/transform/analyzers/location_ambiguity.py +184 -0
- wxcli/migration/transform/analyzers/missing_data.py +300 -0
- wxcli/migration/transform/analyzers/selective_call_handling.py +394 -0
- wxcli/migration/transform/analyzers/shared_line.py +170 -0
- wxcli/migration/transform/analyzers/voicemail_compatibility.py +267 -0
- wxcli/migration/transform/analyzers/workspace_license.py +212 -0
- wxcli/migration/transform/cross_reference.py +1136 -0
- wxcli/migration/transform/cucm_pattern.py +246 -0
- wxcli/migration/transform/decisions.py +301 -0
- wxcli/migration/transform/e164.py +145 -0
- wxcli/migration/transform/engine.py +256 -0
- wxcli/migration/transform/mappers/CLAUDE.md +123 -0
- wxcli/migration/transform/mappers/__init__.py +76 -0
- wxcli/migration/transform/mappers/announcement_mapper.py +122 -0
- wxcli/migration/transform/mappers/base.py +190 -0
- wxcli/migration/transform/mappers/button_template_mapper.py +186 -0
- wxcli/migration/transform/mappers/call_forwarding_mapper.py +228 -0
- wxcli/migration/transform/mappers/call_settings_mapper.py +144 -0
- wxcli/migration/transform/mappers/css_mapper.py +868 -0
- wxcli/migration/transform/mappers/dect_mapper.py +367 -0
- wxcli/migration/transform/mappers/device_layout_mapper.py +289 -0
- wxcli/migration/transform/mappers/device_mapper.py +227 -0
- wxcli/migration/transform/mappers/device_profile_mapper.py +190 -0
- wxcli/migration/transform/mappers/device_settings_mapper.py +464 -0
- wxcli/migration/transform/mappers/e911_mapper.py +129 -0
- wxcli/migration/transform/mappers/ecbn_mapper.py +263 -0
- wxcli/migration/transform/mappers/executive_assistant_mapper.py +156 -0
- wxcli/migration/transform/mappers/feature_mapper.py +1179 -0
- wxcli/migration/transform/mappers/line_mapper.py +310 -0
- wxcli/migration/transform/mappers/location_mapper.py +235 -0
- wxcli/migration/transform/mappers/moh_mapper.py +114 -0
- wxcli/migration/transform/mappers/monitoring_mapper.py +221 -0
- wxcli/migration/transform/mappers/receptionist_mapper.py +245 -0
- wxcli/migration/transform/mappers/routing_mapper.py +821 -0
- wxcli/migration/transform/mappers/snr_mapper.py +201 -0
- wxcli/migration/transform/mappers/softkey_mapper.py +205 -0
- wxcli/migration/transform/mappers/user_mapper.py +264 -0
- wxcli/migration/transform/mappers/voicemail_group_mapper.py +286 -0
- wxcli/migration/transform/mappers/voicemail_mapper.py +514 -0
- wxcli/migration/transform/mappers/workspace_mapper.py +464 -0
- wxcli/migration/transform/normalizers.py +2008 -0
- wxcli/migration/transform/pattern_converter.py +106 -0
- wxcli/migration/transform/pipeline.py +271 -0
- wxcli/migration/transform/rules.py +255 -0
- wxcli/org_health/CLAUDE.md +63 -0
- wxcli/org_health/__init__.py +7 -0
- wxcli/org_health/analyze.py +93 -0
- wxcli/org_health/checks.py +458 -0
- wxcli/org_health/collector.py +62 -0
- wxcli/org_health/models.py +78 -0
- wxcli/org_health/report.py +300 -0
- wxcli/output.py +101 -0
- wxcli-1.2.0.dist-info/METADATA +505 -0
- wxcli-1.2.0.dist-info/RECORD +413 -0
- wxcli-1.2.0.dist-info/WHEEL +5 -0
- wxcli-1.2.0.dist-info/entry_points.txt +2 -0
- wxcli-1.2.0.dist-info/licenses/LICENSE +191 -0
- wxcli-1.2.0.dist-info/scm_file_list.json +751 -0
- wxcli-1.2.0.dist-info/scm_version.json +8 -0
- wxcli-1.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,964 @@
|
|
|
1
|
+
"""Async bulk execution engine for CUCM migration operations.
|
|
2
|
+
|
|
3
|
+
Processes migration operations concurrently via aiohttp with:
|
|
4
|
+
- Semaphore-based rate limiting
|
|
5
|
+
- Automatic 429 retry with Retry-After backoff
|
|
6
|
+
- Per-operation status tracking via update_op_status()
|
|
7
|
+
- 409 auto-recovery (search for existing resource)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import aiohttp
|
|
18
|
+
|
|
19
|
+
from wxcli.migration.execute.handlers import HANDLER_REGISTRY, SkippedResult
|
|
20
|
+
from wxcli.migration.execute.runtime import get_next_batch, update_op_status
|
|
21
|
+
from wxcli.migration.store import MigrationStore
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class JobErrorFetchFailed(Exception):
|
|
25
|
+
"""Raised when the bulk job errors endpoint cannot be read.
|
|
26
|
+
|
|
27
|
+
Distinguishes a legitimately-empty errors list (200 response with empty
|
|
28
|
+
``items``) from a fetch failure (non-200 or network error). Callers must
|
|
29
|
+
treat the fetch failure as "we don't know which devices failed" and
|
|
30
|
+
escalate to a total failure rather than silently assume zero errors.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
BASE = "https://webexapis.com/v1"
|
|
36
|
+
MAX_RETRIES = 5
|
|
37
|
+
|
|
38
|
+
# Sentinel key: when present in a call's body dict, the engine deep-merges
|
|
39
|
+
# the previous call's response body into this body before sending.
|
|
40
|
+
# The sentinel key itself is stripped before the request is made.
|
|
41
|
+
# Usage: handler emits [("GET", url, None), ("PUT", url, {**overrides, "_merge_from_previous": True})]
|
|
42
|
+
MERGE_FROM_PREVIOUS = "_merge_from_previous"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def poll_job_until_complete(
|
|
46
|
+
session: aiohttp.ClientSession,
|
|
47
|
+
job_type: str,
|
|
48
|
+
job_id: str,
|
|
49
|
+
poll_interval: float = 5,
|
|
50
|
+
max_poll_time: float = 600,
|
|
51
|
+
ctx: dict | None = None,
|
|
52
|
+
) -> dict:
|
|
53
|
+
"""Poll a Webex bulk device job until completion or timeout.
|
|
54
|
+
|
|
55
|
+
Returns the final job status dict. Raises TimeoutError if max_poll_time
|
|
56
|
+
elapses without reaching COMPLETED or FAILED.
|
|
57
|
+
"""
|
|
58
|
+
from urllib.parse import urlencode
|
|
59
|
+
|
|
60
|
+
ctx = ctx or {}
|
|
61
|
+
path = f"/telephony/config/jobs/devices/{job_type}/{job_id}"
|
|
62
|
+
url = f"{BASE}{path}"
|
|
63
|
+
if ctx.get("orgId"):
|
|
64
|
+
url += f"?{urlencode({'orgId': ctx['orgId']})}"
|
|
65
|
+
|
|
66
|
+
elapsed: float = 0
|
|
67
|
+
while elapsed <= max_poll_time:
|
|
68
|
+
async with session.get(url) as resp:
|
|
69
|
+
body = await resp.json()
|
|
70
|
+
|
|
71
|
+
exit_code = body.get("latestExecutionExitCode", "UNKNOWN")
|
|
72
|
+
if exit_code in ("COMPLETED", "FAILED"):
|
|
73
|
+
logger.info(
|
|
74
|
+
"Job %s %s: exit=%s updated=%d",
|
|
75
|
+
job_type, job_id[:16], exit_code, body.get("updatedCount", 0),
|
|
76
|
+
)
|
|
77
|
+
return body
|
|
78
|
+
|
|
79
|
+
logger.debug(
|
|
80
|
+
"Job %s %s: %d%% (elapsed %.0fs)",
|
|
81
|
+
job_type, job_id[:16], body.get("percentageComplete", 0), elapsed,
|
|
82
|
+
)
|
|
83
|
+
if elapsed >= max_poll_time:
|
|
84
|
+
break
|
|
85
|
+
await asyncio.sleep(poll_interval)
|
|
86
|
+
elapsed += poll_interval
|
|
87
|
+
|
|
88
|
+
raise TimeoutError(f"Job {job_type}/{job_id} did not complete within {max_poll_time}s")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def fetch_job_errors(
|
|
92
|
+
session: aiohttp.ClientSession,
|
|
93
|
+
job_type: str,
|
|
94
|
+
job_id: str,
|
|
95
|
+
ctx: dict | None = None,
|
|
96
|
+
) -> list[dict]:
|
|
97
|
+
"""GET the errors endpoint for a bulk job and return the items list.
|
|
98
|
+
|
|
99
|
+
GET /v1/telephony/config/jobs/devices/{jobType}/{jobId}/errors
|
|
100
|
+
|
|
101
|
+
Returns the parsed ``items`` list on a 200 response (empty list if the
|
|
102
|
+
payload carries no items). Raises ``JobErrorFetchFailed`` on any
|
|
103
|
+
non-200 status or network exception so the caller can distinguish
|
|
104
|
+
"no errors reported" from "we couldn't ask". Fix #6.
|
|
105
|
+
"""
|
|
106
|
+
from urllib.parse import urlencode
|
|
107
|
+
|
|
108
|
+
ctx = ctx or {}
|
|
109
|
+
path = f"/telephony/config/jobs/devices/{job_type}/{job_id}/errors"
|
|
110
|
+
url = f"{BASE}{path}"
|
|
111
|
+
if ctx.get("orgId"):
|
|
112
|
+
url += f"?{urlencode({'orgId': ctx['orgId']})}"
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
async with session.get(url) as resp:
|
|
116
|
+
if resp.status != 200:
|
|
117
|
+
raise JobErrorFetchFailed(
|
|
118
|
+
f"fetch_job_errors {job_id[:16]} -> HTTP {resp.status}"
|
|
119
|
+
)
|
|
120
|
+
body = await resp.json()
|
|
121
|
+
except JobErrorFetchFailed:
|
|
122
|
+
raise
|
|
123
|
+
except Exception as e:
|
|
124
|
+
raise JobErrorFetchFailed(
|
|
125
|
+
f"fetch_job_errors {job_id[:16]} raised: {e}"
|
|
126
|
+
) from e
|
|
127
|
+
|
|
128
|
+
if not isinstance(body, dict):
|
|
129
|
+
return []
|
|
130
|
+
items = body.get("items", [])
|
|
131
|
+
return list(items) if isinstance(items, list) else []
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass
|
|
135
|
+
class OpResult:
|
|
136
|
+
"""Result of executing one operation."""
|
|
137
|
+
node_id: str
|
|
138
|
+
status: int = 0
|
|
139
|
+
webex_id: str | None = None
|
|
140
|
+
error: str | None = None
|
|
141
|
+
body: dict = field(default_factory=dict)
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def success(self) -> bool:
|
|
145
|
+
return 200 <= self.status < 300
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def reset_in_progress(store: MigrationStore) -> int:
|
|
149
|
+
"""Reset any in_progress ops back to pending. Returns count reset."""
|
|
150
|
+
cursor = store.conn.execute(
|
|
151
|
+
"UPDATE plan_operations SET status = 'pending' WHERE status = 'in_progress'"
|
|
152
|
+
)
|
|
153
|
+
count = cursor.rowcount
|
|
154
|
+
store.conn.commit()
|
|
155
|
+
if count:
|
|
156
|
+
logger.info("Reset %d in_progress operations to pending", count)
|
|
157
|
+
return count
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def execute_single_op(
|
|
161
|
+
session: aiohttp.ClientSession,
|
|
162
|
+
node_id: str,
|
|
163
|
+
calls: list[tuple[str, str, dict | None]],
|
|
164
|
+
semaphore: asyncio.Semaphore,
|
|
165
|
+
max_retries: int = MAX_RETRIES,
|
|
166
|
+
require_webex_id: bool = False,
|
|
167
|
+
) -> OpResult:
|
|
168
|
+
"""Execute one operation's API call(s) with rate limiting and retry.
|
|
169
|
+
|
|
170
|
+
For multi-call operations (e.g., user:configure_settings), calls are
|
|
171
|
+
executed sequentially. The operation fails if any sub-call fails.
|
|
172
|
+
The webex_id comes from the first call's response (the create).
|
|
173
|
+
|
|
174
|
+
When ``require_webex_id`` is True (Fix #18 — create ops), a success
|
|
175
|
+
response with no ``id`` / ``code`` in the body is treated as FAILED
|
|
176
|
+
instead of silently succeeding. Some backends return 200/204 without a
|
|
177
|
+
body on server-side hiccups — the resource may not exist and we'd have
|
|
178
|
+
no way to reference it in later ops.
|
|
179
|
+
"""
|
|
180
|
+
webex_id = None
|
|
181
|
+
last_body = {}
|
|
182
|
+
|
|
183
|
+
for method, url, body in calls:
|
|
184
|
+
# Read-before-write: merge previous response into this body if requested
|
|
185
|
+
if isinstance(body, dict) and body.pop(MERGE_FROM_PREVIOUS, None):
|
|
186
|
+
body = {**last_body, **body}
|
|
187
|
+
|
|
188
|
+
for attempt in range(max_retries):
|
|
189
|
+
try:
|
|
190
|
+
async with semaphore:
|
|
191
|
+
async with session.request(method, url, json=body) as resp:
|
|
192
|
+
resp_status = resp.status
|
|
193
|
+
try:
|
|
194
|
+
resp_body = await resp.json()
|
|
195
|
+
except Exception:
|
|
196
|
+
resp_body = {}
|
|
197
|
+
resp_headers = resp.headers
|
|
198
|
+
|
|
199
|
+
if resp_status == 429:
|
|
200
|
+
retry_after = int(resp_headers.get("Retry-After", 5))
|
|
201
|
+
logger.debug("429 on %s, retry after %ds", node_id, retry_after)
|
|
202
|
+
await asyncio.sleep(retry_after)
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
if resp_status >= 400:
|
|
206
|
+
error_msg = resp_body.get("message") or resp_body.get("errors", str(resp_body))
|
|
207
|
+
return OpResult(
|
|
208
|
+
node_id=node_id, status=resp_status,
|
|
209
|
+
error=f"{resp_status}: {error_msg}", body=resp_body,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Success — extract ID from first call.
|
|
213
|
+
# Activation-code responses (POST /devices/activationCode)
|
|
214
|
+
# return {"code": "...", "expiryTime": "..."} with no "id" field,
|
|
215
|
+
# so fall back to "code" so the activation string is persisted
|
|
216
|
+
# into plan_operations.webex_id for export.
|
|
217
|
+
if webex_id is None and isinstance(resp_body, dict):
|
|
218
|
+
webex_id = resp_body.get("id") or resp_body.get("code")
|
|
219
|
+
last_body = resp_body
|
|
220
|
+
break # Move to next sub-call
|
|
221
|
+
|
|
222
|
+
except aiohttp.ClientError as e:
|
|
223
|
+
if attempt < max_retries - 1:
|
|
224
|
+
await asyncio.sleep(2 ** attempt)
|
|
225
|
+
continue
|
|
226
|
+
return OpResult(
|
|
227
|
+
node_id=node_id, status=0,
|
|
228
|
+
error=f"Connection error: {e}", body={},
|
|
229
|
+
)
|
|
230
|
+
else:
|
|
231
|
+
return OpResult(
|
|
232
|
+
node_id=node_id, status=429,
|
|
233
|
+
error="Max retries exceeded (429)", body={},
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# Fix #18: create ops must return a usable identifier. A silent success
|
|
237
|
+
# with no id means the downstream planner has no webex_id to record, and
|
|
238
|
+
# any dependent op will fail to resolve this create's output. Surface it
|
|
239
|
+
# as a FAILED op instead of silently completing.
|
|
240
|
+
if require_webex_id and not webex_id:
|
|
241
|
+
return OpResult(
|
|
242
|
+
node_id=node_id, status=500,
|
|
243
|
+
error="create succeeded but response contained no id/code",
|
|
244
|
+
body=last_body,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
return OpResult(
|
|
248
|
+
node_id=node_id, status=200,
|
|
249
|
+
webex_id=webex_id, body=last_body,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# Webex bulk job IDs are base64-encoded URN strings (typically 60-120 chars).
|
|
254
|
+
# We enforce a floor of 10 characters — this catches the spec's obvious
|
|
255
|
+
# garbage cases ("x", "", whitespace) *and* any plausible truncation /
|
|
256
|
+
# off-by-one parsing bug. 10 is deliberately conservative vs. the real 60+
|
|
257
|
+
# length: enough to rule out any 1-9 char typo while staying well under the
|
|
258
|
+
# real minimum so test fixtures that use mock IDs just need to use
|
|
259
|
+
# realistic-looking strings (which is cheap to do).
|
|
260
|
+
_MIN_JOB_ID_LEN = 10
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _validate_job_id(job_id: str | None) -> None:
|
|
264
|
+
"""Fix #8: raise ``ValueError`` for missing/malformed bulk job IDs.
|
|
265
|
+
|
|
266
|
+
Webex returns base64-encoded URN job IDs. A submit response with an
|
|
267
|
+
empty, None, single-char, or whitespace ``id`` field points to either an
|
|
268
|
+
upstream backend bug or a parsing error — polling such an ID would 404
|
|
269
|
+
endlessly. Fail fast with a clear error so the operator knows the submit
|
|
270
|
+
response was bogus and the bulk op is FAILED rather than silently stuck
|
|
271
|
+
mid-poll.
|
|
272
|
+
"""
|
|
273
|
+
if job_id is None or not isinstance(job_id, str):
|
|
274
|
+
raise ValueError(f"job_id is missing or not a string: {job_id!r}")
|
|
275
|
+
if not job_id.strip():
|
|
276
|
+
raise ValueError(f"job_id is empty / whitespace: {job_id!r}")
|
|
277
|
+
if len(job_id) < _MIN_JOB_ID_LEN:
|
|
278
|
+
raise ValueError(
|
|
279
|
+
f"job_id {job_id!r} is implausibly short "
|
|
280
|
+
f"(<{_MIN_JOB_ID_LEN} chars); refusing to poll"
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
async def execute_bulk_op(
|
|
285
|
+
session: aiohttp.ClientSession,
|
|
286
|
+
node_id: str,
|
|
287
|
+
resource_type: str,
|
|
288
|
+
calls: list[tuple[str, str, dict | None]],
|
|
289
|
+
semaphore: asyncio.Semaphore,
|
|
290
|
+
poll_interval: float = 5,
|
|
291
|
+
max_poll_time: float = 600,
|
|
292
|
+
ctx: dict | None = None,
|
|
293
|
+
fallback_context: dict | None = None,
|
|
294
|
+
) -> OpResult:
|
|
295
|
+
"""Submit a Webex bulk job, poll to completion, return an OpResult.
|
|
296
|
+
|
|
297
|
+
Expects exactly one call in ``calls`` — the submit POST. The response
|
|
298
|
+
body's ``id`` is the job ID. After submission, this function polls
|
|
299
|
+
using ``poll_job_until_complete`` until the job reaches COMPLETED or
|
|
300
|
+
FAILED (or times out). If the job completes but ``updatedCount`` is
|
|
301
|
+
less than the number of covered devices and ``fallback_context`` is
|
|
302
|
+
provided, the function fetches the job errors endpoint, identifies
|
|
303
|
+
failed device IDs, and re-runs a per-device handler for each.
|
|
304
|
+
"""
|
|
305
|
+
from wxcli.migration.execute import BULK_JOB_TYPES
|
|
306
|
+
|
|
307
|
+
if len(calls) != 1:
|
|
308
|
+
return OpResult(
|
|
309
|
+
node_id=node_id, status=0,
|
|
310
|
+
error=f"execute_bulk_op expects exactly 1 call, got {len(calls)}",
|
|
311
|
+
body={},
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
method, url, body = calls[0]
|
|
315
|
+
job_type = BULK_JOB_TYPES.get(resource_type)
|
|
316
|
+
if not job_type:
|
|
317
|
+
return OpResult(
|
|
318
|
+
node_id=node_id, status=0,
|
|
319
|
+
error=f"No BULK_JOB_TYPES mapping for {resource_type}",
|
|
320
|
+
body={},
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
# Submit the job
|
|
324
|
+
try:
|
|
325
|
+
async with semaphore:
|
|
326
|
+
async with session.request(method, url, json=body) as resp:
|
|
327
|
+
submit_status = resp.status
|
|
328
|
+
submit_body = await resp.json()
|
|
329
|
+
except aiohttp.ClientError as e:
|
|
330
|
+
return OpResult(node_id=node_id, status=0, error=f"Submit error: {e}", body={})
|
|
331
|
+
|
|
332
|
+
if submit_status >= 400:
|
|
333
|
+
msg = submit_body.get("message") if isinstance(submit_body, dict) else str(submit_body)
|
|
334
|
+
return OpResult(
|
|
335
|
+
node_id=node_id, status=submit_status,
|
|
336
|
+
error=f"{submit_status}: {msg}", body=submit_body or {},
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
job_id = submit_body.get("id") if isinstance(submit_body, dict) else None
|
|
340
|
+
if not job_id:
|
|
341
|
+
return OpResult(
|
|
342
|
+
node_id=node_id, status=submit_status,
|
|
343
|
+
error="Submit succeeded but returned no job id",
|
|
344
|
+
body=submit_body or {},
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
# Fix #8: validate the job ID *before* polling. A non-empty but malformed
|
|
348
|
+
# id (e.g. truncated, single-char) would otherwise pass the `if not job_id`
|
|
349
|
+
# check above and then fail mid-poll with a confusing 404. Surface as a
|
|
350
|
+
# FAILED OpResult with the offending value attached for diagnostics.
|
|
351
|
+
try:
|
|
352
|
+
_validate_job_id(job_id)
|
|
353
|
+
except ValueError as e:
|
|
354
|
+
return OpResult(
|
|
355
|
+
node_id=node_id, status=500,
|
|
356
|
+
error=f"Bulk submit returned malformed job_id: {e}",
|
|
357
|
+
body=submit_body or {},
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
# Poll to completion
|
|
361
|
+
try:
|
|
362
|
+
final = await poll_job_until_complete(
|
|
363
|
+
session, job_type, job_id,
|
|
364
|
+
poll_interval=poll_interval,
|
|
365
|
+
max_poll_time=max_poll_time,
|
|
366
|
+
ctx=ctx,
|
|
367
|
+
)
|
|
368
|
+
except TimeoutError as e:
|
|
369
|
+
return OpResult(node_id=node_id, status=0, error=str(e), body={"id": job_id})
|
|
370
|
+
|
|
371
|
+
exit_code = final.get("latestExecutionExitCode", "UNKNOWN")
|
|
372
|
+
if exit_code != "COMPLETED":
|
|
373
|
+
return OpResult(
|
|
374
|
+
node_id=node_id, status=500,
|
|
375
|
+
error=f"Job {job_id} ended in {exit_code}",
|
|
376
|
+
body=final,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
# Partial-failure check.
|
|
380
|
+
updated = final.get("updatedCount", 0)
|
|
381
|
+
expected = 0
|
|
382
|
+
excluded_unresolved = 0
|
|
383
|
+
if fallback_context:
|
|
384
|
+
expected = len(fallback_context.get("covered_devices", []))
|
|
385
|
+
# Fix #5/#7 companion: devices the upstream create never produced.
|
|
386
|
+
# `_build_fallback_context` already logged each one at WARN.
|
|
387
|
+
excluded_unresolved = fallback_context.get("excluded_unresolved_count", 0)
|
|
388
|
+
|
|
389
|
+
# Fix #19: when no fallback_context is configured we used to return
|
|
390
|
+
# success unconditionally. That silently accepted 0-update jobs. Now
|
|
391
|
+
# treat (fallback_context is None AND updated == 0) as a hard failure;
|
|
392
|
+
# when updated > 0 we keep the legacy success path (no way to verify
|
|
393
|
+
# `expected` without context, but SOMETHING worked).
|
|
394
|
+
if fallback_context is None:
|
|
395
|
+
if updated == 0:
|
|
396
|
+
return OpResult(
|
|
397
|
+
node_id=node_id, status=500,
|
|
398
|
+
error=(
|
|
399
|
+
f"Bulk job {job_id}: updated 0 devices and no fallback "
|
|
400
|
+
"configured; cannot verify success"
|
|
401
|
+
),
|
|
402
|
+
body=final,
|
|
403
|
+
)
|
|
404
|
+
return OpResult(
|
|
405
|
+
node_id=node_id, status=200, webex_id=job_id, body=final,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
if updated >= expected:
|
|
409
|
+
# Fix #7: even on the all-bulk-succeeded path, log a summary that
|
|
410
|
+
# accounts for the silently-excluded devices so the operator never
|
|
411
|
+
# has to guess whether anything was dropped.
|
|
412
|
+
logger.info(
|
|
413
|
+
"Bulk job %s summary: bulk_updated=%d/%d, fallback_attempted=0, "
|
|
414
|
+
"fallback_recovered=0, excluded_unresolved=%d",
|
|
415
|
+
job_id[:16], updated, expected, excluded_unresolved,
|
|
416
|
+
)
|
|
417
|
+
return OpResult(
|
|
418
|
+
node_id=node_id, status=200, webex_id=job_id, body=final,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
logger.info(
|
|
422
|
+
"Bulk job %s partial: %d/%d succeeded — running per-device fallback",
|
|
423
|
+
job_id[:16], updated, expected,
|
|
424
|
+
)
|
|
425
|
+
failed_ids = await _resolve_failed_devices(
|
|
426
|
+
session, job_type, job_id, fallback_context, ctx,
|
|
427
|
+
)
|
|
428
|
+
if not failed_ids:
|
|
429
|
+
logger.warning(
|
|
430
|
+
"Bulk job %s updated=%d < expected=%d but error endpoint returned nothing",
|
|
431
|
+
job_id[:16], updated, expected,
|
|
432
|
+
)
|
|
433
|
+
return OpResult(
|
|
434
|
+
node_id=node_id, status=500,
|
|
435
|
+
error=(
|
|
436
|
+
f"Bulk job {job_id} partial failure with no error items "
|
|
437
|
+
f"(excluded_unresolved={excluded_unresolved})"
|
|
438
|
+
),
|
|
439
|
+
body=final,
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
fallback_ok, fallback_error = await _run_per_device_fallback(
|
|
443
|
+
session, failed_ids, fallback_context, semaphore,
|
|
444
|
+
)
|
|
445
|
+
# Fix #4: even on fallback_ok we must verify we actually attempted every
|
|
446
|
+
# missing device. If the errors endpoint returned an incomplete list,
|
|
447
|
+
# some devices are still unaccounted for and the op must be reported
|
|
448
|
+
# FAILED, not COMPLETED.
|
|
449
|
+
attempted = len(failed_ids)
|
|
450
|
+
missing = (expected - updated) - attempted
|
|
451
|
+
fallback_recovered = attempted if fallback_ok else 0
|
|
452
|
+
# Fix #7: summary log fires on EVERY exit path of the fallback branch so
|
|
453
|
+
# bulk_updated, fallback_attempted, fallback_recovered, and the count of
|
|
454
|
+
# devices excluded by `_build_fallback_context` are always visible to
|
|
455
|
+
# the operator. Logged before the early-return so we capture it on
|
|
456
|
+
# both COMPLETED and FAILED outcomes.
|
|
457
|
+
logger.info(
|
|
458
|
+
"Bulk job %s summary: bulk_updated=%d/%d, fallback_attempted=%d, "
|
|
459
|
+
"fallback_recovered=%d, excluded_unresolved=%d",
|
|
460
|
+
job_id[:16], updated, expected, attempted,
|
|
461
|
+
fallback_recovered, excluded_unresolved,
|
|
462
|
+
)
|
|
463
|
+
if fallback_ok:
|
|
464
|
+
if missing > 0:
|
|
465
|
+
logger.warning(
|
|
466
|
+
"Bulk job %s: fallback recovered %d but %d device(s) never attempted",
|
|
467
|
+
job_id[:16], attempted, missing,
|
|
468
|
+
)
|
|
469
|
+
return OpResult(
|
|
470
|
+
node_id=node_id, status=500,
|
|
471
|
+
error=(
|
|
472
|
+
f"Bulk {job_id}: partial failure — {updated} succeeded in bulk, "
|
|
473
|
+
f"{attempted} recovered via fallback, but {missing} device(s) "
|
|
474
|
+
f"still unaccounted for "
|
|
475
|
+
f"(excluded_unresolved={excluded_unresolved})"
|
|
476
|
+
),
|
|
477
|
+
body=final,
|
|
478
|
+
)
|
|
479
|
+
logger.info("Bulk job %s: fallback recovered all %d failed devices",
|
|
480
|
+
job_id[:16], attempted)
|
|
481
|
+
return OpResult(
|
|
482
|
+
node_id=node_id, status=200, webex_id=job_id, body=final,
|
|
483
|
+
)
|
|
484
|
+
return OpResult(
|
|
485
|
+
node_id=node_id, status=500,
|
|
486
|
+
error=(
|
|
487
|
+
f"Bulk {job_id}: fallback failed — {fallback_error} "
|
|
488
|
+
f"(excluded_unresolved={excluded_unresolved})"
|
|
489
|
+
),
|
|
490
|
+
body=final,
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
async def _resolve_failed_devices(
|
|
495
|
+
session: aiohttp.ClientSession,
|
|
496
|
+
job_type: str,
|
|
497
|
+
job_id: str,
|
|
498
|
+
fallback_context: dict,
|
|
499
|
+
ctx: dict | None,
|
|
500
|
+
) -> list[str]:
|
|
501
|
+
"""Return the list of webex device IDs that failed in the bulk job.
|
|
502
|
+
|
|
503
|
+
Reads the errors endpoint and pulls itemId for each failed row. If the
|
|
504
|
+
errors endpoint itself can't be read (HTTP non-200 or network failure),
|
|
505
|
+
returns ``[]`` and logs a WARNING. ``execute_bulk_op`` already treats
|
|
506
|
+
an empty ``failed_ids`` list as a total failure, so this preserves the
|
|
507
|
+
safe behavior. Fix #6.
|
|
508
|
+
"""
|
|
509
|
+
try:
|
|
510
|
+
items = await fetch_job_errors(session, job_type, job_id, ctx)
|
|
511
|
+
except JobErrorFetchFailed:
|
|
512
|
+
logger.warning(
|
|
513
|
+
"Cannot fetch job errors for %s — treating partial bulk failure as total failure",
|
|
514
|
+
job_id[:16],
|
|
515
|
+
)
|
|
516
|
+
return []
|
|
517
|
+
return [i["itemId"] for i in items if i.get("itemId")]
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
async def _run_per_device_fallback(
|
|
521
|
+
session: aiohttp.ClientSession,
|
|
522
|
+
failed_webex_ids: list[str],
|
|
523
|
+
fallback_context: dict,
|
|
524
|
+
semaphore: asyncio.Semaphore,
|
|
525
|
+
) -> tuple[bool, str | None]:
|
|
526
|
+
"""Re-run the per-device handler for each failed device.
|
|
527
|
+
|
|
528
|
+
Returns (all_ok, error_message). The handler is looked up in
|
|
529
|
+
HANDLER_REGISTRY by fallback_context['fallback_handler_key']. Deps
|
|
530
|
+
are augmented with the device's own canonical_id → webex_id so the
|
|
531
|
+
handler can resolve the device. If the handler still returns no calls
|
|
532
|
+
(e.g. because the settings dict is empty but present), a direct PUT
|
|
533
|
+
to the device settings endpoint is issued using the record's data.
|
|
534
|
+
|
|
535
|
+
Fix #7: aggregate errors instead of fail-fast. A single bad device
|
|
536
|
+
should not mask the fate of the rest — we collect unresolved and
|
|
537
|
+
per-device failures, log a WARNING summary after the loop, and
|
|
538
|
+
return a compacted error string listing the first few failures plus
|
|
539
|
+
totals. Fix #8: handler may return ``SkippedResult`` — treat that as
|
|
540
|
+
a per-device failure (not a bare TypeError on iteration).
|
|
541
|
+
"""
|
|
542
|
+
handler_key = fallback_context.get("fallback_handler_key")
|
|
543
|
+
handler = HANDLER_REGISTRY.get(handler_key) if handler_key else None
|
|
544
|
+
if handler is None:
|
|
545
|
+
return False, f"No handler for fallback key {handler_key}"
|
|
546
|
+
|
|
547
|
+
covered = {d["webex_id"]: d for d in fallback_context.get("covered_devices", [])}
|
|
548
|
+
deps = fallback_context.get("deps", {})
|
|
549
|
+
ctx = fallback_context.get("ctx", {})
|
|
550
|
+
|
|
551
|
+
unresolved: list[str] = []
|
|
552
|
+
per_device_errors: list[tuple[str, str]] = []
|
|
553
|
+
|
|
554
|
+
for wid in failed_webex_ids:
|
|
555
|
+
record = covered.get(wid)
|
|
556
|
+
if not record:
|
|
557
|
+
unresolved.append(wid)
|
|
558
|
+
continue
|
|
559
|
+
# Augment deps with the device's own webex_id so handler can resolve it.
|
|
560
|
+
per_device_deps = {**deps, record["canonical_id"]: record["webex_id"]}
|
|
561
|
+
calls = handler(record.get("data", {}), per_device_deps, ctx)
|
|
562
|
+
# Fix #8: a handler that returns SkippedResult is truthy but not
|
|
563
|
+
# iterable — unwrap it as a per-device failure with the reason so
|
|
564
|
+
# the `for method, url, body in calls` loop never sees it.
|
|
565
|
+
if isinstance(calls, SkippedResult):
|
|
566
|
+
per_device_errors.append((wid, f"skipped: {calls.reason}"))
|
|
567
|
+
continue
|
|
568
|
+
if not calls:
|
|
569
|
+
# Handler returned no-op. Issue a direct settings PUT using the
|
|
570
|
+
# record's data (supports both "device_settings" and "settings" keys).
|
|
571
|
+
data_body = record.get("data", {})
|
|
572
|
+
settings = data_body.get("device_settings")
|
|
573
|
+
if settings is None:
|
|
574
|
+
settings = data_body.get("settings")
|
|
575
|
+
if settings is None:
|
|
576
|
+
continue # truly nothing to apply → treat as success
|
|
577
|
+
url = f"{BASE}/telephony/config/devices/{wid}/settings"
|
|
578
|
+
calls = [("PUT", url, settings)]
|
|
579
|
+
call_failed = False
|
|
580
|
+
for method, url, body in calls:
|
|
581
|
+
async with semaphore:
|
|
582
|
+
async with session.request(method, url, json=body) as resp:
|
|
583
|
+
if resp.status >= 400:
|
|
584
|
+
text = await resp.text()
|
|
585
|
+
per_device_errors.append(
|
|
586
|
+
(wid, f"fallback {resp.status}: {text[:200]}")
|
|
587
|
+
)
|
|
588
|
+
call_failed = True
|
|
589
|
+
break
|
|
590
|
+
if call_failed:
|
|
591
|
+
continue
|
|
592
|
+
|
|
593
|
+
# Fix #7: emit a single WARNING summary after the loop so the operator
|
|
594
|
+
# sees every unresolved / failed device in one line, not one exception
|
|
595
|
+
# per case. Only fires when there's something to report.
|
|
596
|
+
if unresolved or per_device_errors:
|
|
597
|
+
logger.warning(
|
|
598
|
+
"Fallback summary: %d unresolved devices %s, %d per-device failures",
|
|
599
|
+
len(unresolved), unresolved, len(per_device_errors),
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
if not unresolved and not per_device_errors:
|
|
603
|
+
return True, None
|
|
604
|
+
# Compact error: list the first 3 per-device errors plus totals for the
|
|
605
|
+
# operator-facing OpResult.error. Unresolved count makes it in too.
|
|
606
|
+
head = per_device_errors[:3]
|
|
607
|
+
return False, (
|
|
608
|
+
f"unresolved={len(unresolved)}, failed={len(per_device_errors)}: {head}"
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
async def _try_find_existing(
|
|
613
|
+
session: aiohttp.ClientSession,
|
|
614
|
+
semaphore: asyncio.Semaphore,
|
|
615
|
+
resource_type: str,
|
|
616
|
+
data: dict,
|
|
617
|
+
ctx: dict,
|
|
618
|
+
) -> str | None:
|
|
619
|
+
"""On 409, search for the existing resource and return its Webex ID."""
|
|
620
|
+
from urllib.parse import urlencode
|
|
621
|
+
search_url = None
|
|
622
|
+
item_key = None
|
|
623
|
+
|
|
624
|
+
if resource_type == "user":
|
|
625
|
+
email = (data.get("emails") or [""])[0]
|
|
626
|
+
if email:
|
|
627
|
+
params = {"email": email}
|
|
628
|
+
if ctx.get("orgId"):
|
|
629
|
+
params["orgId"] = ctx["orgId"]
|
|
630
|
+
search_url = f"{BASE}/people?{urlencode(params)}"
|
|
631
|
+
item_key = "items"
|
|
632
|
+
elif resource_type == "location":
|
|
633
|
+
name = data.get("name")
|
|
634
|
+
if name:
|
|
635
|
+
params = {"name": name}
|
|
636
|
+
if ctx.get("orgId"):
|
|
637
|
+
params["orgId"] = ctx["orgId"]
|
|
638
|
+
search_url = f"{BASE}/locations?{urlencode(params)}"
|
|
639
|
+
item_key = "items"
|
|
640
|
+
elif resource_type == "translation_pattern":
|
|
641
|
+
name = data.get("name")
|
|
642
|
+
if name:
|
|
643
|
+
params = {"name": name, "max": "100"}
|
|
644
|
+
if ctx.get("orgId"):
|
|
645
|
+
params["orgId"] = ctx["orgId"]
|
|
646
|
+
search_url = f"{BASE}/telephony/config/callRouting/translationPatterns?{urlencode(params)}"
|
|
647
|
+
item_key = "translationPatterns"
|
|
648
|
+
elif resource_type == "trunk":
|
|
649
|
+
name = data.get("name")
|
|
650
|
+
if name:
|
|
651
|
+
params = {"name": name, "max": "100"}
|
|
652
|
+
if ctx.get("orgId"):
|
|
653
|
+
params["orgId"] = ctx["orgId"]
|
|
654
|
+
search_url = f"{BASE}/telephony/config/premisePstn/trunks?{urlencode(params)}"
|
|
655
|
+
item_key = "trunks"
|
|
656
|
+
elif resource_type == "dial_plan":
|
|
657
|
+
name = data.get("name")
|
|
658
|
+
if name:
|
|
659
|
+
params = {"name": name, "max": "100"}
|
|
660
|
+
if ctx.get("orgId"):
|
|
661
|
+
params["orgId"] = ctx["orgId"]
|
|
662
|
+
search_url = f"{BASE}/telephony/config/premisePstn/dialPlans?{urlencode(params)}"
|
|
663
|
+
item_key = "dialPlans"
|
|
664
|
+
elif resource_type == "operating_mode":
|
|
665
|
+
name = data.get("name")
|
|
666
|
+
if name:
|
|
667
|
+
params = {"name": name, "max": "100"}
|
|
668
|
+
if ctx.get("orgId"):
|
|
669
|
+
params["orgId"] = ctx["orgId"]
|
|
670
|
+
search_url = f"{BASE}/telephony/config/operatingModes?{urlencode(params)}"
|
|
671
|
+
item_key = "operatingModes"
|
|
672
|
+
elif resource_type == "schedule":
|
|
673
|
+
name = data.get("name")
|
|
674
|
+
location_id = data.get("location_id") or data.get("locationId", "")
|
|
675
|
+
if name and location_id:
|
|
676
|
+
params = {"name": name, "max": "100"}
|
|
677
|
+
if ctx.get("orgId"):
|
|
678
|
+
params["orgId"] = ctx["orgId"]
|
|
679
|
+
search_url = f"{BASE}/telephony/config/locations/{location_id}/schedules?{urlencode(params)}"
|
|
680
|
+
item_key = "schedules"
|
|
681
|
+
elif resource_type == "line_key_template":
|
|
682
|
+
name = data.get("name")
|
|
683
|
+
if name:
|
|
684
|
+
params = {"name": name, "max": "100"}
|
|
685
|
+
if ctx.get("orgId"):
|
|
686
|
+
params["orgId"] = ctx["orgId"]
|
|
687
|
+
search_url = f"{BASE}/telephony/config/devices/lineKeyTemplates?{urlencode(params)}"
|
|
688
|
+
item_key = "lineKeyTemplates"
|
|
689
|
+
elif resource_type in ("call_park", "pickup_group", "paging_group",
|
|
690
|
+
"hunt_group", "call_queue", "auto_attendant"):
|
|
691
|
+
# Location-scoped features — need location_id from deps
|
|
692
|
+
# These are harder to 409-recover because they need a location search.
|
|
693
|
+
# Skip recovery — let cascade handle it on next run.
|
|
694
|
+
pass
|
|
695
|
+
|
|
696
|
+
# For types without search support, return None
|
|
697
|
+
if not search_url:
|
|
698
|
+
return None
|
|
699
|
+
|
|
700
|
+
try:
|
|
701
|
+
async with semaphore:
|
|
702
|
+
async with session.get(search_url) as resp:
|
|
703
|
+
if resp.status == 200:
|
|
704
|
+
body = await resp.json()
|
|
705
|
+
items = body.get(item_key, []) if isinstance(body, dict) else body
|
|
706
|
+
if items and len(items) > 0:
|
|
707
|
+
return items[0].get("id")
|
|
708
|
+
except Exception:
|
|
709
|
+
pass
|
|
710
|
+
return None
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _build_fallback_context(op: dict, ctx: dict) -> dict | None:
|
|
714
|
+
"""Build fallback_context for a bulk op from its payload and resolved deps.
|
|
715
|
+
|
|
716
|
+
Returns None if the op's payload lacks ``covered_canonical_ids`` or
|
|
717
|
+
``fallback_handler_key`` — e.g. for ``bulk_rebuild_phones`` which has
|
|
718
|
+
no per-device fallback path.
|
|
719
|
+
|
|
720
|
+
Fix #5: when a covered_cid has no resolved webex_id (because its upstream
|
|
721
|
+
create op failed/skipped or hasn't run yet), the device cannot participate
|
|
722
|
+
in either the bulk submit or the per-device fallback. We log a WARNING
|
|
723
|
+
per-cid and surface the count via ``excluded_unresolved_count`` so callers
|
|
724
|
+
(and the operator-facing summary log in ``execute_bulk_op``) can account
|
|
725
|
+
for every device that was silently dropped from the fallback path.
|
|
726
|
+
"""
|
|
727
|
+
data = op.get("data", {})
|
|
728
|
+
covered_cids = data.get("covered_canonical_ids")
|
|
729
|
+
fallback_key = data.get("fallback_handler_key")
|
|
730
|
+
if not covered_cids or not fallback_key:
|
|
731
|
+
return None
|
|
732
|
+
|
|
733
|
+
resolved_deps = op.get("resolved_deps", {})
|
|
734
|
+
store = ctx.get("store")
|
|
735
|
+
|
|
736
|
+
covered_devices = []
|
|
737
|
+
excluded: list[str] = []
|
|
738
|
+
for cid in covered_cids:
|
|
739
|
+
webex_id = resolved_deps.get(cid)
|
|
740
|
+
if not webex_id:
|
|
741
|
+
# Fix #5: surface the silent drop so it lands in the logs and in
|
|
742
|
+
# the returned context, instead of a bare `continue`.
|
|
743
|
+
logger.warning(
|
|
744
|
+
"Bulk fallback: device %s not yet created, excluding from fallback",
|
|
745
|
+
cid,
|
|
746
|
+
)
|
|
747
|
+
excluded.append(cid)
|
|
748
|
+
continue
|
|
749
|
+
device_data = {}
|
|
750
|
+
if store:
|
|
751
|
+
obj = store.get_object(cid)
|
|
752
|
+
if obj:
|
|
753
|
+
device_data = obj if isinstance(obj, dict) else obj
|
|
754
|
+
covered_devices.append({
|
|
755
|
+
"canonical_id": cid,
|
|
756
|
+
"webex_id": webex_id,
|
|
757
|
+
"data": device_data,
|
|
758
|
+
})
|
|
759
|
+
|
|
760
|
+
return {
|
|
761
|
+
"fallback_handler_key": tuple(fallback_key),
|
|
762
|
+
"covered_devices": covered_devices,
|
|
763
|
+
"deps": resolved_deps,
|
|
764
|
+
"ctx": ctx,
|
|
765
|
+
"excluded_unresolved_count": len(excluded),
|
|
766
|
+
"excluded_canonical_ids": excluded,
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
async def run_batch_ops(
|
|
771
|
+
session: aiohttp.ClientSession,
|
|
772
|
+
tasks: list[dict],
|
|
773
|
+
semaphore: asyncio.Semaphore,
|
|
774
|
+
ctx: dict,
|
|
775
|
+
poll_interval: float = 5,
|
|
776
|
+
max_poll_time: float = 600,
|
|
777
|
+
) -> list[OpResult]:
|
|
778
|
+
"""Execute all tasks in one (batch, tier) group.
|
|
779
|
+
|
|
780
|
+
Each task is ``{"op": {...}, "calls": [(method, url, body), ...]}``.
|
|
781
|
+
Tasks whose op.resource_type is in ``SERIALIZED_RESOURCE_TYPES`` run
|
|
782
|
+
sequentially; all others run concurrently via ``asyncio.gather``.
|
|
783
|
+
Bulk ops dispatch to ``execute_bulk_op``; non-bulk ops use
|
|
784
|
+
``execute_single_op``.
|
|
785
|
+
|
|
786
|
+
Results are returned in the same order as the input tasks list so
|
|
787
|
+
that callers can zip(tasks, results) without misalignment.
|
|
788
|
+
"""
|
|
789
|
+
from wxcli.migration.execute import SERIALIZED_RESOURCE_TYPES
|
|
790
|
+
|
|
791
|
+
results: list[OpResult | None] = [None] * len(tasks)
|
|
792
|
+
|
|
793
|
+
parallel_indexed: list[tuple[int, dict]] = []
|
|
794
|
+
serial_indexed: list[tuple[int, dict]] = []
|
|
795
|
+
for idx, t in enumerate(tasks):
|
|
796
|
+
op = t["op"]
|
|
797
|
+
if op["resource_type"] in SERIALIZED_RESOURCE_TYPES:
|
|
798
|
+
serial_indexed.append((idx, t))
|
|
799
|
+
else:
|
|
800
|
+
parallel_indexed.append((idx, t))
|
|
801
|
+
|
|
802
|
+
# Kick off parallel ops
|
|
803
|
+
if parallel_indexed:
|
|
804
|
+
parallel_coros = [
|
|
805
|
+
execute_single_op(
|
|
806
|
+
session,
|
|
807
|
+
t["op"]["node_id"],
|
|
808
|
+
t["calls"],
|
|
809
|
+
semaphore,
|
|
810
|
+
# Fix #18: require a returned id/code for create ops so a
|
|
811
|
+
# silent "200 OK with empty body" cannot masquerade as a
|
|
812
|
+
# successful resource creation. Fix #3: also gate
|
|
813
|
+
# ``create_activation_code`` ops — convertible phones use
|
|
814
|
+
# this op_type and fall back to the response's ``code``
|
|
815
|
+
# field as the webex_id (see execute_single_op).
|
|
816
|
+
require_webex_id=(
|
|
817
|
+
t["op"].get("op_type") in ("create", "create_activation_code")
|
|
818
|
+
),
|
|
819
|
+
)
|
|
820
|
+
for _, t in parallel_indexed
|
|
821
|
+
]
|
|
822
|
+
parallel_results = await asyncio.gather(*parallel_coros, return_exceptions=True)
|
|
823
|
+
for (idx, t), res in zip(parallel_indexed, parallel_results):
|
|
824
|
+
if isinstance(res, Exception):
|
|
825
|
+
results[idx] = OpResult(
|
|
826
|
+
node_id=t["op"]["node_id"], status=0, error=str(res), body={},
|
|
827
|
+
)
|
|
828
|
+
else:
|
|
829
|
+
results[idx] = res
|
|
830
|
+
|
|
831
|
+
# Then serial bulk ops, one at a time
|
|
832
|
+
for idx, t in serial_indexed:
|
|
833
|
+
op = t["op"]
|
|
834
|
+
fallback_ctx = _build_fallback_context(op, ctx)
|
|
835
|
+
try:
|
|
836
|
+
res = await execute_bulk_op(
|
|
837
|
+
session,
|
|
838
|
+
node_id=op["node_id"],
|
|
839
|
+
resource_type=op["resource_type"],
|
|
840
|
+
calls=t["calls"],
|
|
841
|
+
semaphore=semaphore,
|
|
842
|
+
poll_interval=poll_interval,
|
|
843
|
+
max_poll_time=max_poll_time,
|
|
844
|
+
ctx=ctx,
|
|
845
|
+
fallback_context=fallback_ctx,
|
|
846
|
+
)
|
|
847
|
+
except Exception as e:
|
|
848
|
+
res = OpResult(node_id=op["node_id"], status=0, error=str(e), body={})
|
|
849
|
+
results[idx] = res
|
|
850
|
+
|
|
851
|
+
# At this point every slot should be filled; cast for the return type
|
|
852
|
+
return [r for r in results if r is not None] # type: ignore[misc]
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
async def execute_all_batches(
|
|
856
|
+
store: MigrationStore,
|
|
857
|
+
token: str,
|
|
858
|
+
concurrency: int = 20,
|
|
859
|
+
ctx: dict | None = None,
|
|
860
|
+
on_progress: Any = None,
|
|
861
|
+
) -> dict[str, int]:
|
|
862
|
+
"""Execute all pending operations in batch order.
|
|
863
|
+
|
|
864
|
+
Returns summary: {"completed": N, "failed": M, "skipped": K}
|
|
865
|
+
"""
|
|
866
|
+
ctx = ctx or {}
|
|
867
|
+
ctx.setdefault("store", store)
|
|
868
|
+
summary = {"completed": 0, "failed": 0, "skipped": 0, "batches": 0}
|
|
869
|
+
semaphore = asyncio.Semaphore(concurrency)
|
|
870
|
+
headers = {
|
|
871
|
+
"Authorization": f"Bearer {token}",
|
|
872
|
+
"Content-Type": "application/json",
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
async with aiohttp.ClientSession(headers=headers) as session:
|
|
876
|
+
while True:
|
|
877
|
+
batch = get_next_batch(store)
|
|
878
|
+
if not batch:
|
|
879
|
+
break
|
|
880
|
+
|
|
881
|
+
summary["batches"] += 1
|
|
882
|
+
batch_name = batch[0]["batch"] or "org-wide"
|
|
883
|
+
tier = batch[0]["tier"]
|
|
884
|
+
logger.info("Batch %s / tier %d: %d operations",
|
|
885
|
+
batch_name, tier, len(batch))
|
|
886
|
+
if on_progress:
|
|
887
|
+
on_progress(f"Batch: {batch_name} / tier {tier} ({len(batch)} ops)")
|
|
888
|
+
|
|
889
|
+
# Build tasks for this batch
|
|
890
|
+
tasks = []
|
|
891
|
+
for op in batch:
|
|
892
|
+
handler = HANDLER_REGISTRY.get(
|
|
893
|
+
(op["resource_type"], op["op_type"])
|
|
894
|
+
)
|
|
895
|
+
if not handler:
|
|
896
|
+
update_op_status(store, op["node_id"], "failed",
|
|
897
|
+
error_message=f"No handler for {op['resource_type']}:{op['op_type']}")
|
|
898
|
+
summary["failed"] += 1
|
|
899
|
+
continue
|
|
900
|
+
|
|
901
|
+
calls = handler(op["data"], op["resolved_deps"], ctx)
|
|
902
|
+
if isinstance(calls, SkippedResult):
|
|
903
|
+
# Handler declared a hard-prerequisite miss — record as
|
|
904
|
+
# 'skipped' so the report separates these from FAILED ops
|
|
905
|
+
# and cascades skip to dependents just like a failure.
|
|
906
|
+
update_op_status(
|
|
907
|
+
store, op["node_id"], "skipped",
|
|
908
|
+
error_message=calls.reason,
|
|
909
|
+
)
|
|
910
|
+
summary["skipped"] += 1
|
|
911
|
+
continue
|
|
912
|
+
if not calls:
|
|
913
|
+
# No-op (e.g., empty workspace assign_number)
|
|
914
|
+
update_op_status(store, op["node_id"], "completed")
|
|
915
|
+
summary["completed"] += 1
|
|
916
|
+
continue
|
|
917
|
+
|
|
918
|
+
tasks.append((op, calls))
|
|
919
|
+
|
|
920
|
+
# Execute all ops in this batch — serialized bulk types run sequentially.
|
|
921
|
+
if tasks:
|
|
922
|
+
results = await run_batch_ops(
|
|
923
|
+
session, [{"op": op, "calls": calls} for op, calls in tasks],
|
|
924
|
+
semaphore=semaphore, ctx=ctx,
|
|
925
|
+
)
|
|
926
|
+
|
|
927
|
+
for (op, _calls), result in zip(tasks, results):
|
|
928
|
+
op_status = "failed" # default
|
|
929
|
+
|
|
930
|
+
if isinstance(result, Exception):
|
|
931
|
+
update_op_status(store, op["node_id"], "failed",
|
|
932
|
+
error_message=str(result))
|
|
933
|
+
summary["failed"] += 1
|
|
934
|
+
elif result.success:
|
|
935
|
+
update_op_status(store, op["node_id"], "completed",
|
|
936
|
+
webex_id=result.webex_id)
|
|
937
|
+
summary["completed"] += 1
|
|
938
|
+
op_status = "completed"
|
|
939
|
+
elif result.status == 409:
|
|
940
|
+
# Auto-recover: search for existing resource
|
|
941
|
+
existing_id = await _try_find_existing(
|
|
942
|
+
session, semaphore,
|
|
943
|
+
op["resource_type"], op["data"], ctx,
|
|
944
|
+
)
|
|
945
|
+
if existing_id:
|
|
946
|
+
update_op_status(store, op["node_id"], "completed",
|
|
947
|
+
webex_id=existing_id)
|
|
948
|
+
summary["completed"] += 1
|
|
949
|
+
op_status = "completed"
|
|
950
|
+
logger.info("409 auto-recovered %s -> %s",
|
|
951
|
+
op["node_id"], existing_id)
|
|
952
|
+
else:
|
|
953
|
+
update_op_status(store, op["node_id"], "failed",
|
|
954
|
+
error_message=result.error)
|
|
955
|
+
summary["failed"] += 1
|
|
956
|
+
else:
|
|
957
|
+
update_op_status(store, op["node_id"], "failed",
|
|
958
|
+
error_message=result.error)
|
|
959
|
+
summary["failed"] += 1
|
|
960
|
+
|
|
961
|
+
if on_progress:
|
|
962
|
+
on_progress(f" {op['node_id']}: {op_status}")
|
|
963
|
+
|
|
964
|
+
return summary
|