sima-cli 2.1.20__tar.gz → 2.1.21__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.
- {sima_cli-2.1.20/sima_cli.egg-info → sima_cli-2.1.21}/PKG-INFO +1 -1
- {sima_cli-2.1.20 → sima_cli-2.1.21}/pyproject.toml +1 -1
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/__version__.py +1 -1
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/cli.py +6 -3
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/cloudex/client.py +1 -2
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/cloudex/commands.py +234 -15
- sima_cli-2.1.21/sima_cli/cloudex/neatops.py +254 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/discover/discover.py +69 -63
- sima_cli-2.1.21/sima_cli/discover/identity.py +70 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/sdk/commands.py +6 -1
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/sdk/install.py +45 -26
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/sdk/neat.py +6 -0
- sima_cli-2.1.21/sima_cli/sdk/releases.json +63 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/sdk/utils.py +93 -40
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/sdk/vscode_extensions.py +16 -1
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/update/netboot.py +193 -73
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/update/netboot_device.py +16 -4
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/utils/net.py +4 -3
- {sima_cli-2.1.20 → sima_cli-2.1.21/sima_cli.egg-info}/PKG-INFO +1 -1
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli.egg-info/SOURCES.txt +7 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_cloudex_client.py +13 -6
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_cloudex_commands.py +1 -1
- sima_cli-2.1.21/tests/unit/test_cloudex_neatops.py +584 -0
- sima_cli-2.1.21/tests/unit/test_insight_url_routing.py +55 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_netboot_autoflash.py +2 -1
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_netboot_device.py +39 -0
- sima_cli-2.1.21/tests/unit/test_netboot_identity.py +331 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_netboot_ip_recovery.py +4 -3
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_sdk_image_detection.py +96 -34
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_sdk_model_compiler.py +23 -0
- sima_cli-2.1.21/tests/unit/test_sdk_release_mapping.py +97 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_vscode_extension_pins.py +4 -3
- {sima_cli-2.1.20 → sima_cli-2.1.21}/LICENSE +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/MANIFEST.in +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/README.md +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/requirements.txt +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/setup.cfg +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/setup.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/__init__.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/__main__.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/app_zoo/__init__.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/app_zoo/app.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/app_zoo/commands.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/auth/__init__.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/auth/auth0.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/auth/devportal.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/auth/login.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/cloudex/__init__.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/cloudex/installer.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/data/__init__.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/data/resources_internal.yaml +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/data/resources_public.yaml +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/deploy_only/__init__.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/deploy_only/device/__init__.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/deploy_only/device/commands.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/deploy_only/mpk/__init__.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/deploy_only/mpk/commands.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/discover/__init__.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/discover/linuxll.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/download/__init__.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/download/downloader.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/download/parallel.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/install/__init__.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/install/compatibility.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/install/github_assets.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/install/hostdriver.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/install/metadata_info.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/install/metadata_installer.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/install/metadata_validator.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/install/optiview.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/install/package_builder.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/install/palette.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/install/registry.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/mla/__init__.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/mla/meminfo.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/model_zoo/__init__.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/model_zoo/model.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/models/__init__.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/models/client.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/models/commands.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/models/rendering.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/network/__init__.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/network/network.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/peripherals/__init__.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/peripherals/commands.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/peripherals/model.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/peripherals/sentinel.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/playbooks/__init__.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/playbooks/commands.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/playbooks/manager.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/sdk/__init__.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/sdk/browser_ca.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/sdk/cmdexec.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/sdk/config.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/sdk/container_registry.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/sdk/docker_staging.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/sdk/linux_devkit_network.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/sdk/linux_shared_network.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/sdk/model_compiler.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/sdk/network_doctor.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/sdk/preinstall.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/sdk/requirements.json +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/sdk/script.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/sdk/stop.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/sdk/uninstall.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/serial/__init__.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/serial/serial.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/storage/__init__.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/storage/nvme.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/storage/sdcard.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/update/__init__.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/update/ab_state.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/update/bmaptool.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/update/bootimg.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/update/cleanlog.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/update/elxr.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/update/emmc.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/update/fast_upload.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/update/host.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/update/local.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/update/mirror_catalog.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/update/netboot_artifacts.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/update/overlay.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/update/query.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/update/remote.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/update/reset_overlay.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/update/rootfs.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/update/staging.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/update/swap.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/update/swu.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/update/swu_artifacts.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/update/swu_certificate.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/update/swu_target.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/update/uboot_environment.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/update/updater.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/upgrade/__init__.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/upgrade/selfupdate.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/utils/__init__.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/utils/api_common.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/utils/artifactory.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/utils/common.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/utils/config.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/utils/config_loader.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/utils/container_registries.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/utils/deprecation.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/utils/device_api.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/utils/disk.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/utils/docker.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/utils/env.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/utils/errors.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/utils/mpk_api.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/utils/network.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/utils/pcie.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/utils/pkg_update_check.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/utils/serializers.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/utils/services.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/utils/streaming.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/utils/tag.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/vulcan/__init__.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/vulcan/artifacts.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli/vulcan/commands.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli.egg-info/dependency_links.txt +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli.egg-info/entry_points.txt +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli.egg-info/requires.txt +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/sima_cli.egg-info/top_level.txt +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/__init__.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/e2e/__init__.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/__init__.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/peripherals_fixtures.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_ab_overlay.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_app_zoo.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_appzoo_commands.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_artifact_parallel_integration.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_auth.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_auth0.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_bootimg_defaults.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_bootimg_recovery.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_cli.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_cli_stdio.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_cli_update.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_cli_update_rerun.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_cloudex_installer.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_code_ui_readiness.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_container_registries.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_deprecation.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_device_discovery_cloudex.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_discovery_host_tools.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_docker_utils.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_download.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_download_retries.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_elxr_update.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_emmc.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_fast_upload.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_firmware.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_firmware_creation_order.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_firmware_layout.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_firmware_selector.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_generate_cli_markdown_docs.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_host_update.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_install_force.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_install_script_terminal.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_install_stub.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_localized_docs_links.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_metadata_installer.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_metadata_validator.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_mirror_catalog.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_mla_meminfo.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_model_deployments.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_model_zoo.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_models.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_modelzoo_commands.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_netboot.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_netboot_images.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_netboot_mirror.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_netboot_trootctl.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_network_custom_static.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_network_safety.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_overlay.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_package_builder.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_palette_image_discovery.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_parallel_downloads.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_pcie.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_peripherals_commands.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_peripherals_contract.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_peripherals_model.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_peripherals_sentinel.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_pkg_update_check.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_reset_overlay.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_sdk_agent_sessions.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_sdk_browser_ca.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_sdk_container_registry.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_sdk_host_key_trust.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_sdk_linux_shared_network.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_sdk_network_doctor.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_sdk_preinstall.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_sdk_uninstall.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_selectable_resources.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_selfupdate.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_skills_commands.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_skills_manager.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_slot_swap.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_streaming_downloads.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_swu.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_swu_certificate.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_swu_errors.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_swu_mirror.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_swu_target.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_uboot_environment.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_update_staging.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_utils.py +0 -0
- {sima_cli-2.1.20 → sima_cli-2.1.21}/tests/unit/test_vulcan.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
# sima_cli/__version__.py
|
|
2
|
-
__version__ = "2.1.
|
|
2
|
+
__version__ = "2.1.21"
|
|
@@ -187,7 +187,7 @@ class InspectionAwareGroup(click.Group):
|
|
|
187
187
|
|
|
188
188
|
# Entry point for the CLI tool using Click's command group decorator
|
|
189
189
|
@click.group(cls=InspectionAwareGroup, context_settings=dict(help_option_names=["-h", "--help", "-?"], max_content_width=120))
|
|
190
|
-
@click.option('-i', '--internal', is_flag=True, help="Use internal
|
|
190
|
+
@click.option('-i', '--internal', is_flag=True, help="Use internal SiMa services, authorized SiMa employees only")
|
|
191
191
|
@click.option('-y', '--yes', is_flag=True, help="Assume yes for confirmation prompts.")
|
|
192
192
|
@click.version_option(version=f"{__version__}", message="SiMa CLI version: %(version)s")
|
|
193
193
|
@click.pass_context
|
|
@@ -196,7 +196,7 @@ def main(ctx, internal, yes):
|
|
|
196
196
|
sima-cli – SiMa Developer Portal CLI Tool
|
|
197
197
|
|
|
198
198
|
Global Options:
|
|
199
|
-
--internal Use internal
|
|
199
|
+
--internal Use internal SiMa services (can also be set via env variable SIMA_CLI_INTERNAL=1)
|
|
200
200
|
"""
|
|
201
201
|
internal = internal or os.getenv("SIMA_CLI_INTERNAL", "0") in ("1", "true", "yes")
|
|
202
202
|
if internal:
|
|
@@ -204,7 +204,10 @@ def main(ctx, internal, yes):
|
|
|
204
204
|
Text("Pre-release software may be unstable. Use at your own risk.", style="yellow"),
|
|
205
205
|
title="Pre-release software", border_style="yellow", width=72,
|
|
206
206
|
))
|
|
207
|
-
|
|
207
|
+
# CloudEx selects Developer Portal or internal NeatOps authentication itself;
|
|
208
|
+
# its internal Vulcan flow does not require Artifactory setup.
|
|
209
|
+
if ctx.meta.get('update_inspect') or ctx.invoked_subcommand == 'cloudex':
|
|
210
|
+
_configure_stdio_errors()
|
|
208
211
|
ctx.ensure_object(dict)
|
|
209
212
|
ctx.obj.update(internal=internal, yes=yes)
|
|
210
213
|
return
|
|
@@ -71,8 +71,7 @@ def decode_key(value):
|
|
|
71
71
|
raise CloudExError("--key contains an invalid value")
|
|
72
72
|
if len(profile["allocation_secret"]) < 32:
|
|
73
73
|
raise CloudExError("--key contains an invalid allocation secret")
|
|
74
|
-
|
|
75
|
-
raise CloudExError("CloudEx supports only vulcan-staging or vulcan-production allocation keys")
|
|
74
|
+
# The environment is a descriptive label; api_url selects the backend.
|
|
76
75
|
return profile
|
|
77
76
|
|
|
78
77
|
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
"""Hidden, experimental CloudEx command group."""
|
|
2
2
|
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
3
5
|
import os
|
|
6
|
+
import uuid
|
|
4
7
|
import stat
|
|
5
8
|
import sys
|
|
6
9
|
import time
|
|
7
10
|
from datetime import datetime, timezone
|
|
11
|
+
from functools import wraps
|
|
8
12
|
from pathlib import Path
|
|
9
13
|
|
|
10
14
|
import click
|
|
@@ -25,6 +29,7 @@ from .client import (
|
|
|
25
29
|
run_benchmark,
|
|
26
30
|
)
|
|
27
31
|
from .installer import DEFAULT_BRANCH, install_forwarder
|
|
32
|
+
from .neatops import NeatOps
|
|
28
33
|
|
|
29
34
|
|
|
30
35
|
console = Console()
|
|
@@ -206,9 +211,33 @@ def _session_table(rows, show_benchmark=False):
|
|
|
206
211
|
return table
|
|
207
212
|
|
|
208
213
|
|
|
214
|
+
def _internal_cloudex():
|
|
215
|
+
ctx = click.get_current_context(silent=True)
|
|
216
|
+
return bool(ctx and isinstance(ctx.obj, dict) and ctx.obj.get("internal"))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _require_vulcan():
|
|
220
|
+
if not _internal_cloudex():
|
|
221
|
+
raise click.ClickException(
|
|
222
|
+
"CloudEx is not available on Arthas yet. Use sima-cli -i cloudex for Vulcan."
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _vulcan_only(function):
|
|
227
|
+
@wraps(function)
|
|
228
|
+
def wrapped(*args, **kwargs):
|
|
229
|
+
_require_vulcan()
|
|
230
|
+
return function(*args, **kwargs)
|
|
231
|
+
return wrapped
|
|
232
|
+
|
|
233
|
+
|
|
209
234
|
@click.group(name="cloudex", hidden=True)
|
|
210
|
-
|
|
211
|
-
|
|
235
|
+
@click.option("-i", "--internal", is_flag=True, help="Use internal Vulcan CloudEx and NeatOps authentication.")
|
|
236
|
+
@click.pass_context
|
|
237
|
+
def cloudex_group(ctx, internal):
|
|
238
|
+
"""Manage CloudEx devices; use -i for internal Vulcan access."""
|
|
239
|
+
ctx.ensure_object(dict)
|
|
240
|
+
ctx.obj["internal"] = internal or ctx.obj.get("internal", False) or os.getenv("SIMA_CLI_INTERNAL", "0") in ("1", "true", "yes")
|
|
212
241
|
|
|
213
242
|
|
|
214
243
|
@cloudex_group.command("connect")
|
|
@@ -222,6 +251,8 @@ def cloudex_group():
|
|
|
222
251
|
is_flag=True,
|
|
223
252
|
help="Read the allocation key from standard input for automation.",
|
|
224
253
|
)
|
|
254
|
+
@click.option("--allocation", help="Connect to your NeatOps allocation ID without copying a key.")
|
|
255
|
+
@click.option("--environment", type=click.Choice(["production", "staging"]), default="production", show_default=True, help="Vulcan environment (requires -i).")
|
|
225
256
|
@click.option("--transport", type=click.Choice(["auto", "p2p", "turn"]), default="auto", hidden=True)
|
|
226
257
|
@click.option("--attempts", type=click.IntRange(1, 5), default=3, hidden=True)
|
|
227
258
|
@click.option(
|
|
@@ -229,11 +260,16 @@ def cloudex_group():
|
|
|
229
260
|
is_flag=True,
|
|
230
261
|
help="Print each sanitized connection stage for troubleshooting.",
|
|
231
262
|
)
|
|
232
|
-
|
|
263
|
+
@_vulcan_only
|
|
264
|
+
def connect_command(key_file, key_stdin, allocation, environment, transport, attempts, verbose):
|
|
233
265
|
"""Connect to an allocated DevKit using a securely supplied key."""
|
|
234
|
-
if key_file
|
|
235
|
-
raise click.UsageError("Use either --key-file or --key-stdin, not both.")
|
|
266
|
+
if sum(bool(value) for value in (key_file, key_stdin, allocation)) > 1:
|
|
267
|
+
raise click.UsageError("Use either --key-file or --key-stdin, not both; --allocation must be used alone.")
|
|
236
268
|
try:
|
|
269
|
+
if allocation:
|
|
270
|
+
profile = _allocation_profile(_neatops_api(environment), _allocation_id(allocation))
|
|
271
|
+
_connect_profile(profile, verbose=verbose, attempts=attempts, transport=transport)
|
|
272
|
+
return
|
|
237
273
|
if key_file:
|
|
238
274
|
key = _read_key_file(key_file)
|
|
239
275
|
elif key_stdin:
|
|
@@ -243,16 +279,7 @@ def connect_command(key_file, key_stdin, transport, attempts, verbose):
|
|
|
243
279
|
else:
|
|
244
280
|
key = click.prompt("CloudEx allocation key", hide_input=True).strip()
|
|
245
281
|
profile = decode_key(key)
|
|
246
|
-
|
|
247
|
-
_authorize_supported_host()
|
|
248
|
-
with StageProgress("Preparing CloudEx connection", verbose=verbose) as progress:
|
|
249
|
-
session = connect(profile, _store(), progress, attempts=attempts, transport=transport)
|
|
250
|
-
progress.success("Connected to {}".format(session["device"]))
|
|
251
|
-
path = session.get("ice_path", "unknown")
|
|
252
|
-
path_text = "direct" if path == "direct" else "relayed" if path == "relay" else path
|
|
253
|
-
console.print(" [dim]Session[/dim] {}".format(_short(session["session_id"])))
|
|
254
|
-
console.print(" [dim]Path[/dim] {}".format(path_text))
|
|
255
|
-
console.print(" [dim]SSH[/dim] [bold]ssh sima@{}[/bold]".format(session["target"]))
|
|
282
|
+
_connect_profile(profile, verbose=verbose, attempts=attempts, transport=transport)
|
|
256
283
|
except CloudExError as exc:
|
|
257
284
|
raise click.ClickException(str(exc)) from exc
|
|
258
285
|
|
|
@@ -264,6 +291,7 @@ def connect_command(key_file, key_stdin, transport, attempts, verbose):
|
|
|
264
291
|
show_default=True,
|
|
265
292
|
help="Kerrigan branch whose latest forwarder should be installed.",
|
|
266
293
|
)
|
|
294
|
+
@_vulcan_only
|
|
267
295
|
def update_command(branch):
|
|
268
296
|
"""Install or update the native CloudEx forwarder."""
|
|
269
297
|
try:
|
|
@@ -326,6 +354,7 @@ def _choose_sessions(sessions):
|
|
|
326
354
|
@cloudex_group.command("disconnect")
|
|
327
355
|
@click.option("--session", "session_id", help="Session ID or unique prefix to disconnect.")
|
|
328
356
|
@click.option("--all", "disconnect_all", is_flag=True, help="Disconnect every recorded CloudEx session.")
|
|
357
|
+
@_vulcan_only
|
|
329
358
|
def disconnect_command(session_id, disconnect_all):
|
|
330
359
|
"""Disconnect one or all CloudEx sessions."""
|
|
331
360
|
if session_id and disconnect_all:
|
|
@@ -373,6 +402,7 @@ def disconnect_command(session_id, disconnect_all):
|
|
|
373
402
|
@click.option("--benchmark", is_flag=True, help="Stress-test RTT and maximum bidirectional throughput for each connection.")
|
|
374
403
|
@click.option("--duration", type=click.IntRange(1, 60), default=10, show_default=True,
|
|
375
404
|
help="Throughput measurement window in seconds.")
|
|
405
|
+
@_vulcan_only
|
|
376
406
|
def list_command(benchmark, duration):
|
|
377
407
|
"""List locally recorded CloudEx connections."""
|
|
378
408
|
store = _store()
|
|
@@ -420,3 +450,192 @@ def list_command(benchmark, duration):
|
|
|
420
450
|
|
|
421
451
|
def register_cloudex_commands(main):
|
|
422
452
|
main.add_command(cloudex_group)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _connect_profile(profile, verbose=False, attempts=3, transport="auto"):
|
|
456
|
+
console.print("[cyan]→[/cyan] Authorizing encrypted tunnel access")
|
|
457
|
+
_authorize_supported_host()
|
|
458
|
+
with StageProgress("Preparing CloudEx connection", verbose=verbose) as progress:
|
|
459
|
+
session = connect(profile, _store(), progress, attempts=attempts, transport=transport)
|
|
460
|
+
progress.success("Connected to {}".format(session["device"]))
|
|
461
|
+
path = session.get("ice_path", "unknown")
|
|
462
|
+
path_text = "direct" if path == "direct" else "relayed" if path == "relay" else path
|
|
463
|
+
console.print(" [dim]Session[/dim] {}".format(_short(session["session_id"])))
|
|
464
|
+
console.print(" [dim]Path[/dim] {}".format(path_text))
|
|
465
|
+
console.print(" [dim]SSH[/dim] [bold]ssh sima@{}[/bold]".format(session["target"]))
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
DURATIONS = {"1h": 3600, "4h": 14400, "8h": 28800, "1d": 86400, "7d": 604800, "4w": 2419200}
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _allocation_id(value):
|
|
472
|
+
if len(value) != 32 or any(c not in "0123456789abcdef" for c in value):
|
|
473
|
+
raise CloudExError("Use the full 32-character allocation ID from sima-cli -i cloudex devices")
|
|
474
|
+
return value
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _allocation_profile(api, allocation_id, timeout=180):
|
|
478
|
+
deadline = time.monotonic() + timeout
|
|
479
|
+
with StageProgress("Waiting for device preparation") as progress:
|
|
480
|
+
while True:
|
|
481
|
+
inventory = api.call("GET", "/devkits")
|
|
482
|
+
allocation = next((a for a in inventory.get("allocations", []) if a["session_id"] == allocation_id), None)
|
|
483
|
+
if not allocation or allocation.get("status") in {"released", "releasing", "expired", "failed"}:
|
|
484
|
+
raise CloudExError("Allocation is unavailable; check sima-cli -i cloudex devices")
|
|
485
|
+
if allocation.get("status") in {"ready", "active"}:
|
|
486
|
+
profile = api.call("POST", "/devkits/allocations/" + allocation_id + "/profile", {})
|
|
487
|
+
# Reuse the same strict profile validation as manually supplied keys.
|
|
488
|
+
profile = decode_key(base64.b64encode(json.dumps(profile).encode()).decode())
|
|
489
|
+
progress.success("Device is ready")
|
|
490
|
+
return profile
|
|
491
|
+
if time.monotonic() >= deadline:
|
|
492
|
+
raise CloudExError("Device preparation timed out. Allocation {} is retained; retry with sima-cli -i cloudex connect --allocation {} or release it".format(allocation_id, allocation_id))
|
|
493
|
+
time.sleep(2)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _neatops_login(api):
|
|
497
|
+
api.login(lambda url: console.print("Open this URL in a browser on this computer:\n" + url, markup=False, soft_wrap=True))
|
|
498
|
+
console.print("[green]Signed in to NeatOps ({})[/green]".format(api.environment))
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _neatops_api(environment):
|
|
502
|
+
_require_vulcan()
|
|
503
|
+
return NeatOps(environment, on_login_required=_neatops_login)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
@cloudex_group.command("login")
|
|
507
|
+
@click.option("--environment", type=click.Choice(["production", "staging"]), default="production", show_default=True, help="Vulcan environment (requires -i).")
|
|
508
|
+
def login_command(environment):
|
|
509
|
+
"""Sign in to the Developer Portal, or NeatOps with -i."""
|
|
510
|
+
if not _internal_cloudex():
|
|
511
|
+
if environment != "production":
|
|
512
|
+
raise click.UsageError("--environment selects a Vulcan environment and requires -i.")
|
|
513
|
+
from sima_cli.auth.devportal import login_external
|
|
514
|
+
if not login_external(loginDocker=False):
|
|
515
|
+
raise click.ClickException("Developer Portal sign-in was not completed.")
|
|
516
|
+
console.print("Signed in to the Developer Portal. CloudEx is not available on Arthas yet.")
|
|
517
|
+
return
|
|
518
|
+
try:
|
|
519
|
+
_neatops_login(NeatOps(environment))
|
|
520
|
+
except CloudExError as exc:
|
|
521
|
+
raise click.ClickException(str(exc)) from exc
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
@cloudex_group.command("logout")
|
|
525
|
+
@click.option("--environment", type=click.Choice(["production", "staging"]), default="production", show_default=True, help="Vulcan environment (requires -i).")
|
|
526
|
+
def logout_command(environment):
|
|
527
|
+
"""Sign out of the Developer Portal, or NeatOps with -i; keep leases."""
|
|
528
|
+
if not _internal_cloudex():
|
|
529
|
+
if environment != "production":
|
|
530
|
+
raise click.UsageError("--environment selects a Vulcan environment and requires -i.")
|
|
531
|
+
from sima_cli.auth.auth0 import clear_external_login_state
|
|
532
|
+
clear_external_login_state()
|
|
533
|
+
console.print("Signed out of the Developer Portal.")
|
|
534
|
+
return
|
|
535
|
+
try:
|
|
536
|
+
NeatOps(environment).logout()
|
|
537
|
+
console.print("Signed out of NeatOps. Existing device leases are unchanged.")
|
|
538
|
+
except CloudExError as exc:
|
|
539
|
+
raise click.ClickException(str(exc)) from exc
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
@cloudex_group.command("devices")
|
|
543
|
+
@click.option("--environment", type=click.Choice(["production", "staging"]), default="production", show_default=True, help="Vulcan environment (requires -i).")
|
|
544
|
+
@_vulcan_only
|
|
545
|
+
def devices_command(environment):
|
|
546
|
+
"""Show NeatOps device availability and your allocation IDs."""
|
|
547
|
+
try:
|
|
548
|
+
inventory = _neatops_api(environment).call("GET", "/devkits")
|
|
549
|
+
table = Table("Device type", "Firmware version", "ID", "Allocation ID", "State")
|
|
550
|
+
allocations = {(a["cluster_id"], a["target_id"]): a for a in inventory.get("allocations", [])}
|
|
551
|
+
visible_devices = set()
|
|
552
|
+
for d in inventory.get("devices", []):
|
|
553
|
+
device_key = (d["cluster_id"], d["id"])
|
|
554
|
+
visible_devices.add(device_key)
|
|
555
|
+
a = d.get("allocation") or allocations.get(device_key, {})
|
|
556
|
+
table.add_row(d.get("type") or "Unknown", d.get("firmware") or "Unknown", d["id"],
|
|
557
|
+
a.get("session_id") or "—", "Available" if d["available"] else "Not available")
|
|
558
|
+
# Allocations can survive an appliance disappearing from inventory.
|
|
559
|
+
for a in inventory.get("allocations", []):
|
|
560
|
+
if (a["cluster_id"], a["target_id"]) not in visible_devices:
|
|
561
|
+
table.add_row("Unknown", "Unknown", a["target_id"], a["session_id"], "Not available")
|
|
562
|
+
console.print(table)
|
|
563
|
+
except CloudExError as exc:
|
|
564
|
+
raise click.ClickException(str(exc)) from exc
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
@cloudex_group.command("request")
|
|
568
|
+
@click.option("--device", help="Device ID or exact name; otherwise choose interactively.")
|
|
569
|
+
@click.option("--cluster", help="Appliance ID, to disambiguate devices.")
|
|
570
|
+
@click.option("--duration", type=click.Choice(list(DURATIONS)), default="1h", show_default=True)
|
|
571
|
+
@click.option("--environment", type=click.Choice(["production", "staging"]), default="production", show_default=True, help="Vulcan environment (requires -i).")
|
|
572
|
+
@click.option("--verbose", is_flag=True, help="Print sanitized connection stages.")
|
|
573
|
+
@_vulcan_only
|
|
574
|
+
def request_command(device, cluster, duration, environment, verbose):
|
|
575
|
+
"""Request a NeatOps device, wait for preparation, and connect automatically."""
|
|
576
|
+
allocation_id = None
|
|
577
|
+
try:
|
|
578
|
+
api = _neatops_api(environment)
|
|
579
|
+
entitlement = api.call("GET", "/users/me/entitlement")["entitlement"]
|
|
580
|
+
if DURATIONS[duration] > entitlement["max_lease_duration_seconds"]:
|
|
581
|
+
raise CloudExError("Requested duration exceeds your NeatOps lease limit; choose a shorter duration")
|
|
582
|
+
inventory = api.call("GET", "/devkits")
|
|
583
|
+
if len(inventory.get("allocations", [])) >= entitlement["max_active_leases"]:
|
|
584
|
+
raise CloudExError("Your device lease limit is reached; connect to an existing allocation or release one")
|
|
585
|
+
candidates = [d for d in inventory.get("devices", []) if d.get("available")
|
|
586
|
+
and (not cluster or d["cluster_id"] == cluster)
|
|
587
|
+
and (not device or device in {d["id"], d["name"]})]
|
|
588
|
+
if not candidates:
|
|
589
|
+
raise CloudExError("No matching devices are available; check sima-cli -i cloudex devices")
|
|
590
|
+
if device and len(candidates) > 1:
|
|
591
|
+
raise CloudExError("Device name or ID is ambiguous; specify --cluster")
|
|
592
|
+
if not device:
|
|
593
|
+
table = Table("#", "Device type", "Software version", "ID")
|
|
594
|
+
for index, d in enumerate(candidates, 1):
|
|
595
|
+
table.add_row(str(index), d.get("type") or "Unknown", d.get("firmware") or "Unknown", d["id"])
|
|
596
|
+
console.print(table)
|
|
597
|
+
choice = click.prompt("Choose a device", type=click.IntRange(1, len(candidates)))
|
|
598
|
+
selected = candidates[choice - 1]
|
|
599
|
+
else:
|
|
600
|
+
selected = candidates[0]
|
|
601
|
+
# Get local administrator authorization before reserving scarce hardware.
|
|
602
|
+
_authorize_supported_host()
|
|
603
|
+
allocation_id = uuid.uuid4().hex
|
|
604
|
+
console.print("Allocation {} · {} · {}".format(allocation_id, selected["name"], duration), markup=False)
|
|
605
|
+
api.call("POST", "/devkits/allocations", {"request_id": allocation_id,
|
|
606
|
+
"cluster_id": selected["cluster_id"], "target_id": selected["id"], "duration_seconds": DURATIONS[duration]})
|
|
607
|
+
profile = _allocation_profile(api, allocation_id)
|
|
608
|
+
_connect_profile(profile, verbose=verbose)
|
|
609
|
+
except CloudExError as exc:
|
|
610
|
+
if allocation_id:
|
|
611
|
+
console.print("Inspect: sima-cli -i cloudex devices --environment {}\nResume: sima-cli -i cloudex connect --allocation {} --environment {}\nRelease: sima-cli -i cloudex release {} --environment {}".format(environment, allocation_id, environment, allocation_id, environment), markup=False, soft_wrap=True)
|
|
612
|
+
raise click.ClickException(str(exc)) from exc
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
@cloudex_group.command("release")
|
|
616
|
+
@click.argument("allocation_id")
|
|
617
|
+
@click.option("--environment", type=click.Choice(["production", "staging"]), default="production", show_default=True, help="Vulcan environment (requires -i).")
|
|
618
|
+
@_vulcan_only
|
|
619
|
+
def release_command(allocation_id, environment):
|
|
620
|
+
"""Release your NeatOps lease and disconnect its local CloudEx sessions."""
|
|
621
|
+
try:
|
|
622
|
+
allocation_id = _allocation_id(allocation_id)
|
|
623
|
+
store = _store()
|
|
624
|
+
failures = []
|
|
625
|
+
for session in store.list():
|
|
626
|
+
if session.get("allocation_id") == allocation_id:
|
|
627
|
+
try:
|
|
628
|
+
with StageProgress("Disconnecting CloudEx") as progress:
|
|
629
|
+
disconnect_session(session, store, progress)
|
|
630
|
+
except CloudExError as exc:
|
|
631
|
+
failures.append("Tunnel cleanup failed: {}. Local retry state retained; retry sima-cli -i cloudex disconnect.".format(exc))
|
|
632
|
+
try:
|
|
633
|
+
_neatops_api(environment).call("DELETE", "/devkits/allocations/" + allocation_id)
|
|
634
|
+
except CloudExError as exc:
|
|
635
|
+
failures.append("Lease release failed: {}".format(exc))
|
|
636
|
+
else:
|
|
637
|
+
console.print("Lease released; device availability returns after cleanup is confirmed.")
|
|
638
|
+
if failures:
|
|
639
|
+
raise CloudExError("; ".join(failures))
|
|
640
|
+
except CloudExError as exc:
|
|
641
|
+
raise click.ClickException(str(exc)) from exc
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""NeatOps browser authentication and personal device allocation API."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import secrets
|
|
8
|
+
import stat
|
|
9
|
+
import tempfile
|
|
10
|
+
import time
|
|
11
|
+
import webbrowser
|
|
12
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from urllib.parse import parse_qs, urlencode, urlsplit
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
|
|
18
|
+
from .client import CloudExError, DEFAULT_ROOT
|
|
19
|
+
|
|
20
|
+
DASHBOARDS = {
|
|
21
|
+
"production": "https://ops.neat.sima.ai",
|
|
22
|
+
"staging": "https://ops.stg.neat.sima.ai",
|
|
23
|
+
}
|
|
24
|
+
REDIRECT_URI = "http://127.0.0.1:47182/callback"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _origin(value):
|
|
28
|
+
if not isinstance(value, str):
|
|
29
|
+
raise CloudExError("NeatOps configuration requires an HTTPS origin")
|
|
30
|
+
parsed = urlsplit(value)
|
|
31
|
+
if (parsed.scheme != "https" or not parsed.hostname or parsed.username
|
|
32
|
+
or parsed.password or parsed.query or parsed.fragment or parsed.path not in {"", "/"}):
|
|
33
|
+
raise CloudExError("NeatOps configuration requires an HTTPS origin")
|
|
34
|
+
return value.rstrip("/")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _json_request(method, url, **kwargs):
|
|
38
|
+
try:
|
|
39
|
+
response = requests.request(method, url, timeout=20, allow_redirects=False, **kwargs)
|
|
40
|
+
except requests.RequestException:
|
|
41
|
+
raise CloudExError("NeatOps is unavailable; check your connection and retry") from None
|
|
42
|
+
try:
|
|
43
|
+
payload = response.json()
|
|
44
|
+
except ValueError:
|
|
45
|
+
payload = {}
|
|
46
|
+
if not isinstance(payload, dict):
|
|
47
|
+
payload = {}
|
|
48
|
+
return response.status_code, payload
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class LoginServer(HTTPServer):
|
|
52
|
+
def get_request(self):
|
|
53
|
+
connection, address = super().get_request()
|
|
54
|
+
connection.settimeout(2)
|
|
55
|
+
return connection, address
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class LoginCallback(BaseHTTPRequestHandler):
|
|
59
|
+
"""Only accept the matching loopback OAuth response; never log its code."""
|
|
60
|
+
|
|
61
|
+
def log_message(self, *args):
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
def do_GET(self):
|
|
65
|
+
parsed = urlsplit(self.path)
|
|
66
|
+
params = parse_qs(parsed.query)
|
|
67
|
+
valid = (self.headers.get("Host") == "127.0.0.1:47182"
|
|
68
|
+
and parsed.path == "/callback"
|
|
69
|
+
and params.get("state") == [self.server.oauth_state])
|
|
70
|
+
code = params.get("code", [])
|
|
71
|
+
error = params.get("error", [])
|
|
72
|
+
valid = valid and ((len(code) == 1 and not error) or (len(error) == 1 and not code))
|
|
73
|
+
if valid:
|
|
74
|
+
self.server.oauth_result = {"code": code[0]} if code else {"error": True}
|
|
75
|
+
self.send_response(200 if valid else 400)
|
|
76
|
+
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
|
77
|
+
self.send_header("Cache-Control", "no-store")
|
|
78
|
+
self.send_header("Referrer-Policy", "no-referrer")
|
|
79
|
+
self.end_headers()
|
|
80
|
+
self.wfile.write(b"Return to sima-cli to finish signing in." if valid else b"Invalid sign-in response.")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class LoginRequired(CloudExError):
|
|
84
|
+
"""Saved credentials cannot authorize a request without browser sign-in."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class NeatOps:
|
|
88
|
+
def __init__(self, environment="production", root=None, on_login_required=None):
|
|
89
|
+
self.environment = environment
|
|
90
|
+
self.dashboard = DASHBOARDS[environment]
|
|
91
|
+
self.path = Path(root or DEFAULT_ROOT) / ("neatops-" + environment + ".json")
|
|
92
|
+
self.config = None
|
|
93
|
+
self.on_login_required = on_login_required
|
|
94
|
+
self._login_attempted = False
|
|
95
|
+
|
|
96
|
+
def configuration(self):
|
|
97
|
+
if self.config is None:
|
|
98
|
+
status, config = _json_request("GET", self.dashboard + "/cli-config.json")
|
|
99
|
+
if status != 200:
|
|
100
|
+
raise CloudExError("NeatOps CLI login is not configured on this dashboard yet")
|
|
101
|
+
auth = config.get("auth", {})
|
|
102
|
+
if (not isinstance(auth, dict) or auth.get("enabled") is not True
|
|
103
|
+
or not isinstance(auth.get("clientId"), str) or not auth["clientId"]
|
|
104
|
+
or auth.get("redirectUri") != REDIRECT_URI
|
|
105
|
+
or config.get("apiBase") != self.dashboard + "/api"):
|
|
106
|
+
raise CloudExError("NeatOps CLI login configuration is unavailable or invalid")
|
|
107
|
+
try:
|
|
108
|
+
_origin(auth["domain"])
|
|
109
|
+
except (KeyError, TypeError, ValueError):
|
|
110
|
+
raise CloudExError("NeatOps sign-in domain is invalid") from None
|
|
111
|
+
self.config = config
|
|
112
|
+
return self.config
|
|
113
|
+
|
|
114
|
+
def _save(self, tokens):
|
|
115
|
+
auth = self.configuration()["auth"]
|
|
116
|
+
if not isinstance(tokens.get("access_token"), str) or not tokens["access_token"]:
|
|
117
|
+
raise CloudExError("NeatOps sign-in did not return an access token")
|
|
118
|
+
lifetime = tokens.get("expires_in")
|
|
119
|
+
if isinstance(lifetime, bool) or not isinstance(lifetime, (int, float)) or lifetime <= 0:
|
|
120
|
+
raise CloudExError("NeatOps sign-in returned an invalid token lifetime")
|
|
121
|
+
session = {"access_token": tokens["access_token"], "refresh_token": tokens.get("refresh_token"),
|
|
122
|
+
"expires_at": time.time() + lifetime, "client_id": auth["clientId"], "domain": auth["domain"]}
|
|
123
|
+
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
124
|
+
descriptor, temporary = tempfile.mkstemp(prefix=".neatops-", dir=str(self.path.parent))
|
|
125
|
+
try:
|
|
126
|
+
with os.fdopen(descriptor, "w") as stream:
|
|
127
|
+
json.dump(session, stream)
|
|
128
|
+
os.replace(temporary, self.path)
|
|
129
|
+
finally:
|
|
130
|
+
if os.path.exists(temporary):
|
|
131
|
+
os.unlink(temporary)
|
|
132
|
+
return session["access_token"]
|
|
133
|
+
|
|
134
|
+
def _exchange(self, parameters):
|
|
135
|
+
auth = self.configuration()["auth"]
|
|
136
|
+
status, tokens = _json_request("POST", auth["domain"].rstrip("/") + "/oauth2/token",
|
|
137
|
+
data={"client_id": auth["clientId"], **parameters})
|
|
138
|
+
if status == 400 and parameters.get("grant_type") == "refresh_token" and tokens.get("error") == "invalid_grant":
|
|
139
|
+
raise LoginRequired("NeatOps sign-in expired; run sima-cli -i cloudex login again")
|
|
140
|
+
if status != 200:
|
|
141
|
+
raise CloudExError("NeatOps sign-in expired or was rejected; run sima-cli -i cloudex login again")
|
|
142
|
+
return tokens
|
|
143
|
+
|
|
144
|
+
def login(self, announce, timeout=180):
|
|
145
|
+
auth = self.configuration()["auth"]
|
|
146
|
+
verifier = secrets.token_urlsafe(48)
|
|
147
|
+
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=")
|
|
148
|
+
state = secrets.token_urlsafe(32)
|
|
149
|
+
parameters = {"response_type": "code", "client_id": auth["clientId"], "redirect_uri": REDIRECT_URI,
|
|
150
|
+
"scope": "openid email profile", "state": state, "code_challenge_method": "S256",
|
|
151
|
+
"code_challenge": challenge}
|
|
152
|
+
if auth.get("identityProvider"):
|
|
153
|
+
parameters["identity_provider"] = auth["identityProvider"]
|
|
154
|
+
url = auth["domain"].rstrip("/") + "/oauth2/authorize?" + urlencode(parameters)
|
|
155
|
+
try:
|
|
156
|
+
server = LoginServer(("127.0.0.1", 47182), LoginCallback)
|
|
157
|
+
except OSError:
|
|
158
|
+
raise CloudExError("CloudEx sign-in needs local port 47182; close another login and retry") from None
|
|
159
|
+
with server:
|
|
160
|
+
server.oauth_state = state
|
|
161
|
+
server.oauth_result = None
|
|
162
|
+
server.timeout = 0.5
|
|
163
|
+
announce(url)
|
|
164
|
+
try:
|
|
165
|
+
webbrowser.open(url)
|
|
166
|
+
except webbrowser.Error:
|
|
167
|
+
pass # The terminal also displays the URL for manual opening.
|
|
168
|
+
deadline = time.monotonic() + timeout
|
|
169
|
+
while server.oauth_result is None and time.monotonic() < deadline:
|
|
170
|
+
server.handle_request()
|
|
171
|
+
result = server.oauth_result
|
|
172
|
+
if result is None:
|
|
173
|
+
raise CloudExError("NeatOps sign-in timed out; run sima-cli -i cloudex login again")
|
|
174
|
+
if result.get("error"):
|
|
175
|
+
raise CloudExError("NeatOps sign-in was cancelled or rejected")
|
|
176
|
+
self._save(self._exchange({"grant_type": "authorization_code", "code": result["code"],
|
|
177
|
+
"code_verifier": verifier, "redirect_uri": REDIRECT_URI}))
|
|
178
|
+
|
|
179
|
+
def _read_session(self):
|
|
180
|
+
try:
|
|
181
|
+
# Refuse symlinks and shared credential files, as for CloudEx keys.
|
|
182
|
+
descriptor = os.open(str(self.path), os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
|
183
|
+
with os.fdopen(descriptor) as stream:
|
|
184
|
+
mode = os.fstat(stream.fileno()).st_mode
|
|
185
|
+
if not stat.S_ISREG(mode):
|
|
186
|
+
raise CloudExError("NeatOps credential file must be a regular file")
|
|
187
|
+
if os.name != "nt" and mode & 0o077:
|
|
188
|
+
raise CloudExError("NeatOps credential file must have mode 0600")
|
|
189
|
+
session = json.load(stream)
|
|
190
|
+
return session
|
|
191
|
+
except (FileNotFoundError, ValueError, KeyError, TypeError):
|
|
192
|
+
raise LoginRequired("Sign in first with sima-cli -i cloudex login --environment " + self.environment) from None
|
|
193
|
+
except OSError:
|
|
194
|
+
raise CloudExError("Sign in first with sima-cli -i cloudex login --environment " + self.environment) from None
|
|
195
|
+
|
|
196
|
+
def access_token(self):
|
|
197
|
+
auth = self.configuration()["auth"]
|
|
198
|
+
session = self._read_session()
|
|
199
|
+
try:
|
|
200
|
+
if session["client_id"] != auth["clientId"] or session["domain"] != auth["domain"]:
|
|
201
|
+
raise ValueError("Sign-in policy changed")
|
|
202
|
+
if session["expires_at"] > time.time() + 60:
|
|
203
|
+
return session["access_token"]
|
|
204
|
+
refresh = session.get("refresh_token")
|
|
205
|
+
except (ValueError, KeyError, TypeError):
|
|
206
|
+
raise LoginRequired("Sign-in policy changed; run sima-cli -i cloudex login again") from None
|
|
207
|
+
if not refresh:
|
|
208
|
+
raise LoginRequired("NeatOps sign-in expired; run sima-cli -i cloudex login again")
|
|
209
|
+
tokens = self._exchange({"grant_type": "refresh_token", "refresh_token": refresh})
|
|
210
|
+
tokens.setdefault("refresh_token", refresh)
|
|
211
|
+
return self._save(tokens)
|
|
212
|
+
|
|
213
|
+
def logout(self):
|
|
214
|
+
if self.path.exists():
|
|
215
|
+
# Revoke the CLI's refresh token without signing the browser out.
|
|
216
|
+
auth = self.configuration()["auth"]
|
|
217
|
+
session = self._read_session()
|
|
218
|
+
if session.get("refresh_token"):
|
|
219
|
+
status, _ = _json_request("POST", auth["domain"].rstrip("/") + "/oauth2/revoke",
|
|
220
|
+
data={"client_id": session["client_id"], "token": session["refresh_token"]})
|
|
221
|
+
if status != 200:
|
|
222
|
+
raise CloudExError("NeatOps token revocation failed; retry logout")
|
|
223
|
+
self.path.unlink()
|
|
224
|
+
|
|
225
|
+
def _authenticate(self):
|
|
226
|
+
if self.on_login_required is None or self._login_attempted:
|
|
227
|
+
raise LoginRequired("Sign in again with sima-cli -i cloudex login --environment " + self.environment)
|
|
228
|
+
self._login_attempted = True
|
|
229
|
+
self.on_login_required(self)
|
|
230
|
+
return self.access_token()
|
|
231
|
+
|
|
232
|
+
def call(self, method, path, data=None):
|
|
233
|
+
config = self.configuration()
|
|
234
|
+
try:
|
|
235
|
+
token = self.access_token()
|
|
236
|
+
except LoginRequired:
|
|
237
|
+
token = self._authenticate()
|
|
238
|
+
|
|
239
|
+
def request(access_token):
|
|
240
|
+
return _json_request(method, config["apiBase"] + path,
|
|
241
|
+
headers={"Authorization": "Bearer " + access_token}, json=data)
|
|
242
|
+
|
|
243
|
+
status, payload = request(token)
|
|
244
|
+
# A 401 from the authorizer has no side effects, so retry the same
|
|
245
|
+
# request once after sign-in. Permission failures and outages do not
|
|
246
|
+
# trigger login or replay allocation requests.
|
|
247
|
+
if status == 401 and self.on_login_required is not None and not self._login_attempted:
|
|
248
|
+
status, payload = request(self._authenticate())
|
|
249
|
+
if status in {401, 403}:
|
|
250
|
+
raise CloudExError("NeatOps access denied; sign in again or check your device permissions")
|
|
251
|
+
if not 200 <= status < 300:
|
|
252
|
+
# Do not print backend response bodies, which could contain credentials.
|
|
253
|
+
raise CloudExError("NeatOps request failed (HTTP {}); check availability and lease limits in My Devices".format(status))
|
|
254
|
+
return payload
|