From aa37c259ada84a8460ae4b59bf33e4d4d65f0ff7 Mon Sep 17 00:00:00 2001 From: Henrik Jess Nielsen Date: Tue, 19 May 2026 10:28:22 +0200 Subject: [PATCH] Roles --- ilsp/bicep_lsp/modules.py | 119 ++++++++++++++++++++++++++++++++++++++ ilsp/bicep_lsp/proxy.py | 12 ++++ tests/test_proxy.py | 51 +++++++++++++++- 3 files changed, 179 insertions(+), 3 deletions(-) diff --git a/ilsp/bicep_lsp/modules.py b/ilsp/bicep_lsp/modules.py index a98841f..43766ef 100644 --- a/ilsp/bicep_lsp/modules.py +++ b/ilsp/bicep_lsp/modules.py @@ -20,6 +20,75 @@ logger = logging.getLogger(__name__) # Known Azure enum values not always captured in the catalog schema _KNOWN_ENUMS: dict[str, list[str]] = { "principalType": ["User", "Group", "ServicePrincipal", "Device", "ForeignGroup"], + "roles": [ + # Key Vault roles + "KEY_VAULT_ADMINISTRATOR", + "KEY_VAULT_CERTIFICATES_OFFICER", + "KEY_VAULT_CRYPTO_OFFICER", + "KEY_VAULT_CRYPTO_SERVICE_ENCRYPTION_USER", + "KEY_VAULT_CRYPTO_USER", + "KEY_VAULT_READER", + "KEY_VAULT_SECRETS_OFFICER", + "KEY_VAULT_SECRETS_USER", + # Storage roles + "STORAGE_BLOB_DATA_CONTRIBUTOR", + "STORAGE_BLOB_DATA_OWNER", + "STORAGE_BLOB_DATA_READER", + "STORAGE_QUEUE_DATA_CONTRIBUTOR", + "STORAGE_QUEUE_DATA_READER", + "STORAGE_TABLE_DATA_CONTRIBUTOR", + "STORAGE_TABLE_DATA_READER", + # Common Azure roles + "CONTRIBUTOR", + "OWNER", + "READER", + "USER_ACCESS_ADMINISTRATOR", + # App/Function roles + "WEBSITE_CONTRIBUTOR", + # Monitoring roles + "MONITORING_CONTRIBUTOR", + "MONITORING_METRICS_PUBLISHER", + "MONITORING_READER", + "LOG_ANALYTICS_CONTRIBUTOR", + "LOG_ANALYTICS_READER", + # SQL roles + "SQL_DB_CONTRIBUTOR", + "SQL_MANAGED_INSTANCE_CONTRIBUTOR", + "SQL_SECURITY_MANAGER", + "SQL_SERVER_CONTRIBUTOR", + ], + "roleDefinitionIds": [ + # Same list for roleDefinitionIds parameter + "KEY_VAULT_ADMINISTRATOR", + "KEY_VAULT_CERTIFICATES_OFFICER", + "KEY_VAULT_CRYPTO_OFFICER", + "KEY_VAULT_CRYPTO_SERVICE_ENCRYPTION_USER", + "KEY_VAULT_CRYPTO_USER", + "KEY_VAULT_READER", + "KEY_VAULT_SECRETS_OFFICER", + "KEY_VAULT_SECRETS_USER", + "STORAGE_BLOB_DATA_CONTRIBUTOR", + "STORAGE_BLOB_DATA_OWNER", + "STORAGE_BLOB_DATA_READER", + "STORAGE_QUEUE_DATA_CONTRIBUTOR", + "STORAGE_QUEUE_DATA_READER", + "STORAGE_TABLE_DATA_CONTRIBUTOR", + "STORAGE_TABLE_DATA_READER", + "CONTRIBUTOR", + "OWNER", + "READER", + "USER_ACCESS_ADMINISTRATOR", + "WEBSITE_CONTRIBUTOR", + "MONITORING_CONTRIBUTOR", + "MONITORING_METRICS_PUBLISHER", + "MONITORING_READER", + "LOG_ANALYTICS_CONTRIBUTOR", + "LOG_ANALYTICS_READER", + "SQL_DB_CONTRIBUTOR", + "SQL_MANAGED_INSTANCE_CONTRIBUTOR", + "SQL_SECURITY_MANAGER", + "SQL_SERVER_CONTRIBUTOR", + ], } # Catalog is baked into the image root at /bicep_modules_catalog.json @@ -268,6 +337,56 @@ class BicepModuleCatalog: iac_params = cls._iac_param_map(module_name) items = [] + + # Build snippet for full params block (shown first) + snippet_params = [] + tabstop = 1 + for param_name, param_info in ver_params.items(): + iac = iac_params.get(param_name, {}) + required = iac.get("required", False) + ptype = param_info.get("type", "any") + allowed = param_info.get("allowed", []) + + # Include required params + first few optional params in snippet + if required or len(snippet_params) < 5: + if allowed: + # Enum: use placeholder with first allowed value + placeholder = f"'{allowed[0]}'" + elif ptype == "bool": + placeholder = "true" + elif ptype == "int": + placeholder = "0" + elif ptype == "array": + placeholder = "[]" + elif ptype == "object": + placeholder = "{{}}" + else: + placeholder = "''" + snippet_params.append(f" {param_name}: ${{{tabstop}:{placeholder}}}") + tabstop += 1 + + if snippet_params: + snippet_text = "\n" + "\n".join(snippet_params) + "\n" + required_count = sum(1 for p, i in ver_params.items() + if iac_params.get(p, {}).get("required", False)) + items.append({ + "label": "⚡ Fill params block", + "kind": 15, # Snippet + "detail": f"{len(snippet_params)} params ({required_count} required)", + "insertText": snippet_text, + "insertTextFormat": 2, # Snippet + "sortText": "0_lru_snippet_000", + "documentation": { + "kind": "markdown", + "value": ( + f"**Fill params block**\n\n" + f"Inserts {len(snippet_params)} params for `{module_name}`.\n" + f"Use Tab to navigate between fields." + ), + }, + }) + + # Individual param completions for param_name, param_info in ver_params.items(): ptype = param_info.get("type", "any") allowed = param_info.get("allowed", []) diff --git a/ilsp/bicep_lsp/proxy.py b/ilsp/bicep_lsp/proxy.py index ec2fa64..aa19181 100644 --- a/ilsp/bicep_lsp/proxy.py +++ b/ilsp/bicep_lsp/proxy.py @@ -142,6 +142,18 @@ class _ProxySession: "has_open_quote": bool(value_m.group(2)), } + # Check if cursor is inside an array value for a param + # e.g. "roles: ['KEY_VAULT_" or "roles: [ '" + array_m = re.search(r"^\s*(\w+):\s*\[[^\]]*?('?)([^',\]]*)$", current) + if array_m and array_m.group(1) not in {"params", "name", "module", "resource"}: + return { + "type": "param_value", + "module": mod_name, + "version": mod_ver, + "param": array_m.group(1), + "has_open_quote": bool(array_m.group(2)), + } + return { "type": "param", "module": mod_name, diff --git a/tests/test_proxy.py b/tests/test_proxy.py index b68c056..a1d762b 100644 --- a/tests/test_proxy.py +++ b/tests/test_proxy.py @@ -173,8 +173,12 @@ def test_param_completions_injected_on_param_context(): assert "roleDefinitionIds" in labels assert "principalId" in labels assert "principalType" in labels - assert items[0]["sortText"].startswith("0_lru_param_") - assert items[0]["kind"] == 5 # Field + # First item is now the snippet completion + assert items[0]["label"] == "⚡ Fill params block" + assert items[0]["kind"] == 15 # Snippet + # Second item should be a regular param + assert items[1]["sortText"].startswith("0_lru_param_") + assert items[1]["kind"] == 5 # Field def test_param_completion_items_have_insert_text(): @@ -182,7 +186,12 @@ def test_param_completion_items_have_insert_text(): "roleassignments", versions=["1.1.x"], schema=_ROLEASSIGNMENTS_SCHEMA )] items = BicepModuleCatalog.param_completion_items("roleassignments", "1.1.x") - for item in items: + # First item should be the snippet completion + assert items[0]["label"] == "⚡ Fill params block" + assert items[0]["insertTextFormat"] == 2 + assert "principalId" in items[0]["insertText"] + # Individual param items should have ": " suffix + for item in items[1:]: assert item["insertText"].endswith(": ") @@ -302,6 +311,24 @@ def test_detect_param_value_context_open_quote(): assert ctx["has_open_quote"] is True +def test_detect_param_value_context_in_array(): + """Cursor inside array value → param_value context.""" + lines = [ + "module myMod 'br/modules:roleassignments:1.1.x' = {", + " params: {", + " roles: ['KEY_VAULT_", # ← cursor inside array element + " }", + "}", + ] + session = _make_session_with_doc(URI, lines) + # character = len(" roles: ['KEY_VAULT_") = 23 + ctx = session._detect_context(URI, {"line": 2, "character": 23}) + assert ctx["type"] == "param_value" + assert ctx["module"] == "roleassignments" + assert ctx["param"] == "roles" + assert ctx["has_open_quote"] is True + + def test_param_value_items_from_catalog_allowed(): """environmentType completions come from catalog 'allowed' field.""" BicepModuleCatalog._modules = [_make_module( @@ -353,6 +380,24 @@ def test_param_value_items_known_enum_fallback(): assert "User" in labels +def test_param_value_items_roles_enum(): + """roles parameter uses _KNOWN_ENUMS for Azure role completions.""" + BicepModuleCatalog._modules = [_make_module( + "roleassignments", + versions=["1.1.x"], + schema={"1.1.x": {"parameters": { + "roles": {"type": "array"}, # no 'allowed' in catalog + }}}, + )] + items = BicepModuleCatalog.param_value_completion_items( + "roleassignments", "1.1.x", "roles" + ) + labels = [i["label"] for i in items] + assert "KEY_VAULT_SECRETS_USER" in labels + assert "STORAGE_BLOB_DATA_READER" in labels + assert "CONTRIBUTOR" in labels + + def test_param_value_items_empty_for_free_string(): """A plain string param with no allowed values returns no completions.""" BicepModuleCatalog._modules = [_make_module(