Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
changeKind: feature
packages:
- "@azure-tools/typespec-azure-resource-manager"
- "@azure-tools/typespec-azure-rulesets"
---

Add `no-billing-data-in-properties-bag` linter rule that flags a `BillingData` property (case-insensitive) present in an ARM resource's property bag, since the name is reserved for platform billing integration.
1 change: 1 addition & 0 deletions packages/typespec-azure-resource-manager/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ Available ruleSets:
| [`@azure-tools/typespec-azure-resource-manager/unsupported-type`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/unsupported-type) | Check for unsupported ARM types. |
| [`@azure-tools/typespec-azure-resource-manager/secret-prop`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/secret-prop) | RPC-v1-13: Check that property with names indicating sensitive information(e.g. contains auth, password, token, secret, etc.) are marked with @secret decorator. |
| [`@azure-tools/typespec-azure-resource-manager/no-empty-model`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/no-empty-model) | ARM Properties with type:object that don't reference a model definition are not allowed. ARM doesn't allow generic type definitions as this leads to bad customer experience. |
| [`@azure-tools/typespec-azure-resource-manager/no-billing-data-in-properties-bag`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/no-billing-data-in-properties-bag) | A property named 'BillingData' must not be present in a resource's property bag. The name is reserved for platform billing integration. |

## Decorators

Expand Down
2 changes: 2 additions & 0 deletions packages/typespec-azure-resource-manager/src/linter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { envelopePropertiesRules } from "./rules/envelope-properties.js";
import { improperSubscriptionListOperationRule } from "./rules/improper-subscription-list-operation.js";
import { lroLocationHeaderRule } from "./rules/lro-location-header.js";
import { missingXmsIdentifiersRule } from "./rules/missing-x-ms-identifiers.js";
import { noBillingDataInPropertiesBagRule } from "./rules/no-billing-data-in-properties-bag.js";
import { noEmptyModel } from "./rules/no-empty-model.js";
import { noOverridePropsRule } from "./rules/no-override-props.js";
import { deleteOperationMissingRule } from "./rules/no-resource-delete-operation.js";
Expand Down Expand Up @@ -79,6 +80,7 @@ const rules = [
unsupportedTypeRule,
secretProprule,
noEmptyModel,
noBillingDataInPropertiesBagRule,
];

