redeploy 0.2.60__tar.gz → 0.2.62__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.
- {redeploy-0.2.60 → redeploy-0.2.62}/PKG-INFO +7 -7
- {redeploy-0.2.60 → redeploy-0.2.62}/README.md +6 -6
- {redeploy-0.2.60 → redeploy-0.2.62}/pyproject.toml +1 -1
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/__init__.py +1 -1
- redeploy-0.2.62/redeploy/analyze/__init__.py +6 -0
- redeploy-0.2.62/redeploy/analyze/checkers/__init__.py +1 -0
- redeploy-0.2.62/redeploy/analyze/spec_analyzer.py +379 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/__init__.py +2 -0
- redeploy-0.2.62/redeploy/cli/commands/lint.py +86 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/plan_apply.py +27 -1
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/prompt_cmd.py +4 -1
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/heal/runner.py +13 -1
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_cli.py +1 -1
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_heal.py +43 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy.egg-info/PKG-INFO +7 -7
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy.egg-info/SOURCES.txt +4 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/apply/__init__.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/apply/exceptions.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/apply/executor.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/apply/handlers.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/apply/progress.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/apply/rollback.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/apply/state.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/apply/state_apply.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/apply/utils/__init__.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/apply/utils/run_container_build.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/audit.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/blueprint/__init__.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/blueprint/extractor.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/blueprint/generators/__init__.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/blueprint/generators/docker_compose.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/blueprint/generators/migration.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/blueprint/sources/__init__.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/blueprint/sources/compose.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/blueprint/sources/hardware.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/blueprint/sources/infra.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/blueprint/sources/migration.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/__init__.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/audit.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/blueprint.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/bump_fix.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/detect.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/device_map.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/device_map_renderers.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/devices.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/diagnose.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/diff.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/exec_.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/export.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/hardware.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/import_.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/init.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/inspect.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/mcp_cmd.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/patterns.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/plugin.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/probe.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/push.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/state.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/status.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/target.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/version/__init__.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/version/commands.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/version/helpers.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/version/monorepo.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/version/release.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/version/scanner.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/version/utils/__init__.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/version/utils/changelog_config.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/version/utils/git_config.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/workflow.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/core.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/display.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/query.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/config_apply/__init__.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/config_apply/applier.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/config_apply/loader.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/data_sync.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/detect/__init__.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/detect/builtin/__init__.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/detect/detector.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/detect/hardware.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/detect/hardware_rules.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/detect/probes.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/detect/remote.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/detect/templates.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/detect/workflow.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/discovery.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/dsl/__init__.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/dsl/loader.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/dsl/parser.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/dsl_python/__init__.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/dsl_python/context.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/dsl_python/decorators.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/dsl_python/docker_steps.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/dsl_python/exceptions.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/dsl_python/runner.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/dsl_python/steps.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/fleet.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/hardware/__init__.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/hardware/config_txt.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/hardware/data/__init__.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/hardware/data/hyperpixel.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/hardware/data/official.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/hardware/data/waveshare.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/hardware/fixes.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/hardware/kiosk/__init__.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/hardware/kiosk/autostart.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/hardware/kiosk/browsers.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/hardware/kiosk/compositors.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/hardware/kiosk/output_profiles.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/hardware/panels.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/hardware/raspi_config.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/heal/__init__.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/heal/decider.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/heal/hint_provider.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/heal/log_writer.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/heal/loop_detector.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/heal.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/iac/__init__.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/iac/base.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/iac/config_hints.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/iac/docker_compose.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/iac/parsers/__init__.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/iac/parsers/compose.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/iac/registry.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/integrations/__init__.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/integrations/op3_bridge.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/markpact/__init__.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/markpact/compiler.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/markpact/models.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/markpact/parser.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/mcp_server.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/models/__init__.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/models/blueprint.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/models/devices.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/models/enums.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/models/hardware.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/models/infra.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/models/manifest.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/models/persisted.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/models/pipeline.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/models/plan.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/models/spec.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/observe.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/parse.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/patterns.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/plan/__init__.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/plan/planner.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/plugins/__init__.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/plugins/builtin/__init__.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/plugins/builtin/browser_reload.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/plugins/builtin/hardware_diagnostic.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/plugins/builtin/notify.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/plugins/builtin/process_control.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/plugins/builtin/systemd_reload.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/schema.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/spec_loader.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/ssh.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/steps/__init__.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/steps/builtins.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/steps/kiosk.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/__init__.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_audit.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_data_sync.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_discovery.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_dsl.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_examples.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_executor.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_fleet.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_git_integration.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_heal_decider.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_hooks.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_iac.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_iac_compose_contract.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_iac_docker_compose_contract.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_manifest.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_markpact_compiler.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_markpact_parser.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_models.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_observe.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_parse.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_patterns.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_planner_edge.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_plugins.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_probes.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_public_api.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_resume.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_spec_loader.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_ssh.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_templates.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_verify.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_version.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_version_changelog.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_version_cli.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_version_manifest.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_version_sources.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_workflow.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/verify.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/version/__init__.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/version/bump.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/version/changelog.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/version/commits.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/version/diff.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/version/git_integration.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/version/git_transaction.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/version/manifest.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/version/sources/__init__.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/version/sources/base.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/version/sources/json_.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/version/sources/plain.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/version/sources/regex.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/version/sources/toml_.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/version/sources/yaml_.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/version/transaction.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/version.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy.egg-info/dependency_links.txt +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy.egg-info/entry_points.txt +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy.egg-info/requires.txt +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/redeploy.egg-info/top_level.txt +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/setup.cfg +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/tests/test_blueprint_sources.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/tests/test_blueprint_sources_compose.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/tests/test_cli_query.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/tests/test_example_argocd_flux_plugin.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/tests/test_example_gitops_ci_plugin.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/tests/test_example_helm_kustomize_plugin.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/tests/test_hardware_config.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/tests/test_hardware_kiosk.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/tests/test_hardware_rules.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/tests/test_heal_decider.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/tests/test_heal_loop_detector.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/tests/test_iac.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/tests/test_iac_config_hints.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/tests/test_iac_plugin_loading.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/tests/test_import_plugin_template.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/tests/test_parse.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/tests/test_prompt_llm.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/tests/test_redeploy.py +0 -0
- {redeploy-0.2.60 → redeploy-0.2.62}/tests/test_steps_kiosk.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: redeploy
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.62
|
|
4
4
|
Summary: Infrastructure migration toolkit: detect → plan → apply
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -32,17 +32,17 @@ Requires-Dist: mcp>=1.0; extra == "mcp"
|
|
|
32
32
|
|
|
33
33
|
## AI Cost Tracking
|
|
34
34
|
|
|
35
|
-
     
