@pagopa/dx-cli 0.22.1 → 0.22.3
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/dist/adapters/commander/__tests__/env.test.js +29 -27
- package/dist/adapters/commander/env.d.ts +1 -1
- package/dist/adapters/commander/env.js +2 -4
- package/dist/adapters/plop/generators/environment/__tests__/actions.test.js +26 -0
- package/dist/adapters/plop/generators/environment/__tests__/prompts.test.js +6 -3
- package/dist/adapters/plop/generators/environment/actions.js +15 -9
- package/dist/adapters/plop/generators/environment/index.js +2 -0
- package/dist/adapters/plop/generators/environment/prompts.d.ts +2 -2
- package/dist/adapters/plop/generators/environment/prompts.js +9 -2
- package/dist/adapters/plop/helpers/__tests__/terraform-state-key.test.d.ts +1 -0
- package/dist/adapters/plop/helpers/__tests__/terraform-state-key.test.js +35 -0
- package/dist/adapters/plop/helpers/terraform-state-key.d.ts +33 -0
- package/dist/adapters/plop/helpers/terraform-state-key.js +30 -0
- package/package.json +2 -1
- package/templates/environment/bootstrapper/{{env.name}}/main.tf.hbs +1 -1
|
@@ -1,43 +1,45 @@
|
|
|
1
|
+
import fc from "fast-check";
|
|
1
2
|
/**
|
|
2
3
|
* Tests for the CLI environment schema.
|
|
3
4
|
* Validates that `cliEnvSchema` correctly parses `process.env`.
|
|
4
5
|
*/
|
|
5
6
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { z } from "zod";
|
|
6
8
|
import { cliEnvSchema } from "../env.js";
|
|
9
|
+
// The accepted stringbool values mirror those recognised by z.stringbool().
|
|
10
|
+
const CI_TRUTHY = ["true", "1", "yes", "y", "on"];
|
|
11
|
+
const CI_FALSEY = ["false", "0", "no", "n", "off"];
|
|
12
|
+
const CI_ACCEPTED = [...CI_TRUTHY, ...CI_FALSEY];
|
|
7
13
|
describe("cliEnvSchema", () => {
|
|
8
14
|
afterEach(() => {
|
|
9
15
|
vi.unstubAllEnvs();
|
|
10
16
|
});
|
|
11
17
|
describe("CI field", () => {
|
|
12
|
-
it("
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
it("should be true for truthy string-bool values", () => {
|
|
19
|
+
fc.assert(fc.property(fc.constantFrom(...CI_TRUTHY), (value) => {
|
|
20
|
+
vi.stubEnv("CI", value);
|
|
21
|
+
const result = cliEnvSchema.safeParse(process.env);
|
|
22
|
+
expect(result.success).toBe(true);
|
|
23
|
+
expect(result.success && result.data.CI).toBe(true);
|
|
24
|
+
}));
|
|
17
25
|
});
|
|
18
|
-
it("
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
26
|
+
it("should be false for falsey string-bool values or when unset", () => {
|
|
27
|
+
fc.assert(fc.property(fc.constantFrom(...CI_FALSEY, undefined), (value) => {
|
|
28
|
+
vi.stubEnv("CI", value);
|
|
29
|
+
const result = cliEnvSchema.safeParse(process.env);
|
|
30
|
+
expect(result.success).toBe(true);
|
|
31
|
+
expect(result.success && result.data.CI).toBe(false);
|
|
32
|
+
}));
|
|
23
33
|
});
|
|
24
|
-
it("
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
34
|
+
it("should not parse unrecognised values", () => {
|
|
35
|
+
fc.assert(fc.property(
|
|
36
|
+
// Every string not in the accepted list
|
|
37
|
+
fc.string().filter((s) => !CI_ACCEPTED.includes(s.toLowerCase())), (value) => {
|
|
38
|
+
vi.stubEnv("CI", value);
|
|
39
|
+
const result = cliEnvSchema.safeParse(process.env);
|
|
40
|
+
expect(result.success).toBe(false);
|
|
41
|
+
expect(result.error).toBeInstanceOf(z.ZodError);
|
|
42
|
+
}));
|
|
29
43
|
});
|
|
30
|
-
it("captures CI=1 for numeric-style env vars", () => {
|
|
31
|
-
vi.stubEnv("CI", "1");
|
|
32
|
-
const result = cliEnvSchema.safeParse(process.env);
|
|
33
|
-
expect(result.success).toBe(true);
|
|
34
|
-
expect(result.success && result.data.CI).toBe("1");
|
|
35
|
-
});
|
|
36
|
-
});
|
|
37
|
-
it("passes through an object with unrelated env keys without failing", () => {
|
|
38
|
-
vi.stubEnv("CI", "true");
|
|
39
|
-
const result = cliEnvSchema.safeParse(process.env);
|
|
40
|
-
expect(result.success).toBe(true);
|
|
41
|
-
expect(result.success && result.data.CI).toBe("true");
|
|
42
44
|
});
|
|
43
45
|
});
|
|
@@ -9,9 +9,7 @@
|
|
|
9
9
|
import { z } from "zod";
|
|
10
10
|
export const cliEnvSchema = z
|
|
11
11
|
.object({
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
// convention used by `is-interactive` and `ora`.
|
|
15
|
-
CI: z.string().optional(),
|
|
12
|
+
// Use a truthy value to enable CI mode.
|
|
13
|
+
CI: z.stringbool().default(false),
|
|
16
14
|
})
|
|
17
15
|
.loose();
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { z } from "zod";
|
|
2
3
|
import getActions from "../actions.js";
|
|
4
|
+
const terraformBackendActionSchema = z.object({
|
|
5
|
+
data: z.object({
|
|
6
|
+
terraformBackendKey: z.string(),
|
|
7
|
+
}),
|
|
8
|
+
type: z.literal("addMany"),
|
|
9
|
+
});
|
|
3
10
|
export const getPayload = (includeInit = false) => {
|
|
4
11
|
const cloudAccount = {
|
|
5
12
|
csp: "azure",
|
|
@@ -63,4 +70,23 @@ describe("actions", () => {
|
|
|
63
70
|
.map((action) => action.type);
|
|
64
71
|
expect(actionTypes).toEqual(actionsOrder);
|
|
65
72
|
});
|
|
73
|
+
test.each([
|
|
74
|
+
{
|
|
75
|
+
expectedKeys: [
|
|
76
|
+
"dx/mytest/bootstrapper.tfstate",
|
|
77
|
+
"dx/mytest/core.tfstate",
|
|
78
|
+
],
|
|
79
|
+
payload: getPayload(true),
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
expectedKeys: ["dx/mytest/bootstrapper.tfstate"],
|
|
83
|
+
payload: getPayload(false),
|
|
84
|
+
},
|
|
85
|
+
])("uses prefix/domain/scope state keys in shared backend actions", ({ expectedKeys, payload }) => {
|
|
86
|
+
const actions = getActions("/templates/path")(payload);
|
|
87
|
+
const terraformBackendKeys = actions
|
|
88
|
+
.filter((action) => terraformBackendActionSchema.safeParse(action).success)
|
|
89
|
+
.map((action) => action.data.terraformBackendKey);
|
|
90
|
+
expect(terraformBackendKeys).toEqual(expectedKeys);
|
|
91
|
+
});
|
|
66
92
|
});
|
|
@@ -18,10 +18,13 @@ describe("workspaceSchema — domain transforms", () => {
|
|
|
18
18
|
expect(result.success).toBe(true);
|
|
19
19
|
expect(result.success && result.data.domain).toBe("api");
|
|
20
20
|
});
|
|
21
|
-
it("
|
|
21
|
+
it("requires domain to be provided", () => {
|
|
22
22
|
const result = workspaceSchema.safeParse({});
|
|
23
|
-
expect(result.success).toBe(
|
|
24
|
-
|
|
23
|
+
expect(result.success).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
it("rejects domains that would create nested paths", () => {
|
|
26
|
+
const result = workspaceSchema.safeParse({ domain: "core/platform" });
|
|
27
|
+
expect(result.success).toBe(false);
|
|
25
28
|
});
|
|
26
29
|
});
|
|
27
30
|
describe("formatInitializationDetails", () => {
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { getLogger } from "@logtape/logtape";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { formatTerraformCode } from "../../../terraform/fmt.js";
|
|
4
|
+
import { terraformStateKey } from "../../helpers/terraform-state-key.js";
|
|
4
5
|
import { payloadSchema } from "./prompts.js";
|
|
5
|
-
const addModule = (
|
|
6
|
+
const addModule = (context, templatesPath, init = false) => {
|
|
7
|
+
const { env } = context;
|
|
6
8
|
const cloudAccountsByCsp = Object.groupBy(env.cloudAccounts, (account) => account.csp);
|
|
7
9
|
const includesProdIO = env.cloudAccounts.some((account) => account.displayName === "PROD-IO");
|
|
8
10
|
const cwd = process.cwd();
|
|
9
|
-
return (name
|
|
11
|
+
return (name) => [
|
|
10
12
|
{
|
|
11
13
|
base: templatesPath,
|
|
12
14
|
data: { cloudAccountsByCsp, includesProdIO, init },
|
|
@@ -19,7 +21,11 @@ const addModule = (env, templatesPath, init = false) => {
|
|
|
19
21
|
},
|
|
20
22
|
{
|
|
21
23
|
base: path.join(templatesPath, "shared"),
|
|
22
|
-
data: {
|
|
24
|
+
data: {
|
|
25
|
+
cloudAccountsByCsp,
|
|
26
|
+
init,
|
|
27
|
+
terraformBackendKey: terraformStateKey(context, name),
|
|
28
|
+
},
|
|
23
29
|
destination: path.join(cwd, "infra", name, "{{env.name}}"),
|
|
24
30
|
force: true,
|
|
25
31
|
templateFiles: path.join(templatesPath, "shared"),
|
|
@@ -41,17 +47,17 @@ const addWorkflowModule = (templatesPath) => {
|
|
|
41
47
|
};
|
|
42
48
|
};
|
|
43
49
|
export default function getActions(templatesPath) {
|
|
44
|
-
return (
|
|
50
|
+
return (input) => {
|
|
45
51
|
const logger = getLogger(["gen", "env"]);
|
|
46
|
-
logger.debug("
|
|
47
|
-
const { env,
|
|
48
|
-
const addEnvironmentModule = addModule(env, templatesPath, !!init);
|
|
52
|
+
logger.debug("environment generator input {input}", { input });
|
|
53
|
+
const { env, init, workspace } = payloadSchema.parse(input);
|
|
54
|
+
const addEnvironmentModule = addModule({ env, workspace }, templatesPath, !!init);
|
|
49
55
|
const actions = [
|
|
50
56
|
{
|
|
51
57
|
type: "getTerraformBackend",
|
|
52
58
|
},
|
|
53
59
|
addWorkflowModule(templatesPath),
|
|
54
|
-
...addEnvironmentModule("bootstrapper"
|
|
60
|
+
...addEnvironmentModule("bootstrapper"),
|
|
55
61
|
];
|
|
56
62
|
if (init) {
|
|
57
63
|
actions.unshift({
|
|
@@ -61,7 +67,7 @@ export default function getActions(templatesPath) {
|
|
|
61
67
|
? "provisionTerraformBackend"
|
|
62
68
|
: "getTerraformBackend",
|
|
63
69
|
});
|
|
64
|
-
actions.push(...addEnvironmentModule("core"
|
|
70
|
+
actions.push(...addEnvironmentModule("core"));
|
|
65
71
|
}
|
|
66
72
|
return actions;
|
|
67
73
|
};
|
|
@@ -4,6 +4,7 @@ import setProvisionTerraformBackendAction from "../../actions/provision-terrafor
|
|
|
4
4
|
import setEnvShortHelper from "../../helpers/env-short.js";
|
|
5
5
|
import setEqHelper from "../../helpers/eq.js";
|
|
6
6
|
import setResourcePrefixHelper from "../../helpers/resource-prefix.js";
|
|
7
|
+
import setTerraformStateKeyHelper from "../../helpers/terraform-state-key.js";
|
|
7
8
|
import getActions from "./actions.js";
|
|
8
9
|
import getPrompts, { payloadSchema } from "./prompts.js";
|
|
9
10
|
export const PLOP_ENVIRONMENT_GENERATOR_NAME = "DX_DeploymentEnvironment";
|
|
@@ -12,6 +13,7 @@ export default function (plop, templatesPath, cloudAccountRepository, cloudAccou
|
|
|
12
13
|
setEnvShortHelper(plop);
|
|
13
14
|
setResourcePrefixHelper(plop);
|
|
14
15
|
setEqHelper(plop);
|
|
16
|
+
setTerraformStateKeyHelper(plop);
|
|
15
17
|
setGetTerraformBackend(plop, cloudAccountService);
|
|
16
18
|
setProvisionTerraformBackendAction(plop, cloudAccountService);
|
|
17
19
|
setInitCloudAccountsAction(plop, cloudAccountService, gitHubService);
|
|
@@ -9,7 +9,7 @@ type InquirerChoice<T> = inquirer.Separator | {
|
|
|
9
9
|
import { z } from "zod/v4";
|
|
10
10
|
import { GitHubRepo } from "../../../../domain/github-repo.js";
|
|
11
11
|
export declare const workspaceSchema: z.ZodObject<{
|
|
12
|
-
domain: z.
|
|
12
|
+
domain: z.ZodString;
|
|
13
13
|
}, z.core.$strip>;
|
|
14
14
|
export declare const payloadSchema: z.ZodObject<{
|
|
15
15
|
env: z.ZodObject<{
|
|
@@ -60,7 +60,7 @@ export declare const payloadSchema: z.ZodObject<{
|
|
|
60
60
|
}, z.core.$strip>>;
|
|
61
61
|
tags: z.ZodRecord<z.ZodString, z.ZodString>;
|
|
62
62
|
workspace: z.ZodObject<{
|
|
63
|
-
domain: z.
|
|
63
|
+
domain: z.ZodString;
|
|
64
64
|
}, z.core.$strip>;
|
|
65
65
|
}, z.core.$strip>;
|
|
66
66
|
export type Payload = z.infer<typeof payloadSchema>;
|
|
@@ -19,8 +19,14 @@ const initSchema = z.object({
|
|
|
19
19
|
.optional(),
|
|
20
20
|
});
|
|
21
21
|
const tagsSchema = z.record(z.string(), z.string().min(1));
|
|
22
|
+
const workspaceDomainSchema = z
|
|
23
|
+
.string()
|
|
24
|
+
.trim()
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.min(1, "Domain is required")
|
|
27
|
+
.regex(/^[a-z0-9-]+$/, "Domain may contain only lowercase letters, numbers, and hyphens");
|
|
22
28
|
export const workspaceSchema = z.object({
|
|
23
|
-
domain:
|
|
29
|
+
domain: workspaceDomainSchema,
|
|
24
30
|
});
|
|
25
31
|
export const payloadSchema = z.object({
|
|
26
32
|
env: environmentSchema,
|
|
@@ -76,9 +82,10 @@ const prompts = (deps) => async (inquirer) => {
|
|
|
76
82
|
},
|
|
77
83
|
{
|
|
78
84
|
filter: (value) => value.trim().toLowerCase(),
|
|
79
|
-
message: "Domain
|
|
85
|
+
message: "Domain",
|
|
80
86
|
name: "workspace.domain",
|
|
81
87
|
type: "input",
|
|
88
|
+
validate: validatePrompt(workspaceSchema.shape.domain),
|
|
82
89
|
},
|
|
83
90
|
{
|
|
84
91
|
choices: [
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Terraform state key generation used by the environment generator.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { terraformStateKey } from "../terraform-state-key.js";
|
|
6
|
+
const createMockContext = (overrides = {}) => ({
|
|
7
|
+
env: {
|
|
8
|
+
cloudAccounts: [],
|
|
9
|
+
name: "dev",
|
|
10
|
+
prefix: "dx",
|
|
11
|
+
},
|
|
12
|
+
workspace: {
|
|
13
|
+
domain: "shared",
|
|
14
|
+
},
|
|
15
|
+
...overrides,
|
|
16
|
+
});
|
|
17
|
+
describe("terraformStateKey", () => {
|
|
18
|
+
it("returns keys using the prefix/domain/scope convention", () => {
|
|
19
|
+
const result = terraformStateKey(createMockContext(), "bootstrapper");
|
|
20
|
+
expect(result).toBe("dx/shared/bootstrapper.tfstate");
|
|
21
|
+
});
|
|
22
|
+
it("supports hyphenated names", () => {
|
|
23
|
+
const result = terraformStateKey(createMockContext({
|
|
24
|
+
workspace: { domain: "playground" },
|
|
25
|
+
}), "mcp-server");
|
|
26
|
+
expect(result).toBe("dx/playground/mcp-server.tfstate");
|
|
27
|
+
});
|
|
28
|
+
it("rejects names that would create nested paths", () => {
|
|
29
|
+
expect(() => terraformStateKey(createMockContext(), "core/root")).toThrow(/Terraform state name may contain only lowercase letters, numbers, and hyphens/u);
|
|
30
|
+
});
|
|
31
|
+
it("rejects names with uppercase letters or surrounding spaces", () => {
|
|
32
|
+
expect(() => terraformStateKey(createMockContext(), "Mcp-Server")).toThrow(/Terraform state name may contain only lowercase letters, numbers, and hyphens/u);
|
|
33
|
+
expect(() => terraformStateKey(createMockContext(), " mcp-server ")).toThrow(/Terraform state name may contain only lowercase letters, numbers, and hyphens/u);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terraform state key helper.
|
|
3
|
+
*
|
|
4
|
+
* Keeps the DX environment generator aligned with the shared
|
|
5
|
+
* prefix/domain/scope.tfstate convention for remote state keys.
|
|
6
|
+
*/
|
|
7
|
+
import { type NodePlopAPI } from "node-plop";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
declare const terraformStateContextSchema: z.ZodObject<{
|
|
10
|
+
env: z.ZodObject<{
|
|
11
|
+
cloudAccounts: z.ZodArray<z.ZodObject<{
|
|
12
|
+
csp: z.ZodDefault<z.ZodEnum<{
|
|
13
|
+
azure: "azure";
|
|
14
|
+
}>>;
|
|
15
|
+
defaultLocation: z.ZodString;
|
|
16
|
+
displayName: z.ZodString;
|
|
17
|
+
id: z.ZodString;
|
|
18
|
+
}, z.core.$strip>>;
|
|
19
|
+
name: z.ZodEnum<{
|
|
20
|
+
dev: "dev";
|
|
21
|
+
prod: "prod";
|
|
22
|
+
uat: "uat";
|
|
23
|
+
}>;
|
|
24
|
+
prefix: z.ZodString;
|
|
25
|
+
}, z.core.$strip>;
|
|
26
|
+
workspace: z.ZodObject<{
|
|
27
|
+
domain: z.ZodString;
|
|
28
|
+
}, z.core.$strip>;
|
|
29
|
+
}, z.core.$strip>;
|
|
30
|
+
type TerraformStateContext = z.infer<typeof terraformStateContextSchema>;
|
|
31
|
+
export declare const terraformStateKey: (context: TerraformStateContext, name: string) => string;
|
|
32
|
+
declare const _default: (plop: NodePlopAPI) => void;
|
|
33
|
+
export default _default;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { payloadSchema } from "../generators/environment/prompts.js";
|
|
3
|
+
const terraformStateContextSchema = payloadSchema.pick({
|
|
4
|
+
env: true,
|
|
5
|
+
workspace: true,
|
|
6
|
+
});
|
|
7
|
+
const terraformStateNameSchema = z
|
|
8
|
+
.string()
|
|
9
|
+
.regex(/^[a-z0-9-]+$/, "Terraform state name may contain only lowercase letters, numbers, and hyphens");
|
|
10
|
+
export const terraformStateKey = (context, name) => {
|
|
11
|
+
const parsedName = terraformStateNameSchema.safeParse(name);
|
|
12
|
+
if (!parsedName.success) {
|
|
13
|
+
throw new Error(parsedName.error.issues[0]?.message ?? "Invalid Terraform state name", {
|
|
14
|
+
cause: parsedName.error,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
return `${context.env.prefix}/${context.workspace.domain}/${parsedName.data}.tfstate`;
|
|
18
|
+
};
|
|
19
|
+
export default (plop) => {
|
|
20
|
+
plop.setHelper("terraformStateKey", (input, name) => {
|
|
21
|
+
const context = terraformStateContextSchema.safeParse(input);
|
|
22
|
+
if (!context.success) {
|
|
23
|
+
throw new Error(context.error.issues[0]?.message ??
|
|
24
|
+
"Invalid Terraform state helper input", {
|
|
25
|
+
cause: context.error,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return terraformStateKey(context.data, name);
|
|
29
|
+
});
|
|
30
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pagopa/dx-cli",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A CLI useful to manage DX tools.",
|
|
6
6
|
"repository": {
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
"@types/semver": "^7.7.1",
|
|
59
59
|
"@vitest/coverage-v8": "^3.2.4",
|
|
60
60
|
"eslint": "^10.3.0",
|
|
61
|
+
"fast-check": "^4.8.0",
|
|
61
62
|
"memfs": "^4.57.2",
|
|
62
63
|
"plop": "^4.0.5",
|
|
63
64
|
"prettier": "3.8.3",
|
|
@@ -60,7 +60,7 @@ module "azure-{{displayName}}_core_values" {
|
|
|
60
60
|
storage_account_name = "{{@root.terraform.backend.storageAccountName}}"
|
|
61
61
|
subscription_id = "{{@root.terraform.backend.subscriptionId}}"
|
|
62
62
|
container_name = "terraform-state"
|
|
63
|
-
key = "{{@root
|
|
63
|
+
key = "{{terraformStateKey @root "core"}}"
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|