@specific.dev/spectest 0.32.1 → 0.34.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/components/aws.d.ts +112 -0
- package/dist/components/aws.js +397 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/daemon.js +97 -14
- package/dist/index.d.ts +20 -0
- package/dist/index.js +12 -4
- package/dist/ingress.d.ts +11 -0
- package/dist/ingress.js +22 -4
- package/package.json +1 -1
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { ServiceSetupContext } from "../index.js";
|
|
2
|
+
export interface AwsOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Lambda functions to deploy from the project's own source, keyed by
|
|
5
|
+
* function name — the name the app (or a state machine) invokes.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* lambdas: {
|
|
9
|
+
* add: { source: "lambda/add" },
|
|
10
|
+
* resize: { source: "lambda/resize", runtime: "python3.12", timeoutSecs: 60 },
|
|
11
|
+
* }
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* Each is packaged and deployed during environment bring-up, so it is part
|
|
15
|
+
* of the warm-template snapshot: a warm start restores the deployed
|
|
16
|
+
* function instead of deploying it again.
|
|
17
|
+
*/
|
|
18
|
+
lambdas?: Record<string, LambdaOptions>;
|
|
19
|
+
}
|
|
20
|
+
export interface LambdaOptions {
|
|
21
|
+
/**
|
|
22
|
+
* The function's code, as a path in your repository — either a directory
|
|
23
|
+
* (packaged whole, recursively, with its contents at the root of the
|
|
24
|
+
* package) or a single file. Relative paths resolve against the project
|
|
25
|
+
* root, so `"lambda/add"` is the repo's own `lambda/add/`.
|
|
26
|
+
*
|
|
27
|
+
* The code is taken from the repository on purpose: the function you test
|
|
28
|
+
* is the function you ship. Anything the handler imports at runtime —
|
|
29
|
+
* including its dependencies — must be inside this path, exactly as a real
|
|
30
|
+
* deployment package requires.
|
|
31
|
+
*/
|
|
32
|
+
source: string;
|
|
33
|
+
/** Entry point, as AWS spells it: `<file>.<exported function>`. Default
|
|
34
|
+
* `"index.handler"`. */
|
|
35
|
+
handler?: string;
|
|
36
|
+
/** Lambda runtime identifier. Default `"nodejs20.x"`. */
|
|
37
|
+
runtime?: string;
|
|
38
|
+
/** Environment variables for the function. */
|
|
39
|
+
env?: Record<string, string>;
|
|
40
|
+
/** Function timeout in seconds. Default `30`. */
|
|
41
|
+
timeoutSecs?: number;
|
|
42
|
+
/** Execution-role ARN. Defaults to a fixed placeholder — the emulator does
|
|
43
|
+
* not enforce IAM. Set it only when the code reads the ARN itself. */
|
|
44
|
+
role?: string;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The AWS cloud APIs, emulated in the environment and answering at their real
|
|
48
|
+
* endpoints. Drop into `environment.services`:
|
|
49
|
+
*
|
|
50
|
+
* ```ts
|
|
51
|
+
* import { aws } from "@specific.dev/spectest/components";
|
|
52
|
+
*
|
|
53
|
+
* services: {
|
|
54
|
+
* aws: aws({
|
|
55
|
+
* lambdas: {
|
|
56
|
+
* add: { source: "lambda/add" },
|
|
57
|
+
* subtract: { source: "lambda/subtract" },
|
|
58
|
+
* },
|
|
59
|
+
* }),
|
|
60
|
+
* app: {
|
|
61
|
+
* image: { type: "dockerfile", content: appDockerfile },
|
|
62
|
+
* // Whatever the app already reads in production. An AWS SDK needs a
|
|
63
|
+
* // region and credentials; the emulator accepts any credential value.
|
|
64
|
+
* env: {
|
|
65
|
+
* AWS_REGION: "us-east-1",
|
|
66
|
+
* AWS_ACCESS_KEY_ID: "test",
|
|
67
|
+
* AWS_SECRET_ACCESS_KEY: "test",
|
|
68
|
+
* },
|
|
69
|
+
* dependsOn: ["aws"],
|
|
70
|
+
* },
|
|
71
|
+
* }
|
|
72
|
+
* ```
|
|
73
|
+
*
|
|
74
|
+
* The app keeps its production configuration — `new SFNClient({})`, no
|
|
75
|
+
* endpoint — because the component owns DNS and TLS for the AWS domains. It
|
|
76
|
+
* answers for **every region**, so an app that talks to two of them, or that
|
|
77
|
+
* reads its region from configuration, needs nothing declared here.
|
|
78
|
+
*
|
|
79
|
+
* Tests reach the same cloud with the AWS CLI, which the emulator's image
|
|
80
|
+
* ships as `awslocal` (the CLI, already pointed at it):
|
|
81
|
+
*
|
|
82
|
+
* ```ts
|
|
83
|
+
* const listed = await ctx.exec("aws", "awslocal stepfunctions list-state-machines");
|
|
84
|
+
* expect(listed.exitCode).toBe(0);
|
|
85
|
+
* const machines = listed.stdout.transform("machines", (out) =>
|
|
86
|
+
* (JSON.parse(out) as { stateMachines: unknown[] }).stateMachines,
|
|
87
|
+
* );
|
|
88
|
+
* expect(machines).toHaveLength(1);
|
|
89
|
+
* ```
|
|
90
|
+
*
|
|
91
|
+
* For S3, prefer the `s3()` component and give it the endpoint your app uses
|
|
92
|
+
* (`s3({ hosts: ["s3.us-east-1.amazonaws.com"] })`). An exact hostname always
|
|
93
|
+
* beats this component's wildcard, so the two compose without further
|
|
94
|
+
* configuration.
|
|
95
|
+
*/
|
|
96
|
+
export declare function aws(opts?: AwsOptions): {
|
|
97
|
+
setup?: (({ name, projectRoot }: ServiceSetupContext) => Promise<void>) | undefined;
|
|
98
|
+
image: {
|
|
99
|
+
type: "registry";
|
|
100
|
+
reference: string;
|
|
101
|
+
};
|
|
102
|
+
env: {
|
|
103
|
+
AWS_DEFAULT_REGION: string;
|
|
104
|
+
};
|
|
105
|
+
ports: number[];
|
|
106
|
+
readyCheck: {
|
|
107
|
+
type: "http";
|
|
108
|
+
port: number;
|
|
109
|
+
path: string;
|
|
110
|
+
timeoutSecs: number;
|
|
111
|
+
};
|
|
112
|
+
};
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
// `aws()` — the AWS cloud APIs, emulated inside the environment, answering
|
|
2
|
+
// at their **real** endpoints.
|
|
3
|
+
//
|
|
4
|
+
// The component runs one emulator container and claims the AWS domains for
|
|
5
|
+
// it: DNS, a leaf certificate minted from the in-VM root CA, and a
|
|
6
|
+
// TLS-terminating reverse proxy. An AWS SDK in the app under test therefore
|
|
7
|
+
// resolves `https://states.us-east-1.amazonaws.com` the way it does in
|
|
8
|
+
// production and reaches the emulator — the app needs no endpoint override,
|
|
9
|
+
// no test-only branch, and no knowledge that it is under test.
|
|
10
|
+
//
|
|
11
|
+
// Lambda functions are declared next to the services and deployed from the
|
|
12
|
+
// project's own source. Deployment happens in the service `setup` hook, which
|
|
13
|
+
// runs before the warm-template snapshot, so a warm start restores functions
|
|
14
|
+
// that are already deployed, already active, and already warm.
|
|
15
|
+
//
|
|
16
|
+
// ── Internal notes (deliberately not in the user-facing docs) ──────────────
|
|
17
|
+
//
|
|
18
|
+
// The backing image is MiniStack (MIT, github.com/ministackorg/ministack),
|
|
19
|
+
// pinned rather than floated. It replaced LocalStack, which folded its
|
|
20
|
+
// Apache-2.0 Community edition into one paid product in March 2026 (current
|
|
21
|
+
// images demand a LOCALSTACK_AUTH_TOKEN). MiniStack also suits fork-per-test
|
|
22
|
+
// far better: ~270 MB against ~1 GB, a ~2 s boot, and Lambda on an in-process
|
|
23
|
+
// warm worker pool instead of sibling containers — so no docker socket is
|
|
24
|
+
// bind-mounted and there is no host-architecture workaround on Graviton.
|
|
25
|
+
//
|
|
26
|
+
// Like `email()`'s mail server, the product name stays out of every
|
|
27
|
+
// user-facing surface (docs, examples, error messages). Users get "the AWS
|
|
28
|
+
// emulator"; `image` swaps it.
|
|
29
|
+
//
|
|
30
|
+
// The emulator ignores SigV4 and does not check that a role exists, both
|
|
31
|
+
// verified on 1.4.8. That is what lets this component deploy over plain
|
|
32
|
+
// unsigned HTTP from the daemon and skip IAM entirely.
|
|
33
|
+
import { readFile, readdir, stat } from "node:fs/promises";
|
|
34
|
+
import path from "node:path";
|
|
35
|
+
import { deflateRawSync } from "node:zlib";
|
|
36
|
+
import { certificate, provides, proxy, SELF_SERVICE_TOKEN } from "../index.js";
|
|
37
|
+
import { pauseRecording, resumeRecording } from "../recorder.js";
|
|
38
|
+
/** Backing image, pinned internally (not part of the documented surface). */
|
|
39
|
+
const AWS_IMAGE = "ministackorg/ministack:1.4.8";
|
|
40
|
+
/** Port the emulator serves every AWS API on. Internal: callers reach the
|
|
41
|
+
* emulator through the AWS hostnames, never through this port. */
|
|
42
|
+
const AWS_PORT = 4566;
|
|
43
|
+
// Every AWS region, so the component can answer for all of them and the user
|
|
44
|
+
// never has to declare which ones the app uses. Enumeration is forced by TLS,
|
|
45
|
+
// not by routing: a wildcard *route* matches any depth, but a wildcard
|
|
46
|
+
// *certificate* covers exactly one label (RFC 6125, enforced by every
|
|
47
|
+
// client), so `*.amazonaws.com` cannot serve `states.us-east-1.amazonaws.com`
|
|
48
|
+
// — each region needs its own `*.<region>.amazonaws.com` name. They all ride
|
|
49
|
+
// ONE certificate as SANs and one shared upstream, so the list costs a few
|
|
50
|
+
// route-table entries, not a key generation each.
|
|
51
|
+
//
|
|
52
|
+
// Sourced from AWS's own published `ip-ranges.json` (2026-07-31). A region
|
|
53
|
+
// added later is simply not claimed: its endpoints fail to resolve, which
|
|
54
|
+
// says plainly what happened — add it here.
|
|
55
|
+
const AWS_REGIONS = [
|
|
56
|
+
"af-south-1", "ap-east-1", "ap-east-2", "ap-northeast-1", "ap-northeast-2",
|
|
57
|
+
"ap-northeast-3", "ap-south-1", "ap-south-2", "ap-southeast-1", "ap-southeast-2",
|
|
58
|
+
"ap-southeast-3", "ap-southeast-4", "ap-southeast-5", "ap-southeast-6",
|
|
59
|
+
"ap-southeast-7", "ca-central-1", "ca-west-1", "eu-central-1", "eu-central-2",
|
|
60
|
+
"eu-north-1", "eu-south-1", "eu-south-2", "eu-west-1", "eu-west-2", "eu-west-3",
|
|
61
|
+
"eusc-de-east-1", "il-central-1", "me-central-1", "me-south-1", "me-west-1",
|
|
62
|
+
"mx-central-1", "sa-east-1", "sa-west-1", "us-east-1", "us-east-2",
|
|
63
|
+
"us-gov-east-1", "us-gov-west-1", "us-south-1", "us-west-1", "us-west-2",
|
|
64
|
+
];
|
|
65
|
+
/** China is a separate partition on its own domain suffix. */
|
|
66
|
+
const AWS_CN_REGIONS = ["cn-north-1", "cn-northwest-1"];
|
|
67
|
+
/** Every hostname pattern the emulator answers to: the two partitions' global
|
|
68
|
+
* endpoints (`sts.amazonaws.com`, `iam.amazonaws.com`) plus one per region
|
|
69
|
+
* for the regional ones (`states.us-east-1.amazonaws.com`). */
|
|
70
|
+
const AWS_HOST_PATTERNS = [
|
|
71
|
+
"*.amazonaws.com",
|
|
72
|
+
...AWS_REGIONS.map((r) => `*.${r}.amazonaws.com`),
|
|
73
|
+
"*.amazonaws.com.cn",
|
|
74
|
+
...AWS_CN_REGIONS.map((r) => `*.${r}.amazonaws.com.cn`),
|
|
75
|
+
];
|
|
76
|
+
/** Region the emulator's own tools default to — the AWS CLI refuses to run
|
|
77
|
+
* without one. Only the *container's* default; it constrains nothing about
|
|
78
|
+
* the app, and every region is served regardless. A `setup` hook that drives
|
|
79
|
+
* the CLI against another region passes `--region`. */
|
|
80
|
+
const CONTAINER_DEFAULT_REGION = "us-east-1";
|
|
81
|
+
/** Role every declared function is created with. The emulator does not
|
|
82
|
+
* enforce IAM, so this only has to be a well-formed ARN — which is why the
|
|
83
|
+
* component creates no role and takes no IAM options. A project that wants a
|
|
84
|
+
* real-looking role can create one itself and set `role` on the function. */
|
|
85
|
+
const DEFAULT_ROLE_ARN = "arn:aws:iam::000000000000:role/spectest-lambda";
|
|
86
|
+
/** Ready-check budget. The emulator boots in a couple of seconds; the slow
|
|
87
|
+
* part is a cold image pull on a machine that has never run it. */
|
|
88
|
+
const READY_TIMEOUT_SECS = 120;
|
|
89
|
+
/** Memory every declared function is created with. Kept at what this
|
|
90
|
+
* component has always used, rather than the emulator's own default, so the
|
|
91
|
+
* removal of the `memoryMb` option changed no behaviour. */
|
|
92
|
+
const MEMORY_MB = 512;
|
|
93
|
+
/**
|
|
94
|
+
* The AWS cloud APIs, emulated in the environment and answering at their real
|
|
95
|
+
* endpoints. Drop into `environment.services`:
|
|
96
|
+
*
|
|
97
|
+
* ```ts
|
|
98
|
+
* import { aws } from "@specific.dev/spectest/components";
|
|
99
|
+
*
|
|
100
|
+
* services: {
|
|
101
|
+
* aws: aws({
|
|
102
|
+
* lambdas: {
|
|
103
|
+
* add: { source: "lambda/add" },
|
|
104
|
+
* subtract: { source: "lambda/subtract" },
|
|
105
|
+
* },
|
|
106
|
+
* }),
|
|
107
|
+
* app: {
|
|
108
|
+
* image: { type: "dockerfile", content: appDockerfile },
|
|
109
|
+
* // Whatever the app already reads in production. An AWS SDK needs a
|
|
110
|
+
* // region and credentials; the emulator accepts any credential value.
|
|
111
|
+
* env: {
|
|
112
|
+
* AWS_REGION: "us-east-1",
|
|
113
|
+
* AWS_ACCESS_KEY_ID: "test",
|
|
114
|
+
* AWS_SECRET_ACCESS_KEY: "test",
|
|
115
|
+
* },
|
|
116
|
+
* dependsOn: ["aws"],
|
|
117
|
+
* },
|
|
118
|
+
* }
|
|
119
|
+
* ```
|
|
120
|
+
*
|
|
121
|
+
* The app keeps its production configuration — `new SFNClient({})`, no
|
|
122
|
+
* endpoint — because the component owns DNS and TLS for the AWS domains. It
|
|
123
|
+
* answers for **every region**, so an app that talks to two of them, or that
|
|
124
|
+
* reads its region from configuration, needs nothing declared here.
|
|
125
|
+
*
|
|
126
|
+
* Tests reach the same cloud with the AWS CLI, which the emulator's image
|
|
127
|
+
* ships as `awslocal` (the CLI, already pointed at it):
|
|
128
|
+
*
|
|
129
|
+
* ```ts
|
|
130
|
+
* const listed = await ctx.exec("aws", "awslocal stepfunctions list-state-machines");
|
|
131
|
+
* expect(listed.exitCode).toBe(0);
|
|
132
|
+
* const machines = listed.stdout.transform("machines", (out) =>
|
|
133
|
+
* (JSON.parse(out) as { stateMachines: unknown[] }).stateMachines,
|
|
134
|
+
* );
|
|
135
|
+
* expect(machines).toHaveLength(1);
|
|
136
|
+
* ```
|
|
137
|
+
*
|
|
138
|
+
* For S3, prefer the `s3()` component and give it the endpoint your app uses
|
|
139
|
+
* (`s3({ hosts: ["s3.us-east-1.amazonaws.com"] })`). An exact hostname always
|
|
140
|
+
* beats this component's wildcard, so the two compose without further
|
|
141
|
+
* configuration.
|
|
142
|
+
*/
|
|
143
|
+
export function aws(opts = {}) {
|
|
144
|
+
const lambdas = opts.lambdas ?? {};
|
|
145
|
+
const service = {
|
|
146
|
+
image: { type: "registry", reference: AWS_IMAGE },
|
|
147
|
+
env: { AWS_DEFAULT_REGION: CONTAINER_DEFAULT_REGION },
|
|
148
|
+
ports: [AWS_PORT],
|
|
149
|
+
readyCheck: {
|
|
150
|
+
type: "http",
|
|
151
|
+
port: AWS_PORT,
|
|
152
|
+
path: "/_ministack/health",
|
|
153
|
+
timeoutSecs: READY_TIMEOUT_SECS,
|
|
154
|
+
},
|
|
155
|
+
...(Object.keys(lambdas).length > 0
|
|
156
|
+
? {
|
|
157
|
+
setup: async ({ name, projectRoot }) => {
|
|
158
|
+
for (const [fn, spec] of Object.entries(lambdas)) {
|
|
159
|
+
await deployLambda(name, projectRoot, fn, spec);
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
: {}),
|
|
164
|
+
};
|
|
165
|
+
// The low-level primitives rather than the `tls` field, for one reason:
|
|
166
|
+
// `tls` mints a separate leaf certificate per entry, and this component
|
|
167
|
+
// claims several dozen names. One `certificate(...)` decl carries them all
|
|
168
|
+
// as SANs of a single leaf — one key generation at bring-up instead of
|
|
169
|
+
// dozens — while the proxies are just route-table entries.
|
|
170
|
+
return provides(service, [
|
|
171
|
+
certificate(AWS_HOST_PATTERNS),
|
|
172
|
+
...AWS_HOST_PATTERNS.map((hostname) => proxy(hostname, { service: SELF_SERVICE_TOKEN, port: AWS_PORT })),
|
|
173
|
+
]);
|
|
174
|
+
}
|
|
175
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
176
|
+
// Talking to the emulator directly
|
|
177
|
+
//
|
|
178
|
+
// The emulator ignores request signatures, so the daemon reaches its APIs
|
|
179
|
+
// over plain unsigned HTTP. That keeps deployment independent of what tools
|
|
180
|
+
// the image happens to ship, and keeps a poll loop cheap.
|
|
181
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
182
|
+
/** One JSON-protocol AWS call (Step Functions, DynamoDB, …), by target. */
|
|
183
|
+
async function request(service, method, urlPath, init = {}) {
|
|
184
|
+
// Never let the component's own traffic land on the timeline: these calls
|
|
185
|
+
// are bring-up and polling, not something a test asked for.
|
|
186
|
+
pauseRecording();
|
|
187
|
+
try {
|
|
188
|
+
const res = await fetch(`http://${service}:${AWS_PORT}${urlPath}`, {
|
|
189
|
+
method,
|
|
190
|
+
headers: { "content-type": "application/json", ...(init.headers ?? {}) },
|
|
191
|
+
...(init.body !== undefined ? { body: init.body } : {}),
|
|
192
|
+
});
|
|
193
|
+
// Coerced rather than read directly: `fetch` is wrapped during a test, so
|
|
194
|
+
// `res.status` is a provenance carrier there and a plain number elsewhere.
|
|
195
|
+
const status = Number(res.status);
|
|
196
|
+
const text = String(await res.text());
|
|
197
|
+
if (status >= 400) {
|
|
198
|
+
const err = new Error(`aws: ${method} ${urlPath} failed: HTTP ${status} ${text.slice(0, 400)}`);
|
|
199
|
+
err.status = status;
|
|
200
|
+
throw err;
|
|
201
|
+
}
|
|
202
|
+
return text === "" ? undefined : JSON.parse(text);
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
resumeRecording();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
209
|
+
// Lambda deployment
|
|
210
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
211
|
+
/** Refuse a package that no real deployment would accept either. */
|
|
212
|
+
const MAX_PACKAGE_BYTES = 64 * 1024 * 1024;
|
|
213
|
+
async function deployLambda(service, projectRoot, name, spec) {
|
|
214
|
+
if (typeof spec?.source !== "string" || spec.source.trim() === "") {
|
|
215
|
+
throw new Error(`aws: lambda "${name}" needs a \`source\` path to its code`);
|
|
216
|
+
}
|
|
217
|
+
const zipped = await packageSource(projectRoot, name, spec.source);
|
|
218
|
+
if (zipped.length > MAX_PACKAGE_BYTES) {
|
|
219
|
+
throw new Error(`aws: lambda "${name}" packages to ${Math.round(zipped.length / 1e6)} MB from ` +
|
|
220
|
+
`${spec.source} — over the ${MAX_PACKAGE_BYTES / 1e6} MB limit. Point \`source\` at ` +
|
|
221
|
+
`the function's own directory, not a whole repository.`);
|
|
222
|
+
}
|
|
223
|
+
const code = Buffer.from(zipped).toString("base64");
|
|
224
|
+
const config = {
|
|
225
|
+
Runtime: spec.runtime ?? "nodejs20.x",
|
|
226
|
+
Handler: spec.handler ?? "index.handler",
|
|
227
|
+
Role: spec.role ?? DEFAULT_ROLE_ARN,
|
|
228
|
+
Timeout: spec.timeoutSecs ?? 30,
|
|
229
|
+
// Fixed, and not an option: the emulated cloud runs handlers in a shared
|
|
230
|
+
// worker pool rather than a memory-capped sandbox, so the number changes
|
|
231
|
+
// nothing about how a function behaves here.
|
|
232
|
+
MemorySize: MEMORY_MB,
|
|
233
|
+
...(spec.env ? { Environment: { Variables: spec.env } } : {}),
|
|
234
|
+
};
|
|
235
|
+
try {
|
|
236
|
+
await request(service, "POST", "/2015-03-31/functions", {
|
|
237
|
+
body: JSON.stringify({ FunctionName: name, Code: { ZipFile: code }, ...config }),
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
// Already there: a re-run of bring-up against an emulator that kept its
|
|
242
|
+
// state. Update instead, so the deployed code always matches the repo.
|
|
243
|
+
if (err.status !== 409)
|
|
244
|
+
throw err;
|
|
245
|
+
await request(service, "PUT", `/2015-03-31/functions/${encodeURIComponent(name)}/code`, {
|
|
246
|
+
body: JSON.stringify({ ZipFile: code }),
|
|
247
|
+
});
|
|
248
|
+
await request(service, "PUT", `/2015-03-31/functions/${encodeURIComponent(name)}/configuration`, {
|
|
249
|
+
body: JSON.stringify(config),
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
await waitActive(service, name);
|
|
253
|
+
// One invoke with an empty event, purely to leave a warm worker behind in
|
|
254
|
+
// the snapshot, so no test pays a cold start. The handler may well reject
|
|
255
|
+
// the event — that is fine, the worker is started either way.
|
|
256
|
+
try {
|
|
257
|
+
await request(service, "POST", `/2015-03-31/functions/${encodeURIComponent(name)}/invocations`, {
|
|
258
|
+
body: "{}",
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
// Ignored on purpose: warming is an optimisation, never a gate.
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/** Wait for a newly created function to leave `Pending`, exactly as a real
|
|
266
|
+
* deployment must. Tolerates an emulator that reports no state at all. */
|
|
267
|
+
async function waitActive(service, name, timeoutMs = 60_000) {
|
|
268
|
+
const deadline = Date.now() + timeoutMs;
|
|
269
|
+
for (;;) {
|
|
270
|
+
const cfg = (await request(service, "GET", `/2015-03-31/functions/${encodeURIComponent(name)}/configuration`));
|
|
271
|
+
const state = cfg?.State;
|
|
272
|
+
if (state === undefined || state === "Active")
|
|
273
|
+
return;
|
|
274
|
+
if (state === "Failed") {
|
|
275
|
+
throw new Error(`aws: lambda "${name}" failed to deploy: ${cfg?.StateReason ?? "no reason given"}`);
|
|
276
|
+
}
|
|
277
|
+
if (Date.now() > deadline) {
|
|
278
|
+
throw new Error(`aws: lambda "${name}" was still ${state} after ${timeoutMs} ms`);
|
|
279
|
+
}
|
|
280
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
async function packageSource(projectRoot, fnName, source) {
|
|
284
|
+
const root = path.isAbsolute(source) ? source : path.join(projectRoot, source);
|
|
285
|
+
let info;
|
|
286
|
+
try {
|
|
287
|
+
info = await stat(root);
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
throw new Error(`aws: lambda "${fnName}" has no code at ${source} (looked in ${root}). ` +
|
|
291
|
+
`\`source\` is a path in your repository, relative to its root.`);
|
|
292
|
+
}
|
|
293
|
+
const entries = [];
|
|
294
|
+
if (info.isDirectory()) {
|
|
295
|
+
await collect(root, "", entries);
|
|
296
|
+
if (entries.length === 0) {
|
|
297
|
+
throw new Error(`aws: lambda "${fnName}" has no files in ${source}`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
entries.push({ name: path.basename(root), data: await readFile(root) });
|
|
302
|
+
}
|
|
303
|
+
// Sorted so the same source always packages to the same bytes.
|
|
304
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
305
|
+
return buildZip(entries);
|
|
306
|
+
}
|
|
307
|
+
async function collect(dir, prefix, out) {
|
|
308
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
309
|
+
const full = path.join(dir, entry.name);
|
|
310
|
+
const rel = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
|
|
311
|
+
if (entry.isDirectory()) {
|
|
312
|
+
await collect(full, rel, out);
|
|
313
|
+
}
|
|
314
|
+
else if (entry.isFile()) {
|
|
315
|
+
out.push({ name: rel, data: await readFile(full) });
|
|
316
|
+
}
|
|
317
|
+
// Symlinks and everything else are skipped: a deployment package holds
|
|
318
|
+
// regular files.
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
function crc32(buf) {
|
|
322
|
+
let crc = 0xffffffff;
|
|
323
|
+
for (let i = 0; i < buf.length; i++) {
|
|
324
|
+
let c = (crc ^ buf[i]) & 0xff;
|
|
325
|
+
for (let k = 0; k < 8; k++)
|
|
326
|
+
c = c & 1 ? (c >>> 1) ^ 0xedb88320 : c >>> 1;
|
|
327
|
+
crc = (crc >>> 8) ^ c;
|
|
328
|
+
}
|
|
329
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Build a zip archive. Entries are deflated (method 8), or stored (method 0)
|
|
333
|
+
* when deflating makes them no smaller. Timestamps are fixed, so identical
|
|
334
|
+
* sources always produce identical bytes.
|
|
335
|
+
*/
|
|
336
|
+
function buildZip(entries) {
|
|
337
|
+
const encoder = new TextEncoder();
|
|
338
|
+
const local = [];
|
|
339
|
+
const central = [];
|
|
340
|
+
let offset = 0;
|
|
341
|
+
for (const entry of entries) {
|
|
342
|
+
const nameBytes = encoder.encode(entry.name);
|
|
343
|
+
const crc = crc32(entry.data);
|
|
344
|
+
const deflated = new Uint8Array(deflateRawSync(entry.data));
|
|
345
|
+
const stored = deflated.length >= entry.data.length;
|
|
346
|
+
const payload = stored ? entry.data : deflated;
|
|
347
|
+
const method = stored ? 0 : 8;
|
|
348
|
+
const header = new DataView(new ArrayBuffer(30));
|
|
349
|
+
header.setUint32(0, 0x04034b50, true); // local file header
|
|
350
|
+
header.setUint16(4, 20, true); // version needed
|
|
351
|
+
header.setUint16(6, 0, true); // flags
|
|
352
|
+
header.setUint16(8, method, true);
|
|
353
|
+
header.setUint16(10, 0, true); // time — fixed
|
|
354
|
+
header.setUint16(12, 0x21, true); // date — fixed (1 Jan 1980)
|
|
355
|
+
header.setUint32(14, crc, true);
|
|
356
|
+
header.setUint32(18, payload.length, true);
|
|
357
|
+
header.setUint32(22, entry.data.length, true);
|
|
358
|
+
header.setUint16(26, nameBytes.length, true);
|
|
359
|
+
header.setUint16(28, 0, true); // extra length
|
|
360
|
+
const headerBytes = new Uint8Array(header.buffer);
|
|
361
|
+
local.push(headerBytes, nameBytes, payload);
|
|
362
|
+
const dirEntry = new DataView(new ArrayBuffer(46));
|
|
363
|
+
dirEntry.setUint32(0, 0x02014b50, true); // central directory header
|
|
364
|
+
dirEntry.setUint16(4, 20, true); // version made by
|
|
365
|
+
dirEntry.setUint16(6, 20, true); // version needed
|
|
366
|
+
dirEntry.setUint16(8, 0, true); // flags
|
|
367
|
+
dirEntry.setUint16(10, method, true);
|
|
368
|
+
dirEntry.setUint16(12, 0, true);
|
|
369
|
+
dirEntry.setUint16(14, 0x21, true);
|
|
370
|
+
dirEntry.setUint32(16, crc, true);
|
|
371
|
+
dirEntry.setUint32(20, payload.length, true);
|
|
372
|
+
dirEntry.setUint32(24, entry.data.length, true);
|
|
373
|
+
dirEntry.setUint16(28, nameBytes.length, true);
|
|
374
|
+
// Permissions in the high half of the external attributes: 0644 for a
|
|
375
|
+
// regular file, which is what a Lambda runtime expects to find.
|
|
376
|
+
dirEntry.setUint32(38, 0o100644 << 16, true);
|
|
377
|
+
dirEntry.setUint32(42, offset, true);
|
|
378
|
+
central.push(new Uint8Array(dirEntry.buffer), nameBytes);
|
|
379
|
+
offset += headerBytes.length + nameBytes.length + payload.length;
|
|
380
|
+
}
|
|
381
|
+
const centralSize = central.reduce((n, part) => n + part.length, 0);
|
|
382
|
+
const end = new DataView(new ArrayBuffer(22));
|
|
383
|
+
end.setUint32(0, 0x06054b50, true); // end of central directory
|
|
384
|
+
end.setUint16(8, entries.length, true);
|
|
385
|
+
end.setUint16(10, entries.length, true);
|
|
386
|
+
end.setUint32(12, centralSize, true);
|
|
387
|
+
end.setUint32(16, offset, true);
|
|
388
|
+
const parts = [...local, ...central, new Uint8Array(end.buffer)];
|
|
389
|
+
const total = parts.reduce((n, part) => n + part.length, 0);
|
|
390
|
+
const zipped = new Uint8Array(total);
|
|
391
|
+
let cursor = 0;
|
|
392
|
+
for (const part of parts) {
|
|
393
|
+
zipped.set(part, cursor);
|
|
394
|
+
cursor += part.length;
|
|
395
|
+
}
|
|
396
|
+
return zipped;
|
|
397
|
+
}
|
|
@@ -5,4 +5,5 @@ export { k3s, type K3sOptions, type K3sHelpers, type K3sClient, type TaggedObjec
|
|
|
5
5
|
export { expo, type ExpoOptions, type ExpoHelpers, } from "./expo.js";
|
|
6
6
|
export { supabase, type SupabaseOptions, type SupabaseHelpers, type SupabaseStack, } from "./supabase.js";
|
|
7
7
|
export { email, type EmailOptions, type EmailHelpers, type EmailMessage, type EmailSummary, type EmailMatch, type EmailAttachment, } from "./email.js";
|
|
8
|
+
export { aws, type AwsOptions, type LambdaOptions, } from "./aws.js";
|
|
8
9
|
export { replayFake, type ReplayFakeOptions, type ReplayMatch, type InjectRule, type InjectMatch, type StringMatch, type SignConfig, type AwsSigV4Sign, type ReplayHelpers, type ReplaySummary, type Cassette, type CassetteInteraction, type CassetteRequest, type CassetteResponse, type CassetteState, } from "./replayFake.js";
|
package/dist/components/index.js
CHANGED
package/dist/daemon.js
CHANGED
|
@@ -511,6 +511,17 @@ async function recordAbsoluteVolumeDir(host) {
|
|
|
511
511
|
ABS_VOLUME_DIRS.add(host);
|
|
512
512
|
await fs.writeFile(VOLUME_DIRS_MANIFEST, [...ABS_VOLUME_DIRS].join("\n") + "\n");
|
|
513
513
|
}
|
|
514
|
+
/// True when `p` exists and is not a directory (a socket, a regular file,
|
|
515
|
+
/// a device). Used to tell "a volume dir we must create" from "a VM-host
|
|
516
|
+
/// object the service asked to bind-mount as-is".
|
|
517
|
+
async function isExistingNonDirectory(p) {
|
|
518
|
+
try {
|
|
519
|
+
return !(await fs.lstat(p)).isDirectory();
|
|
520
|
+
}
|
|
521
|
+
catch {
|
|
522
|
+
return false;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
514
525
|
async function ensureVolumes(svc) {
|
|
515
526
|
const flags = [];
|
|
516
527
|
if (!svc.volumes || svc.volumes.length === 0)
|
|
@@ -522,9 +533,18 @@ async function ensureVolumes(svc) {
|
|
|
522
533
|
throw new Error(`service "${svc.name}" volume for ${JSON.stringify(vol.target)} sets both \`name\` and \`source\``);
|
|
523
534
|
}
|
|
524
535
|
const host = resolveHostPath(svc.name, vol);
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
536
|
+
// An absolute `source` can name something that already exists and is
|
|
537
|
+
// NOT a directory — the canonical case is the VM's docker socket
|
|
538
|
+
// (`/var/run/docker.sock`), bind-mounted into a service that drives
|
|
539
|
+
// docker itself (LocalStack's Lambda executor, dind-style builders).
|
|
540
|
+
// `mkdir -p` fails on those with EEXIST. They are also VM-host
|
|
541
|
+
// infrastructure rather than environment state, so they are neither
|
|
542
|
+
// created here nor recorded for the delta-restore wipe.
|
|
543
|
+
if (!(await isExistingNonDirectory(host))) {
|
|
544
|
+
await fs.mkdir(host, { recursive: true });
|
|
545
|
+
if (vol.source?.startsWith("/") && !host.startsWith("/var/cache/spectest/")) {
|
|
546
|
+
await recordAbsoluteVolumeDir(host);
|
|
547
|
+
}
|
|
528
548
|
}
|
|
529
549
|
flags.push(`--volume=${host}:${vol.target}${vol.readOnly ? ":ro" : ""}`);
|
|
530
550
|
}
|
|
@@ -1575,17 +1595,22 @@ async function startIngress() {
|
|
|
1575
1595
|
routes.set(p.hostname, { kind: "proxy", service: p.service, port: p.port });
|
|
1576
1596
|
}
|
|
1577
1597
|
}
|
|
1578
|
-
// The :443 route table mirrors every
|
|
1598
|
+
// The :443 route table mirrors every handler whose hostname a cert
|
|
1599
|
+
// covers — exactly, or through a wildcard SAN, so `*.example.com` in a
|
|
1600
|
+
// service's `tls` also puts an unrelated exact `api.example.com` route on
|
|
1601
|
+
// HTTPS. Proxies are applied after fakes so a proxy wins a shared host.
|
|
1579
1602
|
if (HTTPS_CERT_BY_HOST.size > 0) {
|
|
1580
1603
|
const httpsRoutes = ensurePort(INGRESS_HTTPS_PORT);
|
|
1581
|
-
for (const
|
|
1582
|
-
for (const
|
|
1583
|
-
if (
|
|
1604
|
+
for (const fake of FAKES.values()) {
|
|
1605
|
+
for (const h of fake.hostnames) {
|
|
1606
|
+
if (certCovers(h))
|
|
1584
1607
|
httpsRoutes.set(h, { kind: "fake", fake });
|
|
1585
1608
|
}
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1609
|
+
}
|
|
1610
|
+
for (const p of LOWERED.proxies) {
|
|
1611
|
+
if (certCovers(p.hostname)) {
|
|
1612
|
+
httpsRoutes.set(p.hostname, { kind: "proxy", service: p.service, port: p.port });
|
|
1613
|
+
}
|
|
1589
1614
|
}
|
|
1590
1615
|
}
|
|
1591
1616
|
// ── HTTP listeners (one per non-443 port).
|
|
@@ -1649,12 +1674,26 @@ function rebindHttpsListener(Bun) {
|
|
|
1649
1674
|
// eslint-disable-next-line no-console
|
|
1650
1675
|
console.log(`[ingress] https :${INGRESS_HTTPS_PORT} for ${[...routes.keys()].join(", ")}`);
|
|
1651
1676
|
}
|
|
1677
|
+
/**
|
|
1678
|
+
* RFC 6125 wildcard match: `*.example.com` covers `api.example.com` but NOT
|
|
1679
|
+
* `a.b.example.com`. Deliberately stricter than {@link matchIngressRoute}'s
|
|
1680
|
+
* suffix matching — this one has to agree with the TLS *client*, and every
|
|
1681
|
+
* client stops at one label. Claiming a deeper name is covered would skip
|
|
1682
|
+
* minting the leaf it actually needs and hand it a cert it rejects.
|
|
1683
|
+
*/
|
|
1684
|
+
function wildcardCoversHost(pattern, hostname) {
|
|
1685
|
+
const suffix = wildcardSuffix(pattern); // "*.example.com" → ".example.com"
|
|
1686
|
+
if (!hostname.endsWith(suffix))
|
|
1687
|
+
return false;
|
|
1688
|
+
const label = hostname.slice(0, -suffix.length);
|
|
1689
|
+
return label.length > 0 && !label.includes(".");
|
|
1690
|
+
}
|
|
1652
1691
|
/** True if an exact or wildcard cert already covers `hostname` for SNI. */
|
|
1653
1692
|
function certCovers(hostname) {
|
|
1654
1693
|
if (HTTPS_CERT_BY_HOST.has(hostname))
|
|
1655
1694
|
return true;
|
|
1656
1695
|
for (const serverName of HTTPS_CERT_BY_HOST.keys()) {
|
|
1657
|
-
if (isWildcard(serverName) &&
|
|
1696
|
+
if (isWildcard(serverName) && wildcardCoversHost(serverName, hostname)) {
|
|
1658
1697
|
return true;
|
|
1659
1698
|
}
|
|
1660
1699
|
}
|
|
@@ -1706,8 +1745,16 @@ async function bindRuntimeTls(hostname, service, port) {
|
|
|
1706
1745
|
rebindHttpsListener(Bun);
|
|
1707
1746
|
}
|
|
1708
1747
|
// Resolve the hostname to the daemon gateway (where :443/:80 listen).
|
|
1748
|
+
// A wildcard can only live in the resolver's suffix table.
|
|
1709
1749
|
const gw = await bridgeGatewayIp();
|
|
1710
|
-
|
|
1750
|
+
if (isWildcard(host)) {
|
|
1751
|
+
const suffix = wildcardSuffix(host);
|
|
1752
|
+
REGISTRY.wildcards = REGISTRY.wildcards.filter((w) => w.suffix !== suffix);
|
|
1753
|
+
REGISTRY.wildcards.push({ suffix, ip: gw });
|
|
1754
|
+
}
|
|
1755
|
+
else {
|
|
1756
|
+
REGISTRY.hosts[host] = gw;
|
|
1757
|
+
}
|
|
1711
1758
|
await writeRegistry();
|
|
1712
1759
|
// eslint-disable-next-line no-console
|
|
1713
1760
|
console.log(`[ingress] runtime https ${host} -> ${service}:${port}`);
|
|
@@ -1722,7 +1769,14 @@ async function unbindRuntimeTls(hostname) {
|
|
|
1722
1769
|
const host = hostname.toLowerCase();
|
|
1723
1770
|
INGRESS_ROUTES_BY_PORT.get(INGRESS_HTTP_PORT)?.delete(host);
|
|
1724
1771
|
INGRESS_ROUTES_BY_PORT.get(INGRESS_HTTPS_PORT)?.delete(host);
|
|
1725
|
-
if (host
|
|
1772
|
+
if (isWildcard(host)) {
|
|
1773
|
+
const suffix = wildcardSuffix(host);
|
|
1774
|
+
const before = REGISTRY.wildcards.length;
|
|
1775
|
+
REGISTRY.wildcards = REGISTRY.wildcards.filter((w) => w.suffix !== suffix);
|
|
1776
|
+
if (REGISTRY.wildcards.length !== before)
|
|
1777
|
+
await writeRegistry();
|
|
1778
|
+
}
|
|
1779
|
+
else if (host in REGISTRY.hosts) {
|
|
1726
1780
|
delete REGISTRY.hosts[host];
|
|
1727
1781
|
await writeRegistry();
|
|
1728
1782
|
}
|
|
@@ -1838,6 +1892,35 @@ Bun, port, byHost, listenerLabel, tlsEntries) {
|
|
|
1838
1892
|
opts.tls = tlsEntries;
|
|
1839
1893
|
return Bun.serve(opts);
|
|
1840
1894
|
}
|
|
1895
|
+
/**
|
|
1896
|
+
* Resolve a Host header to a route: an exact entry first, then the longest
|
|
1897
|
+
* matching `*.suffix` wildcard. Same precedence the resolver applies to DNS
|
|
1898
|
+
* (exact beats wildcard, longest suffix beats shorter), so a name that DNS
|
|
1899
|
+
* pointed at the ingress finds the route that claimed it.
|
|
1900
|
+
*
|
|
1901
|
+
* A wildcard *route* matches any depth of subdomain, while a wildcard
|
|
1902
|
+
* *cert* covers exactly one label ({@link wildcardCoversHost} — RFC 6125,
|
|
1903
|
+
* what TLS clients enforce). So `a.b.example.com` under a `*.example.com`
|
|
1904
|
+
* proxy reverse-proxies over `http://` but needs its own `tls` entry to
|
|
1905
|
+
* present a valid cert over `https://`.
|
|
1906
|
+
*/
|
|
1907
|
+
function matchIngressRoute(byHost, host) {
|
|
1908
|
+
const exact = byHost.get(host);
|
|
1909
|
+
if (exact)
|
|
1910
|
+
return exact;
|
|
1911
|
+
let best;
|
|
1912
|
+
let bestLen = -1;
|
|
1913
|
+
for (const [pattern, route] of byHost) {
|
|
1914
|
+
if (!isWildcard(pattern))
|
|
1915
|
+
continue;
|
|
1916
|
+
const suffix = wildcardSuffix(pattern);
|
|
1917
|
+
if (host.endsWith(suffix) && suffix.length > bestLen) {
|
|
1918
|
+
best = route;
|
|
1919
|
+
bestLen = suffix.length;
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
return best;
|
|
1923
|
+
}
|
|
1841
1924
|
/**
|
|
1842
1925
|
* Per-request dispatch shared by every ingress listener. Looks up the
|
|
1843
1926
|
* Route by Host header (port stripped) and either:
|
|
@@ -1852,7 +1935,7 @@ server, byHost, listenerLabel, proto) {
|
|
|
1852
1935
|
.toLowerCase()
|
|
1853
1936
|
.split(":")[0]
|
|
1854
1937
|
.trim();
|
|
1855
|
-
const route = byHost
|
|
1938
|
+
const route = matchIngressRoute(byHost, host);
|
|
1856
1939
|
if (!route) {
|
|
1857
1940
|
return new Response(`spectest-daemon: no ingress route bound to Host=${JSON.stringify(host)} on ${listenerLabel}\n`, { status: 404, headers: { "content-type": "text/plain" } });
|
|
1858
1941
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -72,6 +72,11 @@ export interface ServiceConfig {
|
|
|
72
72
|
* instead — those names terminate TLS in the daemon and reverse-proxy
|
|
73
73
|
* to the service's HTTP port.
|
|
74
74
|
*
|
|
75
|
+
* A `*.suffix` wildcard (e.g. `"*.example.com"`) points a whole domain
|
|
76
|
+
* at this service. Wildcards are answered by spectest-resolver only —
|
|
77
|
+
* they never land in a container's `/etc/hosts` — which peer containers
|
|
78
|
+
* still reach, since Docker forwards unknown names to that resolver.
|
|
79
|
+
*
|
|
75
80
|
* The `.internal` TLD is reserved: every service automatically
|
|
76
81
|
* answers to `<name>.internal` in addition to its bare `<name>`, and
|
|
77
82
|
* user-supplied hostnames may not end in `.internal`.
|
|
@@ -92,6 +97,15 @@ export interface ServiceConfig {
|
|
|
92
97
|
* tests and from peer services. Hostname rules match {@link hostnames}:
|
|
93
98
|
* multi-label, lowercase, no `.internal` suffix, no collision with
|
|
94
99
|
* services, other service TLS hostnames, or fakes.
|
|
100
|
+
*
|
|
101
|
+
* A `*.suffix` wildcard claims a whole domain with one entry — e.g.
|
|
102
|
+
* `{ hostname: "*.us-east-1.amazonaws.com", port: 4566 }` puts every
|
|
103
|
+
* AWS regional endpoint on one emulator container, so an unmodified SDK
|
|
104
|
+
* reaches it at its production URL. The leaf cert gets a wildcard SAN,
|
|
105
|
+
* which (as in every TLS client) covers exactly **one** label: declare
|
|
106
|
+
* a separate entry for anything deeper. An exact `tls` hostname always
|
|
107
|
+
* beats a wildcard, so a single endpoint can be split off to another
|
|
108
|
+
* service.
|
|
95
109
|
*/
|
|
96
110
|
tls?: readonly ServiceTls[];
|
|
97
111
|
/** Bind-mounted volumes for state that survives snapshot/fork. */
|
|
@@ -625,6 +639,12 @@ export interface VolumeMount {
|
|
|
625
639
|
/**
|
|
626
640
|
* Host path. Relative paths resolve under
|
|
627
641
|
* `.spectest/volumes/<service>/`. Defaults to a path derived from `target`.
|
|
642
|
+
*
|
|
643
|
+
* An absolute path that already exists and is not a directory is
|
|
644
|
+
* bind-mounted as-is — the way to hand a service the VM's docker socket
|
|
645
|
+
* (`source: "/var/run/docker.sock"`), which LocalStack's Lambda executor
|
|
646
|
+
* and dind-style builders need. Such a mount is VM-host infrastructure,
|
|
647
|
+
* so unlike a volume directory it is never created or wiped by spectest.
|
|
628
648
|
*/
|
|
629
649
|
source?: string;
|
|
630
650
|
/** Container path. */
|
package/dist/index.js
CHANGED
|
@@ -31,6 +31,7 @@ import { describeUrlPattern, matchesUrl } from "./url-match.js";
|
|
|
31
31
|
// `tls` / `hostnames` fields and `defineFake(...)` are built on. See
|
|
32
32
|
// `ingress.ts`.
|
|
33
33
|
export { certificate, dnsName, proxy, provides, lowerIngress, isWildcard, SELF_SERVICE_TOKEN, } from "./ingress.js";
|
|
34
|
+
import { isWildcard as isWildcardHost } from "./ingress.js";
|
|
34
35
|
// ──────────────────────────────────────────────────────────────────────────
|
|
35
36
|
// Service groups — one services-map entry that expands to several services.
|
|
36
37
|
//
|
|
@@ -234,8 +235,8 @@ function validateEnvironmentConfig(config) {
|
|
|
234
235
|
}
|
|
235
236
|
for (const raw of svc.hostnames ?? []) {
|
|
236
237
|
const h = raw.toLowerCase();
|
|
237
|
-
if (!HOSTNAME_RE.test(h)) {
|
|
238
|
-
throw new Error(`service "${name}" declares invalid hostname ${JSON.stringify(raw)} — must be a multi-label DNS name (e.g. "api.stripe.com")`);
|
|
238
|
+
if (!HOSTNAME_RE.test(hostPatternBody(h))) {
|
|
239
|
+
throw new Error(`service "${name}" declares invalid hostname ${JSON.stringify(raw)} — must be a multi-label DNS name or a wildcard (e.g. "api.stripe.com", "*.stripe.com")`);
|
|
239
240
|
}
|
|
240
241
|
if (h === "internal" || h.endsWith(".internal")) {
|
|
241
242
|
throw new Error(`service "${name}" declares hostname ${JSON.stringify(raw)} — the ".internal" TLD is reserved; every service already answers to "<name>.internal" automatically`);
|
|
@@ -247,8 +248,8 @@ function validateEnvironmentConfig(config) {
|
|
|
247
248
|
throw new Error(`service "${name}" tls entry must be { hostname: string, port: number }; got ${JSON.stringify(entry)}`);
|
|
248
249
|
}
|
|
249
250
|
const h = entry.hostname.toLowerCase();
|
|
250
|
-
if (!HOSTNAME_RE.test(h)) {
|
|
251
|
-
throw new Error(`service "${name}" tls hostname ${JSON.stringify(entry.hostname)} is not a multi-label DNS name (e.g. "app.test")`);
|
|
251
|
+
if (!HOSTNAME_RE.test(hostPatternBody(h))) {
|
|
252
|
+
throw new Error(`service "${name}" tls hostname ${JSON.stringify(entry.hostname)} is not a multi-label DNS name or wildcard (e.g. "app.test", "*.us-east-1.amazonaws.com")`);
|
|
252
253
|
}
|
|
253
254
|
if (h === "internal" || h.endsWith(".internal")) {
|
|
254
255
|
throw new Error(`service "${name}" tls hostname ${JSON.stringify(entry.hostname)} ends in reserved ".internal" TLD`);
|
|
@@ -263,6 +264,13 @@ function validateEnvironmentConfig(config) {
|
|
|
263
264
|
// Multi-label hostname: at least one dot, each label 1–63 chars of
|
|
264
265
|
// [a-z0-9-], no leading/trailing hyphen.
|
|
265
266
|
const HOSTNAME_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/;
|
|
267
|
+
/** The part of a declared name that must be a valid hostname: a `*.suffix`
|
|
268
|
+
* wildcard is checked by its suffix, so `*.example.com` passes but `*.com`
|
|
269
|
+
* (single-label, too broad) and `*` do not. Keeps the `.internal` checks
|
|
270
|
+
* honest too — `*.foo.internal` reduces to `foo.internal`. */
|
|
271
|
+
function hostPatternBody(lowercased) {
|
|
272
|
+
return isWildcardHost(lowercased) ? lowercased.slice(2) : lowercased;
|
|
273
|
+
}
|
|
266
274
|
/**
|
|
267
275
|
* Define a fake. `defineFake({ ... })` is a thin wrapper that pins the
|
|
268
276
|
* generic `S` and `H` so `helpers`/`handler` see the inferred state type
|
package/dist/ingress.d.ts
CHANGED
|
@@ -38,6 +38,11 @@ export declare function isWildcard(hostname: string): boolean;
|
|
|
38
38
|
* `hostnames`. The daemon binds it on the HTTPS ingress (:443, SNI per
|
|
39
39
|
* hostname). Pairs with a `proxy(...)` (TLS-terminated reverse proxy) or a
|
|
40
40
|
* fake handler; on its own it just makes those hostnames serve HTTPS.
|
|
41
|
+
*
|
|
42
|
+
* A `*.suffix` wildcard is allowed and becomes a wildcard SAN, so one cert
|
|
43
|
+
* covers a whole domain (`*.us-east-1.amazonaws.com`). As in any TLS
|
|
44
|
+
* client, a wildcard SAN covers exactly **one** label — `*.example.com`
|
|
45
|
+
* matches `api.example.com`, not `a.b.example.com`.
|
|
41
46
|
*/
|
|
42
47
|
export declare function certificate(hostnames: string[]): CertificateDecl;
|
|
43
48
|
/**
|
|
@@ -54,6 +59,12 @@ export declare function dnsName(hostname: string, target: DnsTarget): DnsDecl;
|
|
|
54
59
|
* Implies the hostname resolves to the daemon ingress, so containers reach
|
|
55
60
|
* it without any extra `dnsName(...)`. Add a `certificate([hostname])` to
|
|
56
61
|
* serve it over HTTPS as well as HTTP.
|
|
62
|
+
*
|
|
63
|
+
* `hostname` may be a `*.suffix` wildcard, which sends every name under
|
|
64
|
+
* that domain to the same upstream — one route for a whole API surface
|
|
65
|
+
* (`*.us-east-1.amazonaws.com` → a LocalStack container). Exact routes
|
|
66
|
+
* always win over wildcards, and the longest wildcard suffix wins among
|
|
67
|
+
* wildcards.
|
|
57
68
|
*/
|
|
58
69
|
export declare function proxy(hostname: string, upstream: {
|
|
59
70
|
service: string;
|
package/dist/ingress.js
CHANGED
|
@@ -53,13 +53,18 @@ function assertNameOrWildcard(h, ctx) {
|
|
|
53
53
|
* `hostnames`. The daemon binds it on the HTTPS ingress (:443, SNI per
|
|
54
54
|
* hostname). Pairs with a `proxy(...)` (TLS-terminated reverse proxy) or a
|
|
55
55
|
* fake handler; on its own it just makes those hostnames serve HTTPS.
|
|
56
|
+
*
|
|
57
|
+
* A `*.suffix` wildcard is allowed and becomes a wildcard SAN, so one cert
|
|
58
|
+
* covers a whole domain (`*.us-east-1.amazonaws.com`). As in any TLS
|
|
59
|
+
* client, a wildcard SAN covers exactly **one** label — `*.example.com`
|
|
60
|
+
* matches `api.example.com`, not `a.b.example.com`.
|
|
56
61
|
*/
|
|
57
62
|
export function certificate(hostnames) {
|
|
58
63
|
if (!Array.isArray(hostnames) || hostnames.length === 0) {
|
|
59
64
|
throw new Error("certificate(): at least one hostname is required");
|
|
60
65
|
}
|
|
61
66
|
for (const h of hostnames)
|
|
62
|
-
|
|
67
|
+
assertNameOrWildcard(h, "certificate()");
|
|
63
68
|
return { kind: "certificate", hostnames: hostnames.map((h) => h.toLowerCase()) };
|
|
64
69
|
}
|
|
65
70
|
/**
|
|
@@ -82,9 +87,15 @@ export function dnsName(hostname, target) {
|
|
|
82
87
|
* Implies the hostname resolves to the daemon ingress, so containers reach
|
|
83
88
|
* it without any extra `dnsName(...)`. Add a `certificate([hostname])` to
|
|
84
89
|
* serve it over HTTPS as well as HTTP.
|
|
90
|
+
*
|
|
91
|
+
* `hostname` may be a `*.suffix` wildcard, which sends every name under
|
|
92
|
+
* that domain to the same upstream — one route for a whole API surface
|
|
93
|
+
* (`*.us-east-1.amazonaws.com` → a LocalStack container). Exact routes
|
|
94
|
+
* always win over wildcards, and the longest wildcard suffix wins among
|
|
95
|
+
* wildcards.
|
|
85
96
|
*/
|
|
86
97
|
export function proxy(hostname, upstream) {
|
|
87
|
-
|
|
98
|
+
assertNameOrWildcard(hostname, "proxy()");
|
|
88
99
|
if (!upstream || typeof upstream.service !== "string" || typeof upstream.port !== "number") {
|
|
89
100
|
throw new Error(`proxy(${JSON.stringify(hostname)}): upstream must be { service: string, port: number }`);
|
|
90
101
|
}
|
|
@@ -153,8 +164,15 @@ export function lowerIngress(project) {
|
|
|
153
164
|
case "proxy": {
|
|
154
165
|
const service = resolveSelf(decl.upstream.service, selfKey);
|
|
155
166
|
proxies.push({ hostname: decl.hostname, service, port: decl.upstream.port });
|
|
156
|
-
// A proxied hostname must route to the daemon.
|
|
157
|
-
|
|
167
|
+
// A proxied hostname must route to the daemon. A wildcard one can
|
|
168
|
+
// only be answered by the resolver (there is no `--add-host` for a
|
|
169
|
+
// pattern), so it goes in the wildcard table pointed at the ingress.
|
|
170
|
+
if (isWildcard(decl.hostname)) {
|
|
171
|
+
wildcards.push({ pattern: decl.hostname, target: { ingress: true } });
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
ingressSet.add(decl.hostname);
|
|
175
|
+
}
|
|
158
176
|
break;
|
|
159
177
|
}
|
|
160
178
|
case "dns": {
|