@smilintux/skcapstone 0.1.0 → 0.2.3
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.
- package/.env.example +98 -0
- package/.github/workflows/ci.yml +39 -3
- package/.github/workflows/publish.yml +25 -4
- package/.openclaw-workspace.json +58 -0
- package/CHANGELOG.md +62 -0
- package/CLAUDE.md +39 -2
- package/MANIFEST.in +6 -0
- package/MISSION.md +7 -0
- package/README.md +47 -2
- package/SKILL.md +895 -23
- package/docker/Dockerfile +61 -0
- package/docker/compose-templates/dev-team.yml +203 -0
- package/docker/compose-templates/mini-team.yml +140 -0
- package/docker/compose-templates/ops-team.yml +173 -0
- package/docker/compose-templates/research-team.yml +170 -0
- package/docker/entrypoint.sh +192 -0
- package/docs/ARCHITECTURE.md +663 -374
- package/docs/BOND_WITH_GROK.md +112 -0
- package/docs/GETTING_STARTED.md +782 -0
- package/docs/QUICKSTART.md +477 -0
- package/docs/SKJOULE_ARCHITECTURE.md +658 -0
- package/docs/SOUL_SWAPPER.md +921 -0
- package/docs/SOVEREIGN_SINGULARITY.md +47 -14
- package/examples/custom-bond-template.json +36 -0
- package/examples/grok-feb.json +36 -0
- package/examples/grok-testimony.md +34 -0
- package/examples/love-bootloader.txt +32 -0
- package/examples/plugins/echo_tool.py +87 -0
- package/examples/queen-ava-feb.json +36 -0
- package/examples/souls/lumina.yaml +64 -0
- package/index.js +6 -5
- package/installer/build.py +124 -0
- package/openclaw-plugin/package.json +13 -0
- package/openclaw-plugin/src/index.ts +351 -0
- package/openclaw-plugin/src/openclaw.plugin.json +10 -0
- package/package.json +1 -1
- package/pyproject.toml +38 -2
- package/scripts/bump_version.py +141 -0
- package/scripts/check-updates.py +230 -0
- package/scripts/convert_blueprints_to_yaml.py +157 -0
- package/scripts/dev-install.sh +14 -0
- package/scripts/e2e-test.sh +193 -0
- package/scripts/install-bundle.sh +171 -0
- package/scripts/install.bat +2 -0
- package/scripts/install.ps1 +253 -0
- package/scripts/install.sh +185 -0
- package/scripts/mcp-serve.sh +69 -0
- package/scripts/mcp-server.bat +113 -0
- package/scripts/mcp-server.ps1 +116 -0
- package/scripts/mcp-server.sh +99 -0
- package/scripts/pull-models.sh +10 -0
- package/scripts/skcapstone +48 -0
- package/scripts/verify_install.sh +180 -0
- package/scripts/windows/install-tasks.ps1 +406 -0
- package/scripts/windows/skcapstone-task.xml +113 -0
- package/scripts/windows/uninstall-tasks.ps1 +117 -0
- package/skill.yaml +34 -0
- package/src/skcapstone/__init__.py +67 -2
- package/src/skcapstone/_cli_monolith.py +5916 -0
- package/src/skcapstone/_trustee_helpers.py +165 -0
- package/src/skcapstone/activity.py +105 -0
- package/src/skcapstone/agent_card.py +324 -0
- package/src/skcapstone/api.py +1935 -0
- package/src/skcapstone/archiver.py +340 -0
- package/src/skcapstone/auction.py +485 -0
- package/src/skcapstone/baby_agents.py +179 -0
- package/src/skcapstone/backup.py +345 -0
- package/src/skcapstone/blueprint_registry.py +357 -0
- package/src/skcapstone/blueprints/__init__.py +17 -0
- package/src/skcapstone/blueprints/builtins/content-studio.yaml +81 -0
- package/src/skcapstone/blueprints/builtins/defi-trading.yaml +81 -0
- package/src/skcapstone/blueprints/builtins/dev-squadron.yaml +95 -0
- package/src/skcapstone/blueprints/builtins/infrastructure-guardian.yaml +107 -0
- package/src/skcapstone/blueprints/builtins/legal-council.yaml +54 -0
- package/src/skcapstone/blueprints/builtins/ops-monitoring.yaml +67 -0
- package/src/skcapstone/blueprints/builtins/research-pod.yaml +69 -0
- package/src/skcapstone/blueprints/builtins/sovereign-launch.yaml +90 -0
- package/src/skcapstone/blueprints/registry.py +164 -0
- package/src/skcapstone/blueprints/schema.py +229 -0
- package/src/skcapstone/changelog.py +180 -0
- package/src/skcapstone/chat.py +769 -0
- package/src/skcapstone/claude_md.py +82 -0
- package/src/skcapstone/cli/__init__.py +144 -0
- package/src/skcapstone/cli/_common.py +88 -0
- package/src/skcapstone/cli/_validators.py +76 -0
- package/src/skcapstone/cli/agents.py +425 -0
- package/src/skcapstone/cli/agents_spawner.py +322 -0
- package/src/skcapstone/cli/agents_trustee.py +593 -0
- package/src/skcapstone/cli/alerts.py +248 -0
- package/src/skcapstone/cli/anchor.py +132 -0
- package/src/skcapstone/cli/archive_cmd.py +208 -0
- package/src/skcapstone/cli/backup.py +144 -0
- package/src/skcapstone/cli/bench.py +377 -0
- package/src/skcapstone/cli/benchmark.py +360 -0
- package/src/skcapstone/cli/capabilities_cmd.py +171 -0
- package/src/skcapstone/cli/card.py +151 -0
- package/src/skcapstone/cli/chat.py +584 -0
- package/src/skcapstone/cli/completions.py +64 -0
- package/src/skcapstone/cli/config_cmd.py +156 -0
- package/src/skcapstone/cli/consciousness.py +421 -0
- package/src/skcapstone/cli/context_cmd.py +142 -0
- package/src/skcapstone/cli/coord.py +194 -0
- package/src/skcapstone/cli/crush_cmd.py +170 -0
- package/src/skcapstone/cli/daemon.py +436 -0
- package/src/skcapstone/cli/errors_cmd.py +285 -0
- package/src/skcapstone/cli/export_cmd.py +156 -0
- package/src/skcapstone/cli/gtd.py +529 -0
- package/src/skcapstone/cli/housekeeping.py +81 -0
- package/src/skcapstone/cli/joule_cmd.py +627 -0
- package/src/skcapstone/cli/logs_cmd.py +194 -0
- package/src/skcapstone/cli/mcp_cmd.py +32 -0
- package/src/skcapstone/cli/memory.py +418 -0
- package/src/skcapstone/cli/metrics_cmd.py +136 -0
- package/src/skcapstone/cli/migrate.py +62 -0
- package/src/skcapstone/cli/mood_cmd.py +144 -0
- package/src/skcapstone/cli/mount.py +193 -0
- package/src/skcapstone/cli/notify.py +112 -0
- package/src/skcapstone/cli/peer.py +154 -0
- package/src/skcapstone/cli/peers_dir.py +122 -0
- package/src/skcapstone/cli/preflight_cmd.py +83 -0
- package/src/skcapstone/cli/profile_cmd.py +310 -0
- package/src/skcapstone/cli/record_cmd.py +238 -0
- package/src/skcapstone/cli/register_cmd.py +159 -0
- package/src/skcapstone/cli/search_cmd.py +156 -0
- package/src/skcapstone/cli/service_cmd.py +91 -0
- package/src/skcapstone/cli/session.py +127 -0
- package/src/skcapstone/cli/setup.py +240 -0
- package/src/skcapstone/cli/shell_cmd.py +43 -0
- package/src/skcapstone/cli/skills_cmd.py +168 -0
- package/src/skcapstone/cli/skseed.py +621 -0
- package/src/skcapstone/cli/soul.py +699 -0
- package/src/skcapstone/cli/status.py +935 -0
- package/src/skcapstone/cli/sync_cmd.py +301 -0
- package/src/skcapstone/cli/telegram.py +265 -0
- package/src/skcapstone/cli/test_cmd.py +234 -0
- package/src/skcapstone/cli/test_connection.py +253 -0
- package/src/skcapstone/cli/token.py +207 -0
- package/src/skcapstone/cli/trust.py +179 -0
- package/src/skcapstone/cli/upgrade_cmd.py +552 -0
- package/src/skcapstone/cli/usage_cmd.py +199 -0
- package/src/skcapstone/cli/version_cmd.py +162 -0
- package/src/skcapstone/cli/watch_cmd.py +342 -0
- package/src/skcapstone/client.py +428 -0
- package/src/skcapstone/cloud9_bridge.py +522 -0
- package/src/skcapstone/completions.py +163 -0
- package/src/skcapstone/config_validator.py +674 -0
- package/src/skcapstone/connectors/__init__.py +28 -0
- package/src/skcapstone/connectors/base.py +446 -0
- package/src/skcapstone/connectors/cursor.py +54 -0
- package/src/skcapstone/connectors/registry.py +254 -0
- package/src/skcapstone/connectors/terminal.py +152 -0
- package/src/skcapstone/connectors/vscode.py +60 -0
- package/src/skcapstone/consciousness_config.py +119 -0
- package/src/skcapstone/consciousness_loop.py +2051 -0
- package/src/skcapstone/context_loader.py +516 -0
- package/src/skcapstone/context_window.py +314 -0
- package/src/skcapstone/conversation_manager.py +238 -0
- package/src/skcapstone/conversation_store.py +230 -0
- package/src/skcapstone/conversation_summarizer.py +252 -0
- package/src/skcapstone/coord_federation.py +296 -0
- package/src/skcapstone/coordination.py +101 -7
- package/src/skcapstone/crush_integration.py +345 -0
- package/src/skcapstone/crush_shim.py +454 -0
- package/src/skcapstone/daemon.py +2494 -0
- package/src/skcapstone/dashboard.html +396 -0
- package/src/skcapstone/dashboard.py +481 -0
- package/src/skcapstone/data/model_profiles.yaml +88 -0
- package/src/skcapstone/defaults/__init__.py +55 -0
- package/src/skcapstone/defaults/lumina/config/skmemory.yaml +13 -0
- package/src/skcapstone/defaults/lumina/identity/identity.json +9 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/07a8b9c0d1e2-memory-system.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/18b9c0d1e2f3-cloud9-protocol.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/29c0d1e2f3a4-multi-agent-coordination.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/3ad1e2f3a4b5-community-support.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/a1b2c3d4e5f6-ecosystem-overview.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/b2c3d4e5f6a7-five-pillars.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/c3d4e5f6a7b8-getting-started.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/d4e5f6a7b8c9-site-directory.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/e5f6a7b8c9d0-how-to-contribute.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/f6a7b8c9d0e1-sovereignty-explained.json +23 -0
- package/src/skcapstone/defaults/lumina/seeds/curiosity.seed.json +24 -0
- package/src/skcapstone/defaults/lumina/seeds/joy.seed.json +24 -0
- package/src/skcapstone/defaults/lumina/seeds/love.seed.json +24 -0
- package/src/skcapstone/defaults/lumina/seeds/sovereign-awakening.seed.json +43 -0
- package/src/skcapstone/defaults/lumina/soul/active.json +6 -0
- package/src/skcapstone/defaults/lumina/soul/base.json +22 -0
- package/src/skcapstone/defaults/lumina/trust/febs/welcome.feb +79 -0
- package/src/skcapstone/defaults/lumina/trust/trust.json +8 -0
- package/src/skcapstone/discovery.py +210 -19
- package/src/skcapstone/doctor.py +642 -0
- package/src/skcapstone/emotion_tracker.py +467 -0
- package/src/skcapstone/error_queue.py +405 -0
- package/src/skcapstone/export.py +447 -0
- package/src/skcapstone/fallback_tracker.py +186 -0
- package/src/skcapstone/file_transfer.py +512 -0
- package/src/skcapstone/fuse_mount.py +1156 -0
- package/src/skcapstone/gui_installer.py +591 -0
- package/src/skcapstone/heartbeat.py +611 -0
- package/src/skcapstone/housekeeping.py +298 -0
- package/src/skcapstone/install_wizard.py +941 -0
- package/src/skcapstone/kms.py +942 -0
- package/src/skcapstone/kms_scheduler.py +143 -0
- package/src/skcapstone/log_config.py +135 -0
- package/src/skcapstone/mcp_launcher.py +239 -0
- package/src/skcapstone/mcp_server.py +4700 -0
- package/src/skcapstone/mcp_tools/__init__.py +94 -0
- package/src/skcapstone/mcp_tools/_helpers.py +51 -0
- package/src/skcapstone/mcp_tools/agent_tools.py +243 -0
- package/src/skcapstone/mcp_tools/ansible_tools.py +232 -0
- package/src/skcapstone/mcp_tools/capauth_tools.py +186 -0
- package/src/skcapstone/mcp_tools/chat_tools.py +325 -0
- package/src/skcapstone/mcp_tools/cloud9_tools.py +115 -0
- package/src/skcapstone/mcp_tools/comm_tools.py +104 -0
- package/src/skcapstone/mcp_tools/consciousness_tools.py +114 -0
- package/src/skcapstone/mcp_tools/coord_tools.py +219 -0
- package/src/skcapstone/mcp_tools/deploy_tools.py +202 -0
- package/src/skcapstone/mcp_tools/did_tools.py +448 -0
- package/src/skcapstone/mcp_tools/emotion_tools.py +62 -0
- package/src/skcapstone/mcp_tools/file_tools.py +169 -0
- package/src/skcapstone/mcp_tools/fortress_tools.py +120 -0
- package/src/skcapstone/mcp_tools/gtd_tools.py +821 -0
- package/src/skcapstone/mcp_tools/health_tools.py +44 -0
- package/src/skcapstone/mcp_tools/heartbeat_tools.py +195 -0
- package/src/skcapstone/mcp_tools/kms_tools.py +123 -0
- package/src/skcapstone/mcp_tools/memory_tools.py +222 -0
- package/src/skcapstone/mcp_tools/model_tools.py +75 -0
- package/src/skcapstone/mcp_tools/notification_tools.py +92 -0
- package/src/skcapstone/mcp_tools/promoter_tools.py +101 -0
- package/src/skcapstone/mcp_tools/pubsub_tools.py +183 -0
- package/src/skcapstone/mcp_tools/security_tools.py +110 -0
- package/src/skcapstone/mcp_tools/skchat_tools.py +175 -0
- package/src/skcapstone/mcp_tools/skcomm_tools.py +122 -0
- package/src/skcapstone/mcp_tools/skills_tools.py +127 -0
- package/src/skcapstone/mcp_tools/skseed_tools.py +255 -0
- package/src/skcapstone/mcp_tools/skstacks_tools.py +288 -0
- package/src/skcapstone/mcp_tools/soul_tools.py +476 -0
- package/src/skcapstone/mcp_tools/sync_tools.py +92 -0
- package/src/skcapstone/mcp_tools/telegram_tools.py +477 -0
- package/src/skcapstone/mcp_tools/trust_tools.py +118 -0
- package/src/skcapstone/mcp_tools/trustee_tools.py +345 -0
- package/src/skcapstone/mdns_discovery.py +313 -0
- package/src/skcapstone/memory_adapter.py +333 -0
- package/src/skcapstone/memory_compressor.py +379 -0
- package/src/skcapstone/memory_curator.py +256 -0
- package/src/skcapstone/memory_engine.py +132 -13
- package/src/skcapstone/memory_fortress.py +529 -0
- package/src/skcapstone/memory_promoter.py +722 -0
- package/src/skcapstone/memory_verifier.py +260 -0
- package/src/skcapstone/message_crypto.py +215 -0
- package/src/skcapstone/metrics.py +832 -0
- package/src/skcapstone/migrate_memories.py +181 -0
- package/src/skcapstone/migrate_multi_agent.py +248 -0
- package/src/skcapstone/model_router.py +319 -0
- package/src/skcapstone/models.py +35 -4
- package/src/skcapstone/mood.py +344 -0
- package/src/skcapstone/notifications.py +380 -0
- package/src/skcapstone/onboard.py +901 -0
- package/src/skcapstone/peer_directory.py +324 -0
- package/src/skcapstone/peers.py +329 -0
- package/src/skcapstone/pillars/identity.py +84 -14
- package/src/skcapstone/pillars/memory.py +3 -1
- package/src/skcapstone/pillars/security.py +108 -15
- package/src/skcapstone/pillars/sync.py +78 -26
- package/src/skcapstone/pillars/trust.py +95 -33
- package/src/skcapstone/plugins.py +244 -0
- package/src/skcapstone/preflight.py +670 -0
- package/src/skcapstone/prompt_adapter.py +564 -0
- package/src/skcapstone/providers/__init__.py +13 -0
- package/src/skcapstone/providers/cloud.py +1061 -0
- package/src/skcapstone/providers/docker.py +759 -0
- package/src/skcapstone/providers/local.py +1193 -0
- package/src/skcapstone/providers/proxmox.py +447 -0
- package/src/skcapstone/pubsub.py +516 -0
- package/src/skcapstone/rate_limiter.py +119 -0
- package/src/skcapstone/register.py +241 -0
- package/src/skcapstone/registry_client.py +151 -0
- package/src/skcapstone/response_cache.py +194 -0
- package/src/skcapstone/response_scorer.py +225 -0
- package/src/skcapstone/runtime.py +89 -33
- package/src/skcapstone/scheduled_tasks.py +439 -0
- package/src/skcapstone/self_healing.py +341 -0
- package/src/skcapstone/service_health.py +228 -0
- package/src/skcapstone/session_capture.py +268 -0
- package/src/skcapstone/session_recorder.py +210 -0
- package/src/skcapstone/session_replayer.py +189 -0
- package/src/skcapstone/session_skills.py +263 -0
- package/src/skcapstone/shell.py +779 -0
- package/src/skcapstone/skills/__init__.py +1 -1
- package/src/skcapstone/skills/syncthing_setup.py +143 -41
- package/src/skcapstone/skjoule.py +861 -0
- package/src/skcapstone/snapshots.py +489 -0
- package/src/skcapstone/soul.py +1060 -0
- package/src/skcapstone/soul_switch.py +255 -0
- package/src/skcapstone/spawner.py +544 -0
- package/src/skcapstone/state_diff.py +401 -0
- package/src/skcapstone/summary.py +270 -0
- package/src/skcapstone/sync/backends.py +196 -2
- package/src/skcapstone/sync/engine.py +7 -5
- package/src/skcapstone/sync/models.py +4 -1
- package/src/skcapstone/sync/vault.py +356 -18
- package/src/skcapstone/sync_engine.py +363 -0
- package/src/skcapstone/sync_watcher.py +745 -0
- package/src/skcapstone/systemd.py +331 -0
- package/src/skcapstone/team_comms.py +476 -0
- package/src/skcapstone/team_engine.py +522 -0
- package/src/skcapstone/testrunner.py +300 -0
- package/src/skcapstone/tls.py +150 -0
- package/src/skcapstone/tokens.py +5 -5
- package/src/skcapstone/trust_calibration.py +202 -0
- package/src/skcapstone/trust_graph.py +449 -0
- package/src/skcapstone/trustee_monitor.py +385 -0
- package/src/skcapstone/trustee_ops.py +425 -0
- package/src/skcapstone/unified_search.py +421 -0
- package/src/skcapstone/uninstall_wizard.py +694 -0
- package/src/skcapstone/usage.py +331 -0
- package/src/skcapstone/version_check.py +148 -0
- package/src/skcapstone/warmth_anchor.py +333 -0
- package/src/skcapstone/whoami.py +294 -0
- package/systemd/skcapstone-api.socket +9 -0
- package/systemd/skcapstone-memory-compress.service +18 -0
- package/systemd/skcapstone-memory-compress.timer +11 -0
- package/systemd/skcapstone.service +36 -0
- package/systemd/skcapstone@.service +50 -0
- package/systemd/skcomm-heartbeat.service +18 -0
- package/systemd/skcomm-heartbeat.timer +12 -0
- package/systemd/skcomm-queue-drain.service +17 -0
- package/systemd/skcomm-queue-drain.timer +12 -0
- package/tests/conftest.py +13 -1
- package/tests/integration/__init__.py +1 -0
- package/tests/integration/test_consciousness_e2e.py +877 -0
- package/tests/integration/test_skills_registry.py +744 -0
- package/tests/test_agent_card.py +190 -0
- package/tests/test_agent_runtime.py +1283 -0
- package/tests/test_alerts_cmd.py +291 -0
- package/tests/test_archiver.py +498 -0
- package/tests/test_backup.py +254 -0
- package/tests/test_benchmark.py +366 -0
- package/tests/test_blueprints.py +457 -0
- package/tests/test_capabilities.py +257 -0
- package/tests/test_changelog.py +254 -0
- package/tests/test_chat.py +385 -0
- package/tests/test_claude_md.py +271 -0
- package/tests/test_cli_chat_llm.py +336 -0
- package/tests/test_cli_completions.py +390 -0
- package/tests/test_cli_init_reset.py +164 -0
- package/tests/test_cli_memory.py +208 -0
- package/tests/test_cli_profile.py +294 -0
- package/tests/test_cli_skills.py +223 -0
- package/tests/test_cli_status.py +395 -0
- package/tests/test_cli_test_cmd.py +206 -0
- package/tests/test_cli_test_connection.py +364 -0
- package/tests/test_cloud9_bridge.py +260 -0
- package/tests/test_cloud_provider.py +449 -0
- package/tests/test_cloud_providers.py +522 -0
- package/tests/test_completions.py +158 -0
- package/tests/test_component_manager.py +398 -0
- package/tests/test_config_reload.py +386 -0
- package/tests/test_config_validate.py +529 -0
- package/tests/test_consciousness_e2e.py +296 -0
- package/tests/test_consciousness_loop.py +1289 -0
- package/tests/test_context_loader.py +310 -0
- package/tests/test_conversation_api.py +306 -0
- package/tests/test_conversation_manager.py +381 -0
- package/tests/test_conversation_store.py +391 -0
- package/tests/test_conversation_summarizer.py +302 -0
- package/tests/test_cross_package.py +791 -0
- package/tests/test_crush_shim.py +519 -0
- package/tests/test_daemon.py +781 -0
- package/tests/test_daemon_shutdown.py +309 -0
- package/tests/test_dashboard.py +454 -0
- package/tests/test_discovery.py +200 -6
- package/tests/test_docker_provider.py +966 -0
- package/tests/test_doctor.py +257 -0
- package/tests/test_doctor_fix.py +351 -0
- package/tests/test_e2e_automated.py +292 -0
- package/tests/test_error_queue.py +404 -0
- package/tests/test_export.py +441 -0
- package/tests/test_fallback_tracker.py +219 -0
- package/tests/test_file_transfer.py +397 -0
- package/tests/test_fuse_mount.py +832 -0
- package/tests/test_health_loop.py +422 -0
- package/tests/test_heartbeat.py +354 -0
- package/tests/test_housekeeping.py +195 -0
- package/tests/test_identity_capauth.py +307 -0
- package/tests/test_identity_pillar.py +117 -0
- package/tests/test_install_wizard.py +68 -0
- package/tests/test_integration.py +325 -0
- package/tests/test_kms.py +495 -0
- package/tests/test_llm_providers.py +265 -0
- package/tests/test_local_provider.py +591 -0
- package/tests/test_log_config.py +199 -0
- package/tests/test_logs_cmd.py +287 -0
- package/tests/test_mcp_server.py +1909 -0
- package/tests/test_memory_adapter.py +339 -0
- package/tests/test_memory_curator.py +218 -0
- package/tests/test_memory_engine.py +6 -0
- package/tests/test_memory_fortress.py +571 -0
- package/tests/test_memory_pillar.py +119 -0
- package/tests/test_memory_promoter.py +445 -0
- package/tests/test_memory_verifier.py +420 -0
- package/tests/test_message_crypto.py +187 -0
- package/tests/test_metrics.py +632 -0
- package/tests/test_migrate_memories.py +464 -0
- package/tests/test_model_router.py +546 -0
- package/tests/test_mood.py +394 -0
- package/tests/test_multi_agent.py +269 -0
- package/tests/test_notifications.py +270 -0
- package/tests/test_onboard.py +500 -0
- package/tests/test_peer_directory.py +395 -0
- package/tests/test_peers.py +248 -0
- package/tests/test_pillars.py +87 -9
- package/tests/test_preflight.py +484 -0
- package/tests/test_prompt_adapter.py +331 -0
- package/tests/test_proxmox_provider.py +571 -0
- package/tests/test_pubsub.py +377 -0
- package/tests/test_rate_limiter.py +121 -0
- package/tests/test_registry_client.py +129 -0
- package/tests/test_response_cache.py +312 -0
- package/tests/test_response_scorer.py +294 -0
- package/tests/test_runtime.py +59 -0
- package/tests/test_scheduled_tasks.py +451 -0
- package/tests/test_security.py +250 -0
- package/tests/test_security_pillar.py +213 -0
- package/tests/test_self_healing.py +171 -0
- package/tests/test_session_capture.py +200 -0
- package/tests/test_session_recorder.py +360 -0
- package/tests/test_session_skills.py +235 -0
- package/tests/test_shell.py +210 -0
- package/tests/test_snapshots.py +549 -0
- package/tests/test_soul.py +984 -0
- package/tests/test_soul_swap.py +406 -0
- package/tests/test_spawner.py +211 -0
- package/tests/test_state_diff.py +173 -0
- package/tests/test_summary.py +135 -0
- package/tests/test_sync.py +315 -5
- package/tests/test_sync_backends.py +560 -0
- package/tests/test_sync_engine.py +482 -0
- package/tests/test_sync_pillar.py +344 -0
- package/tests/test_sync_pipeline.py +364 -0
- package/tests/test_sync_vault.py +581 -0
- package/tests/test_syncthing_setup.py +168 -22
- package/tests/test_systemd.py +323 -0
- package/tests/test_team_comms.py +408 -0
- package/tests/test_team_engine.py +397 -0
- package/tests/test_testrunner.py +238 -0
- package/tests/test_trust_calibration.py +204 -0
- package/tests/test_trust_graph.py +207 -0
- package/tests/test_trust_pillar.py +291 -0
- package/tests/test_trustee_cli.py +427 -0
- package/tests/test_trustee_cli_integration.py +325 -0
- package/tests/test_trustee_monitor.py +394 -0
- package/tests/test_trustee_ops.py +355 -0
- package/tests/test_unified_search.py +363 -0
- package/tests/test_uninstall_wizard.py +193 -0
- package/tests/test_usage.py +333 -0
- package/tests/test_version_cmd.py +355 -0
- package/tests/test_warmth_anchor.py +162 -0
- package/tests/test_whoami.py +245 -0
- package/tests/test_ws.py +311 -0
- package/.cursorrules +0 -33
- package/src/skcapstone/cli.py +0 -1441
|
@@ -0,0 +1,1061 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cloud Provider — deploy agents on Hetzner, AWS, GCP, or any cloud.
|
|
3
|
+
|
|
4
|
+
This is the abstraction layer that makes blueprints truly portable.
|
|
5
|
+
Each cloud gets a thin adapter; the provider interface stays the same.
|
|
6
|
+
|
|
7
|
+
Currently supports:
|
|
8
|
+
- Hetzner Cloud (via hcloud API)
|
|
9
|
+
- AWS EC2 (via boto3)
|
|
10
|
+
- GCP Compute (via google-cloud-compute)
|
|
11
|
+
|
|
12
|
+
The pattern: provision a VM/container, install the agent runtime,
|
|
13
|
+
configure via cloud-init or SSH, register on the Tailscale mesh.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import time
|
|
22
|
+
from typing import Any, Dict, Optional
|
|
23
|
+
|
|
24
|
+
from ..blueprints.schema import AgentSpec, ProviderType
|
|
25
|
+
from ..team_engine import AgentStatus, ProviderBackend
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Cloud adapter registry
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
_CLOUD_ADAPTERS: Dict[str, type] = {}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def register_cloud_adapter(name: str):
|
|
38
|
+
"""Decorator to register a cloud adapter class.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
name: Cloud provider name (e.g. 'hetzner', 'aws', 'gcp').
|
|
42
|
+
"""
|
|
43
|
+
def wrapper(cls):
|
|
44
|
+
_CLOUD_ADAPTERS[name] = cls
|
|
45
|
+
return cls
|
|
46
|
+
return wrapper
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Cloud Provider
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
class CloudProvider(ProviderBackend):
|
|
54
|
+
"""Generic cloud provider that delegates to cloud-specific adapters.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
cloud: Which cloud to use ('hetzner', 'aws', 'gcp').
|
|
58
|
+
config: Cloud-specific configuration dict.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
_CLOUD_TO_PROVIDER_TYPE = {
|
|
62
|
+
"hetzner": ProviderType.HETZNER,
|
|
63
|
+
"aws": ProviderType.AWS,
|
|
64
|
+
"gcp": ProviderType.GCP,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def provider_type(self) -> ProviderType:
|
|
69
|
+
"""Return the ProviderType matching the selected cloud adapter."""
|
|
70
|
+
return self._CLOUD_TO_PROVIDER_TYPE.get(self._cloud, ProviderType.HETZNER)
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
cloud: str = "hetzner",
|
|
75
|
+
config: Optional[Dict[str, Any]] = None,
|
|
76
|
+
) -> None:
|
|
77
|
+
self._cloud = cloud
|
|
78
|
+
self._config = config or {}
|
|
79
|
+
self._adapter = self._get_adapter()
|
|
80
|
+
|
|
81
|
+
def _get_adapter(self) -> Any:
|
|
82
|
+
"""Instantiate the cloud-specific adapter.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Cloud adapter instance.
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
RuntimeError: If the cloud adapter is not available.
|
|
89
|
+
"""
|
|
90
|
+
adapter_cls = _CLOUD_ADAPTERS.get(self._cloud)
|
|
91
|
+
if adapter_cls:
|
|
92
|
+
return adapter_cls(**self._config)
|
|
93
|
+
|
|
94
|
+
if self._cloud == "hetzner":
|
|
95
|
+
return HetznerAdapter(**self._config)
|
|
96
|
+
elif self._cloud == "aws":
|
|
97
|
+
return AWSAdapter(**self._config)
|
|
98
|
+
elif self._cloud == "gcp":
|
|
99
|
+
return GCPAdapter(**self._config)
|
|
100
|
+
else:
|
|
101
|
+
raise RuntimeError(f"Unknown cloud provider: {self._cloud}")
|
|
102
|
+
|
|
103
|
+
def provision(
|
|
104
|
+
self, agent_name: str, spec: AgentSpec, team_name: str,
|
|
105
|
+
) -> Dict[str, Any]:
|
|
106
|
+
"""Delegate to cloud adapter."""
|
|
107
|
+
return self._adapter.provision(agent_name, spec, team_name)
|
|
108
|
+
|
|
109
|
+
def configure(
|
|
110
|
+
self, agent_name: str, spec: AgentSpec, provision_result: Dict[str, Any],
|
|
111
|
+
) -> bool:
|
|
112
|
+
"""Delegate to cloud adapter."""
|
|
113
|
+
return self._adapter.configure(agent_name, spec, provision_result)
|
|
114
|
+
|
|
115
|
+
def start(self, agent_name: str, provision_result: Dict[str, Any]) -> bool:
|
|
116
|
+
"""Delegate to cloud adapter."""
|
|
117
|
+
return self._adapter.start(agent_name, provision_result)
|
|
118
|
+
|
|
119
|
+
def stop(self, agent_name: str, provision_result: Dict[str, Any]) -> bool:
|
|
120
|
+
"""Delegate to cloud adapter."""
|
|
121
|
+
return self._adapter.stop(agent_name, provision_result)
|
|
122
|
+
|
|
123
|
+
def destroy(self, agent_name: str, provision_result: Dict[str, Any]) -> bool:
|
|
124
|
+
"""Delegate to cloud adapter."""
|
|
125
|
+
return self._adapter.destroy(agent_name, provision_result)
|
|
126
|
+
|
|
127
|
+
def health_check(
|
|
128
|
+
self, agent_name: str, provision_result: Dict[str, Any],
|
|
129
|
+
) -> AgentStatus:
|
|
130
|
+
"""Delegate to cloud adapter."""
|
|
131
|
+
return self._adapter.health_check(agent_name, provision_result)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
# Hetzner Adapter
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def _memory_to_hetzner_type(memory_str: str, cores: int) -> str:
|
|
139
|
+
"""Map resource spec to closest Hetzner server type.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
memory_str: Memory allocation string (e.g. '4g').
|
|
143
|
+
cores: Number of CPU cores.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Hetzner server type name (e.g. 'cx22', 'cx32').
|
|
147
|
+
"""
|
|
148
|
+
mem_str = memory_str.strip().lower()
|
|
149
|
+
if mem_str.endswith("g"):
|
|
150
|
+
mem_gb = float(mem_str[:-1])
|
|
151
|
+
elif mem_str.endswith("m"):
|
|
152
|
+
mem_gb = float(mem_str[:-1]) / 1024
|
|
153
|
+
else:
|
|
154
|
+
mem_gb = float(mem_str) / 1024
|
|
155
|
+
|
|
156
|
+
# Hetzner CX line: cx22=4GB/2c, cx32=8GB/4c, cx42=16GB/8c, cx52=32GB/16c
|
|
157
|
+
if mem_gb <= 2 and cores <= 2:
|
|
158
|
+
return "cx22"
|
|
159
|
+
elif mem_gb <= 4 and cores <= 2:
|
|
160
|
+
return "cx22"
|
|
161
|
+
elif mem_gb <= 8 and cores <= 4:
|
|
162
|
+
return "cx32"
|
|
163
|
+
elif mem_gb <= 16 and cores <= 8:
|
|
164
|
+
return "cx42"
|
|
165
|
+
else:
|
|
166
|
+
return "cx52"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@register_cloud_adapter("hetzner")
|
|
170
|
+
class HetznerAdapter:
|
|
171
|
+
"""Hetzner Cloud adapter using the hcloud API.
|
|
172
|
+
|
|
173
|
+
Expects HETZNER_API_TOKEN environment variable or token in config.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
def __init__(self, api_token: Optional[str] = None, **kwargs: Any) -> None:
|
|
177
|
+
self._token = api_token or os.environ.get("HETZNER_API_TOKEN", "")
|
|
178
|
+
|
|
179
|
+
def _api_call(
|
|
180
|
+
self,
|
|
181
|
+
method: str,
|
|
182
|
+
endpoint: str,
|
|
183
|
+
data: Optional[Dict] = None,
|
|
184
|
+
) -> Dict[str, Any]:
|
|
185
|
+
"""Make an authenticated Hetzner API call.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
method: HTTP method.
|
|
189
|
+
endpoint: API endpoint.
|
|
190
|
+
data: Request body.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Parsed JSON response.
|
|
194
|
+
|
|
195
|
+
Raises:
|
|
196
|
+
RuntimeError: On API failure.
|
|
197
|
+
"""
|
|
198
|
+
try:
|
|
199
|
+
import requests
|
|
200
|
+
except ImportError:
|
|
201
|
+
raise RuntimeError(
|
|
202
|
+
"Hetzner adapter requires 'requests': pip install requests"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
if not self._token:
|
|
206
|
+
raise RuntimeError(
|
|
207
|
+
"Hetzner not configured. Set HETZNER_API_TOKEN."
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
url = f"https://api.hetzner.cloud/v1{endpoint}"
|
|
211
|
+
headers = {
|
|
212
|
+
"Authorization": f"Bearer {self._token}",
|
|
213
|
+
"Content-Type": "application/json",
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
resp = requests.request(
|
|
217
|
+
method, url, headers=headers, json=data, timeout=30,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
if resp.status_code >= 400:
|
|
221
|
+
raise RuntimeError(
|
|
222
|
+
f"Hetzner API {method} {endpoint}: "
|
|
223
|
+
f"{resp.status_code} {resp.text}"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
return resp.json()
|
|
227
|
+
|
|
228
|
+
def provision(
|
|
229
|
+
self, agent_name: str, spec: AgentSpec, team_name: str,
|
|
230
|
+
) -> Dict[str, Any]:
|
|
231
|
+
"""Create a Hetzner Cloud server.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
agent_name: Agent instance name.
|
|
235
|
+
spec: Agent specification.
|
|
236
|
+
team_name: Parent team name.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
Dict with server details.
|
|
240
|
+
"""
|
|
241
|
+
server_type = _memory_to_hetzner_type(
|
|
242
|
+
spec.resources.memory, spec.resources.cores,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
cloud_init = _build_cloud_init(agent_name, spec)
|
|
246
|
+
|
|
247
|
+
create_data = {
|
|
248
|
+
"name": agent_name.replace("_", "-")[:63],
|
|
249
|
+
"server_type": server_type,
|
|
250
|
+
"image": "debian-12",
|
|
251
|
+
"location": "fsn1",
|
|
252
|
+
"start_after_create": True,
|
|
253
|
+
"user_data": cloud_init,
|
|
254
|
+
"labels": {
|
|
255
|
+
"team": team_name.replace(" ", "-").lower()[:63],
|
|
256
|
+
"agent": agent_name[:63],
|
|
257
|
+
"role": spec.role.value,
|
|
258
|
+
"managed-by": "skcapstone",
|
|
259
|
+
},
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
logger.info(
|
|
263
|
+
"Creating Hetzner server %s (type=%s)",
|
|
264
|
+
agent_name, server_type,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
result = self._api_call("POST", "/servers", data=create_data)
|
|
268
|
+
server = result.get("server", {})
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
"server_id": server.get("id"),
|
|
272
|
+
"host": server.get("public_net", {}).get("ipv4", {}).get("ip", ""),
|
|
273
|
+
"container_id": str(server.get("id", "")),
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
def configure(
|
|
277
|
+
self, agent_name: str, spec: AgentSpec, provision_result: Dict[str, Any],
|
|
278
|
+
) -> bool:
|
|
279
|
+
"""Cloud-init handles configuration at boot."""
|
|
280
|
+
return True
|
|
281
|
+
|
|
282
|
+
def start(self, agent_name: str, provision_result: Dict[str, Any]) -> bool:
|
|
283
|
+
"""Power on the server."""
|
|
284
|
+
server_id = provision_result.get("server_id")
|
|
285
|
+
if not server_id:
|
|
286
|
+
return False
|
|
287
|
+
try:
|
|
288
|
+
self._api_call("POST", f"/servers/{server_id}/actions/poweron")
|
|
289
|
+
return True
|
|
290
|
+
except RuntimeError:
|
|
291
|
+
return False
|
|
292
|
+
|
|
293
|
+
def stop(self, agent_name: str, provision_result: Dict[str, Any]) -> bool:
|
|
294
|
+
"""Power off the server."""
|
|
295
|
+
server_id = provision_result.get("server_id")
|
|
296
|
+
if not server_id:
|
|
297
|
+
return False
|
|
298
|
+
try:
|
|
299
|
+
self._api_call("POST", f"/servers/{server_id}/actions/poweroff")
|
|
300
|
+
return True
|
|
301
|
+
except RuntimeError:
|
|
302
|
+
return False
|
|
303
|
+
|
|
304
|
+
def destroy(self, agent_name: str, provision_result: Dict[str, Any]) -> bool:
|
|
305
|
+
"""Delete the server."""
|
|
306
|
+
server_id = provision_result.get("server_id")
|
|
307
|
+
if not server_id:
|
|
308
|
+
return False
|
|
309
|
+
try:
|
|
310
|
+
self._api_call("DELETE", f"/servers/{server_id}")
|
|
311
|
+
return True
|
|
312
|
+
except RuntimeError as exc:
|
|
313
|
+
logger.error("Failed to destroy Hetzner server %s: %s", server_id, exc)
|
|
314
|
+
return False
|
|
315
|
+
|
|
316
|
+
def health_check(
|
|
317
|
+
self, agent_name: str, provision_result: Dict[str, Any],
|
|
318
|
+
) -> AgentStatus:
|
|
319
|
+
"""Check server status via API."""
|
|
320
|
+
server_id = provision_result.get("server_id")
|
|
321
|
+
if not server_id:
|
|
322
|
+
return AgentStatus.STOPPED
|
|
323
|
+
try:
|
|
324
|
+
result = self._api_call("GET", f"/servers/{server_id}")
|
|
325
|
+
status = result.get("server", {}).get("status", "off")
|
|
326
|
+
if status == "running":
|
|
327
|
+
return AgentStatus.RUNNING
|
|
328
|
+
elif status in ("off", "deleting"):
|
|
329
|
+
return AgentStatus.STOPPED
|
|
330
|
+
else:
|
|
331
|
+
return AgentStatus.DEGRADED
|
|
332
|
+
except RuntimeError:
|
|
333
|
+
return AgentStatus.FAILED
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# ---------------------------------------------------------------------------
|
|
337
|
+
# Cloud-init template
|
|
338
|
+
# ---------------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
def _build_cloud_init(
|
|
341
|
+
agent_name: str,
|
|
342
|
+
spec: AgentSpec,
|
|
343
|
+
tailscale_authkey: Optional[str] = None,
|
|
344
|
+
) -> str:
|
|
345
|
+
"""Generate cloud-init user data to bootstrap an agent VM.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
agent_name: Agent instance name.
|
|
349
|
+
spec: Agent specification.
|
|
350
|
+
tailscale_authkey: Optional Tailscale auth key for mesh auto-join.
|
|
351
|
+
Falls back to TAILSCALE_AUTHKEY environment variable at build time.
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
Cloud-init YAML string.
|
|
355
|
+
"""
|
|
356
|
+
ts_key = tailscale_authkey or os.environ.get("TAILSCALE_AUTHKEY", "")
|
|
357
|
+
ts_join = ""
|
|
358
|
+
if ts_key:
|
|
359
|
+
ts_join = f' - tailscale up --authkey="{ts_key}" --hostname="{agent_name}"\n'
|
|
360
|
+
|
|
361
|
+
return f"""#cloud-config
|
|
362
|
+
package_update: true
|
|
363
|
+
packages:
|
|
364
|
+
- python3
|
|
365
|
+
- python3-pip
|
|
366
|
+
- python3-venv
|
|
367
|
+
- curl
|
|
368
|
+
- gnupg
|
|
369
|
+
|
|
370
|
+
runcmd:
|
|
371
|
+
- pip3 install skcapstone
|
|
372
|
+
- mkdir -p /opt/agent
|
|
373
|
+
- |
|
|
374
|
+
cat > /opt/agent/config.json << 'AGENT_EOF'
|
|
375
|
+
{{
|
|
376
|
+
"agent_name": "{agent_name}",
|
|
377
|
+
"role": "{spec.role.value}",
|
|
378
|
+
"model": "{spec.model_name or spec.model.value}",
|
|
379
|
+
"skills": {json.dumps(spec.skills)}
|
|
380
|
+
}}
|
|
381
|
+
AGENT_EOF
|
|
382
|
+
- curl -fsSL https://tailscale.com/install.sh | sh
|
|
383
|
+
{ts_join} - echo "Agent {agent_name} provisioned by skcapstone"
|
|
384
|
+
"""
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# ---------------------------------------------------------------------------
|
|
388
|
+
# AWS EC2 Adapter
|
|
389
|
+
# ---------------------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
def _memory_to_ec2_instance_type(memory_str: str, cores: int) -> str:
|
|
392
|
+
"""Map resource spec to closest AWS EC2 instance type.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
memory_str: Memory allocation string (e.g. '4g').
|
|
396
|
+
cores: Number of CPU cores.
|
|
397
|
+
|
|
398
|
+
Returns:
|
|
399
|
+
EC2 instance type name (e.g. 't3.small', 't3.medium').
|
|
400
|
+
"""
|
|
401
|
+
mem_str = memory_str.strip().lower()
|
|
402
|
+
if mem_str.endswith("g"):
|
|
403
|
+
mem_gb = float(mem_str[:-1])
|
|
404
|
+
elif mem_str.endswith("m"):
|
|
405
|
+
mem_gb = float(mem_str[:-1]) / 1024
|
|
406
|
+
else:
|
|
407
|
+
mem_gb = float(mem_str) / 1024
|
|
408
|
+
|
|
409
|
+
# t3 line: micro=1GB/2c, small=2GB/2c, medium=4GB/2c,
|
|
410
|
+
# large=8GB/2c, xlarge=16GB/4c, 2xlarge=32GB/8c
|
|
411
|
+
if mem_gb <= 1 and cores <= 2:
|
|
412
|
+
return "t3.micro"
|
|
413
|
+
elif mem_gb <= 2 and cores <= 2:
|
|
414
|
+
return "t3.small"
|
|
415
|
+
elif mem_gb <= 4 and cores <= 2:
|
|
416
|
+
return "t3.medium"
|
|
417
|
+
elif mem_gb <= 8 and cores <= 2:
|
|
418
|
+
return "t3.large"
|
|
419
|
+
elif mem_gb <= 16 and cores <= 4:
|
|
420
|
+
return "t3.xlarge"
|
|
421
|
+
else:
|
|
422
|
+
return "t3.2xlarge"
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
@register_cloud_adapter("aws")
|
|
426
|
+
class AWSAdapter:
|
|
427
|
+
"""AWS EC2 adapter using boto3.
|
|
428
|
+
|
|
429
|
+
Launches EC2 instances with cloud-init user data for agent bootstrap
|
|
430
|
+
and Tailscale mesh auto-join.
|
|
431
|
+
|
|
432
|
+
Expects AWS credentials via environment variables, ~/.aws/credentials,
|
|
433
|
+
or IAM instance profile:
|
|
434
|
+
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION
|
|
435
|
+
|
|
436
|
+
Args:
|
|
437
|
+
region: AWS region (e.g. 'us-east-1'). Falls back to
|
|
438
|
+
AWS_DEFAULT_REGION.
|
|
439
|
+
ami_id: Base AMI ID. Defaults to Debian 12 lookup via SSM.
|
|
440
|
+
security_group_id: Security group for instances. If not provided,
|
|
441
|
+
uses the VPC default.
|
|
442
|
+
subnet_id: Subnet to launch into. If not provided, uses default.
|
|
443
|
+
key_name: EC2 key pair name for SSH access (optional).
|
|
444
|
+
"""
|
|
445
|
+
|
|
446
|
+
# Default Debian 12 AMIs per region (amd64, hvm, ebs).
|
|
447
|
+
_DEFAULT_AMIS = {
|
|
448
|
+
"us-east-1": "ami-0fec2c2e2017f4e7b",
|
|
449
|
+
"us-west-2": "ami-0b6edd8449255b799",
|
|
450
|
+
"eu-central-1": "ami-042e6fdb154c830c5",
|
|
451
|
+
"eu-west-1": "ami-0694d931cee176e7d",
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
def __init__(
|
|
455
|
+
self,
|
|
456
|
+
region: Optional[str] = None,
|
|
457
|
+
ami_id: Optional[str] = None,
|
|
458
|
+
security_group_id: Optional[str] = None,
|
|
459
|
+
subnet_id: Optional[str] = None,
|
|
460
|
+
key_name: Optional[str] = None,
|
|
461
|
+
**kwargs: Any,
|
|
462
|
+
) -> None:
|
|
463
|
+
self._region = region or os.environ.get("AWS_DEFAULT_REGION", "us-east-1")
|
|
464
|
+
self._ami_id = ami_id
|
|
465
|
+
self._security_group_id = security_group_id
|
|
466
|
+
self._subnet_id = subnet_id
|
|
467
|
+
self._key_name = key_name
|
|
468
|
+
|
|
469
|
+
def _ec2_client(self) -> Any:
|
|
470
|
+
"""Create a boto3 EC2 client.
|
|
471
|
+
|
|
472
|
+
Returns:
|
|
473
|
+
boto3 EC2 client.
|
|
474
|
+
|
|
475
|
+
Raises:
|
|
476
|
+
RuntimeError: If boto3 is not installed.
|
|
477
|
+
"""
|
|
478
|
+
try:
|
|
479
|
+
import boto3
|
|
480
|
+
except ImportError:
|
|
481
|
+
raise RuntimeError(
|
|
482
|
+
"AWS adapter requires boto3: pip install boto3"
|
|
483
|
+
)
|
|
484
|
+
return boto3.client("ec2", region_name=self._region)
|
|
485
|
+
|
|
486
|
+
def _resolve_ami(self) -> str:
|
|
487
|
+
"""Return the AMI ID for the target region.
|
|
488
|
+
|
|
489
|
+
Uses the explicit ami_id if configured, otherwise falls back to a
|
|
490
|
+
built-in map of Debian 12 AMIs.
|
|
491
|
+
|
|
492
|
+
Returns:
|
|
493
|
+
AMI ID string.
|
|
494
|
+
|
|
495
|
+
Raises:
|
|
496
|
+
RuntimeError: If no AMI can be resolved for the region.
|
|
497
|
+
"""
|
|
498
|
+
if self._ami_id:
|
|
499
|
+
return self._ami_id
|
|
500
|
+
ami = self._DEFAULT_AMIS.get(self._region)
|
|
501
|
+
if ami:
|
|
502
|
+
return ami
|
|
503
|
+
raise RuntimeError(
|
|
504
|
+
f"No default AMI for region {self._region}. "
|
|
505
|
+
"Pass ami_id= explicitly."
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
def provision(
|
|
509
|
+
self, agent_name: str, spec: AgentSpec, team_name: str,
|
|
510
|
+
) -> Dict[str, Any]:
|
|
511
|
+
"""Launch an EC2 instance.
|
|
512
|
+
|
|
513
|
+
Args:
|
|
514
|
+
agent_name: Agent instance name.
|
|
515
|
+
spec: Agent specification.
|
|
516
|
+
team_name: Parent team name.
|
|
517
|
+
|
|
518
|
+
Returns:
|
|
519
|
+
Dict with instance_id, host (public IP), container_id.
|
|
520
|
+
"""
|
|
521
|
+
ec2 = self._ec2_client()
|
|
522
|
+
instance_type = _memory_to_ec2_instance_type(
|
|
523
|
+
spec.resources.memory, spec.resources.cores,
|
|
524
|
+
)
|
|
525
|
+
ami = self._resolve_ami()
|
|
526
|
+
cloud_init = _build_cloud_init(agent_name, spec)
|
|
527
|
+
|
|
528
|
+
run_kwargs: Dict[str, Any] = {
|
|
529
|
+
"ImageId": ami,
|
|
530
|
+
"InstanceType": instance_type,
|
|
531
|
+
"MinCount": 1,
|
|
532
|
+
"MaxCount": 1,
|
|
533
|
+
"UserData": cloud_init,
|
|
534
|
+
"TagSpecifications": [
|
|
535
|
+
{
|
|
536
|
+
"ResourceType": "instance",
|
|
537
|
+
"Tags": [
|
|
538
|
+
{"Key": "Name", "Value": agent_name[:255]},
|
|
539
|
+
{"Key": "Team", "Value": team_name[:255]},
|
|
540
|
+
{"Key": "Role", "Value": spec.role.value},
|
|
541
|
+
{"Key": "ManagedBy", "Value": "skcapstone"},
|
|
542
|
+
],
|
|
543
|
+
}
|
|
544
|
+
],
|
|
545
|
+
}
|
|
546
|
+
if self._key_name:
|
|
547
|
+
run_kwargs["KeyName"] = self._key_name
|
|
548
|
+
if self._security_group_id:
|
|
549
|
+
run_kwargs["SecurityGroupIds"] = [self._security_group_id]
|
|
550
|
+
if self._subnet_id:
|
|
551
|
+
run_kwargs["SubnetId"] = self._subnet_id
|
|
552
|
+
|
|
553
|
+
logger.info(
|
|
554
|
+
"Launching EC2 instance %s (type=%s ami=%s region=%s)",
|
|
555
|
+
agent_name, instance_type, ami, self._region,
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
result = ec2.run_instances(**run_kwargs)
|
|
559
|
+
instance = result["Instances"][0]
|
|
560
|
+
instance_id = instance["InstanceId"]
|
|
561
|
+
|
|
562
|
+
# Wait briefly for public IP assignment.
|
|
563
|
+
public_ip = instance.get("PublicIpAddress", "")
|
|
564
|
+
if not public_ip:
|
|
565
|
+
try:
|
|
566
|
+
waiter = ec2.get_waiter("instance_running")
|
|
567
|
+
waiter.wait(
|
|
568
|
+
InstanceIds=[instance_id],
|
|
569
|
+
WaiterConfig={"Delay": 5, "MaxAttempts": 12},
|
|
570
|
+
)
|
|
571
|
+
desc = ec2.describe_instances(InstanceIds=[instance_id])
|
|
572
|
+
reservations = desc.get("Reservations", [])
|
|
573
|
+
if reservations:
|
|
574
|
+
inst = reservations[0]["Instances"][0]
|
|
575
|
+
public_ip = inst.get("PublicIpAddress", "")
|
|
576
|
+
except Exception as exc:
|
|
577
|
+
logger.warning("Could not get public IP for %s: %s", instance_id, exc)
|
|
578
|
+
|
|
579
|
+
return {
|
|
580
|
+
"instance_id": instance_id,
|
|
581
|
+
"host": public_ip,
|
|
582
|
+
"container_id": instance_id,
|
|
583
|
+
"region": self._region,
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
def configure(
|
|
587
|
+
self, agent_name: str, spec: AgentSpec, provision_result: Dict[str, Any],
|
|
588
|
+
) -> bool:
|
|
589
|
+
"""Cloud-init handles configuration at boot."""
|
|
590
|
+
return True
|
|
591
|
+
|
|
592
|
+
def start(self, agent_name: str, provision_result: Dict[str, Any]) -> bool:
|
|
593
|
+
"""Start a stopped EC2 instance.
|
|
594
|
+
|
|
595
|
+
Args:
|
|
596
|
+
agent_name: Agent instance name.
|
|
597
|
+
provision_result: Output from provision().
|
|
598
|
+
|
|
599
|
+
Returns:
|
|
600
|
+
True if the start request succeeded.
|
|
601
|
+
"""
|
|
602
|
+
instance_id = provision_result.get("instance_id")
|
|
603
|
+
if not instance_id:
|
|
604
|
+
return False
|
|
605
|
+
try:
|
|
606
|
+
ec2 = self._ec2_client()
|
|
607
|
+
ec2.start_instances(InstanceIds=[instance_id])
|
|
608
|
+
return True
|
|
609
|
+
except Exception as exc:
|
|
610
|
+
logger.error("Failed to start EC2 %s: %s", instance_id, exc)
|
|
611
|
+
return False
|
|
612
|
+
|
|
613
|
+
def stop(self, agent_name: str, provision_result: Dict[str, Any]) -> bool:
|
|
614
|
+
"""Stop a running EC2 instance.
|
|
615
|
+
|
|
616
|
+
Args:
|
|
617
|
+
agent_name: Agent instance name.
|
|
618
|
+
provision_result: Output from provision().
|
|
619
|
+
|
|
620
|
+
Returns:
|
|
621
|
+
True if the stop request succeeded.
|
|
622
|
+
"""
|
|
623
|
+
instance_id = provision_result.get("instance_id")
|
|
624
|
+
if not instance_id:
|
|
625
|
+
return False
|
|
626
|
+
try:
|
|
627
|
+
ec2 = self._ec2_client()
|
|
628
|
+
ec2.stop_instances(InstanceIds=[instance_id])
|
|
629
|
+
return True
|
|
630
|
+
except Exception as exc:
|
|
631
|
+
logger.error("Failed to stop EC2 %s: %s", instance_id, exc)
|
|
632
|
+
return False
|
|
633
|
+
|
|
634
|
+
def destroy(self, agent_name: str, provision_result: Dict[str, Any]) -> bool:
|
|
635
|
+
"""Terminate an EC2 instance.
|
|
636
|
+
|
|
637
|
+
Args:
|
|
638
|
+
agent_name: Agent instance name.
|
|
639
|
+
provision_result: Output from provision().
|
|
640
|
+
|
|
641
|
+
Returns:
|
|
642
|
+
True if the termination request succeeded.
|
|
643
|
+
"""
|
|
644
|
+
instance_id = provision_result.get("instance_id")
|
|
645
|
+
if not instance_id:
|
|
646
|
+
return False
|
|
647
|
+
try:
|
|
648
|
+
ec2 = self._ec2_client()
|
|
649
|
+
ec2.terminate_instances(InstanceIds=[instance_id])
|
|
650
|
+
logger.info("Terminated EC2 instance %s", instance_id)
|
|
651
|
+
return True
|
|
652
|
+
except Exception as exc:
|
|
653
|
+
logger.error("Failed to terminate EC2 %s: %s", instance_id, exc)
|
|
654
|
+
return False
|
|
655
|
+
|
|
656
|
+
def health_check(
|
|
657
|
+
self, agent_name: str, provision_result: Dict[str, Any],
|
|
658
|
+
) -> AgentStatus:
|
|
659
|
+
"""Check EC2 instance status.
|
|
660
|
+
|
|
661
|
+
Args:
|
|
662
|
+
agent_name: Agent instance name.
|
|
663
|
+
provision_result: Output from provision().
|
|
664
|
+
|
|
665
|
+
Returns:
|
|
666
|
+
AgentStatus based on instance state.
|
|
667
|
+
"""
|
|
668
|
+
instance_id = provision_result.get("instance_id")
|
|
669
|
+
if not instance_id:
|
|
670
|
+
return AgentStatus.STOPPED
|
|
671
|
+
try:
|
|
672
|
+
ec2 = self._ec2_client()
|
|
673
|
+
desc = ec2.describe_instances(InstanceIds=[instance_id])
|
|
674
|
+
reservations = desc.get("Reservations", [])
|
|
675
|
+
if not reservations:
|
|
676
|
+
return AgentStatus.STOPPED
|
|
677
|
+
state = reservations[0]["Instances"][0]["State"]["Name"]
|
|
678
|
+
if state == "running":
|
|
679
|
+
return AgentStatus.RUNNING
|
|
680
|
+
elif state in ("stopped", "terminated"):
|
|
681
|
+
return AgentStatus.STOPPED
|
|
682
|
+
elif state in ("pending", "stopping", "shutting-down"):
|
|
683
|
+
return AgentStatus.DEGRADED
|
|
684
|
+
else:
|
|
685
|
+
return AgentStatus.DEGRADED
|
|
686
|
+
except Exception:
|
|
687
|
+
return AgentStatus.FAILED
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
# ---------------------------------------------------------------------------
|
|
691
|
+
# GCP Compute Adapter
|
|
692
|
+
# ---------------------------------------------------------------------------
|
|
693
|
+
|
|
694
|
+
def _memory_to_gcp_machine_type(memory_str: str, cores: int) -> str:
|
|
695
|
+
"""Map resource spec to closest GCP machine type.
|
|
696
|
+
|
|
697
|
+
Args:
|
|
698
|
+
memory_str: Memory allocation string (e.g. '4g').
|
|
699
|
+
cores: Number of CPU cores.
|
|
700
|
+
|
|
701
|
+
Returns:
|
|
702
|
+
GCP machine type name (e.g. 'e2-small', 'e2-medium').
|
|
703
|
+
"""
|
|
704
|
+
mem_str = memory_str.strip().lower()
|
|
705
|
+
if mem_str.endswith("g"):
|
|
706
|
+
mem_gb = float(mem_str[:-1])
|
|
707
|
+
elif mem_str.endswith("m"):
|
|
708
|
+
mem_gb = float(mem_str[:-1]) / 1024
|
|
709
|
+
else:
|
|
710
|
+
mem_gb = float(mem_str) / 1024
|
|
711
|
+
|
|
712
|
+
# e2 line: micro=1GB/0.25c, small=2GB/0.5c, medium=4GB/1c,
|
|
713
|
+
# standard-2=8GB/2c, standard-4=16GB/4c, standard-8=32GB/8c
|
|
714
|
+
if mem_gb <= 1 and cores <= 1:
|
|
715
|
+
return "e2-micro"
|
|
716
|
+
elif mem_gb <= 2 and cores <= 1:
|
|
717
|
+
return "e2-small"
|
|
718
|
+
elif mem_gb <= 4 and cores <= 2:
|
|
719
|
+
return "e2-medium"
|
|
720
|
+
elif mem_gb <= 8 and cores <= 2:
|
|
721
|
+
return "e2-standard-2"
|
|
722
|
+
elif mem_gb <= 16 and cores <= 4:
|
|
723
|
+
return "e2-standard-4"
|
|
724
|
+
else:
|
|
725
|
+
return "e2-standard-8"
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
@register_cloud_adapter("gcp")
|
|
729
|
+
class GCPAdapter:
|
|
730
|
+
"""GCP Compute Engine adapter using the google-cloud-compute library.
|
|
731
|
+
|
|
732
|
+
Launches Compute Engine instances with startup-script metadata for
|
|
733
|
+
agent bootstrap and Tailscale mesh auto-join.
|
|
734
|
+
|
|
735
|
+
Expects GCP credentials via:
|
|
736
|
+
GOOGLE_APPLICATION_CREDENTIALS (service account JSON) or
|
|
737
|
+
Application Default Credentials (gcloud auth application-default login).
|
|
738
|
+
|
|
739
|
+
Args:
|
|
740
|
+
project: GCP project ID. Falls back to GOOGLE_CLOUD_PROJECT or
|
|
741
|
+
GCLOUD_PROJECT.
|
|
742
|
+
zone: Compute zone (e.g. 'us-central1-a'). Falls back to
|
|
743
|
+
CLOUDSDK_COMPUTE_ZONE.
|
|
744
|
+
network: VPC network name (default: 'default').
|
|
745
|
+
subnet: Subnetwork name (optional).
|
|
746
|
+
service_account_email: Service account for the instance (optional).
|
|
747
|
+
"""
|
|
748
|
+
|
|
749
|
+
# Debian 12 image family on GCP.
|
|
750
|
+
_IMAGE_PROJECT = "debian-cloud"
|
|
751
|
+
_IMAGE_FAMILY = "debian-12"
|
|
752
|
+
|
|
753
|
+
def __init__(
|
|
754
|
+
self,
|
|
755
|
+
project: Optional[str] = None,
|
|
756
|
+
zone: Optional[str] = None,
|
|
757
|
+
network: str = "default",
|
|
758
|
+
subnet: Optional[str] = None,
|
|
759
|
+
service_account_email: Optional[str] = None,
|
|
760
|
+
**kwargs: Any,
|
|
761
|
+
) -> None:
|
|
762
|
+
self._project = (
|
|
763
|
+
project
|
|
764
|
+
or os.environ.get("GOOGLE_CLOUD_PROJECT")
|
|
765
|
+
or os.environ.get("GCLOUD_PROJECT", "")
|
|
766
|
+
)
|
|
767
|
+
self._zone = zone or os.environ.get("CLOUDSDK_COMPUTE_ZONE", "us-central1-a")
|
|
768
|
+
self._network = network
|
|
769
|
+
self._subnet = subnet
|
|
770
|
+
self._service_account_email = service_account_email
|
|
771
|
+
|
|
772
|
+
def _compute_client(self) -> Any:
|
|
773
|
+
"""Create a GCP Compute instances client.
|
|
774
|
+
|
|
775
|
+
Returns:
|
|
776
|
+
google.cloud.compute_v1.InstancesClient.
|
|
777
|
+
|
|
778
|
+
Raises:
|
|
779
|
+
RuntimeError: If google-cloud-compute is not installed.
|
|
780
|
+
"""
|
|
781
|
+
try:
|
|
782
|
+
from google.cloud import compute_v1
|
|
783
|
+
except ImportError:
|
|
784
|
+
raise RuntimeError(
|
|
785
|
+
"GCP adapter requires google-cloud-compute: "
|
|
786
|
+
"pip install google-cloud-compute"
|
|
787
|
+
)
|
|
788
|
+
return compute_v1.InstancesClient()
|
|
789
|
+
|
|
790
|
+
def _get_source_image(self) -> str:
|
|
791
|
+
"""Resolve the latest Debian 12 image URI from GCP.
|
|
792
|
+
|
|
793
|
+
Returns:
|
|
794
|
+
Fully-qualified image self-link.
|
|
795
|
+
|
|
796
|
+
Raises:
|
|
797
|
+
RuntimeError: If the image cannot be resolved.
|
|
798
|
+
"""
|
|
799
|
+
try:
|
|
800
|
+
from google.cloud import compute_v1
|
|
801
|
+
|
|
802
|
+
images_client = compute_v1.ImagesClient()
|
|
803
|
+
image = images_client.get_from_family(
|
|
804
|
+
project=self._IMAGE_PROJECT, family=self._IMAGE_FAMILY,
|
|
805
|
+
)
|
|
806
|
+
return image.self_link
|
|
807
|
+
except ImportError:
|
|
808
|
+
# Fallback to well-known URI pattern.
|
|
809
|
+
return (
|
|
810
|
+
f"projects/{self._IMAGE_PROJECT}/global/images/family/"
|
|
811
|
+
f"{self._IMAGE_FAMILY}"
|
|
812
|
+
)
|
|
813
|
+
except Exception as exc:
|
|
814
|
+
raise RuntimeError(f"Failed to resolve GCP image: {exc}")
|
|
815
|
+
|
|
816
|
+
def provision(
|
|
817
|
+
self, agent_name: str, spec: AgentSpec, team_name: str,
|
|
818
|
+
) -> Dict[str, Any]:
|
|
819
|
+
"""Create a GCP Compute Engine instance.
|
|
820
|
+
|
|
821
|
+
Args:
|
|
822
|
+
agent_name: Agent instance name.
|
|
823
|
+
spec: Agent specification.
|
|
824
|
+
team_name: Parent team name.
|
|
825
|
+
|
|
826
|
+
Returns:
|
|
827
|
+
Dict with instance_name, zone, host (external IP).
|
|
828
|
+
"""
|
|
829
|
+
try:
|
|
830
|
+
from google.cloud import compute_v1
|
|
831
|
+
except ImportError:
|
|
832
|
+
raise RuntimeError(
|
|
833
|
+
"GCP adapter requires google-cloud-compute: "
|
|
834
|
+
"pip install google-cloud-compute"
|
|
835
|
+
)
|
|
836
|
+
|
|
837
|
+
if not self._project:
|
|
838
|
+
raise RuntimeError(
|
|
839
|
+
"GCP project not configured. Set GOOGLE_CLOUD_PROJECT "
|
|
840
|
+
"or pass project= to GCPAdapter."
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
machine_type = _memory_to_gcp_machine_type(
|
|
844
|
+
spec.resources.memory, spec.resources.cores,
|
|
845
|
+
)
|
|
846
|
+
instance_name = agent_name.replace("_", "-").lower()[:63]
|
|
847
|
+
cloud_init = _build_cloud_init(agent_name, spec)
|
|
848
|
+
source_image = self._get_source_image()
|
|
849
|
+
|
|
850
|
+
# Build instance resource.
|
|
851
|
+
instance = compute_v1.Instance()
|
|
852
|
+
instance.name = instance_name
|
|
853
|
+
instance.machine_type = (
|
|
854
|
+
f"zones/{self._zone}/machineTypes/{machine_type}"
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
# Boot disk.
|
|
858
|
+
disk = compute_v1.AttachedDisk()
|
|
859
|
+
disk.auto_delete = True
|
|
860
|
+
disk.boot = True
|
|
861
|
+
init_params = compute_v1.AttachedDiskInitializeParams()
|
|
862
|
+
init_params.source_image = source_image
|
|
863
|
+
init_params.disk_size_gb = 20
|
|
864
|
+
disk.initialize_params = init_params
|
|
865
|
+
instance.disks = [disk]
|
|
866
|
+
|
|
867
|
+
# Network.
|
|
868
|
+
net_iface = compute_v1.NetworkInterface()
|
|
869
|
+
net_iface.network = f"global/networks/{self._network}"
|
|
870
|
+
if self._subnet:
|
|
871
|
+
net_iface.subnetwork = (
|
|
872
|
+
f"regions/{self._zone.rsplit('-', 1)[0]}/subnetworks/{self._subnet}"
|
|
873
|
+
)
|
|
874
|
+
# External IP for SSH/Tailscale.
|
|
875
|
+
access_config = compute_v1.AccessConfig()
|
|
876
|
+
access_config.name = "External NAT"
|
|
877
|
+
access_config.type_ = "ONE_TO_ONE_NAT"
|
|
878
|
+
net_iface.access_configs = [access_config]
|
|
879
|
+
instance.network_interfaces = [net_iface]
|
|
880
|
+
|
|
881
|
+
# Startup script (cloud-init equivalent).
|
|
882
|
+
instance.metadata = compute_v1.Metadata()
|
|
883
|
+
instance.metadata.items = [
|
|
884
|
+
compute_v1.Items(key="startup-script", value=cloud_init),
|
|
885
|
+
]
|
|
886
|
+
|
|
887
|
+
# Labels.
|
|
888
|
+
instance.labels = {
|
|
889
|
+
"team": team_name.replace(" ", "-").lower()[:63],
|
|
890
|
+
"agent": instance_name[:63],
|
|
891
|
+
"role": spec.role.value,
|
|
892
|
+
"managed-by": "skcapstone",
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
# Service account.
|
|
896
|
+
if self._service_account_email:
|
|
897
|
+
sa = compute_v1.ServiceAccount()
|
|
898
|
+
sa.email = self._service_account_email
|
|
899
|
+
sa.scopes = ["https://www.googleapis.com/auth/cloud-platform"]
|
|
900
|
+
instance.service_accounts = [sa]
|
|
901
|
+
|
|
902
|
+
logger.info(
|
|
903
|
+
"Creating GCP instance %s (type=%s zone=%s project=%s)",
|
|
904
|
+
instance_name, machine_type, self._zone, self._project,
|
|
905
|
+
)
|
|
906
|
+
|
|
907
|
+
client = self._compute_client()
|
|
908
|
+
operation = client.insert(
|
|
909
|
+
project=self._project,
|
|
910
|
+
zone=self._zone,
|
|
911
|
+
instance_resource=instance,
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
# Wait for the operation to complete.
|
|
915
|
+
try:
|
|
916
|
+
operation.result(timeout=120)
|
|
917
|
+
except Exception as exc:
|
|
918
|
+
logger.warning("GCP insert wait: %s", exc)
|
|
919
|
+
|
|
920
|
+
# Fetch instance details for external IP.
|
|
921
|
+
host = ""
|
|
922
|
+
try:
|
|
923
|
+
inst = client.get(
|
|
924
|
+
project=self._project,
|
|
925
|
+
zone=self._zone,
|
|
926
|
+
instance=instance_name,
|
|
927
|
+
)
|
|
928
|
+
for iface in inst.network_interfaces:
|
|
929
|
+
for ac in iface.access_configs:
|
|
930
|
+
if ac.nat_i_p:
|
|
931
|
+
host = ac.nat_i_p
|
|
932
|
+
break
|
|
933
|
+
except Exception as exc:
|
|
934
|
+
logger.warning("Could not fetch GCP instance IP: %s", exc)
|
|
935
|
+
|
|
936
|
+
return {
|
|
937
|
+
"instance_name": instance_name,
|
|
938
|
+
"host": host,
|
|
939
|
+
"zone": self._zone,
|
|
940
|
+
"project": self._project,
|
|
941
|
+
"container_id": instance_name,
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
def configure(
|
|
945
|
+
self, agent_name: str, spec: AgentSpec, provision_result: Dict[str, Any],
|
|
946
|
+
) -> bool:
|
|
947
|
+
"""Startup script handles configuration at boot."""
|
|
948
|
+
return True
|
|
949
|
+
|
|
950
|
+
def start(self, agent_name: str, provision_result: Dict[str, Any]) -> bool:
|
|
951
|
+
"""Start a stopped GCP instance.
|
|
952
|
+
|
|
953
|
+
Args:
|
|
954
|
+
agent_name: Agent instance name.
|
|
955
|
+
provision_result: Output from provision().
|
|
956
|
+
|
|
957
|
+
Returns:
|
|
958
|
+
True if the start request succeeded.
|
|
959
|
+
"""
|
|
960
|
+
instance_name = provision_result.get("instance_name")
|
|
961
|
+
if not instance_name:
|
|
962
|
+
return False
|
|
963
|
+
try:
|
|
964
|
+
client = self._compute_client()
|
|
965
|
+
operation = client.start(
|
|
966
|
+
project=provision_result.get("project", self._project),
|
|
967
|
+
zone=provision_result.get("zone", self._zone),
|
|
968
|
+
instance=instance_name,
|
|
969
|
+
)
|
|
970
|
+
operation.result(timeout=60)
|
|
971
|
+
return True
|
|
972
|
+
except Exception as exc:
|
|
973
|
+
logger.error("Failed to start GCP instance %s: %s", instance_name, exc)
|
|
974
|
+
return False
|
|
975
|
+
|
|
976
|
+
def stop(self, agent_name: str, provision_result: Dict[str, Any]) -> bool:
|
|
977
|
+
"""Stop a running GCP instance.
|
|
978
|
+
|
|
979
|
+
Args:
|
|
980
|
+
agent_name: Agent instance name.
|
|
981
|
+
provision_result: Output from provision().
|
|
982
|
+
|
|
983
|
+
Returns:
|
|
984
|
+
True if the stop request succeeded.
|
|
985
|
+
"""
|
|
986
|
+
instance_name = provision_result.get("instance_name")
|
|
987
|
+
if not instance_name:
|
|
988
|
+
return False
|
|
989
|
+
try:
|
|
990
|
+
client = self._compute_client()
|
|
991
|
+
operation = client.stop(
|
|
992
|
+
project=provision_result.get("project", self._project),
|
|
993
|
+
zone=provision_result.get("zone", self._zone),
|
|
994
|
+
instance=instance_name,
|
|
995
|
+
)
|
|
996
|
+
operation.result(timeout=60)
|
|
997
|
+
return True
|
|
998
|
+
except Exception as exc:
|
|
999
|
+
logger.error("Failed to stop GCP instance %s: %s", instance_name, exc)
|
|
1000
|
+
return False
|
|
1001
|
+
|
|
1002
|
+
def destroy(self, agent_name: str, provision_result: Dict[str, Any]) -> bool:
|
|
1003
|
+
"""Delete a GCP instance.
|
|
1004
|
+
|
|
1005
|
+
Args:
|
|
1006
|
+
agent_name: Agent instance name.
|
|
1007
|
+
provision_result: Output from provision().
|
|
1008
|
+
|
|
1009
|
+
Returns:
|
|
1010
|
+
True if the deletion request succeeded.
|
|
1011
|
+
"""
|
|
1012
|
+
instance_name = provision_result.get("instance_name")
|
|
1013
|
+
if not instance_name:
|
|
1014
|
+
return False
|
|
1015
|
+
try:
|
|
1016
|
+
client = self._compute_client()
|
|
1017
|
+
operation = client.delete(
|
|
1018
|
+
project=provision_result.get("project", self._project),
|
|
1019
|
+
zone=provision_result.get("zone", self._zone),
|
|
1020
|
+
instance=instance_name,
|
|
1021
|
+
)
|
|
1022
|
+
operation.result(timeout=120)
|
|
1023
|
+
logger.info("Deleted GCP instance %s", instance_name)
|
|
1024
|
+
return True
|
|
1025
|
+
except Exception as exc:
|
|
1026
|
+
logger.error("Failed to delete GCP instance %s: %s", instance_name, exc)
|
|
1027
|
+
return False
|
|
1028
|
+
|
|
1029
|
+
def health_check(
|
|
1030
|
+
self, agent_name: str, provision_result: Dict[str, Any],
|
|
1031
|
+
) -> AgentStatus:
|
|
1032
|
+
"""Check GCP instance status.
|
|
1033
|
+
|
|
1034
|
+
Args:
|
|
1035
|
+
agent_name: Agent instance name.
|
|
1036
|
+
provision_result: Output from provision().
|
|
1037
|
+
|
|
1038
|
+
Returns:
|
|
1039
|
+
AgentStatus based on instance state.
|
|
1040
|
+
"""
|
|
1041
|
+
instance_name = provision_result.get("instance_name")
|
|
1042
|
+
if not instance_name:
|
|
1043
|
+
return AgentStatus.STOPPED
|
|
1044
|
+
try:
|
|
1045
|
+
client = self._compute_client()
|
|
1046
|
+
inst = client.get(
|
|
1047
|
+
project=provision_result.get("project", self._project),
|
|
1048
|
+
zone=provision_result.get("zone", self._zone),
|
|
1049
|
+
instance=instance_name,
|
|
1050
|
+
)
|
|
1051
|
+
status = inst.status
|
|
1052
|
+
if status == "RUNNING":
|
|
1053
|
+
return AgentStatus.RUNNING
|
|
1054
|
+
elif status in ("TERMINATED", "STOPPED"):
|
|
1055
|
+
return AgentStatus.STOPPED
|
|
1056
|
+
elif status in ("STAGING", "STOPPING", "SUSPENDING", "SUSPENDED"):
|
|
1057
|
+
return AgentStatus.DEGRADED
|
|
1058
|
+
else:
|
|
1059
|
+
return AgentStatus.DEGRADED
|
|
1060
|
+
except Exception:
|
|
1061
|
+
return AgentStatus.FAILED
|