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.
Files changed (184) hide show
  1. iac_code/__init__.py +2 -0
  2. iac_code/acp/__init__.py +97 -0
  3. iac_code/acp/convert.py +423 -0
  4. iac_code/acp/http_sse.py +448 -0
  5. iac_code/acp/mcp.py +54 -0
  6. iac_code/acp/metrics.py +71 -0
  7. iac_code/acp/server.py +662 -0
  8. iac_code/acp/session.py +446 -0
  9. iac_code/acp/slash_registry.py +125 -0
  10. iac_code/acp/state.py +99 -0
  11. iac_code/acp/tools.py +112 -0
  12. iac_code/acp/types.py +13 -0
  13. iac_code/acp/version.py +26 -0
  14. iac_code/agent/__init__.py +19 -0
  15. iac_code/agent/agent_loop.py +640 -0
  16. iac_code/agent/agent_tool.py +269 -0
  17. iac_code/agent/agent_types.py +87 -0
  18. iac_code/agent/message.py +153 -0
  19. iac_code/agent/system_prompt.py +313 -0
  20. iac_code/cli/__init__.py +3 -0
  21. iac_code/cli/headless.py +114 -0
  22. iac_code/cli/main.py +246 -0
  23. iac_code/cli/output_formats.py +125 -0
  24. iac_code/commands/__init__.py +93 -0
  25. iac_code/commands/auth.py +1055 -0
  26. iac_code/commands/clear.py +34 -0
  27. iac_code/commands/compact.py +43 -0
  28. iac_code/commands/debug.py +45 -0
  29. iac_code/commands/effort.py +116 -0
  30. iac_code/commands/exit.py +10 -0
  31. iac_code/commands/help.py +49 -0
  32. iac_code/commands/model.py +130 -0
  33. iac_code/commands/registry.py +245 -0
  34. iac_code/commands/resume.py +49 -0
  35. iac_code/commands/tasks.py +41 -0
  36. iac_code/config.py +304 -0
  37. iac_code/i18n/__init__.py +141 -0
  38. iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +1355 -0
  39. iac_code/memory/__init__.py +1 -0
  40. iac_code/memory/memory_manager.py +92 -0
  41. iac_code/memory/memory_tools.py +88 -0
  42. iac_code/providers/__init__.py +1 -0
  43. iac_code/providers/anthropic_provider.py +284 -0
  44. iac_code/providers/base.py +128 -0
  45. iac_code/providers/dashscope_provider.py +47 -0
  46. iac_code/providers/deepseek_provider.py +36 -0
  47. iac_code/providers/manager.py +399 -0
  48. iac_code/providers/openai_provider.py +344 -0
  49. iac_code/providers/retry.py +58 -0
  50. iac_code/providers/stream_watchdog.py +47 -0
  51. iac_code/providers/thinking.py +164 -0
  52. iac_code/services/__init__.py +1 -0
  53. iac_code/services/agent_factory.py +127 -0
  54. iac_code/services/cloud_credentials.py +22 -0
  55. iac_code/services/context_manager.py +221 -0
  56. iac_code/services/providers/__init__.py +1 -0
  57. iac_code/services/providers/aliyun.py +232 -0
  58. iac_code/services/session_index.py +281 -0
  59. iac_code/services/session_storage.py +245 -0
  60. iac_code/services/telemetry/__init__.py +66 -0
  61. iac_code/services/telemetry/attributes.py +84 -0
  62. iac_code/services/telemetry/client.py +330 -0
  63. iac_code/services/telemetry/config.py +76 -0
  64. iac_code/services/telemetry/constants.py +75 -0
  65. iac_code/services/telemetry/content_serializer.py +124 -0
  66. iac_code/services/telemetry/events.py +42 -0
  67. iac_code/services/telemetry/fallback.py +59 -0
  68. iac_code/services/telemetry/identity.py +73 -0
  69. iac_code/services/telemetry/metrics.py +62 -0
  70. iac_code/services/telemetry/names.py +199 -0
  71. iac_code/services/telemetry/sanitize.py +88 -0
  72. iac_code/services/telemetry/sink.py +67 -0
  73. iac_code/services/telemetry/tracing.py +38 -0
  74. iac_code/services/telemetry/types.py +13 -0
  75. iac_code/services/token_budget.py +54 -0
  76. iac_code/services/token_counter.py +76 -0
  77. iac_code/skills/__init__.py +1 -0
  78. iac_code/skills/bundled/__init__.py +94 -0
  79. iac_code/skills/bundled/iac_aliyun/SKILL.md +192 -0
  80. iac_code/skills/bundled/iac_aliyun/__init__.py +16 -0
  81. iac_code/skills/bundled/iac_aliyun/references/cloud-products/ecs.md +167 -0
  82. iac_code/skills/bundled/iac_aliyun/references/cloud-products/oss.md +69 -0
  83. iac_code/skills/bundled/iac_aliyun/references/cloud-products/rds.md +95 -0
  84. iac_code/skills/bundled/iac_aliyun/references/cloud-products/redis.md +100 -0
  85. iac_code/skills/bundled/iac_aliyun/references/cloud-products/slb.md +60 -0
  86. iac_code/skills/bundled/iac_aliyun/references/cloud-products/vpc.md +54 -0
  87. iac_code/skills/bundled/iac_aliyun/references/ros-template.md +155 -0
  88. iac_code/skills/bundled/iac_aliyun/references/template-parameters.md +206 -0
  89. iac_code/skills/bundled/iac_aliyun/references/terraform-template.md +101 -0
  90. iac_code/skills/bundled/iac_aliyun/scripts/tf2ros.py +77 -0
  91. iac_code/skills/bundled/simplify.py +28 -0
  92. iac_code/skills/discovery.py +136 -0
  93. iac_code/skills/frontmatter.py +119 -0
  94. iac_code/skills/listing.py +92 -0
  95. iac_code/skills/loader.py +42 -0
  96. iac_code/skills/processor.py +81 -0
  97. iac_code/skills/renderer.py +157 -0
  98. iac_code/skills/skill_definition.py +82 -0
  99. iac_code/skills/skill_tool.py +261 -0
  100. iac_code/state/__init__.py +5 -0
  101. iac_code/state/app_state.py +122 -0
  102. iac_code/tasks/__init__.py +1 -0
  103. iac_code/tasks/notification_queue.py +28 -0
  104. iac_code/tasks/task_state.py +66 -0
  105. iac_code/tasks/task_tools.py +114 -0
  106. iac_code/tools/__init__.py +8 -0
  107. iac_code/tools/base.py +226 -0
  108. iac_code/tools/bash.py +133 -0
  109. iac_code/tools/cloud/__init__.py +0 -0
  110. iac_code/tools/cloud/aliyun/__init__.py +0 -0
  111. iac_code/tools/cloud/aliyun/aliyun_api.py +510 -0
  112. iac_code/tools/cloud/aliyun/aliyun_doc_search.py +145 -0
  113. iac_code/tools/cloud/aliyun/endpoints.yml +343 -0
  114. iac_code/tools/cloud/aliyun/ros_client.py +56 -0
  115. iac_code/tools/cloud/aliyun/ros_stack.py +633 -0
  116. iac_code/tools/cloud/aliyun/ros_stack_instances.py +247 -0
  117. iac_code/tools/cloud/base_api.py +162 -0
  118. iac_code/tools/cloud/base_stack.py +242 -0
  119. iac_code/tools/cloud/registry.py +20 -0
  120. iac_code/tools/cloud/types.py +105 -0
  121. iac_code/tools/edit_file.py +121 -0
  122. iac_code/tools/glob.py +103 -0
  123. iac_code/tools/grep.py +254 -0
  124. iac_code/tools/list_files.py +104 -0
  125. iac_code/tools/read_file.py +127 -0
  126. iac_code/tools/result_storage.py +39 -0
  127. iac_code/tools/tool_executor.py +165 -0
  128. iac_code/tools/web_fetch.py +177 -0
  129. iac_code/tools/write_file.py +88 -0
  130. iac_code/types/__init__.py +40 -0
  131. iac_code/types/permissions.py +26 -0
  132. iac_code/types/skill_source.py +11 -0
  133. iac_code/types/stream_events.py +227 -0
  134. iac_code/ui/__init__.py +5 -0
  135. iac_code/ui/banner.py +110 -0
  136. iac_code/ui/components/__init__.py +0 -0
  137. iac_code/ui/components/dialog.py +142 -0
  138. iac_code/ui/components/divider.py +20 -0
  139. iac_code/ui/components/fuzzy_picker.py +308 -0
  140. iac_code/ui/components/progress_bar.py +54 -0
  141. iac_code/ui/components/search_box.py +165 -0
  142. iac_code/ui/components/select.py +319 -0
  143. iac_code/ui/components/status_icon.py +42 -0
  144. iac_code/ui/components/tabs.py +128 -0
  145. iac_code/ui/core/__init__.py +0 -0
  146. iac_code/ui/core/in_place_render.py +129 -0
  147. iac_code/ui/core/input_history.py +118 -0
  148. iac_code/ui/core/key_event.py +41 -0
  149. iac_code/ui/core/prompt_input.py +507 -0
  150. iac_code/ui/core/raw_input.py +302 -0
  151. iac_code/ui/core/screen.py +80 -0
  152. iac_code/ui/dialogs/__init__.py +0 -0
  153. iac_code/ui/dialogs/global_search.py +178 -0
  154. iac_code/ui/dialogs/history_search.py +100 -0
  155. iac_code/ui/dialogs/model_picker.py +280 -0
  156. iac_code/ui/dialogs/quick_open.py +108 -0
  157. iac_code/ui/dialogs/resume_picker.py +749 -0
  158. iac_code/ui/keybindings/__init__.py +0 -0
  159. iac_code/ui/keybindings/manager.py +124 -0
  160. iac_code/ui/renderer.py +1535 -0
  161. iac_code/ui/repl.py +772 -0
  162. iac_code/ui/spinner.py +112 -0
  163. iac_code/ui/suggestions/__init__.py +0 -0
  164. iac_code/ui/suggestions/aggregator.py +171 -0
  165. iac_code/ui/suggestions/command_provider.py +43 -0
  166. iac_code/ui/suggestions/directory_provider.py +95 -0
  167. iac_code/ui/suggestions/file_provider.py +121 -0
  168. iac_code/ui/suggestions/shell_history_provider.py +108 -0
  169. iac_code/ui/suggestions/token_extractor.py +77 -0
  170. iac_code/ui/suggestions/types.py +45 -0
  171. iac_code/ui/transcript_view.py +199 -0
  172. iac_code/utils/__init__.py +0 -0
  173. iac_code/utils/background_housekeeping.py +53 -0
  174. iac_code/utils/cleanup.py +68 -0
  175. iac_code/utils/json_utils.py +60 -0
  176. iac_code/utils/log.py +150 -0
  177. iac_code/utils/project_paths.py +74 -0
  178. iac_code/utils/tool_input_parser.py +62 -0
  179. iac_code-0.1.0.dist-info/LICENSE +201 -0
  180. iac_code-0.1.0.dist-info/METADATA +64 -0
  181. iac_code-0.1.0.dist-info/RECORD +184 -0
  182. iac_code-0.1.0.dist-info/WHEEL +5 -0
  183. iac_code-0.1.0.dist-info/entry_points.txt +2 -0
  184. 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