@intentius/chant-lexicon-aws 0.45.0 → 0.49.0
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/api/read-client.d.ts +10 -2
- package/dist/api/read-client.d.ts.map +1 -1
- package/dist/components/cloud-executor.d.ts.map +1 -1
- package/dist/composites/agentcore-agent.d.ts +26 -1
- package/dist/composites/agentcore-agent.d.ts.map +1 -1
- package/dist/composites/bucket-deployment.d.ts +82 -0
- package/dist/composites/bucket-deployment.d.ts.map +1 -0
- package/dist/composites/dynamodb-table.d.ts +45 -0
- package/dist/composites/dynamodb-table.d.ts.map +1 -0
- package/dist/composites/ec2-instance-bundle.d.ts +61 -0
- package/dist/composites/ec2-instance-bundle.d.ts.map +1 -0
- package/dist/composites/ecr-repository.d.ts +49 -0
- package/dist/composites/ecr-repository.d.ts.map +1 -0
- package/dist/composites/fargate-service.d.ts +16 -0
- package/dist/composites/fargate-service.d.ts.map +1 -1
- package/dist/composites/index.d.ts +16 -2
- package/dist/composites/index.d.ts.map +1 -1
- package/dist/composites/lambda-function.d.ts +4 -4
- package/dist/composites/lambda-function.d.ts.map +1 -1
- package/dist/composites/monitoring-stack.d.ts +99 -0
- package/dist/composites/monitoring-stack.d.ts.map +1 -0
- package/dist/composites/nlb-service.d.ts +48 -0
- package/dist/composites/nlb-service.d.ts.map +1 -0
- package/dist/composites/step-functions-workflow.d.ts +36 -0
- package/dist/composites/step-functions-workflow.d.ts.map +1 -0
- package/dist/deep-observe.d.ts +45 -0
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/deep-topology.d.ts +104 -0
- package/dist/deep-topology.d.ts.map +1 -0
- package/dist/effect-receipt-row.d.ts +92 -0
- package/dist/effect-receipt-row.d.ts.map +1 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +9 -4
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/cf-refs.d.ts +24 -0
- package/dist/lint/post-synth/cf-refs.d.ts.map +1 -1
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/waw059.d.ts +36 -0
- package/dist/lint/post-synth/waw059.d.ts.map +1 -0
- package/dist/lint/post-synth/waw060.d.ts +16 -0
- package/dist/lint/post-synth/waw060.d.ts.map +1 -0
- package/dist/lint/post-synth/waw061.d.ts +19 -0
- package/dist/lint/post-synth/waw061.d.ts.map +1 -0
- package/dist/lint/post-synth/waw062.d.ts +19 -0
- package/dist/lint/post-synth/waw062.d.ts.map +1 -0
- package/dist/lint/post-synth/waw063.d.ts +35 -0
- package/dist/lint/post-synth/waw063.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +5 -0
- package/dist/okf/rules/WAW059.md +25 -0
- package/dist/okf/rules/WAW060.md +17 -0
- package/dist/okf/rules/WAW061.md +17 -0
- package/dist/okf/rules/WAW062.md +27 -0
- package/dist/okf/rules/WAW063.md +19 -0
- package/dist/okf/types/Action.md +2 -0
- package/dist/okf/types/AutoScalingGroup.md +1 -0
- package/dist/okf/types/Bucket.md +2 -0
- package/dist/okf/types/ECRRepository.md +1 -0
- package/dist/okf/types/Function.md +1 -0
- package/dist/okf/types/GlobalTable.md +4 -0
- package/dist/okf/types/IamPolicy.md +3 -0
- package/dist/okf/types/InstanceProfile.md +4 -0
- package/dist/okf/types/LaunchTemplate.md +1 -0
- package/dist/okf/types/LoadBalancer.md +1 -0
- package/dist/okf/types/ManagedPolicy.md +4 -0
- package/dist/okf/types/Map.md +2 -0
- package/dist/okf/types/Queue.md +1 -0
- package/dist/okf/types/Role.md +3 -0
- package/dist/okf/types/SecurityGroup.md +1 -0
- package/dist/okf/types/StateMachine.md +4 -0
- package/dist/okf/types/Subnet.md +4 -0
- package/dist/okf/types/Table.md +2 -0
- package/dist/okf/types/Type.md +5 -0
- package/dist/okf/types/Vpc.md +1 -0
- package/dist/op/activities/aws-apply.d.ts +48 -5
- package/dist/op/activities/aws-apply.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +11 -4
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/ownership.d.ts +18 -0
- package/dist/ownership.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/receipt-store.d.ts +109 -0
- package/dist/receipt-store.d.ts.map +1 -0
- package/dist/rules/cf-refs.ts +62 -0
- package/dist/rules/waw059.ts +353 -0
- package/dist/rules/waw060.ts +91 -0
- package/dist/rules/waw061.ts +67 -0
- package/dist/rules/waw062.ts +0 -0
- package/dist/rules/waw063.ts +0 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/skills/chant-aws-carve-terraform.md +26 -23
- package/dist/teardown.d.ts +85 -0
- package/dist/teardown.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/api/read-client.test.ts +9 -0
- package/src/api/read-client.ts +15 -2
- package/src/components/cloud-executor.ts +10 -1
- package/src/composites/agentcore-agent.ts +35 -13
- package/src/composites/bucket-deployment.test.ts +84 -0
- package/src/composites/bucket-deployment.ts +153 -0
- package/src/composites/composites.test.ts +325 -0
- package/src/composites/dynamodb-table.test.ts +154 -0
- package/src/composites/dynamodb-table.ts +124 -0
- package/src/composites/ec2-instance-bundle.ts +134 -0
- package/src/composites/ecr-repository.test.ts +118 -0
- package/src/composites/ecr-repository.ts +117 -0
- package/src/composites/fargate-service.ts +19 -3
- package/src/composites/index.ts +21 -2
- package/src/composites/monitoring-stack.test.ts +130 -0
- package/src/composites/monitoring-stack.ts +164 -0
- package/src/composites/nlb-service.ts +117 -0
- package/src/composites/step-functions-workflow.ts +127 -0
- package/src/deep-observe.test.ts +201 -1
- package/src/deep-observe.ts +204 -0
- package/src/deep-topology.test.ts +392 -0
- package/src/deep-topology.ts +237 -0
- package/src/effect-receipt-row.test.ts +164 -0
- package/src/effect-receipt-row.ts +147 -0
- package/src/index.ts +28 -7
- package/src/lifecycle-integration.test.ts +4 -0
- package/src/lint/audit-catalog.ts +11 -0
- package/src/lint/post-synth/cf-refs.ts +62 -0
- package/src/lint/post-synth/index.ts +10 -0
- package/src/lint/post-synth/waw059.test.ts +309 -0
- package/src/lint/post-synth/waw059.ts +353 -0
- package/src/lint/post-synth/waw060.test.ts +131 -0
- package/src/lint/post-synth/waw060.ts +91 -0
- package/src/lint/post-synth/waw061.test.ts +99 -0
- package/src/lint/post-synth/waw061.ts +67 -0
- package/src/lint/post-synth/waw062.test.ts +109 -0
- package/src/lint/post-synth/waw062.ts +0 -0
- package/src/lint/post-synth/waw063.test.ts +220 -0
- package/src/lint/post-synth/waw063.ts +0 -0
- package/src/op/activities/aws-apply.test.ts +111 -0
- package/src/op/activities/aws-apply.ts +100 -6
- package/src/op/activities/index.ts +20 -3
- package/src/ownership.test.ts +24 -1
- package/src/ownership.ts +37 -0
- package/src/plugin.ts +59 -10
- package/src/receipt-store.test.ts +204 -0
- package/src/receipt-store.ts +315 -0
- package/src/serializer-ownership.test.ts +18 -0
- package/src/serializer.ts +114 -3
- package/src/skills/chant-aws-carve-terraform.md +26 -23
- package/src/teardown.test.ts +258 -0
- package/src/teardown.ts +276 -0
package/src/plugin.ts
CHANGED
|
@@ -505,7 +505,7 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
505
505
|
{
|
|
506
506
|
file: "chant-aws-carve-terraform.md",
|
|
507
507
|
name: "chant-aws-carve-terraform",
|
|
508
|
-
description: "Demo carving a resource out of Terraform into native chant — advise, emit, bridge, apply — fully offline",
|
|
508
|
+
description: "Demo carving a resource out of Terraform into native chant — advise, emit, audit, bridge, apply — fully offline",
|
|
509
509
|
triggers: [
|
|
510
510
|
{ type: "context", value: "terraform" },
|
|
511
511
|
{ type: "context", value: "carve" },
|
|
@@ -581,6 +581,17 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
581
581
|
// single-stack convention is the stack named after the environment (#932).
|
|
582
582
|
const stackName = options.stack ?? `${options.environment}`;
|
|
583
583
|
|
|
584
|
+
// Effect receipt rows (#1835): a receipt is never a stack member (the
|
|
585
|
+
// applier never writes it, #1832), so the stack read honestly reports it
|
|
586
|
+
// absent even while the parameter exists — which would arrive downstream
|
|
587
|
+
// as "the effect never ran". The serializer rendered each receipt's
|
|
588
|
+
// derived path into the template Metadata; read those parameters directly,
|
|
589
|
+
// and report a failed read as an `unobserved` hole rather than a wrong
|
|
590
|
+
// answer.
|
|
591
|
+
const { observeReceiptRows } = await import("./receipt-store");
|
|
592
|
+
const receiptObs = await observeReceiptRows(options.entityNames, options.buildOutput, client);
|
|
593
|
+
const receiptHoles = Object.keys(receiptObs.unobserved).length > 0 ? receiptObs.unobserved : undefined;
|
|
594
|
+
|
|
584
595
|
// Describe stack resources. The endpoint override rides the client, so a
|
|
585
596
|
// local emulator (Floci) is observed instead of real AWS (#926) — behold
|
|
586
597
|
// serve --local relies on this for the overlay.
|
|
@@ -598,7 +609,12 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
598
609
|
if (err instanceof AwsReadError && stackDoesNotExist(err.message)) {
|
|
599
610
|
const { observeByIdentity } = await import("./identity-observe");
|
|
600
611
|
const identity = await observeByIdentity(options.entityNames, options.entities, resources, client);
|
|
601
|
-
return observation(
|
|
612
|
+
return observation(
|
|
613
|
+
{ ...resources, ...identity.resources, ...receiptObs.resources },
|
|
614
|
+
receiptHoles,
|
|
615
|
+
identity.queried,
|
|
616
|
+
notes,
|
|
617
|
+
);
|
|
602
618
|
}
|
|
603
619
|
// Any other failure (credentials, throttling, a region that can't be
|
|
604
620
|
// reached) establishes nothing about what is deployed. Reporting every
|
|
@@ -608,13 +624,16 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
608
624
|
const reason = /credential|token|expired|AccessDenied|not authorized|Unauthorized/i.test(detail)
|
|
609
625
|
? "no-credentials"
|
|
610
626
|
: "read-failed";
|
|
627
|
+
// A receipt row the leg above did read stays read: its answer came from
|
|
628
|
+
// GetParameter, not from the failed stack call, so it is not a hole.
|
|
629
|
+
const stackHoles = unobservedAll(
|
|
630
|
+
options.entityNames.filter((n) => !(n in receiptObs.resources)),
|
|
631
|
+
reason,
|
|
632
|
+
`DescribeStackResources failed for stack "${stackName}": ${detail}`,
|
|
633
|
+
);
|
|
611
634
|
return observation(
|
|
612
|
-
{},
|
|
613
|
-
|
|
614
|
-
options.entityNames,
|
|
615
|
-
reason,
|
|
616
|
-
`DescribeStackResources failed for stack "${stackName}": ${detail}`,
|
|
617
|
-
),
|
|
635
|
+
{ ...receiptObs.resources },
|
|
636
|
+
{ ...stackHoles, ...receiptObs.unobserved },
|
|
618
637
|
undefined,
|
|
619
638
|
notes,
|
|
620
639
|
);
|
|
@@ -656,6 +675,9 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
656
675
|
// this path cannot read the ownership marker. Say `unknown` explicitly
|
|
657
676
|
// rather than leaving the field off and letting each consumer guess —
|
|
658
677
|
// the change set never escalates `unknown` to a delete.
|
|
678
|
+
// For the same reason `marker` (#1222) stays absent here: no tags, no
|
|
679
|
+
// stack/env identity to read, and absent means absent — never a guess.
|
|
680
|
+
// aws teardown is stack-level and reads the stack's own tags instead.
|
|
659
681
|
ownership: "unknown",
|
|
660
682
|
};
|
|
661
683
|
}
|
|
@@ -700,13 +722,25 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
700
722
|
detail: `the stack was read, but this resource's own properties were not — ${own.failures.get(type) ?? "the describe call failed"}`,
|
|
701
723
|
};
|
|
702
724
|
}
|
|
703
|
-
return observation(
|
|
725
|
+
return observation(
|
|
726
|
+
{ ...described, ...identity.resources, ...receiptObs.resources },
|
|
727
|
+
{ ...holes, ...receiptObs.unobserved },
|
|
728
|
+
identity.queried,
|
|
729
|
+
notes,
|
|
730
|
+
stackExports,
|
|
731
|
+
);
|
|
704
732
|
}
|
|
705
733
|
|
|
706
734
|
// Every entity the stack answered for was answered for: an entity the
|
|
707
735
|
// template doesn't carry is genuinely not in this stack, which is an
|
|
708
736
|
// absence, not a hole — unless the identity fallback saw it live (#1647).
|
|
709
|
-
return observation(
|
|
737
|
+
return observation(
|
|
738
|
+
{ ...withProperties, ...identity.resources, ...receiptObs.resources },
|
|
739
|
+
receiptHoles,
|
|
740
|
+
identity.queried,
|
|
741
|
+
notes,
|
|
742
|
+
stackExports,
|
|
743
|
+
);
|
|
710
744
|
},
|
|
711
745
|
|
|
712
746
|
/**
|
|
@@ -803,6 +837,21 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
803
837
|
return { stack: options.stack, present: true, status, healthy };
|
|
804
838
|
},
|
|
805
839
|
|
|
840
|
+
// Env teardown at STACK granularity (#1222): `describeResources` carries no
|
|
841
|
+
// tags, so per-resource marker selection is impossible here — the env's
|
|
842
|
+
// stacks are enumerated instead (`stacks[]`, else the env-named default) and
|
|
843
|
+
// ownership is verified on each stack's own DescribeStacks tags. Execution
|
|
844
|
+
// is DeleteStack via the applier's `awsDelete`. See ./teardown.ts.
|
|
845
|
+
async teardownOwned(options) {
|
|
846
|
+
const { teardownOwned } = await import("./teardown");
|
|
847
|
+
return teardownOwned(options);
|
|
848
|
+
},
|
|
849
|
+
|
|
850
|
+
async executeTeardown(options) {
|
|
851
|
+
const { executeTeardown } = await import("./teardown");
|
|
852
|
+
return executeTeardown(options);
|
|
853
|
+
},
|
|
854
|
+
|
|
806
855
|
async exportResources(options: {
|
|
807
856
|
environment: string;
|
|
808
857
|
stack?: string;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { awsReceiptStore, observeReceiptRows, ssmGetParameter, ssmPutParameter } from "./receipt-store";
|
|
3
|
+
import { EFFECT_RECEIPTS_METADATA_KEY } from "./effect-receipt-row";
|
|
4
|
+
import { receiptActivities, type EffectReceiptRef } from "@intentius/chant/op/receipt-store";
|
|
5
|
+
import type { AwsReadHttp } from "./api/read-client";
|
|
6
|
+
import { mkdtempSync } from "fs";
|
|
7
|
+
import { tmpdir } from "os";
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
|
|
10
|
+
/** A directory with no chant.config.ts anywhere upward. */
|
|
11
|
+
const bareDir = mkdtempSync(join(tmpdir(), "chant-receipt-store-"));
|
|
12
|
+
|
|
13
|
+
interface Call {
|
|
14
|
+
url: string;
|
|
15
|
+
target: string;
|
|
16
|
+
body: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Fake SSM endpoint: records calls, answers from a name → value map. */
|
|
20
|
+
function fakeSsm(parameters: Record<string, string>, opts?: { failReads?: boolean }) {
|
|
21
|
+
const calls: Call[] = [];
|
|
22
|
+
const http: AwsReadHttp = async (url, init) => {
|
|
23
|
+
const body = JSON.parse(init.body) as Record<string, unknown>;
|
|
24
|
+
const target = init.headers["x-amz-target"] ?? "";
|
|
25
|
+
calls.push({ url, target: target.replace(/^AmazonSSM\./, ""), body });
|
|
26
|
+
if (target.endsWith("GetParameter")) {
|
|
27
|
+
if (opts?.failReads) return { status: 500, text: JSON.stringify({ __type: "InternalServerError" }) };
|
|
28
|
+
const value = parameters[body.Name as string];
|
|
29
|
+
if (value === undefined) {
|
|
30
|
+
return { status: 400, text: JSON.stringify({ __type: "ParameterNotFound" }) };
|
|
31
|
+
}
|
|
32
|
+
return { status: 200, text: JSON.stringify({ Parameter: { Name: body.Name, Value: value } }) };
|
|
33
|
+
}
|
|
34
|
+
if (target.endsWith("PutParameter")) {
|
|
35
|
+
if (parameters[body.Name as string] !== undefined && body.Overwrite !== true) {
|
|
36
|
+
return { status: 400, text: JSON.stringify({ __type: "ParameterAlreadyExists" }) };
|
|
37
|
+
}
|
|
38
|
+
parameters[body.Name as string] = body.Value as string;
|
|
39
|
+
return { status: 200, text: JSON.stringify({ Version: 1 }) };
|
|
40
|
+
}
|
|
41
|
+
return { status: 400, text: JSON.stringify({ __type: "InvalidAction" }) };
|
|
42
|
+
};
|
|
43
|
+
return { calls, http, parameters };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const ref: EffectReceiptRef = { name: "seeded", effect: "db-seed", flavor: "hash", inputs: { v: 1 } };
|
|
47
|
+
|
|
48
|
+
function store(fake: ReturnType<typeof fakeSsm>, env: Record<string, string | undefined> = {}) {
|
|
49
|
+
return awsReceiptStore({
|
|
50
|
+
stack: "demo",
|
|
51
|
+
environment: "dev",
|
|
52
|
+
cwd: bareDir,
|
|
53
|
+
http: fake.http,
|
|
54
|
+
env,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("awsReceiptStore", () => {
|
|
59
|
+
it("read returns the stored value at the derived path", async () => {
|
|
60
|
+
const fake = fakeSsm({ "/chant-receipts/demo/dev/db-seed": "sha256:abc" });
|
|
61
|
+
await expect(store(fake).read(ref)).resolves.toBe("sha256:abc");
|
|
62
|
+
expect(fake.calls[0].target).toBe("GetParameter");
|
|
63
|
+
expect(fake.calls[0].body.Name).toBe("/chant-receipts/demo/dev/db-seed");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("read returns undefined for an absent receipt — a real answer, not an error", async () => {
|
|
67
|
+
const fake = fakeSsm({});
|
|
68
|
+
await expect(store(fake).read(ref)).resolves.toBeUndefined();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("read throws on a failed read rather than answering wrongly", async () => {
|
|
72
|
+
const fake = fakeSsm({}, { failReads: true });
|
|
73
|
+
await expect(store(fake).read(ref)).rejects.toThrow(/GetParameter/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("write creates plain String with the ownership tags", async () => {
|
|
77
|
+
const fake = fakeSsm({});
|
|
78
|
+
await store(fake).write(ref, "sha256:abc");
|
|
79
|
+
expect(fake.calls).toHaveLength(1);
|
|
80
|
+
const { body } = fake.calls[0];
|
|
81
|
+
expect(body.Type).toBe("String");
|
|
82
|
+
expect(body.Overwrite).toBeUndefined();
|
|
83
|
+
expect(body.Tags).toContainEqual({ Key: "chant:managed-by", Value: "chant" });
|
|
84
|
+
expect(body.Tags).toContainEqual({ Key: "chant:stack", Value: "demo" });
|
|
85
|
+
expect(body.Tags).toContainEqual({ Key: "chant:env", Value: "dev" });
|
|
86
|
+
expect(fake.parameters["/chant-receipts/demo/dev/db-seed"]).toBe("sha256:abc");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("write overwrites an existing receipt — String + Overwrite, no tags on the retry", async () => {
|
|
90
|
+
const fake = fakeSsm({ "/chant-receipts/demo/dev/db-seed": "sha256:old" });
|
|
91
|
+
await store(fake).write(ref, "sha256:new");
|
|
92
|
+
expect(fake.calls.map((c) => c.target)).toEqual(["PutParameter", "PutParameter"]);
|
|
93
|
+
const retry = fake.calls[1].body;
|
|
94
|
+
expect(retry.Type).toBe("String");
|
|
95
|
+
expect(retry.Overwrite).toBe(true);
|
|
96
|
+
expect(retry.Tags).toBeUndefined();
|
|
97
|
+
expect(fake.parameters["/chant-receipts/demo/dev/db-seed"]).toBe("sha256:new");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("honors AWS_ENDPOINT_URL_SSM, then AWS_ENDPOINT_URL (#1694)", async () => {
|
|
101
|
+
const perService = fakeSsm({});
|
|
102
|
+
await store(perService, { AWS_ENDPOINT_URL_SSM: "http://localhost:4566", AWS_ENDPOINT_URL: "http://elsewhere:1" }).read(ref);
|
|
103
|
+
expect(perService.calls[0].url).toBe("http://localhost:4566/");
|
|
104
|
+
|
|
105
|
+
const ambient = fakeSsm({});
|
|
106
|
+
await store(ambient, { AWS_ENDPOINT_URL: "http://localhost:4566" }).read(ref);
|
|
107
|
+
expect(ambient.calls[0].url).toBe("http://localhost:4566/");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("errors without a stack identity", async () => {
|
|
111
|
+
const fake = fakeSsm({});
|
|
112
|
+
const s = awsReceiptStore({ environment: "dev", cwd: bareDir, http: fake.http, env: {} });
|
|
113
|
+
await expect(s.read(ref)).rejects.toThrow(/ownership: \{ stack \}/);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("errors without an env — the segment is explicit (decision 4)", async () => {
|
|
117
|
+
const fake = fakeSsm({});
|
|
118
|
+
const s = awsReceiptStore({ stack: "demo", cwd: bareDir, http: fake.http, env: {} });
|
|
119
|
+
await expect(s.read(ref)).rejects.toThrow(/CHANT_ENV/);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("reads CHANT_ENV for the env segment", async () => {
|
|
123
|
+
const fake = fakeSsm({ "/chant-receipts/demo/staging/db-seed": "x" });
|
|
124
|
+
const s = awsReceiptStore({ stack: "demo", cwd: bareDir, http: fake.http, env: { CHANT_ENV: "staging" } });
|
|
125
|
+
await expect(s.read(ref)).resolves.toBe("x");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("receiptActivities over the aws store", () => {
|
|
130
|
+
it("resolves receiptRead/receiptWrite/receiptStaleness against the SSM store", async () => {
|
|
131
|
+
const fake = fakeSsm({});
|
|
132
|
+
const activities = receiptActivities(store(fake));
|
|
133
|
+
|
|
134
|
+
const read = await activities.receiptRead({ receipt: ref, expectation: "sha256:abc" });
|
|
135
|
+
expect(read).toEqual({ current: null, expectation: "sha256:abc", applied: false });
|
|
136
|
+
|
|
137
|
+
await activities.receiptWrite({ receipt: ref, expectation: "sha256:abc" });
|
|
138
|
+
const again = await activities.receiptRead({ receipt: ref, expectation: "sha256:abc" });
|
|
139
|
+
expect(again.applied).toBe(true);
|
|
140
|
+
|
|
141
|
+
const staleness = await activities.receiptStaleness({
|
|
142
|
+
receipts: [{ receipt: ref, expectation: "sha256:other" }],
|
|
143
|
+
});
|
|
144
|
+
expect(staleness.stale).toBe(true);
|
|
145
|
+
expect(staleness.findings[0]).toMatchObject({ receipt: "seeded", kind: "differs" });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("is wired into the op activities barrel by name", async () => {
|
|
149
|
+
const barrel = await import("./op/activities/index");
|
|
150
|
+
expect(typeof barrel.receiptRead).toBe("function");
|
|
151
|
+
expect(typeof barrel.receiptWrite).toBe("function");
|
|
152
|
+
expect(typeof barrel.receiptStaleness).toBe("function");
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("observeReceiptRows (plan's live read)", () => {
|
|
157
|
+
const buildOutput = JSON.stringify({
|
|
158
|
+
AWSTemplateFormatVersion: "2010-09-09",
|
|
159
|
+
Metadata: {
|
|
160
|
+
[EFFECT_RECEIPTS_METADATA_KEY]: {
|
|
161
|
+
seeded: {
|
|
162
|
+
Type: "AWS::SSM::Parameter",
|
|
163
|
+
Properties: { Name: "/chant-receipts/demo/dev/db-seed", Type: "String", Value: "sha256:abc", Tags: [] },
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
Resources: {},
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("maps a present receipt's stored value onto attributes.value", async () => {
|
|
171
|
+
const fake = fakeSsm({ "/chant-receipts/demo/dev/db-seed": "sha256:abc" });
|
|
172
|
+
const obs = await observeReceiptRows(["seeded", "unrelated"], buildOutput, { http: fake.http, env: {} });
|
|
173
|
+
expect(obs.resources.seeded).toMatchObject({
|
|
174
|
+
type: "AWS::SSM::Parameter",
|
|
175
|
+
physicalId: "/chant-receipts/demo/dev/db-seed",
|
|
176
|
+
status: "EXTERNAL",
|
|
177
|
+
attributes: { value: "sha256:abc" },
|
|
178
|
+
});
|
|
179
|
+
expect(obs.unobserved).toEqual({});
|
|
180
|
+
// Only the rendered receipt was queried — never a guess for other entities.
|
|
181
|
+
expect(fake.calls).toHaveLength(1);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("reports a confirmed absence as absence — in neither map", async () => {
|
|
185
|
+
const fake = fakeSsm({});
|
|
186
|
+
const obs = await observeReceiptRows(["seeded"], buildOutput, { http: fake.http, env: {} });
|
|
187
|
+
expect(obs.resources).toEqual({});
|
|
188
|
+
expect(obs.unobserved).toEqual({});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("reports a failed read as unobserved, never a wrong answer", async () => {
|
|
192
|
+
const fake = fakeSsm({}, { failReads: true });
|
|
193
|
+
const obs = await observeReceiptRows(["seeded"], buildOutput, { http: fake.http, env: {} });
|
|
194
|
+
expect(obs.resources).toEqual({});
|
|
195
|
+
expect(obs.unobserved.seeded).toMatchObject({ type: "AWS::SSM::Parameter", reason: "read-failed" });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("answers nothing for a template without receipt rows", async () => {
|
|
199
|
+
const fake = fakeSsm({});
|
|
200
|
+
const obs = await observeReceiptRows(["seeded"], JSON.stringify({ Resources: {} }), { http: fake.http, env: {} });
|
|
201
|
+
expect(obs).toEqual({ resources: {}, unobserved: {} });
|
|
202
|
+
expect(fake.calls).toHaveLength(0);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The aws `ReceiptStore` (#1835, epic #1703) — core's injectable receipt seam
|
|
3
|
+
* (#1834, `@intentius/chant/op/receipt-store`) implemented over SSM Parameter
|
|
4
|
+
* Store, plain `String`, at the path identity ./effect-receipt-row.ts derives
|
|
5
|
+
* from the ownership marker fields.
|
|
6
|
+
*
|
|
7
|
+
* The transport is the lexicon's own read/apply transport (./api/read-client,
|
|
8
|
+
* #1206), pointed at the SSM JSON API: `fetch`, SigV4 when credentials
|
|
9
|
+
* resolve, and the one endpoint-override rule (#1694) — the `endpoint`
|
|
10
|
+
* option, else `AWS_ENDPOINT_URL_SSM`, else `AWS_ENDPOINT_URL` — so a local
|
|
11
|
+
* emulator lane reads and writes receipts without any store-specific wiring.
|
|
12
|
+
*
|
|
13
|
+
* Write discipline (epic decision 3): `write` exists for the `effect()` step
|
|
14
|
+
* alone — the step's read-compare-run-write is the only path that reaches it,
|
|
15
|
+
* on success, last. `PutParameter` is `Type: "String"` always; the first
|
|
16
|
+
* write creates the parameter with the ownership tags, and a later write
|
|
17
|
+
* overwrites the value (SSM refuses `Overwrite` and `Tags` in one call, so
|
|
18
|
+
* tags ride creation only — they never change after).
|
|
19
|
+
*
|
|
20
|
+
* Identity: the parameter name needs `<stack>` and `<env>`, which the
|
|
21
|
+
* activity args deliberately do not carry (the `EffectReceiptRef` is
|
|
22
|
+
* identity-of-the-effect, not identity-of-the-deployment). The store resolves
|
|
23
|
+
* them once, lazily, at first use: an explicit option, else `CHANT_ENV` (what
|
|
24
|
+
* `chant run --env` sets) and the project's `ownership` block — the same
|
|
25
|
+
* fields that stamp markers (epic decision 4). Nothing resolving is an error,
|
|
26
|
+
* never a guessed segment.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { loadChantConfigUpward, resolveOwnershipStack } from "@intentius/chant/config";
|
|
30
|
+
import { ownershipEntries } from "@intentius/chant/ownership";
|
|
31
|
+
import type { EffectReceiptRef, ReceiptStore } from "@intentius/chant/op/receipt-store";
|
|
32
|
+
import type { ResourceMetadata } from "@intentius/chant/lexicon";
|
|
33
|
+
import type { UnobservedReason } from "@intentius/chant/observation";
|
|
34
|
+
import {
|
|
35
|
+
AwsReadError,
|
|
36
|
+
requestHeaders,
|
|
37
|
+
serviceUrl,
|
|
38
|
+
withEndpointOverride,
|
|
39
|
+
type AwsCredentialSource,
|
|
40
|
+
type AwsReadClientOptions,
|
|
41
|
+
type AwsReadHttp,
|
|
42
|
+
} from "./api/read-client";
|
|
43
|
+
import { AWS_TAG_OWNERSHIP_KEYS } from "./ownership";
|
|
44
|
+
import { AWS_EFFECT_RECEIPT_ENTITY_TYPE, EFFECT_RECEIPTS_METADATA_KEY, receiptParameterName } from "./effect-receipt-row";
|
|
45
|
+
|
|
46
|
+
const SSM_SERVICE = "ssm";
|
|
47
|
+
const SSM_TARGET_PREFIX = "AmazonSSM";
|
|
48
|
+
|
|
49
|
+
/** Options for {@link awsReceiptStore}. All optional: the default store reads
|
|
50
|
+
* its identity from the project and its endpoint from the environment. */
|
|
51
|
+
export interface AwsReceiptStoreOptions {
|
|
52
|
+
/** The path's `<stack>` segment. Omitted, the project's `ownership.stack`
|
|
53
|
+
* (chant.config.ts, found upward from `cwd`) answers. */
|
|
54
|
+
stack?: string;
|
|
55
|
+
/** The path's `<env>` segment — explicit by decision 4. Omitted, `CHANT_ENV`
|
|
56
|
+
* (set by `chant run --env`) answers, then a literal `ownership.env`. */
|
|
57
|
+
environment?: string;
|
|
58
|
+
/** Where to look for chant.config.ts. Defaults to the working directory. */
|
|
59
|
+
cwd?: string;
|
|
60
|
+
/** Endpoint override; omitted, `AWS_ENDPOINT_URL[_SSM]` answers (#1694). */
|
|
61
|
+
endpoint?: string;
|
|
62
|
+
/** Region for the real-AWS host. */
|
|
63
|
+
region?: string;
|
|
64
|
+
/** Injectable HTTP, mirroring the read client's. Tests avoid the network. */
|
|
65
|
+
http?: AwsReadHttp;
|
|
66
|
+
/** Environment record the endpoint/credential/identity fallbacks read.
|
|
67
|
+
* Defaults to `process.env`; injectable for tests. */
|
|
68
|
+
env?: Record<string, string | undefined>;
|
|
69
|
+
/** What to sign with — same seam as the read client. */
|
|
70
|
+
credentials?: AwsCredentialSource;
|
|
71
|
+
/** Sign even against an endpoint override — for an override that is real AWS. */
|
|
72
|
+
signEndpointOverride?: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** One SSM JSON call. Exported for the observation leg (plugin.ts), which
|
|
76
|
+
* reads the same parameters the store writes. */
|
|
77
|
+
export async function ssmCall(
|
|
78
|
+
action: string,
|
|
79
|
+
payload: Record<string, unknown>,
|
|
80
|
+
options: AwsReadClientOptions,
|
|
81
|
+
): Promise<{ status: number; json: Record<string, unknown> }> {
|
|
82
|
+
const settled = withEndpointOverride(SSM_SERVICE, options);
|
|
83
|
+
const url = serviceUrl(SSM_SERVICE, settled.endpoint, settled.region);
|
|
84
|
+
const body = JSON.stringify(payload);
|
|
85
|
+
const base = {
|
|
86
|
+
"content-type": "application/x-amz-json-1.1",
|
|
87
|
+
"x-amz-target": `${SSM_TARGET_PREFIX}.${action}`,
|
|
88
|
+
};
|
|
89
|
+
const headers = requestHeaders(SSM_SERVICE, url, body, base, settled);
|
|
90
|
+
const http: AwsReadHttp =
|
|
91
|
+
settled.http ??
|
|
92
|
+
(async (u, init, signal) => {
|
|
93
|
+
const res = await fetch(u, { method: "POST", headers: init.headers, body: init.body, signal });
|
|
94
|
+
return { status: res.status, text: await res.text() };
|
|
95
|
+
});
|
|
96
|
+
const res = await http(url, { headers, body }, settled.signal);
|
|
97
|
+
let json: Record<string, unknown> = {};
|
|
98
|
+
try {
|
|
99
|
+
json = res.text ? (JSON.parse(res.text) as Record<string, unknown>) : {};
|
|
100
|
+
} catch {
|
|
101
|
+
if (res.status < 400) {
|
|
102
|
+
throw new AwsReadError(`SSM ${action}: unparseable response (status ${res.status})`, res.status);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { status: res.status, json };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The API's own error code from a JSON-protocol error body — `__type`, with
|
|
109
|
+
* any `namespace#` prefix stripped. */
|
|
110
|
+
export function ssmErrorCode(json: Record<string, unknown>): string | undefined {
|
|
111
|
+
const type = json.__type;
|
|
112
|
+
if (typeof type !== "string") return undefined;
|
|
113
|
+
return type.includes("#") ? type.slice(type.indexOf("#") + 1) : type;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function ssmError(action: string, status: number, json: Record<string, unknown>): AwsReadError {
|
|
117
|
+
const code = ssmErrorCode(json);
|
|
118
|
+
const message = typeof json.message === "string" ? json.message : typeof json.Message === "string" ? json.Message : "";
|
|
119
|
+
return new AwsReadError(`SSM ${action} failed (status ${status})${code ? ` ${code}` : ""}${message ? `: ${message}` : ""}`, status, code);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* `GetParameter` by name. Absent (`ParameterNotFound`) is `undefined` — a real
|
|
124
|
+
* answer, distinct from a failed read, which throws.
|
|
125
|
+
*/
|
|
126
|
+
export async function ssmGetParameter(
|
|
127
|
+
name: string,
|
|
128
|
+
options: AwsReadClientOptions = {},
|
|
129
|
+
): Promise<string | undefined> {
|
|
130
|
+
const { status, json } = await ssmCall("GetParameter", { Name: name }, options);
|
|
131
|
+
if (status >= 400) {
|
|
132
|
+
if (ssmErrorCode(json) === "ParameterNotFound") return undefined;
|
|
133
|
+
throw ssmError("GetParameter", status, json);
|
|
134
|
+
}
|
|
135
|
+
const value = (json.Parameter as { Value?: unknown } | undefined)?.Value;
|
|
136
|
+
return typeof value === "string" ? value : undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* `PutParameter`, plain `String`. Creation carries `tags`; an existing
|
|
141
|
+
* parameter is overwritten (`Overwrite: true`) without them — SSM refuses
|
|
142
|
+
* `Overwrite` and `Tags` in the same call, and ownership tags never change.
|
|
143
|
+
*/
|
|
144
|
+
export async function ssmPutParameter(
|
|
145
|
+
name: string,
|
|
146
|
+
value: string,
|
|
147
|
+
tags: Record<string, string>,
|
|
148
|
+
options: AwsReadClientOptions = {},
|
|
149
|
+
): Promise<void> {
|
|
150
|
+
const tagList = Object.entries(tags).map(([Key, Value]) => ({ Key, Value }));
|
|
151
|
+
const create = await ssmCall(
|
|
152
|
+
"PutParameter",
|
|
153
|
+
{ Name: name, Value: value, Type: "String", ...(tagList.length > 0 ? { Tags: tagList } : {}) },
|
|
154
|
+
options,
|
|
155
|
+
);
|
|
156
|
+
if (create.status < 400) return;
|
|
157
|
+
if (ssmErrorCode(create.json) !== "ParameterAlreadyExists") {
|
|
158
|
+
throw ssmError("PutParameter", create.status, create.json);
|
|
159
|
+
}
|
|
160
|
+
const overwrite = await ssmCall(
|
|
161
|
+
"PutParameter",
|
|
162
|
+
{ Name: name, Value: value, Type: "String", Overwrite: true },
|
|
163
|
+
options,
|
|
164
|
+
);
|
|
165
|
+
if (overwrite.status >= 400) throw ssmError("PutParameter", overwrite.status, overwrite.json);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The resolved path identity plus the tags a creation stamps. */
|
|
169
|
+
interface ReceiptIdentity {
|
|
170
|
+
stack: string;
|
|
171
|
+
env: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function resolveIdentity(options: AwsReceiptStoreOptions): Promise<ReceiptIdentity> {
|
|
175
|
+
const processEnv = options.env ?? process.env;
|
|
176
|
+
let stack = options.stack;
|
|
177
|
+
let env = options.environment ?? processEnv.CHANT_ENV;
|
|
178
|
+
if (!stack || !env) {
|
|
179
|
+
let config;
|
|
180
|
+
try {
|
|
181
|
+
config = (await loadChantConfigUpward(options.cwd ?? process.cwd())).config;
|
|
182
|
+
} catch {
|
|
183
|
+
config = undefined;
|
|
184
|
+
}
|
|
185
|
+
if (config) {
|
|
186
|
+
stack = stack ?? resolveOwnershipStack(config);
|
|
187
|
+
// Only a literal env can answer here: a `{ param }` reference resolves
|
|
188
|
+
// per build, and an op run has no build parameters — `--env` does.
|
|
189
|
+
const configEnv = config.ownership?.env;
|
|
190
|
+
env = env ?? (typeof configEnv === "string" ? configEnv : undefined);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (!stack) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
"aws receipt store: no stack identity — the receipt path is " +
|
|
196
|
+
"/chant-receipts/<stack>/<env>/<effect>, derived from the same ownership fields that stamp " +
|
|
197
|
+
"markers (chant #1703, decision 4). Set ownership: { stack } in chant.config.ts.",
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
if (!env) {
|
|
201
|
+
throw new Error(
|
|
202
|
+
"aws receipt store: no environment resolved — the receipt path's <env> segment is explicit " +
|
|
203
|
+
"(chant #1703, decision 4). Run with --env <name>, set CHANT_ENV, or set a literal " +
|
|
204
|
+
"ownership.env in chant.config.ts.",
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
return { stack, env };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** What the observation leg learned about the declared receipt rows. */
|
|
211
|
+
export interface ReceiptRowObservation {
|
|
212
|
+
resources: Record<string, ResourceMetadata>;
|
|
213
|
+
unobserved: Record<string, { type: string; reason: UnobservedReason; detail: string }>;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The plan-side live read of the receipt rows (#1835's observation leg).
|
|
218
|
+
*
|
|
219
|
+
* A receipt is not a stack member — the applier never writes it (#1832) — so
|
|
220
|
+
* `describe-stack-resources` honestly reports it absent even while the
|
|
221
|
+
* parameter exists. The serializer renders each receipt's derived path into
|
|
222
|
+
* the template's `Metadata` (./serializer.ts), so this leg reads the paths
|
|
223
|
+
* back from the build output — one derivation, decision 4 — and asks SSM
|
|
224
|
+
* `GetParameter` for each. Present maps the stored value onto
|
|
225
|
+
* `attributes.value` (core's `RECEIPT_VALUE_ATTRIBUTE`); `ParameterNotFound`
|
|
226
|
+
* is a real absence and stays one; a failed read is an `unobserved` hole,
|
|
227
|
+
* never a wrong answer — a receipt nobody could read must not arrive
|
|
228
|
+
* downstream as "the effect never ran".
|
|
229
|
+
*/
|
|
230
|
+
export async function observeReceiptRows(
|
|
231
|
+
entityNames: string[],
|
|
232
|
+
buildOutput: string,
|
|
233
|
+
options: AwsReadClientOptions = {},
|
|
234
|
+
): Promise<ReceiptRowObservation> {
|
|
235
|
+
const out: ReceiptRowObservation = { resources: {}, unobserved: {} };
|
|
236
|
+
let rows: Record<string, { Properties?: { Name?: unknown } }> | undefined;
|
|
237
|
+
try {
|
|
238
|
+
const template = JSON.parse(buildOutput) as { Metadata?: Record<string, unknown> };
|
|
239
|
+
const block = template.Metadata?.[EFFECT_RECEIPTS_METADATA_KEY];
|
|
240
|
+
if (typeof block === "object" && block !== null) {
|
|
241
|
+
rows = block as Record<string, { Properties?: { Name?: unknown } }>;
|
|
242
|
+
}
|
|
243
|
+
} catch {
|
|
244
|
+
rows = undefined; // Not a JSON template — no receipt rows to read.
|
|
245
|
+
}
|
|
246
|
+
if (!rows) return out;
|
|
247
|
+
|
|
248
|
+
for (const name of entityNames) {
|
|
249
|
+
const rendered = rows[name]?.Properties?.Name;
|
|
250
|
+
if (typeof rendered !== "string") continue;
|
|
251
|
+
try {
|
|
252
|
+
const value = await ssmGetParameter(rendered, options);
|
|
253
|
+
if (value === undefined) continue; // Confirmed absent — the effect has not stamped it yet.
|
|
254
|
+
out.resources[name] = {
|
|
255
|
+
type: AWS_EFFECT_RECEIPT_ENTITY_TYPE,
|
|
256
|
+
physicalId: rendered,
|
|
257
|
+
// Live outside the stack by design — the same word the identity
|
|
258
|
+
// fallback (#1647) uses for a resource CloudFormation does not hold.
|
|
259
|
+
status: "EXTERNAL",
|
|
260
|
+
// GetParameter returns no tags, so the marker channel is unreadable
|
|
261
|
+
// here; `unknown` is the total verdict, never a guess (#1089).
|
|
262
|
+
ownership: "unknown",
|
|
263
|
+
attributes: { value },
|
|
264
|
+
};
|
|
265
|
+
} catch (err) {
|
|
266
|
+
const detail =
|
|
267
|
+
err instanceof AwsReadError && err.code
|
|
268
|
+
? `${err.code}: ${err.message}`
|
|
269
|
+
: String(err instanceof Error ? err.message : err);
|
|
270
|
+
const reason: UnobservedReason = /credential|token|expired|AccessDenied|not authorized|Unauthorized/i.test(detail)
|
|
271
|
+
? "no-credentials"
|
|
272
|
+
: "read-failed";
|
|
273
|
+
out.unobserved[name] = {
|
|
274
|
+
type: AWS_EFFECT_RECEIPT_ENTITY_TYPE,
|
|
275
|
+
reason,
|
|
276
|
+
detail: `GetParameter failed for receipt "${rendered}": ${detail}`,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The `ReceiptStore` over SSM. Bind it once in the op activities barrel —
|
|
285
|
+
* `receiptActivities(awsReceiptStore())` — and the registry resolves
|
|
286
|
+
* `receiptRead`/`receiptWrite`/`receiptStaleness` by name, exactly like
|
|
287
|
+
* `ensureSecret` (#1830). Identity and endpoint resolve lazily at first use,
|
|
288
|
+
* so module load never reads the project or the environment.
|
|
289
|
+
*/
|
|
290
|
+
export function awsReceiptStore(options: AwsReceiptStoreOptions = {}): ReceiptStore {
|
|
291
|
+
let identity: Promise<ReceiptIdentity> | undefined;
|
|
292
|
+
const identityOf = () => (identity ??= resolveIdentity(options));
|
|
293
|
+
|
|
294
|
+
const client = (): AwsReadClientOptions => ({
|
|
295
|
+
...(options.endpoint !== undefined ? { endpoint: options.endpoint } : {}),
|
|
296
|
+
...(options.region !== undefined ? { region: options.region } : {}),
|
|
297
|
+
...(options.http !== undefined ? { http: options.http } : {}),
|
|
298
|
+
...(options.env !== undefined ? { env: options.env } : {}),
|
|
299
|
+
...(options.credentials !== undefined ? { credentials: options.credentials } : {}),
|
|
300
|
+
...(options.signEndpointOverride !== undefined ? { signEndpointOverride: options.signEndpointOverride } : {}),
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
async read(receipt: EffectReceiptRef): Promise<string | undefined> {
|
|
305
|
+
const { stack, env } = await identityOf();
|
|
306
|
+
return ssmGetParameter(receiptParameterName(stack, env, receipt.effect), client());
|
|
307
|
+
},
|
|
308
|
+
|
|
309
|
+
async write(receipt: EffectReceiptRef, expectation: string): Promise<void> {
|
|
310
|
+
const { stack, env } = await identityOf();
|
|
311
|
+
const tags = ownershipEntries(AWS_TAG_OWNERSHIP_KEYS, { stack, env });
|
|
312
|
+
await ssmPutParameter(receiptParameterName(stack, env, receipt.effect), expectation, tags, client());
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
}
|
|
@@ -34,4 +34,22 @@ describe("awsSerializer ownership stamping (#119)", () => {
|
|
|
34
34
|
const tags = (template.Resources.MyBucket.Properties?.Tags ?? []) as Array<{ Key: string }>;
|
|
35
35
|
expect(tags.some((t) => t.Key.startsWith("chant:"))).toBe(false);
|
|
36
36
|
});
|
|
37
|
+
|
|
38
|
+
test("carries the marker at the template level too — Metadata[chant:ownership], the stack-tag source (#1222)", () => {
|
|
39
|
+
const entities = new Map<string, Declarable>([["MyBucket", new MockBucket({ BucketName: "b" })]]);
|
|
40
|
+
const out = awsSerializer.serialize(entities, [], { ownership: { stack: "billing", env: "prod" } });
|
|
41
|
+
const template = JSON.parse(out as string);
|
|
42
|
+
expect(template.Metadata["chant:ownership"]).toEqual({
|
|
43
|
+
"chant:managed-by": "chant",
|
|
44
|
+
"chant:stack": "billing",
|
|
45
|
+
"chant:env": "prod",
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("no ownership context → no template Metadata block", () => {
|
|
50
|
+
const entities = new Map<string, Declarable>([["MyBucket", new MockBucket({ BucketName: "b" })]]);
|
|
51
|
+
const out = awsSerializer.serialize(entities, []);
|
|
52
|
+
const template = JSON.parse(out as string);
|
|
53
|
+
expect(template.Metadata).toBeUndefined();
|
|
54
|
+
});
|
|
37
55
|
});
|