@intentius/chant-lexicon-aws 0.0.8 → 0.0.9
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 +25 -10
- package/dist/manifest.json +1 -1
- package/dist/meta.json +5743 -896
- package/dist/rules/cf-refs.ts +99 -0
- package/dist/rules/ext001.ts +30 -21
- package/dist/rules/hardcoded-region.ts +1 -0
- package/dist/rules/iam-wildcard.ts +1 -0
- package/dist/rules/s3-encryption.ts +1 -0
- package/dist/rules/waw016.ts +86 -0
- package/dist/rules/waw017.ts +53 -0
- package/dist/rules/waw018.ts +71 -0
- package/dist/rules/waw019.ts +82 -0
- package/dist/rules/waw020.ts +64 -0
- package/dist/rules/waw021.ts +53 -0
- package/dist/rules/waw022.ts +43 -0
- package/dist/rules/waw023.ts +47 -0
- package/dist/rules/waw024.ts +54 -0
- package/dist/rules/waw025.ts +43 -0
- package/dist/rules/waw026.ts +46 -0
- package/dist/rules/waw027.ts +50 -0
- package/dist/rules/waw028.ts +47 -0
- package/dist/rules/waw029.ts +62 -0
- package/dist/rules/waw030.ts +246 -0
- package/dist/skills/chant-aws.md +388 -30
- package/dist/types/index.d.ts +1552 -1528
- package/package.json +2 -2
- package/src/actions/actions.test.ts +75 -0
- package/src/actions/dynamodb.ts +36 -0
- package/src/actions/ecr.ts +9 -0
- package/src/actions/ecs.ts +5 -0
- package/src/actions/iam.ts +3 -0
- package/src/actions/index.ts +9 -0
- package/src/actions/lambda.ts +11 -0
- package/src/actions/logs.ts +4 -0
- package/src/actions/s3.ts +34 -0
- package/src/actions/sns.ts +5 -0
- package/src/actions/sqs.ts +15 -0
- package/src/codegen/__snapshots__/snapshot.test.ts.snap +2 -2
- package/src/codegen/docs-links.test.ts +143 -0
- package/src/codegen/docs.ts +247 -132
- package/src/codegen/generate-lexicon.ts +8 -0
- package/src/codegen/generate-typescript.ts +25 -1
- package/src/composites/composites.test.ts +442 -0
- package/src/composites/fargate-alb.ts +253 -0
- package/src/composites/index.ts +20 -0
- package/src/composites/lambda-api.ts +20 -0
- package/src/composites/lambda-dynamodb.ts +64 -0
- package/src/composites/lambda-eventbridge.ts +36 -0
- package/src/composites/lambda-function.ts +76 -0
- package/src/composites/lambda-s3.ts +72 -0
- package/src/composites/lambda-sns.ts +30 -0
- package/src/composites/lambda-sqs.ts +44 -0
- package/src/composites/scheduled-lambda.ts +37 -0
- package/src/composites/vpc-default.ts +148 -0
- package/src/default-tags.test.ts +38 -0
- package/src/default-tags.ts +77 -0
- package/src/generated/index.d.ts +1552 -1528
- package/src/generated/lexicon-aws.json +5743 -896
- package/src/import/roundtrip-fixtures.test.ts +1 -1
- package/src/index.ts +21 -0
- package/src/integration.test.ts +71 -0
- package/src/intrinsics.ts +24 -13
- package/src/lint/post-synth/cf-refs.ts +99 -0
- package/src/lint/post-synth/ext001.test.ts +214 -31
- package/src/lint/post-synth/ext001.ts +30 -21
- package/src/lint/post-synth/waw013.test.ts +120 -0
- package/src/lint/post-synth/waw014.test.ts +121 -0
- package/src/lint/post-synth/waw015.test.ts +147 -0
- package/src/lint/post-synth/waw016.test.ts +141 -0
- package/src/lint/post-synth/waw016.ts +86 -0
- package/src/lint/post-synth/waw017.test.ts +130 -0
- package/src/lint/post-synth/waw017.ts +53 -0
- package/src/lint/post-synth/waw018.test.ts +109 -0
- package/src/lint/post-synth/waw018.ts +71 -0
- package/src/lint/post-synth/waw019.test.ts +138 -0
- package/src/lint/post-synth/waw019.ts +82 -0
- package/src/lint/post-synth/waw020.test.ts +125 -0
- package/src/lint/post-synth/waw020.ts +64 -0
- package/src/lint/post-synth/waw021.test.ts +81 -0
- package/src/lint/post-synth/waw021.ts +53 -0
- package/src/lint/post-synth/waw022.test.ts +54 -0
- package/src/lint/post-synth/waw022.ts +43 -0
- package/src/lint/post-synth/waw023.test.ts +53 -0
- package/src/lint/post-synth/waw023.ts +47 -0
- package/src/lint/post-synth/waw024.test.ts +64 -0
- package/src/lint/post-synth/waw024.ts +54 -0
- package/src/lint/post-synth/waw025.test.ts +42 -0
- package/src/lint/post-synth/waw025.ts +43 -0
- package/src/lint/post-synth/waw026.test.ts +54 -0
- package/src/lint/post-synth/waw026.ts +46 -0
- package/src/lint/post-synth/waw027.test.ts +63 -0
- package/src/lint/post-synth/waw027.ts +50 -0
- package/src/lint/post-synth/waw028.test.ts +68 -0
- package/src/lint/post-synth/waw028.ts +47 -0
- package/src/lint/post-synth/waw029.test.ts +179 -0
- package/src/lint/post-synth/waw029.ts +62 -0
- package/src/lint/post-synth/waw030.test.ts +800 -0
- package/src/lint/post-synth/waw030.ts +246 -0
- package/src/lint/rules/hardcoded-region.ts +1 -0
- package/src/lint/rules/iam-wildcard.ts +1 -0
- package/src/lint/rules/s3-encryption.ts +1 -0
- package/src/lsp/hover.ts +15 -0
- package/src/nested-stack-integration.test.ts +100 -0
- package/src/nested-stack.ts +1 -1
- package/src/plugin.ts +468 -36
- package/src/serializer.test.ts +330 -2
- package/src/serializer.ts +62 -1
- package/src/spec/fetch.ts +10 -0
- package/src/spec/parse.test.ts +141 -0
- package/src/spec/parse.ts +40 -0
- package/src/taggable.ts +44 -0
- package/src/testdata/nested-stacks/app.ts +26 -0
- package/src/testdata/nested-stacks/network/outputs.ts +17 -0
- package/src/testdata/nested-stacks/network/security.ts +17 -0
- package/src/testdata/nested-stacks/network/vpc.ts +54 -0
package/src/serializer.test.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { createResource } from "@intentius/chant/runtime";
|
|
|
11
11
|
import type { SerializerResult } from "@intentius/chant/serializer";
|
|
12
12
|
import type { BuildResult } from "@intentius/chant/build";
|
|
13
13
|
import { Parameter } from "./parameter";
|
|
14
|
+
import { defaultTags } from "./default-tags";
|
|
14
15
|
|
|
15
16
|
// Mock S3 Bucket for testing
|
|
16
17
|
class MockBucket implements Declarable {
|
|
@@ -18,9 +19,9 @@ class MockBucket implements Declarable {
|
|
|
18
19
|
readonly lexicon = "aws";
|
|
19
20
|
readonly entityType = "AWS::S3::Bucket";
|
|
20
21
|
readonly arn: AttrRef;
|
|
21
|
-
readonly props:
|
|
22
|
+
readonly props: Record<string, unknown>;
|
|
22
23
|
|
|
23
|
-
constructor(props: { BucketName?: string; VersioningConfiguration?: { Status: string } } = {}) {
|
|
24
|
+
constructor(props: { BucketName?: string; VersioningConfiguration?: { Status: string }; Tags?: unknown[] } = {}) {
|
|
24
25
|
this.props = props;
|
|
25
26
|
this.arn = new AttrRef(this, "Arn");
|
|
26
27
|
}
|
|
@@ -489,3 +490,330 @@ describe("nested stack serialization", () => {
|
|
|
489
490
|
});
|
|
490
491
|
});
|
|
491
492
|
});
|
|
493
|
+
|
|
494
|
+
// ── Resource-Level CF Attributes ──────────────────────────
|
|
495
|
+
|
|
496
|
+
// Mock resource that supports the second constructor `attributes` argument
|
|
497
|
+
class MockResourceWithAttrs implements Declarable {
|
|
498
|
+
readonly [DECLARABLE_MARKER] = true as const;
|
|
499
|
+
readonly lexicon = "aws";
|
|
500
|
+
readonly entityType: string;
|
|
501
|
+
readonly props: Record<string, unknown>;
|
|
502
|
+
readonly attributes: Record<string, unknown>;
|
|
503
|
+
|
|
504
|
+
constructor(
|
|
505
|
+
type: string,
|
|
506
|
+
props: Record<string, unknown>,
|
|
507
|
+
attributes: Record<string, unknown> = {},
|
|
508
|
+
) {
|
|
509
|
+
this.entityType = type;
|
|
510
|
+
this.props = props;
|
|
511
|
+
Object.defineProperty(this, "attributes", { value: attributes, enumerable: false, configurable: true });
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
describe("resource-level CF attributes", () => {
|
|
516
|
+
function serialize(...entries: [string, Declarable][]) {
|
|
517
|
+
const entities = new Map<string, Declarable>(entries);
|
|
518
|
+
const output = awsSerializer.serialize(entities);
|
|
519
|
+
return JSON.parse(output as string);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
test("DependsOn with string logical name", () => {
|
|
523
|
+
const res = new MockResourceWithAttrs(
|
|
524
|
+
"AWS::EC2::Instance", { InstanceType: "t3.micro" }, { DependsOn: "Database" },
|
|
525
|
+
);
|
|
526
|
+
const template = serialize(["Server", res]);
|
|
527
|
+
expect(template.Resources.Server.DependsOn).toBe("Database");
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
test("DependsOn with Declarable reference resolves to logical name", () => {
|
|
531
|
+
const bucket = new MockBucket({ BucketName: "data" });
|
|
532
|
+
const fn = new MockResourceWithAttrs(
|
|
533
|
+
"AWS::Lambda::Function", { Runtime: "nodejs20.x" }, { DependsOn: bucket },
|
|
534
|
+
);
|
|
535
|
+
const template = serialize(["DataBucket", bucket], ["Handler", fn]);
|
|
536
|
+
expect(template.Resources.Handler.DependsOn).toBe("DataBucket");
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test("DependsOn with array of mixed strings and Declarables", () => {
|
|
540
|
+
const bucket = new MockBucket({ BucketName: "data" });
|
|
541
|
+
const fn = new MockResourceWithAttrs(
|
|
542
|
+
"AWS::Lambda::Function", { Runtime: "nodejs20.x" },
|
|
543
|
+
{ DependsOn: [bucket, "ExternalResource"] },
|
|
544
|
+
);
|
|
545
|
+
const template = serialize(["DataBucket", bucket], ["Handler", fn]);
|
|
546
|
+
expect(template.Resources.Handler.DependsOn).toEqual(["DataBucket", "ExternalResource"]);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test("single DependsOn array item serializes as string, not array", () => {
|
|
550
|
+
const fn = new MockResourceWithAttrs(
|
|
551
|
+
"AWS::Lambda::Function", { Runtime: "nodejs20.x" }, { DependsOn: ["OnlyOne"] },
|
|
552
|
+
);
|
|
553
|
+
const template = serialize(["Handler", fn]);
|
|
554
|
+
expect(template.Resources.Handler.DependsOn).toBe("OnlyOne");
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test("Condition attribute", () => {
|
|
558
|
+
const res = new MockResourceWithAttrs(
|
|
559
|
+
"AWS::S3::Bucket", { BucketName: "cond-bucket" }, { Condition: "CreateProdResources" },
|
|
560
|
+
);
|
|
561
|
+
const template = serialize(["MyBucket", res]);
|
|
562
|
+
expect(template.Resources.MyBucket.Condition).toBe("CreateProdResources");
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
test("DeletionPolicy attribute", () => {
|
|
566
|
+
const res = new MockResourceWithAttrs(
|
|
567
|
+
"AWS::RDS::DBInstance", { DBInstanceClass: "db.t3.micro" }, { DeletionPolicy: "Retain" },
|
|
568
|
+
);
|
|
569
|
+
const template = serialize(["Database", res]);
|
|
570
|
+
expect(template.Resources.Database.DeletionPolicy).toBe("Retain");
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
test("UpdateReplacePolicy attribute", () => {
|
|
574
|
+
const res = new MockResourceWithAttrs(
|
|
575
|
+
"AWS::RDS::DBInstance", { DBInstanceClass: "db.t3.micro" }, { UpdateReplacePolicy: "Snapshot" },
|
|
576
|
+
);
|
|
577
|
+
const template = serialize(["Database", res]);
|
|
578
|
+
expect(template.Resources.Database.UpdateReplacePolicy).toBe("Snapshot");
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
test("UpdatePolicy attribute", () => {
|
|
582
|
+
const policy = {
|
|
583
|
+
AutoScalingRollingUpdate: {
|
|
584
|
+
MaxBatchSize: 2,
|
|
585
|
+
MinInstancesInService: 1,
|
|
586
|
+
PauseTime: "PT5M",
|
|
587
|
+
WaitOnResourceSignals: true,
|
|
588
|
+
},
|
|
589
|
+
};
|
|
590
|
+
const res = new MockResourceWithAttrs(
|
|
591
|
+
"AWS::AutoScaling::AutoScalingGroup", { MinSize: "1", MaxSize: "4" },
|
|
592
|
+
{ UpdatePolicy: policy },
|
|
593
|
+
);
|
|
594
|
+
const template = serialize(["ASG", res]);
|
|
595
|
+
expect(template.Resources.ASG.UpdatePolicy).toEqual(policy);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test("CreationPolicy attribute", () => {
|
|
599
|
+
const policy = { ResourceSignal: { Count: 3, Timeout: "PT15M" } };
|
|
600
|
+
const res = new MockResourceWithAttrs(
|
|
601
|
+
"AWS::AutoScaling::AutoScalingGroup", { MinSize: "3", MaxSize: "3" },
|
|
602
|
+
{ CreationPolicy: policy },
|
|
603
|
+
);
|
|
604
|
+
const template = serialize(["ASG", res]);
|
|
605
|
+
expect(template.Resources.ASG.CreationPolicy).toEqual(policy);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
test("Metadata attribute with plain object", () => {
|
|
609
|
+
const res = new MockResourceWithAttrs(
|
|
610
|
+
"AWS::EC2::Instance", { InstanceType: "t3.micro" },
|
|
611
|
+
{ Metadata: { "AWS::CloudFormation::Init": { config: { packages: { yum: { httpd: [] } } } } } },
|
|
612
|
+
);
|
|
613
|
+
const template = serialize(["Server", res]);
|
|
614
|
+
expect(template.Resources.Server.Metadata).toEqual({
|
|
615
|
+
"AWS::CloudFormation::Init": { config: { packages: { yum: { httpd: [] } } } },
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
test("Metadata with intrinsic values resolves them", () => {
|
|
620
|
+
const res = new MockResourceWithAttrs(
|
|
621
|
+
"AWS::EC2::Instance", { InstanceType: "t3.micro" },
|
|
622
|
+
{ Metadata: { StackInfo: Sub`${AWS.StackName}-metadata` } },
|
|
623
|
+
);
|
|
624
|
+
const template = serialize(["Server", res]);
|
|
625
|
+
expect(template.Resources.Server.Metadata.StackInfo).toEqual({
|
|
626
|
+
"Fn::Sub": "${AWS::StackName}-metadata",
|
|
627
|
+
});
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
test("all 7 attributes on a single resource", () => {
|
|
631
|
+
const dependency = new MockBucket({ BucketName: "dep" });
|
|
632
|
+
const res = new MockResourceWithAttrs(
|
|
633
|
+
"AWS::RDS::DBInstance", { DBInstanceClass: "db.t3.micro", Engine: "postgres" },
|
|
634
|
+
{
|
|
635
|
+
DependsOn: dependency,
|
|
636
|
+
Condition: "CreateDatabase",
|
|
637
|
+
DeletionPolicy: "Snapshot",
|
|
638
|
+
UpdateReplacePolicy: "Retain",
|
|
639
|
+
UpdatePolicy: { AutoScalingReplacingUpdate: { WillReplace: true } },
|
|
640
|
+
CreationPolicy: { ResourceSignal: { Count: 1, Timeout: "PT10M" } },
|
|
641
|
+
Metadata: { Version: "1.0" },
|
|
642
|
+
},
|
|
643
|
+
);
|
|
644
|
+
const template = serialize(["DepBucket", dependency], ["Database", res]);
|
|
645
|
+
|
|
646
|
+
const db = template.Resources.Database;
|
|
647
|
+
expect(db.DependsOn).toBe("DepBucket");
|
|
648
|
+
expect(db.Condition).toBe("CreateDatabase");
|
|
649
|
+
expect(db.DeletionPolicy).toBe("Snapshot");
|
|
650
|
+
expect(db.UpdateReplacePolicy).toBe("Retain");
|
|
651
|
+
expect(db.UpdatePolicy).toEqual({ AutoScalingReplacingUpdate: { WillReplace: true } });
|
|
652
|
+
expect(db.CreationPolicy).toEqual({ ResourceSignal: { Count: 1, Timeout: "PT10M" } });
|
|
653
|
+
expect(db.Metadata).toEqual({ Version: "1.0" });
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
test("undefined attributes are omitted from CF template", () => {
|
|
657
|
+
const res = new MockResourceWithAttrs(
|
|
658
|
+
"AWS::S3::Bucket", { BucketName: "bucket" },
|
|
659
|
+
{ DependsOn: undefined, Condition: undefined },
|
|
660
|
+
);
|
|
661
|
+
const template = serialize(["MyBucket", res]);
|
|
662
|
+
expect(template.Resources.MyBucket.DependsOn).toBeUndefined();
|
|
663
|
+
expect(template.Resources.MyBucket.Condition).toBeUndefined();
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
test("empty attributes object produces no resource-level attributes", () => {
|
|
667
|
+
const res = new MockResourceWithAttrs("AWS::S3::Bucket", { BucketName: "bucket" }, {});
|
|
668
|
+
const template = serialize(["MyBucket", res]);
|
|
669
|
+
const r = template.Resources.MyBucket;
|
|
670
|
+
expect(r.DependsOn).toBeUndefined();
|
|
671
|
+
expect(r.Condition).toBeUndefined();
|
|
672
|
+
expect(r.DeletionPolicy).toBeUndefined();
|
|
673
|
+
expect(r.Metadata).toBeUndefined();
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
test("resource without attributes property works unchanged", () => {
|
|
677
|
+
// MockBucket has no `attributes` property — should still serialize fine
|
|
678
|
+
const bucket = new MockBucket({ BucketName: "legacy" });
|
|
679
|
+
const template = serialize(["MyBucket", bucket]);
|
|
680
|
+
expect(template.Resources.MyBucket.Type).toBe("AWS::S3::Bucket");
|
|
681
|
+
expect(template.Resources.MyBucket.Properties.BucketName).toBe("legacy");
|
|
682
|
+
expect(template.Resources.MyBucket.DependsOn).toBeUndefined();
|
|
683
|
+
});
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
// ── Default Tags Serialization ──────────────────────────
|
|
687
|
+
|
|
688
|
+
// Mock Lambda Permission (non-taggable) for testing
|
|
689
|
+
class MockPermission implements Declarable {
|
|
690
|
+
readonly [DECLARABLE_MARKER] = true as const;
|
|
691
|
+
readonly lexicon = "aws";
|
|
692
|
+
readonly entityType = "AWS::Lambda::Permission";
|
|
693
|
+
readonly props: { Action: string; FunctionName: string; Principal: string };
|
|
694
|
+
|
|
695
|
+
constructor(props: { Action: string; FunctionName: string; Principal: string }) {
|
|
696
|
+
this.props = props;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Mock Lambda Function (taggable) for testing
|
|
701
|
+
class MockFunction implements Declarable {
|
|
702
|
+
readonly [DECLARABLE_MARKER] = true as const;
|
|
703
|
+
readonly lexicon = "aws";
|
|
704
|
+
readonly entityType = "AWS::Lambda::Function";
|
|
705
|
+
readonly props: Record<string, unknown>;
|
|
706
|
+
|
|
707
|
+
constructor(props: Record<string, unknown> = {}) {
|
|
708
|
+
this.props = props;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
describe("default tags serialization", () => {
|
|
713
|
+
test("DefaultTags entity is not emitted as a CF Resource", () => {
|
|
714
|
+
const entities = new Map<string, Declarable>();
|
|
715
|
+
entities.set("MyBucket", new MockBucket({ BucketName: "bucket" }));
|
|
716
|
+
entities.set("tags", defaultTags([{ Key: "Env", Value: "prod" }]) as unknown as Declarable);
|
|
717
|
+
|
|
718
|
+
const output = awsSerializer.serialize(entities);
|
|
719
|
+
const template = JSON.parse(output as string);
|
|
720
|
+
|
|
721
|
+
expect(template.Resources.tags).toBeUndefined();
|
|
722
|
+
expect(template.Resources.MyBucket).toBeDefined();
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
test("taggable resource gets default tags injected", () => {
|
|
726
|
+
const entities = new Map<string, Declarable>();
|
|
727
|
+
entities.set("MyBucket", new MockBucket({ BucketName: "bucket" }));
|
|
728
|
+
entities.set("tags", defaultTags([{ Key: "Env", Value: "prod" }]) as unknown as Declarable);
|
|
729
|
+
|
|
730
|
+
const output = awsSerializer.serialize(entities);
|
|
731
|
+
const template = JSON.parse(output as string);
|
|
732
|
+
|
|
733
|
+
expect(template.Resources.MyBucket.Properties.Tags).toEqual([
|
|
734
|
+
{ Key: "Env", Value: "prod" },
|
|
735
|
+
]);
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
test("non-taggable resource does NOT get tags", () => {
|
|
739
|
+
const entities = new Map<string, Declarable>();
|
|
740
|
+
entities.set("Perm", new MockPermission({
|
|
741
|
+
Action: "lambda:InvokeFunction",
|
|
742
|
+
FunctionName: "fn",
|
|
743
|
+
Principal: "apigateway.amazonaws.com",
|
|
744
|
+
}));
|
|
745
|
+
entities.set("tags", defaultTags([{ Key: "Env", Value: "prod" }]) as unknown as Declarable);
|
|
746
|
+
|
|
747
|
+
const output = awsSerializer.serialize(entities);
|
|
748
|
+
const template = JSON.parse(output as string);
|
|
749
|
+
|
|
750
|
+
expect(template.Resources.Perm.Properties.Tags).toBeUndefined();
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
test("explicit tags win over defaults on same key", () => {
|
|
754
|
+
const entities = new Map<string, Declarable>();
|
|
755
|
+
entities.set("MyBucket", new MockBucket({
|
|
756
|
+
BucketName: "bucket",
|
|
757
|
+
Tags: [{ Key: "Env", Value: "staging" }],
|
|
758
|
+
}));
|
|
759
|
+
entities.set("tags", defaultTags([
|
|
760
|
+
{ Key: "Env", Value: "prod" },
|
|
761
|
+
{ Key: "Team", Value: "platform" },
|
|
762
|
+
]) as unknown as Declarable);
|
|
763
|
+
|
|
764
|
+
const output = awsSerializer.serialize(entities);
|
|
765
|
+
const template = JSON.parse(output as string);
|
|
766
|
+
|
|
767
|
+
const tags = template.Resources.MyBucket.Properties.Tags;
|
|
768
|
+
expect(tags).toHaveLength(2);
|
|
769
|
+
// Explicit "Env" wins, default "Team" is added
|
|
770
|
+
const envTag = tags.find((t: { Key: string }) => t.Key === "Env");
|
|
771
|
+
const teamTag = tags.find((t: { Key: string }) => t.Key === "Team");
|
|
772
|
+
expect(envTag.Value).toBe("staging");
|
|
773
|
+
expect(teamTag.Value).toBe("platform");
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
test("intrinsic tag values resolve correctly", () => {
|
|
777
|
+
const entities = new Map<string, Declarable>();
|
|
778
|
+
entities.set("MyBucket", new MockBucket({ BucketName: "bucket" }));
|
|
779
|
+
entities.set("tags", defaultTags([
|
|
780
|
+
{ Key: "Stack", Value: Sub`${AWS.StackName}` },
|
|
781
|
+
]) as unknown as Declarable);
|
|
782
|
+
|
|
783
|
+
const output = awsSerializer.serialize(entities);
|
|
784
|
+
const template = JSON.parse(output as string);
|
|
785
|
+
|
|
786
|
+
const tags = template.Resources.MyBucket.Properties.Tags;
|
|
787
|
+
expect(tags).toHaveLength(1);
|
|
788
|
+
expect(tags[0].Key).toBe("Stack");
|
|
789
|
+
expect(tags[0].Value).toEqual({ "Fn::Sub": "${AWS::StackName}" });
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
test("parameter tag values resolve to Ref", () => {
|
|
793
|
+
const env = new Parameter("String", { defaultValue: "dev" });
|
|
794
|
+
const entities = new Map<string, Declarable>();
|
|
795
|
+
entities.set("Env", env as unknown as Declarable);
|
|
796
|
+
entities.set("MyBucket", new MockBucket({ BucketName: "bucket" }));
|
|
797
|
+
entities.set("tags", defaultTags([
|
|
798
|
+
{ Key: "Environment", Value: env },
|
|
799
|
+
]) as unknown as Declarable);
|
|
800
|
+
|
|
801
|
+
const output = awsSerializer.serialize(entities);
|
|
802
|
+
const template = JSON.parse(output as string);
|
|
803
|
+
|
|
804
|
+
const tags = template.Resources.MyBucket.Properties.Tags;
|
|
805
|
+
expect(tags).toHaveLength(1);
|
|
806
|
+
expect(tags[0].Key).toBe("Environment");
|
|
807
|
+
expect(tags[0].Value).toEqual({ Ref: "Env" });
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
test("no defaultTags = no injection (existing behavior)", () => {
|
|
811
|
+
const entities = new Map<string, Declarable>();
|
|
812
|
+
entities.set("MyBucket", new MockBucket({ BucketName: "bucket" }));
|
|
813
|
+
|
|
814
|
+
const output = awsSerializer.serialize(entities);
|
|
815
|
+
const template = JSON.parse(output as string);
|
|
816
|
+
|
|
817
|
+
expect(template.Resources.MyBucket.Properties.Tags).toBeUndefined();
|
|
818
|
+
});
|
|
819
|
+
});
|
package/src/serializer.ts
CHANGED
|
@@ -5,6 +5,9 @@ import type { LexiconOutput } from "@intentius/chant/lexicon-output";
|
|
|
5
5
|
import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
|
|
6
6
|
import { isChildProject, type ChildProjectInstance } from "@intentius/chant/child-project";
|
|
7
7
|
import { isStackOutput, type StackOutput } from "@intentius/chant/stack-output";
|
|
8
|
+
import { resolveDependsOn } from "@intentius/chant/resource-attributes";
|
|
9
|
+
import { isDefaultTags, type TagEntry } from "./default-tags";
|
|
10
|
+
import { loadTaggableResources } from "./taggable";
|
|
8
11
|
|
|
9
12
|
/**
|
|
10
13
|
* Check if a declarable is a CoreParameter
|
|
@@ -49,6 +52,10 @@ interface CFResource {
|
|
|
49
52
|
Properties?: Record<string, unknown>;
|
|
50
53
|
DependsOn?: string | string[];
|
|
51
54
|
Condition?: string;
|
|
55
|
+
DeletionPolicy?: string;
|
|
56
|
+
UpdateReplacePolicy?: string;
|
|
57
|
+
UpdatePolicy?: unknown;
|
|
58
|
+
CreationPolicy?: unknown;
|
|
52
59
|
Metadata?: Record<string, unknown>;
|
|
53
60
|
}
|
|
54
61
|
|
|
@@ -141,6 +148,14 @@ function serializeToTemplate(
|
|
|
141
148
|
entityNames.set(entity, name);
|
|
142
149
|
}
|
|
143
150
|
|
|
151
|
+
// Collect default tags
|
|
152
|
+
const defaultTagEntries: TagEntry[] = [];
|
|
153
|
+
for (const [, entity] of entities) {
|
|
154
|
+
if (isDefaultTags(entity)) {
|
|
155
|
+
defaultTagEntries.push(...entity.tags);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
144
159
|
// Process entities
|
|
145
160
|
for (const [name, entity] of entities) {
|
|
146
161
|
// Skip StackOutput entities — they go in the Outputs section
|
|
@@ -148,6 +163,11 @@ function serializeToTemplate(
|
|
|
148
163
|
continue;
|
|
149
164
|
}
|
|
150
165
|
|
|
166
|
+
// Skip DefaultTags entities — handled via tag injection below
|
|
167
|
+
if (isDefaultTags(entity)) {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
151
171
|
if (isCoreParameter(entity)) {
|
|
152
172
|
if (!template.Parameters) {
|
|
153
173
|
template.Parameters = {};
|
|
@@ -202,15 +222,56 @@ function serializeToTemplate(
|
|
|
202
222
|
Type: entity.entityType,
|
|
203
223
|
};
|
|
204
224
|
|
|
225
|
+
// Read resource-level attributes from the second constructor arg
|
|
226
|
+
const attrs = ("attributes" in entity && typeof entity.attributes === "object" && entity.attributes !== null)
|
|
227
|
+
? entity.attributes as Record<string, unknown>
|
|
228
|
+
: undefined;
|
|
229
|
+
|
|
230
|
+
if (attrs) {
|
|
231
|
+
// DependsOn — resolve Declarable refs to logical names
|
|
232
|
+
if (attrs.DependsOn !== undefined) {
|
|
233
|
+
const resolved = resolveDependsOn(attrs.DependsOn, entityNames, name);
|
|
234
|
+
if (resolved.length > 0) {
|
|
235
|
+
resource.DependsOn = resolved.length === 1 ? resolved[0] : resolved;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// Pass-through attributes
|
|
239
|
+
if (attrs.Condition) resource.Condition = attrs.Condition as string;
|
|
240
|
+
if (attrs.DeletionPolicy) resource.DeletionPolicy = attrs.DeletionPolicy as string;
|
|
241
|
+
if (attrs.UpdateReplacePolicy) resource.UpdateReplacePolicy = attrs.UpdateReplacePolicy as string;
|
|
242
|
+
if (attrs.UpdatePolicy) resource.UpdatePolicy = attrs.UpdatePolicy;
|
|
243
|
+
if (attrs.CreationPolicy) resource.CreationPolicy = attrs.CreationPolicy;
|
|
244
|
+
if (attrs.Metadata) resource.Metadata = toCFValue(attrs.Metadata, entityNames) as Record<string, unknown>;
|
|
245
|
+
}
|
|
246
|
+
|
|
205
247
|
const properties = toProperties(entity, entityNames);
|
|
206
248
|
if (properties) {
|
|
207
|
-
|
|
249
|
+
if (Object.keys(properties).length > 0) {
|
|
250
|
+
resource.Properties = properties;
|
|
251
|
+
}
|
|
208
252
|
}
|
|
209
253
|
|
|
210
254
|
template.Resources[name] = resource;
|
|
211
255
|
}
|
|
212
256
|
}
|
|
213
257
|
|
|
258
|
+
// Inject default tags into taggable resources
|
|
259
|
+
if (defaultTagEntries.length > 0) {
|
|
260
|
+
const taggable = loadTaggableResources();
|
|
261
|
+
for (const [, resource] of Object.entries(template.Resources)) {
|
|
262
|
+
if (!taggable.has(resource.Type)) continue;
|
|
263
|
+
const resolved = defaultTagEntries.map(t => ({
|
|
264
|
+
Key: t.Key,
|
|
265
|
+
Value: toCFValue(t.Value, entityNames),
|
|
266
|
+
}));
|
|
267
|
+
const explicit = (resource.Properties?.Tags ?? []) as Array<{ Key: string }>;
|
|
268
|
+
const explicitKeys = new Set(explicit.map(t => t.Key));
|
|
269
|
+
const merged = [...resolved.filter(t => !explicitKeys.has(t.Key)), ...explicit];
|
|
270
|
+
if (!resource.Properties) resource.Properties = {};
|
|
271
|
+
resource.Properties.Tags = merged;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
214
275
|
// Emit StackOutput entities as CF Outputs
|
|
215
276
|
for (const [name, entity] of entities) {
|
|
216
277
|
if (isStackOutput(entity)) {
|
package/src/spec/fetch.ts
CHANGED
|
@@ -15,6 +15,16 @@ export interface CFNSchema {
|
|
|
15
15
|
createOnlyProperties?: string[];
|
|
16
16
|
writeOnlyProperties?: string[];
|
|
17
17
|
primaryIdentifier?: string[];
|
|
18
|
+
deprecatedProperties?: string[];
|
|
19
|
+
conditionalCreateOnlyProperties?: string[];
|
|
20
|
+
replacementStrategy?: string;
|
|
21
|
+
tagging?: {
|
|
22
|
+
taggable?: boolean;
|
|
23
|
+
tagOnCreate?: boolean;
|
|
24
|
+
tagUpdatable?: boolean;
|
|
25
|
+
cloudFormationSystemTags?: boolean;
|
|
26
|
+
tagProperty?: string;
|
|
27
|
+
};
|
|
18
28
|
additionalProperties?: boolean;
|
|
19
29
|
}
|
|
20
30
|
|
package/src/spec/parse.test.ts
CHANGED
|
@@ -20,6 +20,7 @@ const sampleBucketSchema = JSON.stringify({
|
|
|
20
20
|
},
|
|
21
21
|
AccessControl: {
|
|
22
22
|
type: "string",
|
|
23
|
+
description: "This is a legacy property, and it is not recommended for most use cases.",
|
|
23
24
|
enum: ["Private", "PublicRead", "PublicReadWrite"],
|
|
24
25
|
},
|
|
25
26
|
},
|
|
@@ -42,6 +43,7 @@ const sampleBucketSchema = JSON.stringify({
|
|
|
42
43
|
additionalProperties: false,
|
|
43
44
|
},
|
|
44
45
|
},
|
|
46
|
+
deprecatedProperties: ["/properties/AccessControl"],
|
|
45
47
|
readOnlyProperties: [
|
|
46
48
|
"/properties/Arn",
|
|
47
49
|
"/properties/DomainName",
|
|
@@ -134,6 +136,145 @@ describe("parseCFNSchema", () => {
|
|
|
134
136
|
expect(result.resource.properties).toEqual([]);
|
|
135
137
|
expect(result.resource.attributes).toEqual([]);
|
|
136
138
|
});
|
|
139
|
+
|
|
140
|
+
// --- Deprecated properties ---
|
|
141
|
+
|
|
142
|
+
test("parses explicit deprecatedProperties", () => {
|
|
143
|
+
const result = parseCFNSchema(sampleBucketSchema);
|
|
144
|
+
expect(result.resource.deprecatedProperties).toContain("AccessControl");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("mines deprecation from property description", () => {
|
|
148
|
+
const schema = JSON.stringify({
|
|
149
|
+
typeName: "AWS::Test::DescMined",
|
|
150
|
+
properties: {
|
|
151
|
+
OldProp: { type: "string", description: "This property is deprecated. Use NewProp instead." },
|
|
152
|
+
NewProp: { type: "string", description: "The replacement property" },
|
|
153
|
+
},
|
|
154
|
+
additionalProperties: false,
|
|
155
|
+
});
|
|
156
|
+
const result = parseCFNSchema(schema);
|
|
157
|
+
expect(result.resource.deprecatedProperties).toContain("OldProp");
|
|
158
|
+
expect(result.resource.deprecatedProperties).not.toContain("NewProp");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("mines 'legacy' keyword from description", () => {
|
|
162
|
+
const schema = JSON.stringify({
|
|
163
|
+
typeName: "AWS::Test::Legacy",
|
|
164
|
+
properties: {
|
|
165
|
+
LegacyProp: { type: "string", description: "This is a legacy property, not recommended." },
|
|
166
|
+
},
|
|
167
|
+
additionalProperties: false,
|
|
168
|
+
});
|
|
169
|
+
const result = parseCFNSchema(schema);
|
|
170
|
+
expect(result.resource.deprecatedProperties).toContain("LegacyProp");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("deduplicates when both explicit and description flag same property", () => {
|
|
174
|
+
// sampleBucketSchema has AccessControl in both deprecatedProperties array and description
|
|
175
|
+
const result = parseCFNSchema(sampleBucketSchema);
|
|
176
|
+
const count = result.resource.deprecatedProperties.filter((p) => p === "AccessControl").length;
|
|
177
|
+
expect(count).toBe(1);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("empty deprecatedProperties when none found", () => {
|
|
181
|
+
const schema = JSON.stringify({
|
|
182
|
+
typeName: "AWS::Test::Clean",
|
|
183
|
+
properties: {
|
|
184
|
+
Name: { type: "string", description: "A normal property" },
|
|
185
|
+
},
|
|
186
|
+
additionalProperties: false,
|
|
187
|
+
});
|
|
188
|
+
const result = parseCFNSchema(schema);
|
|
189
|
+
expect(result.resource.deprecatedProperties).toEqual([]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// --- Tagging metadata ---
|
|
193
|
+
|
|
194
|
+
test("parses tagging metadata when taggable", () => {
|
|
195
|
+
const schema = JSON.stringify({
|
|
196
|
+
typeName: "AWS::Test::Taggable",
|
|
197
|
+
properties: { Tags: { type: "array" } },
|
|
198
|
+
tagging: { taggable: true, tagOnCreate: true, tagUpdatable: true },
|
|
199
|
+
additionalProperties: false,
|
|
200
|
+
});
|
|
201
|
+
const result = parseCFNSchema(schema);
|
|
202
|
+
expect(result.resource.tagging).toEqual({
|
|
203
|
+
taggable: true,
|
|
204
|
+
tagOnCreate: true,
|
|
205
|
+
tagUpdatable: true,
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("omits tagging when not taggable", () => {
|
|
210
|
+
const schema = JSON.stringify({
|
|
211
|
+
typeName: "AWS::Test::NotTaggable",
|
|
212
|
+
properties: { Name: { type: "string" } },
|
|
213
|
+
tagging: { taggable: false },
|
|
214
|
+
additionalProperties: false,
|
|
215
|
+
});
|
|
216
|
+
const result = parseCFNSchema(schema);
|
|
217
|
+
expect(result.resource.tagging).toBeUndefined();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("omits tagging when absent", () => {
|
|
221
|
+
const schema = JSON.stringify({
|
|
222
|
+
typeName: "AWS::Test::NoTagging",
|
|
223
|
+
properties: { Name: { type: "string" } },
|
|
224
|
+
additionalProperties: false,
|
|
225
|
+
});
|
|
226
|
+
const result = parseCFNSchema(schema);
|
|
227
|
+
expect(result.resource.tagging).toBeUndefined();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// --- Replacement strategy ---
|
|
231
|
+
|
|
232
|
+
test("parses replacementStrategy", () => {
|
|
233
|
+
const schema = JSON.stringify({
|
|
234
|
+
typeName: "AWS::Test::DeleteFirst",
|
|
235
|
+
properties: { Name: { type: "string" } },
|
|
236
|
+
replacementStrategy: "delete_then_create",
|
|
237
|
+
additionalProperties: false,
|
|
238
|
+
});
|
|
239
|
+
const result = parseCFNSchema(schema);
|
|
240
|
+
expect(result.resource.replacementStrategy).toBe("delete_then_create");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("omits replacementStrategy when absent", () => {
|
|
244
|
+
const schema = JSON.stringify({
|
|
245
|
+
typeName: "AWS::Test::NoStrategy",
|
|
246
|
+
properties: { Name: { type: "string" } },
|
|
247
|
+
additionalProperties: false,
|
|
248
|
+
});
|
|
249
|
+
const result = parseCFNSchema(schema);
|
|
250
|
+
expect(result.resource.replacementStrategy).toBeUndefined();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// --- Conditional create-only ---
|
|
254
|
+
|
|
255
|
+
test("parses conditionalCreateOnlyProperties", () => {
|
|
256
|
+
const schema = JSON.stringify({
|
|
257
|
+
typeName: "AWS::Test::ConditionalCreate",
|
|
258
|
+
properties: {
|
|
259
|
+
Name: { type: "string" },
|
|
260
|
+
Engine: { type: "string" },
|
|
261
|
+
},
|
|
262
|
+
conditionalCreateOnlyProperties: ["/properties/Engine"],
|
|
263
|
+
additionalProperties: false,
|
|
264
|
+
});
|
|
265
|
+
const result = parseCFNSchema(schema);
|
|
266
|
+
expect(result.resource.conditionalCreateOnly).toContain("Engine");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("empty conditionalCreateOnly when absent", () => {
|
|
270
|
+
const schema = JSON.stringify({
|
|
271
|
+
typeName: "AWS::Test::NoConditional",
|
|
272
|
+
properties: { Name: { type: "string" } },
|
|
273
|
+
additionalProperties: false,
|
|
274
|
+
});
|
|
275
|
+
const result = parseCFNSchema(schema);
|
|
276
|
+
expect(result.resource.conditionalCreateOnly).toEqual([]);
|
|
277
|
+
});
|
|
137
278
|
});
|
|
138
279
|
|
|
139
280
|
describe("cfnShortName", () => {
|
package/src/spec/parse.ts
CHANGED
|
@@ -52,6 +52,10 @@ export interface ParsedResource {
|
|
|
52
52
|
createOnly: string[];
|
|
53
53
|
writeOnly: string[];
|
|
54
54
|
primaryIdentifier: string[];
|
|
55
|
+
deprecatedProperties: string[];
|
|
56
|
+
conditionalCreateOnly: string[];
|
|
57
|
+
replacementStrategy?: "delete_then_create" | "create_then_delete";
|
|
58
|
+
tagging?: { taggable: boolean; tagOnCreate: boolean; tagUpdatable: boolean };
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
export interface SchemaParseResult {
|
|
@@ -137,6 +141,38 @@ export function parseCFNSchema(data: string | Buffer): SchemaParseResult {
|
|
|
137
141
|
}
|
|
138
142
|
}
|
|
139
143
|
|
|
144
|
+
// --- Deprecated properties: explicit + description-mined ---
|
|
145
|
+
const deprecatedSet = new Set<string>(
|
|
146
|
+
stripPointerPaths(schema.deprecatedProperties ?? []),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const DEPRECATION_RE = /\bdeprecated\b|\blegacy\b|no longer (available|recommended|used|supported)|is not recommended|has been discontinued/i;
|
|
150
|
+
|
|
151
|
+
// Mine top-level property descriptions
|
|
152
|
+
if (schema.properties) {
|
|
153
|
+
for (const [name, prop] of Object.entries(schema.properties)) {
|
|
154
|
+
if (prop.description && DEPRECATION_RE.test(prop.description)) {
|
|
155
|
+
deprecatedSet.add(name);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// --- Tagging ---
|
|
161
|
+
let tagging: ParsedResource["tagging"];
|
|
162
|
+
if (schema.tagging && schema.tagging.taggable) {
|
|
163
|
+
tagging = {
|
|
164
|
+
taggable: true,
|
|
165
|
+
tagOnCreate: schema.tagging.tagOnCreate ?? false,
|
|
166
|
+
tagUpdatable: schema.tagging.tagUpdatable ?? false,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// --- Replacement strategy ---
|
|
171
|
+
let replacementStrategy: ParsedResource["replacementStrategy"];
|
|
172
|
+
if (schema.replacementStrategy === "delete_then_create" || schema.replacementStrategy === "create_then_delete") {
|
|
173
|
+
replacementStrategy = schema.replacementStrategy;
|
|
174
|
+
}
|
|
175
|
+
|
|
140
176
|
return {
|
|
141
177
|
resource: {
|
|
142
178
|
typeName: schema.typeName,
|
|
@@ -145,6 +181,10 @@ export function parseCFNSchema(data: string | Buffer): SchemaParseResult {
|
|
|
145
181
|
createOnly: stripPointerPaths(schema.createOnlyProperties ?? []),
|
|
146
182
|
writeOnly: stripPointerPaths(schema.writeOnlyProperties ?? []),
|
|
147
183
|
primaryIdentifier: stripPointerPaths(schema.primaryIdentifier ?? []),
|
|
184
|
+
deprecatedProperties: [...deprecatedSet],
|
|
185
|
+
conditionalCreateOnly: stripPointerPaths(schema.conditionalCreateOnlyProperties ?? []),
|
|
186
|
+
...(replacementStrategy && { replacementStrategy }),
|
|
187
|
+
...(tagging && { tagging }),
|
|
148
188
|
},
|
|
149
189
|
propertyTypes,
|
|
150
190
|
enums,
|