@intentius/chant-lexicon-aws 0.46.0 → 0.50.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/codegen/generate-lexicon.d.ts +5 -0
- package/dist/codegen/generate-lexicon.d.ts.map +1 -1
- package/dist/components/host-delivery.d.ts +4 -3
- package/dist/components/host-delivery.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/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 +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +14 -6
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/cf-refs.d.ts +17 -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/waw016.d.ts +10 -2
- package/dist/lint/post-synth/waw016.d.ts.map +1 -1
- 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/lint/post-synth/waw064.d.ts +17 -0
- package/dist/lint/post-synth/waw064.d.ts.map +1 -0
- package/dist/lint/post-synth/waw065.d.ts +23 -0
- package/dist/lint/post-synth/waw065.d.ts.map +1 -0
- package/dist/lint/post-synth/waw066.d.ts +25 -0
- package/dist/lint/post-synth/waw066.d.ts.map +1 -0
- package/dist/lint/post-synth/waw067.d.ts +24 -0
- package/dist/lint/post-synth/waw067.d.ts.map +1 -0
- package/dist/lint/post-synth/waw068.d.ts +20 -0
- package/dist/lint/post-synth/waw068.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/meta.json +34 -0
- package/dist/okf/index.md +8 -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/rules/WAW064.md +18 -0
- package/dist/okf/rules/WAW065.md +25 -0
- package/dist/okf/rules/WAW066.md +22 -0
- package/dist/okf/rules/WAW067.md +20 -0
- package/dist/okf/rules/WAW068.md +19 -0
- package/dist/okf/types/Action.md +1 -0
- package/dist/okf/types/AutoScalingGroup.md +1 -0
- package/dist/okf/types/Bucket.md +1 -0
- package/dist/okf/types/EC2Route.md +2 -0
- package/dist/okf/types/ECRRepository.md +1 -0
- package/dist/okf/types/Function.md +1 -0
- package/dist/okf/types/IamPolicy.md +1 -0
- package/dist/okf/types/InternetGateway.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 +2 -0
- package/dist/okf/types/Map.md +5 -0
- package/dist/okf/types/NatGateway.md +4 -0
- package/dist/okf/types/Role.md +2 -0
- package/dist/okf/types/RouteTable.md +4 -0
- package/dist/okf/types/SecurityGroup.md +1 -0
- package/dist/okf/types/StateMachine.md +4 -0
- package/dist/okf/types/Subnet.md +6 -0
- package/dist/okf/types/SubnetRouteTableAssociation.md +5 -0
- package/dist/okf/types/Table.md +4 -0
- package/dist/okf/types/TransitGateway.md +6 -0
- package/dist/okf/types/TransitGatewayAttachment.md +4 -0
- package/dist/okf/types/TransitGatewayPeeringAttachment.md +4 -0
- package/dist/okf/types/TransitGatewayRoute.md +5 -0
- package/dist/okf/types/TransitGatewayRouteTable.md +4 -0
- package/dist/okf/types/TransitGatewayRouteTableAssociation.md +4 -0
- package/dist/okf/types/TransitGatewayRouteTablePropagation.md +4 -0
- package/dist/okf/types/TransitGatewayVpcAttachment.md +4 -0
- package/dist/okf/types/Type.md +8 -0
- package/dist/okf/types/VPNConnection.md +4 -0
- package/dist/okf/types/VPNGateway.md +4 -0
- package/dist/okf/types/Vpc.md +1 -0
- package/dist/op/activities/index.d.ts +6 -0
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/builders.d.ts +47 -0
- package/dist/op/builders.d.ts.map +1 -0
- 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 +40 -0
- package/dist/rules/waw016.ts +37 -16
- package/dist/rules/waw061.ts +67 -0
- package/dist/rules/waw062.ts +0 -0
- package/dist/rules/waw063.ts +0 -0
- package/dist/rules/waw064.ts +52 -0
- package/dist/rules/waw065.ts +110 -0
- package/dist/rules/waw066.ts +155 -0
- package/dist/rules/waw067.ts +114 -0
- package/dist/rules/waw068.ts +78 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/skills/chant-aws-carve-terraform.md +26 -23
- package/dist/spec/parse.d.ts +7 -0
- package/dist/spec/parse.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/api/read-client.test.ts +9 -0
- package/src/api/read-client.ts +15 -2
- package/src/codegen/fallback.ts +1 -0
- package/src/codegen/generate-lexicon.ts +6 -0
- package/src/codegen/sam.ts +9 -0
- package/src/components/host-delivery.ts +4 -3
- 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/generated/lexicon-aws.json +34 -0
- package/src/index.ts +36 -7
- package/src/lint/audit-catalog.ts +16 -0
- package/src/lint/post-synth/cf-refs.ts +40 -0
- package/src/lint/post-synth/index.ts +16 -0
- package/src/lint/post-synth/waw016.test.ts +61 -4
- package/src/lint/post-synth/waw016.ts +37 -16
- 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/lint/post-synth/waw064.test.ts +79 -0
- package/src/lint/post-synth/waw064.ts +52 -0
- package/src/lint/post-synth/waw065.test.ts +90 -0
- package/src/lint/post-synth/waw065.ts +110 -0
- package/src/lint/post-synth/waw066.test.ts +108 -0
- package/src/lint/post-synth/waw066.ts +155 -0
- package/src/lint/post-synth/waw067.test.ts +79 -0
- package/src/lint/post-synth/waw067.ts +114 -0
- package/src/lint/post-synth/waw068.test.ts +74 -0
- package/src/lint/post-synth/waw068.ts +78 -0
- package/src/lsp/hover.ts +17 -2
- package/src/op/activities/index.ts +15 -0
- package/src/op/builders.test.ts +61 -0
- package/src/op/builders.ts +72 -0
- package/src/plugin.ts +41 -10
- package/src/receipt-store.test.ts +204 -0
- package/src/receipt-store.ts +315 -0
- package/src/serializer.ts +105 -2
- package/src/skills/chant-aws-carve-terraform.md +26 -23
- package/src/spec/parse.test.ts +74 -0
- package/src/spec/parse.ts +26 -6
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed step-builder wrappers for this lexicon's activities (chant #1288
|
|
3
|
+
* Stage 2). See `lexicons/k8s/src/op/builders.ts`'s module doc for why these
|
|
4
|
+
* live beside their `*Args` interfaces rather than in core or the temporal
|
|
5
|
+
* barrel: core cannot import a lexicon's types (lexicons depend on core, not
|
|
6
|
+
* the reverse), and wiring them into the cloud-agnostic temporal barrel
|
|
7
|
+
* would make it depend on every cloud lexicon at runtime. `opts`'s type in
|
|
8
|
+
* each wrapper below IS the activity's own `*Args` interface (via
|
|
9
|
+
* `Omit`/`WithStepRefs`) — never restated. `core`'s own
|
|
10
|
+
* `awsApply`/`awsDelete`/`flociUp`/`flociDown` are unchanged and produce
|
|
11
|
+
* byte-identical `ActivityStep` output; these are purely additive.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
activity,
|
|
16
|
+
takeProfileAndId,
|
|
17
|
+
type ActivityStep,
|
|
18
|
+
type NamedActivityStep,
|
|
19
|
+
type WithStepRefs,
|
|
20
|
+
} from "@intentius/chant/op";
|
|
21
|
+
import type { AwsApplyArgs, AwsDeleteArgs } from "./activities/aws-apply";
|
|
22
|
+
import type { FlociUpArgs, FlociDownArgs } from "./activities/floci";
|
|
23
|
+
|
|
24
|
+
/** Extra opts every wrapper below accepts alongside its activity's own fields. */
|
|
25
|
+
type StepOpts = { profile?: ActivityStep["profile"]; id?: string };
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Deploy a built CloudFormation template directly against the CFN API — the
|
|
29
|
+
* fully typed twin of core's `awsApply`. `opts` is {@link AwsApplyArgs}
|
|
30
|
+
* itself, minus the positional `templatePath`. Defaults to the `longInfra`
|
|
31
|
+
* profile.
|
|
32
|
+
*/
|
|
33
|
+
export const awsApply = (
|
|
34
|
+
templatePath: string,
|
|
35
|
+
opts: WithStepRefs<Omit<AwsApplyArgs, "templatePath">> & StepOpts,
|
|
36
|
+
): NamedActivityStep => {
|
|
37
|
+
const { args, profile, id } = takeProfileAndId(opts as Record<string, unknown> | undefined);
|
|
38
|
+
return activity("awsApply", { templatePath, ...args }, { profile: profile ?? "longInfra", ...(id ? { id } : {}) });
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Delete a CloudFormation stack — the inverse of {@link awsApply}, fully
|
|
43
|
+
* typed twin of core's `awsDelete`. `opts` is {@link AwsDeleteArgs} itself,
|
|
44
|
+
* minus the positional `templatePath`. Defaults to the `longInfra` profile.
|
|
45
|
+
*/
|
|
46
|
+
export const awsDelete = (
|
|
47
|
+
templatePath: string,
|
|
48
|
+
opts: WithStepRefs<Omit<AwsDeleteArgs, "templatePath">> & StepOpts,
|
|
49
|
+
): NamedActivityStep => {
|
|
50
|
+
const { args, profile, id } = takeProfileAndId(opts as Record<string, unknown> | undefined);
|
|
51
|
+
return activity("awsDelete", { templatePath, ...args }, { profile: profile ?? "longInfra", ...(id ? { id } : {}) });
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Boot the local Floci AWS emulator — the fully typed twin of core's
|
|
56
|
+
* `flociUp`. `opts` is {@link FlociUpArgs} itself. Defaults to the
|
|
57
|
+
* `longInfra` profile.
|
|
58
|
+
*/
|
|
59
|
+
export const flociUp = (opts?: WithStepRefs<FlociUpArgs> & StepOpts): NamedActivityStep => {
|
|
60
|
+
const { args, profile, id } = takeProfileAndId(opts as Record<string, unknown> | undefined);
|
|
61
|
+
return activity("flociUp", args, { profile: profile ?? "longInfra", ...(id ? { id } : {}) });
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Stop and remove the local Floci emulator container — the fully typed twin
|
|
66
|
+
* of core's `flociDown`. `opts` is {@link FlociDownArgs} itself. Defaults to
|
|
67
|
+
* the `fastIdempotent` profile.
|
|
68
|
+
*/
|
|
69
|
+
export const flociDown = (opts?: WithStepRefs<FlociDownArgs> & StepOpts): NamedActivityStep => {
|
|
70
|
+
const { args, profile, id } = takeProfileAndId(opts as Record<string, unknown> | undefined);
|
|
71
|
+
return activity("flociDown", args, { profile: profile ?? "fastIdempotent", ...(id ? { id } : {}) });
|
|
72
|
+
};
|
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
|
);
|
|
@@ -703,13 +722,25 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
703
722
|
detail: `the stack was read, but this resource's own properties were not — ${own.failures.get(type) ?? "the describe call failed"}`,
|
|
704
723
|
};
|
|
705
724
|
}
|
|
706
|
-
return observation(
|
|
725
|
+
return observation(
|
|
726
|
+
{ ...described, ...identity.resources, ...receiptObs.resources },
|
|
727
|
+
{ ...holes, ...receiptObs.unobserved },
|
|
728
|
+
identity.queried,
|
|
729
|
+
notes,
|
|
730
|
+
stackExports,
|
|
731
|
+
);
|
|
707
732
|
}
|
|
708
733
|
|
|
709
734
|
// Every entity the stack answered for was answered for: an entity the
|
|
710
735
|
// template doesn't carry is genuinely not in this stack, which is an
|
|
711
736
|
// absence, not a hole — unless the identity fallback saw it live (#1647).
|
|
712
|
-
return observation(
|
|
737
|
+
return observation(
|
|
738
|
+
{ ...withProperties, ...identity.resources, ...receiptObs.resources },
|
|
739
|
+
receiptHoles,
|
|
740
|
+
identity.queried,
|
|
741
|
+
notes,
|
|
742
|
+
stackExports,
|
|
743
|
+
);
|
|
713
744
|
},
|
|
714
745
|
|
|
715
746
|
/**
|
|
@@ -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
|
+
}
|