redeploy 0.2.45__tar.gz → 0.2.47__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.45 → redeploy-0.2.47}/PKG-INFO +7 -7
- {redeploy-0.2.45 → redeploy-0.2.47}/README.md +5 -5
- {redeploy-0.2.45 → redeploy-0.2.47}/pyproject.toml +2 -2
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/__init__.py +1 -1
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/apply/__init__.py +10 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/apply/handlers.py +109 -27
- redeploy-0.2.47/redeploy/blueprint/extractor.py +117 -0
- redeploy-0.2.47/redeploy/blueprint/sources/__init__.py +8 -0
- redeploy-0.2.47/redeploy/blueprint/sources/compose.py +146 -0
- redeploy-0.2.47/redeploy/blueprint/sources/hardware.py +45 -0
- redeploy-0.2.47/redeploy/blueprint/sources/infra.py +43 -0
- redeploy-0.2.47/redeploy/blueprint/sources/migration.py +34 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/blueprint.py +3 -63
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/device_map.py +6 -250
- redeploy-0.2.47/redeploy/cli/commands/device_map_renderers.py +170 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/hardware.py +35 -22
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/plan_apply.py +82 -13
- redeploy-0.2.47/redeploy/cli/query.py +42 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/detect/templates.py +117 -72
- redeploy-0.2.47/redeploy/heal.py +414 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/integrations/op3_bridge.py +91 -11
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/models.py +9 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy.egg-info/PKG-INFO +7 -7
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy.egg-info/SOURCES.txt +8 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy.egg-info/requires.txt +1 -1
- redeploy-0.2.45/redeploy/blueprint/extractor.py +0 -328
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/apply/exceptions.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/apply/executor.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/apply/progress.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/apply/rollback.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/apply/state.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/apply/state_apply.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/apply/utils/__init__.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/apply/utils/run_container_build.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/audit.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/blueprint/__init__.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/blueprint/generators/__init__.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/blueprint/generators/docker_compose.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/blueprint/generators/migration.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/__init__.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/__init__.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/audit.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/detect.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/devices.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/diagnose.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/diff.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/exec_.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/export.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/import_.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/init.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/inspect.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/patterns.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/plugin.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/probe.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/push.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/state.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/status.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/target.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/version/__init__.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/version/commands.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/version/helpers.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/version/monorepo.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/version/release.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/version/scanner.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/commands/workflow.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/core.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli/display.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/cli.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/config_apply/__init__.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/config_apply/applier.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/config_apply/loader.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/data_sync.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/detect/__init__.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/detect/builtin/__init__.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/detect/detector.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/detect/hardware.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/detect/hardware_rules.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/detect/probes.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/detect/remote.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/detect/workflow.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/discovery.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/dsl/__init__.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/dsl/loader.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/dsl/parser.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/dsl_python/__init__.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/dsl_python/context.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/dsl_python/decorators.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/dsl_python/docker_steps.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/dsl_python/exceptions.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/dsl_python/runner.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/dsl_python/steps.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/fleet.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/hardware/__init__.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/hardware/config_txt.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/hardware/data/__init__.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/hardware/data/hyperpixel.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/hardware/data/official.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/hardware/data/waveshare.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/hardware/fixes.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/hardware/kiosk/__init__.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/hardware/kiosk/autostart.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/hardware/kiosk/browsers.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/hardware/kiosk/compositors.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/hardware/kiosk/output_profiles.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/hardware/panels.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/hardware/raspi_config.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/iac/__init__.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/iac/base.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/iac/docker_compose.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/iac/parsers/__init__.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/iac/parsers/compose.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/iac/registry.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/integrations/__init__.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/markpact/__init__.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/markpact/compiler.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/markpact/models.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/markpact/parser.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/observe.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/parse.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/patterns.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/plan/__init__.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/plan/planner.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/plugins/__init__.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/plugins/builtin/__init__.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/plugins/builtin/browser_reload.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/plugins/builtin/hardware_diagnostic.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/plugins/builtin/notify.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/plugins/builtin/process_control.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/plugins/builtin/systemd_reload.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/spec_loader.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/ssh.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/steps.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/__init__.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_audit.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_cli.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_data_sync.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_discovery.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_dsl.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_examples.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_executor.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_fleet.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_git_integration.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_iac.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_manifest.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_markpact_compiler.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_markpact_parser.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_models.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_observe.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_parse.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_patterns.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_planner_edge.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_plugins.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_probes.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_public_api.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_resume.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_spec_loader.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_ssh.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_templates.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_verify.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_version.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_version_changelog.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_version_cli.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_version_manifest.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_version_sources.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/tests/test_workflow.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/verify.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/version/__init__.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/version/bump.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/version/changelog.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/version/commits.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/version/diff.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/version/git_integration.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/version/git_transaction.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/version/manifest.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/version/sources/__init__.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/version/sources/base.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/version/sources/json_.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/version/sources/plain.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/version/sources/regex.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/version/sources/toml_.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/version/sources/yaml_.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/version/transaction.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy/version.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy.egg-info/dependency_links.txt +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy.egg-info/entry_points.txt +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/redeploy.egg-info/top_level.txt +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/setup.cfg +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/tests/test_hardware_config.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/tests/test_hardware_kiosk.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/tests/test_hardware_rules.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/tests/test_iac.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/tests/test_parse.py +0 -0
- {redeploy-0.2.45 → redeploy-0.2.47}/tests/test_redeploy.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.47
|
|
4
4
|
Summary: Infrastructure migration toolkit: detect → plan → apply
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -23,24 +23,24 @@ Requires-Dist: goal>=2.1.0; extra == "dev"
|
|
|
23
23
|
Requires-Dist: costs>=0.1.20; extra == "dev"
|
|
24
24
|
Requires-Dist: pfix>=0.1.60; extra == "dev"
|
|
25
25
|
Provides-Extra: op3
|
|
26
|
-
Requires-Dist: op3>=0.1.
|
|
26
|
+
Requires-Dist: op3>=0.1.8; extra == "op3"
|
|
27
27
|
|
|
28
28
|
# redeploy
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
## AI Cost Tracking
|
|
32
32
|
|
|
33
|
-
     
