vmware-aiops 2.4.1__tar.gz → 2.4.2__tar.gz
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.
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/.claude-plugin/plugin.json +1 -1
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/.mcp.json +1 -1
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/PKG-INFO +3 -3
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/RELEASE_NOTES.md +47 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/openclaw.plugin.json +1 -1
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/package.json +1 -1
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/pyproject.toml +3 -3
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/server.json +2 -2
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/skills/vmware-aiops/SKILL.md +2 -2
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/skills/vmware-aiops/references/setup-guide.md +5 -5
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_code_review_fixes.py +3 -3
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_daemon_calls_are_audited.py +8 -8
- vmware_aiops-2.4.2/tests/eval/regression/test_image_registry_cross_process.py +134 -0
- vmware_aiops-2.4.2/tests/eval/regression/test_image_registry_legacy_rows.py +150 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_login_without_vsphere_role.py +56 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_set_ttl_safety.py +12 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_cluster_ttl_plan_gate.py +2 -2
- vmware_aiops-2.4.2/tests/test_filelock.py +81 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_gate_review_fixes.py +2 -2
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_host_network_mgmt.py +10 -0
- vmware_aiops-2.4.2/tests/test_migrate_compatibility_check.py +405 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_plan_gate_narrow_fixes.py +3 -3
- vmware_aiops-2.4.2/tests/test_power_off_and_preview_policy.py +581 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/uv.lock +7 -7
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/__init__.py +1 -1
- vmware_aiops-2.4.2/vmware_aiops/_filelock.py +94 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/cli/cluster.py +7 -1
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/cli/deploy.py +3 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/cli/vm.py +38 -6
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/connection.py +39 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/mcp_server/tools/datastore.py +2 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/mcp_server/tools/guest.py +20 -7
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/mcp_server/tools/plan.py +9 -2
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/mcp_server/tools/ttl.py +31 -8
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/datastore_browser.py +69 -17
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/host_network_mgmt.py +16 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/plan_executor.py +28 -6
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/plan_gate.py +48 -2
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/step_policy.py +74 -3
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/ttl.py +19 -3
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/vm_gate.py +120 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/vm_lifecycle.py +28 -5
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/policy_roles.py +2 -1
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/scanner/scheduler.py +46 -2
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/.clawhubignore +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/.env.example +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/.github/dependabot.yml +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/.gitignore +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/Dockerfile +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/LICENSE +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/README-CN.md +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/README.md +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/SECURITY.md +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/config.example.yaml +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/docker-compose.yml +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/docs/integrations/continue.md +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/docs/integrations/cursor.md +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/docs/integrations/goose.md +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/docs/integrations/localcowork.md +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/docs/integrations/mcp-agent.md +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/docs/integrations/vscode-copilot.md +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/docs/plans/2026-03-09-plan-apply-design.md +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/examples/deploy.yaml +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/examples/mcp-configs/README.md +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/examples/mcp-configs/claude-code.json +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/examples/mcp-configs/continue.yaml +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/examples/mcp-configs/cursor.json +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/examples/mcp-configs/goose.json +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/examples/mcp-configs/localcowork.json +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/examples/mcp-configs/mcp-agent.yaml +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/examples/mcp-configs/vscode-copilot.json +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/examples/ollama-local-setup.md +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/skills/vmware-aiops/evals/evals.json +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/skills/vmware-aiops/references/agent-guardrails.md +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/skills/vmware-aiops/references/capabilities.md +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/skills/vmware-aiops/references/cli-reference.md +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/skills/vmware-aiops/references/investigation-protocol.md +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/smithery.yaml +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/__init__.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/conftest.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/__init__.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/capability/__init__.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/capability/_family.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/capability/_scores.json +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/capability/_scoring.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/capability/_skill.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/capability/conftest.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/capability/test_entity_reachability.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/capability/test_error_actionability.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/capability/test_tool_description_quality.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/capability/test_tool_manifest_budget.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/__init__.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/_diag_fakes.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/_pc_fakes.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_aiops_specific.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_alarm_health_scale_n1.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_atexit_handlers_do_not_accumulate.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_browse_ds_path_join.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_browse_sees_every_file.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_capability_grader.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_cli_destructive_guards.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_cli_writes_guarded.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_cluster_health_summary_delegation.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_config_path_resolution_is_shared.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_daemon_host_log_cycles.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_deploy_correctness.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_deploy_failures_are_failures.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_doctor_authenticates_every_target.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_documented_gates_match_the_registry.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_enum_params_match_implementation.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_env_secret_obfuscation.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_error_hints_fit_the_surface.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_event_sets_match_real_class_names.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_events_are_read_newest_first.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_guest_download_writes_the_local_disk.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_guest_username_is_explicit.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_host_log_scan_reads_real_logs.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_host_paths_protected.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_image_registry_per_target.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_instructions_name_targets.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_inventory_scale_issue31.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_linked_clone_failure_on_the_wire.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_list_envelope_conformance.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_list_plans_is_non_destructive.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_mcp_frame_marks_failures.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_ova_network_mapping.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_release_blockers.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_result_envelope.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_returned_failure_is_audited.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_session_probe_eviction.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_snapshot_async.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_text_io_declares_encoding.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_tool_annotations_match_behaviour.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_unreachable_host_vmks.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_v1_5_25_blockers.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_vim_attribute_conformance.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_write_round_low_findings.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_access_info.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_cluster_mgmt_sanitize.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_composite_tools_obey_policy.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_concurrent_local_state.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_config.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_drs_rules.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_guest_tools_gate.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_init_and_onboarding.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_network_mgmt.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_no_destructive_ops.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_plan_rollback_final_fixes.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_plan_steps_obey_policy.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_policy_roles.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_reset_alarm_gate.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_results_name_their_target.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_safe_error_passthrough.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_session_pool.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_stop_signal_runs_atexit.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_vm_delete_blast_radius.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/test_vm_tools_gate.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/_fsutil.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/access_requirements.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/cli/__init__.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/cli/_common.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/cli/_root.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/cli/access.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/cli/alarm.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/cli/doctor.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/cli/hub.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/cli/investigate.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/cli/mcp_config.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/cli/plan.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/cli/scan.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/cli/summary.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/config.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/doctor.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/init_wizard.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/mcp_server/__init__.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/mcp_server/__main__.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/mcp_server/_shared.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/mcp_server/server.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/mcp_server/tools/__init__.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/mcp_server/tools/access.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/mcp_server/tools/alarm.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/mcp_server/tools/cluster.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/mcp_server/tools/deploy.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/mcp_server/tools/network.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/mcp_server/tools/summary.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/mcp_server/tools/vm.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/notify/__init__.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/notify/audit.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/notify/logger.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/notify/webhook.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/__init__.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/_paging.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/alarm_mgmt.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/cluster_gate.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/cluster_mgmt.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/gate.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/guest_gate.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/guest_ops.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/health.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/inventory.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/iscsi_config.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/network_mgmt.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/ova_deploy.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/planner.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/vm_delete_gate.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/ops/vm_deploy.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/policy_environment.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/scanner/__init__.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/scanner/alarm_scanner.py +0 -0
- {vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/vmware_aiops/scanner/log_scanner.py +0 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "vmware-aiops",
|
|
4
4
|
"displayName": "VMware AIops",
|
|
5
|
-
"version": "2.4.
|
|
5
|
+
"version": "2.4.2",
|
|
6
6
|
"description": "AI-powered VMware vCenter/ESXi VM lifecycle and deployment with 61 MCP tools.",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "Wei Zhou",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: vmware-aiops
|
|
3
|
-
Version: 2.4.
|
|
3
|
+
Version: 2.4.2
|
|
4
4
|
Summary: VMware vCenter/ESXi AI-powered VM lifecycle and deployment tool
|
|
5
5
|
Project-URL: Homepage, https://github.com/vmware-skills/VMware-AIops
|
|
6
6
|
Project-URL: Repository, https://github.com/vmware-skills/VMware-AIops
|
|
@@ -12,13 +12,13 @@ License-File: LICENSE
|
|
|
12
12
|
Requires-Python: >=3.10
|
|
13
13
|
Requires-Dist: apscheduler<4.0,>=3.10
|
|
14
14
|
Requires-Dist: httpx<1.0,>=0.27
|
|
15
|
-
Requires-Dist: mcp[cli]<2.0,>=1.
|
|
15
|
+
Requires-Dist: mcp[cli]<2.0,>=1.30
|
|
16
16
|
Requires-Dist: pyaml<27.0,>=24.0
|
|
17
17
|
Requires-Dist: python-dotenv<2.0,>=1.0
|
|
18
18
|
Requires-Dist: pyvmomi<10.0,>=8.0.3.0
|
|
19
19
|
Requires-Dist: rich<16.0,>=13.0
|
|
20
20
|
Requires-Dist: typer<1.0,>=0.12
|
|
21
|
-
Requires-Dist: vmware-monitor<3.0,>=2.
|
|
21
|
+
Requires-Dist: vmware-monitor<3.0,>=2.4.2
|
|
22
22
|
Requires-Dist: vmware-policy<2.0,>=1.23.1
|
|
23
23
|
Description-Content-Type: text/markdown
|
|
24
24
|
|
|
@@ -1,3 +1,50 @@
|
|
|
1
|
+
## v2.4.2 — a login refused for lack of a role says so; an unreadable host is not called unreachable (2026-10-19)
|
|
2
|
+
|
|
3
|
+
Needs vmware-monitor 2.4.2 (now the floor). Found on a vCenter 8.0.3 lab with two restricted SSO accounts
|
|
4
|
+
(follow-up to AIops#47).
|
|
5
|
+
|
|
6
|
+
* **A login vCenter refuses with `NoPermission` gets the role error, not the raw fault.** For an account with no
|
|
7
|
+
permission anywhere, vCenter refuses the login itself (`System.View` on the root folder), so the post-login
|
|
8
|
+
no-role check never ran and `doctor` and every tool showed the raw fault with a connectivity hint. It now raises
|
|
9
|
+
the no-role error: the "no vSphere role / Global Permissions" remedy only for a vCenter target and `System.View`;
|
|
10
|
+
an ESXi target or another privilege gets a neutral text naming the (sanitized) privilege. A wrong password
|
|
11
|
+
(`InvalidLogin`) is unchanged. Same text as vmware-monitor.
|
|
12
|
+
* **vmk tools and `cluster info` no longer call an unreadable host unreachable.** When the account cannot read a
|
|
13
|
+
host's connection state, `list_host_vmks` rows and the vmk write tools' refusal say the account cannot read the
|
|
14
|
+
host (`reachable: false` means "not read"), instead of advising a reconnect. `cluster info` colours the state
|
|
15
|
+
yellow, not red.
|
|
16
|
+
* **From vmware-monitor 2.4.2 (through `get_cluster_health_summary` / `get_cross_vcenter_attention`):** a host this
|
|
17
|
+
account cannot read is counted `hosts_unreadable` with a warning issue `host_unreadable`, not ranked as a
|
|
18
|
+
critical `host_down`.
|
|
19
|
+
|
|
20
|
+
## Unreleased — follow-ups to the 2.4.1 review
|
|
21
|
+
|
|
22
|
+
* **Previews show policy refusals.** `restore_vm_clean_slate`, `provision_vm_guest` and
|
|
23
|
+
`run_vm_guest_command_with_output` were refused by `rules.yaml` only at `confirm=True`; the preview now lists that
|
|
24
|
+
refusal in `blast_radius.blockers`, so the user sees it before agreeing.
|
|
25
|
+
* **A delete that powers the VM off first is checked as `power_off_vm` too:** the CLI `vm delete`, the TTL daemon,
|
|
26
|
+
and a plan rollback deleting the VM it created (checked when the VM is running or its state cannot be read). The
|
|
27
|
+
TTL daemon records one `delete_vm` row per change of outcome, as for a deny on `delete_vm`, and keeps the entry.
|
|
28
|
+
**Behaviour change for custom network roles:** a role that allows `set_vm_ttl` must also allow `power_off_vm`.
|
|
29
|
+
* **Image cache:** writes take a lock across processes (a CLI and an MCP scan at the same time lost one side's
|
|
30
|
+
rows); a scan keeps rows on datastores it failed to browse (its own or legacy ones) and drops this target's rows
|
|
31
|
+
on datastores that no longer exist. A cache that cannot be written no longer fails the scan: the images come back
|
|
32
|
+
with a `cache_note`, as in vmware-storage.
|
|
33
|
+
* **Each call is checked for what it will do, at the moment it does it.** `restore_vm_clean_slate` asks about
|
|
34
|
+
`power_off_vm` only when it actually powers the VM off; `provision_vm_guest` asks about the guest tools its step
|
|
35
|
+
types use (an exec-only provision is not refused by a deny on `upload_vm_guest_file`); a plan rollback asks about
|
|
36
|
+
the power-off after confirming it is the VM the plan created, just before powering it off.
|
|
37
|
+
* **`set_vm_ttl` is checked as the deletion it schedules:** the preview lists a deny on `delete_vm` (and on
|
|
38
|
+
`power_off_vm` for a running VM), and `confirm=True` refuses it, instead of scheduling a TTL the daemon can never
|
|
39
|
+
act on. The daemon still asks again at expiry.
|
|
40
|
+
* **`migrate_vm` previews run vCenter's own vMotion compatibility check** (`CheckMigrate_Task`, read-only): its
|
|
41
|
+
errors (CPU features, devices, networks, datastores) become blockers, with the CPU-feature remedy where it applies,
|
|
42
|
+
so a move vCenter would refuse is refused in the preview and before the CLI asks for confirmation; warnings are
|
|
43
|
+
shown and do not block. On a standalone ESXi host, or when the check cannot run, the preview says "not checked"
|
|
44
|
+
and does not block (vCenter checks again when the move starts). Plan migrate steps get the same check.
|
|
45
|
+
* The TTL store takes the same cross-process lock as the image cache (`set_vm_ttl` and the daemon run in separate
|
|
46
|
+
processes).
|
|
47
|
+
|
|
1
48
|
## v2.4.1 — fixes from the 2026-10-17 real-hardware read and write rounds
|
|
2
49
|
|
|
3
50
|
* **Tools that run other tools' operations obey `rules.yaml` for those tools**, on the MCP and CLI surfaces.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "vmware-aiops"
|
|
7
|
-
version = "2.4.
|
|
7
|
+
version = "2.4.2"
|
|
8
8
|
description = "VMware vCenter/ESXi AI-powered VM lifecycle and deployment tool"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -18,12 +18,12 @@ dependencies = [
|
|
|
18
18
|
"apscheduler>=3.10,<4.0",
|
|
19
19
|
"httpx>=0.27,<1.0",
|
|
20
20
|
"python-dotenv>=1.0,<2.0",
|
|
21
|
-
"mcp[cli]>=1.
|
|
21
|
+
"mcp[cli]>=1.30,<2.0",
|
|
22
22
|
"vmware-policy>=1.23.1,<2.0",
|
|
23
23
|
# Read-only cluster-health triage (summary + HTML snapshot) is owned by
|
|
24
24
|
# vmware-monitor; AIops delegates to it so a conversation with the AIops
|
|
25
25
|
# entry point can produce the same report without duplicating the code.
|
|
26
|
-
"vmware-monitor>=2.
|
|
26
|
+
"vmware-monitor>=2.4.2,<3.0", # unreadable host != down, login refused -> role error
|
|
27
27
|
]
|
|
28
28
|
|
|
29
29
|
|
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"url": "https://github.com/vmware-skills/VMware-AIops",
|
|
8
8
|
"source": "github"
|
|
9
9
|
},
|
|
10
|
-
"version": "2.4.
|
|
10
|
+
"version": "2.4.2",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "pypi",
|
|
14
14
|
"identifier": "vmware-aiops",
|
|
15
|
-
"version": "2.4.
|
|
15
|
+
"version": "2.4.2",
|
|
16
16
|
"runtimeHint": "uvx",
|
|
17
17
|
"packageArguments": [
|
|
18
18
|
{
|
|
@@ -63,7 +63,7 @@ Read before connecting an agent. Per-tool inventory: `references/capabilities.md
|
|
|
63
63
|
## Quick Install
|
|
64
64
|
|
|
65
65
|
```bash
|
|
66
|
-
uv tool install vmware-aiops==2.4.
|
|
66
|
+
uv tool install vmware-aiops==2.4.2
|
|
67
67
|
vmware-aiops doctor
|
|
68
68
|
vmware-aiops hub status # see which family members are installed
|
|
69
69
|
```
|
|
@@ -303,7 +303,7 @@ The account has no vSphere role. Assign one (Read-Only or higher) on the vCenter
|
|
|
303
303
|
## Setup
|
|
304
304
|
|
|
305
305
|
```bash
|
|
306
|
-
uv tool install vmware-aiops==2.4.
|
|
306
|
+
uv tool install vmware-aiops==2.4.2
|
|
307
307
|
mkdir -p ~/.vmware-aiops
|
|
308
308
|
vmware-aiops init # generates config.yaml and .env templates
|
|
309
309
|
chmod 600 ~/.vmware-aiops/.env
|
|
@@ -6,13 +6,13 @@ All install methods fetch from the same source: [github.com/vmware-skills/VMware
|
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
# Via PyPI (recommended for version pinning)
|
|
9
|
-
uv tool install vmware-aiops==2.4.
|
|
9
|
+
uv tool install vmware-aiops==2.4.2
|
|
10
10
|
|
|
11
11
|
# Via Skills.sh (fetches from GitHub)
|
|
12
|
-
npx skills add vmware-skills/VMware-AIops#v2.4.
|
|
12
|
+
npx skills add vmware-skills/VMware-AIops#v2.4.2
|
|
13
13
|
|
|
14
14
|
# Via ClawHub (fetches from ClawHub registry snapshot of GitHub)
|
|
15
|
-
clawhub install @zw008/vmware-aiops --version 2.4.
|
|
15
|
+
clawhub install @zw008/vmware-aiops --version 2.4.2
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
### Claude Code
|
|
@@ -35,7 +35,7 @@ claude mcp add vmware-aiops -- vmware-aiops mcp
|
|
|
35
35
|
|
|
36
36
|
```bash
|
|
37
37
|
# 1. Install from PyPI (source: github.com/vmware-skills/VMware-AIops)
|
|
38
|
-
uv tool install vmware-aiops==2.4.
|
|
38
|
+
uv tool install vmware-aiops==2.4.2
|
|
39
39
|
|
|
40
40
|
# 2. Verify installation source
|
|
41
41
|
vmware-aiops --version # confirms installed version
|
|
@@ -73,7 +73,7 @@ The `vmware-aiops` package installs a Python CLI binary and its dependencies (py
|
|
|
73
73
|
## Development Install
|
|
74
74
|
|
|
75
75
|
```bash
|
|
76
|
-
git clone --branch v2.4.
|
|
76
|
+
git clone --branch v2.4.2 https://github.com/vmware-skills/VMware-AIops.git
|
|
77
77
|
cd VMware-AIops
|
|
78
78
|
uv venv && source .venv/bin/activate
|
|
79
79
|
# --no-sources: pyproject's [tool.uv.sources] points vmware-monitor at a sibling
|
|
@@ -127,7 +127,7 @@ def test_ttl_entry_kept_on_transient_failure(monkeypatch: pytest.MonkeyPatch) ->
|
|
|
127
127
|
monkeypatch.setattr(scheduler, "remove_entry", removed.append)
|
|
128
128
|
monkeypatch.setattr(
|
|
129
129
|
scheduler, "delete_vm",
|
|
130
|
-
lambda si, name: (_ for _ in ()).throw(RuntimeError("vCenter task timeout")),
|
|
130
|
+
lambda si, name, **k: (_ for _ in ()).throw(RuntimeError("vCenter task timeout")),
|
|
131
131
|
)
|
|
132
132
|
|
|
133
133
|
scheduler._run_ttl_check(MagicMock())
|
|
@@ -143,7 +143,7 @@ def test_ttl_entry_removed_when_vm_already_gone(monkeypatch: pytest.MonkeyPatch)
|
|
|
143
143
|
monkeypatch.setattr(scheduler, "remove_entry", removed.append)
|
|
144
144
|
monkeypatch.setattr(
|
|
145
145
|
scheduler, "delete_vm",
|
|
146
|
-
lambda si, name: (_ for _ in ()).throw(VMNotFoundError("VM 'ttl-vm' not found")),
|
|
146
|
+
lambda si, name, **k: (_ for _ in ()).throw(VMNotFoundError("VM 'ttl-vm' not found")),
|
|
147
147
|
)
|
|
148
148
|
|
|
149
149
|
scheduler._run_ttl_check(MagicMock())
|
|
@@ -156,7 +156,7 @@ def test_ttl_entry_removed_on_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
156
156
|
removed: list[str] = []
|
|
157
157
|
monkeypatch.setattr(scheduler, "get_expired_entries", lambda: [_ttl_entry()])
|
|
158
158
|
monkeypatch.setattr(scheduler, "remove_entry", removed.append)
|
|
159
|
-
monkeypatch.setattr(scheduler, "delete_vm", lambda si, name: f"VM '{name}' deleted")
|
|
159
|
+
monkeypatch.setattr(scheduler, "delete_vm", lambda si, name, **k: f"VM '{name}' deleted")
|
|
160
160
|
|
|
161
161
|
scheduler._run_ttl_check(MagicMock())
|
|
162
162
|
assert removed == ["ttl-vm"]
|
{vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_daemon_calls_are_audited.py
RENAMED
|
@@ -71,7 +71,7 @@ def ttl(monkeypatch):
|
|
|
71
71
|
|
|
72
72
|
def test_a_ttl_delete_writes_one_vm_delete_row(ttl, monkeypatch):
|
|
73
73
|
scheduler, removed = ttl
|
|
74
|
-
monkeypatch.setattr(scheduler, "delete_vm", lambda si, name: f"VM '{name}' deleted")
|
|
74
|
+
monkeypatch.setattr(scheduler, "delete_vm", lambda si, name, **k: f"VM '{name}' deleted")
|
|
75
75
|
|
|
76
76
|
scheduler._run_ttl_check(MagicMock())
|
|
77
77
|
|
|
@@ -88,7 +88,7 @@ def test_a_ttl_delete_writes_one_vm_delete_row(ttl, monkeypatch):
|
|
|
88
88
|
def test_a_failed_ttl_delete_is_recorded_as_error_and_the_entry_kept(ttl, monkeypatch):
|
|
89
89
|
scheduler, removed = ttl
|
|
90
90
|
|
|
91
|
-
def boom(si, name):
|
|
91
|
+
def boom(si, name, **k):
|
|
92
92
|
raise RuntimeError("vCenter task timeout")
|
|
93
93
|
|
|
94
94
|
monkeypatch.setattr(scheduler, "delete_vm", boom)
|
|
@@ -106,7 +106,7 @@ def test_a_ttl_vm_already_gone_is_recorded_and_the_stale_entry_dropped(ttl, monk
|
|
|
106
106
|
|
|
107
107
|
scheduler, removed = ttl
|
|
108
108
|
|
|
109
|
-
def gone(si, name):
|
|
109
|
+
def gone(si, name, **k):
|
|
110
110
|
raise VMNotFoundError(f"VM '{name}' not found")
|
|
111
111
|
|
|
112
112
|
monkeypatch.setattr(scheduler, "delete_vm", gone)
|
|
@@ -122,7 +122,7 @@ def test_a_ttl_vm_already_gone_is_recorded_and_the_stale_entry_dropped(ttl, monk
|
|
|
122
122
|
def test_a_ttl_delete_refused_by_policy_never_runs(ttl, monkeypatch):
|
|
123
123
|
scheduler, removed = ttl
|
|
124
124
|
deleted: list[str] = []
|
|
125
|
-
monkeypatch.setattr(scheduler, "delete_vm", lambda si, name: deleted.append(name))
|
|
125
|
+
monkeypatch.setattr(scheduler, "delete_vm", lambda si, name, **k: deleted.append(name))
|
|
126
126
|
|
|
127
127
|
def deny(skill, tool, params=None, *, risk_level="low", target=""):
|
|
128
128
|
raise PolicyDenied(
|
|
@@ -144,7 +144,7 @@ def test_a_refused_ttl_entry_writes_one_row_per_outcome_not_one_per_minute(ttl,
|
|
|
144
144
|
failing entry wrote a delete_vm row on every run — 1,440 a day per entry, on
|
|
145
145
|
this lab whose rules.yaml denies delete_vm — burying real calls."""
|
|
146
146
|
scheduler, removed = ttl
|
|
147
|
-
monkeypatch.setattr(scheduler, "delete_vm", lambda si, name: None)
|
|
147
|
+
monkeypatch.setattr(scheduler, "delete_vm", lambda si, name, **k: None)
|
|
148
148
|
|
|
149
149
|
def deny(skill, tool, params=None, *, risk_level="low", target=""):
|
|
150
150
|
raise PolicyDenied(
|
|
@@ -165,7 +165,7 @@ def test_a_failing_ttl_delete_writes_again_only_when_its_outcome_changes(ttl, mo
|
|
|
165
165
|
scheduler, removed = ttl
|
|
166
166
|
attempts: list[str] = []
|
|
167
167
|
|
|
168
|
-
def flaky(si, name):
|
|
168
|
+
def flaky(si, name, **k):
|
|
169
169
|
attempts.append(name)
|
|
170
170
|
if len(attempts) <= 3:
|
|
171
171
|
raise RuntimeError("vCenter task timeout")
|
|
@@ -315,7 +315,7 @@ def test_a_ttl_vm_whose_name_is_ambiguous_is_not_deleted_and_the_entry_dropped(t
|
|
|
315
315
|
|
|
316
316
|
scheduler, removed = ttl
|
|
317
317
|
|
|
318
|
-
def ambiguous(si, name):
|
|
318
|
+
def ambiguous(si, name, **k):
|
|
319
319
|
raise AmbiguousVMError(f"2 VMs are named '{name}'.")
|
|
320
320
|
|
|
321
321
|
monkeypatch.setattr(scheduler, "delete_vm", ambiguous)
|
|
@@ -332,7 +332,7 @@ def test_daemon_rows_say_daemon_not_mcp(ttl, monkeypatch):
|
|
|
332
332
|
"""HLD §16 D-14: a TTL delete made by the daemon is not an MCP call. Without
|
|
333
333
|
an explicit surface the row read `mcp-stdio` — non-empty, and wrong."""
|
|
334
334
|
scheduler, _removed = ttl
|
|
335
|
-
monkeypatch.setattr(scheduler, "delete_vm", lambda si, name: f"VM '{name}' deleted")
|
|
335
|
+
monkeypatch.setattr(scheduler, "delete_vm", lambda si, name, **k: f"VM '{name}' deleted")
|
|
336
336
|
|
|
337
337
|
scheduler._run_ttl_check(MagicMock())
|
|
338
338
|
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Two processes scanning at once must not drop each other's cached images.
|
|
2
|
+
|
|
3
|
+
Review 2026-10-18: the registry's read-modify-write was guarded by a
|
|
4
|
+
``threading.Lock``, which only serialises one process. A CLI scan and an MCP
|
|
5
|
+
server scan running side by side each read the old file, and the last
|
|
6
|
+
``os.replace`` wrote the other's rows away. The atomic replace stops a reader
|
|
7
|
+
seeing half a file; it does nothing for a lost update.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import textwrap
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import pytest
|
|
21
|
+
|
|
22
|
+
from vmware_aiops.ops import datastore_browser as dsb
|
|
23
|
+
|
|
24
|
+
_WRITERS = 4
|
|
25
|
+
|
|
26
|
+
_CHILD = textwrap.dedent(
|
|
27
|
+
"""
|
|
28
|
+
import sys, time
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from vmware_aiops.ops import datastore_browser as dsb
|
|
31
|
+
|
|
32
|
+
reg, target = Path(sys.argv[1]), sys.argv[2]
|
|
33
|
+
dsb.IMAGE_REGISTRY_FILE = reg
|
|
34
|
+
dsb.CONFIG_DIR = reg.parent
|
|
35
|
+
real_load = dsb._load_registry
|
|
36
|
+
|
|
37
|
+
def slow_load():
|
|
38
|
+
# Widen the read-modify-write window so the race is not left to luck.
|
|
39
|
+
data = real_load()
|
|
40
|
+
time.sleep(0.3)
|
|
41
|
+
return data
|
|
42
|
+
|
|
43
|
+
dsb._load_registry = slow_load
|
|
44
|
+
(reg.parent / f"ready-{target}").touch()
|
|
45
|
+
go = reg.parent / "go"
|
|
46
|
+
deadline = time.monotonic() + 60
|
|
47
|
+
while not go.exists():
|
|
48
|
+
if time.monotonic() > deadline:
|
|
49
|
+
sys.exit("parent never said go")
|
|
50
|
+
time.sleep(0.01)
|
|
51
|
+
img = {"name": f"{target}.iso", "ds_path": f"[datastore1] {target}.iso",
|
|
52
|
+
"size_mb": 1.0, "type": "File", "modified": ""}
|
|
53
|
+
dsb.scan_all_datastores = lambda si: {"datastore1": [img]}
|
|
54
|
+
dsb.update_registry(object(), target=target)
|
|
55
|
+
"""
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _child_env() -> dict[str, str]:
|
|
60
|
+
env = dict(os.environ)
|
|
61
|
+
# The children must import exactly the code this test process imported.
|
|
62
|
+
env["PYTHONPATH"] = os.pathsep.join(p for p in sys.path if p)
|
|
63
|
+
return env
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _run_writers(reg: Path, script: str, targets: list[str]) -> None:
|
|
67
|
+
procs = [
|
|
68
|
+
subprocess.Popen([sys.executable, "-c", script, str(reg), t], env=_child_env())
|
|
69
|
+
for t in targets
|
|
70
|
+
]
|
|
71
|
+
try:
|
|
72
|
+
deadline = time.monotonic() + 60
|
|
73
|
+
while not all((reg.parent / f"ready-{t}").exists() for t in targets):
|
|
74
|
+
assert time.monotonic() < deadline, "writers never became ready"
|
|
75
|
+
assert all(p.poll() is None for p in procs), "a writer exited early"
|
|
76
|
+
time.sleep(0.02)
|
|
77
|
+
(reg.parent / "go").touch()
|
|
78
|
+
for p in procs:
|
|
79
|
+
assert p.wait(timeout=60) == 0
|
|
80
|
+
finally:
|
|
81
|
+
for p in procs:
|
|
82
|
+
if p.poll() is None:
|
|
83
|
+
p.kill()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@pytest.mark.unit
|
|
87
|
+
def test_concurrent_scans_in_separate_processes_keep_every_row(tmp_path) -> None:
|
|
88
|
+
reg = tmp_path / "image_registry.json"
|
|
89
|
+
targets = [f"t{i}" for i in range(_WRITERS)]
|
|
90
|
+
|
|
91
|
+
_run_writers(reg, _CHILD, targets)
|
|
92
|
+
|
|
93
|
+
rows = json.loads(reg.read_text(encoding="utf-8"))["images"]
|
|
94
|
+
assert sorted(r["target"] for r in rows) == targets
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@pytest.fixture
|
|
98
|
+
def registry(tmp_path, monkeypatch):
|
|
99
|
+
path = tmp_path / "image_registry.json"
|
|
100
|
+
monkeypatch.setattr(dsb, "IMAGE_REGISTRY_FILE", path)
|
|
101
|
+
monkeypatch.setattr(dsb, "CONFIG_DIR", tmp_path)
|
|
102
|
+
img = {"name": "x.iso", "ds_path": "[ds1] x.iso", "size_mb": 1.0,
|
|
103
|
+
"type": "File", "modified": ""}
|
|
104
|
+
monkeypatch.setattr(dsb, "scan_all_datastores", lambda si: {"ds1": [img]})
|
|
105
|
+
return path
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _hold_lock(reg: Path):
|
|
109
|
+
fcntl = pytest.importorskip("fcntl")
|
|
110
|
+
fh = open(reg.with_name(reg.name + ".lock"), "a+b") # noqa: SIM115
|
|
111
|
+
fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
112
|
+
return fh
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@pytest.mark.unit
|
|
116
|
+
def test_a_held_lock_is_a_bounded_wait_that_names_the_lock_file(registry, monkeypatch) -> None:
|
|
117
|
+
monkeypatch.setattr(dsb, "REGISTRY_LOCK_TIMEOUT_S", 0.3, raising=False)
|
|
118
|
+
holder = _hold_lock(registry)
|
|
119
|
+
try:
|
|
120
|
+
started = time.monotonic()
|
|
121
|
+
# The scan is returned; the note says the cache was not updated and why.
|
|
122
|
+
out = dsb.update_registry(object(), target="vc")
|
|
123
|
+
assert "image_registry.json.lock" in out["cache_note"]
|
|
124
|
+
assert time.monotonic() - started < 5
|
|
125
|
+
finally:
|
|
126
|
+
holder.close()
|
|
127
|
+
assert not registry.exists(), "nothing may be written without the lock"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@pytest.mark.unit
|
|
131
|
+
def test_the_lock_is_released_after_a_write(registry) -> None:
|
|
132
|
+
dsb.update_registry(object(), target="vc")
|
|
133
|
+
holder = _hold_lock(registry) # would raise BlockingIOError if still held
|
|
134
|
+
holder.close()
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""A scan replaces only the legacy rows it can attribute (review 2026-10-18).
|
|
2
|
+
|
|
3
|
+
Legacy rows are the ones written before rows carried ``target``. A scan of one
|
|
4
|
+
target deleted every legacy row in the file, including rows for datastores that
|
|
5
|
+
target does not have — the cache lost images no scan had looked at again.
|
|
6
|
+
VMware-Storage's ``_record_scan`` replaces a legacy row only when the scan
|
|
7
|
+
covered its datastore (and folder); this follows the same rule. A whole-target
|
|
8
|
+
scan covers each datastore it browsed successfully, whether or not it found
|
|
9
|
+
images there.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
from vmware_aiops.ops import datastore_browser as dsb
|
|
19
|
+
from vmware_aiops.ops import inventory
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _legacy(ds: str, name: str) -> dict:
|
|
23
|
+
return {"datastore": ds, "name": name, "ds_path": f"[{ds}] {name}",
|
|
24
|
+
"size_mb": 1.0, "type": "File", "modified": ""}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _img(ds: str, name: str) -> dict:
|
|
28
|
+
return {"name": name, "ds_path": f"[{ds}] {name}", "size_mb": 1.0,
|
|
29
|
+
"type": "File", "modified": ""}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.fixture
|
|
33
|
+
def registry(tmp_path, monkeypatch):
|
|
34
|
+
path = tmp_path / "image_registry.json"
|
|
35
|
+
monkeypatch.setattr(dsb, "IMAGE_REGISTRY_FILE", path)
|
|
36
|
+
monkeypatch.setattr(dsb, "CONFIG_DIR", tmp_path)
|
|
37
|
+
return path
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _seed(path, rows: list[dict]) -> None:
|
|
41
|
+
path.write_text(json.dumps({"images": rows, "last_scan": None}), encoding="utf-8")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _names() -> list[str]:
|
|
45
|
+
return sorted(i["name"] for i in dsb.list_images())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.mark.unit
|
|
49
|
+
def test_legacy_rows_on_datastores_the_scan_did_not_cover_survive(registry, monkeypatch) -> None:
|
|
50
|
+
_seed(registry, [_legacy("datastore1", "old.iso"), _legacy("nfs-b", "keep.iso")])
|
|
51
|
+
monkeypatch.setattr(dsb, "scan_all_datastores",
|
|
52
|
+
lambda si: {"datastore1": [_img("datastore1", "new.iso")]})
|
|
53
|
+
|
|
54
|
+
dsb.update_registry(object(), target="vc")
|
|
55
|
+
|
|
56
|
+
assert _names() == ["keep.iso", "new.iso"]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@pytest.mark.unit
|
|
60
|
+
def test_other_targets_rows_survive(registry, monkeypatch) -> None:
|
|
61
|
+
other = dict(_legacy("datastore1", "b.iso"), target="esx")
|
|
62
|
+
_seed(registry, [other])
|
|
63
|
+
monkeypatch.setattr(dsb, "scan_all_datastores",
|
|
64
|
+
lambda si: {"datastore1": [_img("datastore1", "a.iso")]})
|
|
65
|
+
|
|
66
|
+
dsb.update_registry(object(), target="vc")
|
|
67
|
+
|
|
68
|
+
assert _names() == ["a.iso", "b.iso"]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _datastores(monkeypatch, names: list[str], found: dict[str, list[dict] | Exception]) -> None:
|
|
72
|
+
monkeypatch.setattr(inventory, "list_datastores",
|
|
73
|
+
lambda si: [{"name": n, "accessible": True} for n in names])
|
|
74
|
+
|
|
75
|
+
def fake_scan(si, ds_name, path=""):
|
|
76
|
+
res = found.get(ds_name, [])
|
|
77
|
+
if isinstance(res, Exception):
|
|
78
|
+
raise res
|
|
79
|
+
return res
|
|
80
|
+
|
|
81
|
+
monkeypatch.setattr(dsb, "scan_images", fake_scan)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@pytest.mark.unit
|
|
85
|
+
def test_a_datastore_scanned_empty_still_replaces_its_legacy_rows(registry, monkeypatch) -> None:
|
|
86
|
+
_seed(registry, [_legacy("empty-ds", "gone.iso")])
|
|
87
|
+
_datastores(monkeypatch, ["empty-ds"], {})
|
|
88
|
+
|
|
89
|
+
dsb.update_registry(object(), target="vc")
|
|
90
|
+
|
|
91
|
+
assert _names() == []
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@pytest.mark.unit
|
|
95
|
+
def test_a_datastore_whose_scan_failed_keeps_its_legacy_rows(registry, monkeypatch) -> None:
|
|
96
|
+
_seed(registry, [_legacy("flaky-ds", "keep.iso")])
|
|
97
|
+
_datastores(monkeypatch, ["flaky-ds", "ok-ds"],
|
|
98
|
+
{"flaky-ds": RuntimeError("browse failed"), "ok-ds": [_img("ok-ds", "n.iso")]})
|
|
99
|
+
|
|
100
|
+
dsb.update_registry(object(), target="vc")
|
|
101
|
+
|
|
102
|
+
assert _names() == ["keep.iso", "n.iso"]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@pytest.mark.unit
|
|
106
|
+
def test_this_targets_rows_on_a_datastore_it_did_not_browse_survive(registry, monkeypatch) -> None:
|
|
107
|
+
"""Review 2026-10-18: a failed or skipped browse is not "no images there"."""
|
|
108
|
+
_seed(registry, [dict(_legacy("ds2", "mine.iso"), target="vc"),
|
|
109
|
+
dict(_legacy("datastore1", "stale.iso"), target="vc")])
|
|
110
|
+
monkeypatch.setattr(dsb, "scan_all_datastores",
|
|
111
|
+
lambda si: {"datastore1": [_img("datastore1", "new.iso")], "ds2": None})
|
|
112
|
+
|
|
113
|
+
dsb.update_registry(object(), target="vc")
|
|
114
|
+
|
|
115
|
+
assert _names() == ["mine.iso", "new.iso"]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@pytest.mark.unit
|
|
119
|
+
def test_this_targets_rows_on_a_datastore_that_is_gone_are_dropped(registry, monkeypatch) -> None:
|
|
120
|
+
"""Narrow review 2026-10-18: not listed at all is not "failed to browse"."""
|
|
121
|
+
_seed(registry, [dict(_legacy("removed-ds", "ghost.iso"), target="vc"),
|
|
122
|
+
dict(_legacy("flaky-ds", "kept.iso"), target="vc")])
|
|
123
|
+
_datastores(monkeypatch, ["ds1", "flaky-ds"],
|
|
124
|
+
{"ds1": [_img("ds1", "new.iso")], "flaky-ds": RuntimeError("browse failed")})
|
|
125
|
+
|
|
126
|
+
dsb.update_registry(object(), target="vc")
|
|
127
|
+
|
|
128
|
+
assert _names() == ["kept.iso", "new.iso"]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@pytest.mark.unit
|
|
132
|
+
def test_a_scan_whose_cache_cannot_be_locked_still_returns_its_images(registry, monkeypatch) -> None:
|
|
133
|
+
"""Narrow review 2026-10-18: Storage returns the scan with a cache_note; so does AIops."""
|
|
134
|
+
import errno
|
|
135
|
+
|
|
136
|
+
from vmware_aiops import _filelock
|
|
137
|
+
|
|
138
|
+
monkeypatch.setattr(dsb, "scan_all_datastores",
|
|
139
|
+
lambda si: {"ds1": [_img("ds1", "new.iso")]})
|
|
140
|
+
if _filelock.fcntl is None:
|
|
141
|
+
pytest.skip("POSIX branch")
|
|
142
|
+
|
|
143
|
+
def no_locks(fd, op):
|
|
144
|
+
raise OSError(errno.ENOLCK, "No locks available")
|
|
145
|
+
|
|
146
|
+
monkeypatch.setattr(_filelock.fcntl, "flock", no_locks)
|
|
147
|
+
out = dsb.update_registry(object(), target="vc")
|
|
148
|
+
|
|
149
|
+
assert [i["name"] for i in out["images"]] == ["new.iso"]
|
|
150
|
+
assert "cache" in out["cache_note"].lower()
|
{vmware_aiops-2.4.1 → vmware_aiops-2.4.2}/tests/eval/regression/test_login_without_vsphere_role.py
RENAMED
|
@@ -223,3 +223,59 @@ def test_mcp_not_authenticated_names_both_causes() -> None:
|
|
|
223
223
|
assert "credentials" not in payload["hint"]
|
|
224
224
|
text = _mcp_payload(vim.fault.NotAuthenticated(), shape="str")
|
|
225
225
|
assert "vSphere role" in text and "verify connectivity and credentials" not in text
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# 2026-10-18, lab vCenter 8.0.3, SSO account with no permission anywhere:
|
|
229
|
+
# vCenter refuses the *login itself* with NoPermission (System.View on the root
|
|
230
|
+
# folder). Before the fix that raw fault reached doctor and every tool.
|
|
231
|
+
def _login_refused(exc: Exception):
|
|
232
|
+
mgr = ConnectionManager(_config("vc1"))
|
|
233
|
+
with patch("pyVim.connect.SmartConnect", side_effect=exc):
|
|
234
|
+
try:
|
|
235
|
+
mgr.connect("vc1")
|
|
236
|
+
except Exception as e: # noqa: BLE001 — the test inspects whatever is raised
|
|
237
|
+
return e
|
|
238
|
+
raise AssertionError("connect did not raise")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_login_refused_with_no_permission_names_the_missing_role() -> None:
|
|
242
|
+
fault = vim.fault.NoPermission(
|
|
243
|
+
privilegeId="System.View", msg="Permission to perform this operation was denied."
|
|
244
|
+
)
|
|
245
|
+
err = _login_refused(fault)
|
|
246
|
+
assert isinstance(err, NoVSphereRoleError)
|
|
247
|
+
msg = str(err)
|
|
248
|
+
assert "no vSphere role" in msg and "Read-Only" in msg and "'vc1'" in msg
|
|
249
|
+
assert "dynamicType" not in msg and "operation failed" not in msg
|
|
250
|
+
assert len(msg) <= 300
|
|
251
|
+
assert err.__cause__ is fault
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_wrong_password_is_not_reported_as_a_missing_role() -> None:
|
|
255
|
+
err = _login_refused(vim.fault.InvalidLogin())
|
|
256
|
+
assert not isinstance(err, NoVSphereRoleError)
|
|
257
|
+
assert isinstance(err, vim.fault.InvalidLogin)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@pytest.mark.parametrize(
|
|
261
|
+
("privilege", "esxi"),
|
|
262
|
+
[("System.View", True), ("Sessions.ValidateSession", False), (None, False)],
|
|
263
|
+
)
|
|
264
|
+
def test_other_login_refusals_get_a_neutral_remedy(privilege, esxi) -> None:
|
|
265
|
+
# Review 2026-10-18 (M3): an ESXi host (lockdown mode, host permissions) or
|
|
266
|
+
# another privilege must not be sent to vCenter Global Permissions.
|
|
267
|
+
from dataclasses import replace
|
|
268
|
+
|
|
269
|
+
fault = vim.fault.NoPermission(privilegeId=privilege)
|
|
270
|
+
mgr = ConnectionManager(_config("vc1"))
|
|
271
|
+
if esxi:
|
|
272
|
+
esxi_targets = tuple(replace(t, type="esxi") for t in mgr._config.targets)
|
|
273
|
+
mgr._config = replace(mgr._config, targets=esxi_targets)
|
|
274
|
+
with patch("pyVim.connect.SmartConnect", side_effect=fault):
|
|
275
|
+
with pytest.raises(Exception) as info:
|
|
276
|
+
mgr.connect("vc1")
|
|
277
|
+
msg = str(info.value)
|
|
278
|
+
assert "Global Permissions" not in msg and "lockdown mode" in msg
|
|
279
|
+
if privilege:
|
|
280
|
+
assert f"({privilege})" in msg
|
|
281
|
+
assert info.value.__cause__ is fault and len(msg) <= 300
|