@intentius/chant-lexicon-aws 0.1.13 → 0.1.15
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/integrity.json +4 -4
- package/dist/manifest.json +1 -1
- package/dist/meta.json +1086 -92
- package/dist/types/index.d.ts +1419 -79
- package/package.json +1 -1
- package/src/codegen/versions.ts +12 -8
- package/src/composites/fargate-alb.ts +77 -16
- package/src/generated/index.d.ts +1419 -79
- package/src/generated/index.ts +116 -12
- package/src/generated/lexicon-aws.json +1086 -92
- package/src/import/live-export-io.test.ts +61 -0
- package/src/import/live-export.test.ts +78 -0
- package/src/import/live-export.ts +38 -0
- package/src/lifecycle-integration.test.ts +105 -0
- package/src/plugin.ts +41 -1
- package/src/serializer-ownership.test.ts +37 -0
- package/src/serializer.ts +16 -5
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, test, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// AWS exportResources reaches the cloud through the runtime adapter's spawn
|
|
4
|
+
// (not node:child_process), so the I/O seam is the runtime-adapter module.
|
|
5
|
+
const spawnMock = vi.fn();
|
|
6
|
+
vi.mock("@intentius/chant/runtime-adapter", () => ({
|
|
7
|
+
getRuntime: () => ({ spawn: spawnMock }),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
import { awsPlugin } from "../plugin";
|
|
11
|
+
|
|
12
|
+
const liveTemplate = {
|
|
13
|
+
AWSTemplateFormatVersion: "2010-09-09",
|
|
14
|
+
Resources: {
|
|
15
|
+
MyBucket: {
|
|
16
|
+
Type: "AWS::S3::Bucket",
|
|
17
|
+
Properties: { BucketName: "my-bucket" },
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe("aws exportResources I/O glue (#160)", () => {
|
|
23
|
+
beforeEach(() => spawnMock.mockReset());
|
|
24
|
+
|
|
25
|
+
test("spawns `cloudformation get-template` for the env stack and maps the body", async () => {
|
|
26
|
+
spawnMock.mockResolvedValue({
|
|
27
|
+
stdout: JSON.stringify({ TemplateBody: liveTemplate }),
|
|
28
|
+
stderr: "",
|
|
29
|
+
exitCode: 0,
|
|
30
|
+
});
|
|
31
|
+
const ir = await awsPlugin.exportResources!({ environment: "prod" });
|
|
32
|
+
expect(spawnMock).toHaveBeenCalledTimes(1);
|
|
33
|
+
const argv = spawnMock.mock.calls[0][0] as string[];
|
|
34
|
+
expect(argv).toEqual(
|
|
35
|
+
expect.arrayContaining([
|
|
36
|
+
"aws", "cloudformation", "get-template",
|
|
37
|
+
"--stack-name", "prod",
|
|
38
|
+
"--output", "json",
|
|
39
|
+
]),
|
|
40
|
+
);
|
|
41
|
+
expect(ir.resources.map((r) => r.logicalId)).toEqual(["MyBucket"]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("a non-zero exit throws with the stderr surfaced", async () => {
|
|
45
|
+
spawnMock.mockResolvedValue({
|
|
46
|
+
stdout: "",
|
|
47
|
+
stderr: "Stack with id ghost does not exist",
|
|
48
|
+
exitCode: 254,
|
|
49
|
+
});
|
|
50
|
+
await expect(awsPlugin.exportResources!({ environment: "ghost" })).rejects.toThrow(
|
|
51
|
+
/Failed to get template for stack "ghost".*does not exist/,
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("a stack with no TemplateBody throws", async () => {
|
|
56
|
+
spawnMock.mockResolvedValue({ stdout: JSON.stringify({}), stderr: "", exitCode: 0 });
|
|
57
|
+
await expect(awsPlugin.exportResources!({ environment: "prod" })).rejects.toThrow(
|
|
58
|
+
/no TemplateBody/,
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { parseStackTemplate } from "./live-export";
|
|
3
|
+
import { CFGenerator } from "./generator";
|
|
4
|
+
|
|
5
|
+
// Mimics what `aws cloudformation get-template --output json` returns:
|
|
6
|
+
// a JSON object under TemplateBody.
|
|
7
|
+
const liveTemplate = {
|
|
8
|
+
AWSTemplateFormatVersion: "2010-09-09",
|
|
9
|
+
Resources: {
|
|
10
|
+
MyBucket: {
|
|
11
|
+
Type: "AWS::S3::Bucket",
|
|
12
|
+
Properties: { BucketName: "my-bucket", VersioningConfiguration: { Status: "Enabled" } },
|
|
13
|
+
},
|
|
14
|
+
MyQueue: {
|
|
15
|
+
Type: "AWS::SQS::Queue",
|
|
16
|
+
Properties: { QueueName: "my-queue" },
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe("AWS exportResources mapping (#115)", () => {
|
|
22
|
+
test("maps a live CloudFormation template body to export IR", () => {
|
|
23
|
+
const ir = parseStackTemplate(liveTemplate);
|
|
24
|
+
expect(ir.resources.map((r) => r.logicalId).sort()).toEqual(["MyBucket", "MyQueue"]);
|
|
25
|
+
const bucket = ir.resources.find((r) => r.logicalId === "MyBucket")!;
|
|
26
|
+
expect(bucket.type).toBe("AWS::S3::Bucket");
|
|
27
|
+
expect(bucket.properties.BucketName).toBe("my-bucket");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("accepts a stringified template body (YAML-origin stacks)", () => {
|
|
31
|
+
const ir = parseStackTemplate(JSON.stringify(liveTemplate));
|
|
32
|
+
expect(ir.resources).toHaveLength(2);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("selector by name narrows the export", () => {
|
|
36
|
+
const ir = parseStackTemplate(liveTemplate, { name: "MyQueue" });
|
|
37
|
+
expect(ir.resources.map((r) => r.logicalId)).toEqual(["MyQueue"]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("selector by type narrows the export", () => {
|
|
41
|
+
const ir = parseStackTemplate(liveTemplate, { type: "AWS::S3::Bucket" });
|
|
42
|
+
expect(ir.resources.map((r) => r.logicalId)).toEqual(["MyBucket"]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("owned filter keeps only resources carrying the chant marker (#120)", () => {
|
|
46
|
+
const mixed = {
|
|
47
|
+
AWSTemplateFormatVersion: "2010-09-09",
|
|
48
|
+
Resources: {
|
|
49
|
+
Mine: {
|
|
50
|
+
Type: "AWS::S3::Bucket",
|
|
51
|
+
Properties: {
|
|
52
|
+
BucketName: "mine",
|
|
53
|
+
Tags: [
|
|
54
|
+
{ Key: "chant:managed-by", Value: "chant" },
|
|
55
|
+
{ Key: "chant:stack", Value: "billing" },
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
Theirs: {
|
|
60
|
+
Type: "AWS::S3::Bucket",
|
|
61
|
+
Properties: { BucketName: "theirs", Tags: [{ Key: "team", Value: "other" }] },
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
const ir = parseStackTemplate(mixed, undefined, true);
|
|
66
|
+
expect(ir.resources.map((r) => r.logicalId)).toEqual(["Mine"]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("export IR feeds CFGenerator (templateGenerator) unchanged", () => {
|
|
70
|
+
const ir = parseStackTemplate(liveTemplate);
|
|
71
|
+
const files = new CFGenerator().generate(ir);
|
|
72
|
+
expect(files.length).toBeGreaterThan(0);
|
|
73
|
+
const all = files.map((f) => f.content).join("\n");
|
|
74
|
+
// CFGenerator emits chant TypeScript (constructors), not raw CFN type strings.
|
|
75
|
+
expect(all).toContain("new Bucket(");
|
|
76
|
+
expect(all).toContain("BucketName");
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ExportedTemplate, ResourceSelector } from "@intentius/chant/lexicon";
|
|
2
|
+
import { hasOwnershipMarker, tagArrayToMap } from "@intentius/chant/ownership";
|
|
3
|
+
import { CFParser } from "./parser";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Map a live CloudFormation template body to full-fidelity export IR.
|
|
7
|
+
*
|
|
8
|
+
* `aws cloudformation get-template` returns the template body as a JSON object
|
|
9
|
+
* (for JSON templates) or a string (for YAML). `CFParser` already turns either
|
|
10
|
+
* into `TemplateIR` — the same IR the import path uses — so the result feeds
|
|
11
|
+
* `templateGenerator()` (CFGenerator) unchanged. Pure: all I/O stays in the
|
|
12
|
+
* caller.
|
|
13
|
+
*/
|
|
14
|
+
export function parseStackTemplate(
|
|
15
|
+
templateBody: unknown,
|
|
16
|
+
selector?: ResourceSelector,
|
|
17
|
+
owned?: boolean,
|
|
18
|
+
): ExportedTemplate {
|
|
19
|
+
const content =
|
|
20
|
+
typeof templateBody === "string" ? templateBody : JSON.stringify(templateBody);
|
|
21
|
+
const ir = new CFParser().parse(content);
|
|
22
|
+
|
|
23
|
+
const hasSelector = selector && (selector.type !== undefined || selector.name !== undefined);
|
|
24
|
+
if (!hasSelector && !owned) return ir;
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
...ir,
|
|
28
|
+
resources: ir.resources.filter((r) => {
|
|
29
|
+
if (selector?.type !== undefined && r.type !== selector.type) return false;
|
|
30
|
+
if (selector?.name !== undefined && r.logicalId !== selector.name) return false;
|
|
31
|
+
if (owned) {
|
|
32
|
+
const tags = (r.properties as { Tags?: Array<{ Key?: string; Value?: unknown }> }).Tags;
|
|
33
|
+
if (!hasOwnershipMarker(tagArrayToMap(tags), "aws-tag")) return false;
|
|
34
|
+
}
|
|
35
|
+
return true;
|
|
36
|
+
}),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-lexicon lifecycle integration (#163) — AWS row.
|
|
3
|
+
*
|
|
4
|
+
* Drives the REAL awsPlugin through core's live-import driver
|
|
5
|
+
* (`liveImportFromPlugins`) and the changeset path (`buildChangeSet`), with the
|
|
6
|
+
* cloud edge (the runtime adapter's spawn) mocked. Proves the seam between core
|
|
7
|
+
* and a real lexicon — not a `createMockPlugin` fixture.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, test, expect, vi, beforeEach } from "vitest";
|
|
10
|
+
import { mkdtempSync, rmSync, readdirSync, readFileSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
const spawnMock = vi.fn();
|
|
15
|
+
vi.mock("@intentius/chant/runtime-adapter", () => ({
|
|
16
|
+
getRuntime: () => ({ spawn: spawnMock }),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
const { awsPlugin } = await import("./plugin");
|
|
20
|
+
const { liveImportFromPlugins } = await import("@intentius/chant/cli/commands/import");
|
|
21
|
+
const { buildChangeSet } = await import("@intentius/chant/lifecycle/change-set");
|
|
22
|
+
|
|
23
|
+
const liveTemplate = {
|
|
24
|
+
AWSTemplateFormatVersion: "2010-09-09",
|
|
25
|
+
Resources: {
|
|
26
|
+
MyBucket: { Type: "AWS::S3::Bucket", Properties: { BucketName: "my-bucket" } },
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const ok = (stdout: string) => ({ stdout, stderr: "", exitCode: 0 });
|
|
31
|
+
|
|
32
|
+
describe("aws lifecycle integration (#163)", () => {
|
|
33
|
+
beforeEach(() => spawnMock.mockReset());
|
|
34
|
+
|
|
35
|
+
test("live-import driver: real exportResources → IR → generated source", async () => {
|
|
36
|
+
spawnMock.mockResolvedValue(ok(JSON.stringify({ TemplateBody: liveTemplate })));
|
|
37
|
+
const output = mkdtempSync(join(tmpdir(), "chant-aws-li-"));
|
|
38
|
+
try {
|
|
39
|
+
const result = await liveImportFromPlugins([awsPlugin], {
|
|
40
|
+
environment: "prod",
|
|
41
|
+
output,
|
|
42
|
+
force: true,
|
|
43
|
+
});
|
|
44
|
+
expect(result.success).toBe(true);
|
|
45
|
+
expect(result.generatedFiles.length).toBeGreaterThan(0);
|
|
46
|
+
const all = readdirSync(output)
|
|
47
|
+
.map((f) => readFileSync(join(output, f), "utf-8"))
|
|
48
|
+
.join("\n");
|
|
49
|
+
expect(all).toContain("new Bucket(");
|
|
50
|
+
expect(all).toContain("BucketName");
|
|
51
|
+
} finally {
|
|
52
|
+
rmSync(output, { recursive: true, force: true });
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("changeset path: real describeResources → buildChangeSet verdicts", async () => {
|
|
57
|
+
// describe-stack-resources then describe-stacks.
|
|
58
|
+
spawnMock.mockImplementation((argv?: string[]) => {
|
|
59
|
+
if (argv?.includes("describe-stack-resources")) {
|
|
60
|
+
return Promise.resolve(
|
|
61
|
+
ok(
|
|
62
|
+
JSON.stringify({
|
|
63
|
+
StackResources: [
|
|
64
|
+
{
|
|
65
|
+
LogicalResourceId: "MyBucket",
|
|
66
|
+
ResourceType: "AWS::S3::Bucket",
|
|
67
|
+
PhysicalResourceId: "my-bucket",
|
|
68
|
+
ResourceStatus: "CREATE_COMPLETE",
|
|
69
|
+
Timestamp: "2026-01-01T00:00:00Z",
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
}),
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
return Promise.resolve(ok(JSON.stringify({ Stacks: [{ Outputs: [] }] })));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const observedNow = await awsPlugin.describeResources!({
|
|
80
|
+
environment: "prod",
|
|
81
|
+
buildOutput: "",
|
|
82
|
+
entityNames: ["MyBucket"],
|
|
83
|
+
});
|
|
84
|
+
expect(observedNow.MyBucket?.type).toBe("AWS::S3::Bucket");
|
|
85
|
+
|
|
86
|
+
// Declared "MyQueue" is absent from live → create; live "MyBucket" is
|
|
87
|
+
// undeclared and unmarked → adopt (never delete without ownership).
|
|
88
|
+
const cs = buildChangeSet("prod", {
|
|
89
|
+
declared: new Set(["MyQueue"]),
|
|
90
|
+
observedNow,
|
|
91
|
+
observedThen: undefined,
|
|
92
|
+
});
|
|
93
|
+
const byName = Object.fromEntries(cs.entries.map((e) => [e.name, e.action]));
|
|
94
|
+
expect(byName.MyQueue).toBe("create");
|
|
95
|
+
expect(byName.MyBucket).toBe("adopt");
|
|
96
|
+
|
|
97
|
+
// Declared + live with no drift → noop.
|
|
98
|
+
const cs2 = buildChangeSet("prod", {
|
|
99
|
+
declared: new Set(["MyBucket"]),
|
|
100
|
+
observedNow,
|
|
101
|
+
observedThen: undefined,
|
|
102
|
+
});
|
|
103
|
+
expect(cs2.entries.find((e) => e.name === "MyBucket")!.action).toBe("noop");
|
|
104
|
+
});
|
|
105
|
+
});
|
package/src/plugin.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRequire } from "module";
|
|
2
|
-
import type { LexiconPlugin, IntrinsicDef, ResourceMetadata } from "@intentius/chant/lexicon";
|
|
2
|
+
import type { LexiconPlugin, IntrinsicDef, ResourceMetadata, ExportedTemplate, ResourceSelector } from "@intentius/chant/lexicon";
|
|
3
3
|
const require = createRequire(import.meta.url);
|
|
4
4
|
import type { LintRule } from "@intentius/chant/lint/rule";
|
|
5
5
|
import type { TemplateParser } from "@intentius/chant/import/parser";
|
|
@@ -13,6 +13,7 @@ import { fileURLToPath } from "url";
|
|
|
13
13
|
import { awsSerializer } from "./serializer";
|
|
14
14
|
import { CFParser } from "./import/parser";
|
|
15
15
|
import { CFGenerator } from "./import/generator";
|
|
16
|
+
import { parseStackTemplate } from "./import/live-export";
|
|
16
17
|
import { awsCompletions } from "./lsp/completions";
|
|
17
18
|
import { awsHover } from "./lsp/hover";
|
|
18
19
|
|
|
@@ -475,11 +476,21 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
475
476
|
environment: string;
|
|
476
477
|
buildOutput: string;
|
|
477
478
|
entityNames: string[];
|
|
479
|
+
owned?: boolean;
|
|
478
480
|
}): Promise<Record<string, ResourceMetadata>> {
|
|
479
481
|
const { getRuntime } = await import("@intentius/chant/runtime-adapter");
|
|
480
482
|
const rt = getRuntime();
|
|
481
483
|
const resources: Record<string, ResourceMetadata> = {};
|
|
482
484
|
|
|
485
|
+
if (options.owned) {
|
|
486
|
+
// describe-stack-resources does not return tags, so ownership cannot be
|
|
487
|
+
// determined here. Degrade to detect-only rather than silently filtering.
|
|
488
|
+
// eslint-disable-next-line no-console
|
|
489
|
+
console.warn(
|
|
490
|
+
"[aws] ownership filter unavailable on describeResources (no tags from describe-stack-resources) — returning all; use `chant import --from <env> --owned` for ownership-filtered export",
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
483
494
|
// Derive stack name: environment-based convention
|
|
484
495
|
// Try to parse the build output to detect stack name from Metadata or use convention
|
|
485
496
|
const stackName = `${options.environment}`;
|
|
@@ -556,6 +567,35 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
556
567
|
return resources;
|
|
557
568
|
},
|
|
558
569
|
|
|
570
|
+
async exportResources(options: {
|
|
571
|
+
environment: string;
|
|
572
|
+
selector?: ResourceSelector;
|
|
573
|
+
owned?: boolean;
|
|
574
|
+
}): Promise<ExportedTemplate> {
|
|
575
|
+
const { getRuntime } = await import("@intentius/chant/runtime-adapter");
|
|
576
|
+
const rt = getRuntime();
|
|
577
|
+
|
|
578
|
+
// Same stack-name convention as describeResources.
|
|
579
|
+
const stackName = `${options.environment}`;
|
|
580
|
+
|
|
581
|
+
const result = await rt.spawn([
|
|
582
|
+
"aws", "cloudformation", "get-template",
|
|
583
|
+
"--stack-name", stackName,
|
|
584
|
+
"--template-stage", "Original",
|
|
585
|
+
"--output", "json",
|
|
586
|
+
]);
|
|
587
|
+
if (result.exitCode !== 0) {
|
|
588
|
+
throw new Error(`Failed to get template for stack "${stackName}": ${result.stderr}`);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const parsed = JSON.parse(result.stdout) as { TemplateBody?: unknown };
|
|
592
|
+
if (parsed.TemplateBody === undefined) {
|
|
593
|
+
throw new Error(`Stack "${stackName}" returned no TemplateBody`);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return parseStackTemplate(parsed.TemplateBody, options.selector, options.owned);
|
|
597
|
+
},
|
|
598
|
+
|
|
559
599
|
mcpTools() {
|
|
560
600
|
return [
|
|
561
601
|
{
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { awsSerializer } from "./serializer";
|
|
3
|
+
import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
|
|
4
|
+
import { AttrRef } from "@intentius/chant/attrref";
|
|
5
|
+
|
|
6
|
+
class MockBucket implements Declarable {
|
|
7
|
+
readonly [DECLARABLE_MARKER] = true as const;
|
|
8
|
+
readonly lexicon = "aws";
|
|
9
|
+
readonly entityType = "AWS::S3::Bucket";
|
|
10
|
+
readonly arn: AttrRef;
|
|
11
|
+
readonly props: Record<string, unknown>;
|
|
12
|
+
constructor(props: Record<string, unknown> = {}) {
|
|
13
|
+
this.props = props;
|
|
14
|
+
this.arn = new AttrRef(this, "Arn");
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("awsSerializer ownership stamping (#119)", () => {
|
|
19
|
+
test("stamps the ownership marker as tags when context.ownership is set", () => {
|
|
20
|
+
const entities = new Map<string, Declarable>([["MyBucket", new MockBucket({ BucketName: "b" })]]);
|
|
21
|
+
const out = awsSerializer.serialize(entities, [], { ownership: { stack: "billing", env: "prod" } });
|
|
22
|
+
const template = JSON.parse(out as string);
|
|
23
|
+
const tags = template.Resources.MyBucket.Properties.Tags as Array<{ Key: string; Value: string }>;
|
|
24
|
+
const byKey = Object.fromEntries(tags.map((t) => [t.Key, t.Value]));
|
|
25
|
+
expect(byKey["chant:managed-by"]).toBe("chant");
|
|
26
|
+
expect(byKey["chant:stack"]).toBe("billing");
|
|
27
|
+
expect(byKey["chant:env"]).toBe("prod");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("no ownership context → no chant tags injected", () => {
|
|
31
|
+
const entities = new Map<string, Declarable>([["MyBucket", new MockBucket({ BucketName: "b" })]]);
|
|
32
|
+
const out = awsSerializer.serialize(entities, []);
|
|
33
|
+
const template = JSON.parse(out as string);
|
|
34
|
+
const tags = (template.Resources.MyBucket.Properties?.Tags ?? []) as Array<{ Key: string }>;
|
|
35
|
+
expect(tags.some((t) => t.Key.startsWith("chant:"))).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
});
|
package/src/serializer.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Declarable, CoreParameter } from "@intentius/chant/declarable";
|
|
2
2
|
import { isPropertyDeclarable } from "@intentius/chant/declarable";
|
|
3
|
-
import type { Serializer, SerializerResult } from "@intentius/chant/serializer";
|
|
3
|
+
import type { Serializer, SerializerResult, SerializeContext } from "@intentius/chant/serializer";
|
|
4
|
+
import { ownershipEntries, type OwnershipMarker } from "@intentius/chant/ownership";
|
|
4
5
|
import type { LexiconOutput } from "@intentius/chant/lexicon-output";
|
|
5
6
|
import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
|
|
6
7
|
import { isChildProject, type ChildProjectInstance } from "@intentius/chant/child-project";
|
|
@@ -131,6 +132,7 @@ function serializeToTemplate(
|
|
|
131
132
|
outputs?: LexiconOutput[],
|
|
132
133
|
extraParameters?: Record<string, CFParameter>,
|
|
133
134
|
extraOutputs?: Record<string, CFOutput>,
|
|
135
|
+
ownership?: OwnershipMarker,
|
|
134
136
|
): CFTemplate {
|
|
135
137
|
const template: CFTemplate = {
|
|
136
138
|
AWSTemplateFormatVersion: "2010-09-09",
|
|
@@ -148,8 +150,15 @@ function serializeToTemplate(
|
|
|
148
150
|
entityNames.set(entity, name);
|
|
149
151
|
}
|
|
150
152
|
|
|
151
|
-
// Collect default tags
|
|
153
|
+
// Collect default tags. The ownership marker is stamped as tags, seeded
|
|
154
|
+
// first so user default tags (and explicit per-resource tags) take precedence
|
|
155
|
+
// on key collisions.
|
|
152
156
|
const defaultTagEntries: TagEntry[] = [];
|
|
157
|
+
if (ownership) {
|
|
158
|
+
for (const [Key, Value] of Object.entries(ownershipEntries("aws-tag", ownership))) {
|
|
159
|
+
defaultTagEntries.push({ Key, Value });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
153
162
|
for (const [, entity] of entities) {
|
|
154
163
|
if (isDefaultTags(entity)) {
|
|
155
164
|
defaultTagEntries.push(...entity.tags);
|
|
@@ -323,7 +332,9 @@ export const awsSerializer: Serializer = {
|
|
|
323
332
|
name: "aws",
|
|
324
333
|
rulePrefix: "WAW",
|
|
325
334
|
|
|
326
|
-
serialize(entities: Map<string, Declarable>, outputs?: LexiconOutput[]): string | SerializerResult {
|
|
335
|
+
serialize(entities: Map<string, Declarable>, outputs?: LexiconOutput[], context?: SerializeContext): string | SerializerResult {
|
|
336
|
+
const ownership = context?.ownership;
|
|
337
|
+
|
|
327
338
|
// Check if any entities are child projects (nested stacks)
|
|
328
339
|
const childProjects = new Map<string, ChildProjectInstance>();
|
|
329
340
|
let hasChildProjects = false;
|
|
@@ -337,7 +348,7 @@ export const awsSerializer: Serializer = {
|
|
|
337
348
|
|
|
338
349
|
// No nested stacks — use the simple path
|
|
339
350
|
if (!hasChildProjects) {
|
|
340
|
-
const template = serializeToTemplate(entities, outputs);
|
|
351
|
+
const template = serializeToTemplate(entities, outputs, undefined, undefined, ownership);
|
|
341
352
|
return JSON.stringify(template, null, 2);
|
|
342
353
|
}
|
|
343
354
|
|
|
@@ -377,7 +388,7 @@ export const awsSerializer: Serializer = {
|
|
|
377
388
|
}
|
|
378
389
|
|
|
379
390
|
// Serialize the parent template (ChildProjectInstance entities become CF::Stack resources)
|
|
380
|
-
const parentTemplate = serializeToTemplate(entities, outputs, parentParams);
|
|
391
|
+
const parentTemplate = serializeToTemplate(entities, outputs, parentParams, undefined, ownership);
|
|
381
392
|
const primary = JSON.stringify(parentTemplate, null, 2);
|
|
382
393
|
|
|
383
394
|
return {
|