|
|
34
|
+
  
|
|
35
35
|
|
|
36
|
-
- 🤖 **LLM usage:** $7.5000 (
|
|
37
|
-
- 👤 **Human dev:** ~$
|
|
36
|
+
- 🤖 **LLM usage:** $7.5000 (68 commits)
|
|
37
|
+
- 👤 **Human dev:** ~$2398 (24.0h @ $100/h, 30min dedup)
|
|
38
38
|
|
|
39
39
|
Generated on 2026-04-21 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
|
|
40
40
|
|
|
41
41
|
---
|
|
42
42
|
|
|
43
|
-
    
|
|
44
44
|
|
|
45
45
|
Infrastructure migration and device deploy toolkit — VPS, Raspberry Pi kiosk, Podman Quadlet, k3s.
|
|
46
46
|
|
|
@@ -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 (68 commits)
|
|
10
|
+
- 👤 **Human dev:** ~$2398 (24.0h @ $100/h, 30min dedup)
|
|
11
11
|
|
|
12
12
|
Generated on 2026-04-21 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
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "redeploy"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.47"
|
|
8
8
|
description = "Infrastructure migration toolkit: detect → plan → apply"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -32,7 +32,7 @@ dev = ["pytest>=8.0", "ruff>=0.4",
|
|
|
32
32
|
"costs>=0.1.20",
|
|
33
33
|
"pfix>=0.1.60",
|
|
34
34
|
]
|
|
35
|
-
op3 = ["op3>=0.1.
|
|
35
|
+
op3 = ["op3>=0.1.8"]
|
|
36
36
|
|
|
37
37
|
[tool.ruff]
|
|
38
38
|
line-length = 100
|
|
@@ -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.47"
|
|
11
11
|
|
|
12
12
|
# ── Core models ───────────────────────────────────────────────────────────────
|
|
13
13
|
from .models import ( # noqa: F401
|
|
@@ -5,10 +5,15 @@ from .handlers import (
|
|
|
5
5
|
run_container_log_tail,
|
|
6
6
|
run_docker_build,
|
|
7
7
|
run_docker_health_wait,
|
|
8
|
+
run_ensure_autostart_entry,
|
|
9
|
+
run_ensure_browser_kiosk_script,
|
|
10
|
+
run_ensure_config_line,
|
|
11
|
+
run_ensure_kanshi_profile,
|
|
8
12
|
run_http_check,
|
|
9
13
|
run_inline_script,
|
|
10
14
|
run_plugin,
|
|
11
15
|
run_podman_build,
|
|
16
|
+
run_raspi_config,
|
|
12
17
|
run_rsync,
|
|
13
18
|
run_scp,
|
|
14
19
|
run_ssh,
|
|
@@ -37,10 +42,15 @@ __all__ = [
|
|
|
37
42
|
"run_container_log_tail",
|
|
38
43
|
"run_docker_build",
|
|
39
44
|
"run_docker_health_wait",
|
|
45
|
+
"run_ensure_autostart_entry",
|
|
46
|
+
"run_ensure_browser_kiosk_script",
|
|
47
|
+
"run_ensure_config_line",
|
|
48
|
+
"run_ensure_kanshi_profile",
|
|
40
49
|
"run_http_check",
|
|
41
50
|
"run_inline_script",
|
|
42
51
|
"run_plugin",
|
|
43
52
|
"run_podman_build",
|
|
53
|
+
"run_raspi_config",
|
|
44
54
|
"run_rsync",
|
|
45
55
|
"run_scp",
|
|
46
56
|
"run_ssh",
|
|
@@ -405,14 +405,36 @@ def run_raspi_config(step: MigrationStep, probe: "RemoteProbe") -> None:
|
|
|
405
405
|
def run_ensure_kanshi_profile(step: MigrationStep, probe: "RemoteProbe") -> None:
|
|
406
406
|
"""Idempotently write or replace a named kanshi output profile.
|
|
407
407
|
|
|
408
|
-
|
|
409
|
-
|
|
408
|
+
Declarative mode (preferred)::
|
|
409
|
+
|
|
410
|
+
profile_name: waveshare-only
|
|
411
|
+
outputs_on: [DSI-2]
|
|
412
|
+
outputs_off: [HDMI-A-2]
|
|
413
|
+
|
|
414
|
+
Legacy mode (pre-rendered block)::
|
|
415
|
+
|
|
416
|
+
step.command — the profile block to write (rendered kanshi syntax)
|
|
417
|
+
step.config_file — kanshi config path (default: ~/.config/kanshi/config)
|
|
410
418
|
"""
|
|
411
419
|
from ..hardware.kiosk.output_profiles import OutputProfile
|
|
412
420
|
|
|
413
|
-
|
|
421
|
+
# Declarative mode: build profile from semantic fields
|
|
422
|
+
if step.profile_name:
|
|
423
|
+
profile = OutputProfile(
|
|
424
|
+
name=step.profile_name,
|
|
425
|
+
enabled=list(step.outputs_on),
|
|
426
|
+
disabled=list(step.outputs_off),
|
|
427
|
+
)
|
|
428
|
+
profile_block = profile.to_kanshi_config()
|
|
429
|
+
else:
|
|
430
|
+
profile_block = step.command
|
|
431
|
+
|
|
414
432
|
if not profile_block:
|
|
415
|
-
raise StepError(
|
|
433
|
+
raise StepError(
|
|
434
|
+
step,
|
|
435
|
+
"ensure_kanshi_profile requires profile_name+outputs_on/outputs_off "
|
|
436
|
+
"or step.command with pre-rendered profile block",
|
|
437
|
+
)
|
|
416
438
|
|
|
417
439
|
config_path = step.config_file or "~/.config/kanshi/config"
|
|
418
440
|
mkdir_cmd = f"mkdir -p $(dirname {config_path})"
|
|
@@ -462,56 +484,116 @@ def run_ensure_kanshi_profile(step: MigrationStep, probe: "RemoteProbe") -> None
|
|
|
462
484
|
|
|
463
485
|
|
|
464
486
|
def run_ensure_autostart_entry(step: MigrationStep, probe: "RemoteProbe") -> None:
|
|
465
|
-
"""Idempotently add or replace
|
|
487
|
+
"""Idempotently add or replace keyed entries in a compositor autostart file.
|
|
466
488
|
|
|
467
|
-
|
|
468
|
-
step.config_line — the line to write (the entry body)
|
|
469
|
-
step.config_section — used as the entry key for idempotent marker
|
|
470
|
-
"""
|
|
471
|
-
from ..hardware.kiosk.autostart import AutostartEntry, ensure_autostart_entry
|
|
489
|
+
Declarative mode (preferred)::
|
|
472
490
|
|
|
473
|
-
|
|
474
|
-
|
|
491
|
+
compositor: labwc
|
|
492
|
+
entries:
|
|
493
|
+
- "kanshid &"
|
|
494
|
+
- "sleep 3"
|
|
495
|
+
- "bash /home/pi/c2004/scripts/kiosk-launch.sh &"
|
|
475
496
|
|
|
476
|
-
|
|
477
|
-
entry = AutostartEntry(key=key, line=step.config_line)
|
|
497
|
+
Legacy mode (single entry)::
|
|
478
498
|
|
|
479
|
-
|
|
499
|
+
step.config_file — path to autostart file
|
|
500
|
+
step.config_line — the line to write (the entry body)
|
|
501
|
+
step.config_section — used as the entry key for idempotent marker
|
|
502
|
+
"""
|
|
503
|
+
from ..hardware.kiosk.autostart import AutostartEntry, ensure_autostart_entry
|
|
504
|
+
from ..hardware.kiosk.compositors import COMPOSITORS
|
|
505
|
+
|
|
506
|
+
# Determine autostart path
|
|
507
|
+
config_file = step.config_file
|
|
508
|
+
if not config_file and step.compositor:
|
|
509
|
+
comp = COMPOSITORS.get(step.compositor)
|
|
510
|
+
if not comp:
|
|
511
|
+
raise StepError(step, f"Unknown compositor '{step.compositor}'")
|
|
512
|
+
config_file = comp.autostart_abs()
|
|
513
|
+
if not config_file:
|
|
514
|
+
raise StepError(step, "ensure_autostart_entry requires config_file or compositor")
|
|
515
|
+
|
|
516
|
+
# Collect entries to apply
|
|
517
|
+
entries: list[AutostartEntry] = []
|
|
518
|
+
if step.entries:
|
|
519
|
+
for i, line in enumerate(step.entries):
|
|
520
|
+
key = f"redeploy-{i}-{line.split()[0] if line else 'entry'}"
|
|
521
|
+
entries.append(AutostartEntry(key=key, line=line))
|
|
522
|
+
elif step.config_line:
|
|
523
|
+
key = step.config_section or "redeploy"
|
|
524
|
+
entries.append(AutostartEntry(key=key, line=step.config_line))
|
|
525
|
+
|
|
526
|
+
if not entries:
|
|
527
|
+
raise StepError(step, "ensure_autostart_entry requires entries or config_line")
|
|
528
|
+
|
|
529
|
+
r = probe.run(f"cat {config_file} 2>/dev/null || echo ''", timeout=10)
|
|
480
530
|
existing = r.out if r.ok else ""
|
|
481
531
|
|
|
482
|
-
|
|
483
|
-
|
|
532
|
+
changed_any = False
|
|
533
|
+
new_content = existing
|
|
534
|
+
for entry in entries:
|
|
535
|
+
new_content, changed = ensure_autostart_entry(new_content, entry)
|
|
536
|
+
if changed:
|
|
537
|
+
changed_any = True
|
|
538
|
+
|
|
539
|
+
if not changed_any:
|
|
484
540
|
step.status = StepStatus.DONE
|
|
485
|
-
step.result = f"no-op:
|
|
541
|
+
step.result = f"no-op: {len(entries)} autostart entry(ies) already correct"
|
|
486
542
|
return
|
|
487
543
|
|
|
488
|
-
mkdir_cmd = f"mkdir -p $(dirname {
|
|
544
|
+
mkdir_cmd = f"mkdir -p $(dirname {config_file})"
|
|
489
545
|
probe.run(mkdir_cmd, timeout=10)
|
|
490
546
|
|
|
491
547
|
encoded = base64.b64encode(new_content.encode()).decode()
|
|
492
548
|
tmp = f"/tmp/redeploy-autostart-{step.id}.txt"
|
|
493
549
|
write_r = probe.run(
|
|
494
|
-
f"echo '{encoded}' | base64 -d | tee {tmp} > /dev/null && mv {tmp} {
|
|
550
|
+
f"echo '{encoded}' | base64 -d | tee {tmp} > /dev/null && mv {tmp} {config_file}",
|
|
495
551
|
timeout=15,
|
|
496
552
|
)
|
|
497
553
|
if not write_r.ok:
|
|
498
|
-
raise StepError(step, f"Cannot write {
|
|
554
|
+
raise StepError(step, f"Cannot write {config_file}: {write_r.stderr[:200]}")
|
|
499
555
|
|
|
500
556
|
step.status = StepStatus.DONE
|
|
501
|
-
step.result = f"
|
|
557
|
+
step.result = f"{len(entries)} autostart entry(ies) written to {config_file}"
|
|
502
558
|
|
|
503
559
|
|
|
504
560
|
def run_ensure_browser_kiosk_script(step: MigrationStep, probe: "RemoteProbe") -> None:
|
|
505
561
|
"""Write a kiosk-launch.sh script to the remote device.
|
|
506
562
|
|
|
507
|
-
|
|
508
|
-
|
|
563
|
+
Declarative mode (preferred)::
|
|
564
|
+
|
|
565
|
+
browser_profile: chromium_wayland_kiosk
|
|
566
|
+
url: "http://localhost:8100/connect-id?font=xlarge&theme=dark"
|
|
567
|
+
kiosk_script_path: /home/pi/c2004/scripts/kiosk-launch.sh
|
|
568
|
+
|
|
569
|
+
Legacy mode (pre-rendered script)::
|
|
570
|
+
|
|
571
|
+
step.command — the full script content to write
|
|
572
|
+
step.dst — destination path (default: ~/kiosk-launch.sh)
|
|
509
573
|
"""
|
|
510
|
-
|
|
574
|
+
from ..hardware.kiosk.browsers import CHROMIUM_WAYLAND_KIOSK
|
|
575
|
+
|
|
576
|
+
# Declarative mode: build script from profile + URL
|
|
577
|
+
if step.browser_profile:
|
|
578
|
+
# Registry lookup (extend when more profiles exist)
|
|
579
|
+
if step.browser_profile == "chromium_wayland_kiosk":
|
|
580
|
+
profile = CHROMIUM_WAYLAND_KIOSK
|
|
581
|
+
else:
|
|
582
|
+
raise StepError(step, f"Unknown browser_profile '{step.browser_profile}'")
|
|
583
|
+
if not step.url:
|
|
584
|
+
raise StepError(step, "browser_profile mode requires 'url' field")
|
|
585
|
+
script_content = profile.build_launch_cmd(step.url)
|
|
586
|
+
else:
|
|
587
|
+
script_content = step.command
|
|
588
|
+
|
|
511
589
|
if not script_content:
|
|
512
|
-
raise StepError(
|
|
590
|
+
raise StepError(
|
|
591
|
+
step,
|
|
592
|
+
"ensure_browser_kiosk_script requires browser_profile+url "
|
|
593
|
+
"or step.command with pre-rendered script body",
|
|
594
|
+
)
|
|
513
595
|
|
|
514
|
-
dst = step.dst or "~/kiosk-launch.sh"
|
|
596
|
+
dst = step.kiosk_script_path or step.dst or "~/kiosk-launch.sh"
|
|
515
597
|
|
|
516
598
|
r = probe.run(f"cat {dst} 2>/dev/null || echo ''", timeout=10)
|
|
517
599
|
existing = r.out if r.ok else ""
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Blueprint extractor — derive a DeviceBlueprint from multiple sources.
|
|
2
|
+
|
|
3
|
+
Sources (in order of priority):
|
|
4
|
+
1. Running InfraState (podman ps / docker inspect on live device)
|
|
5
|
+
2. DeviceMap hardware snapshot
|
|
6
|
+
3. docker-compose YAML files
|
|
7
|
+
4. markpact / migration YAML definitions
|
|
8
|
+
|
|
9
|
+
The extractor reconciles all available information into a single
|
|
10
|
+
DeviceBlueprint that is self-contained and portable.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from ..models import (
|
|
18
|
+
BlueprintSource,
|
|
19
|
+
DeviceBlueprint,
|
|
20
|
+
DeviceMap,
|
|
21
|
+
InfraState,
|
|
22
|
+
ServiceSpec,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def extract_blueprint(
|
|
27
|
+
*,
|
|
28
|
+
name: str,
|
|
29
|
+
device_map: Optional[DeviceMap] = None,
|
|
30
|
+
infra: Optional[InfraState] = None,
|
|
31
|
+
compose_files: Optional[list[Path]] = None,
|
|
32
|
+
migration_file: Optional[Path] = None,
|
|
33
|
+
markpact_files: Optional[list[Path]] = None,
|
|
34
|
+
version: str = "0.0.0",
|
|
35
|
+
description: str = "",
|
|
36
|
+
tags: Optional[list[str]] = None,
|
|
37
|
+
detect_live: bool = False,
|
|
38
|
+
host: Optional[str] = None,
|
|
39
|
+
) -> DeviceBlueprint:
|
|
40
|
+
"""Build a DeviceBlueprint by reconciling all available sources.
|
|
41
|
+
|
|
42
|
+
Parameters
|
|
43
|
+
----------
|
|
44
|
+
name:
|
|
45
|
+
Human-readable blueprint name, e.g. ``"c2004-kiosk"``.
|
|
46
|
+
device_map:
|
|
47
|
+
Previously saved DeviceMap (hardware + infra snapshot).
|
|
48
|
+
infra:
|
|
49
|
+
InfraState if already probed (skips live detection).
|
|
50
|
+
compose_files:
|
|
51
|
+
Local docker-compose YAML files to parse service specs from.
|
|
52
|
+
migration_file:
|
|
53
|
+
Local migration.yaml to extract app version and strategy from.
|
|
54
|
+
markpact_files:
|
|
55
|
+
Local markdown files with markpact fenced blocks.
|
|
56
|
+
detect_live:
|
|
57
|
+
If True and *host* is given, probe the host via SSH for a fresh
|
|
58
|
+
InfraState even if *device_map* already has one.
|
|
59
|
+
host:
|
|
60
|
+
SSH target used when ``detect_live=True``.
|
|
61
|
+
"""
|
|
62
|
+
if detect_live and host and not infra:
|
|
63
|
+
from ..detect import Detector
|
|
64
|
+
infra = infra or Detector(host).run()
|
|
65
|
+
|
|
66
|
+
from .sources.compose import merge_compose_files
|
|
67
|
+
from .sources.hardware import build_hw_requirements
|
|
68
|
+
from .sources.infra import extract_services_from_infra, infer_app_url
|
|
69
|
+
from .sources.migration import parse_migration_meta
|
|
70
|
+
|
|
71
|
+
# Prefer freshly probed infra, fall back to device_map.infra
|
|
72
|
+
_infra: Optional[InfraState] = infra or (device_map.infra if device_map else None)
|
|
73
|
+
_hw = device_map.hardware if device_map else None
|
|
74
|
+
|
|
75
|
+
services: list[ServiceSpec] = []
|
|
76
|
+
seen: set[str] = set()
|
|
77
|
+
|
|
78
|
+
# ── 1. Live InfraState ────────────────────────────────────────────────────
|
|
79
|
+
if _infra:
|
|
80
|
+
services.extend(extract_services_from_infra(_infra, seen))
|
|
81
|
+
|
|
82
|
+
# ── 2. docker-compose files ───────────────────────────────────────────────
|
|
83
|
+
merge_compose_files(list(compose_files or []), services, seen)
|
|
84
|
+
|
|
85
|
+
# ── 3. migration.yaml ─────────────────────────────────────────────────────
|
|
86
|
+
app_version = version
|
|
87
|
+
deploy_strategy = "podman_quadlet"
|
|
88
|
+
if migration_file and migration_file.exists():
|
|
89
|
+
meta = parse_migration_meta(migration_file)
|
|
90
|
+
app_version = meta.get("version", version)
|
|
91
|
+
deploy_strategy = meta.get("strategy", deploy_strategy)
|
|
92
|
+
|
|
93
|
+
# ── 4. Hardware requirements ────────────────────────────────────────────
|
|
94
|
+
hw_req = build_hw_requirements(_hw)
|
|
95
|
+
|
|
96
|
+
# ── 5. App URL ────────────────────────────────────────────────────────────
|
|
97
|
+
app_url = infer_app_url(_infra)
|
|
98
|
+
|
|
99
|
+
source = BlueprintSource(
|
|
100
|
+
device_id=device_map.id if device_map else host,
|
|
101
|
+
device_name=device_map.name if device_map else "",
|
|
102
|
+
migration_file=str(migration_file) if migration_file else None,
|
|
103
|
+
markpact_files=[str(p) for p in (markpact_files or [])],
|
|
104
|
+
compose_files=[str(p) for p in (compose_files or [])],
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
return DeviceBlueprint(
|
|
108
|
+
name=name,
|
|
109
|
+
version=app_version,
|
|
110
|
+
description=description,
|
|
111
|
+
tags=tags or (device_map.tags if device_map else []),
|
|
112
|
+
services=services,
|
|
113
|
+
hardware=hw_req,
|
|
114
|
+
app_url=app_url,
|
|
115
|
+
deploy_strategy=deploy_strategy,
|
|
116
|
+
source=source,
|
|
117
|
+
)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Blueprint source adapters — extract partial blueprints from multiple inputs.
|
|
2
|
+
|
|
3
|
+
Each module in this package exposes a narrow, single-responsibility function
|
|
4
|
+
that reads one kind of source (live infra, docker-compose, migration.yaml,
|
|
5
|
+
DeviceMap hardware, …) and returns structured data for the orchestrator in
|
|
6
|
+
:mod:`redeploy.blueprint.extractor` to merge into a ``DeviceBlueprint``.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Parse docker-compose YAML files into :class:`ServiceSpec` objects."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from ...models import ServicePort, ServiceSpec, VolumeMount
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def merge_compose_files(
|
|
14
|
+
compose_files: list[Path],
|
|
15
|
+
services: list[ServiceSpec],
|
|
16
|
+
seen: set[str],
|
|
17
|
+
) -> None:
|
|
18
|
+
"""Parse each docker-compose file and merge specs into *services* / *seen*."""
|
|
19
|
+
for cf in compose_files:
|
|
20
|
+
_merge_compose(cf, services, seen)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _merge_compose(path: Path, services: list[ServiceSpec], seen: set[str]) -> None:
|
|
24
|
+
"""Parse a single docker-compose YAML and merge service specs."""
|
|
25
|
+
try:
|
|
26
|
+
raw = yaml.safe_load(path.read_text())
|
|
27
|
+
except Exception:
|
|
28
|
+
return
|
|
29
|
+
if not isinstance(raw, dict) or "services" not in raw:
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
for svc_name, svc_def in raw["services"].items():
|
|
33
|
+
if not isinstance(svc_def, dict):
|
|
34
|
+
continue
|
|
35
|
+
|
|
36
|
+
existing = next((s for s in services if s.name == svc_name), None)
|
|
37
|
+
image = svc_def.get("image", "")
|
|
38
|
+
platform = svc_def.get("platform", "")
|
|
39
|
+
ports = _parse_compose_ports(svc_def.get("ports", []))
|
|
40
|
+
volumes = _parse_compose_volumes(svc_def.get("volumes", []))
|
|
41
|
+
env = _parse_compose_env(svc_def.get("environment", {}))
|
|
42
|
+
healthcheck = _parse_compose_healthcheck(svc_def.get("healthcheck", {}))
|
|
43
|
+
depends = svc_def.get("depends_on", [])
|
|
44
|
+
if isinstance(depends, dict):
|
|
45
|
+
depends = list(depends.keys())
|
|
46
|
+
command = svc_def.get("command", None)
|
|
47
|
+
if isinstance(command, list):
|
|
48
|
+
command = " ".join(str(c) for c in command)
|
|
49
|
+
restart = svc_def.get("restart", "unless-stopped")
|
|
50
|
+
privileged = bool(svc_def.get("privileged", False))
|
|
51
|
+
|
|
52
|
+
if existing:
|
|
53
|
+
if not existing.image and image:
|
|
54
|
+
existing.image = image
|
|
55
|
+
if not existing.platform and platform:
|
|
56
|
+
existing.platform = platform
|
|
57
|
+
if not existing.ports:
|
|
58
|
+
existing.ports = ports
|
|
59
|
+
if not existing.volumes:
|
|
60
|
+
existing.volumes = volumes
|
|
61
|
+
existing.env.update(env)
|
|
62
|
+
if not existing.healthcheck and healthcheck:
|
|
63
|
+
existing.healthcheck = healthcheck
|
|
64
|
+
if not existing.depends_on:
|
|
65
|
+
existing.depends_on = depends
|
|
66
|
+
existing.source_ref = str(path)
|
|
67
|
+
else:
|
|
68
|
+
seen.add(svc_name)
|
|
69
|
+
services.append(ServiceSpec(
|
|
70
|
+
name=svc_name,
|
|
71
|
+
image=image,
|
|
72
|
+
platform=platform,
|
|
73
|
+
ports=ports,
|
|
74
|
+
volumes=volumes,
|
|
75
|
+
env=env,
|
|
76
|
+
command=command,
|
|
77
|
+
healthcheck=healthcheck,
|
|
78
|
+
depends_on=depends,
|
|
79
|
+
restart=restart,
|
|
80
|
+
privileged=privileged,
|
|
81
|
+
source_ref=str(path),
|
|
82
|
+
))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _parse_compose_ports(raw: list) -> list[ServicePort]:
|
|
86
|
+
result = []
|
|
87
|
+
for entry in raw:
|
|
88
|
+
if isinstance(entry, int):
|
|
89
|
+
result.append(ServicePort(host=entry, container=entry))
|
|
90
|
+
elif isinstance(entry, str):
|
|
91
|
+
m = re.match(r"(?:[\d.]+:)?(\d+):(\d+)(?:/(tcp|udp))?", entry)
|
|
92
|
+
if m:
|
|
93
|
+
result.append(ServicePort(
|
|
94
|
+
host=int(m.group(1)),
|
|
95
|
+
container=int(m.group(2)),
|
|
96
|
+
protocol=m.group(3) or "tcp",
|
|
97
|
+
))
|
|
98
|
+
elif isinstance(entry, dict):
|
|
99
|
+
result.append(ServicePort(
|
|
100
|
+
host=int(entry.get("published", entry.get("target", 0))),
|
|
101
|
+
container=int(entry.get("target", 0)),
|
|
102
|
+
protocol=entry.get("protocol", "tcp"),
|
|
103
|
+
))
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _parse_compose_volumes(raw: list | dict) -> list[VolumeMount]:
|
|
108
|
+
result = []
|
|
109
|
+
items = raw if isinstance(raw, list) else [f"{k}:{v}" for k, v in raw.items()]
|
|
110
|
+
for entry in items:
|
|
111
|
+
if isinstance(entry, str) and ":" in entry:
|
|
112
|
+
parts = entry.split(":")
|
|
113
|
+
result.append(VolumeMount(
|
|
114
|
+
host=parts[0],
|
|
115
|
+
container=parts[1],
|
|
116
|
+
read_only=len(parts) > 2 and "ro" in parts[2],
|
|
117
|
+
))
|
|
118
|
+
elif isinstance(entry, dict):
|
|
119
|
+
result.append(VolumeMount(
|
|
120
|
+
host=entry.get("source", ""),
|
|
121
|
+
container=entry.get("target", ""),
|
|
122
|
+
read_only=entry.get("read_only", False),
|
|
123
|
+
))
|
|
124
|
+
return result
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _parse_compose_env(raw: dict | list) -> dict[str, str]:
|
|
128
|
+
if isinstance(raw, dict):
|
|
129
|
+
return {str(k): str(v) for k, v in raw.items() if v is not None}
|
|
130
|
+
result = {}
|
|
131
|
+
for entry in raw:
|
|
132
|
+
if isinstance(entry, str) and "=" in entry:
|
|
133
|
+
k, _, v = entry.partition("=")
|
|
134
|
+
result[k.strip()] = v.strip()
|
|
135
|
+
return result
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _parse_compose_healthcheck(raw: dict) -> Optional[str]:
|
|
139
|
+
if not raw:
|
|
140
|
+
return None
|
|
141
|
+
test = raw.get("test", [])
|
|
142
|
+
if isinstance(test, list) and len(test) > 1:
|
|
143
|
+
return " ".join(str(t) for t in test[1:])
|
|
144
|
+
if isinstance(test, str):
|
|
145
|
+
return test
|
|
146
|
+
return None
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Build ``HardwareRequirements`` from a DeviceMap hardware snapshot."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from ...models import HardwareRequirements
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build_hw_requirements(hw) -> HardwareRequirements:
|
|
10
|
+
"""Derive hardware requirements from a probed *hw* object."""
|
|
11
|
+
if hw is None:
|
|
12
|
+
return HardwareRequirements()
|
|
13
|
+
|
|
14
|
+
display_type = None
|
|
15
|
+
display_res = None
|
|
16
|
+
features: list[str] = []
|
|
17
|
+
|
|
18
|
+
# DRM / DSI
|
|
19
|
+
for output in getattr(hw, "drm_outputs", []):
|
|
20
|
+
if "DSI" in (output.connector or ""):
|
|
21
|
+
display_type = "DSI"
|
|
22
|
+
if output.modes:
|
|
23
|
+
display_res = output.modes[0]
|
|
24
|
+
|
|
25
|
+
if getattr(hw, "backlights", []):
|
|
26
|
+
features.append("backlight")
|
|
27
|
+
if getattr(hw, "i2c_buses", []):
|
|
28
|
+
features.append("i2c")
|
|
29
|
+
|
|
30
|
+
# Infer arch from board name
|
|
31
|
+
board = getattr(hw, "board", "") or ""
|
|
32
|
+
arch = "linux/arm64" if any(x in board.lower() for x in ("rpi", "raspberry", "aarch64", "pi")) else ""
|
|
33
|
+
|
|
34
|
+
# Overlays → wayland / kiosk hints
|
|
35
|
+
for overlay in getattr(hw, "dsi_overlays", []):
|
|
36
|
+
if "dsi" in overlay.lower():
|
|
37
|
+
features.append("wayland")
|
|
38
|
+
|
|
39
|
+
return HardwareRequirements(
|
|
40
|
+
arch=arch,
|
|
41
|
+
display_type=display_type,
|
|
42
|
+
display_resolution=display_res,
|
|
43
|
+
i2c_required=bool(getattr(hw, "i2c_buses", [])),
|
|
44
|
+
features=features,
|
|
45
|
+
)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Extract service specs and app URL from a live or cached ``InfraState``."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from ...models import InfraState, ServiceSpec
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def extract_services_from_infra(infra: InfraState, seen: set[str]) -> list[ServiceSpec]:
|
|
10
|
+
"""Return :class:`ServiceSpec` objects for every service found in *infra*.
|
|
11
|
+
|
|
12
|
+
Skips entries whose ``name`` is already in *seen* and adds the new names.
|
|
13
|
+
"""
|
|
14
|
+
result: list[ServiceSpec] = []
|
|
15
|
+
for svcs in infra.services.values():
|
|
16
|
+
for svc in svcs:
|
|
17
|
+
if svc.name in seen:
|
|
18
|
+
continue
|
|
19
|
+
seen.add(svc.name)
|
|
20
|
+
result.append(ServiceSpec(
|
|
21
|
+
name=svc.name,
|
|
22
|
+
image=getattr(svc, "image", ""),
|
|
23
|
+
platform="", # unknown at this point
|
|
24
|
+
source_ref="infra:live",
|
|
25
|
+
))
|
|
26
|
+
return result
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def infer_app_url(infra: Optional[InfraState]) -> str | None:
|
|
30
|
+
"""Guess the application URL from open ports on *infra*.
|
|
31
|
+
|
|
32
|
+
Prefers ports ``80 → http://host``, ``443 → https://host``, then
|
|
33
|
+
``8100``, ``8080`` with explicit port numbers.
|
|
34
|
+
"""
|
|
35
|
+
if not infra or not infra.host:
|
|
36
|
+
return None
|
|
37
|
+
host = infra.host.split("@")[-1]
|
|
38
|
+
for port in (80, 8100, 8080, 443):
|
|
39
|
+
if port in (infra.ports or {}):
|
|
40
|
+
if port in (80, 443):
|
|
41
|
+
return f"http://{host}" if port == 80 else f"https://{host}"
|
|
42
|
+
return f"http://{host}:{port}"
|
|
43
|
+
return None
|