diff --git a/.chronus/changes/arm-resource-required-operations-2026-5-5-2-15-0.md b/.chronus/changes/arm-resource-required-operations-2026-5-5-2-15-0.md new file mode 100644 index 0000000000..6980f256b2 --- /dev/null +++ b/.chronus/changes/arm-resource-required-operations-2026-5-5-2-15-0.md @@ -0,0 +1,8 @@ +--- +changeKind: feature +packages: + - "@azure-tools/typespec-azure-resource-manager" + - "@azure-tools/typespec-azure-rulesets" +--- + +Add new `arm-resource-required-operations` linting rule that ensures every ARM resource declares the complete set of required lifecycle and list operations (singleton-aware; complements`no-resource-delete-operation`). diff --git a/packages/samples/specs/resource-manager/legacy/optional-body/main.tsp b/packages/samples/specs/resource-manager/legacy/optional-body/main.tsp index cecd27f2d7..0c3a13a2cc 100644 --- a/packages/samples/specs/resource-manager/legacy/optional-body/main.tsp +++ b/packages/samples/specs/resource-manager/legacy/optional-body/main.tsp @@ -43,4 +43,5 @@ interface Widgets { >; delete is ArmResourceDeleteWithoutOkAsync; list is ArmResourceListByParent; + listBySubscription is ArmListBySubscription; } diff --git a/packages/samples/specs/resource-manager/legacy/rename-operations/main.tsp b/packages/samples/specs/resource-manager/legacy/rename-operations/main.tsp index 98c0d272ba..4081084f76 100644 --- a/packages/samples/specs/resource-manager/legacy/rename-operations/main.tsp +++ b/packages/samples/specs/resource-manager/legacy/rename-operations/main.tsp @@ -56,4 +56,5 @@ interface Widgets { @Azure.ResourceManager.Legacy.renamePathParameter("widgetName", "resourceGroupName") delete is ArmResourceDeleteWithoutOkAsync; list is ArmResourceListByParent; + listBySubscription is ArmListBySubscription; } diff --git a/packages/samples/specs/resource-manager/legacy/static-routes/main.tsp b/packages/samples/specs/resource-manager/legacy/static-routes/main.tsp index 1ee47938c4..e4c12222e3 100644 --- a/packages/samples/specs/resource-manager/legacy/static-routes/main.tsp +++ b/packages/samples/specs/resource-manager/legacy/static-routes/main.tsp @@ -117,6 +117,7 @@ alias BaseParams2 = { }; /** Subscription operations */ +#suppress "@azure-tools/typespec-azure-resource-manager/arm-resource-required-operations" "Legacy static-routes sample uses custom operation templates not recognized by the standard required-operations check." @armResourceOperations(#{ allowStaticRoutes: true }) interface EmployeeSubscriptions { get is EmplOps.Read; diff --git a/packages/samples/test/output/azure/resource-manager/legacy/optional-body/@azure-tools/typespec-autorest/2025-01-01-preview/openapi.json b/packages/samples/test/output/azure/resource-manager/legacy/optional-body/@azure-tools/typespec-autorest/2025-01-01-preview/openapi.json index 1f44d2e741..a0ed566e32 100644 --- a/packages/samples/test/output/azure/resource-manager/legacy/optional-body/@azure-tools/typespec-autorest/2025-01-01-preview/openapi.json +++ b/packages/samples/test/output/azure/resource-manager/legacy/optional-body/@azure-tools/typespec-autorest/2025-01-01-preview/openapi.json @@ -77,6 +77,40 @@ } } }, + "/subscriptions/{subscriptionId}/providers/Microsoft.OptionalBody/widgets": { + "get": { + "operationId": "Widgets_ListBySubscription", + "tags": [ + "Widgets" + ], + "description": "List Widget resources by subscription ID", + "parameters": [ + { + "$ref": "../../../../../../../../../specs/resource-manager/common-types/v5/types.json#/parameters/ApiVersionParameter" + }, + { + "$ref": "../../../../../../../../../specs/resource-manager/common-types/v5/types.json#/parameters/SubscriptionIdParameter" + } + ], + "responses": { + "200": { + "description": "Azure operation completed successfully.", + "schema": { + "$ref": "#/definitions/WidgetListResult" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "../../../../../../../../../specs/resource-manager/common-types/v5/types.json#/definitions/ErrorResponse" + } + } + }, + "x-ms-pageable": { + "nextLinkName": "nextLink" + } + } + }, "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.OptionalBody/widgets": { "get": { "operationId": "Widgets_List", diff --git a/packages/samples/test/output/azure/resource-manager/legacy/rename-operations/@azure-tools/typespec-autorest/2025-01-01-preview/openapi.json b/packages/samples/test/output/azure/resource-manager/legacy/rename-operations/@azure-tools/typespec-autorest/2025-01-01-preview/openapi.json index 2777179b92..fee650c9c5 100644 --- a/packages/samples/test/output/azure/resource-manager/legacy/rename-operations/@azure-tools/typespec-autorest/2025-01-01-preview/openapi.json +++ b/packages/samples/test/output/azure/resource-manager/legacy/rename-operations/@azure-tools/typespec-autorest/2025-01-01-preview/openapi.json @@ -77,6 +77,40 @@ } } }, + "/subscriptions/{subscriptionId}/providers/Microsoft.RenamedOperations/widgets": { + "get": { + "operationId": "Widgets_ListBySubscription", + "tags": [ + "Widgets" + ], + "description": "List Widget resources by subscription ID", + "parameters": [ + { + "$ref": "../../../../../../../../../specs/resource-manager/common-types/v5/types.json#/parameters/ApiVersionParameter" + }, + { + "$ref": "../../../../../../../../../specs/resource-manager/common-types/v5/types.json#/parameters/SubscriptionIdParameter" + } + ], + "responses": { + "200": { + "description": "Azure operation completed successfully.", + "schema": { + "$ref": "#/definitions/WidgetListResult" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "../../../../../../../../../specs/resource-manager/common-types/v5/types.json#/definitions/ErrorResponse" + } + } + }, + "x-ms-pageable": { + "nextLinkName": "nextLink" + } + } + }, "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.RenamedOperations/widgets": { "get": { "operationId": "Widgets_List", diff --git a/packages/typespec-azure-resource-manager/README.md b/packages/typespec-azure-resource-manager/README.md index 7cf7b4411c..964eac7b4d 100644 --- a/packages/typespec-azure-resource-manager/README.md +++ b/packages/typespec-azure-resource-manager/README.md @@ -43,6 +43,7 @@ Available ruleSets: | [`@azure-tools/typespec-azure-resource-manager/arm-resource-operation-response`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/arm-resource-operation-response) | [RPC 008]: PUT, GET, PATCH & LIST must return the same resource schema. | | [`@azure-tools/typespec-azure-resource-manager/arm-resource-path-segment-invalid-chars`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/arm-resource-path-segment-invalid-chars) | Arm resource name must contain only alphanumeric characters. | | [`@azure-tools/typespec-azure-resource-manager/arm-resource-provisioning-state`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/arm-resource-provisioning-state) | Check for properly configured provisioningState property. | +| [`@azure-tools/typespec-azure-resource-manager/arm-resource-required-operations`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/arm-resource-required-operations) | ARM resources must define their required operations: tracked resources need the full lifecycle and list set, other resources need a read, and any resource defining createOrUpdate must also define delete. | | [`@azure-tools/typespec-azure-resource-manager/version-progression`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/version-progression) | Validate that ARM service versions all use unique dates and are declared in strictly increasing chronological order. | | [`@azure-tools/typespec-azure-resource-manager/arm-custom-resource-no-key`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/arm-custom-resource-no-key) | Validate that custom resource contains a key property. | | [`@azure-tools/typespec-azure-resource-manager/arm-custom-resource-usage-discourage`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/arm-custom-resource-usage-discourage) | Verify the usage of @customAzureResource decorator. | diff --git a/packages/typespec-azure-resource-manager/src/linter.ts b/packages/typespec-azure-resource-manager/src/linter.ts index 1a027a612a..fe00206500 100644 --- a/packages/typespec-azure-resource-manager/src/linter.ts +++ b/packages/typespec-azure-resource-manager/src/linter.ts @@ -19,6 +19,7 @@ import { armResourceOperationsRule } from "./rules/arm-resource-operation-respon import { patchOperationsRule } from "./rules/arm-resource-patch.js"; import { armResourcePathInvalidCharsRule } from "./rules/arm-resource-path-invalid-chars.js"; import { armResourceProvisioningStateRule } from "./rules/arm-resource-provisioning-state-rule.js"; +import { armResourceRequiredOperationsRule } from "./rules/arm-resource-required-operations.js"; import { beyondNestingRule } from "./rules/beyond-nesting-levels.js"; import { coreOperationsRule } from "./rules/core-operations.js"; import { envelopePropertiesRules } from "./rules/envelope-properties.js"; @@ -52,6 +53,7 @@ const rules = [ armResourceOperationsRule, armResourcePathInvalidCharsRule, armResourceProvisioningStateRule, + armResourceRequiredOperationsRule, versionProgressionRule, armCustomResourceNoKey, armCustomResourceUsageDiscourage, diff --git a/packages/typespec-azure-resource-manager/src/rules/arm-resource-required-operations.ts b/packages/typespec-azure-resource-manager/src/rules/arm-resource-required-operations.ts new file mode 100644 index 0000000000..7a87f46c31 --- /dev/null +++ b/packages/typespec-azure-resource-manager/src/rules/arm-resource-required-operations.ts @@ -0,0 +1,290 @@ +import { + CodeFix, + createRule, + defineCodeFix, + getNamespaceFullName, + getSourceLocation, + Interface, + LinterRuleContext, + Model, + paramMessage, + Program, +} from "@typespec/compiler"; +import { resolveArmResources, ResolvedResource } from "../resource.js"; +import { isInternalTypeSpec } from "./utils.js"; + +type RequiredOperation = + | "read" + | "createOrUpdate" + | "delete" + | "list-by-parent" + | "list-by-subscription"; + +type RequiredOperationsMessages = { + default: ReturnType; + missingListBySubscription: ReturnType; + missingListByParent: ReturnType; + missingGet: ReturnType; + missingCreateOrUpdate: ReturnType; + missingDelete: ReturnType; +}; + +/** + * Verify that an ARM resource declares the complete set of required + * lifecycle and list operations for its kind. This rule is singleton-aware + * and complements `no-resource-delete-operation`. + */ +export const armResourceRequiredOperationsRule = createRule({ + name: "arm-resource-required-operations", + severity: "warning", + url: "https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/arm-resource-required-operations", + description: + "ARM resources must define their required operations: tracked resources need the full lifecycle and list set, other resources need a read operation.", + messages: { + default: paramMessage`Resource '${"name"}' is missing required operations: [${"operations"}].`, + missingListBySubscription: paramMessage`Tracked resource '${"name"}' must have a list-by-subscription operation.`, + missingListByParent: paramMessage`Resource '${"name"}' must have a list-by-parent operation (list-by-resource-group satisfies this for tracked resources).`, + missingGet: paramMessage`Resource '${"name"}' must have a GET (read) operation.`, + missingCreateOrUpdate: paramMessage`Resource '${"name"}' must have a PUT (createOrUpdate) operation.`, + missingDelete: paramMessage`Resource '${"name"}' must have a delete operation.`, + }, + create(context) { + return { + // Iterate every resolved ARM resource once for the program. We use + // `resolveArmResources` (which handles both standard and legacy resource + // operations) and read kind / singleton / operations directly from the + // returned ResolvedResource entries. Each ResolvedResource represents + // a distinct resource (a single model may produce multiple resolved + // resources, e.g. an extension resource declared at multiple scopes), + // so each is validated independently and its diagnostic is targeted + // at the interface containing the resource's operations. + root: (program: Program) => { + const provider = resolveArmResources(program); + for (const resource of provider.resources ?? []) { + if (resource.kind === "Other") continue; + if (isInternalTypeSpec(program, resource.type)) continue; + // Network Security Perimeter configurations, Private Links, and + // Private Endpoint Connections have their own well-defined operation + // shapes and are exempt from this rule. + if (isExemptCommonTypeResource(resource.type)) continue; + checkResource(context, resource); + } + }, + }; + }, +}); + +function checkResource( + context: LinterRuleContext, + resource: ResolvedResource, +): void { + const required = getRequiredOperationsForResource(resource); + if (required.length === 0) return; + const present = getPresentOperations(resource); + const missing = required.filter((op) => !present.has(op)); + if (missing.length === 0) return; + + const resourceInterface = getResolvedResourceInterface(resource); + const target = resourceInterface ?? resource.type; + const name = resource.resourceName; + + if (missing.length === 1) { + context.reportDiagnostic({ + messageId: singleMessageId(missing[0]), + target, + format: { name }, + codefixes: buildCodefixes(missing, name, resourceInterface), + }); + return; + } + context.reportDiagnostic({ + target, + format: { name, operations: missing.join(", ") }, + codefixes: buildCodefixes(missing, name, resourceInterface), + }); +} + +function getRequiredOperationsForResource(resource: ResolvedResource): RequiredOperation[] { + const isSingleton = resource.singleton !== undefined; + if (resource.kind === "Tracked") { + if (isSingleton) { + return ["read", "createOrUpdate"]; + } + // Tracked non-singleton resources require the full set of lifecycle and + // list operations. For resources at resource-group scope, + // list-by-resource-group satisfies the list-by-parent requirement. + const required: RequiredOperation[] = ["read", "createOrUpdate", "delete", "list-by-parent"]; + // list-by-subscription is required only for top-level resource-group-scoped + // tracked resources (the standard Azure RP pattern). Nested tracked + // resources and tracked resources at non-RG scope (tenant, subscription, + // location) do not require a list-by-subscription. + if (isTopLevelResourceGroupScoped(resource)) { + required.push("list-by-subscription"); + } + return required; + } + // Non-tracked resources (Proxy / Extension) only require a read operation. + // The "createOrUpdate without delete" condition is enforced separately by + // the `no-resource-delete-operation` rule. + return ["read"]; +} + +function isTopLevelResourceGroupScoped(resource: ResolvedResource): boolean { + if (resource.parent !== undefined) return false; + const path = resource.resourceInstancePath ?? ""; + return /\/resourceGroups\/\{/.test(path); +} + +function getPresentOperations(resource: ResolvedResource): Set { + const present = new Set(); + const isTracked = resource.kind === "Tracked"; + const lifecycle = resource.operations.lifecycle; + if (lifecycle.read?.length) present.add("read"); + if (lifecycle.createOrUpdate?.length) present.add("createOrUpdate"); + if (lifecycle.delete?.length) present.add("delete"); + for (const op of resource.operations.lists ?? []) { + const path = op.path ?? ""; + if (!isTracked) { + // For non-tracked resources (Proxy / Extension), any list operation + // satisfies the list-by-parent requirement regardless of scope. + present.add("list-by-parent"); + continue; + } + // Tracked resources: list-by-subscription is a list rooted at + // subscription scope (has /subscriptions/{...} but not + // /resourceGroups/{...} and no nested /providers/). Everything else + // (resource-group, location, parent, etc.) counts as list-by-parent. + const providersCount = (path.match(/\/providers\//g) ?? []).length; + const hasSubscription = /\/subscriptions\/\{/.test(path); + const hasResourceGroup = /\/resourceGroups\/\{/.test(path); + if (hasSubscription && !hasResourceGroup && providersCount <= 1) { + present.add("list-by-subscription"); + } else { + present.add("list-by-parent"); + } + } + return present; +} + +function getResolvedResourceInterface(resource: ResolvedResource): Interface | undefined { + for (const ops of Object.values(resource.operations.lifecycle)) { + if (!Array.isArray(ops)) continue; + for (const op of ops) { + if (op.operation.interface) return op.operation.interface; + } + } + for (const op of resource.operations.lists ?? []) { + if (op.operation.interface) return op.operation.interface; + } + for (const op of resource.operations.actions ?? []) { + if (op.operation.interface) return op.operation.interface; + } + return undefined; +} + +function singleMessageId( + op: RequiredOperation, +): + | "missingGet" + | "missingCreateOrUpdate" + | "missingDelete" + | "missingListByParent" + | "missingListBySubscription" { + switch (op) { + case "read": + return "missingGet"; + case "createOrUpdate": + return "missingCreateOrUpdate"; + case "delete": + return "missingDelete"; + case "list-by-parent": + return "missingListByParent"; + case "list-by-subscription": + return "missingListBySubscription"; + } +} + +function operationTemplate(op: RequiredOperation, resourceName: string): string { + switch (op) { + case "read": + return `read is ArmResourceRead<${resourceName}>;`; + case "createOrUpdate": + return `createOrUpdate is ArmResourceCreateOrReplaceAsync<${resourceName}>;`; + case "delete": + return `delete is ArmResourceDeleteWithoutOkAsync<${resourceName}>;`; + case "list-by-parent": + return `listByParent is ArmResourceListByParent<${resourceName}>;`; + case "list-by-subscription": + return `listBySubscription is ArmListBySubscription<${resourceName}>;`; + } +} + +function buildCodefixes( + missing: RequiredOperation[], + resourceName: string, + resourceInterface: Interface | undefined, +): CodeFix[] | undefined { + if (!resourceInterface || !resourceInterface.node) return undefined; + const node = resourceInterface.node; + const fixes: CodeFix[] = []; + for (const op of missing) { + fixes.push(makeAddOperationFix(op, resourceName, node)); + } + return fixes; +} + +function makeAddOperationFix( + op: RequiredOperation, + resourceName: string, + node: NonNullable, +): CodeFix { + return defineCodeFix({ + id: `add-${op}-operation`, + label: `Add ${op} operation declaration`, + fix: (context) => { + const sourceLocation = getSourceLocation(node); + const file = sourceLocation.file; + // Insert just before the closing `}` of the interface body. + const insertPos = node.bodyRange.end - 1; + return context.prependText( + { file, pos: insertPos }, + ` ${operationTemplate(op, resourceName)}\n`, + ); + }, + }); +} + +/** + * Common-types resource models that have their own well-defined operation + * shapes and are therefore exempt from the required-operations rule. These + * are the canonical models in `Azure.ResourceManager.CommonTypes`. + */ +const EXEMPT_COMMON_TYPE_RESOURCES = new Set([ + "PrivateLinkResource", + "PrivateEndpointConnection", + "NetworkSecurityPerimeterConfiguration", +]); +const EXEMPT_COMMON_TYPE_NAMESPACE = "Azure.ResourceManager.CommonTypes"; + +/** + * Returns true if the given model derives from one of the exempt common-type + * ARM resource models (NSP configurations, Private Links, Private Endpoint + * Connections). Walks both `sourceModel` (`is`) and `baseModel` (`extends`) + * chains. + */ +function isExemptCommonTypeResource(model: Model): boolean { + const visited = new Set(); + let current: Model | undefined = model; + while (current && !visited.has(current)) { + visited.add(current); + if ( + EXEMPT_COMMON_TYPE_RESOURCES.has(current.name) && + current.namespace !== undefined && + getNamespaceFullName(current.namespace) === EXEMPT_COMMON_TYPE_NAMESPACE + ) { + return true; + } + current = current.sourceModel ?? current.baseModel; + } + return false; +} diff --git a/packages/typespec-azure-resource-manager/test/rules/arm-resource-required-operations.test.ts b/packages/typespec-azure-resource-manager/test/rules/arm-resource-required-operations.test.ts new file mode 100644 index 0000000000..161762518c --- /dev/null +++ b/packages/typespec-azure-resource-manager/test/rules/arm-resource-required-operations.test.ts @@ -0,0 +1,504 @@ +import { Tester } from "#test/tester.js"; +import { + LinterRuleTester, + TesterInstance, + createLinterRuleTester, +} from "@typespec/compiler/testing"; +import { beforeEach, it } from "vitest"; + +import { armResourceRequiredOperationsRule } from "../../src/rules/arm-resource-required-operations.js"; + +let runner: TesterInstance; +let tester: LinterRuleTester; + +beforeEach(async () => { + runner = await Tester.createInstance(); + tester = createLinterRuleTester( + runner, + armResourceRequiredOperationsRule, + "@azure-tools/typespec-azure-resource-manager", + ); +}); + +it("is valid when tracked resource has the complete set of required operations", async () => { + await tester + .expect( + ` + @armProviderNamespace + namespace Microsoft.Foo; + + model Foo is TrackedResource<{}> { + @key @path @segment("foos") name: string; + } + + @armResourceOperations + interface FooOperations { + read is ArmResourceRead; + createOrUpdate is ArmResourceCreateOrReplaceAsync; + delete is ArmResourceDeleteWithoutOkAsync; + listByResourceGroup is ArmResourceListByParent; + listBySubscription is ArmListBySubscription; + } + `, + ) + .toBeValid(); +}); + +it("emits missingDelete when only the delete operation is missing", async () => { + await tester + .expect( + ` + @armProviderNamespace + namespace Microsoft.Foo; + + model Foo is TrackedResource<{}> { + @key @path @segment("foos") name: string; + } + + @armResourceOperations + interface FooOperations { + read is ArmResourceRead; + createOrUpdate is ArmResourceCreateOrReplaceAsync; + listByResourceGroup is ArmResourceListByParent; + listBySubscription is ArmListBySubscription; + } + `, + ) + .toEmitDiagnostics({ + code: "@azure-tools/typespec-azure-resource-manager/arm-resource-required-operations", + message: `Resource 'Foo' must have a delete operation.`, + }); +}); + +it("emits missingGet when only read is missing", async () => { + await tester + .expect( + ` + @armProviderNamespace + namespace Microsoft.Foo; + + model Foo is TrackedResource<{}> { + @key @path @segment("foos") name: string; + } + + @armResourceOperations + interface FooOperations { + createOrUpdate is ArmResourceCreateOrReplaceAsync; + delete is ArmResourceDeleteWithoutOkAsync; + listByResourceGroup is ArmResourceListByParent; + listBySubscription is ArmListBySubscription; + } + `, + ) + .toEmitDiagnostics({ + code: "@azure-tools/typespec-azure-resource-manager/arm-resource-required-operations", + message: `Resource 'Foo' must have a GET (read) operation.`, + }); +}); + +it("emits a single default diagnostic listing all missing operations when multiple are missing", async () => { + await tester + .expect( + ` + @armProviderNamespace + namespace Microsoft.Foo; + + model Foo is TrackedResource<{}> { + @key @path @segment("foos") name: string; + } + + @armResourceOperations + interface FooOperations { + createOrUpdate is ArmResourceCreateOrReplaceAsync; + } + `, + ) + .toEmitDiagnostics({ + code: "@azure-tools/typespec-azure-resource-manager/arm-resource-required-operations", + message: + "Resource 'Foo' is missing required operations: [read, delete, list-by-parent, list-by-subscription].", + }); +}); + +it("emits missingListByParent for a tracked resource without a list-by-resource-group operation", async () => { + await tester + .expect( + ` + @armProviderNamespace + namespace Microsoft.Foo; + + model Foo is TrackedResource<{}> { + @key @path @segment("foos") name: string; + } + + @armResourceOperations + interface FooOperations { + read is ArmResourceRead; + createOrUpdate is ArmResourceCreateOrReplaceAsync; + delete is ArmResourceDeleteWithoutOkAsync; + listBySubscription is ArmListBySubscription; + } + `, + ) + .toEmitDiagnostics({ + code: "@azure-tools/typespec-azure-resource-manager/arm-resource-required-operations", + message: `Resource 'Foo' must have a list-by-parent operation (list-by-resource-group satisfies this for tracked resources).`, + }); +}); + +it("emits missingListBySubscription for a tracked resource without a list-by-subscription operation", async () => { + await tester + .expect( + ` + @armProviderNamespace + namespace Microsoft.Foo; + + model Foo is TrackedResource<{}> { + @key @path @segment("foos") name: string; + } + + @armResourceOperations + interface FooOperations { + read is ArmResourceRead; + createOrUpdate is ArmResourceCreateOrReplaceAsync; + delete is ArmResourceDeleteWithoutOkAsync; + listByResourceGroup is ArmResourceListByParent; + } + `, + ) + .toEmitDiagnostics({ + code: "@azure-tools/typespec-azure-resource-manager/arm-resource-required-operations", + message: `Tracked resource 'Foo' must have a list-by-subscription operation.`, + }); +}); + +it("is valid when an extension resource has only a read operation", async () => { + await tester + .expect( + ` + @armProviderNamespace + namespace Microsoft.Foo; + + model Bar is ExtensionResource<{}> { + @key @path @segment("bars") barName: string; + } + + @armResourceOperations + interface BarOperations { + read is ArmResourceRead; + } + `, + ) + .toBeValid(); +}); + +it("emits missingGet for an extension resource missing read", async () => { + await tester + .expect( + ` + @armProviderNamespace + namespace Microsoft.Foo; + + model Bar is ExtensionResource<{}> { + @key @path @segment("bars") barName: string; + } + + @armResourceOperations + interface BarOperations { + listByParent is ArmResourceListByParent; + } + `, + ) + .toEmitDiagnostics({ + code: "@azure-tools/typespec-azure-resource-manager/arm-resource-required-operations", + message: `Resource 'Bar' must have a GET (read) operation.`, + }); +}); + +it("does not emit missingDelete or missingList for a singleton tracked resource", async () => { + await tester + .expect( + ` + @armProviderNamespace + namespace Microsoft.Foo; + + @singleton + model Foo is TrackedResource<{}> { + @key @path @segment("foos") name: string; + } + + @armResourceOperations + interface FooOperations { + read is ArmResourceRead; + createOrUpdate is ArmResourceCreateOrReplaceAsync; + } + `, + ) + .toBeValid(); +}); + +it("emits missingCreateOrUpdate when only createOrUpdate is missing", async () => { + await tester + .expect( + ` + @armProviderNamespace + namespace Microsoft.Foo; + + model Foo is TrackedResource<{}> { + @key @path @segment("foos") name: string; + } + + @armResourceOperations + interface FooOperations { + read is ArmResourceRead; + delete is ArmResourceDeleteWithoutOkAsync; + listByResourceGroup is ArmResourceListByParent; + listBySubscription is ArmListBySubscription; + } + `, + ) + .toEmitDiagnostics({ + code: "@azure-tools/typespec-azure-resource-manager/arm-resource-required-operations", + message: `Resource 'Foo' must have a PUT (createOrUpdate) operation.`, + }); +}); + +it("emits missingGet for a singleton tracked resource missing read", async () => { + await tester + .expect( + ` + @armProviderNamespace + namespace Microsoft.Foo; + + @singleton + model Foo is TrackedResource<{}> { + @key @path @segment("foos") name: string; + } + + @armResourceOperations + interface FooOperations { + createOrUpdate is ArmResourceCreateOrReplaceAsync; + } + `, + ) + .toEmitDiagnostics({ + code: "@azure-tools/typespec-azure-resource-manager/arm-resource-required-operations", + message: `Resource 'Foo' must have a GET (read) operation.`, + }); +}); + +it("is valid when an extension resource has only a read operation", async () => { + await tester + .expect( + ` + @armProviderNamespace + namespace Microsoft.Foo; + + model Foo is ExtensionResource<{}> { + @key @path @segment("foos") name: string; + } + + @armResourceOperations + interface FooOperations { + read is ArmResourceRead; + } + `, + ) + .toBeValid(); +}); + +it("is valid when an extension resource has read, createOrUpdate, and delete", async () => { + await tester + .expect( + ` + @armProviderNamespace + namespace Microsoft.Foo; + + model Foo is ExtensionResource<{}> { + @key @path @segment("foos") name: string; + } + + @armResourceOperations + interface FooOperations { + read is ArmResourceRead; + createOrUpdate is ArmResourceCreateOrReplaceAsync; + delete is ArmResourceDeleteWithoutOkAsync; + } + `, + ) + .toBeValid(); +}); + +it("is valid when an extension resource declared at multiple scopes has read at every scope", async () => { + await tester + .expect( + ` + @armProviderNamespace + namespace Microsoft.Foo; + + model Employee is ExtensionResource<{}> { + @key @path @segment("employees") name: string; + } + + @armResourceOperations + interface AtSubscription { + read is Extension.Read; + } + + @armResourceOperations + interface AtTenant { + read is Extension.Read; + } + `, + ) + .toBeValid(); +}); + +it("emits missingGet for the scope that lacks read when an extension resource is declared at multiple scopes", async () => { + await tester + .expect( + ` + @armProviderNamespace + namespace Microsoft.Foo; + + model Employee is ExtensionResource<{}> { + @key @path @segment("employees") name: string; + } + + @armResourceOperations + interface AtSubscription { + read is Extension.Read; + } + + @armResourceOperations + interface AtTenant { + list is Extension.ListByTarget; + } + `, + ) + .toEmitDiagnostics({ + code: "@azure-tools/typespec-azure-resource-manager/arm-resource-required-operations", + message: `Resource 'TenantEmployee' must have a GET (read) operation.`, + }); +}); + +it("skips @armVirtualResource models", async () => { + await tester + .expect( + ` + @armProviderNamespace + namespace Microsoft.Foo; + + @armVirtualResource + model VirtualFoo { + @key @path @segment("virtualFoos") name: string; + } + `, + ) + .toBeValid(); +}); + +it("exempts NetworkSecurityPerimeterConfiguration resources", async () => { + await tester + .expect( + ` + @armProviderNamespace + @versioned(Versions) + namespace Microsoft.Foo; + + enum Versions { + @armCommonTypesVersion(Azure.ResourceManager.CommonTypes.Versions.v5) + v1, + } + + interface Operations extends Azure.ResourceManager.Operations {} + + model Employee is TrackedResource<{}> { + @key @path @segment("employees") name: string; + } + + model EmployeeNspConfig is Azure.ResourceManager.NspConfiguration; + alias EmployeeNspOps = Azure.ResourceManager.NspConfigurations; + + @armResourceOperations + interface Employees { + get is ArmResourceRead; + createOrUpdate is ArmResourceCreateOrReplaceAsync; + delete is ArmResourceDeleteWithoutOkAsync; + listByResourceGroup is ArmResourceListByParent; + listBySubscription is ArmListBySubscription; + /** Get NSP config */ + getNsp is EmployeeNspOps.Read; + /** List NSP configs */ + listNsp is EmployeeNspOps.ListByParent; + } + `, + ) + .toBeValid(); +}); + +it("exempts PrivateLink resources", async () => { + await tester + .expect( + ` + @armProviderNamespace + namespace Microsoft.Foo; + + interface Operations extends Azure.ResourceManager.Operations {} + + model Employee is TrackedResource<{}> { + @key @path @segment("employees") name: string; + } + + model EmployeePrivateLink is Azure.ResourceManager.PrivateLink; + alias EmployeePLOps = Azure.ResourceManager.PrivateLinks; + + @armResourceOperations + interface Employees { + get is ArmResourceRead; + createOrUpdate is ArmResourceCreateOrReplaceAsync; + delete is ArmResourceDeleteWithoutOkAsync; + listByResourceGroup is ArmResourceListByParent; + listBySubscription is ArmListBySubscription; + /** Get private link */ + getPrivateLink is EmployeePLOps.Read; + /** List private links */ + listPrivateLinks is EmployeePLOps.ListByParent; + } + `, + ) + .toBeValid(); +}); + +it("exempts PrivateEndpointConnection resources", async () => { + await tester + .expect( + ` + @armProviderNamespace + namespace Microsoft.Foo; + + interface Operations extends Azure.ResourceManager.Operations {} + + model Employee is TrackedResource<{}> { + @key @path @segment("employees") name: string; + } + + model EmployeePrivateEndpoint is Azure.ResourceManager.PrivateEndpointConnectionResource; + alias EmployeePEOps = Azure.ResourceManager.PrivateEndpoints; + + @armResourceOperations + interface Employees { + get is ArmResourceRead; + createOrUpdate is ArmResourceCreateOrReplaceAsync; + delete is ArmResourceDeleteWithoutOkAsync; + listByResourceGroup is ArmResourceListByParent; + listBySubscription is ArmListBySubscription; + /** Get private endpoint connection */ + getPE is EmployeePEOps.Read; + /** List private endpoint connections */ + listPE is EmployeePEOps.ListByParent; + } + `, + ) + .toBeValid(); +}); diff --git a/packages/typespec-azure-rulesets/src/rulesets/resource-manager.ts b/packages/typespec-azure-rulesets/src/rulesets/resource-manager.ts index ba2b21abe0..eec21e6b11 100644 --- a/packages/typespec-azure-rulesets/src/rulesets/resource-manager.ts +++ b/packages/typespec-azure-rulesets/src/rulesets/resource-manager.ts @@ -79,6 +79,7 @@ export default { "@azure-tools/typespec-azure-resource-manager/beyond-nesting-levels": true, "@azure-tools/typespec-azure-resource-manager/arm-resource-operation": true, "@azure-tools/typespec-azure-resource-manager/no-resource-delete-operation": true, + "@azure-tools/typespec-azure-resource-manager/arm-resource-required-operations": true, "@azure-tools/typespec-azure-resource-manager/empty-updateable-properties": true, "@azure-tools/typespec-azure-resource-manager/arm-resource-interface-requires-decorator": true, "@azure-tools/typespec-azure-resource-manager/arm-resource-invalid-action-verb": true, diff --git a/website/src/content/docs/docs/libraries/azure-resource-manager/reference/linter.md b/website/src/content/docs/docs/libraries/azure-resource-manager/reference/linter.md index 4d0be9c35e..39599c8d9d 100644 --- a/website/src/content/docs/docs/libraries/azure-resource-manager/reference/linter.md +++ b/website/src/content/docs/docs/libraries/azure-resource-manager/reference/linter.md @@ -37,6 +37,7 @@ Available ruleSets: | [`@azure-tools/typespec-azure-resource-manager/arm-resource-operation-response`](../rules/arm-resource-operation-response.md) | [RPC 008]: PUT, GET, PATCH & LIST must return the same resource schema. | | [`@azure-tools/typespec-azure-resource-manager/arm-resource-path-segment-invalid-chars`](../rules/arm-resource-path-segment-invalid-chars.md) | Arm resource name must contain only alphanumeric characters. | | [`@azure-tools/typespec-azure-resource-manager/arm-resource-provisioning-state`](../rules/arm-resource-provisioning-state.md) | Check for properly configured provisioningState property. | +| [`@azure-tools/typespec-azure-resource-manager/arm-resource-required-operations`](../rules/arm-resource-required-operations.md) | ARM resources must define their required operations: tracked resources need the full lifecycle and list set, other resources need a read, and any resource defining createOrUpdate must also define delete. | | [`@azure-tools/typespec-azure-resource-manager/version-progression`](../rules/version-progression.md) | Validate that ARM service versions all use unique dates and are declared in strictly increasing chronological order. | | [`@azure-tools/typespec-azure-resource-manager/arm-custom-resource-no-key`](../rules/arm-custom-resource-no-key.md) | Validate that custom resource contains a key property. | | [`@azure-tools/typespec-azure-resource-manager/arm-custom-resource-usage-discourage`](../rules/arm-custom-resource-usage-discourage.md) | Verify the usage of @customAzureResource decorator. | diff --git a/website/src/content/docs/docs/libraries/azure-resource-manager/rules/arm-resource-required-operations.md b/website/src/content/docs/docs/libraries/azure-resource-manager/rules/arm-resource-required-operations.md new file mode 100644 index 0000000000..a06cd2db30 --- /dev/null +++ b/website/src/content/docs/docs/libraries/azure-resource-manager/rules/arm-resource-required-operations.md @@ -0,0 +1,74 @@ +--- +title: arm-resource-required-operations +--- + +```text title=- Full name- +@azure-tools/typespec-azure-resource-manager/arm-resource-required-operations +``` + +ARM resources must declare their required lifecycle and list operations as +defined by the [ARM RPC contract][rpc] (sections 2.2 and 2.3). + +The required set depends on the resource kind: + +| Resource kind | Required operations | +| ----------------- | -------------------------------------------------------------------------------------------------------------------- | +| Tracked | `read`, `createOrUpdate`, `delete`, `list-by-parent` (satisfied by `list-by-resource-group`), `list-by-subscription` | +| Tracked singleton | `read`, `createOrUpdate` only | +| Proxy / Extension | `read` | + +For tracked resources, a `list-by-resource-group` operation satisfies the +`list-by-parent` requirement (the resource group is the parent in that scope). +`list-by-subscription` is required only for top-level resource-group-scoped +tracked resources. + +When more than one operation is missing, a single diagnostic is emitted that +lists every missing operation. When only one is missing, a more specific +message ID is used so editors and tooling can present a clearer hint. + +The requirement that a resource defining `createOrUpdate` must also define +`delete` is enforced by the separate +[`no-resource-delete-operation`](./no-resource-delete-operation.md) rule. + +#### ❌ Incorrect — tracked resource missing the delete and list operations + +```tsp +@armResourceOperations +interface EmployeeOperations { + read is ArmResourceRead; + createOrUpdate is ArmResourceCreateOrReplaceAsync; +} +``` + +#### ✅ Correct — complete operation set for a tracked resource + +```tsp +@armResourceOperations +interface EmployeeOperations { + read is ArmResourceRead; + createOrUpdate is ArmResourceCreateOrReplaceAsync; + delete is ArmResourceDeleteWithoutOkAsync; + listByResourceGroup is ArmResourceListByParent; + listBySubscription is ArmListBySubscription; +} +``` + +#### ✅ Correct — singleton resource (no delete or list required) + +```tsp +@singleton +model Settings is ProxyResource { + @key + @path + @segment("settings") + name: string; +} + +@armResourceOperations +interface SettingsOperations { + read is ArmResourceRead; + createOrUpdate is ArmResourceCreateOrReplaceAsync; +} +``` + +[rpc]: https://github.com/cloud-and-ai-microsoft/resource-provider-contract