export const $linter = defineLinter({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Model, createRule, getProperty, paramMessage } from "@typespec/compiler";

import { getArmResource } from "../resource.js";
import { getProperties } from "./utils.js";

const restrictedPropertyName = "billingdata";

export const noBillingDataInPropertiesBagRule = createRule({
name: "no-billing-data-in-properties-bag",
description:
"A property named 'BillingData' must not be present in a resource's property bag. The name is reserved for platform billing integration.",
severity: "warning",
url: "https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/no-billing-data-in-properties-bag",
messages: {
default: paramMessage`Property "${"propertyName"}" is not allowed in the resource property bag. The "BillingData" property name is reserved for platform billing integration.`,
},
create(context) {
return {
model: (model: Model) => {
const resourceModel = getArmResource(context.program, model);
if (resourceModel === undefined) {
return;
}

const resourceProperties = getProperty(model, "properties")?.type;
if (resourceProperties === undefined || resourceProperties.kind !== "Model") {
return;
}

for (const property of getProperties(resourceProperties)) {
if (property.name.toLowerCase() === restrictedPropertyName) {
context.reportDiagnostic({
format: { propertyName: property.name },
target: property,
});
}
}
},
};
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
import { Tester } from "#test/tester.js";
import {
LinterRuleTester,
TesterInstance,
createLinterRuleTester,
} from "@typespec/compiler/testing";
import { beforeEach, describe, it } from "vitest";

import { noBillingDataInPropertiesBagRule } from "../../src/rules/no-billing-data-in-properties-bag.js";

const ruleCode = "@azure-tools/typespec-azure-resource-manager/no-billing-data-in-properties-bag";

function expectedMessage(propertyName: string): string {
return `Property "${propertyName}" is not allowed in the resource property bag. The "BillingData" property name is reserved for platform billing integration.`;
}

let runner: TesterInstance;
let tester: LinterRuleTester;

beforeEach(async () => {
runner = await Tester.createInstance();
tester = createLinterRuleTester(
runner,
noBillingDataInPropertiesBagRule,
"@azure-tools/typespec-azure-resource-manager",
);
});

describe("valid cases", () => {
it("is valid when the property bag has no BillingData property", async () => {
await tester
.expect(
`
@armProviderNamespace namespace MyService;

model FooResource is TrackedResource<FooProperties> {
@key @segment("foo") name: string;
}

model FooProperties {
@visibility(Lifecycle.Read)
provisioningState?: ResourceProvisioningState;
}
`,
)
.toBeValid();
});

it("is valid when a property name only contains BillingData as a substring", async () => {
await tester
.expect(
`
@armProviderNamespace namespace MyService;

model FooResource is TrackedResource<FooProperties> {
@key @segment("foo") name: string;
}

model FooProperties {
billingDataId?: string;
currentBillingData?: string;
}
`,
)
.toBeValid();
});

it("is valid when BillingData is used outside of a resource property bag", async () => {
await tester
.expect(
`
@armProviderNamespace namespace MyService;

model FooResource is TrackedResource<FooProperties> {
@key @segment("foo") name: string;
}

model FooProperties {
@visibility(Lifecycle.Read)
provisioningState?: ResourceProvisioningState;
}

model NotAResource {
billingData?: string;
}
`,
)
.toBeValid();
});
});

describe("invalid cases", () => {
it("emits a warning when BillingData is a primitive type", async () => {
await tester
.expect(
`
@armProviderNamespace namespace MyService;

model FooResource is TrackedResource<FooProperties> {
@key @segment("foo") name: string;
}

model FooProperties {
BillingData?: string;
}
`,
)
.toEmitDiagnostics({
code: ruleCode,
message: expectedMessage("BillingData"),
});
});

it("emits a warning when BillingData references a named model", async () => {
await tester
.expect(
`
@armProviderNamespace namespace MyService;

model FooResource is TrackedResource<FooProperties> {
@key @segment("foo") name: string;
}

model FooProperties {
billingData?: BillingData;
}

model BillingData {
amount?: int32;
}
`,
)
.toEmitDiagnostics({
code: ruleCode,
message: expectedMessage("billingData"),
});
});

it("emits a warning when BillingData is an inline anonymous model", async () => {
await tester
.expect(
`
@armProviderNamespace namespace MyService;

model FooResource is TrackedResource<FooProperties> {
@key @segment("foo") name: string;
}

model FooProperties {
billingData?: {
amount?: int32;
};
}
`,
)
.toEmitDiagnostics({
code: ruleCode,
message: expectedMessage("billingData"),
});
});

describe("matches the property name case-insensitively", () => {
["billingData", "billingdata", "BILLINGDATA", "BillingDATA"].forEach((name) => {
it(name, async () => {
await tester
.expect(
`
@armProviderNamespace namespace MyService;

model FooResource is TrackedResource<FooProperties> {
@key @segment("foo") name: string;
}

model FooProperties {
${name}?: string;
}
`,
)
.toEmitDiagnostics({
code: ruleCode,
message: expectedMessage(name),
});
});
});
});

it("emits a warning when BillingData is inherited from a base model", async () => {
await tester
.expect(
`
@armProviderNamespace namespace MyService;

model FooResource is TrackedResource<FooProperties> {
@key @segment("foo") name: string;
}

model FooProperties extends BaseProperties {
@visibility(Lifecycle.Read)
provisioningState?: ResourceProvisioningState;
}

model BaseProperties {
billingData?: string;
}
`,
)
.toEmitDiagnostics({
code: ruleCode,
message: expectedMessage("billingData"),
});
});

it("emits a warning when BillingData is spread into the property bag", async () => {
await tester
.expect(
`
@armProviderNamespace namespace MyService;

model FooResource is TrackedResource<FooProperties> {
@key @segment("foo") name: string;
}

model FooProperties {
...BillingBag;
}

model BillingBag {
billingData?: string;
}
`,
)
.toEmitDiagnostics({
code: ruleCode,
message: expectedMessage("billingData"),
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export default {
"@azure-tools/typespec-azure-resource-manager/arm-resource-operation-response": true,
"@azure-tools/typespec-azure-resource-manager/arm-resource-path-segment-invalid-chars": true,
"@azure-tools/typespec-azure-resource-manager/arm-resource-provisioning-state": true,
"@azure-tools/typespec-azure-resource-manager/no-billing-data-in-properties-bag": true,
"@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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,4 @@ Available ruleSets:
| [`@azure-tools/typespec-azure-resource-manager/unsupported-type`](../rules/unsupported-type.md) | Check for unsupported ARM types. |
| [`@azure-tools/typespec-azure-resource-manager/secret-prop`](../rules/secret-prop.md) | RPC-v1-13: Check that property with names indicating sensitive information(e.g. contains auth, password, token, secret, etc.) are marked with @secret decorator. |
| [`@azure-tools/typespec-azure-resource-manager/no-empty-model`](../rules/no-empty-model.md) | ARM Properties with type:object that don't reference a model definition are not allowed. ARM doesn't allow generic type definitions as this leads to bad customer experience. |
| [`@azure-tools/typespec-azure-resource-manager/no-billing-data-in-properties-bag`](../rules/no-billing-data-in-properties-bag.md) | A property named 'BillingData' must not be present in a resource's property bag. The name is reserved for platform billing integration. |
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
title: no-billing-data-in-properties-bag
---

```text title="Full name"
@azure-tools/typespec-azure-resource-manager/no-billing-data-in-properties-bag
```

A property named `BillingData` must not be present in a resource's property bag (the model referenced by the `properties` property of an ARM resource). The name is reserved for platform billing integration and is being standardized through Common Types, so it must not be declared by resource providers. The property name is matched case-insensitively (`BillingData`, `billingData`, `billingdata`, etc.) and is disallowed regardless of its type (named model reference, inline model, or primitive).

#### ❌ Incorrect

```tsp
@armProviderNamespace
namespace MyService;

model FooResource is TrackedResource<FooProperties> {
@key @segment("foo") name: string;
}

model FooProperties {
@visibility(Lifecycle.Read)
provisioningState?: ResourceProvisioningState;

billingData?: string; // "BillingData" is reserved and not allowed in the property bag
}
```

#### ✅ Correct

```tsp
@armProviderNamespace
namespace MyService;

model FooResource is TrackedResource<FooProperties> {
@key @segment("foo") name: string;
}

model FooProperties {
@visibility(Lifecycle.Read)
provisioningState?: ResourceProvisioningState;

billingInfo?: string;
}
```
Loading