@pagopa/dx-cli 0.18.6 → 0.18.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -17
- package/dist/adapters/azure/__tests__/cloud-account-service.test.js +30 -1
- package/dist/adapters/azure/cloud-account-service.js +43 -2
- package/dist/adapters/commander/commands/savemoney.js +29 -5
- package/package.json +2 -2
- package/templates/environment/bootstrapper/{{env.name}}/main.tf.hbs +1 -0
- package/templates/monorepo/.pre-commit-config.yaml.hbs +1 -1
package/README.md
CHANGED
|
@@ -178,13 +178,14 @@ dx savemoney [options]
|
|
|
178
178
|
|
|
179
179
|
**Options:**
|
|
180
180
|
|
|
181
|
-
| Option | Alias | Description
|
|
182
|
-
| :----------- | :---- |
|
|
183
|
-
| `--config` | `-c` | Path to a
|
|
184
|
-
| `--format` | `-f` | Report format
|
|
185
|
-
| `--days` | `-d` | Metric analysis period in days.
|
|
186
|
-
| `--location` | `-l` | Preferred Azure location for resources.
|
|
187
|
-
| `--verbose` | `-v` | Enable verbose mode with detailed logging for each resource analyzed.
|
|
181
|
+
| Option | Alias | Description | Default |
|
|
182
|
+
| :----------- | :---- | :------------------------------------------------------------------------------------------------------------------------------------------- | :----------- |
|
|
183
|
+
| `--config` | `-c` | Path to a YAML configuration file. | N/A |
|
|
184
|
+
| `--format` | `-f` | Report format: `table`, `json`, `detailed-json`, or `lint`. | `table` |
|
|
185
|
+
| `--days` | `-d` | Metric analysis period in days (overrides config file). | `30` |
|
|
186
|
+
| `--location` | `-l` | Preferred Azure location for resources (overrides config file). | `italynorth` |
|
|
187
|
+
| `--verbose` | `-v` | Enable verbose mode with detailed logging for each resource analyzed. | `false` |
|
|
188
|
+
| `--tags` | `-t` | Filter resources by tags (`key=value key2=value2`). Only resources matching **all** specified tags are analyzed (variadic: space-separated). | N/A |
|
|
188
189
|
|
|
189
190
|
**Example usage:**
|
|
190
191
|
|
|
@@ -193,24 +194,38 @@ dx savemoney [options]
|
|
|
193
194
|
dx savemoney
|
|
194
195
|
|
|
195
196
|
# Use a configuration file
|
|
196
|
-
dx savemoney --config config.
|
|
197
|
+
dx savemoney --config config.yaml
|
|
197
198
|
|
|
198
199
|
# Output as JSON with verbose logging
|
|
199
200
|
dx savemoney --format json --verbose
|
|
200
201
|
|
|
202
|
+
# Linter-style output for CI pipelines
|
|
203
|
+
dx savemoney --config config.yaml --format lint
|
|
204
|
+
|
|
205
|
+
# Analyze only resources tagged environment=prod
|
|
206
|
+
dx savemoney --config config.yaml --tags "environment=prod"
|
|
207
|
+
|
|
201
208
|
# Analyze with specific timespan
|
|
202
209
|
dx savemoney --days 60 --location italynorth
|
|
203
210
|
```
|
|
204
211
|
|
|
205
|
-
**Configuration file example (`config.
|
|
206
|
-
|
|
207
|
-
```
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
212
|
+
**Configuration file example (`config.yaml`):**
|
|
213
|
+
|
|
214
|
+
```yaml
|
|
215
|
+
azure:
|
|
216
|
+
subscriptionIds:
|
|
217
|
+
- subscription-1
|
|
218
|
+
- subscription-2
|
|
219
|
+
preferredLocation: italynorth
|
|
220
|
+
timespanDays: 30
|
|
221
|
+
thresholds: # optional — omit to use built-in defaults
|
|
222
|
+
vm:
|
|
223
|
+
cpuPercent: 5
|
|
224
|
+
appService:
|
|
225
|
+
cpuPercent: 10
|
|
226
|
+
memoryPercent: 20
|
|
227
|
+
storage:
|
|
228
|
+
transactionsPerDay: 50
|
|
214
229
|
```
|
|
215
230
|
|
|
216
231
|
**Analyzed Azure resources:**
|
|
@@ -4,6 +4,12 @@ import { AzureCloudAccountService } from "../cloud-account-service.js";
|
|
|
4
4
|
const { queryResources } = vi.hoisted(() => ({
|
|
5
5
|
queryResources: vi.fn().mockRejectedValue(new Error("Not implemented")),
|
|
6
6
|
}));
|
|
7
|
+
const { mockProviderGet, mockProviderRegister } = vi.hoisted(() => ({
|
|
8
|
+
mockProviderGet: vi
|
|
9
|
+
.fn()
|
|
10
|
+
.mockResolvedValue({ registrationState: "Registered" }),
|
|
11
|
+
mockProviderRegister: vi.fn().mockResolvedValue({}),
|
|
12
|
+
}));
|
|
7
13
|
vi.mock("@azure/identity", () => ({
|
|
8
14
|
DefaultAzureCredential: vi.fn(),
|
|
9
15
|
}));
|
|
@@ -12,6 +18,14 @@ vi.mock("@azure/arm-resourcegraph", () => ({
|
|
|
12
18
|
resources = queryResources;
|
|
13
19
|
},
|
|
14
20
|
}));
|
|
21
|
+
vi.mock("@azure/arm-resources", () => ({
|
|
22
|
+
ResourceManagementClient: class {
|
|
23
|
+
providers = {
|
|
24
|
+
get: mockProviderGet,
|
|
25
|
+
register: mockProviderRegister,
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
}));
|
|
15
29
|
const test = baseTest.extend({
|
|
16
30
|
// the empty pattern is required by vitest!!!
|
|
17
31
|
// eslint-disable-next-line no-empty-pattern
|
|
@@ -94,11 +108,12 @@ describe("getTerraformBackend", () => {
|
|
|
94
108
|
});
|
|
95
109
|
});
|
|
96
110
|
describe("isInitialized", () => {
|
|
97
|
-
test("returns true when both bootstrap identity and common Key Vault exist", async ({ cloudAccountService, }) => {
|
|
111
|
+
test("returns true when both bootstrap identity and common Key Vault exist and all providers are registered", async ({ cloudAccountService, }) => {
|
|
98
112
|
// First call: identity query → found
|
|
99
113
|
queryResources.mockResolvedValueOnce({ data: [], totalRecords: 1 });
|
|
100
114
|
// Second call: key vault query → found
|
|
101
115
|
queryResources.mockResolvedValueOnce({ data: [], totalRecords: 1 });
|
|
116
|
+
// Provider checks → all registered (default mock)
|
|
102
117
|
const result = await cloudAccountService.isInitialized("sub-1", {
|
|
103
118
|
name: "dev",
|
|
104
119
|
prefix: "dx",
|
|
@@ -127,4 +142,18 @@ describe("isInitialized", () => {
|
|
|
127
142
|
});
|
|
128
143
|
expect(result).toBe(false);
|
|
129
144
|
});
|
|
145
|
+
test("returns false when resources exist but a required provider is not registered", async ({ cloudAccountService, }) => {
|
|
146
|
+
// Both resources exist
|
|
147
|
+
queryResources.mockResolvedValueOnce({ data: [], totalRecords: 1 });
|
|
148
|
+
queryResources.mockResolvedValueOnce({ data: [], totalRecords: 1 });
|
|
149
|
+
// One provider is not registered
|
|
150
|
+
mockProviderGet.mockResolvedValueOnce({
|
|
151
|
+
registrationState: "NotRegistered",
|
|
152
|
+
});
|
|
153
|
+
const result = await cloudAccountService.isInitialized("sub-1", {
|
|
154
|
+
name: "dev",
|
|
155
|
+
prefix: "dx",
|
|
156
|
+
});
|
|
157
|
+
expect(result).toBe(false);
|
|
158
|
+
});
|
|
130
159
|
});
|
|
@@ -33,6 +33,24 @@ const graphGroupMembershipResponseSchema = z.object({
|
|
|
33
33
|
});
|
|
34
34
|
export class AzureCloudAccountService {
|
|
35
35
|
#credential;
|
|
36
|
+
#requiredResourceProviders = [
|
|
37
|
+
"Microsoft.Advisor",
|
|
38
|
+
"Microsoft.AlertsManagement",
|
|
39
|
+
"Microsoft.ApiManagement",
|
|
40
|
+
"Microsoft.App",
|
|
41
|
+
"Microsoft.Authorization",
|
|
42
|
+
"Microsoft.AzureTerraform",
|
|
43
|
+
"Microsoft.Cache",
|
|
44
|
+
"Microsoft.Cdn",
|
|
45
|
+
"Microsoft.ContainerInstance",
|
|
46
|
+
"Microsoft.CostManagement",
|
|
47
|
+
"Microsoft.DBforPostgreSQL",
|
|
48
|
+
"Microsoft.KeyVault",
|
|
49
|
+
"Microsoft.ServiceBus",
|
|
50
|
+
"Microsoft.Sql",
|
|
51
|
+
"Microsoft.Storage",
|
|
52
|
+
"Microsoft.Web",
|
|
53
|
+
];
|
|
36
54
|
#resourceGraphClient;
|
|
37
55
|
constructor(credential) {
|
|
38
56
|
this.#resourceGraphClient = new ResourceGraphClient(credential);
|
|
@@ -126,6 +144,8 @@ export class AzureCloudAccountService {
|
|
|
126
144
|
assert.equal(cloudAccount.csp, "azure", "Cloud account must be Azure");
|
|
127
145
|
assert.ok(isAzureLocation(cloudAccount.defaultLocation), "The default location of the cloud account is not a valid Azure location");
|
|
128
146
|
const logger = getLogger(["gen", "env"]);
|
|
147
|
+
// Register required resource providers before creating any resources
|
|
148
|
+
await this.#registerProviders(cloudAccount.id);
|
|
129
149
|
const resourceManagementClient = new ResourceManagementClient(this.#credential, cloudAccount.id);
|
|
130
150
|
const short = {
|
|
131
151
|
env: environmentShort[name],
|
|
@@ -210,7 +230,7 @@ export class AzureCloudAccountService {
|
|
|
210
230
|
| where type == 'microsoft.keyvault/vaults'
|
|
211
231
|
| where name matches regex @'${keyVaultResourceName}'
|
|
212
232
|
`;
|
|
213
|
-
const [identityResult, keyVaultResult] = await Promise.all([
|
|
233
|
+
const [identityResult, keyVaultResult, areProvidersRegistered] = await Promise.all([
|
|
214
234
|
this.#resourceGraphClient.resources({
|
|
215
235
|
query: identityQuery,
|
|
216
236
|
subscriptions: [cloudAccountId],
|
|
@@ -219,8 +239,11 @@ export class AzureCloudAccountService {
|
|
|
219
239
|
query: keyVaultQuery,
|
|
220
240
|
subscriptions: [cloudAccountId],
|
|
221
241
|
}),
|
|
242
|
+
this.#areProvidersRegistered(cloudAccountId),
|
|
222
243
|
]);
|
|
223
|
-
const initialized = identityResult.totalRecords > 0 &&
|
|
244
|
+
const initialized = identityResult.totalRecords > 0 &&
|
|
245
|
+
keyVaultResult.totalRecords > 0 &&
|
|
246
|
+
areProvidersRegistered;
|
|
224
247
|
const logger = getLogger(["gen", "env"]);
|
|
225
248
|
logger.debug("subscription {subscriptionId} initialized: {initialized}", {
|
|
226
249
|
initialized,
|
|
@@ -282,6 +305,14 @@ export class AzureCloudAccountService {
|
|
|
282
305
|
type: "azurerm",
|
|
283
306
|
});
|
|
284
307
|
}
|
|
308
|
+
async #areProvidersRegistered(subscriptionId) {
|
|
309
|
+
const client = new ResourceManagementClient(this.#credential, subscriptionId);
|
|
310
|
+
const results = await Promise.all(this.#requiredResourceProviders.map(async (namespace) => {
|
|
311
|
+
const provider = await client.providers.get(namespace);
|
|
312
|
+
return provider.registrationState === "Registered";
|
|
313
|
+
}));
|
|
314
|
+
return results.every(Boolean);
|
|
315
|
+
}
|
|
285
316
|
async #getCurrentPrincipalIds() {
|
|
286
317
|
// Create Graph client with custom auth provider that fetches fresh tokens
|
|
287
318
|
const graphClient = Client.init({
|
|
@@ -318,4 +349,14 @@ export class AzureCloudAccountService {
|
|
|
318
349
|
const allPrincipalIds = new Set([userObjectId, ...groupIds]);
|
|
319
350
|
return allPrincipalIds;
|
|
320
351
|
}
|
|
352
|
+
async #registerProviders(subscriptionId) {
|
|
353
|
+
const logger = getLogger(["dx-cli", "register-providers"]);
|
|
354
|
+
const client = new ResourceManagementClient(this.#credential, subscriptionId);
|
|
355
|
+
logger.info("Registering {count} resource providers on subscription {subscriptionId}", { count: this.#requiredResourceProviders.length, subscriptionId });
|
|
356
|
+
await Promise.all(this.#requiredResourceProviders.map(async (namespace) => {
|
|
357
|
+
await client.providers.register(namespace);
|
|
358
|
+
logger.debug("Registered provider {namespace}", { namespace });
|
|
359
|
+
}));
|
|
360
|
+
logger.info("All resource providers registered on subscription {subscriptionId}", { subscriptionId });
|
|
361
|
+
}
|
|
321
362
|
}
|
|
@@ -2,17 +2,21 @@ import { azure, loadConfig } from "@pagopa/dx-savemoney";
|
|
|
2
2
|
import { Command } from "commander";
|
|
3
3
|
export const makeSavemoneyCommand = () => new Command("savemoney")
|
|
4
4
|
.description("Analyze Azure subscriptions and report unused or inefficient resources")
|
|
5
|
-
.option("-c, --config <path>", "Path to configuration file
|
|
6
|
-
.option("-f, --format <format>", "Report format: json, table,
|
|
7
|
-
.option("-l, --location <string>", "Preferred Azure location for resources", "italynorth")
|
|
8
|
-
.option("-d, --days <number>", "Number of days for metrics analysis", "30")
|
|
5
|
+
.option("-c, --config <path>", "Path to YAML configuration file")
|
|
6
|
+
.option("-f, --format <format>", "Report format: json, table, detailed-json, or lint (default: table)", "table")
|
|
7
|
+
.option("-l, --location <string>", "Preferred Azure location for resources (overrides config file)", "italynorth")
|
|
8
|
+
.option("-d, --days <number>", "Number of days for metrics analysis (overrides config file)", "30")
|
|
9
9
|
.option("-v, --verbose", "Enable verbose logging")
|
|
10
|
+
.option("-t, --tags <tags...>", "Filter resources by tags (key=value key2=value2). Only resources matching ALL specified tags are analyzed.")
|
|
10
11
|
.action(async function (options) {
|
|
11
12
|
try {
|
|
12
|
-
// Load configuration
|
|
13
|
+
// Load configuration from YAML (includes subscriptionIds, location, timespanDays, thresholds)
|
|
13
14
|
const config = await loadConfig(options.config);
|
|
15
|
+
// Parse tag filter
|
|
16
|
+
const filterTags = parseTagsOption(options.tags);
|
|
14
17
|
const finalConfig = {
|
|
15
18
|
...config,
|
|
19
|
+
filterTags,
|
|
16
20
|
preferredLocation: options.location || config.preferredLocation,
|
|
17
21
|
timespanDays: Number.parseInt(options.days, 10) || config.timespanDays,
|
|
18
22
|
verbose: options.verbose || false,
|
|
@@ -24,3 +28,23 @@ export const makeSavemoneyCommand = () => new Command("savemoney")
|
|
|
24
28
|
this.error(`Analysis failed: ${error instanceof Error ? error.message : error}`);
|
|
25
29
|
}
|
|
26
30
|
});
|
|
31
|
+
/**
|
|
32
|
+
* Parses an array of "key=value" strings (from commander variadic option) into a Map<string, string>.
|
|
33
|
+
* Returns an empty Map when the option is not provided or empty.
|
|
34
|
+
* Supports values that contain "=" (only the first "=" is treated as separator).
|
|
35
|
+
*/
|
|
36
|
+
function parseTagsOption(tagsOption) {
|
|
37
|
+
const result = new Map();
|
|
38
|
+
if (!tagsOption || tagsOption.length === 0) {
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
for (const pair of tagsOption) {
|
|
42
|
+
const [rawKey, ...rest] = pair.split("=");
|
|
43
|
+
const key = rawKey?.trim();
|
|
44
|
+
const value = rest.join("=").trim();
|
|
45
|
+
if (key && rest.length > 0) {
|
|
46
|
+
result.set(key, value);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pagopa/dx-cli",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A CLI useful to manage DX tools.",
|
|
6
6
|
"repository": {
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"semver": "^7.7.4",
|
|
48
48
|
"yaml": "^2.8.2",
|
|
49
49
|
"zod": "^4.3.6",
|
|
50
|
-
"@pagopa/dx-savemoney": "^0.
|
|
50
|
+
"@pagopa/dx-savemoney": "^0.2.0"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@tsconfig/node24": "24.0.4",
|
|
@@ -103,6 +103,7 @@ module "azure-{{displayName}}_bootstrap" {
|
|
|
103
103
|
key_vault = {
|
|
104
104
|
name = module.azure-{{displayName}}_core_values.common_key_vault.name
|
|
105
105
|
resource_group_name = module.azure-{{displayName}}_core_values.common_key_vault.resource_group_name
|
|
106
|
+
use_rbac = true
|
|
106
107
|
}
|
|
107
108
|
use_github_app = true
|
|
108
109
|
}
|
|
@@ -7,7 +7,7 @@ repos:
|
|
|
7
7
|
exclude: ^.*/(_modules|modules|\.terraform)(/.*)?$
|
|
8
8
|
files: infra/(resources/prod|repository)
|
|
9
9
|
- repo: https://github.com/antonbabenko/pre-commit-terraform
|
|
10
|
-
rev:
|
|
10
|
+
rev: v{{ preCommitTerraformVersion }}
|
|
11
11
|
hooks:
|
|
12
12
|
- id: terraform_tflint
|
|
13
13
|
args:
|