local-serverless-stack 1.0.0 → 1.1.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/CHANGELOG.md +125 -3
- package/README.md +92 -38
- package/bin/cli.js +218 -28
- package/bin/i18n.js +57 -3
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/namespaces/services.d.ts +46 -0
- package/dist/client/namespaces/services.d.ts.map +1 -1
- package/dist/client/namespaces/services.js +1 -0
- package/dist/client/namespaces/services.js.map +1 -1
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +17 -0
- package/dist/mcp/tools.js.map +1 -1
- package/dist/server/engine/backends/self-backend.d.ts +26 -1
- package/dist/server/engine/backends/self-backend.d.ts.map +1 -1
- package/dist/server/engine/backends/self-backend.js +104 -11
- package/dist/server/engine/backends/self-backend.js.map +1 -1
- package/dist/server/engine/emulators/dynamodb/expressions/parser.d.ts.map +1 -1
- package/dist/server/engine/emulators/dynamodb/expressions/parser.js +12 -1
- package/dist/server/engine/emulators/dynamodb/expressions/parser.js.map +1 -1
- package/dist/server/engine/emulators/dynamodb/expressions/reserved-words.d.ts +3 -0
- package/dist/server/engine/emulators/dynamodb/expressions/reserved-words.d.ts.map +1 -0
- package/dist/server/engine/emulators/dynamodb/expressions/reserved-words.js +78 -0
- package/dist/server/engine/emulators/dynamodb/expressions/reserved-words.js.map +1 -0
- package/dist/server/engine/emulators/dynamodb/index.d.ts +11 -0
- package/dist/server/engine/emulators/dynamodb/index.d.ts.map +1 -1
- package/dist/server/engine/emulators/dynamodb/index.js +101 -7
- package/dist/server/engine/emulators/dynamodb/index.js.map +1 -1
- package/dist/server/engine/emulators/dynamodb/query.d.ts +1 -0
- package/dist/server/engine/emulators/dynamodb/query.d.ts.map +1 -1
- package/dist/server/engine/emulators/dynamodb/query.js +34 -6
- package/dist/server/engine/emulators/dynamodb/query.js.map +1 -1
- package/dist/server/engine/emulators/s3/index.d.ts +10 -0
- package/dist/server/engine/emulators/s3/index.d.ts.map +1 -1
- package/dist/server/engine/emulators/s3/index.js +369 -25
- package/dist/server/engine/emulators/s3/index.js.map +1 -1
- package/dist/server/engine/emulators/sqs/index.d.ts +2 -0
- package/dist/server/engine/emulators/sqs/index.d.ts.map +1 -1
- package/dist/server/engine/emulators/sqs/index.js +70 -7
- package/dist/server/engine/emulators/sqs/index.js.map +1 -1
- package/dist/server/engine/emulators/sqs/queue.d.ts +2 -1
- package/dist/server/engine/emulators/sqs/queue.d.ts.map +1 -1
- package/dist/server/engine/emulators/sqs/queue.js +68 -35
- package/dist/server/engine/emulators/sqs/queue.js.map +1 -1
- package/dist/server/engine/emulators/ssm/index.d.ts +31 -0
- package/dist/server/engine/emulators/ssm/index.d.ts.map +1 -0
- package/dist/server/engine/emulators/ssm/index.js +670 -0
- package/dist/server/engine/emulators/ssm/index.js.map +1 -0
- package/dist/server/engine/http/errors.d.ts +1 -0
- package/dist/server/engine/http/errors.d.ts.map +1 -1
- package/dist/server/engine/http/errors.js +13 -5
- package/dist/server/engine/http/errors.js.map +1 -1
- package/dist/server/engine/http/is-aws-request.d.ts.map +1 -1
- package/dist/server/engine/http/is-aws-request.js +13 -3
- package/dist/server/engine/http/is-aws-request.js.map +1 -1
- package/dist/server/engine/http/protocols/aws-json.d.ts +1 -0
- package/dist/server/engine/http/protocols/aws-json.d.ts.map +1 -1
- package/dist/server/engine/http/protocols/aws-json.js +13 -0
- package/dist/server/engine/http/protocols/aws-json.js.map +1 -1
- package/dist/server/engine/http/reset.d.ts +4 -0
- package/dist/server/engine/http/reset.d.ts.map +1 -0
- package/dist/server/engine/http/reset.js +17 -0
- package/dist/server/engine/http/reset.js.map +1 -0
- package/dist/server/engine/http/router.d.ts +1 -0
- package/dist/server/engine/http/router.d.ts.map +1 -1
- package/dist/server/engine/http/router.js +185 -34
- package/dist/server/engine/http/router.js.map +1 -1
- package/dist/server/engine/types.d.ts +2 -1
- package/dist/server/engine/types.d.ts.map +1 -1
- package/dist/server/routes/apis.d.ts.map +1 -1
- package/dist/server/routes/apis.js +6 -0
- package/dist/server/routes/apis.js.map +1 -1
- package/dist/server/routes/config.d.ts.map +1 -1
- package/dist/server/routes/config.js +25 -1
- package/dist/server/routes/config.js.map +1 -1
- package/dist/server/routes/lambdas.d.ts.map +1 -1
- package/dist/server/routes/lambdas.js +12 -0
- package/dist/server/routes/lambdas.js.map +1 -1
- package/dist/server/routes/services.d.ts.map +1 -1
- package/dist/server/routes/services.js +66 -5
- package/dist/server/routes/services.js.map +1 -1
- package/dist/server/runtime/runtime-worker.js +40 -3
- package/dist/server/runtime/runtime-worker.js.map +1 -1
- package/dist/server/services/api-gateway-events.d.ts +14 -0
- package/dist/server/services/api-gateway-events.d.ts.map +1 -1
- package/dist/server/services/api-gateway-events.js +114 -8
- package/dist/server/services/api-gateway-events.js.map +1 -1
- package/dist/server/services/artifact-staleness.d.ts +20 -0
- package/dist/server/services/artifact-staleness.d.ts.map +1 -0
- package/dist/server/services/artifact-staleness.js +115 -0
- package/dist/server/services/artifact-staleness.js.map +1 -0
- package/dist/server/services/authorizer-service.d.ts +34 -5
- package/dist/server/services/authorizer-service.d.ts.map +1 -1
- package/dist/server/services/authorizer-service.js +0 -0
- package/dist/server/services/authorizer-service.js.map +1 -1
- package/dist/server/services/cache-manager.d.ts +5 -0
- package/dist/server/services/cache-manager.d.ts.map +1 -1
- package/dist/server/services/cache-manager.js.map +1 -1
- package/dist/server/services/cfn-intrinsics.d.ts +63 -0
- package/dist/server/services/cfn-intrinsics.d.ts.map +1 -0
- package/dist/server/services/cfn-intrinsics.js +403 -0
- package/dist/server/services/cfn-intrinsics.js.map +1 -0
- package/dist/server/services/cfn-physical-values.d.ts +51 -0
- package/dist/server/services/cfn-physical-values.d.ts.map +1 -0
- package/dist/server/services/cfn-physical-values.js +186 -0
- package/dist/server/services/cfn-physical-values.js.map +1 -0
- package/dist/server/services/cloudformation-parser.d.ts +26 -3
- package/dist/server/services/cloudformation-parser.d.ts.map +1 -1
- package/dist/server/services/cloudformation-parser.js +79 -7
- package/dist/server/services/cloudformation-parser.js.map +1 -1
- package/dist/server/services/config-manager.d.ts +49 -2
- package/dist/server/services/config-manager.d.ts.map +1 -1
- package/dist/server/services/config-manager.js +206 -24
- package/dist/server/services/config-manager.js.map +1 -1
- package/dist/server/services/gateway-manager.d.ts.map +1 -1
- package/dist/server/services/gateway-manager.js +52 -20
- package/dist/server/services/gateway-manager.js.map +1 -1
- package/dist/server/services/lambda-runtime-manager.d.ts +2 -0
- package/dist/server/services/lambda-runtime-manager.d.ts.map +1 -1
- package/dist/server/services/lambda-runtime-manager.js +49 -15
- package/dist/server/services/lambda-runtime-manager.js.map +1 -1
- package/dist/server/services/raw-api-assembler.d.ts +1 -1
- package/dist/server/services/raw-api-assembler.d.ts.map +1 -1
- package/dist/server/services/raw-api-assembler.js +18 -8
- package/dist/server/services/raw-api-assembler.js.map +1 -1
- package/dist/server/services/resource-provisioner.d.ts +31 -1
- package/dist/server/services/resource-provisioner.d.ts.map +1 -1
- package/dist/server/services/resource-provisioner.js +257 -25
- package/dist/server/services/resource-provisioner.js.map +1 -1
- package/dist/server/services/s3-explorer.js +1 -1
- package/dist/server/services/seed-manager.d.ts.map +1 -1
- package/dist/server/services/seed-manager.js +11 -6
- package/dist/server/services/seed-manager.js.map +1 -1
- package/dist/server/services/serverless-state-parser.d.ts +45 -2
- package/dist/server/services/serverless-state-parser.d.ts.map +1 -1
- package/dist/server/services/serverless-state-parser.js +217 -29
- package/dist/server/services/serverless-state-parser.js.map +1 -1
- package/dist/server/services/service-graph.d.ts +76 -0
- package/dist/server/services/service-graph.d.ts.map +1 -0
- package/dist/server/services/service-graph.js +914 -0
- package/dist/server/services/service-graph.js.map +1 -0
- package/dist/server/services/service-registrar.d.ts +17 -1
- package/dist/server/services/service-registrar.d.ts.map +1 -1
- package/dist/server/services/service-registrar.js +190 -66
- package/dist/server/services/service-registrar.js.map +1 -1
- package/dist/server/services/service-scanner.d.ts +7 -2
- package/dist/server/services/service-scanner.d.ts.map +1 -1
- package/dist/server/services/service-scanner.js +53 -7
- package/dist/server/services/service-scanner.js.map +1 -1
- package/dist/server/services/source-watcher.d.ts +1 -0
- package/dist/server/services/source-watcher.d.ts.map +1 -1
- package/dist/server/services/source-watcher.js +19 -1
- package/dist/server/services/source-watcher.js.map +1 -1
- package/dist/server/services/worker-runtime.d.ts +82 -0
- package/dist/server/services/worker-runtime.d.ts.map +1 -0
- package/dist/server/services/worker-runtime.js +303 -0
- package/dist/server/services/worker-runtime.js.map +1 -0
- package/dist/ui/assets/ApisPage-BkW-QZ0X.js +1 -0
- package/dist/ui/assets/BucketDetailPage-CJwTQJh7.js +1 -0
- package/dist/ui/assets/BucketsPage-B1xPVhZC.js +1 -0
- package/dist/ui/assets/DynamoPage-DvbpwZDc.js +1 -0
- package/dist/ui/assets/DynamoTablePage-xn3Lq3so.js +3 -0
- package/dist/ui/assets/LambdaDetailPage-BhUyO77H.js +4 -0
- package/dist/ui/assets/LambdasPage-CUF_4rOH.js +1 -0
- package/dist/ui/assets/OnboardingPage-U49THUzu.js +1 -0
- package/dist/ui/assets/OpenSearchCollectionPage-BJTVNCq4.js +1 -0
- package/dist/ui/assets/OpenSearchPage-4s-egkB9.js +1 -0
- package/dist/ui/assets/OverviewPage-DJeJQktG.js +1 -0
- package/dist/ui/assets/{OverviewPage-C1pBJ8aP.css → OverviewPage-DjQETmb_.css} +1 -1
- package/dist/ui/assets/QueueDetailPage-DWsy5Yt8.js +1 -0
- package/dist/ui/assets/QueuesPage-BZJSqman.js +1 -0
- package/dist/ui/assets/SecretsPage-YC9ua54G.js +1 -0
- package/dist/ui/assets/ServiceDetailPage-Cum5frP4.js +2 -0
- package/dist/ui/assets/ServicesPage-IpaPDuqx.js +2 -0
- package/dist/ui/assets/SettingsPage-BiCRU8O6.js +1 -0
- package/dist/ui/assets/index-BDlrAJhi.css +1 -0
- package/dist/ui/assets/index-BouKSS43.js +38 -0
- package/dist/ui/assets/resourceIcons-DZs28nbs.js +1 -0
- package/dist/ui/index.html +5 -2
- package/package.json +1 -1
- package/dist/ui/assets/ApisPage-BOuimwWz.js +0 -1
- package/dist/ui/assets/BucketDetailPage-vLYwhoQt.js +0 -1
- package/dist/ui/assets/BucketsPage-Cu5lU1Hp.js +0 -1
- package/dist/ui/assets/DynamoPage-y3TTEPUL.js +0 -1
- package/dist/ui/assets/DynamoTablePage-BnmSRZjc.js +0 -3
- package/dist/ui/assets/LambdaDetailPage-B5q_d27t.js +0 -4
- package/dist/ui/assets/LambdasPage-CJpRoEDx.js +0 -1
- package/dist/ui/assets/OnboardingPage-D02dymQi.js +0 -1
- package/dist/ui/assets/OpenSearchCollectionPage-B_wWTlfL.js +0 -1
- package/dist/ui/assets/OpenSearchPage-BLEgfF_g.js +0 -1
- package/dist/ui/assets/OverviewPage-EdJWiAt6.js +0 -1
- package/dist/ui/assets/QueueDetailPage-ByEYHvHe.js +0 -1
- package/dist/ui/assets/QueuesPage-CvwYHSll.js +0 -1
- package/dist/ui/assets/SecretsPage-DASw8Ntf.js +0 -1
- package/dist/ui/assets/ServiceDetailPage-BMsUDg6L.js +0 -2
- package/dist/ui/assets/ServicesPage-2JuiIiRr.js +0 -2
- package/dist/ui/assets/SettingsPage-DStO4bgN.js +0 -1
- package/dist/ui/assets/index-BIEznsi4.js +0 -37
- package/dist/ui/assets/index-BRMvNP3Y.css +0 -1
- package/dist/ui/assets/resourceIcons-baecADXA.js +0 -1
|
@@ -0,0 +1,914 @@
|
|
|
1
|
+
import { CloudFormationParser } from './cloudformation-parser.js';
|
|
2
|
+
// Same locals the raw API assembler signs with — the engine issues one account
|
|
3
|
+
// and one partition, so a graph ARN must reduce to the same string an
|
|
4
|
+
// assembler-built ARN does or the two would never match.
|
|
5
|
+
const ACCOUNT_ID = '000000000000';
|
|
6
|
+
const PARTITION = 'aws';
|
|
7
|
+
// The Serverless Framework's own execution role, and the helper role it emits
|
|
8
|
+
// for custom resources. The second one is FRAMEWORK plumbing — its statements
|
|
9
|
+
// grant `s3:PutBucketNotification` / `lambda:AddPermission` to a helper Lambda
|
|
10
|
+
// the user never wrote. Walking it would grow a bogus lambda→bucket edge on
|
|
11
|
+
// every service that uses an existing-bucket S3 event.
|
|
12
|
+
const CUSTOM_RESOURCES_ROLE = 'IamRoleCustomResourcesLambdaExecution';
|
|
13
|
+
// --- Small shared helpers ----------------------------------------------------
|
|
14
|
+
// CFN lists are routinely written as a lone scalar (`Action: 'logs:PutLogEvents'`,
|
|
15
|
+
// `Resource: {Fn::Join: […]}`). Coerce before iterating, and keep objects — an
|
|
16
|
+
// IAM Resource entry is usually an intrinsic, not a string.
|
|
17
|
+
function toArray(value) {
|
|
18
|
+
if (Array.isArray(value))
|
|
19
|
+
return value;
|
|
20
|
+
if (value === undefined || value === null)
|
|
21
|
+
return [];
|
|
22
|
+
return [value];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The logical id inside a `{Ref}` / `{Fn::GetAtt}`, or null for anything else.
|
|
26
|
+
*
|
|
27
|
+
* Both JSON spellings of Fn::GetAtt are handled. The parser's own private
|
|
28
|
+
* `extractFunctionName`/`extractArn` assume the array form and misbehave on
|
|
29
|
+
* the dotted string (`{"Fn::GetAtt": "X.Arn"}`) — one returns the first
|
|
30
|
+
* character, the other throws — so this does not reuse them.
|
|
31
|
+
*/
|
|
32
|
+
function refLogicalId(ref) {
|
|
33
|
+
if (typeof ref === 'string') {
|
|
34
|
+
// Already reduced by the parser: `X::Arn` (extractArn) or `X.Arn`
|
|
35
|
+
// (resolveArnLike / sanitizeEnvironmentValues). Three encodings of the same
|
|
36
|
+
// reference coexist in this codebase; normalise all of them here.
|
|
37
|
+
if (ref.startsWith('arn:'))
|
|
38
|
+
return null;
|
|
39
|
+
return ref.split(/[:.]/)[0] || null;
|
|
40
|
+
}
|
|
41
|
+
if (typeof ref !== 'object' || ref === null)
|
|
42
|
+
return null;
|
|
43
|
+
const obj = ref;
|
|
44
|
+
if ('Ref' in obj)
|
|
45
|
+
return String(obj.Ref);
|
|
46
|
+
if ('Fn::GetAtt' in obj) {
|
|
47
|
+
const raw = obj['Fn::GetAtt'];
|
|
48
|
+
const parts = Array.isArray(raw) ? raw.map(String) : String(raw).split('.');
|
|
49
|
+
return parts[0] || null;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Reduce an ARN to the (aws service, resource name) pair a declared resource
|
|
55
|
+
* would be indexed under.
|
|
56
|
+
*
|
|
57
|
+
* The sub-resource suffixes matter: a policy that grants `dynamodb:Query` on
|
|
58
|
+
* `…:table/Orders/index/*` is about the Orders TABLE, and a stream ARN carries
|
|
59
|
+
* a creation timestamp nobody can predict at synth time. Secrets are the one
|
|
60
|
+
* kind that cannot be matched exactly — AWS appends six random characters at
|
|
61
|
+
* create time — so they match by prefix at the call site.
|
|
62
|
+
*/
|
|
63
|
+
export function parseResourceArn(arn) {
|
|
64
|
+
if (!arn.startsWith('arn:'))
|
|
65
|
+
return null;
|
|
66
|
+
const parts = arn.split(':');
|
|
67
|
+
if (parts.length < 6)
|
|
68
|
+
return null;
|
|
69
|
+
const service = parts[2];
|
|
70
|
+
const raw = parts.slice(5).join(':');
|
|
71
|
+
if (!raw)
|
|
72
|
+
return null;
|
|
73
|
+
// AWS spells the resource-type discriminator two ways, and BOTH have to go or
|
|
74
|
+
// the name never matches a declared resource. Secrets Manager and Lambda use
|
|
75
|
+
// a COLON (`secret:app-db-AbCdEf`, `function:svc-dev-worker`) — which the
|
|
76
|
+
// rejoin above has just put back — while DynamoDB, EventBridge and OpenSearch
|
|
77
|
+
// use a SLASH (`table/Orders`). Missing the colon form is not theoretical:
|
|
78
|
+
// the engine's own Secrets Manager emits `…:secret:<name>-<6 chars>`, so an
|
|
79
|
+
// IAM grant on a secret this service declares would have drawn an external
|
|
80
|
+
// node sitting next to the real one.
|
|
81
|
+
const colonPrefixed = ['secret', 'function', 'layer', 'event-source-mapping'];
|
|
82
|
+
const colonSegments = raw.split(':');
|
|
83
|
+
const tail = colonSegments.length > 1 && colonPrefixed.includes(colonSegments[0])
|
|
84
|
+
? colonSegments.slice(1).join(':')
|
|
85
|
+
: raw;
|
|
86
|
+
if (!tail)
|
|
87
|
+
return null;
|
|
88
|
+
const segments = tail.split('/');
|
|
89
|
+
// `table/Orders`, `event-bus/billing`, `collection/abc`, `domain/search`: the
|
|
90
|
+
// first segment is the discriminator, the second is the name. SQS and SNS
|
|
91
|
+
// have neither form, and S3 puts the bucket first.
|
|
92
|
+
// `function` appears in BOTH lists on purpose: AWS accepts `function:name`
|
|
93
|
+
// and `function/name`, and templates in the wild carry each.
|
|
94
|
+
const prefixed = ['table', 'event-bus', 'rule', 'collection', 'domain', 'stream', 'function'];
|
|
95
|
+
if (segments.length > 1 && prefixed.includes(segments[0])) {
|
|
96
|
+
return segments[1] ? { service, name: segments[1] } : null;
|
|
97
|
+
}
|
|
98
|
+
return segments[0] ? { service, name: segments[0] } : null;
|
|
99
|
+
}
|
|
100
|
+
// Which declared node kind an IAM action grants against. Driven entirely by the
|
|
101
|
+
// prefix before the colon — the action space in play is small and closed.
|
|
102
|
+
// Everything absent from this map is deliberately noise: `logs:*` is on every
|
|
103
|
+
// osls role by construction, and `xray`/`sts`/`ec2`/`elasticfilesystem`/
|
|
104
|
+
// `cloudwatch` are VPC and tracing plumbing that say nothing about wiring.
|
|
105
|
+
const ACTION_SERVICE_TO_KIND = {
|
|
106
|
+
dynamodb: 'dynamodb',
|
|
107
|
+
sqs: 'sqs',
|
|
108
|
+
sns: 'sns',
|
|
109
|
+
s3: 's3',
|
|
110
|
+
secretsmanager: 'secret',
|
|
111
|
+
es: 'opensearch',
|
|
112
|
+
aoss: 'opensearch',
|
|
113
|
+
events: 'eventbus',
|
|
114
|
+
lambda: 'lambda',
|
|
115
|
+
};
|
|
116
|
+
// The ARN service segment uses the same vocabulary as the action prefix, with
|
|
117
|
+
// one exception: an `execute-api` ARN is an API Gateway grant, which the graph
|
|
118
|
+
// models as routes rather than as a node kind of its own.
|
|
119
|
+
function kindForAwsService(service) {
|
|
120
|
+
return ACTION_SERVICE_TO_KIND[service] ?? null;
|
|
121
|
+
}
|
|
122
|
+
// `lambda:CheckpointDurableExecution` and its sibling are an osls self-grant
|
|
123
|
+
// pointing the function at itself; drawing them would put a self-loop on every
|
|
124
|
+
// Lambda of a durable-execution service.
|
|
125
|
+
const IGNORED_ACTIONS = new Set([
|
|
126
|
+
'lambda:CheckpointDurableExecution',
|
|
127
|
+
'lambda:GetDurableExecutionState',
|
|
128
|
+
]);
|
|
129
|
+
// Env vars whose value is infrastructure, not a resource reference. Without
|
|
130
|
+
// this list `AWS_ENDPOINT=http://localhost:14566` matches a "resource" called
|
|
131
|
+
// `14566` on three of the four bundled examples.
|
|
132
|
+
const ENV_KEY_DENYLIST = new Set([
|
|
133
|
+
'AWS_ENDPOINT',
|
|
134
|
+
'AWS_ENDPOINT_URL',
|
|
135
|
+
'AWS_REGION',
|
|
136
|
+
'AWS_DEFAULT_REGION',
|
|
137
|
+
'AWS_ACCOUNT_ID',
|
|
138
|
+
'AWS_LAMBDA_EXEC_WRAPPER',
|
|
139
|
+
'NODE_OPTIONS',
|
|
140
|
+
'STAGE',
|
|
141
|
+
'NODE_ENV',
|
|
142
|
+
'LOG_LEVEL',
|
|
143
|
+
]);
|
|
144
|
+
/**
|
|
145
|
+
* Builds the wiring graph for one service.
|
|
146
|
+
*
|
|
147
|
+
* Pure: no I/O, no engine calls, no clock. Everything it needs is the template
|
|
148
|
+
* the registrar cached and the metadata that came with it — which is what lets
|
|
149
|
+
* the unit suite cover every edge kind from hand-written templates.
|
|
150
|
+
*/
|
|
151
|
+
export function buildServiceGraph(input) {
|
|
152
|
+
const warnings = [];
|
|
153
|
+
const nodes = new Map();
|
|
154
|
+
const edges = new Map();
|
|
155
|
+
const raw = (input.template ?? {});
|
|
156
|
+
const rawResources = raw.Resources ?? {};
|
|
157
|
+
// --- Index the declared resources ------------------------------------------
|
|
158
|
+
// Two indexes, because references arrive in two currencies: a logical id
|
|
159
|
+
// (every intra-template intrinsic) and a concrete name (every literal ARN,
|
|
160
|
+
// every env-var value). The provisioner builds the first one exactly this way.
|
|
161
|
+
const byLogicalId = new Map();
|
|
162
|
+
const byKindAndName = new Map();
|
|
163
|
+
const lambdas = new Map();
|
|
164
|
+
for (const resource of input.resources) {
|
|
165
|
+
if ('logicalId' in resource && resource.logicalId) {
|
|
166
|
+
byLogicalId.set(resource.logicalId, resource);
|
|
167
|
+
if (resource.type === 'lambda')
|
|
168
|
+
lambdas.set(resource.logicalId, resource);
|
|
169
|
+
}
|
|
170
|
+
const kind = nodeKindFor(resource);
|
|
171
|
+
if (kind && 'name' in resource && typeof resource.name === 'string') {
|
|
172
|
+
byKindAndName.set(kindNameKey(kind, resource.name), resource);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// --- Nodes: one per declared resource --------------------------------------
|
|
176
|
+
// Order matters only for readability of the payload; the dashboard lays out
|
|
177
|
+
// by kind, not by array position.
|
|
178
|
+
for (const resource of input.resources) {
|
|
179
|
+
const kind = nodeKindFor(resource);
|
|
180
|
+
if (!kind)
|
|
181
|
+
continue;
|
|
182
|
+
addResourceNode(nodes, resource, kind, input.functions);
|
|
183
|
+
}
|
|
184
|
+
// --- Nodes: one per declared HTTP route ------------------------------------
|
|
185
|
+
// Routes are the granular entry point — "POST /orders" is what a developer
|
|
186
|
+
// actually calls, and collapsing them into a single API node would hide which
|
|
187
|
+
// function serves which path, which is the first thing this screen is for.
|
|
188
|
+
const routes = input.routes ?? [];
|
|
189
|
+
const functions = input.functions ?? [];
|
|
190
|
+
for (const route of routes) {
|
|
191
|
+
const node = routeNode(route);
|
|
192
|
+
nodes.set(node.id, node);
|
|
193
|
+
const target = lambdaNodeIdForShortName(route.functionName, functions, byLogicalId);
|
|
194
|
+
if (target && nodes.has(target)) {
|
|
195
|
+
addEdge(edges, {
|
|
196
|
+
from: node.id,
|
|
197
|
+
to: target,
|
|
198
|
+
kind: 'http-route',
|
|
199
|
+
detail: route.eventType === 'http' ? 'REST' : 'HTTP API',
|
|
200
|
+
confidence: 'declared',
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
// A route whose function is not a local Lambda is a cross-service or raw
|
|
205
|
+
// ::Integration route; the assembler keeps its ARN precisely for this.
|
|
206
|
+
const external = externalNodeFromArn(nodes, route.functionArn, 'lambda');
|
|
207
|
+
if (external) {
|
|
208
|
+
addEdge(edges, {
|
|
209
|
+
from: node.id,
|
|
210
|
+
to: external,
|
|
211
|
+
kind: 'http-route',
|
|
212
|
+
detail: route.eventType === 'http' ? 'REST' : 'HTTP API',
|
|
213
|
+
confidence: 'declared',
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
warnings.push(`Route ${route.method} ${route.path} names function "${route.functionName}", which this service does not declare`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// --- Edges: authorizer Lambda guards a route -------------------------------
|
|
222
|
+
const authorizers = new Map();
|
|
223
|
+
for (const authorizer of input.authorizers ?? [])
|
|
224
|
+
authorizers.set(authorizer.name, authorizer);
|
|
225
|
+
for (const route of routes) {
|
|
226
|
+
if (!route.authorizerName)
|
|
227
|
+
continue;
|
|
228
|
+
const authorizer = authorizers.get(route.authorizerName);
|
|
229
|
+
if (!authorizer) {
|
|
230
|
+
warnings.push(`Route ${route.method} ${route.path} names authorizer "${route.authorizerName}", which this service does not declare`);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
const routeId = routeNode(route).id;
|
|
234
|
+
const local = authorizer.functionName
|
|
235
|
+
? lambdaNodeIdForShortName(authorizer.functionName, functions, byLogicalId)
|
|
236
|
+
: null;
|
|
237
|
+
const source = local && nodes.has(local)
|
|
238
|
+
? local
|
|
239
|
+
: externalNodeFromArn(nodes, authorizer.arn, 'lambda');
|
|
240
|
+
if (!source) {
|
|
241
|
+
warnings.push(`Authorizer "${authorizer.name}" resolves to neither a local function nor an ARN`);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
addEdge(edges, {
|
|
245
|
+
from: source,
|
|
246
|
+
to: routeId,
|
|
247
|
+
kind: 'authorizer',
|
|
248
|
+
detail: authorizer.type,
|
|
249
|
+
confidence: 'declared',
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
// --- Edges: event-source mappings (SQS → Lambda, DynamoDB stream → Lambda) --
|
|
253
|
+
for (const resource of input.resources) {
|
|
254
|
+
if (resource.type !== 'event-source')
|
|
255
|
+
continue;
|
|
256
|
+
addEventSourceEdge(resource, { nodes, edges, byLogicalId, byKindAndName, warnings });
|
|
257
|
+
}
|
|
258
|
+
// --- Edges: S3 notifications, EventBridge rules, SQS redrive ---------------
|
|
259
|
+
for (const resource of input.resources) {
|
|
260
|
+
if (resource.type === 's3')
|
|
261
|
+
addS3NotificationEdges(resource, { nodes, edges, byLogicalId, byKindAndName, warnings });
|
|
262
|
+
if (resource.type === 'event-rule')
|
|
263
|
+
addEventRuleEdges(resource, { nodes, edges, byLogicalId, byKindAndName, warnings });
|
|
264
|
+
if (resource.type === 'sqs')
|
|
265
|
+
addRedriveEdge(resource, { nodes, edges, byLogicalId, byKindAndName, warnings });
|
|
266
|
+
}
|
|
267
|
+
// --- Edges: SNS subscriptions (raw template — the parser has no arm) -------
|
|
268
|
+
addSnsSubscriptionEdges(rawResources, { nodes, edges, byLogicalId, byKindAndName, warnings });
|
|
269
|
+
// --- Edges: IAM grants (raw template — the parser drops IAM roles) ---------
|
|
270
|
+
const parser = new CloudFormationParser();
|
|
271
|
+
const ctx = {
|
|
272
|
+
region: input.region || 'us-east-1',
|
|
273
|
+
accountId: ACCOUNT_ID,
|
|
274
|
+
partition: PARTITION,
|
|
275
|
+
lambdas,
|
|
276
|
+
};
|
|
277
|
+
addIamEdges(rawResources, {
|
|
278
|
+
nodes, edges, byLogicalId, byKindAndName, warnings, parser, ctx,
|
|
279
|
+
});
|
|
280
|
+
// --- Edges: environment variables naming a declared resource ---------------
|
|
281
|
+
addEnvEdges(functions, { nodes, edges, byKindAndName, byLogicalId, warnings });
|
|
282
|
+
const edgeList = [...edges.values()];
|
|
283
|
+
return {
|
|
284
|
+
service: input.serviceName,
|
|
285
|
+
nodes: [...nodes.values()],
|
|
286
|
+
edges: edgeList,
|
|
287
|
+
edgeKinds: [...new Set(edgeList.map(edge => edge.kind))],
|
|
288
|
+
warnings,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
// --- Node construction -------------------------------------------------------
|
|
292
|
+
// Which parsed resources become nodes. The API Gateway v2 plumbing
|
|
293
|
+
// (`apigw-api`/`-integration`/`-route`/`-authorizer`) and `lambda-permission`
|
|
294
|
+
// are deliberately absent: they are how a route is WIRED, not something a
|
|
295
|
+
// developer reasons about, and drawing them would bury the four real nodes of a
|
|
296
|
+
// small service under a dozen framework boxes. Their information reaches the
|
|
297
|
+
// graph as route nodes instead.
|
|
298
|
+
function nodeKindFor(resource) {
|
|
299
|
+
switch (resource.type) {
|
|
300
|
+
case 'lambda': return 'lambda';
|
|
301
|
+
case 'dynamodb': return 'dynamodb';
|
|
302
|
+
case 'sqs': return 'sqs';
|
|
303
|
+
case 'sns': return 'sns';
|
|
304
|
+
case 's3': return 's3';
|
|
305
|
+
case 'secret': return 'secret';
|
|
306
|
+
case 'eventbus': return 'eventbus';
|
|
307
|
+
case 'event-rule': return 'event-rule';
|
|
308
|
+
case 'opensearch': return 'opensearch';
|
|
309
|
+
default: return null;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
function nodeId(kind, key) {
|
|
313
|
+
return `${kind}:${key}`;
|
|
314
|
+
}
|
|
315
|
+
// Key for the (kind, declared name) index. The separator is a NUL rather than a
|
|
316
|
+
// space because it must never occur inside an AWS resource name — splitting on
|
|
317
|
+
// a space would truncate any name that contains one.
|
|
318
|
+
function kindNameKey(kind, name) {
|
|
319
|
+
return `${kind}\u0000${name}`;
|
|
320
|
+
}
|
|
321
|
+
function addResourceNode(nodes, resource, kind, functions) {
|
|
322
|
+
const declared = resource;
|
|
323
|
+
const id = nodeId(kind, declared.logicalId);
|
|
324
|
+
const node = {
|
|
325
|
+
id,
|
|
326
|
+
kind,
|
|
327
|
+
// A `TableName: {Fn::Sub: …}` reaches the parser as an object cast to
|
|
328
|
+
// string, so the label falls back to the logical id rather than rendering
|
|
329
|
+
// "[object Object]" on the canvas.
|
|
330
|
+
label: typeof declared.name === 'string' && declared.name ? declared.name : declared.logicalId,
|
|
331
|
+
logicalId: declared.logicalId,
|
|
332
|
+
};
|
|
333
|
+
if (kind === 'lambda') {
|
|
334
|
+
const lambda = resource;
|
|
335
|
+
node.fullName = lambda.name;
|
|
336
|
+
node.handler = lambda.handler;
|
|
337
|
+
}
|
|
338
|
+
if (kind === 'lambda' && functions) {
|
|
339
|
+
// The dashboard links a Lambda node to its detail screen by SHORT name,
|
|
340
|
+
// which only serverless-state knows; the template carries the full name.
|
|
341
|
+
const match = functions.find(fn => fn.fullName === resource.name);
|
|
342
|
+
if (match)
|
|
343
|
+
node.label = match.name;
|
|
344
|
+
}
|
|
345
|
+
nodes.set(id, node);
|
|
346
|
+
}
|
|
347
|
+
function routeNode(route) {
|
|
348
|
+
return {
|
|
349
|
+
id: nodeId('route', `${route.method} ${route.path}`),
|
|
350
|
+
kind: 'route',
|
|
351
|
+
label: `${route.method} ${route.path}`,
|
|
352
|
+
method: route.method,
|
|
353
|
+
path: route.path,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
// A referenced ARN that this service does not declare still deserves a node —
|
|
357
|
+
// the billing example's grant on the orders service's queue is exactly the
|
|
358
|
+
// cross-service link a per-service view would otherwise hide.
|
|
359
|
+
function externalNodeFromArn(nodes, arn, fallbackKind) {
|
|
360
|
+
if (!arn)
|
|
361
|
+
return null;
|
|
362
|
+
const parsed = parseResourceArn(arn);
|
|
363
|
+
const label = parsed ? parsed.name : arn;
|
|
364
|
+
const service = parsed ? parsed.service : undefined;
|
|
365
|
+
const id = nodeId('external', arn);
|
|
366
|
+
if (!nodes.has(id)) {
|
|
367
|
+
nodes.set(id, {
|
|
368
|
+
id,
|
|
369
|
+
kind: 'external',
|
|
370
|
+
label,
|
|
371
|
+
arn,
|
|
372
|
+
service: service ?? fallbackKind,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
return id;
|
|
376
|
+
}
|
|
377
|
+
// A reference that names — rather than Refs — a resource owned by another
|
|
378
|
+
// service. It has no ARN and no logical id here, only a name.
|
|
379
|
+
function externalNodeByName(nodes, name, kind) {
|
|
380
|
+
const id = nodeId('external', name);
|
|
381
|
+
if (!nodes.has(id)) {
|
|
382
|
+
nodes.set(id, { id, kind: 'external', label: name, service: kind });
|
|
383
|
+
}
|
|
384
|
+
return id;
|
|
385
|
+
}
|
|
386
|
+
// serverless-state speaks SHORT function names; the template speaks logical ids
|
|
387
|
+
// and FULL names. This is the join between the two vocabularies.
|
|
388
|
+
function lambdaNodeIdForShortName(shortName, functions, byLogicalId) {
|
|
389
|
+
const fn = functions.find(candidate => candidate.name === shortName);
|
|
390
|
+
if (!fn)
|
|
391
|
+
return null;
|
|
392
|
+
for (const [logicalId, resource] of byLogicalId) {
|
|
393
|
+
if (resource.type === 'lambda' && resource.name === fn.fullName)
|
|
394
|
+
return nodeId('lambda', logicalId);
|
|
395
|
+
}
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Adds an edge, keyed by (from, to, kind) so the same wiring described twice
|
|
400
|
+
* lands once.
|
|
401
|
+
*
|
|
402
|
+
* The de-duplication is load-bearing, not hygiene: osls pushes an
|
|
403
|
+
* `sqs:ReceiveMessage` / `dynamodb:GetRecords` statement onto the execution
|
|
404
|
+
* role for every event-source mapping it compiles, aimed at the very same ARN.
|
|
405
|
+
* Without this the canvas draws a doubled arrow on every event source. The
|
|
406
|
+
* structural edge wins because it carries batch size and filters; the IAM
|
|
407
|
+
* statement only re-states it.
|
|
408
|
+
*/
|
|
409
|
+
function addEdge(edges, edge) {
|
|
410
|
+
const id = `${edge.from}\u0000${edge.to}\u0000${edge.kind}`;
|
|
411
|
+
if (edges.has(id))
|
|
412
|
+
return;
|
|
413
|
+
edges.set(id, { ...edge, id });
|
|
414
|
+
}
|
|
415
|
+
// True when some edge already connects this pair, whatever its kind — used to
|
|
416
|
+
// keep an IAM grant from restating a structural edge in a second line.
|
|
417
|
+
function connected(edges, from, to) {
|
|
418
|
+
for (const edge of edges.values()) {
|
|
419
|
+
if ((edge.from === from && edge.to === to) || (edge.from === to && edge.to === from))
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Resolve an intra-template reference (logical id, `X::Arn`, `X.Arn`) or a
|
|
426
|
+
* literal ARN to a node id, creating an external node when it points outside.
|
|
427
|
+
*
|
|
428
|
+
* `nameKind` opts a carrier into name-addressed cross-service resolution. Most
|
|
429
|
+
* references are logical ids and a miss means a broken template — but a few
|
|
430
|
+
* CloudFormation properties take a plain NAME that legitimately belongs to
|
|
431
|
+
* another stack. `EventBusName: billing-events` is the bundled demo's own
|
|
432
|
+
* pipeline: the notifications rule listens on the bus the billing service
|
|
433
|
+
* declares. Dropping that reference silently would hide the single most
|
|
434
|
+
* interesting arrow on the screen, so those carriers pass the kind they expect
|
|
435
|
+
* and get an external node instead of a warning.
|
|
436
|
+
*/
|
|
437
|
+
function resolveRefToNode(ref, ctx, nameKind) {
|
|
438
|
+
if (typeof ref === 'string' && ref.startsWith('arn:')) {
|
|
439
|
+
const parsed = parseResourceArn(ref);
|
|
440
|
+
if (parsed) {
|
|
441
|
+
const kind = kindForAwsService(parsed.service);
|
|
442
|
+
if (kind && ctx.byKindAndName) {
|
|
443
|
+
const local = ctx.byKindAndName.get(kindNameKey(kind, parsed.name));
|
|
444
|
+
if (local && 'logicalId' in local && local.logicalId)
|
|
445
|
+
return nodeId(kind, local.logicalId);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return externalNodeFromArn(ctx.nodes, ref, 'lambda');
|
|
449
|
+
}
|
|
450
|
+
const logicalId = refLogicalId(ref);
|
|
451
|
+
if (!logicalId)
|
|
452
|
+
return null;
|
|
453
|
+
const resource = ctx.byLogicalId.get(logicalId);
|
|
454
|
+
if (!resource) {
|
|
455
|
+
if (!nameKind || typeof ref !== 'string')
|
|
456
|
+
return null;
|
|
457
|
+
// Not a logical id in this template, so the string is a name owned
|
|
458
|
+
// elsewhere. Match a same-named local resource first — a service is allowed
|
|
459
|
+
// to name its own bus in prose — then fall back to an external node.
|
|
460
|
+
const local = ctx.byKindAndName?.get(kindNameKey(nameKind, ref));
|
|
461
|
+
if (local && 'logicalId' in local && local.logicalId)
|
|
462
|
+
return nodeId(nameKind, local.logicalId);
|
|
463
|
+
return externalNodeByName(ctx.nodes, ref, nameKind);
|
|
464
|
+
}
|
|
465
|
+
const kind = nodeKindFor(resource);
|
|
466
|
+
if (!kind)
|
|
467
|
+
return null;
|
|
468
|
+
// No `nodes.has` guard: the node pass created a node for every resource
|
|
469
|
+
// `nodeKindFor` accepts, under the very key `byLogicalId` is indexed by, so a
|
|
470
|
+
// kind-ful hit here always has one.
|
|
471
|
+
return nodeId(kind, logicalId);
|
|
472
|
+
}
|
|
473
|
+
function addEventSourceEdge(mapping, ctx) {
|
|
474
|
+
const target = resolveRefToNode(mapping.functionName, ctx);
|
|
475
|
+
const source = resolveRefToNode(mapping.eventSourceArn, ctx);
|
|
476
|
+
if (!target || !source) {
|
|
477
|
+
warnUnresolved(ctx, `Event-source mapping ${mapping.eventSourceArn || '(unnamed)'} → ${mapping.functionName || '(unnamed)'}`);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
// A DynamoDB source is the table's STREAM, not the table itself. Saying so in
|
|
481
|
+
// the label keeps the arrow honest: a stream edge does not mean the function
|
|
482
|
+
// reads the table through the data plane.
|
|
483
|
+
const isStream = typeof mapping.eventSourceArn === 'string'
|
|
484
|
+
&& (mapping.eventSourceArn.includes('StreamArn') || mapping.eventSourceArn.includes('/stream/'));
|
|
485
|
+
const detail = isStream ? 'stream' : 'poll';
|
|
486
|
+
addEdge(ctx.edges, {
|
|
487
|
+
from: source,
|
|
488
|
+
to: target,
|
|
489
|
+
kind: 'event-source',
|
|
490
|
+
detail: mapping.batchSize ? `${detail} ×${mapping.batchSize}` : detail,
|
|
491
|
+
confidence: 'declared',
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
function addS3NotificationEdges(bucket, ctx) {
|
|
495
|
+
for (const notification of bucket.notifications) {
|
|
496
|
+
const target = resolveRefToNode(notification.functionRef, ctx);
|
|
497
|
+
if (!target) {
|
|
498
|
+
warnUnresolved(ctx, `S3 notification on ${bucket.name} → ${notification.functionRef}`);
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
addEdge(ctx.edges, {
|
|
502
|
+
from: nodeId('s3', bucket.logicalId),
|
|
503
|
+
to: target,
|
|
504
|
+
kind: 's3-notification',
|
|
505
|
+
detail: notification.events.join(', '),
|
|
506
|
+
confidence: 'declared',
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
function addEventRuleEdges(rule, ctx) {
|
|
511
|
+
const ruleNode = nodeId('event-rule', rule.logicalId);
|
|
512
|
+
// A rule with no `EventBusName` listens on the default bus, which no template
|
|
513
|
+
// declares — there is nothing to point at, so no edge is drawn rather than
|
|
514
|
+
// inventing a node for a bus this service does not own.
|
|
515
|
+
if (rule.eventBusRef) {
|
|
516
|
+
const bus = resolveRefToNode(rule.eventBusRef, ctx, 'eventbus');
|
|
517
|
+
if (bus) {
|
|
518
|
+
addEdge(ctx.edges, {
|
|
519
|
+
from: bus,
|
|
520
|
+
to: ruleNode,
|
|
521
|
+
kind: 'event-bus-rule',
|
|
522
|
+
detail: rule.scheduleExpression || undefined,
|
|
523
|
+
confidence: 'declared',
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
else {
|
|
527
|
+
warnUnresolved(ctx, `EventBridge rule ${rule.name} names bus "${rule.eventBusRef}"`);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
for (const target of rule.targets) {
|
|
531
|
+
const node = resolveRefToNode(target.functionRef, ctx);
|
|
532
|
+
if (!node) {
|
|
533
|
+
warnUnresolved(ctx, `EventBridge rule ${rule.name} targets ${target.functionRef}`);
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
addEdge(ctx.edges, {
|
|
537
|
+
from: ruleNode,
|
|
538
|
+
to: node,
|
|
539
|
+
kind: 'event-rule-target',
|
|
540
|
+
detail: rule.scheduleExpression || undefined,
|
|
541
|
+
confidence: 'declared',
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
function addRedriveEdge(queue, ctx) {
|
|
546
|
+
if (!queue.redrivePolicy)
|
|
547
|
+
return;
|
|
548
|
+
const dlq = resolveRefToNode(queue.redrivePolicy.deadLetterTargetRef, ctx);
|
|
549
|
+
if (!dlq) {
|
|
550
|
+
warnUnresolved(ctx, `Queue ${queue.name} redrives to ${queue.redrivePolicy.deadLetterTargetRef}`);
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
addEdge(ctx.edges, {
|
|
554
|
+
from: nodeId('sqs', queue.logicalId),
|
|
555
|
+
to: dlq,
|
|
556
|
+
kind: 'redrive',
|
|
557
|
+
detail: `after ${queue.redrivePolicy.maxReceiveCount}`,
|
|
558
|
+
confidence: 'declared',
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* SNS subscriptions, read straight from the raw template.
|
|
563
|
+
*
|
|
564
|
+
* `AWS::SNS::Subscription` has no arm in the CloudFormation parser, so without
|
|
565
|
+
* this every declared topic renders as an orphan box — a node with no edges is
|
|
566
|
+
* a question the diagram raises and does not answer.
|
|
567
|
+
*/
|
|
568
|
+
function addSnsSubscriptionEdges(rawResources, ctx) {
|
|
569
|
+
for (const resource of Object.values(rawResources)) {
|
|
570
|
+
if (resource.Type !== 'AWS::SNS::Subscription')
|
|
571
|
+
continue;
|
|
572
|
+
const props = resource.Properties ?? {};
|
|
573
|
+
const topic = resolveRefToNode(props.TopicArn, ctx);
|
|
574
|
+
const endpoint = resolveRefToNode(props.Endpoint, ctx);
|
|
575
|
+
if (!topic || !endpoint) {
|
|
576
|
+
warnUnresolved(ctx, 'SNS subscription with an unresolvable topic or endpoint');
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
addEdge(ctx.edges, {
|
|
580
|
+
from: topic,
|
|
581
|
+
to: endpoint,
|
|
582
|
+
kind: 'sns-subscription',
|
|
583
|
+
detail: typeof props.Protocol === 'string' ? props.Protocol : undefined,
|
|
584
|
+
confidence: 'declared',
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Turns the execution role's inline policies into edges.
|
|
590
|
+
*
|
|
591
|
+
* The shape of the answer is dictated by how the Serverless Framework writes
|
|
592
|
+
* roles: ONE `IamRoleLambdaExecution` shared by every function of the service,
|
|
593
|
+
* with a single PolicyDocument that carries no per-function attribution
|
|
594
|
+
* whatsoever. So "this Lambda may touch that table" is, truthfully, "every
|
|
595
|
+
* Lambda bound to this role may touch that table".
|
|
596
|
+
*
|
|
597
|
+
* Two renderings follow from that, and the rule between them is the number of
|
|
598
|
+
* functions on the role:
|
|
599
|
+
*
|
|
600
|
+
* - ONE function on the role → the fan-out is a fan-out of one, so the edge is
|
|
601
|
+
* drawn `lambda → resource` directly. That is the arrow a developer means.
|
|
602
|
+
* - MORE than one → the role becomes a node, and the graph draws N + M edges
|
|
603
|
+
* (each function → role, role → each grant) instead of N × M. At the stack
|
|
604
|
+
* sizes LSS targets (≈60 functions × ≈10 tables) the product form is 600
|
|
605
|
+
* arrows of pure noise; the hub form is 70 and is also what the template
|
|
606
|
+
* literally says.
|
|
607
|
+
*/
|
|
608
|
+
function addIamEdges(rawResources, iam) {
|
|
609
|
+
// Which functions sit on which role. `Properties.Role` is
|
|
610
|
+
// `{Fn::GetAtt: [<RoleId>, 'Arn']}` for an in-template role; an ARN string or
|
|
611
|
+
// an ImportValue means the role lives outside the stack and carries no
|
|
612
|
+
// statements we can read.
|
|
613
|
+
const lambdasByRole = new Map();
|
|
614
|
+
for (const [logicalId, resource] of Object.entries(rawResources)) {
|
|
615
|
+
if (resource.Type !== 'AWS::Lambda::Function')
|
|
616
|
+
continue;
|
|
617
|
+
const roleId = refLogicalId(resource.Properties?.Role);
|
|
618
|
+
if (!roleId)
|
|
619
|
+
continue;
|
|
620
|
+
const node = nodeId('lambda', logicalId);
|
|
621
|
+
if (!iam.nodes.has(node))
|
|
622
|
+
continue;
|
|
623
|
+
const list = lambdasByRole.get(roleId) ?? [];
|
|
624
|
+
list.push(node);
|
|
625
|
+
lambdasByRole.set(roleId, list);
|
|
626
|
+
}
|
|
627
|
+
for (const [roleLogicalId, resource] of Object.entries(rawResources)) {
|
|
628
|
+
if (resource.Type !== 'AWS::IAM::Role')
|
|
629
|
+
continue;
|
|
630
|
+
// Framework plumbing: its statements are about a helper Lambda osls
|
|
631
|
+
// generates, not about anything the developer wrote.
|
|
632
|
+
if (roleLogicalId === CUSTOM_RESOURCES_ROLE)
|
|
633
|
+
continue;
|
|
634
|
+
const bound = lambdasByRole.get(roleLogicalId) ?? [];
|
|
635
|
+
if (bound.length === 0)
|
|
636
|
+
continue;
|
|
637
|
+
const grants = collectGrants(resource, iam);
|
|
638
|
+
if (grants.length === 0)
|
|
639
|
+
continue;
|
|
640
|
+
if (bound.length === 1) {
|
|
641
|
+
for (const grant of grants) {
|
|
642
|
+
// Do not restate a structural edge. The queue→lambda arrow already
|
|
643
|
+
// exists from the event-source mapping and carries more information.
|
|
644
|
+
if (connected(iam.edges, bound[0], grant.node))
|
|
645
|
+
continue;
|
|
646
|
+
addEdge(iam.edges, {
|
|
647
|
+
from: bound[0],
|
|
648
|
+
to: grant.node,
|
|
649
|
+
kind: 'iam',
|
|
650
|
+
detail: grant.actions.join(', '),
|
|
651
|
+
confidence: 'declared',
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
const roleNode = nodeId('iam-role', roleLogicalId);
|
|
657
|
+
iam.nodes.set(roleNode, {
|
|
658
|
+
id: roleNode,
|
|
659
|
+
kind: 'iam-role',
|
|
660
|
+
label: roleLogicalId,
|
|
661
|
+
logicalId: roleLogicalId,
|
|
662
|
+
});
|
|
663
|
+
for (const lambda of bound) {
|
|
664
|
+
addEdge(iam.edges, { from: lambda, to: roleNode, kind: 'iam', confidence: 'declared' });
|
|
665
|
+
}
|
|
666
|
+
for (const grant of grants) {
|
|
667
|
+
addEdge(iam.edges, {
|
|
668
|
+
from: roleNode,
|
|
669
|
+
to: grant.node,
|
|
670
|
+
kind: 'iam',
|
|
671
|
+
detail: grant.actions.join(', '),
|
|
672
|
+
confidence: 'declared',
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
/** Every (resource, actions) pair an `Allow` statement of this role grants. */
|
|
678
|
+
function collectGrants(role, iam) {
|
|
679
|
+
const grants = new Map();
|
|
680
|
+
for (const policy of toArray(role.Properties?.Policies)) {
|
|
681
|
+
const document = policy?.PolicyDocument;
|
|
682
|
+
for (const entry of toArray(document?.Statement)) {
|
|
683
|
+
const statement = entry;
|
|
684
|
+
// `Deny` is emitted by osls for `disableLogs`; `NotResource`/`NotAction`
|
|
685
|
+
// describe a complement that cannot be turned into an arrow. Neither is
|
|
686
|
+
// evidence that this function talks to that resource.
|
|
687
|
+
if (statement.Effect !== 'Allow')
|
|
688
|
+
continue;
|
|
689
|
+
if (statement.NotResource !== undefined || statement.NotAction !== undefined)
|
|
690
|
+
continue;
|
|
691
|
+
const actions = toArray(statement.Action)
|
|
692
|
+
.filter((action) => typeof action === 'string')
|
|
693
|
+
.filter(action => !IGNORED_ACTIONS.has(action));
|
|
694
|
+
const kinds = new Set(actions
|
|
695
|
+
.map(action => ACTION_SERVICE_TO_KIND[action.split(':')[0]])
|
|
696
|
+
.filter((kind) => Boolean(kind)));
|
|
697
|
+
if (kinds.size === 0)
|
|
698
|
+
continue;
|
|
699
|
+
for (const target of toArray(statement.Resource)) {
|
|
700
|
+
// `Resource: "*"` is a blanket grant. It is true of everything and
|
|
701
|
+
// therefore evidence of nothing — an arrow to every node would be the
|
|
702
|
+
// least useful drawing this screen could produce.
|
|
703
|
+
if (target === '*')
|
|
704
|
+
continue;
|
|
705
|
+
const node = resolveGrantTarget(target, kinds, iam);
|
|
706
|
+
if (!node)
|
|
707
|
+
continue;
|
|
708
|
+
const bucket = grants.get(node) ?? new Set();
|
|
709
|
+
for (const action of actions)
|
|
710
|
+
bucket.add(action);
|
|
711
|
+
grants.set(node, bucket);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
return [...grants.entries()].map(([node, actions]) => ({ node, actions: [...actions] }));
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Resolve one policy `Resource` entry to a node.
|
|
719
|
+
*
|
|
720
|
+
* Order matters. A `Ref`/`Fn::GetAtt` is tried FIRST, because its unresolved
|
|
721
|
+
* form already IS the logical id — going through `resolveArnLike` would reduce
|
|
722
|
+
* it to a string that then has to be reverse-matched for no gain.
|
|
723
|
+
* `Fn::Sub`/`Fn::Join`/`Fn::ImportValue` and literal ARNs take the reducer.
|
|
724
|
+
*/
|
|
725
|
+
function resolveGrantTarget(target, kinds, iam) {
|
|
726
|
+
const logicalId = refLogicalId(target);
|
|
727
|
+
if (logicalId) {
|
|
728
|
+
const resource = iam.byLogicalId.get(logicalId);
|
|
729
|
+
if (resource) {
|
|
730
|
+
const kind = nodeKindFor(resource);
|
|
731
|
+
if (kind && iam.nodes.has(nodeId(kind, logicalId)))
|
|
732
|
+
return nodeId(kind, logicalId);
|
|
733
|
+
}
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
736
|
+
const reduced = iam.parser.resolveArnLike(target, iam.ctx);
|
|
737
|
+
if (!reduced.value || !reduced.value.startsWith('arn:'))
|
|
738
|
+
return null;
|
|
739
|
+
const parsed = parseResourceArn(reduced.value);
|
|
740
|
+
if (!parsed)
|
|
741
|
+
return null;
|
|
742
|
+
const kind = kindForAwsService(parsed.service);
|
|
743
|
+
// The action list and the ARN must agree on what kind of thing this is;
|
|
744
|
+
// disagreement means the reduction went wrong and an arrow would be a guess.
|
|
745
|
+
if (!kind || !kinds.has(kind))
|
|
746
|
+
return null;
|
|
747
|
+
const local = iam.byKindAndName.get(kindNameKey(kind, parsed.name))
|
|
748
|
+
// Secrets Manager appends six random characters at create time, so the ARN
|
|
749
|
+
// in a policy never matches the declared name exactly — match by prefix.
|
|
750
|
+
?? (kind === 'secret' ? findSecretByPrefix(iam.byKindAndName, parsed.name) : undefined)
|
|
751
|
+
// …and if it is not a declared NAME, it may be a declared LOGICAL ID that
|
|
752
|
+
// survived the reduction. `resolveArnLike` only turns Lambda and API Refs
|
|
753
|
+
// into real values, so the most common S3 grant the Serverless Framework
|
|
754
|
+
// writes — `Fn::Join: ['', ['arn:aws:s3:::', {Ref: UploadsBucket}, '/*']]`
|
|
755
|
+
// — reduces to `arn:aws:s3:::UploadsBucket/*`, carrying the logical id
|
|
756
|
+
// where the bucket name should be. Without this branch that grant draws an
|
|
757
|
+
// external node for a bucket declared three lines further down the same
|
|
758
|
+
// template.
|
|
759
|
+
?? localByLogicalId(iam.byLogicalId, kind, parsed.name);
|
|
760
|
+
if (local && 'logicalId' in local && local.logicalId)
|
|
761
|
+
return nodeId(kind, local.logicalId);
|
|
762
|
+
return externalNodeFromArn(iam.nodes, reduced.value, kind);
|
|
763
|
+
}
|
|
764
|
+
// A declared resource whose LOGICAL ID is `name` and whose kind matches — the
|
|
765
|
+
// shape left behind when an intrinsic reduced only halfway.
|
|
766
|
+
function localByLogicalId(byLogicalId, kind, name) {
|
|
767
|
+
const resource = byLogicalId.get(name);
|
|
768
|
+
return resource && nodeKindFor(resource) === kind ? resource : undefined;
|
|
769
|
+
}
|
|
770
|
+
function findSecretByPrefix(byKindAndName, name) {
|
|
771
|
+
for (const [key, resource] of byKindAndName) {
|
|
772
|
+
const [kind, declared] = key.split('\u0000');
|
|
773
|
+
if (kind === 'secret' && name.startsWith(declared))
|
|
774
|
+
return resource;
|
|
775
|
+
}
|
|
776
|
+
return undefined;
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* The weakest edge kind, and the one that needs the loudest caveat.
|
|
780
|
+
*
|
|
781
|
+
* `provider.environment` is copied onto EVERY function of the service by the
|
|
782
|
+
* packager, so a variable naming a table proves the service uses it, not that
|
|
783
|
+
* this function does. Those edges are flagged `serviceWide` so the dashboard
|
|
784
|
+
* can subordinate them; the whole class is `inferred`, because a name match is
|
|
785
|
+
* a guess no matter how good.
|
|
786
|
+
*
|
|
787
|
+
* Matching is exact-first and never substring: `PRODUCTS_INDEX=products` must
|
|
788
|
+
* NOT match a collection called `products-catalog`, which a substring matcher
|
|
789
|
+
* happily invents.
|
|
790
|
+
*/
|
|
791
|
+
function addEnvEdges(functions, env) {
|
|
792
|
+
// A key/value pair present on every function came from provider-level
|
|
793
|
+
// configuration, not from this function's own declaration.
|
|
794
|
+
const occurrences = new Map();
|
|
795
|
+
for (const fn of functions) {
|
|
796
|
+
for (const [key, value] of Object.entries(fn.environment ?? {})) {
|
|
797
|
+
const pair = `${key}\u0000${value}`;
|
|
798
|
+
occurrences.set(pair, (occurrences.get(pair) ?? 0) + 1);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
// Which pairs are already linked, in either direction. Built once and kept
|
|
802
|
+
// up to date rather than re-scanned per variable: this is the innermost loop
|
|
803
|
+
// of the whole builder (functions x variables), and at the scale LSS targets
|
|
804
|
+
// — 60 functions, each carrying the provider's environment — a linear scan
|
|
805
|
+
// per check would be the only quadratic thing here.
|
|
806
|
+
const linked = new Set();
|
|
807
|
+
for (const edge of env.edges.values()) {
|
|
808
|
+
linked.add(`${edge.from}\u0000${edge.to}`);
|
|
809
|
+
linked.add(`${edge.to}\u0000${edge.from}`);
|
|
810
|
+
}
|
|
811
|
+
for (const fn of functions) {
|
|
812
|
+
const source = lambdaNodeIdForFullName(fn.fullName, env.byLogicalId);
|
|
813
|
+
if (!source || !env.nodes.has(source))
|
|
814
|
+
continue;
|
|
815
|
+
for (const [key, value] of Object.entries(fn.environment ?? {})) {
|
|
816
|
+
if (ENV_KEY_DENYLIST.has(key))
|
|
817
|
+
continue;
|
|
818
|
+
const target = matchEnvValue(value, env);
|
|
819
|
+
if (!target || target === source)
|
|
820
|
+
continue;
|
|
821
|
+
// An env var that merely restates a link CloudFormation already wires
|
|
822
|
+
// adds a weaker duplicate of a stronger fact.
|
|
823
|
+
if (linked.has(`${source}\u0000${target}`))
|
|
824
|
+
continue;
|
|
825
|
+
linked.add(`${source}\u0000${target}`);
|
|
826
|
+
linked.add(`${target}\u0000${source}`);
|
|
827
|
+
addEdge(env.edges, {
|
|
828
|
+
from: source,
|
|
829
|
+
to: target,
|
|
830
|
+
kind: 'env',
|
|
831
|
+
detail: key,
|
|
832
|
+
confidence: 'inferred',
|
|
833
|
+
serviceWide: functions.length > 1 && occurrences.get(`${key}\u0000${value}`) === functions.length,
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
function lambdaNodeIdForFullName(fullName, byLogicalId) {
|
|
839
|
+
for (const [logicalId, resource] of byLogicalId) {
|
|
840
|
+
if (resource.type === 'lambda' && resource.name === fullName)
|
|
841
|
+
return nodeId('lambda', logicalId);
|
|
842
|
+
}
|
|
843
|
+
return null;
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Which declared resource, if any, an environment value names.
|
|
847
|
+
*
|
|
848
|
+
* Four grammars, tried in order of how much they prove:
|
|
849
|
+
* 1. the value IS a declared name (`ORDERS_TABLE=orders-Orders`);
|
|
850
|
+
* 2. the value is an ARN;
|
|
851
|
+
* 3. the value is an unresolved intrinsic the packager left as `X.Arn` /
|
|
852
|
+
* `X::Arn` — the logical id is right there;
|
|
853
|
+
* 4. the value is a service URL whose LAST path segment is the name
|
|
854
|
+
* (`…/000000000000/orders-to-process`, `…/_aoss/products-catalog`).
|
|
855
|
+
*
|
|
856
|
+
* Never a substring match, and never a bare last-path-segment match on a value
|
|
857
|
+
* that is not a URL — that is how `http://localhost:14566` becomes a resource
|
|
858
|
+
* called `14566`.
|
|
859
|
+
*/
|
|
860
|
+
function matchEnvValue(value, env) {
|
|
861
|
+
if (!value)
|
|
862
|
+
return null;
|
|
863
|
+
const direct = findByName(env.byKindAndName, value);
|
|
864
|
+
if (direct)
|
|
865
|
+
return direct;
|
|
866
|
+
if (value.startsWith('arn:')) {
|
|
867
|
+
const parsed = parseResourceArn(value);
|
|
868
|
+
if (!parsed)
|
|
869
|
+
return null;
|
|
870
|
+
const kind = kindForAwsService(parsed.service);
|
|
871
|
+
if (!kind)
|
|
872
|
+
return null;
|
|
873
|
+
const local = env.byKindAndName.get(kindNameKey(kind, parsed.name));
|
|
874
|
+
if (local && 'logicalId' in local && local.logicalId)
|
|
875
|
+
return nodeId(kind, local.logicalId);
|
|
876
|
+
return null;
|
|
877
|
+
}
|
|
878
|
+
if (!value.includes('/') && (value.includes('::') || value.includes('.'))) {
|
|
879
|
+
const logicalId = refLogicalId(value);
|
|
880
|
+
if (logicalId) {
|
|
881
|
+
const resource = env.byLogicalId.get(logicalId);
|
|
882
|
+
const kind = resource ? nodeKindFor(resource) : null;
|
|
883
|
+
if (kind && env.nodes.has(nodeId(kind, logicalId)))
|
|
884
|
+
return nodeId(kind, logicalId);
|
|
885
|
+
}
|
|
886
|
+
return null;
|
|
887
|
+
}
|
|
888
|
+
if (value.startsWith('http://') || value.startsWith('https://')) {
|
|
889
|
+
const path = value.replace(/^https?:\/\/[^/]+/, '').split('?')[0];
|
|
890
|
+
const segments = path.split('/').filter(Boolean);
|
|
891
|
+
if (segments.length === 0)
|
|
892
|
+
return null;
|
|
893
|
+
return findByName(env.byKindAndName, segments[segments.length - 1]);
|
|
894
|
+
}
|
|
895
|
+
return null;
|
|
896
|
+
}
|
|
897
|
+
// A name is matched against every kind, because an env var does not say which
|
|
898
|
+
// service it points at. A name colliding across two kinds is vanishingly rare
|
|
899
|
+
// and the first match is deterministic (insertion order = template order).
|
|
900
|
+
function findByName(byKindAndName, name) {
|
|
901
|
+
for (const [key, resource] of byKindAndName) {
|
|
902
|
+
const [kind, declared] = key.split('\u0000');
|
|
903
|
+
if (declared === name && 'logicalId' in resource && resource.logicalId) {
|
|
904
|
+
return nodeId(kind, resource.logicalId);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
return null;
|
|
908
|
+
}
|
|
909
|
+
// One phrasing for every "reference pointed at nothing" warning, so the
|
|
910
|
+
// dashboard can show them as a single list without classifying them.
|
|
911
|
+
function warnUnresolved(ctx, what) {
|
|
912
|
+
ctx.warnings.push(`${what} could not be resolved to a declared resource`);
|
|
913
|
+
}
|
|
914
|
+
//# sourceMappingURL=service-graph.js.map
|