From 29fd5e56531bae4e9cfec93bdb0b60925eb8b381 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Wed, 8 Jul 2026 18:53:02 +0800 Subject: [PATCH] Add ros_deploy wait recovery action --- .../i18n/locales/de/LC_MESSAGES/messages.po | 15 ++ .../i18n/locales/es/LC_MESSAGES/messages.po | 15 ++ .../i18n/locales/fr/LC_MESSAGES/messages.po | 15 ++ .../i18n/locales/ja/LC_MESSAGES/messages.po | 15 ++ .../i18n/locales/pt/LC_MESSAGES/messages.po | 15 ++ .../i18n/locales/zh/LC_MESSAGES/messages.po | 15 ++ src/iac_code/pipeline/selling/pipeline.yaml | 2 +- .../pipeline/selling/prompts/deploying.md | 6 +- .../skills/iac-aliyun-deploying/SKILL.md | 11 +- .../pipeline/selling/tools/ros_deploy_tool.py | 99 +++++++++- src/iac_code/tools/cloud/base_stack.py | 12 +- .../engine/test_complete_step_tool.py | 51 ++++++ .../selling/test_terminal_ui_contract.py | 2 +- .../selling/tools/test_ros_deploy_tool.py | 169 +++++++++++++++++- 14 files changed, 426 insertions(+), 16 deletions(-) diff --git a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po index ff13e30..d094c83 100644 --- a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po @@ -3964,6 +3964,16 @@ msgstr "Erstellung von {name} fehlgeschlagen: {reason} ({stack_id})" msgid "{name} creation failed: {reason}" msgstr "Erstellung von {name} fehlgeschlagen: {reason}" +#: src/iac_code/pipeline/selling/tools/ros_deploy_tool.py +#, python-brace-format +msgid "Fields are not supported for action '{action}': {fields}" +msgstr "Felder werden für Aktion '{action}' nicht unterstützt: {fields}" + +#: src/iac_code/pipeline/selling/tools/ros_deploy_tool.py +#, python-brace-format +msgid "Missing required field(s) for action '{action}': {fields}" +msgstr "Erforderliche Felder für Aktion '{action}' fehlen: {fields}" + #: src/iac_code/pipeline/selling/tools/ros_deploy_tool.py #, python-brace-format msgid "Create ROS stack: {target}" @@ -3974,6 +3984,11 @@ msgstr "ROS-Stack erstellen: {target}" msgid "Continue ROS stack creation: {target}" msgstr "Erstellung des ROS-Stacks fortsetzen: {target}" +#: src/iac_code/pipeline/selling/tools/ros_deploy_tool.py +#, python-brace-format +msgid "Wait for ROS stack creation: {target}" +msgstr "Auf Erstellung des ROS-Stacks warten: {target}" + #: src/iac_code/pipeline/selling/tools/ros_deploy_tool.py #, python-brace-format msgid "Delete failed ROS stack and create replacement: {target}" diff --git a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po index db28320..be858d4 100644 --- a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po @@ -3949,6 +3949,16 @@ msgstr "Error al crear {name}: {reason} ({stack_id})" msgid "{name} creation failed: {reason}" msgstr "Error al crear {name}: {reason}" +#: src/iac_code/pipeline/selling/tools/ros_deploy_tool.py +#, python-brace-format +msgid "Fields are not supported for action '{action}': {fields}" +msgstr "Los campos no son compatibles con la acción '{action}': {fields}" + +#: src/iac_code/pipeline/selling/tools/ros_deploy_tool.py +#, python-brace-format +msgid "Missing required field(s) for action '{action}': {fields}" +msgstr "Faltan campos obligatorios para la acción '{action}': {fields}" + #: src/iac_code/pipeline/selling/tools/ros_deploy_tool.py #, python-brace-format msgid "Create ROS stack: {target}" @@ -3959,6 +3969,11 @@ msgstr "Crear pila ROS: {target}" msgid "Continue ROS stack creation: {target}" msgstr "Continuar la creación de la pila ROS: {target}" +#: src/iac_code/pipeline/selling/tools/ros_deploy_tool.py +#, python-brace-format +msgid "Wait for ROS stack creation: {target}" +msgstr "Esperar a que se cree la pila ROS: {target}" + #: src/iac_code/pipeline/selling/tools/ros_deploy_tool.py #, python-brace-format msgid "Delete failed ROS stack and create replacement: {target}" diff --git a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po index 31ec2a3..6f2a4ca 100644 --- a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po @@ -3959,6 +3959,16 @@ msgstr "Échec de la création de {name} : {reason} ({stack_id})" msgid "{name} creation failed: {reason}" msgstr "Échec de la création de {name} : {reason}" +#: src/iac_code/pipeline/selling/tools/ros_deploy_tool.py +#, python-brace-format +msgid "Fields are not supported for action '{action}': {fields}" +msgstr "Les champs ne sont pas pris en charge pour l'action '{action}' : {fields}" + +#: src/iac_code/pipeline/selling/tools/ros_deploy_tool.py +#, python-brace-format +msgid "Missing required field(s) for action '{action}': {fields}" +msgstr "Champs obligatoires manquants pour l'action '{action}' : {fields}" + #: src/iac_code/pipeline/selling/tools/ros_deploy_tool.py #, python-brace-format msgid "Create ROS stack: {target}" @@ -3969,6 +3979,11 @@ msgstr "Créer la pile ROS : {target}" msgid "Continue ROS stack creation: {target}" msgstr "Continuer la création de la pile ROS : {target}" +#: src/iac_code/pipeline/selling/tools/ros_deploy_tool.py +#, python-brace-format +msgid "Wait for ROS stack creation: {target}" +msgstr "Attendre la création de la pile ROS : {target}" + #: src/iac_code/pipeline/selling/tools/ros_deploy_tool.py #, python-brace-format msgid "Delete failed ROS stack and create replacement: {target}" diff --git a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po index dace584..f5a330b 100644 --- a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po @@ -3750,6 +3750,16 @@ msgstr "{name} の作成に失敗しました: {reason} ({stack_id})" msgid "{name} creation failed: {reason}" msgstr "{name} の作成に失敗しました: {reason}" +#: src/iac_code/pipeline/selling/tools/ros_deploy_tool.py +#, python-brace-format +msgid "Fields are not supported for action '{action}': {fields}" +msgstr "アクション '{action}' ではサポートされていないフィールドです: {fields}" + +#: src/iac_code/pipeline/selling/tools/ros_deploy_tool.py +#, python-brace-format +msgid "Missing required field(s) for action '{action}': {fields}" +msgstr "アクション '{action}' に必須フィールドがありません: {fields}" + #: src/iac_code/pipeline/selling/tools/ros_deploy_tool.py #, python-brace-format msgid "Create ROS stack: {target}" @@ -3760,6 +3770,11 @@ msgstr "ROS スタックを作成: {target}" msgid "Continue ROS stack creation: {target}" msgstr "ROS スタックの作成を続行: {target}" +#: src/iac_code/pipeline/selling/tools/ros_deploy_tool.py +#, python-brace-format +msgid "Wait for ROS stack creation: {target}" +msgstr "ROS スタックの作成を待機: {target}" + #: src/iac_code/pipeline/selling/tools/ros_deploy_tool.py #, python-brace-format msgid "Delete failed ROS stack and create replacement: {target}" diff --git a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po index 56df9bf..0eceb93 100644 --- a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po @@ -3922,6 +3922,16 @@ msgstr "Falha ao criar {name}: {reason} ({stack_id})" msgid "{name} creation failed: {reason}" msgstr "Falha ao criar {name}: {reason}" +#: src/iac_code/pipeline/selling/tools/ros_deploy_tool.py +#, python-brace-format +msgid "Fields are not supported for action '{action}': {fields}" +msgstr "Os campos não são compatíveis com a ação '{action}': {fields}" + +#: src/iac_code/pipeline/selling/tools/ros_deploy_tool.py +#, python-brace-format +msgid "Missing required field(s) for action '{action}': {fields}" +msgstr "Campos obrigatórios ausentes para a ação '{action}': {fields}" + #: src/iac_code/pipeline/selling/tools/ros_deploy_tool.py #, python-brace-format msgid "Create ROS stack: {target}" @@ -3932,6 +3942,11 @@ msgstr "Criar pilha ROS: {target}" msgid "Continue ROS stack creation: {target}" msgstr "Continuar a criação da pilha ROS: {target}" +#: src/iac_code/pipeline/selling/tools/ros_deploy_tool.py +#, python-brace-format +msgid "Wait for ROS stack creation: {target}" +msgstr "Aguardar a criação da pilha ROS: {target}" + #: src/iac_code/pipeline/selling/tools/ros_deploy_tool.py #, python-brace-format msgid "Delete failed ROS stack and create replacement: {target}" diff --git a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po index 5becf3b..e30180f 100644 --- a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po @@ -3699,6 +3699,16 @@ msgstr "{name} 创建失败:{reason} ({stack_id})" msgid "{name} creation failed: {reason}" msgstr "{name} 创建失败:{reason}" +#: src/iac_code/pipeline/selling/tools/ros_deploy_tool.py +#, python-brace-format +msgid "Fields are not supported for action '{action}': {fields}" +msgstr "操作 '{action}' 不支持这些字段:{fields}" + +#: src/iac_code/pipeline/selling/tools/ros_deploy_tool.py +#, python-brace-format +msgid "Missing required field(s) for action '{action}': {fields}" +msgstr "操作 '{action}' 缺少必填字段:{fields}" + #: src/iac_code/pipeline/selling/tools/ros_deploy_tool.py #, python-brace-format msgid "Create ROS stack: {target}" @@ -3709,6 +3719,11 @@ msgstr "创建 ROS 资源栈:{target}" msgid "Continue ROS stack creation: {target}" msgstr "继续创建 ROS 资源栈:{target}" +#: src/iac_code/pipeline/selling/tools/ros_deploy_tool.py +#, python-brace-format +msgid "Wait for ROS stack creation: {target}" +msgstr "等待 ROS 资源栈创建:{target}" + #: src/iac_code/pipeline/selling/tools/ros_deploy_tool.py #, python-brace-format msgid "Delete failed ROS stack and create replacement: {target}" diff --git a/src/iac_code/pipeline/selling/pipeline.yaml b/src/iac_code/pipeline/selling/pipeline.yaml index 4ab326b..fd6d7fc 100644 --- a/src/iac_code/pipeline/selling/pipeline.yaml +++ b/src/iac_code/pipeline/selling/pipeline.yaml @@ -502,7 +502,7 @@ steps: required_conclusion_field: stack_id require_tool_result: tool: ros_deploy - action_in: [create, continue_create, delete_and_create] + action_in: [create, continue_create, delete_and_create, wait] is_success: true status_in: [CREATE_COMPLETE] match_conclusion_field: stack_id diff --git a/src/iac_code/pipeline/selling/prompts/deploying.md b/src/iac_code/pipeline/selling/prompts/deploying.md index 383119f..6687841 100644 --- a/src/iac_code/pipeline/selling/prompts/deploying.md +++ b/src/iac_code/pipeline/selling/prompts/deploying.md @@ -5,7 +5,7 @@ ## 部署执行 用户已在上一步确认选择了该方案,该选择等价于本步骤的部署确认。不要再次询问是否确认部署,也不要询问是否确认部署参数。`selected_plan.preview_ready_for_create` 为 `true` 时按快速创建路径执行,快速创建路径见技能;否则按常规部署路径执行。 -本步骤的部署与失败恢复入口仅为 `ros_deploy`,不要绕过它调用原始 ROS 部署生命周期接口。删除约束和失败恢复策略见技能。 +本步骤的部署、等待与失败恢复入口仅为 `ros_deploy`,不要绕过它调用原始 ROS 部署生命周期接口。删除约束和失败恢复策略见技能;超时等待策略也见技能。 ## 原始用户需求与约束 部署时必须继续遵守原始用户需求中的地域、资源命名、StackName、是否复用已有资源等约束。如果这些约束与候选方案、模板文件名或默认参数冲突,以原始用户需求为准。 @@ -27,7 +27,7 @@ ## ROS 模板来源 本步骤已选定模板文件路径:`{selected_plan.template_url}`。 -需要调用 `ros_validate_template` 校验时,必须传 `template_url = "{selected_plan.template_url}"`。调用 `ros_deploy` 时,必须传 `template_url = "{selected_plan.template_url}"`。不要通过 `aliyun_api` 调用 ROS 模板校验或部署生命周期接口;不要传 `TemplateBody`、`TemplateId` 或 `TemplateScratchId`;不要省略 `template_url`。 +需要调用 `ros_validate_template` 校验时,必须传 `template_url = "{selected_plan.template_url}"`。调用 `ros_deploy` 的 `create` / `continue_create` / `delete_and_create` 时,必须传 `template_url = "{selected_plan.template_url}"`;调用 `ros_deploy` 的 `wait` 时不要传 `template_url`。不要通过 `aliyun_api` 调用 ROS 模板校验或部署生命周期接口;不要传 `TemplateBody`、`TemplateId` 或 `TemplateScratchId`;部署类动作不要省略 `template_url`。 ## 所有候选方案的评估数据 `selected_plan.selection_valid` 为 `true` 时,使用 `selected_plan.selected_candidate` 和 @@ -47,7 +47,7 @@ ## 错误处理 - 模板校验失败 → 就地修复模板后重试(最多 5 轮) -- 部署失败 → 按技能的 `ros_deploy` 恢复策略处理;只调整部署参数时由 `ros_deploy` 的部署调用做最终校验 +- 部署失败或等待超时 → 按技能的 `ros_deploy` 恢复策略处理;只调整部署参数时由 `ros_deploy` 的部署调用做最终校验 - 架构层面必须变更(如产品组合不可行)→ rollback_request 到 `architecture_planning` ## 注意事项 diff --git a/src/iac_code/pipeline/selling/skills/iac-aliyun-deploying/SKILL.md b/src/iac_code/pipeline/selling/skills/iac-aliyun-deploying/SKILL.md index 8403c40..f208237 100644 --- a/src/iac_code/pipeline/selling/skills/iac-aliyun-deploying/SKILL.md +++ b/src/iac_code/pipeline/selling/skills/iac-aliyun-deploying/SKILL.md @@ -68,6 +68,7 @@ conclusion_schema: - `selected_plan.preview_ready_for_create` 为 `true` 时,表示成本步骤已对同一模板路径完成预览验证,且没有完整部署参数缺口;部署时直接调用 `ros_deploy` 的 `create`,跳过例行 `ros_validate_template`,并跳过例行可用性查询。用户覆盖后的最终部署参数由 `ros_deploy` 的部署调用做最终校验。 - 否则,部署前必须校验模板文件。调用 `ros_validate_template` 校验,`template_url` 使用当前步骤 prompt 中已选定的具体模板文件路径;已有具体地域时传 `region_id`,否则使用工具默认地域。不要通过 `aliyun_api` 调用 ROS 模板校验或部署生命周期接口。校验失败时分析错误原因,查 GetResourceType Schema(如需),修复模板文件后重试(最多 5 轮)。模板文件会被后续步骤依赖,必须确保其内容正确后再继续。 - `ros_deploy` 的 `create` 失败后,如果需要修改模板,成本步骤的预览验证已失效;修复后必须重新调用 `ros_validate_template`,通过后再调用 `ros_deploy` 的 `continue_create`。只调整部署参数时,不需要为了参数变化补跑 `ros_validate_template`;最终参数由 `ros_deploy` 的部署调用校验。 +- `ros_deploy` 的 `create` / `continue_create` / `delete_and_create` 已经发起 ROS 操作但工具调用超时或中断时,不要再次调用创建类动作。使用同一 `stack_id` 调用 `ros_deploy` 的 `wait`,它只轮询已有 Stack 的创建进度,不会调用 CreateStack 或 ContinueCreateStack。 ## 可用性查询 @@ -78,6 +79,7 @@ conclusion_schema: | ros_deploy create | 全量查询所有库存相关 Parameters | | ros_deploy continue_create | 查询失败资源相关的 Parameters | | ros_deploy delete_and_create | 按替代创建参数全量查询库存相关 Parameters | +| ros_deploy wait | 不查询库存;仅等待已发起创建的 Stack 达到终态 | 查询步骤: 1. 解析模板 Parameters,识别库存相关参数及对应产品 @@ -104,19 +106,22 @@ conclusion_schema: - `ros_deploy` 的 `create` 必须传 `stack_name`,不要省略,不要使用容易重复的固定名称。 - `ros_deploy` 的 `continue_create` 面向已有失败 Stack 时,使用 `create` 失败结果中的 Stack 标识,不要生成新的 StackName。 - `ros_deploy` 的 `delete_and_create` 面向已有失败 Stack 时,`stack_id` 使用旧失败 Stack 标识;`stack_name` 使用替代创建目标的名称。 +- `ros_deploy` 的 `wait` 面向已有创建中 Stack 时,只传 `stack_id` 和 `region_id`;不要传 `template_url`、`parameters`,不要生成新的 StackName。 ## 执行部署 -- 使用 `ros_deploy` 工具执行 `create` / `continue_create` / `delete_and_create`,禁止用 Bash +- 使用 `ros_deploy` 工具执行 `create` / `continue_create` / `delete_and_create` / `wait`,禁止用 Bash - `ros_deploy` 的 `create` 会使用 `DisableRollback: true` -- `ros_deploy` 使用装配后的 `parameters` 字典;不要手动展开为 `Parameters.N.ParameterKey` +- `ros_deploy` 的 `wait` 只等待已有 Stack 创建完成,不发起创建、继续创建、删除或更新 +- `ros_deploy` 的创建类动作使用装配后的 `parameters` 字典;不要手动展开为 `Parameters.N.ParameterKey` -> **template_url 支持本地文件路径**:`ros_deploy` 中 `template_url` 可传本地文件路径(如 `/tmp/template.yml`),工具会自动读取文件内容。避免将大模板内容直接作为参数传递。 +> **template_url 支持本地文件路径**:`ros_deploy` 的创建类动作中,`template_url` 可传本地文件路径(如 `/tmp/template.yml`),工具会自动读取文件内容。避免将大模板内容直接作为参数传递。 ## 错误处理 ### 部署失败 分析错误原因: +- 工具调用超时但已有 `stack_id`,且 Stack 仍在创建 → 调用 `ros_deploy` 的 `wait` - 权限/配额 → 告知用户处理 - 模板/参数 → 修复后调用 `ros_deploy` 的 `continue_create` - `continue_create` 返回 `ContinueCreateStackValidationFailed` → 告知用户需要重建本步骤创建的失败 Stack,再调用 `ros_deploy` 的 `delete_and_create` diff --git a/src/iac_code/pipeline/selling/tools/ros_deploy_tool.py b/src/iac_code/pipeline/selling/tools/ros_deploy_tool.py index 84e951b..f2dc6c1 100644 --- a/src/iac_code/pipeline/selling/tools/ros_deploy_tool.py +++ b/src/iac_code/pipeline/selling/tools/ros_deploy_tool.py @@ -23,7 +23,19 @@ ) _OWNED_STACKS_KEY = "ros_deploy_owned_stack_ids" -_ACTIONS = ("create", "continue_create", "delete_and_create") +_ACTIONS = ("create", "continue_create", "delete_and_create", "wait") +_ACTION_ALLOWED_FIELDS = { + "create": frozenset(("action", "stack_name", "template_url", "parameters", "region_id")), + "continue_create": frozenset(("action", "stack_id", "template_url", "parameters", "region_id")), + "delete_and_create": frozenset(("action", "stack_id", "stack_name", "template_url", "parameters", "region_id")), + "wait": frozenset(("action", "stack_id", "region_id")), +} +_ACTION_REQUIRED_FIELDS = { + "create": ("stack_name", "template_url"), + "continue_create": ("stack_id", "template_url"), + "delete_and_create": ("stack_id", "stack_name", "template_url"), + "wait": ("stack_id",), +} _SAFE_RULE_SEGMENT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,255}$") _ROS_ERROR_CODE_RE = re.compile(r"\bcode:\s*([A-Za-z0-9_.-]+)") _WHITESPACE_RE = re.compile(r"\s+") @@ -99,11 +111,16 @@ def __init__(self, completion_guard_state: dict[str, Any] | None = None) -> None def name(self) -> str: return "ros_deploy" + @property + def timeout(self) -> float | None: + return 3600.0 + @property def description(self) -> str: return ( "Deploy a ROS template in the selling pipeline. Use create for the initial stack, continue_create for " - "failed stacks created by this step, and delete_and_create only after ContinueCreateStackValidationFailed." + "failed stacks created by this step, delete_and_create only after ContinueCreateStackValidationFailed, " + "and wait to resume polling an already-started stack creation." ) @property @@ -122,7 +139,7 @@ def input_schema(self) -> dict[str, Any]: }, "stack_id": { "type": "string", - "description": "Failed stack ID for continue_create or delete_and_create.", + "description": "Stack ID for wait, or failed stack ID for continue_create/delete_and_create.", }, "template_url": { "type": "string", @@ -146,6 +163,9 @@ def input_schema(self) -> dict[str, Any]: def supports_blanket_allow(self) -> bool: return False + def is_read_only(self, input: dict | None = None) -> bool: + return isinstance(input, dict) and input.get("action") == "wait" + def user_facing_name(self, input: dict | None = None) -> str: return _("ROS Deploy") @@ -212,11 +232,41 @@ def is_concurrency_safe(self, tool_input: dict[str, Any]) -> bool: return False def is_destructive(self, input: dict | None = None) -> bool: - return True + return not self.is_read_only(input) def _new_stack_tool(self) -> RosStack: return RosStack(allow_pipeline_deployment_actions=True) + def validate_input(self, tool_input: dict[str, Any]) -> tuple[bool, str]: + valid, error = super().validate_input(tool_input) + if not valid: + return valid, error + if action_error := self._action_input_error(tool_input): + return False, action_error + return True, "" + + @staticmethod + def _action_input_error(input: dict[str, Any]) -> str | None: + action = _string_value(input.get("action")) + if action not in _ACTIONS: + return None + + unsupported_fields = sorted(set(input) - _ACTION_ALLOWED_FIELDS[action]) + if unsupported_fields: + return _("Fields are not supported for action '{action}': {fields}").format( + action=action, + fields=", ".join(unsupported_fields), + ) + + missing_fields = [field for field in _ACTION_REQUIRED_FIELDS[action] if not _string_value(input.get(field))] + if missing_fields: + return _("Missing required field(s) for action '{action}': {fields}").format( + action=action, + fields=", ".join(missing_fields), + ) + + return None + def _owned_stacks(self) -> dict[str, Any]: stacks = self._completion_guard_state.setdefault(_OWNED_STACKS_KEY, {}) if not isinstance(stacks, dict): @@ -236,7 +286,7 @@ def _rule_target(self, input: dict) -> str: action = _string_value(input.get("action")) if action == "create": return _string_value(input.get("stack_name")) - if action in {"continue_create", "delete_and_create"}: + if action in {"continue_create", "delete_and_create", "wait"}: return _string_value(input.get("stack_id")) return "" @@ -256,6 +306,8 @@ def _rule_display_text(self, input: dict) -> str | None: return _("Create ROS stack: {target}").format(target=target) if action == "continue_create": return _("Continue ROS stack creation: {target}").format(target=target) + if action == "wait": + return _("Wait for ROS stack creation: {target}").format(target=target) return _("Delete failed ROS stack and create replacement: {target}").format(target=target) def _operation_metadata(self, input: dict) -> dict[str, object]: @@ -287,7 +339,7 @@ def _audit( rule=rule, reason_type=reason.type if reason else None, reason_detail=reason.detail if reason else None, - is_read_only=False, + is_read_only=self.is_read_only(input), operation=self._operation_metadata(input), ) @@ -401,6 +453,9 @@ async def check_permissions(self, input: dict, context=None) -> PermissionResult ), ) + if self.is_read_only(input): + return PermissionResult(behavior="allow", audit=self._audit(input, scope="once")) + if template_permission := self._local_template_url_permission_error(input, context): return template_permission @@ -526,6 +581,15 @@ def _delete_stack_input(self, input: dict) -> ToolResult | dict[str, Any]: tool_input["region_id"] = region_id return tool_input + def _wait_stack_input(self, input: dict) -> ToolResult | dict[str, Any]: + stack_id = _string_value(input.get("stack_id")) + if error := self._require(stack_id, "stack_id"): + return error + tool_input: dict[str, Any] = {"action": "CreateStack", "params": {"StackId": stack_id}, "stack_id": stack_id} + if region_id := _string_value(input.get("region_id")): + tool_input["region_id"] = region_id + return tool_input + @staticmethod def _preflight_local_template(input: dict, context: ToolContext) -> ToolResult | None: template_url = _string_value(input.get("template_url")) @@ -575,10 +639,25 @@ def _continue_validation_failure_result(stack_id: str, content: str) -> ToolResu async def _call_stack(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: return await self._new_stack_tool().execute(tool_input=tool_input, context=context) + async def _wait_stack(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + stack = self._new_stack_tool() + region = _string_value(tool_input.get("region_id")) + if not region: + region = stack._resolve_region(tool_input) + return await stack.wait_for_stack_operation( + tool_input["action"], + tool_input["params"], + region, + tool_input["stack_id"], + context, + ) + async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: action = _string_value(tool_input.get("action")) if action not in _ACTIONS: return ToolResult.error(_("Invalid action '{}'. Supported actions: {}").format(action, list(_ACTIONS))) + if action_error := self._action_input_error(tool_input): + return ToolResult.error(action_error) if action == "create": create_input = self._create_stack_input(tool_input, context) @@ -600,6 +679,14 @@ async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> self._record_result_stack(result, action="continue_create") return result + if action == "wait": + wait_input = self._wait_stack_input(tool_input) + if isinstance(wait_input, ToolResult): + return wait_input + result = await self._wait_stack(wait_input, context) + self._record_result_stack(result, action="wait") + return result + create_input = self._create_stack_input(tool_input, context) if isinstance(create_input, ToolResult): return create_input diff --git a/src/iac_code/tools/cloud/base_stack.py b/src/iac_code/tools/cloud/base_stack.py index f33587a..86272d6 100644 --- a/src/iac_code/tools/cloud/base_stack.py +++ b/src/iac_code/tools/cloud/base_stack.py @@ -266,8 +266,18 @@ async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> ) ) - start_time = time.monotonic() + return await self.wait_for_stack_operation(action, params, region, stack_id, context) + async def wait_for_stack_operation( + self, + action: str, + params: dict, + region: str, + stack_id: str, + context: ToolContext, + ) -> ToolResult: + """Poll an already-started stack operation until it reaches a terminal state.""" + start_time = time.monotonic() try: while True: await asyncio.sleep(self._poll_interval) diff --git a/tests/pipeline/engine/test_complete_step_tool.py b/tests/pipeline/engine/test_complete_step_tool.py index 02f7009..995b890 100644 --- a/tests/pipeline/engine/test_complete_step_tool.py +++ b/tests/pipeline/engine/test_complete_step_tool.py @@ -503,6 +503,57 @@ async def test_required_tool_result_accepts_matching_continue_create_stack_succe assert not result.is_error + @pytest.mark.asyncio + async def test_required_tool_result_accepts_matching_ros_deploy_wait_success(self): + config = StepConfig( + step_id="deploying", + conclusion_field="deployment", + forward=None, + conclusion_schema={ + "type": "object", + "required": ["status", "stack_id"], + "properties": { + "stack_id": {"type": "string"}, + "status": {"type": "string", "enum": ["success", "failed", "cancelled"]}, + }, + }, + ) + tool = CompleteStepTool( + config, + completion_guards=[ + { + "when_conclusion_field_equals": {"status": "success"}, + "required_conclusion_field": "stack_id", + "require_tool_result": { + "tool": "ros_deploy", + "action_in": ["create", "continue_create", "delete_and_create", "wait"], + "is_success": True, + "status_in": ["CREATE_COMPLETE"], + "match_conclusion_field": "stack_id", + }, + } + ], + completion_guard_state={ + "successful_tools": {"ros_deploy"}, + "tool_results": {}, + "tool_result_records": [ + { + "tool_name": "ros_deploy", + "input": {"action": "wait", "stack_id": "stack-123"}, + "result": {"stack_id": "stack-123", "status": "CREATE_COMPLETE", "is_success": True}, + "is_error": False, + } + ], + }, + ) + + result = await tool.execute( + tool_input={"conclusion": {"status": "success", "stack_id": "stack-123"}}, + context=ToolContext(), + ) + + assert not result.is_error + @pytest.mark.asyncio async def test_required_tool_result_rejects_non_matching_stack_action(self): tool = self._deploying_tool( diff --git a/tests/pipeline/selling/test_terminal_ui_contract.py b/tests/pipeline/selling/test_terminal_ui_contract.py index 18f9673..e907891 100644 --- a/tests/pipeline/selling/test_terminal_ui_contract.py +++ b/tests/pipeline/selling/test_terminal_ui_contract.py @@ -176,7 +176,7 @@ def test_deploying_success_requires_create_stack_complete_guard(): assert guard["required_conclusion_field"] == "stack_id" assert guard["require_tool_result"] == { "tool": "ros_deploy", - "action_in": ["create", "continue_create", "delete_and_create"], + "action_in": ["create", "continue_create", "delete_and_create", "wait"], "is_success": True, "status_in": ["CREATE_COMPLETE"], "match_conclusion_field": "stack_id", diff --git a/tests/pipeline/selling/tools/test_ros_deploy_tool.py b/tests/pipeline/selling/tools/test_ros_deploy_tool.py index 7ca95c9..7b8a2b3 100644 --- a/tests/pipeline/selling/tools/test_ros_deploy_tool.py +++ b/tests/pipeline/selling/tools/test_ros_deploy_tool.py @@ -7,10 +7,11 @@ from iac_code.types.permissions import PermissionMode, ToolPermissionContext -def _permission_ctx(*, allow=None, mode=PermissionMode.DEFAULT): +def _permission_ctx(*, allow=None, deny=None, mode=PermissionMode.DEFAULT): return ToolPermissionContext( cwd="/tmp", allow_rules=allow or {}, + deny_rules=deny or {}, mode=mode, ) @@ -19,6 +20,7 @@ class FakeRosStack: def __init__(self, results): self.results = list(results) self.calls = [] + self.wait_calls = [] async def execute(self, *, tool_input, context): self.calls.append((tool_input, context)) @@ -26,6 +28,12 @@ async def execute(self, *, tool_input, context): raise AssertionError("unexpected ros stack call") return self.results.pop(0) + async def wait_for_stack_operation(self, action, params, region, stack_id, context): + self.wait_calls.append((action, params, region, stack_id, context)) + if not self.results: + raise AssertionError("unexpected ros stack wait call") + return self.results.pop(0) + def _deploy_tool(monkeypatch, *, guard_state=None, results=None): from iac_code.pipeline.selling.tools.ros_deploy_tool import RosDeployTool @@ -112,6 +120,78 @@ def test_internal_ros_stack_allows_deployment_guard_without_clearing_pipeline_mo assert kwargs["allow_pipeline_deployment_actions"] is True +def test_ros_deploy_uses_long_running_stack_timeout(): + from iac_code.pipeline.selling.tools.ros_deploy_tool import RosDeployTool + + assert RosDeployTool().timeout == 3600.0 + + +@pytest.mark.parametrize( + ("tool_input", "expected_fields"), + [ + ( + { + "action": "wait", + "stack_id": "stack-slow", + "stack_name": "demo", + "template_url": "templates/demo.yml", + "parameters": {"ZoneId": "cn-hangzhou-k"}, + }, + ("parameters", "stack_name", "template_url"), + ), + ( + { + "action": "create", + "stack_id": "stack-existing", + "stack_name": "demo", + "template_url": "templates/demo.yml", + }, + ("stack_id",), + ), + ( + { + "action": "continue_create", + "stack_id": "stack-failed", + "stack_name": "demo", + "template_url": "templates/demo.yml", + }, + ("stack_name",), + ), + ], +) +def test_ros_deploy_validate_input_rejects_fields_not_used_by_action(tool_input, expected_fields): + from iac_code.pipeline.selling.tools.ros_deploy_tool import RosDeployTool + + valid, error = RosDeployTool().validate_input(tool_input) + + assert valid is False + assert "not supported for action" in error + for field in expected_fields: + assert field in error + + +@pytest.mark.parametrize( + ("tool_input", "expected_fields"), + [ + ({"action": "create", "template_url": "templates/demo.yml"}, ("stack_name",)), + ({"action": "create", "stack_name": "demo"}, ("template_url",)), + ({"action": "continue_create", "template_url": "templates/demo.yml"}, ("stack_id",)), + ({"action": "continue_create", "stack_id": "stack-failed"}, ("template_url",)), + ({"action": "delete_and_create", "stack_name": "demo", "template_url": "templates/demo.yml"}, ("stack_id",)), + ({"action": "wait"}, ("stack_id",)), + ], +) +def test_ros_deploy_validate_input_requires_action_fields(tool_input, expected_fields): + from iac_code.pipeline.selling.tools.ros_deploy_tool import RosDeployTool + + valid, error = RosDeployTool().validate_input(tool_input) + + assert valid is False + assert "Missing required field" in error + for field in expected_fields: + assert field in error + + @pytest.mark.asyncio async def test_create_records_started_stack_as_owned_when_polling_fails(monkeypatch): guard_state = {} @@ -198,6 +278,67 @@ async def test_continue_create_defaults_to_recreate_with_auto_recreating_resourc } +@pytest.mark.asyncio +async def test_wait_action_polls_existing_stack_without_starting_lifecycle_action(monkeypatch): + tool, fake_stack = _deploy_tool( + monkeypatch, + results=[ + ToolResult.success( + json.dumps( + { + "stack_id": "stack-slow", + "stack_name": "demo", + "status": "CREATE_COMPLETE", + "is_success": True, + } + ) + ) + ], + ) + + result = await tool.execute( + tool_input={ + "action": "wait", + "stack_id": "stack-slow", + "region_id": "cn-hangzhou", + }, + context=ToolContext(cwd="/workspace", pipeline_mode=True), + ) + + assert result.is_error is False + assert fake_stack.calls == [] + assert fake_stack.wait_calls[0][:4] == ( + "CreateStack", + {"StackId": "stack-slow"}, + "cn-hangzhou", + "stack-slow", + ) + + +@pytest.mark.asyncio +async def test_wait_action_rejects_create_only_fields_without_polling(monkeypatch): + tool, fake_stack = _deploy_tool(monkeypatch, results=[]) + + result = await tool.execute( + tool_input={ + "action": "wait", + "stack_id": "stack-slow", + "stack_name": "demo", + "template_url": "templates/demo.yml", + "parameters": {"ZoneId": "cn-hangzhou-k"}, + }, + context=ToolContext(cwd="/workspace", pipeline_mode=True), + ) + + assert result.is_error is True + assert "not supported for action" in result.content + assert "parameters" in result.content + assert "stack_name" in result.content + assert "template_url" in result.content + assert fake_stack.calls == [] + assert fake_stack.wait_calls == [] + + @pytest.mark.asyncio async def test_continue_validation_failure_recommends_delete_and_create_without_running_it(monkeypatch): guard_state = {"ros_deploy_owned_stack_ids": {"stack-failed": {"action": "create"}}} @@ -385,6 +526,32 @@ async def test_permission_allows_matching_continue_create_stack_rule(monkeypatch assert result.behavior == "allow" +@pytest.mark.asyncio +async def test_permission_allows_wait_without_stack_ownership(monkeypatch): + tool, _fake_stack = _deploy_tool(monkeypatch, guard_state={}) + + result = await tool.check_permissions( + {"action": "wait", "stack_id": "stack-from-progress"}, + _permission_ctx(), + ) + + assert result.behavior == "allow" + assert result.audit is not None + assert result.audit.is_read_only is True + + +@pytest.mark.asyncio +async def test_permission_deny_rule_still_blocks_wait(monkeypatch): + tool, _fake_stack = _deploy_tool(monkeypatch, guard_state={}) + + result = await tool.check_permissions( + {"action": "wait", "stack_id": "stack-from-progress"}, + _permission_ctx(deny={"session": ["ros_deploy(wait:stack-from-progress)"]}), + ) + + assert result.behavior == "deny" + + @pytest.mark.asyncio async def test_permission_asks_before_reading_local_template_url_outside_allowed_roots_even_with_allow_rule( monkeypatch,