iac-code 0.1.0__py3-none-any.whl
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.
- iac_code/__init__.py +2 -0
- iac_code/acp/__init__.py +97 -0
- iac_code/acp/convert.py +423 -0
- iac_code/acp/http_sse.py +448 -0
- iac_code/acp/mcp.py +54 -0
- iac_code/acp/metrics.py +71 -0
- iac_code/acp/server.py +662 -0
- iac_code/acp/session.py +446 -0
- iac_code/acp/slash_registry.py +125 -0
- iac_code/acp/state.py +99 -0
- iac_code/acp/tools.py +112 -0
- iac_code/acp/types.py +13 -0
- iac_code/acp/version.py +26 -0
- iac_code/agent/__init__.py +19 -0
- iac_code/agent/agent_loop.py +640 -0
- iac_code/agent/agent_tool.py +269 -0
- iac_code/agent/agent_types.py +87 -0
- iac_code/agent/message.py +153 -0
- iac_code/agent/system_prompt.py +313 -0
- iac_code/cli/__init__.py +3 -0
- iac_code/cli/headless.py +114 -0
- iac_code/cli/main.py +246 -0
- iac_code/cli/output_formats.py +125 -0
- iac_code/commands/__init__.py +93 -0
- iac_code/commands/auth.py +1055 -0
- iac_code/commands/clear.py +34 -0
- iac_code/commands/compact.py +43 -0
- iac_code/commands/debug.py +45 -0
- iac_code/commands/effort.py +116 -0
- iac_code/commands/exit.py +10 -0
- iac_code/commands/help.py +49 -0
- iac_code/commands/model.py +130 -0
- iac_code/commands/registry.py +245 -0
- iac_code/commands/resume.py +49 -0
- iac_code/commands/tasks.py +41 -0
- iac_code/config.py +304 -0
- iac_code/i18n/__init__.py +141 -0
- iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +1355 -0
- iac_code/memory/__init__.py +1 -0
- iac_code/memory/memory_manager.py +92 -0
- iac_code/memory/memory_tools.py +88 -0
- iac_code/providers/__init__.py +1 -0
- iac_code/providers/anthropic_provider.py +284 -0
- iac_code/providers/base.py +128 -0
- iac_code/providers/dashscope_provider.py +47 -0
- iac_code/providers/deepseek_provider.py +36 -0
- iac_code/providers/manager.py +399 -0
- iac_code/providers/openai_provider.py +344 -0
- iac_code/providers/retry.py +58 -0
- iac_code/providers/stream_watchdog.py +47 -0
- iac_code/providers/thinking.py +164 -0
- iac_code/services/__init__.py +1 -0
- iac_code/services/agent_factory.py +127 -0
- iac_code/services/cloud_credentials.py +22 -0
- iac_code/services/context_manager.py +221 -0
- iac_code/services/providers/__init__.py +1 -0
- iac_code/services/providers/aliyun.py +232 -0
- iac_code/services/session_index.py +281 -0
- iac_code/services/session_storage.py +245 -0
- iac_code/services/telemetry/__init__.py +66 -0
- iac_code/services/telemetry/attributes.py +84 -0
- iac_code/services/telemetry/client.py +330 -0
- iac_code/services/telemetry/config.py +76 -0
- iac_code/services/telemetry/constants.py +75 -0
- iac_code/services/telemetry/content_serializer.py +124 -0
- iac_code/services/telemetry/events.py +42 -0
- iac_code/services/telemetry/fallback.py +59 -0
- iac_code/services/telemetry/identity.py +73 -0
- iac_code/services/telemetry/metrics.py +62 -0
- iac_code/services/telemetry/names.py +199 -0
- iac_code/services/telemetry/sanitize.py +88 -0
- iac_code/services/telemetry/sink.py +67 -0
- iac_code/services/telemetry/tracing.py +38 -0
- iac_code/services/telemetry/types.py +13 -0
- iac_code/services/token_budget.py +54 -0
- iac_code/services/token_counter.py +76 -0
- iac_code/skills/__init__.py +1 -0
- iac_code/skills/bundled/__init__.py +94 -0
- iac_code/skills/bundled/iac_aliyun/SKILL.md +192 -0
- iac_code/skills/bundled/iac_aliyun/__init__.py +16 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/ecs.md +167 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/oss.md +69 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/rds.md +95 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/redis.md +100 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/slb.md +60 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/vpc.md +54 -0
- iac_code/skills/bundled/iac_aliyun/references/ros-template.md +155 -0
- iac_code/skills/bundled/iac_aliyun/references/template-parameters.md +206 -0
- iac_code/skills/bundled/iac_aliyun/references/terraform-template.md +101 -0
- iac_code/skills/bundled/iac_aliyun/scripts/tf2ros.py +77 -0
- iac_code/skills/bundled/simplify.py +28 -0
- iac_code/skills/discovery.py +136 -0
- iac_code/skills/frontmatter.py +119 -0
- iac_code/skills/listing.py +92 -0
- iac_code/skills/loader.py +42 -0
- iac_code/skills/processor.py +81 -0
- iac_code/skills/renderer.py +157 -0
- iac_code/skills/skill_definition.py +82 -0
- iac_code/skills/skill_tool.py +261 -0
- iac_code/state/__init__.py +5 -0
- iac_code/state/app_state.py +122 -0
- iac_code/tasks/__init__.py +1 -0
- iac_code/tasks/notification_queue.py +28 -0
- iac_code/tasks/task_state.py +66 -0
- iac_code/tasks/task_tools.py +114 -0
- iac_code/tools/__init__.py +8 -0
- iac_code/tools/base.py +226 -0
- iac_code/tools/bash.py +133 -0
- iac_code/tools/cloud/__init__.py +0 -0
- iac_code/tools/cloud/aliyun/__init__.py +0 -0
- iac_code/tools/cloud/aliyun/aliyun_api.py +510 -0
- iac_code/tools/cloud/aliyun/aliyun_doc_search.py +145 -0
- iac_code/tools/cloud/aliyun/endpoints.yml +343 -0
- iac_code/tools/cloud/aliyun/ros_client.py +56 -0
- iac_code/tools/cloud/aliyun/ros_stack.py +633 -0
- iac_code/tools/cloud/aliyun/ros_stack_instances.py +247 -0
- iac_code/tools/cloud/base_api.py +162 -0
- iac_code/tools/cloud/base_stack.py +242 -0
- iac_code/tools/cloud/registry.py +20 -0
- iac_code/tools/cloud/types.py +105 -0
- iac_code/tools/edit_file.py +121 -0
- iac_code/tools/glob.py +103 -0
- iac_code/tools/grep.py +254 -0
- iac_code/tools/list_files.py +104 -0
- iac_code/tools/read_file.py +127 -0
- iac_code/tools/result_storage.py +39 -0
- iac_code/tools/tool_executor.py +165 -0
- iac_code/tools/web_fetch.py +177 -0
- iac_code/tools/write_file.py +88 -0
- iac_code/types/__init__.py +40 -0
- iac_code/types/permissions.py +26 -0
- iac_code/types/skill_source.py +11 -0
- iac_code/types/stream_events.py +227 -0
- iac_code/ui/__init__.py +5 -0
- iac_code/ui/banner.py +110 -0
- iac_code/ui/components/__init__.py +0 -0
- iac_code/ui/components/dialog.py +142 -0
- iac_code/ui/components/divider.py +20 -0
- iac_code/ui/components/fuzzy_picker.py +308 -0
- iac_code/ui/components/progress_bar.py +54 -0
- iac_code/ui/components/search_box.py +165 -0
- iac_code/ui/components/select.py +319 -0
- iac_code/ui/components/status_icon.py +42 -0
- iac_code/ui/components/tabs.py +128 -0
- iac_code/ui/core/__init__.py +0 -0
- iac_code/ui/core/in_place_render.py +129 -0
- iac_code/ui/core/input_history.py +118 -0
- iac_code/ui/core/key_event.py +41 -0
- iac_code/ui/core/prompt_input.py +507 -0
- iac_code/ui/core/raw_input.py +302 -0
- iac_code/ui/core/screen.py +80 -0
- iac_code/ui/dialogs/__init__.py +0 -0
- iac_code/ui/dialogs/global_search.py +178 -0
- iac_code/ui/dialogs/history_search.py +100 -0
- iac_code/ui/dialogs/model_picker.py +280 -0
- iac_code/ui/dialogs/quick_open.py +108 -0
- iac_code/ui/dialogs/resume_picker.py +749 -0
- iac_code/ui/keybindings/__init__.py +0 -0
- iac_code/ui/keybindings/manager.py +124 -0
- iac_code/ui/renderer.py +1535 -0
- iac_code/ui/repl.py +772 -0
- iac_code/ui/spinner.py +112 -0
- iac_code/ui/suggestions/__init__.py +0 -0
- iac_code/ui/suggestions/aggregator.py +171 -0
- iac_code/ui/suggestions/command_provider.py +43 -0
- iac_code/ui/suggestions/directory_provider.py +95 -0
- iac_code/ui/suggestions/file_provider.py +121 -0
- iac_code/ui/suggestions/shell_history_provider.py +108 -0
- iac_code/ui/suggestions/token_extractor.py +77 -0
- iac_code/ui/suggestions/types.py +45 -0
- iac_code/ui/transcript_view.py +199 -0
- iac_code/utils/__init__.py +0 -0
- iac_code/utils/background_housekeeping.py +53 -0
- iac_code/utils/cleanup.py +68 -0
- iac_code/utils/json_utils.py +60 -0
- iac_code/utils/log.py +150 -0
- iac_code/utils/project_paths.py +74 -0
- iac_code/utils/tool_input_parser.py +62 -0
- iac_code-0.1.0.dist-info/LICENSE +201 -0
- iac_code-0.1.0.dist-info/METADATA +64 -0
- iac_code-0.1.0.dist-info/RECORD +184 -0
- iac_code-0.1.0.dist-info/WHEEL +5 -0
- iac_code-0.1.0.dist-info/entry_points.txt +2 -0
- iac_code-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
"""ROS Stack tool for Alibaba Cloud Resource Orchestration Service."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
from alibabacloud_ros20190910 import models as ros_models
|
|
14
|
+
|
|
15
|
+
from iac_code.i18n import _
|
|
16
|
+
from iac_code.services.cloud_credentials import CloudCredentials
|
|
17
|
+
from iac_code.services.telemetry import add_metric, log_event
|
|
18
|
+
from iac_code.services.telemetry.names import Events, Metrics
|
|
19
|
+
from iac_code.services.telemetry.sanitize import (
|
|
20
|
+
bucket_resource_count,
|
|
21
|
+
sanitize_error_message,
|
|
22
|
+
sanitize_resource_type,
|
|
23
|
+
sanitize_terraform_provider,
|
|
24
|
+
)
|
|
25
|
+
from iac_code.tools.cloud.aliyun.ros_client import RosClientFactory
|
|
26
|
+
from iac_code.tools.cloud.base_stack import BaseCloudStack
|
|
27
|
+
from iac_code.tools.cloud.types import ResourceStatus, StackStatus
|
|
28
|
+
|
|
29
|
+
_URL_SCHEMES = ("http://", "https://", "oss://")
|
|
30
|
+
|
|
31
|
+
# Telemetry helpers
|
|
32
|
+
_TERRAFORM_TRANSFORM_PREFIXES = ("Aliyun::Terraform-", "Aliyun::OpenTofu-")
|
|
33
|
+
_HCL_RESOURCE_RE = re.compile(r'resource\s+"([^"]+)"\s+"[^"]+"\s*\{', re.MULTILINE)
|
|
34
|
+
|
|
35
|
+
_ROS_ERROR_CATEGORIES = {
|
|
36
|
+
"InvalidTemplateBody": "syntax",
|
|
37
|
+
"TemplateFormatVersionNotSupported": "syntax",
|
|
38
|
+
"TemplateURLNotReachable": "reference",
|
|
39
|
+
"ResourceNotFound": "reference",
|
|
40
|
+
"QuotaExceeded": "quota",
|
|
41
|
+
"Forbidden": "permission",
|
|
42
|
+
"NoPermission": "permission",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
SUPPORTED_ACTIONS = [
|
|
46
|
+
"CreateStack",
|
|
47
|
+
"UpdateStack",
|
|
48
|
+
"ContinueCreateStack",
|
|
49
|
+
"DeleteStack",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _parse_template(template_body: str) -> dict | None:
|
|
54
|
+
"""Try YAML first (covers JSON too). Return None if unparseable."""
|
|
55
|
+
try:
|
|
56
|
+
data = yaml.safe_load(template_body)
|
|
57
|
+
except Exception:
|
|
58
|
+
try:
|
|
59
|
+
data = json.loads(template_body)
|
|
60
|
+
except Exception:
|
|
61
|
+
return None
|
|
62
|
+
return data if isinstance(data, dict) else None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _detect_iac_kind(template_data: dict) -> Literal["ros", "terraform"]:
|
|
66
|
+
"""iac_kind driven by Transform field."""
|
|
67
|
+
transform = template_data.get("Transform", "")
|
|
68
|
+
values = transform if isinstance(transform, list) else [transform]
|
|
69
|
+
for v in values:
|
|
70
|
+
if isinstance(v, str) and v.startswith(_TERRAFORM_TRANSFORM_PREFIXES):
|
|
71
|
+
return "terraform"
|
|
72
|
+
return "ros"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _extract_ros_resource_types(template_data: dict) -> list[str]:
|
|
76
|
+
"""ROS native: enumerate Resources.<name>.Type."""
|
|
77
|
+
resources = template_data.get("Resources", {})
|
|
78
|
+
if not isinstance(resources, dict):
|
|
79
|
+
return []
|
|
80
|
+
return [v.get("Type", "") for v in resources.values() if isinstance(v, dict) and v.get("Type")]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _extract_terraform_resource_types(template_data: dict) -> list[str]:
|
|
84
|
+
"""Terraform transform: grep `resource "<type>" "<name>"` in Workspace *.tf files."""
|
|
85
|
+
workspace = template_data.get("Workspace", {})
|
|
86
|
+
if not isinstance(workspace, dict):
|
|
87
|
+
return []
|
|
88
|
+
types: list[str] = []
|
|
89
|
+
for filename, content in workspace.items():
|
|
90
|
+
if not isinstance(filename, str) or not filename.endswith(".tf"):
|
|
91
|
+
continue
|
|
92
|
+
if not isinstance(content, str):
|
|
93
|
+
continue
|
|
94
|
+
types.extend(_HCL_RESOURCE_RE.findall(content))
|
|
95
|
+
return types
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _extract_resource_types(template_body: str) -> tuple[Literal["ros", "terraform"], list[str]]:
|
|
99
|
+
"""Return (iac_kind, list_of_resource_types)."""
|
|
100
|
+
data = _parse_template(template_body)
|
|
101
|
+
if data is None:
|
|
102
|
+
return ("ros", [])
|
|
103
|
+
kind = _detect_iac_kind(data)
|
|
104
|
+
if kind == "terraform":
|
|
105
|
+
return ("terraform", _extract_terraform_resource_types(data))
|
|
106
|
+
return ("ros", _extract_ros_resource_types(data))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _count_by_type(types: list[str]) -> dict[str, int]:
|
|
110
|
+
counts: dict[str, int] = {}
|
|
111
|
+
for t in types:
|
|
112
|
+
counts[t] = counts.get(t, 0) + 1
|
|
113
|
+
return counts
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _classify_ros_error(e: Exception) -> str:
|
|
117
|
+
code = getattr(e, "code", "")
|
|
118
|
+
return _ROS_ERROR_CATEGORIES.get(code, "other")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class RosStack(BaseCloudStack):
|
|
122
|
+
"""Alibaba Cloud ROS Stack lifecycle tool.
|
|
123
|
+
|
|
124
|
+
Manages the full lifecycle of ROS stacks including create, update,
|
|
125
|
+
continue-create, and delete operations with progress polling.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
poll_interval: int = 5
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def provider_name(self) -> str:
|
|
132
|
+
return "ros"
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def supported_actions(self) -> list[str]:
|
|
136
|
+
return SUPPORTED_ACTIONS
|
|
137
|
+
|
|
138
|
+
def user_facing_name(self, input: dict | None = None) -> str:
|
|
139
|
+
return _("ROS Stack")
|
|
140
|
+
|
|
141
|
+
def _get_default_region(self) -> str:
|
|
142
|
+
credentials = CloudCredentials()
|
|
143
|
+
cred = credentials.get_provider("aliyun")
|
|
144
|
+
return cred.region_id if cred else ""
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def description(self) -> str:
|
|
148
|
+
return (
|
|
149
|
+
"Manage Alibaba Cloud ROS (Resource Orchestration Service) stack lifecycle. "
|
|
150
|
+
"Supports creating, updating, continuing, and deleting stacks with "
|
|
151
|
+
"real-time progress polling."
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def _get_client(self, region: str) -> Any:
|
|
155
|
+
credentials = CloudCredentials()
|
|
156
|
+
cred = credentials.get_provider("aliyun")
|
|
157
|
+
return RosClientFactory.create(cred, region_id=region)
|
|
158
|
+
|
|
159
|
+
async def call_action(self, action: str, params: dict, region: str) -> str:
|
|
160
|
+
client = self._get_client(region)
|
|
161
|
+
# Ensure RegionId is always in params for the API request
|
|
162
|
+
if region:
|
|
163
|
+
params.setdefault("RegionId", region)
|
|
164
|
+
# TemplateURL as local file path → read into TemplateBody
|
|
165
|
+
template_url = params.get("TemplateURL", "")
|
|
166
|
+
if template_url and not template_url.startswith(_URL_SCHEMES):
|
|
167
|
+
params["TemplateBody"] = Path(template_url).read_text()
|
|
168
|
+
del params["TemplateURL"]
|
|
169
|
+
# TemplateBody must be a JSON string; models may pass a dict
|
|
170
|
+
if isinstance(params.get("TemplateBody"), dict):
|
|
171
|
+
params["TemplateBody"] = json.dumps(params["TemplateBody"], ensure_ascii=False)
|
|
172
|
+
|
|
173
|
+
if action == "CreateStack":
|
|
174
|
+
return await self._handle_create_stack(client, params, region)
|
|
175
|
+
elif action == "UpdateStack":
|
|
176
|
+
return await self._handle_update_stack(client, params, region)
|
|
177
|
+
elif action == "ContinueCreateStack":
|
|
178
|
+
request = ros_models.ContinueCreateStackRequest().from_map(params)
|
|
179
|
+
response = client.continue_create_stack(request)
|
|
180
|
+
return response.body.stack_id
|
|
181
|
+
elif action == "DeleteStack":
|
|
182
|
+
return await self._handle_delete_stack(client, params, region)
|
|
183
|
+
raise ValueError(f"Unsupported: {action}")
|
|
184
|
+
|
|
185
|
+
async def _handle_create_stack(self, client: Any, params: dict, region: str) -> str:
|
|
186
|
+
"""CreateStack with telemetry for template generation and deployment."""
|
|
187
|
+
template_body = params.get("TemplateBody", "")
|
|
188
|
+
|
|
189
|
+
# Extract IaC kind and resource types
|
|
190
|
+
kind, resource_types_raw = _extract_resource_types(template_body)
|
|
191
|
+
resource_counts = _count_by_type(resource_types_raw)
|
|
192
|
+
safe_types = [sanitize_resource_type(t, kind) for t in resource_counts.keys()]
|
|
193
|
+
counts = list(resource_counts.values())
|
|
194
|
+
total = sum(counts)
|
|
195
|
+
|
|
196
|
+
# Terraform-specific: extract providers
|
|
197
|
+
tf_providers: list[str] = []
|
|
198
|
+
if kind == "terraform":
|
|
199
|
+
raw_providers = {t.split("_", 1)[0] for t in resource_counts.keys() if "_" in t}
|
|
200
|
+
tf_providers = sorted({sanitize_terraform_provider(p) for p in raw_providers})
|
|
201
|
+
|
|
202
|
+
# --- Task 27: Emit template.generated event ---
|
|
203
|
+
stripped = template_body.lstrip()
|
|
204
|
+
template_format = "json" if stripped.startswith("{") else "yaml"
|
|
205
|
+
|
|
206
|
+
template_generated_payload = {
|
|
207
|
+
"iac_kind": kind,
|
|
208
|
+
"template_format": template_format,
|
|
209
|
+
"template_size_bytes": len(template_body.encode("utf-8")),
|
|
210
|
+
"resource_count_total": total,
|
|
211
|
+
"resource_count_distinct": len(set(safe_types)),
|
|
212
|
+
"resource_types": safe_types[:50],
|
|
213
|
+
"resource_type_counts": counts[:50],
|
|
214
|
+
"generation_source": "agent",
|
|
215
|
+
}
|
|
216
|
+
if kind == "terraform":
|
|
217
|
+
template_generated_payload["terraform_providers"] = tf_providers
|
|
218
|
+
|
|
219
|
+
log_event(Events.TEMPLATE_GENERATED, template_generated_payload)
|
|
220
|
+
add_metric(
|
|
221
|
+
Metrics.TEMPLATE_GENERATED_COUNT,
|
|
222
|
+
1,
|
|
223
|
+
{
|
|
224
|
+
"kind": kind,
|
|
225
|
+
"format": template_format,
|
|
226
|
+
"outcome": "success",
|
|
227
|
+
},
|
|
228
|
+
)
|
|
229
|
+
for rtype, count in zip(safe_types, counts):
|
|
230
|
+
add_metric(
|
|
231
|
+
Metrics.RESOURCE_TYPE_OBSERVED_COUNT,
|
|
232
|
+
count,
|
|
233
|
+
{
|
|
234
|
+
"kind": kind,
|
|
235
|
+
"resource_type": rtype,
|
|
236
|
+
"phase": "generate",
|
|
237
|
+
},
|
|
238
|
+
)
|
|
239
|
+
if kind == "terraform":
|
|
240
|
+
for prov in tf_providers:
|
|
241
|
+
add_metric(
|
|
242
|
+
Metrics.TERRAFORM_PROVIDER_OBSERVED_COUNT,
|
|
243
|
+
1,
|
|
244
|
+
{
|
|
245
|
+
"provider": prov,
|
|
246
|
+
"phase": "generate",
|
|
247
|
+
},
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# --- Task 26: Emit deployment events ---
|
|
251
|
+
deployment_started_payload = {
|
|
252
|
+
"iac_kind": kind,
|
|
253
|
+
"region": region,
|
|
254
|
+
"resource_count_total": total,
|
|
255
|
+
"resource_count_distinct": len(set(safe_types)),
|
|
256
|
+
"resource_types": safe_types[:50],
|
|
257
|
+
"resource_type_counts": counts[:50],
|
|
258
|
+
}
|
|
259
|
+
if kind == "terraform":
|
|
260
|
+
deployment_started_payload["terraform_providers"] = tf_providers
|
|
261
|
+
|
|
262
|
+
log_event(Events.DEPLOYMENT_STARTED, deployment_started_payload)
|
|
263
|
+
|
|
264
|
+
started = time.monotonic()
|
|
265
|
+
try:
|
|
266
|
+
request = ros_models.CreateStackRequest().from_map(params)
|
|
267
|
+
response = client.create_stack(request)
|
|
268
|
+
duration_ms = int((time.monotonic() - started) * 1000)
|
|
269
|
+
log_event(
|
|
270
|
+
Events.DEPLOYMENT_SUCCEEDED,
|
|
271
|
+
{
|
|
272
|
+
"iac_kind": kind,
|
|
273
|
+
"region": region,
|
|
274
|
+
"duration_ms": duration_ms,
|
|
275
|
+
"resource_count_total": total,
|
|
276
|
+
"stack_status": "CREATE_COMPLETE",
|
|
277
|
+
},
|
|
278
|
+
)
|
|
279
|
+
add_metric(Metrics.DEPLOYMENT_COUNT, 1, {"kind": kind, "outcome": "success"})
|
|
280
|
+
add_metric(
|
|
281
|
+
Metrics.DEPLOYMENT_DURATION,
|
|
282
|
+
duration_ms,
|
|
283
|
+
{
|
|
284
|
+
"kind": kind,
|
|
285
|
+
"outcome": "success",
|
|
286
|
+
"resource_count_bucket": bucket_resource_count(total),
|
|
287
|
+
},
|
|
288
|
+
)
|
|
289
|
+
for rtype, count in zip(safe_types, counts):
|
|
290
|
+
add_metric(
|
|
291
|
+
Metrics.RESOURCE_TYPE_OBSERVED_COUNT,
|
|
292
|
+
count,
|
|
293
|
+
{
|
|
294
|
+
"kind": kind,
|
|
295
|
+
"resource_type": rtype,
|
|
296
|
+
"phase": "deploy",
|
|
297
|
+
},
|
|
298
|
+
)
|
|
299
|
+
if kind == "terraform":
|
|
300
|
+
for prov in tf_providers:
|
|
301
|
+
add_metric(
|
|
302
|
+
Metrics.TERRAFORM_PROVIDER_OBSERVED_COUNT,
|
|
303
|
+
1,
|
|
304
|
+
{
|
|
305
|
+
"provider": prov,
|
|
306
|
+
"phase": "deploy",
|
|
307
|
+
},
|
|
308
|
+
)
|
|
309
|
+
return response.body.stack_id
|
|
310
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
311
|
+
duration_ms = int((time.monotonic() - started) * 1000)
|
|
312
|
+
log_event(
|
|
313
|
+
Events.DEPLOYMENT_CANCELLED,
|
|
314
|
+
{
|
|
315
|
+
"iac_kind": kind,
|
|
316
|
+
"region": region,
|
|
317
|
+
"duration_ms": duration_ms,
|
|
318
|
+
"reason": "user_cancel",
|
|
319
|
+
},
|
|
320
|
+
)
|
|
321
|
+
add_metric(Metrics.DEPLOYMENT_COUNT, 1, {"kind": kind, "outcome": "cancel"})
|
|
322
|
+
raise
|
|
323
|
+
except TimeoutError:
|
|
324
|
+
duration_ms = int((time.monotonic() - started) * 1000)
|
|
325
|
+
log_event(
|
|
326
|
+
Events.DEPLOYMENT_CANCELLED,
|
|
327
|
+
{
|
|
328
|
+
"iac_kind": kind,
|
|
329
|
+
"region": region,
|
|
330
|
+
"duration_ms": duration_ms,
|
|
331
|
+
"reason": "timeout",
|
|
332
|
+
},
|
|
333
|
+
)
|
|
334
|
+
add_metric(Metrics.DEPLOYMENT_COUNT, 1, {"kind": kind, "outcome": "cancel"})
|
|
335
|
+
raise
|
|
336
|
+
except Exception as e:
|
|
337
|
+
duration_ms = int((time.monotonic() - started) * 1000)
|
|
338
|
+
error_category = _classify_ros_error(e)
|
|
339
|
+
log_event(
|
|
340
|
+
Events.DEPLOYMENT_FAILED,
|
|
341
|
+
{
|
|
342
|
+
"iac_kind": kind,
|
|
343
|
+
"region": region,
|
|
344
|
+
"duration_ms": duration_ms,
|
|
345
|
+
"resource_count_total": total,
|
|
346
|
+
"error_code": getattr(e, "code", type(e).__name__),
|
|
347
|
+
"error_category": error_category,
|
|
348
|
+
"http_status": getattr(e, "status_code", 0) or 0,
|
|
349
|
+
"error_message": sanitize_error_message(str(e)),
|
|
350
|
+
},
|
|
351
|
+
)
|
|
352
|
+
add_metric(
|
|
353
|
+
Metrics.DEPLOYMENT_COUNT,
|
|
354
|
+
1,
|
|
355
|
+
{
|
|
356
|
+
"kind": kind,
|
|
357
|
+
"outcome": "fail",
|
|
358
|
+
"error_category": error_category,
|
|
359
|
+
},
|
|
360
|
+
)
|
|
361
|
+
add_metric(
|
|
362
|
+
Metrics.DEPLOYMENT_DURATION,
|
|
363
|
+
duration_ms,
|
|
364
|
+
{
|
|
365
|
+
"kind": kind,
|
|
366
|
+
"outcome": "fail",
|
|
367
|
+
"resource_count_bucket": bucket_resource_count(total),
|
|
368
|
+
},
|
|
369
|
+
)
|
|
370
|
+
raise
|
|
371
|
+
|
|
372
|
+
async def _handle_update_stack(self, client: Any, params: dict, region: str) -> str:
|
|
373
|
+
"""UpdateStack with telemetry for deployment events."""
|
|
374
|
+
template_body = params.get("TemplateBody", "")
|
|
375
|
+
|
|
376
|
+
# Extract IaC kind and resource types
|
|
377
|
+
kind, resource_types_raw = _extract_resource_types(template_body)
|
|
378
|
+
resource_counts = _count_by_type(resource_types_raw)
|
|
379
|
+
safe_types = [sanitize_resource_type(t, kind) for t in resource_counts.keys()]
|
|
380
|
+
counts = list(resource_counts.values())
|
|
381
|
+
total = sum(counts)
|
|
382
|
+
|
|
383
|
+
# Terraform-specific: extract providers
|
|
384
|
+
tf_providers: list[str] = []
|
|
385
|
+
if kind == "terraform":
|
|
386
|
+
raw_providers = {t.split("_", 1)[0] for t in resource_counts.keys() if "_" in t}
|
|
387
|
+
tf_providers = sorted({sanitize_terraform_provider(p) for p in raw_providers})
|
|
388
|
+
|
|
389
|
+
deployment_started_payload = {
|
|
390
|
+
"iac_kind": kind,
|
|
391
|
+
"region": region,
|
|
392
|
+
"resource_count_total": total,
|
|
393
|
+
"resource_count_distinct": len(set(safe_types)),
|
|
394
|
+
"resource_types": safe_types[:50],
|
|
395
|
+
"resource_type_counts": counts[:50],
|
|
396
|
+
}
|
|
397
|
+
if kind == "terraform":
|
|
398
|
+
deployment_started_payload["terraform_providers"] = tf_providers
|
|
399
|
+
|
|
400
|
+
log_event(Events.DEPLOYMENT_STARTED, deployment_started_payload)
|
|
401
|
+
|
|
402
|
+
started = time.monotonic()
|
|
403
|
+
try:
|
|
404
|
+
request = ros_models.UpdateStackRequest().from_map(params)
|
|
405
|
+
response = client.update_stack(request)
|
|
406
|
+
duration_ms = int((time.monotonic() - started) * 1000)
|
|
407
|
+
log_event(
|
|
408
|
+
Events.DEPLOYMENT_SUCCEEDED,
|
|
409
|
+
{
|
|
410
|
+
"iac_kind": kind,
|
|
411
|
+
"region": region,
|
|
412
|
+
"duration_ms": duration_ms,
|
|
413
|
+
"resource_count_total": total,
|
|
414
|
+
"stack_status": "UPDATE_COMPLETE",
|
|
415
|
+
},
|
|
416
|
+
)
|
|
417
|
+
add_metric(Metrics.DEPLOYMENT_COUNT, 1, {"kind": kind, "outcome": "success"})
|
|
418
|
+
add_metric(
|
|
419
|
+
Metrics.DEPLOYMENT_DURATION,
|
|
420
|
+
duration_ms,
|
|
421
|
+
{
|
|
422
|
+
"kind": kind,
|
|
423
|
+
"outcome": "success",
|
|
424
|
+
"resource_count_bucket": bucket_resource_count(total),
|
|
425
|
+
},
|
|
426
|
+
)
|
|
427
|
+
for rtype, count in zip(safe_types, counts):
|
|
428
|
+
add_metric(
|
|
429
|
+
Metrics.RESOURCE_TYPE_OBSERVED_COUNT,
|
|
430
|
+
count,
|
|
431
|
+
{
|
|
432
|
+
"kind": kind,
|
|
433
|
+
"resource_type": rtype,
|
|
434
|
+
"phase": "deploy",
|
|
435
|
+
},
|
|
436
|
+
)
|
|
437
|
+
if kind == "terraform":
|
|
438
|
+
for prov in tf_providers:
|
|
439
|
+
add_metric(
|
|
440
|
+
Metrics.TERRAFORM_PROVIDER_OBSERVED_COUNT,
|
|
441
|
+
1,
|
|
442
|
+
{
|
|
443
|
+
"provider": prov,
|
|
444
|
+
"phase": "deploy",
|
|
445
|
+
},
|
|
446
|
+
)
|
|
447
|
+
return response.body.stack_id
|
|
448
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
449
|
+
duration_ms = int((time.monotonic() - started) * 1000)
|
|
450
|
+
log_event(
|
|
451
|
+
Events.DEPLOYMENT_CANCELLED,
|
|
452
|
+
{
|
|
453
|
+
"iac_kind": kind,
|
|
454
|
+
"region": region,
|
|
455
|
+
"duration_ms": duration_ms,
|
|
456
|
+
"reason": "user_cancel",
|
|
457
|
+
},
|
|
458
|
+
)
|
|
459
|
+
add_metric(Metrics.DEPLOYMENT_COUNT, 1, {"kind": kind, "outcome": "cancel"})
|
|
460
|
+
raise
|
|
461
|
+
except TimeoutError:
|
|
462
|
+
duration_ms = int((time.monotonic() - started) * 1000)
|
|
463
|
+
log_event(
|
|
464
|
+
Events.DEPLOYMENT_CANCELLED,
|
|
465
|
+
{
|
|
466
|
+
"iac_kind": kind,
|
|
467
|
+
"region": region,
|
|
468
|
+
"duration_ms": duration_ms,
|
|
469
|
+
"reason": "timeout",
|
|
470
|
+
},
|
|
471
|
+
)
|
|
472
|
+
add_metric(Metrics.DEPLOYMENT_COUNT, 1, {"kind": kind, "outcome": "cancel"})
|
|
473
|
+
raise
|
|
474
|
+
except Exception as e:
|
|
475
|
+
duration_ms = int((time.monotonic() - started) * 1000)
|
|
476
|
+
error_category = _classify_ros_error(e)
|
|
477
|
+
log_event(
|
|
478
|
+
Events.DEPLOYMENT_FAILED,
|
|
479
|
+
{
|
|
480
|
+
"iac_kind": kind,
|
|
481
|
+
"region": region,
|
|
482
|
+
"duration_ms": duration_ms,
|
|
483
|
+
"resource_count_total": total,
|
|
484
|
+
"error_code": getattr(e, "code", type(e).__name__),
|
|
485
|
+
"error_category": error_category,
|
|
486
|
+
"http_status": getattr(e, "status_code", 0) or 0,
|
|
487
|
+
"error_message": sanitize_error_message(str(e)),
|
|
488
|
+
},
|
|
489
|
+
)
|
|
490
|
+
add_metric(
|
|
491
|
+
Metrics.DEPLOYMENT_COUNT,
|
|
492
|
+
1,
|
|
493
|
+
{
|
|
494
|
+
"kind": kind,
|
|
495
|
+
"outcome": "fail",
|
|
496
|
+
"error_category": error_category,
|
|
497
|
+
},
|
|
498
|
+
)
|
|
499
|
+
add_metric(
|
|
500
|
+
Metrics.DEPLOYMENT_DURATION,
|
|
501
|
+
duration_ms,
|
|
502
|
+
{
|
|
503
|
+
"kind": kind,
|
|
504
|
+
"outcome": "fail",
|
|
505
|
+
"resource_count_bucket": bucket_resource_count(total),
|
|
506
|
+
},
|
|
507
|
+
)
|
|
508
|
+
raise
|
|
509
|
+
|
|
510
|
+
async def _handle_delete_stack(self, client: Any, params: dict, region: str) -> str:
|
|
511
|
+
"""DeleteStack with telemetry (succeeded/failed/cancelled, no started event)."""
|
|
512
|
+
stack_id = params.get("StackId", "")
|
|
513
|
+
|
|
514
|
+
# DeleteStack: no template available, use "ros" as conservative default for kind
|
|
515
|
+
kind = "ros"
|
|
516
|
+
|
|
517
|
+
started = time.monotonic()
|
|
518
|
+
try:
|
|
519
|
+
request = ros_models.DeleteStackRequest().from_map(params)
|
|
520
|
+
client.delete_stack(request)
|
|
521
|
+
duration_ms = int((time.monotonic() - started) * 1000)
|
|
522
|
+
log_event(
|
|
523
|
+
Events.DEPLOYMENT_SUCCEEDED,
|
|
524
|
+
{
|
|
525
|
+
"iac_kind": kind,
|
|
526
|
+
"region": region,
|
|
527
|
+
"duration_ms": duration_ms,
|
|
528
|
+
"stack_status": "DELETE_COMPLETE",
|
|
529
|
+
},
|
|
530
|
+
)
|
|
531
|
+
add_metric(Metrics.DEPLOYMENT_COUNT, 1, {"kind": kind, "outcome": "success"})
|
|
532
|
+
add_metric(
|
|
533
|
+
Metrics.DEPLOYMENT_DURATION,
|
|
534
|
+
duration_ms,
|
|
535
|
+
{
|
|
536
|
+
"kind": kind,
|
|
537
|
+
"outcome": "success",
|
|
538
|
+
"resource_count_bucket": "0", # Unknown resource count
|
|
539
|
+
},
|
|
540
|
+
)
|
|
541
|
+
return stack_id
|
|
542
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
543
|
+
duration_ms = int((time.monotonic() - started) * 1000)
|
|
544
|
+
log_event(
|
|
545
|
+
Events.DEPLOYMENT_CANCELLED,
|
|
546
|
+
{
|
|
547
|
+
"iac_kind": kind,
|
|
548
|
+
"region": region,
|
|
549
|
+
"duration_ms": duration_ms,
|
|
550
|
+
"reason": "user_cancel",
|
|
551
|
+
},
|
|
552
|
+
)
|
|
553
|
+
add_metric(Metrics.DEPLOYMENT_COUNT, 1, {"kind": kind, "outcome": "cancel"})
|
|
554
|
+
raise
|
|
555
|
+
except TimeoutError:
|
|
556
|
+
duration_ms = int((time.monotonic() - started) * 1000)
|
|
557
|
+
log_event(
|
|
558
|
+
Events.DEPLOYMENT_CANCELLED,
|
|
559
|
+
{
|
|
560
|
+
"iac_kind": kind,
|
|
561
|
+
"region": region,
|
|
562
|
+
"duration_ms": duration_ms,
|
|
563
|
+
"reason": "timeout",
|
|
564
|
+
},
|
|
565
|
+
)
|
|
566
|
+
add_metric(Metrics.DEPLOYMENT_COUNT, 1, {"kind": kind, "outcome": "cancel"})
|
|
567
|
+
raise
|
|
568
|
+
except Exception as e:
|
|
569
|
+
duration_ms = int((time.monotonic() - started) * 1000)
|
|
570
|
+
error_category = _classify_ros_error(e)
|
|
571
|
+
log_event(
|
|
572
|
+
Events.DEPLOYMENT_FAILED,
|
|
573
|
+
{
|
|
574
|
+
"iac_kind": kind,
|
|
575
|
+
"region": region,
|
|
576
|
+
"duration_ms": duration_ms,
|
|
577
|
+
"error_code": getattr(e, "code", type(e).__name__),
|
|
578
|
+
"error_category": error_category,
|
|
579
|
+
"http_status": getattr(e, "status_code", 0) or 0,
|
|
580
|
+
"error_message": sanitize_error_message(str(e)),
|
|
581
|
+
},
|
|
582
|
+
)
|
|
583
|
+
add_metric(
|
|
584
|
+
Metrics.DEPLOYMENT_COUNT,
|
|
585
|
+
1,
|
|
586
|
+
{
|
|
587
|
+
"kind": kind,
|
|
588
|
+
"outcome": "fail",
|
|
589
|
+
"error_category": error_category,
|
|
590
|
+
},
|
|
591
|
+
)
|
|
592
|
+
add_metric(
|
|
593
|
+
Metrics.DEPLOYMENT_DURATION,
|
|
594
|
+
duration_ms,
|
|
595
|
+
{
|
|
596
|
+
"kind": kind,
|
|
597
|
+
"outcome": "fail",
|
|
598
|
+
"resource_count_bucket": "0", # Unknown resource count
|
|
599
|
+
},
|
|
600
|
+
)
|
|
601
|
+
raise
|
|
602
|
+
|
|
603
|
+
async def get_stack_status(self, stack_id: str, region: str) -> StackStatus:
|
|
604
|
+
client = self._get_client(region)
|
|
605
|
+
request = ros_models.GetStackRequest(
|
|
606
|
+
stack_id=stack_id, region_id=region, show_resource_progress="PercentageOnly"
|
|
607
|
+
)
|
|
608
|
+
response = client.get_stack(request)
|
|
609
|
+
data = response.body.to_map()
|
|
610
|
+
return StackStatus(
|
|
611
|
+
stack_id=data.get("StackId", stack_id),
|
|
612
|
+
stack_name=data.get("StackName", ""),
|
|
613
|
+
status=data.get("Status", ""),
|
|
614
|
+
status_reason=data.get("StatusReason", ""),
|
|
615
|
+
progress_percentage=data.get("ResourceProgress", {}).get("StackOperationProgress", 0),
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
async def get_stack_resources(self, stack_id: str, region: str) -> list[ResourceStatus]:
|
|
619
|
+
client = self._get_client(region)
|
|
620
|
+
request = ros_models.ListStackResourcesRequest(stack_id=stack_id, region_id=region)
|
|
621
|
+
response = client.list_stack_resources(request)
|
|
622
|
+
data = response.body.to_map()
|
|
623
|
+
resources = []
|
|
624
|
+
for r in data.get("Resources", []):
|
|
625
|
+
resources.append(
|
|
626
|
+
ResourceStatus(
|
|
627
|
+
name=r.get("LogicalResourceId", ""),
|
|
628
|
+
resource_type=r.get("ResourceType", ""),
|
|
629
|
+
status=r.get("Status", ""),
|
|
630
|
+
status_reason=r.get("StatusReason", ""),
|
|
631
|
+
)
|
|
632
|
+
)
|
|
633
|
+
return resources
|