|
|
36
|
+
  
|
|
37
37
|
|
|
38
|
-
- 🤖 **LLM usage:** $7.5000 (
|
|
39
|
-
- 👤 **Human dev:** ~$
|
|
38
|
+
- 🤖 **LLM usage:** $7.5000 (82 commits)
|
|
39
|
+
- 👤 **Human dev:** ~$3083 (30.8h @ $100/h, 30min dedup)
|
|
40
40
|
|
|
41
|
-
Generated on 2026-04-
|
|
41
|
+
Generated on 2026-04-23 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
|
|
42
42
|
|
|
43
43
|
---
|
|
44
44
|
|
|
45
|
-
    
|
|
46
46
|
|
|
47
47
|
Infrastructure migration and device deploy toolkit — VPS, Raspberry Pi kiosk, Podman Quadlet, k3s.
|
|
48
48
|
|
|
@@ -3,17 +3,17 @@
|
|
|
3
3
|
|
|
4
4
|
## AI Cost Tracking
|
|
5
5
|
|
|
6
|
-
     
|
|
7
|
+
  
|
|
8
8
|
|
|
9
|
-
- 🤖 **LLM usage:** $7.5000 (
|
|
10
|
-
- 👤 **Human dev:** ~$
|
|
9
|
+
- 🤖 **LLM usage:** $7.5000 (82 commits)
|
|
10
|
+
- 👤 **Human dev:** ~$3083 (30.8h @ $100/h, 30min dedup)
|
|
11
11
|
|
|
12
|
-
Generated on 2026-04-
|
|
12
|
+
Generated on 2026-04-23 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
16
|
-
    
|
|
17
17
|
|
|
18
18
|
Infrastructure migration and device deploy toolkit — VPS, Raspberry Pi kiosk, Podman Quadlet, k3s.
|
|
19
19
|
|
|
@@ -7,7 +7,7 @@ Public API (stable from 0.2.0, semver guaranteed)::
|
|
|
7
7
|
|
|
8
8
|
Everything not listed in ``__all__`` is internal and may change without notice.
|
|
9
9
|
"""
|
|
10
|
-
__version__ = "0.2.
|
|
10
|
+
__version__ = "0.2.62"
|
|
11
11
|
|
|
12
12
|
# ── Core models ───────────────────────────────────────────────────────────────
|
|
13
13
|
from .models import ( # noqa: F401
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Built-in static checkers for redeploy specs."""
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""Static analysis engine for migration specs.
|
|
2
|
+
|
|
3
|
+
Scans compiled MigrationSpec + raw markpact blocks for:
|
|
4
|
+
- missing local files/paths referenced in steps (src, dst, command, config_file)
|
|
5
|
+
- broken command_ref / insert_before references
|
|
6
|
+
- docker-compose.yml inconsistencies (missing files, volume paths, build contexts)
|
|
7
|
+
- hardcoded absolute paths to external dependencies in commands
|
|
8
|
+
- .env / config file references
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import yaml
|
|
20
|
+
|
|
21
|
+
from ..markpact.models import MarkpactDocument
|
|
22
|
+
from ..models.plan import MigrationStep
|
|
23
|
+
from ..models.spec import MigrationSpec
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class IssueSeverity(str, Enum):
|
|
27
|
+
ERROR = "error"
|
|
28
|
+
WARNING = "warning"
|
|
29
|
+
INFO = "info"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Issue:
|
|
34
|
+
severity: IssueSeverity
|
|
35
|
+
category: str
|
|
36
|
+
step_id: str | None
|
|
37
|
+
message: str
|
|
38
|
+
suggestion: str | None = None
|
|
39
|
+
line: int | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class AnalysisResult:
|
|
44
|
+
issues: list[Issue] = field(default_factory=list)
|
|
45
|
+
passed: bool = True
|
|
46
|
+
|
|
47
|
+
def add(self, severity: IssueSeverity, category: str, message: str,
|
|
48
|
+
step_id: str | None = None, suggestion: str | None = None, line: int | None = None) -> None:
|
|
49
|
+
self.issues.append(Issue(severity, category, step_id, message, suggestion, line))
|
|
50
|
+
if severity == IssueSeverity.ERROR:
|
|
51
|
+
self.passed = False
|
|
52
|
+
|
|
53
|
+
def errors(self) -> list[Issue]:
|
|
54
|
+
return [i for i in self.issues if i.severity == IssueSeverity.ERROR]
|
|
55
|
+
|
|
56
|
+
def warnings(self) -> list[Issue]:
|
|
57
|
+
return [i for i in self.issues if i.severity == IssueSeverity.WARNING]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _Checker:
|
|
61
|
+
def check(self, spec: MigrationSpec, document: MarkpactDocument | None,
|
|
62
|
+
base_dir: Path, result: AnalysisResult) -> None:
|
|
63
|
+
raise NotImplementedError
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _PathChecker(_Checker):
|
|
67
|
+
"""Validate local file paths referenced by steps."""
|
|
68
|
+
|
|
69
|
+
def check(self, spec, document, base_dir, result):
|
|
70
|
+
for step in spec.extra_steps:
|
|
71
|
+
self._check_field(step, "src", base_dir, result)
|
|
72
|
+
self._check_field(step, "dst", base_dir, result)
|
|
73
|
+
self._check_field(step, "config_file", base_dir, result)
|
|
74
|
+
self._check_env_file(step, base_dir, result)
|
|
75
|
+
|
|
76
|
+
def _check_field(self, step: dict, field_name: str, base_dir: Path, result: AnalysisResult):
|
|
77
|
+
val = step.get(field_name)
|
|
78
|
+
if not val:
|
|
79
|
+
return
|
|
80
|
+
# Only check local (non-ssh) paths; remote paths often start with ~ or user@host:...
|
|
81
|
+
if re.search(r"^[\w.-]+@", str(val)):
|
|
82
|
+
return
|
|
83
|
+
# dst pointing to remote home (~/) is not a local path to verify
|
|
84
|
+
if field_name == "dst" and str(val).startswith("~/"):
|
|
85
|
+
return
|
|
86
|
+
path = self._resolve(val, base_dir)
|
|
87
|
+
if path and not path.exists():
|
|
88
|
+
result.add(
|
|
89
|
+
IssueSeverity.ERROR, "paths",
|
|
90
|
+
f"Step '{step.get('id')}' references missing {field_name}: {val}",
|
|
91
|
+
step.get("id"),
|
|
92
|
+
suggestion=f"Create file/dir or correct path: {path}"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def _check_env_file(self, step: dict, base_dir: Path, result: AnalysisResult):
|
|
96
|
+
# Some steps may compose env_file indirectly; we also scan raw commands for .env
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def _resolve(val: str, base_dir: Path) -> Path | None:
|
|
101
|
+
val = val.strip()
|
|
102
|
+
if val.startswith("~/"):
|
|
103
|
+
return Path.home() / val[2:]
|
|
104
|
+
if val.startswith("/"):
|
|
105
|
+
return Path(val)
|
|
106
|
+
# relative -> resolve against base_dir
|
|
107
|
+
return base_dir / val
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class _CommandPathChecker(_Checker):
|
|
111
|
+
"""Scan command strings for hardcoded absolute paths outside the project."""
|
|
112
|
+
|
|
113
|
+
EXTERNAL_RE = re.compile(
|
|
114
|
+
r"(?:^|\s)(/home/\w+/[^\s'\"]+|~/[^\s'\"]+)"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def check(self, spec, document, base_dir, result):
|
|
118
|
+
for step in spec.extra_steps:
|
|
119
|
+
cmd = step.get("command") or ""
|
|
120
|
+
for match in self.EXTERNAL_RE.finditer(cmd):
|
|
121
|
+
path_str = match.group(1)
|
|
122
|
+
# Ignore paths inside the project tree
|
|
123
|
+
resolved = self._resolve(path_str, base_dir)
|
|
124
|
+
if resolved and self._is_inside(resolved, base_dir):
|
|
125
|
+
continue
|
|
126
|
+
# Check if exists locally
|
|
127
|
+
if resolved and not resolved.exists():
|
|
128
|
+
result.add(
|
|
129
|
+
IssueSeverity.ERROR, "commands",
|
|
130
|
+
f"Step '{step.get('id')}' command references missing external path: {path_str}",
|
|
131
|
+
step.get("id"),
|
|
132
|
+
suggestion="Add rsync/scp step to sync this dependency, or correct the path."
|
|
133
|
+
)
|
|
134
|
+
else:
|
|
135
|
+
result.add(
|
|
136
|
+
IssueSeverity.WARNING, "commands",
|
|
137
|
+
f"Step '{step.get('id')}' command references external path: {path_str}",
|
|
138
|
+
step.get("id"),
|
|
139
|
+
suggestion="Consider adding an explicit sync step for this dependency."
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def _resolve(val: str, base_dir: Path) -> Path | None:
|
|
144
|
+
val = val.strip().rstrip("/")
|
|
145
|
+
if val.startswith("~/"):
|
|
146
|
+
return Path.home() / val[2:]
|
|
147
|
+
if val.startswith("/"):
|
|
148
|
+
return Path(val)
|
|
149
|
+
return base_dir / val
|
|
150
|
+
|
|
151
|
+
@staticmethod
|
|
152
|
+
def _is_inside(path: Path, base: Path) -> bool:
|
|
153
|
+
try:
|
|
154
|
+
path.resolve().relative_to(base.resolve())
|
|
155
|
+
return True
|
|
156
|
+
except ValueError:
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class _ReferenceChecker(_Checker):
|
|
161
|
+
"""Ensure command_ref and insert_before point to existing things."""
|
|
162
|
+
|
|
163
|
+
def check(self, spec, document, base_dir, result):
|
|
164
|
+
step_ids = {s.get("id") for s in spec.extra_steps if s.get("id")}
|
|
165
|
+
ref_ids = set()
|
|
166
|
+
if document:
|
|
167
|
+
for block in document.blocks:
|
|
168
|
+
if block.kind == "ref" and block.ref_id:
|
|
169
|
+
ref_ids.add(block.ref_id)
|
|
170
|
+
|
|
171
|
+
for step in spec.extra_steps:
|
|
172
|
+
sid = step.get("id")
|
|
173
|
+
cref = step.get("command_ref")
|
|
174
|
+
if cref and cref not in ref_ids:
|
|
175
|
+
result.add(
|
|
176
|
+
IssueSeverity.ERROR, "references",
|
|
177
|
+
f"Step '{sid}' references unknown command_ref '{cref}'",
|
|
178
|
+
sid,
|
|
179
|
+
suggestion=f"Define a ```bash markpact:ref {cref} block or remove command_ref."
|
|
180
|
+
)
|
|
181
|
+
ib = step.get("insert_before")
|
|
182
|
+
if ib and ib not in step_ids:
|
|
183
|
+
result.add(
|
|
184
|
+
IssueSeverity.ERROR, "references",
|
|
185
|
+
f"Step '{sid}' insert_before points to unknown step '{ib}'",
|
|
186
|
+
sid,
|
|
187
|
+
suggestion=f"Available step ids: {', '.join(sorted(step_ids)) or 'none'}"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class _ComposeChecker(_Checker):
|
|
192
|
+
"""Validate docker-compose files declared in spec or found in project."""
|
|
193
|
+
|
|
194
|
+
def check(self, spec, document, base_dir, result):
|
|
195
|
+
compose_files = list(spec.target.compose_files or [])
|
|
196
|
+
# Also try to discover common names if none explicitly declared
|
|
197
|
+
if not compose_files:
|
|
198
|
+
for candidate in ("docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"):
|
|
199
|
+
if (base_dir / candidate).exists():
|
|
200
|
+
compose_files.append(candidate)
|
|
201
|
+
break
|
|
202
|
+
|
|
203
|
+
for cf in compose_files:
|
|
204
|
+
path = base_dir / cf
|
|
205
|
+
if not path.exists():
|
|
206
|
+
result.add(
|
|
207
|
+
IssueSeverity.ERROR, "compose",
|
|
208
|
+
f"Declared compose file not found: {cf}",
|
|
209
|
+
suggestion=f"Create {path} or remove from target.compose_files."
|
|
210
|
+
)
|
|
211
|
+
continue
|
|
212
|
+
self._scan_compose(path, base_dir, result)
|
|
213
|
+
|
|
214
|
+
def _scan_compose(self, path: Path, base_dir: Path, result: AnalysisResult):
|
|
215
|
+
try:
|
|
216
|
+
data = yaml.safe_load(path.read_text()) or {}
|
|
217
|
+
except Exception as exc:
|
|
218
|
+
result.add(IssueSeverity.ERROR, "compose", f"Cannot parse {path}: {exc}")
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
services = data.get("services", {})
|
|
222
|
+
for svc_name, svc in services.items():
|
|
223
|
+
if not isinstance(svc, dict):
|
|
224
|
+
continue
|
|
225
|
+
# build.context
|
|
226
|
+
build = svc.get("build")
|
|
227
|
+
if isinstance(build, dict):
|
|
228
|
+
ctx = build.get("context", ".")
|
|
229
|
+
ctx_path = base_dir / ctx if not ctx.startswith("/") else Path(ctx)
|
|
230
|
+
if not ctx_path.exists():
|
|
231
|
+
result.add(
|
|
232
|
+
IssueSeverity.ERROR, "compose",
|
|
233
|
+
f"Service '{svc_name}' build.context missing: {ctx}",
|
|
234
|
+
suggestion=f"Create directory {ctx_path} or fix context."
|
|
235
|
+
)
|
|
236
|
+
dockerfile = build.get("dockerfile")
|
|
237
|
+
if dockerfile:
|
|
238
|
+
df_path = ctx_path / dockerfile
|
|
239
|
+
if not df_path.exists():
|
|
240
|
+
result.add(
|
|
241
|
+
IssueSeverity.ERROR, "compose",
|
|
242
|
+
f"Service '{svc_name}' Dockerfile missing: {df_path}",
|
|
243
|
+
suggestion=f"Create {df_path} or correct dockerfile path."
|
|
244
|
+
)
|
|
245
|
+
# env_file
|
|
246
|
+
env_files = svc.get("env_file", [])
|
|
247
|
+
if isinstance(env_files, str):
|
|
248
|
+
env_files = [env_files]
|
|
249
|
+
for ef in env_files:
|
|
250
|
+
ef_path = base_dir / ef if not str(ef).startswith("/") else Path(ef)
|
|
251
|
+
if not ef_path.exists():
|
|
252
|
+
result.add(
|
|
253
|
+
IssueSeverity.WARNING, "compose",
|
|
254
|
+
f"Service '{svc_name}' env_file missing: {ef}",
|
|
255
|
+
suggestion=f"Create {ef_path} or remove env_file entry."
|
|
256
|
+
)
|
|
257
|
+
# volumes
|
|
258
|
+
for vol in svc.get("volumes", []):
|
|
259
|
+
if isinstance(vol, str):
|
|
260
|
+
if ":" in vol:
|
|
261
|
+
host_part = vol.split(":", 1)[0]
|
|
262
|
+
# Skip named volumes and relative paths that exist
|
|
263
|
+
if host_part.startswith("/"):
|
|
264
|
+
if not Path(host_part).exists():
|
|
265
|
+
result.add(
|
|
266
|
+
IssueSeverity.WARNING, "compose",
|
|
267
|
+
f"Service '{svc_name}' volume host path missing: {host_part}",
|
|
268
|
+
suggestion=f"Ensure host path exists before deployment."
|
|
269
|
+
)
|
|
270
|
+
elif host_part != "." and "/" in host_part:
|
|
271
|
+
hp = base_dir / host_part
|
|
272
|
+
if not hp.exists():
|
|
273
|
+
result.add(
|
|
274
|
+
IssueSeverity.WARNING, "compose",
|
|
275
|
+
f"Service '{svc_name}' relative volume path missing: {hp}",
|
|
276
|
+
suggestion=f"Create {hp} or mount as named volume."
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class _EnvFileChecker(_Checker):
|
|
281
|
+
"""Check that .env referenced by target.env_file exists."""
|
|
282
|
+
|
|
283
|
+
def check(self, spec, document, base_dir, result):
|
|
284
|
+
ef = spec.target.env_file or spec.source.env_file
|
|
285
|
+
if ef:
|
|
286
|
+
path = base_dir / ef if not ef.startswith("/") else Path(ef)
|
|
287
|
+
if not path.exists():
|
|
288
|
+
result.add(
|
|
289
|
+
IssueSeverity.ERROR, "env",
|
|
290
|
+
f"env_file not found: {ef}",
|
|
291
|
+
suggestion=f"Create {path} or correct env_file path."
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class _BinaryChecker(_Checker):
|
|
296
|
+
"""Warn if commands reference binaries not available locally (best-effort)."""
|
|
297
|
+
|
|
298
|
+
def check(self, spec, document, base_dir, result):
|
|
299
|
+
for step in spec.extra_steps:
|
|
300
|
+
cmd = step.get("command") or ""
|
|
301
|
+
for word in cmd.split():
|
|
302
|
+
word = word.strip(";|&`'\"()")
|
|
303
|
+
if "/" in word or word in ("if", "then", "else", "fi", "for", "do", "done", "echo", "true", "false"):
|
|
304
|
+
continue
|
|
305
|
+
if not self._which(word):
|
|
306
|
+
# Only warn for likely commands (simple words, not variable names)
|
|
307
|
+
if re.match(r"^[a-zA-Z][a-zA-Z0-9_-]+$", word):
|
|
308
|
+
result.add(
|
|
309
|
+
IssueSeverity.WARNING, "binaries",
|
|
310
|
+
f"Step '{step.get('id')}' command uses binary not found locally: '{word}'",
|
|
311
|
+
step.get("id"),
|
|
312
|
+
suggestion=f"Install '{word}' or ensure it exists on target host."
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
@staticmethod
|
|
316
|
+
def _which(cmd: str) -> bool:
|
|
317
|
+
try:
|
|
318
|
+
subprocess.run(["which", cmd], capture_output=True, check=True)
|
|
319
|
+
return True
|
|
320
|
+
except Exception:
|
|
321
|
+
return False
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class SpecAnalyzer:
|
|
325
|
+
"""Run static checks against a compiled MigrationSpec (and optional raw MarkpactDocument)."""
|
|
326
|
+
|
|
327
|
+
DEFAULT_CHECKERS: list[_Checker] = [
|
|
328
|
+
_PathChecker(),
|
|
329
|
+
_CommandPathChecker(),
|
|
330
|
+
_ReferenceChecker(),
|
|
331
|
+
_ComposeChecker(),
|
|
332
|
+
_EnvFileChecker(),
|
|
333
|
+
_BinaryChecker(),
|
|
334
|
+
]
|
|
335
|
+
|
|
336
|
+
def __init__(self, base_dir: Path | None = None,
|
|
337
|
+
checkers: list[_Checker] | None = None):
|
|
338
|
+
self.base_dir = base_dir or Path.cwd()
|
|
339
|
+
self.checkers = checkers or list(self.DEFAULT_CHECKERS)
|
|
340
|
+
|
|
341
|
+
def analyze(self, spec: MigrationSpec,
|
|
342
|
+
document: MarkpactDocument | None = None) -> AnalysisResult:
|
|
343
|
+
result = AnalysisResult()
|
|
344
|
+
for checker in self.checkers:
|
|
345
|
+
checker.check(spec, document, self.base_dir, result)
|
|
346
|
+
return result
|
|
347
|
+
|
|
348
|
+
def analyze_file(self, spec_path: Path) -> tuple[MigrationSpec | None, AnalysisResult]:
|
|
349
|
+
"""Load spec from file (YAML or markpact) and analyze."""
|
|
350
|
+
from ..markpact.parser import parse_markpact_file
|
|
351
|
+
from ..markpact.compiler import compile_markpact_document, MarkpactCompileError
|
|
352
|
+
from ..models.spec import MigrationSpec as MS
|
|
353
|
+
|
|
354
|
+
document = None
|
|
355
|
+
spec = None
|
|
356
|
+
result = AnalysisResult()
|
|
357
|
+
|
|
358
|
+
# Try markpact first
|
|
359
|
+
if spec_path.suffix == ".md":
|
|
360
|
+
try:
|
|
361
|
+
document = parse_markpact_file(spec_path)
|
|
362
|
+
spec = compile_markpact_document(document)
|
|
363
|
+
except MarkpactCompileError as exc:
|
|
364
|
+
result.add(IssueSeverity.ERROR, "compile", str(exc))
|
|
365
|
+
return None, result
|
|
366
|
+
except Exception as exc:
|
|
367
|
+
result.add(IssueSeverity.ERROR, "compile", f"Failed to parse markpact: {exc}")
|
|
368
|
+
return None, result
|
|
369
|
+
else:
|
|
370
|
+
try:
|
|
371
|
+
spec = MS.from_file(spec_path)
|
|
372
|
+
except Exception as exc:
|
|
373
|
+
result.add(IssueSeverity.ERROR, "load", f"Failed to load spec: {exc}")
|
|
374
|
+
return None, result
|
|
375
|
+
|
|
376
|
+
if spec:
|
|
377
|
+
self.base_dir = spec_path.parent
|
|
378
|
+
result = self.analyze(spec, document)
|
|
379
|
+
return spec, result
|
|
@@ -57,10 +57,12 @@ from .commands.status import status
|
|
|
57
57
|
from .commands.bump_fix import bump_cmd, fix_cmd
|
|
58
58
|
from .commands.prompt_cmd import prompt_cmd
|
|
59
59
|
from .commands.mcp_cmd import mcp_cmd
|
|
60
|
+
from .commands.lint import lint
|
|
60
61
|
|
|
61
62
|
# Register commands
|
|
62
63
|
cli.add_command(audit)
|
|
63
64
|
cli.add_command(detect)
|
|
65
|
+
cli.add_command(lint)
|
|
64
66
|
cli.add_command(devices)
|
|
65
67
|
cli.add_command(scan)
|
|
66
68
|
cli.add_command(device_add)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""lint command — static analysis of migration specs before deployment."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from ...analyze import SpecAnalyzer
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.command()
|
|
15
|
+
@click.argument("spec_file", default=None, required=False, type=click.Path(), metavar="SPEC")
|
|
16
|
+
@click.option("--env", "env_name", default="", help="Named environment from redeploy.yaml")
|
|
17
|
+
@click.option("--json", "as_json", is_flag=True, help="Output raw JSON")
|
|
18
|
+
@click.option("--exit-zero", is_flag=True, help="Exit 0 even if errors found")
|
|
19
|
+
@click.pass_context
|
|
20
|
+
def lint(ctx, spec_file, env_name, as_json, exit_zero):
|
|
21
|
+
"""Static analysis of a migration spec (YAML or markpact .md).
|
|
22
|
+
|
|
23
|
+
Detects missing files, broken references, missing command_ref blocks,
|
|
24
|
+
docker-compose inconsistencies, and hardcoded external paths.
|
|
25
|
+
"""
|
|
26
|
+
console = Console()
|
|
27
|
+
from ...cli.core import load_spec_or_exit
|
|
28
|
+
from ...models import ProjectManifest
|
|
29
|
+
|
|
30
|
+
manifest = ProjectManifest.find_and_load(Path.cwd())
|
|
31
|
+
resolved = spec_file or (manifest.spec if manifest else "migration.yaml")
|
|
32
|
+
|
|
33
|
+
if not Path(resolved).exists():
|
|
34
|
+
console.print(f"[red]✗ spec file not found: {resolved}[/red]")
|
|
35
|
+
sys.exit(1)
|
|
36
|
+
|
|
37
|
+
spec = load_spec_or_exit(console, resolved)
|
|
38
|
+
|
|
39
|
+
if manifest and env_name:
|
|
40
|
+
if env_name not in manifest.environments:
|
|
41
|
+
console.print(f"[yellow]⚠ env '{env_name}' not in redeploy.yaml[/yellow]")
|
|
42
|
+
manifest.apply_to_spec(spec, env_name=env_name)
|
|
43
|
+
|
|
44
|
+
analyzer = SpecAnalyzer(base_dir=Path(resolved).parent)
|
|
45
|
+
_, result = analyzer.analyze_file(Path(resolved))
|
|
46
|
+
|
|
47
|
+
if as_json:
|
|
48
|
+
import json
|
|
49
|
+
out = {
|
|
50
|
+
"passed": result.passed,
|
|
51
|
+
"errors": len(result.errors()),
|
|
52
|
+
"warnings": len(result.warnings()),
|
|
53
|
+
"issues": [
|
|
54
|
+
{
|
|
55
|
+
"severity": i.severity.value,
|
|
56
|
+
"category": i.category,
|
|
57
|
+
"step_id": i.step_id,
|
|
58
|
+
"message": i.message,
|
|
59
|
+
"suggestion": i.suggestion,
|
|
60
|
+
}
|
|
61
|
+
for i in result.issues
|
|
62
|
+
],
|
|
63
|
+
}
|
|
64
|
+
console.print(json.dumps(out, indent=2))
|
|
65
|
+
else:
|
|
66
|
+
if result.passed and not result.issues:
|
|
67
|
+
console.print("[green]✓ No issues found[/green]")
|
|
68
|
+
else:
|
|
69
|
+
if result.errors():
|
|
70
|
+
console.print(f"[red]✗ {len(result.errors())} error(s)[/red]")
|
|
71
|
+
if result.warnings():
|
|
72
|
+
console.print(f"[yellow]⚠ {len(result.warnings())} warning(s)[/yellow]")
|
|
73
|
+
|
|
74
|
+
t = Table(show_header=True, box=None, padding=(0, 2))
|
|
75
|
+
t.add_column("Severity", width=8)
|
|
76
|
+
t.add_column("Category", width=12)
|
|
77
|
+
t.add_column("Step", width=20)
|
|
78
|
+
t.add_column("Message")
|
|
79
|
+
t.add_column("Suggestion", style="dim")
|
|
80
|
+
for i in result.issues:
|
|
81
|
+
sev = f"[red]{i.severity.value}[/red]" if i.severity.value == "error" else f"[yellow]{i.severity.value}[/yellow]"
|
|
82
|
+
t.add_row(sev, i.category, i.step_id or "—", i.message, i.suggestion or "")
|
|
83
|
+
console.print(t)
|
|
84
|
+
|
|
85
|
+
if not exit_zero and not result.passed:
|
|
86
|
+
sys.exit(1)
|
|
@@ -264,10 +264,14 @@ def migrate(ctx, host, app, domain, target, strategy, target_version,
|
|
|
264
264
|
"--max-heal-retries", default=3, show_default=True,
|
|
265
265
|
help="Maximum number of LLM self-healing attempts."
|
|
266
266
|
)
|
|
267
|
+
@click.option(
|
|
268
|
+
"--lint/--no-lint", default=True, show_default=True,
|
|
269
|
+
help="Run static analysis before deployment to catch missing files, broken references, and external path issues."
|
|
270
|
+
)
|
|
267
271
|
@click.pass_context
|
|
268
272
|
def run(ctx, spec_file, dry_run, plan_only, do_detect, plan_out, output,
|
|
269
273
|
env_name, progress_yaml, resume, from_step, state_file, no_state,
|
|
270
|
-
heal, fix_hint, max_heal_retries):
|
|
274
|
+
heal, fix_hint, max_heal_retries, lint):
|
|
271
275
|
"""Execute migration from a single YAML spec (source + target in one file).
|
|
272
276
|
|
|
273
277
|
SPEC defaults to migration.yaml (or value from redeploy.yaml manifest).
|
|
@@ -305,6 +309,28 @@ def run(ctx, spec_file, dry_run, plan_only, do_detect, plan_out, output,
|
|
|
305
309
|
_apply_manifest_to_spec(console, manifest, spec, env_name)
|
|
306
310
|
_print_spec_summary(console, spec)
|
|
307
311
|
|
|
312
|
+
# Static analysis (lint)
|
|
313
|
+
if lint:
|
|
314
|
+
from ...analyze import SpecAnalyzer, IssueSeverity
|
|
315
|
+
analyzer = SpecAnalyzer(base_dir=Path(resolved_spec).parent)
|
|
316
|
+
_, lint_result = analyzer.analyze_file(Path(resolved_spec))
|
|
317
|
+
if lint_result.issues:
|
|
318
|
+
console.print(f"\n[bold]lint[/bold]")
|
|
319
|
+
for i in lint_result.issues:
|
|
320
|
+
color = "red" if i.severity == IssueSeverity.ERROR else "yellow"
|
|
321
|
+
prefix = f"[{color}]{i.severity.value}[/{color}]"
|
|
322
|
+
step = f" ({i.step_id})" if i.step_id else ""
|
|
323
|
+
console.print(f" {prefix}{step} {i.category}: {i.message}")
|
|
324
|
+
if i.suggestion:
|
|
325
|
+
console.print(f" [dim]→ {i.suggestion}[/dim]")
|
|
326
|
+
if not lint_result.passed:
|
|
327
|
+
console.print("\n[red]✗ lint failed — fix errors above or use --no-lint to skip.[/red]")
|
|
328
|
+
sys.exit(1)
|
|
329
|
+
else:
|
|
330
|
+
console.print(f"[yellow]⚠ lint passed with {len(lint_result.warnings())} warning(s)[/yellow]")
|
|
331
|
+
else:
|
|
332
|
+
console.print("[green] ✓ lint passed[/green]")
|
|
333
|
+
|
|
308
334
|
# Optional live detect
|
|
309
335
|
if do_detect:
|
|
310
336
|
planner = _perform_live_detect(console, spec)
|
|
@@ -113,7 +113,10 @@ def _call_llm(schema: dict, user_prompt: str) -> str:
|
|
|
113
113
|
kwargs["api_key"] = api_key
|
|
114
114
|
|
|
115
115
|
resp = litellm.completion(**kwargs)
|
|
116
|
-
|
|
116
|
+
content = resp.choices[0].message.content
|
|
117
|
+
if content is None:
|
|
118
|
+
raise ValueError("LLM returned None content")
|
|
119
|
+
return content.strip()
|
|
117
120
|
|
|
118
121
|
|
|
119
122
|
def _parse_llm_response(raw: str) -> dict:
|
|
@@ -145,12 +145,24 @@ class HealRunner:
|
|
|
145
145
|
" [dim]LLM proposal:[/dim]\n"
|
|
146
146
|
+ "\n".join(f" {l}" for l in llm_response.splitlines()[:12])
|
|
147
147
|
)
|
|
148
|
+
before_patch = self.spec_path.read_text()
|
|
148
149
|
fixed = apply_fix_to_spec(self.spec_path, failed_step, llm_response)
|
|
149
150
|
if fixed:
|
|
150
151
|
desc_m = re.search(r'description:\s*"([^"]+)"', llm_response)
|
|
151
152
|
summary = desc_m.group(1) if desc_m else llm_response[:60].replace("\n", " ")
|
|
152
153
|
self.console.print(f" [green]patched spec:[/green] `{failed_step}`")
|
|
153
|
-
|
|
154
|
+
try:
|
|
155
|
+
self._reload_migration()
|
|
156
|
+
except Exception as exc:
|
|
157
|
+
# LLM patch changed the file but produced an invalid spec.
|
|
158
|
+
# Restore previous content and continue heal flow safely.
|
|
159
|
+
self.spec_path.write_text(before_patch)
|
|
160
|
+
fixed = False
|
|
161
|
+
summary = f"invalid patch: {exc.__class__.__name__}"
|
|
162
|
+
self.console.print(
|
|
163
|
+
" [yellow]LLM fix invalid for current runtime; "
|
|
164
|
+
"reverted spec patch[/yellow]"
|
|
165
|
+
)
|
|
154
166
|
else:
|
|
155
167
|
self.console.print(" [yellow]LLM fix not applicable[/yellow]")
|
|
156
168
|
else:
|
|
@@ -163,7 +163,7 @@ class TestRunPlanOnly:
|
|
|
163
163
|
spec = tmp_path / "migration.md"
|
|
164
164
|
spec.write_text(_migration_markdown_supported(), encoding="utf-8")
|
|
165
165
|
|
|
166
|
-
result = _runner().invoke(cli, ["run", str(spec), "--plan-only"])
|
|
166
|
+
result = _runner().invoke(cli, ["run", str(spec), "--plan-only", "--no-lint"])
|
|
167
167
|
|
|
168
168
|
assert result.exit_code == 0, result.output
|
|
169
169
|
assert "markdown migration" in result.output
|