@lunora/container 1.0.0-alpha.3 → 1.0.0-alpha.4
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/README.md +73 -1
- package/dist/do/index.d.mts +36 -2
- package/dist/do/index.d.ts +36 -2
- package/dist/do/index.mjs +120 -2
- package/dist/index.d.mts +60 -4
- package/dist/index.d.ts +60 -4
- package/dist/index.mjs +2 -2
- package/dist/packem_shared/{containerBindingName-BGdSdFNA.mjs → containerBindingName-C7Lic2ET.mjs} +87 -3
- package/dist/packem_shared/{createContainerContext-CTpyUQ4J.mjs → createContainerContext-DBp8oJOr.mjs} +54 -29
- package/dist/packem_shared/{types.d-D2l2SYol.d.mts → types.d-DAOLRiZQ.d.mts} +97 -3
- package/dist/packem_shared/{types.d-D2l2SYol.d.ts → types.d-DAOLRiZQ.d.ts} +97 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -66,6 +66,7 @@ export const transcoder = defineContainer({
|
|
|
66
66
|
maxInstances: 5,
|
|
67
67
|
sleepAfter: "5m",
|
|
68
68
|
secrets: ["TRANSCODER_API_KEY"], // forwarded from Worker secrets / .dev.vars
|
|
69
|
+
labels: { team: "media" }, // metadata attached to every instance for metrics/observability
|
|
69
70
|
});
|
|
70
71
|
```
|
|
71
72
|
|
|
@@ -89,10 +90,81 @@ export const transcode = action.input({ videoId: v.id("videos") }).action(async
|
|
|
89
90
|
});
|
|
90
91
|
```
|
|
91
92
|
|
|
92
|
-
`ctx.containers` is action-only (container calls are external I/O, like `ctx.fetch`); `.get(name)` handles also expose `start`/`stop`/`destroy`/`getState` lifecycle control.
|
|
93
|
+
`ctx.containers` is action-only (container calls are external I/O, like `ctx.fetch`); `.get(name)` handles also expose `start`/`stop`/`destroy`/`getState` lifecycle control plus `renewActivityTimeout()` (keep a busy WebSocket's container awake) and `egress.*` (adjust the allow/deny lists at runtime).
|
|
93
94
|
|
|
94
95
|
The config layer (`lunora dev` / `lunora deploy`) reconciles the wrangler `containers[]` entry, the `CONTAINER_*` Durable Object binding, and the SQLite-class migration automatically; `wrangler deploy` builds the Dockerfile with local Docker and pushes it to the Cloudflare Registry.
|
|
95
96
|
|
|
97
|
+
### Multi-port containers
|
|
98
|
+
|
|
99
|
+
Declare every port the container must be listening on with `requiredPorts` (start-up waits for all of them); `defaultPort` is the target when a request doesn't pick one. Route a single request to another port with `.port(n)` — it composes with `.get()`, `.any()`, and `.pool()`:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
export const app = defineContainer({
|
|
103
|
+
image: "./containers/app",
|
|
104
|
+
defaultPort: 8080,
|
|
105
|
+
requiredPorts: [8080, 9090], // app + admin
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// in an action:
|
|
109
|
+
await ctx.containers.app.get(tenantId).fetch("/work"); // → 8080
|
|
110
|
+
await ctx.containers.app.get(tenantId).port(9090).fetch("/admin"); // → 9090
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Build-time args
|
|
114
|
+
|
|
115
|
+
`env` and `secrets` are runtime values; for build-time `docker build --build-arg` values (wrangler `image_vars`, exposed to the Dockerfile as `ARG`) use `buildArgs`. They apply only to an image Lunora builds and are ignored for a pre-built `{ registry }` image.
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
export const worker = defineContainer({
|
|
119
|
+
image: "./containers/worker",
|
|
120
|
+
buildArgs: { NODE_VERSION: "22", BUILD_TARGET: "production" },
|
|
121
|
+
});
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Egress firewall
|
|
125
|
+
|
|
126
|
+
Pair `enableInternet: false` with an `allowedHosts` allow-list (or layer a `deniedHosts` deny-list that overrides everything) to constrain a container's outbound traffic; `interceptHttps: true` extends the lists to TLS connections (the image must trust the Cloudflare CA). Codegen re-exports the `ContainerProxy` worker entrypoint the interception path needs automatically.
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
export const fetcher = defineContainer({
|
|
130
|
+
image: "./containers/fetcher",
|
|
131
|
+
enableInternet: false,
|
|
132
|
+
allowedHosts: ["*.stripe.com", "api.github.com"],
|
|
133
|
+
deniedHosts: ["*.evil.com"],
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// tighten or relax one running instance at runtime:
|
|
137
|
+
await ctx.containers.fetcher.get(tenantId).egress.allow("hooks.slack.com");
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
For advanced egress rewriting in worker code, `@lunora/container/do` re-exports Cloudflare's custom outbound-handler types (`OutboundHandler`, `OutboundHandlers`, `outboundParams`) — wire them onto a hand-authored `LunoraContainer` subclass to inject auth, route, or mock a container's outbound calls.
|
|
141
|
+
|
|
142
|
+
### Readiness gating
|
|
143
|
+
|
|
144
|
+
The platform health check waits for an open port, not necessarily a _ready_ app. `readyOn` adds application-level probes that gate request proxying: a `ctx.containers.<name>` fetch holds until every probe responds with its expected status, so callers never hit a container still applying migrations or warming caches. Probes are declarative data (path + optional `port`/`status`), run in parallel at start, and probe the container's TCP port directly.
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
export const api = defineContainer({
|
|
148
|
+
image: "./containers/api",
|
|
149
|
+
defaultPort: 8080,
|
|
150
|
+
readyOn: [
|
|
151
|
+
{ path: "/ready" }, // expect 200 on defaultPort
|
|
152
|
+
{ path: "/live", port: 9090, status: 204 }, // own port + expected status
|
|
153
|
+
],
|
|
154
|
+
});
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Hard timeout
|
|
158
|
+
|
|
159
|
+
`sleepAfter` caps _idle_ time; `hardTimeout` caps _total_ lifetime — a runaway-cost backstop measured from start, regardless of activity (same grammar as `sleepAfter`). When it elapses the generated class's `onHardTimeoutExpired` hook runs (default: `stop()`); the timer is run-generation-stamped so a stale timer from a slept/crashed run can't kill a fresh one.
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
export const job = defineContainer({
|
|
163
|
+
image: "./containers/job",
|
|
164
|
+
hardTimeout: "1h", // never run longer than an hour, busy or not
|
|
165
|
+
});
|
|
166
|
+
```
|
|
167
|
+
|
|
96
168
|
### Calling Lunora from inside a container
|
|
97
169
|
|
|
98
170
|
Container code calls back into your app's functions with the bridge client (any JS runtime), over the Worker's HTTP RPC endpoint:
|
package/dist/do/index.d.mts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Container, StopParams } from '@cloudflare/containers';
|
|
2
|
-
|
|
2
|
+
export { ContainerProxy, type OutboundHandler, type OutboundHandlerContext, type OutboundHandlerParams, type OutboundHandlerParamsOf, type OutboundHandlers, outboundParams } from '@cloudflare/containers';
|
|
3
|
+
import { C as ContainerDefinition } from "../packem_shared/types.d-DAOLRiZQ.mjs";
|
|
3
4
|
/**
|
|
4
5
|
* Cloudflare Durable Object data-residency jurisdiction. Widening union —
|
|
5
6
|
* Cloudflare adds values over time.
|
|
@@ -41,11 +42,44 @@ declare class LunoraContainer<Env = unknown> extends Container<Env> {
|
|
|
41
42
|
private readonly lunoraJurisdiction?;
|
|
42
43
|
/** The `lunora/containers.ts` export name, for lifecycle log correlation. */
|
|
43
44
|
private readonly lunoraName;
|
|
45
|
+
/** Default port the readiness probes target when a check omits its own `port`. */
|
|
46
|
+
private readonly lunoraDefaultPort?;
|
|
47
|
+
/** Hard-cap lifetime in whole seconds (from the `hardTimeout` config), or `undefined`. */
|
|
48
|
+
private readonly lunoraHardTimeoutSeconds?;
|
|
49
|
+
/** Declarative readiness probes that gate request proxying (from the `readyOn` config). */
|
|
50
|
+
private readonly lunoraReadyOn;
|
|
44
51
|
constructor(context: DurableObjectContext, env: Env, definition: ContainerDefinition, exportName?: string, jurisdiction?: DurableObjectJurisdiction);
|
|
52
|
+
override onActivityExpired(): Promise<void>;
|
|
45
53
|
override onError(error: unknown): unknown;
|
|
46
54
|
override onStart(): Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* Hook run when the container's `hardTimeout` elapses (dispatched by the base
|
|
57
|
+
* scheduler via the run-generation-stamped schedule armed in
|
|
58
|
+
* {@link onStart}). Default: stop the instance. Override to drain/checkpoint
|
|
59
|
+
* first. A stale schedule from a previous run, or an already-stopped
|
|
60
|
+
* instance, is ignored (upstream cloudflare/containers#85).
|
|
61
|
+
*/
|
|
62
|
+
onHardTimeoutExpired(payload?: {
|
|
63
|
+
generation?: number;
|
|
64
|
+
}): Promise<void>;
|
|
47
65
|
override onStop(parameters: StopParams): Promise<void>;
|
|
48
66
|
/**
|
|
67
|
+
* Arm the hard-timeout kill via the base scheduler (so it integrates with
|
|
68
|
+
* the container's own alarm machinery instead of fighting it). Bumps the run
|
|
69
|
+
* generation and stamps the schedule with it, so {@link onHardTimeoutExpired}
|
|
70
|
+
* can tell a fresh schedule from a stale one. No-op without a `hardTimeout`.
|
|
71
|
+
*/
|
|
72
|
+
private armHardTimeout;
|
|
73
|
+
/**
|
|
74
|
+
* Block until every `readyOn` probe responds with its expected status, or
|
|
75
|
+
* throw once the readiness budget is spent. Probes run in parallel and hit
|
|
76
|
+
* the container's TCP port directly (NOT `containerFetch`, which would
|
|
77
|
+
* recurse back into the start path). No-op without `readyOn`.
|
|
78
|
+
*/
|
|
79
|
+
private awaitContainerReadiness;
|
|
80
|
+
/** Poll one readiness probe until it returns its expected status or the shared deadline passes. */
|
|
81
|
+
private awaitReadinessCheck;
|
|
82
|
+
/**
|
|
49
83
|
* Best-effort push of `envelope` into the root ShardDO's log buffer so it
|
|
50
84
|
* also appears in the Studio Logs panel (the terminal already has it via
|
|
51
85
|
* `emitContainerLifecycle`). Fire-and-forget and fully swallowed: a missing
|
|
@@ -60,4 +94,4 @@ declare class LunoraContainer<Env = unknown> extends Container<Env> {
|
|
|
60
94
|
*/
|
|
61
95
|
private instanceId;
|
|
62
96
|
}
|
|
63
|
-
export { LunoraContainer
|
|
97
|
+
export { LunoraContainer };
|
package/dist/do/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Container, StopParams } from '@cloudflare/containers';
|
|
2
|
-
|
|
2
|
+
export { ContainerProxy, type OutboundHandler, type OutboundHandlerContext, type OutboundHandlerParams, type OutboundHandlerParamsOf, type OutboundHandlers, outboundParams } from '@cloudflare/containers';
|
|
3
|
+
import { C as ContainerDefinition } from "../packem_shared/types.d-DAOLRiZQ.js";
|
|
3
4
|
/**
|
|
4
5
|
* Cloudflare Durable Object data-residency jurisdiction. Widening union —
|
|
5
6
|
* Cloudflare adds values over time.
|
|
@@ -41,11 +42,44 @@ declare class LunoraContainer<Env = unknown> extends Container<Env> {
|
|
|
41
42
|
private readonly lunoraJurisdiction?;
|
|
42
43
|
/** The `lunora/containers.ts` export name, for lifecycle log correlation. */
|
|
43
44
|
private readonly lunoraName;
|
|
45
|
+
/** Default port the readiness probes target when a check omits its own `port`. */
|
|
46
|
+
private readonly lunoraDefaultPort?;
|
|
47
|
+
/** Hard-cap lifetime in whole seconds (from the `hardTimeout` config), or `undefined`. */
|
|
48
|
+
private readonly lunoraHardTimeoutSeconds?;
|
|
49
|
+
/** Declarative readiness probes that gate request proxying (from the `readyOn` config). */
|
|
50
|
+
private readonly lunoraReadyOn;
|
|
44
51
|
constructor(context: DurableObjectContext, env: Env, definition: ContainerDefinition, exportName?: string, jurisdiction?: DurableObjectJurisdiction);
|
|
52
|
+
override onActivityExpired(): Promise<void>;
|
|
45
53
|
override onError(error: unknown): unknown;
|
|
46
54
|
override onStart(): Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* Hook run when the container's `hardTimeout` elapses (dispatched by the base
|
|
57
|
+
* scheduler via the run-generation-stamped schedule armed in
|
|
58
|
+
* {@link onStart}). Default: stop the instance. Override to drain/checkpoint
|
|
59
|
+
* first. A stale schedule from a previous run, or an already-stopped
|
|
60
|
+
* instance, is ignored (upstream cloudflare/containers#85).
|
|
61
|
+
*/
|
|
62
|
+
onHardTimeoutExpired(payload?: {
|
|
63
|
+
generation?: number;
|
|
64
|
+
}): Promise<void>;
|
|
47
65
|
override onStop(parameters: StopParams): Promise<void>;
|
|
48
66
|
/**
|
|
67
|
+
* Arm the hard-timeout kill via the base scheduler (so it integrates with
|
|
68
|
+
* the container's own alarm machinery instead of fighting it). Bumps the run
|
|
69
|
+
* generation and stamps the schedule with it, so {@link onHardTimeoutExpired}
|
|
70
|
+
* can tell a fresh schedule from a stale one. No-op without a `hardTimeout`.
|
|
71
|
+
*/
|
|
72
|
+
private armHardTimeout;
|
|
73
|
+
/**
|
|
74
|
+
* Block until every `readyOn` probe responds with its expected status, or
|
|
75
|
+
* throw once the readiness budget is spent. Probes run in parallel and hit
|
|
76
|
+
* the container's TCP port directly (NOT `containerFetch`, which would
|
|
77
|
+
* recurse back into the start path). No-op without `readyOn`.
|
|
78
|
+
*/
|
|
79
|
+
private awaitContainerReadiness;
|
|
80
|
+
/** Poll one readiness probe until it returns its expected status or the shared deadline passes. */
|
|
81
|
+
private awaitReadinessCheck;
|
|
82
|
+
/**
|
|
49
83
|
* Best-effort push of `envelope` into the root ShardDO's log buffer so it
|
|
50
84
|
* also appears in the Studio Logs panel (the terminal already has it via
|
|
51
85
|
* `emitContainerLifecycle`). Fire-and-forget and fully swallowed: a missing
|
|
@@ -60,4 +94,4 @@ declare class LunoraContainer<Env = unknown> extends Container<Env> {
|
|
|
60
94
|
*/
|
|
61
95
|
private instanceId;
|
|
62
96
|
}
|
|
63
|
-
export { LunoraContainer
|
|
97
|
+
export { LunoraContainer };
|
package/dist/do/index.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Container } from '@cloudflare/containers';
|
|
2
|
-
|
|
2
|
+
export { ContainerProxy, outboundParams } from '@cloudflare/containers';
|
|
3
|
+
import { resolveContainerEnvVars as resolveContainerEnvVariables, parseDurationSeconds } from '../packem_shared/containerBindingName-C7Lic2ET.mjs';
|
|
3
4
|
|
|
4
5
|
const LUNORA_EVENT_SOURCE = "lunora";
|
|
5
6
|
const buildContainerLifecycleEvent = (container, instance, event, message) => {
|
|
@@ -73,6 +74,9 @@ const reportContainerLifecycle = async (env, envelope, jurisdiction) => {
|
|
|
73
74
|
}
|
|
74
75
|
};
|
|
75
76
|
|
|
77
|
+
const READINESS_POLL_INTERVAL_MS = 500;
|
|
78
|
+
const READINESS_TIMEOUT_MS = 3e4;
|
|
79
|
+
const HARD_TIMEOUT_GENERATION_KEY = "__lunoraHardTimeoutGeneration";
|
|
76
80
|
class LunoraContainer extends Container {
|
|
77
81
|
/**
|
|
78
82
|
* Data-residency jurisdiction the app's DOs are pinned to (codegen passes the
|
|
@@ -82,17 +86,50 @@ class LunoraContainer extends Container {
|
|
|
82
86
|
lunoraJurisdiction;
|
|
83
87
|
/** The `lunora/containers.ts` export name, for lifecycle log correlation. */
|
|
84
88
|
lunoraName;
|
|
89
|
+
/** Default port the readiness probes target when a check omits its own `port`. */
|
|
90
|
+
lunoraDefaultPort;
|
|
91
|
+
/** Hard-cap lifetime in whole seconds (from the `hardTimeout` config), or `undefined`. */
|
|
92
|
+
lunoraHardTimeoutSeconds;
|
|
93
|
+
/** Declarative readiness probes that gate request proxying (from the `readyOn` config). */
|
|
94
|
+
lunoraReadyOn;
|
|
85
95
|
constructor(context, env, definition, exportName, jurisdiction) {
|
|
86
96
|
super(context, env, {
|
|
87
97
|
defaultPort: definition.defaultPort,
|
|
98
|
+
entrypoint: definition.entrypoint ? [...definition.entrypoint] : void 0,
|
|
88
99
|
envVars: resolveContainerEnvVariables(definition, env, exportName),
|
|
89
100
|
sleepAfter: definition.sleepAfter
|
|
90
101
|
});
|
|
91
102
|
if (definition.enableInternet !== void 0) {
|
|
92
103
|
this.enableInternet = definition.enableInternet;
|
|
93
104
|
}
|
|
105
|
+
if (definition.requiredPorts !== void 0) {
|
|
106
|
+
this.requiredPorts = [...definition.requiredPorts];
|
|
107
|
+
}
|
|
108
|
+
if (definition.interceptHttps !== void 0) {
|
|
109
|
+
this.interceptHttps = definition.interceptHttps;
|
|
110
|
+
}
|
|
111
|
+
if (definition.allowedHosts !== void 0) {
|
|
112
|
+
this.allowedHosts = [...definition.allowedHosts];
|
|
113
|
+
}
|
|
114
|
+
if (definition.deniedHosts !== void 0) {
|
|
115
|
+
this.deniedHosts = [...definition.deniedHosts];
|
|
116
|
+
}
|
|
117
|
+
if (definition.pingEndpoint !== void 0) {
|
|
118
|
+
this.pingEndpoint = definition.pingEndpoint;
|
|
119
|
+
}
|
|
120
|
+
if (definition.labels !== void 0) {
|
|
121
|
+
this.labels = { ...definition.labels };
|
|
122
|
+
}
|
|
94
123
|
this.lunoraName = exportName ?? "container";
|
|
95
124
|
this.lunoraJurisdiction = jurisdiction;
|
|
125
|
+
this.lunoraDefaultPort = definition.defaultPort;
|
|
126
|
+
this.lunoraReadyOn = definition.readyOn ? [...definition.readyOn] : [];
|
|
127
|
+
this.lunoraHardTimeoutSeconds = definition.hardTimeout === void 0 ? void 0 : parseDurationSeconds(definition.hardTimeout);
|
|
128
|
+
}
|
|
129
|
+
async onActivityExpired() {
|
|
130
|
+
const envelope = emitContainerLifecycle(this.lunoraName, this.instanceId(), "sleep");
|
|
131
|
+
this.surfaceInStudioLogs(envelope);
|
|
132
|
+
await super.onActivityExpired();
|
|
96
133
|
}
|
|
97
134
|
onError(error) {
|
|
98
135
|
const envelope = emitContainerLifecycle(this.lunoraName, this.instanceId(), "error", error instanceof Error ? error.message : String(error));
|
|
@@ -103,12 +140,93 @@ class LunoraContainer extends Container {
|
|
|
103
140
|
const envelope = emitContainerLifecycle(this.lunoraName, this.instanceId(), "start");
|
|
104
141
|
this.surfaceInStudioLogs(envelope);
|
|
105
142
|
await super.onStart();
|
|
143
|
+
await this.armHardTimeout();
|
|
144
|
+
await this.awaitContainerReadiness();
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Hook run when the container's `hardTimeout` elapses (dispatched by the base
|
|
148
|
+
* scheduler via the run-generation-stamped schedule armed in
|
|
149
|
+
* {@link onStart}). Default: stop the instance. Override to drain/checkpoint
|
|
150
|
+
* first. A stale schedule from a previous run, or an already-stopped
|
|
151
|
+
* instance, is ignored (upstream cloudflare/containers#85).
|
|
152
|
+
*/
|
|
153
|
+
async onHardTimeoutExpired(payload) {
|
|
154
|
+
const current = await this.ctx.storage.get(HARD_TIMEOUT_GENERATION_KEY);
|
|
155
|
+
if (payload?.generation !== void 0 && payload.generation !== current) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (this.ctx.container?.running !== true) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const envelope = emitContainerLifecycle(this.lunoraName, this.instanceId(), "stop", "hard timeout reached");
|
|
162
|
+
this.surfaceInStudioLogs(envelope);
|
|
163
|
+
await this.stop();
|
|
106
164
|
}
|
|
107
165
|
async onStop(parameters) {
|
|
108
166
|
const envelope = emitContainerLifecycle(this.lunoraName, this.instanceId(), "stop", `${parameters.reason} (exit ${String(parameters.exitCode)})`);
|
|
109
167
|
this.surfaceInStudioLogs(envelope);
|
|
110
168
|
await super.onStop(parameters);
|
|
111
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* Arm the hard-timeout kill via the base scheduler (so it integrates with
|
|
172
|
+
* the container's own alarm machinery instead of fighting it). Bumps the run
|
|
173
|
+
* generation and stamps the schedule with it, so {@link onHardTimeoutExpired}
|
|
174
|
+
* can tell a fresh schedule from a stale one. No-op without a `hardTimeout`.
|
|
175
|
+
*/
|
|
176
|
+
async armHardTimeout() {
|
|
177
|
+
if (this.lunoraHardTimeoutSeconds === void 0) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const generation = (await this.ctx.storage.get(HARD_TIMEOUT_GENERATION_KEY) ?? 0) + 1;
|
|
181
|
+
await this.ctx.storage.put(HARD_TIMEOUT_GENERATION_KEY, generation);
|
|
182
|
+
await this.schedule(this.lunoraHardTimeoutSeconds, "onHardTimeoutExpired", { generation });
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Block until every `readyOn` probe responds with its expected status, or
|
|
186
|
+
* throw once the readiness budget is spent. Probes run in parallel and hit
|
|
187
|
+
* the container's TCP port directly (NOT `containerFetch`, which would
|
|
188
|
+
* recurse back into the start path). No-op without `readyOn`.
|
|
189
|
+
*/
|
|
190
|
+
async awaitContainerReadiness() {
|
|
191
|
+
if (this.lunoraReadyOn.length === 0) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const { container } = this.ctx;
|
|
195
|
+
if (container === void 0) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const deadline = Date.now() + READINESS_TIMEOUT_MS;
|
|
199
|
+
await Promise.all(this.lunoraReadyOn.map(async (check) => this.awaitReadinessCheck(container, check, deadline)));
|
|
200
|
+
}
|
|
201
|
+
/** Poll one readiness probe until it returns its expected status or the shared deadline passes. */
|
|
202
|
+
async awaitReadinessCheck(container, check, deadline) {
|
|
203
|
+
const port = check.port ?? this.lunoraDefaultPort;
|
|
204
|
+
if (port === void 0) {
|
|
205
|
+
throw new Error(
|
|
206
|
+
`container "${this.lunoraName}": readyOn check "${check.path}" has no port — set the check's \`port\` or the container \`defaultPort\`.`
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
const expectedStatus = check.status ?? 200;
|
|
210
|
+
const path = check.path.startsWith("/") ? check.path : `/${check.path}`;
|
|
211
|
+
const tcpPort = container.getTcpPort(port);
|
|
212
|
+
for (; ; ) {
|
|
213
|
+
try {
|
|
214
|
+
const response = await tcpPort.fetch(`http://container${path}`);
|
|
215
|
+
if (response.status === expectedStatus) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
} catch {
|
|
219
|
+
}
|
|
220
|
+
if (Date.now() >= deadline) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
`container "${this.lunoraName}": readiness check "${check.path}" (port ${String(port)}) did not return ${String(expectedStatus)} within ${String(READINESS_TIMEOUT_MS)}ms`
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
await new Promise((resolve) => {
|
|
226
|
+
setTimeout(resolve, READINESS_POLL_INTERVAL_MS);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
112
230
|
/**
|
|
113
231
|
* Best-effort push of `envelope` into the root ShardDO's log buffer so it
|
|
114
232
|
* also appears in the Studio Logs panel (the terminal already has it via
|
|
@@ -135,4 +253,4 @@ class LunoraContainer extends Container {
|
|
|
135
253
|
}
|
|
136
254
|
}
|
|
137
255
|
|
|
138
|
-
export { LunoraContainer
|
|
256
|
+
export { LunoraContainer };
|
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { a as ContainerConfig, C as ContainerDefinition, b as ContainerImageSource, N as NormalizedContainerImage } from "./packem_shared/types.d-
|
|
2
|
-
export type { B as BuildImageSource, c as ContainerInstanceType, d as
|
|
1
|
+
import { a as ContainerConfig, C as ContainerDefinition, b as ContainerImageSource, N as NormalizedContainerImage } from "./packem_shared/types.d-DAOLRiZQ.mjs";
|
|
2
|
+
export type { B as BuildImageSource, c as ContainerInstanceType, d as ContainerReadinessCheck, e as ContainerRollout, f as CustomContainerInstanceType, g as NamedContainerInstanceType, R as RegistryImageSource } from "./packem_shared/types.d-DAOLRiZQ.mjs";
|
|
3
3
|
/**
|
|
4
4
|
* The `ctx.containers` action surface: typed handles over the `CONTAINER_*`
|
|
5
5
|
* Durable Object namespace bindings the config layer reconciles.
|
|
@@ -23,13 +23,25 @@ interface ContainerStartOptions {
|
|
|
23
23
|
/** A container instance's runtime state, as returned by `getState()`. Structural — the platform adds fields over time. */
|
|
24
24
|
interface ContainerInstanceState {
|
|
25
25
|
[key: string]: unknown;
|
|
26
|
+
/** Process exit code, present once the instance has `stopped_with_code`. */
|
|
27
|
+
exitCode?: number;
|
|
28
|
+
/** Epoch-ms of the last state transition. */
|
|
26
29
|
lastChange?: number;
|
|
30
|
+
/** Lifecycle status. Widening union — Cloudflare adds values over time. */
|
|
31
|
+
status?: "healthy" | "running" | "stopped" | "stopped_with_code" | "stopping";
|
|
27
32
|
}
|
|
28
|
-
/** What a handle needs from a Durable Object stub — `fetch` plus the optional lifecycle RPCs the container DO exposes. */
|
|
33
|
+
/** What a handle needs from a Durable Object stub — `fetch` plus the optional lifecycle/egress RPCs the container DO exposes. */
|
|
29
34
|
interface ContainerStubLike {
|
|
35
|
+
allowHost?: (hostname: string) => Promise<void>;
|
|
36
|
+
denyHost?: (hostname: string) => Promise<void>;
|
|
30
37
|
destroy?: () => Promise<void>;
|
|
31
38
|
fetch: (input: Request) => Promise<Response>;
|
|
32
39
|
getState?: () => Promise<ContainerInstanceState>;
|
|
40
|
+
removeAllowedHost?: (hostname: string) => Promise<void>;
|
|
41
|
+
removeDeniedHost?: (hostname: string) => Promise<void>;
|
|
42
|
+
renewActivityTimeout?: () => Promise<void>;
|
|
43
|
+
setAllowedHosts?: (hosts: string[]) => Promise<void>;
|
|
44
|
+
setDeniedHosts?: (hosts: string[]) => Promise<void>;
|
|
33
45
|
start?: (options?: ContainerStartOptions) => Promise<void>;
|
|
34
46
|
stop?: (signal?: number | string) => Promise<void>;
|
|
35
47
|
}
|
|
@@ -57,6 +69,15 @@ interface ContainerHandle {
|
|
|
57
69
|
* `Request`/URL passes through unchanged.
|
|
58
70
|
*/
|
|
59
71
|
fetch: (input: Request | string, init?: RequestInit) => Promise<Response>;
|
|
72
|
+
/**
|
|
73
|
+
* Return a handle that routes every request to `targetPort` on the
|
|
74
|
+
* container instead of the definition's `defaultPort` — for multi-port
|
|
75
|
+
* containers (declare the ports in `requiredPorts`). Sets the
|
|
76
|
+
* `cf-container-target-port` header the way `@cloudflare/containers`'
|
|
77
|
+
* `switchPort` does, so it composes with `.get()`, `.any()`, and `.pool()`:
|
|
78
|
+
* `ctx.containers.app.get("u1").port(9090).fetch("/admin")`.
|
|
79
|
+
*/
|
|
80
|
+
port: (targetPort: number) => ContainerHandle;
|
|
60
81
|
}
|
|
61
82
|
/**
|
|
62
83
|
* A handle on a *named* instance (from `.get(name)`) — `fetch` plus explicit
|
|
@@ -68,13 +89,48 @@ interface ContainerHandle {
|
|
|
68
89
|
interface ContainerInstanceHandle extends ContainerHandle {
|
|
69
90
|
/** Stop and discard the instance (its ephemeral disk is lost). */
|
|
70
91
|
destroy: () => Promise<void>;
|
|
92
|
+
/**
|
|
93
|
+
* Adjust this instance's egress allow/deny lists at runtime — the dynamic
|
|
94
|
+
* counterpart to the static `allowedHosts`/`deniedHosts` config. Useful for
|
|
95
|
+
* per-tenant egress policy. Requires the worker to export `ContainerProxy`
|
|
96
|
+
* (codegen re-exports it from the generated container file whenever any
|
|
97
|
+
* container is defined, so the runtime controls always work).
|
|
98
|
+
*/
|
|
99
|
+
egress: ContainerEgressControls;
|
|
71
100
|
/** Read the instance's current runtime state. */
|
|
72
101
|
getState: () => Promise<ContainerInstanceState>;
|
|
102
|
+
/**
|
|
103
|
+
* Reset the instance's `sleepAfter` idle timer. The platform renews it on
|
|
104
|
+
* each request automatically, but WebSocket message activity does not yet
|
|
105
|
+
* renew it (cloudflare/containers#147) — call this on inbound WS traffic to
|
|
106
|
+
* keep a busy socket's container awake.
|
|
107
|
+
*/
|
|
108
|
+
renewActivityTimeout: () => Promise<void>;
|
|
73
109
|
/** Explicitly start the instance, optionally with per-instance env/entrypoint. */
|
|
74
110
|
start: (options?: ContainerStartOptions) => Promise<void>;
|
|
75
111
|
/** Stop the instance (optionally with a signal); it can start again on the next request. */
|
|
76
112
|
stop: (signal?: number | string) => Promise<void>;
|
|
77
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* Runtime egress-firewall controls for a named instance (`handle.egress.*`).
|
|
116
|
+
* Each maps to the corresponding `@cloudflare/containers` `Container` RPC, so
|
|
117
|
+
* an app can tighten or relax a single instance's allowed/denied hosts after
|
|
118
|
+
* start without redeploying.
|
|
119
|
+
*/
|
|
120
|
+
interface ContainerEgressControls {
|
|
121
|
+
/** Add one hostname (or glob) to the allow-list. */
|
|
122
|
+
allow: (hostname: string) => Promise<void>;
|
|
123
|
+
/** Add one hostname (or glob) to the deny-list. */
|
|
124
|
+
deny: (hostname: string) => Promise<void>;
|
|
125
|
+
/** Remove one hostname from the allow-list. */
|
|
126
|
+
removeAllowed: (hostname: string) => Promise<void>;
|
|
127
|
+
/** Remove one hostname from the deny-list. */
|
|
128
|
+
removeDenied: (hostname: string) => Promise<void>;
|
|
129
|
+
/** Replace the entire allow-list. */
|
|
130
|
+
setAllowed: (hosts: ReadonlyArray<string>) => Promise<void>;
|
|
131
|
+
/** Replace the entire deny-list. */
|
|
132
|
+
setDenied: (hosts: ReadonlyArray<string>) => Promise<void>;
|
|
133
|
+
}
|
|
78
134
|
/** The per-definition accessor exposed as `ctx.containers.<exportName>`. */
|
|
79
135
|
interface ContainerAccessor {
|
|
80
136
|
/**
|
|
@@ -197,4 +253,4 @@ declare const isContainerDefinition: (value: unknown) => value is ContainerDefin
|
|
|
197
253
|
* credential it was promised yields far worse errors downstream.
|
|
198
254
|
*/
|
|
199
255
|
declare const resolveContainerEnvVariables: (definition: ContainerDefinition, workerEnv: Record<string, unknown>, exportName?: string) => Record<string, string>;
|
|
200
|
-
export { type ContainerAccessor, type ContainerBindingSpec, type ContainerConfig, type ContainerDefinition, type ContainerHandle, type ContainerImageSource, type ContainerInstanceHandle, type ContainerInstanceState, type ContainerNamespaceLike, type ContainerStartOptions, type ContainerTestHandler, type DurableObjectJurisdiction, type NormalizedContainerImage, type PoolOptions, containerBindingName, containerBuildTag, containerClassName, createContainerContext, createContainerTestContext, defineContainer, isContainerDefinition, normalizeContainerImage, resolveContainerEnvVariables as resolveContainerEnvVars };
|
|
256
|
+
export { type ContainerAccessor, type ContainerBindingSpec, type ContainerConfig, type ContainerDefinition, type ContainerEgressControls, type ContainerHandle, type ContainerImageSource, type ContainerInstanceHandle, type ContainerInstanceState, type ContainerNamespaceLike, type ContainerStartOptions, type ContainerTestHandler, type DurableObjectJurisdiction, type NormalizedContainerImage, type PoolOptions, containerBindingName, containerBuildTag, containerClassName, createContainerContext, createContainerTestContext, defineContainer, isContainerDefinition, normalizeContainerImage, resolveContainerEnvVariables as resolveContainerEnvVars };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { a as ContainerConfig, C as ContainerDefinition, b as ContainerImageSource, N as NormalizedContainerImage } from "./packem_shared/types.d-
|
|
2
|
-
export type { B as BuildImageSource, c as ContainerInstanceType, d as
|
|
1
|
+
import { a as ContainerConfig, C as ContainerDefinition, b as ContainerImageSource, N as NormalizedContainerImage } from "./packem_shared/types.d-DAOLRiZQ.js";
|
|
2
|
+
export type { B as BuildImageSource, c as ContainerInstanceType, d as ContainerReadinessCheck, e as ContainerRollout, f as CustomContainerInstanceType, g as NamedContainerInstanceType, R as RegistryImageSource } from "./packem_shared/types.d-DAOLRiZQ.js";
|
|
3
3
|
/**
|
|
4
4
|
* The `ctx.containers` action surface: typed handles over the `CONTAINER_*`
|
|
5
5
|
* Durable Object namespace bindings the config layer reconciles.
|
|
@@ -23,13 +23,25 @@ interface ContainerStartOptions {
|
|
|
23
23
|
/** A container instance's runtime state, as returned by `getState()`. Structural — the platform adds fields over time. */
|
|
24
24
|
interface ContainerInstanceState {
|
|
25
25
|
[key: string]: unknown;
|
|
26
|
+
/** Process exit code, present once the instance has `stopped_with_code`. */
|
|
27
|
+
exitCode?: number;
|
|
28
|
+
/** Epoch-ms of the last state transition. */
|
|
26
29
|
lastChange?: number;
|
|
30
|
+
/** Lifecycle status. Widening union — Cloudflare adds values over time. */
|
|
31
|
+
status?: "healthy" | "running" | "stopped" | "stopped_with_code" | "stopping";
|
|
27
32
|
}
|
|
28
|
-
/** What a handle needs from a Durable Object stub — `fetch` plus the optional lifecycle RPCs the container DO exposes. */
|
|
33
|
+
/** What a handle needs from a Durable Object stub — `fetch` plus the optional lifecycle/egress RPCs the container DO exposes. */
|
|
29
34
|
interface ContainerStubLike {
|
|
35
|
+
allowHost?: (hostname: string) => Promise<void>;
|
|
36
|
+
denyHost?: (hostname: string) => Promise<void>;
|
|
30
37
|
destroy?: () => Promise<void>;
|
|
31
38
|
fetch: (input: Request) => Promise<Response>;
|
|
32
39
|
getState?: () => Promise<ContainerInstanceState>;
|
|
40
|
+
removeAllowedHost?: (hostname: string) => Promise<void>;
|
|
41
|
+
removeDeniedHost?: (hostname: string) => Promise<void>;
|
|
42
|
+
renewActivityTimeout?: () => Promise<void>;
|
|
43
|
+
setAllowedHosts?: (hosts: string[]) => Promise<void>;
|
|
44
|
+
setDeniedHosts?: (hosts: string[]) => Promise<void>;
|
|
33
45
|
start?: (options?: ContainerStartOptions) => Promise<void>;
|
|
34
46
|
stop?: (signal?: number | string) => Promise<void>;
|
|
35
47
|
}
|
|
@@ -57,6 +69,15 @@ interface ContainerHandle {
|
|
|
57
69
|
* `Request`/URL passes through unchanged.
|
|
58
70
|
*/
|
|
59
71
|
fetch: (input: Request | string, init?: RequestInit) => Promise<Response>;
|
|
72
|
+
/**
|
|
73
|
+
* Return a handle that routes every request to `targetPort` on the
|
|
74
|
+
* container instead of the definition's `defaultPort` — for multi-port
|
|
75
|
+
* containers (declare the ports in `requiredPorts`). Sets the
|
|
76
|
+
* `cf-container-target-port` header the way `@cloudflare/containers`'
|
|
77
|
+
* `switchPort` does, so it composes with `.get()`, `.any()`, and `.pool()`:
|
|
78
|
+
* `ctx.containers.app.get("u1").port(9090).fetch("/admin")`.
|
|
79
|
+
*/
|
|
80
|
+
port: (targetPort: number) => ContainerHandle;
|
|
60
81
|
}
|
|
61
82
|
/**
|
|
62
83
|
* A handle on a *named* instance (from `.get(name)`) — `fetch` plus explicit
|
|
@@ -68,13 +89,48 @@ interface ContainerHandle {
|
|
|
68
89
|
interface ContainerInstanceHandle extends ContainerHandle {
|
|
69
90
|
/** Stop and discard the instance (its ephemeral disk is lost). */
|
|
70
91
|
destroy: () => Promise<void>;
|
|
92
|
+
/**
|
|
93
|
+
* Adjust this instance's egress allow/deny lists at runtime — the dynamic
|
|
94
|
+
* counterpart to the static `allowedHosts`/`deniedHosts` config. Useful for
|
|
95
|
+
* per-tenant egress policy. Requires the worker to export `ContainerProxy`
|
|
96
|
+
* (codegen re-exports it from the generated container file whenever any
|
|
97
|
+
* container is defined, so the runtime controls always work).
|
|
98
|
+
*/
|
|
99
|
+
egress: ContainerEgressControls;
|
|
71
100
|
/** Read the instance's current runtime state. */
|
|
72
101
|
getState: () => Promise<ContainerInstanceState>;
|
|
102
|
+
/**
|
|
103
|
+
* Reset the instance's `sleepAfter` idle timer. The platform renews it on
|
|
104
|
+
* each request automatically, but WebSocket message activity does not yet
|
|
105
|
+
* renew it (cloudflare/containers#147) — call this on inbound WS traffic to
|
|
106
|
+
* keep a busy socket's container awake.
|
|
107
|
+
*/
|
|
108
|
+
renewActivityTimeout: () => Promise<void>;
|
|
73
109
|
/** Explicitly start the instance, optionally with per-instance env/entrypoint. */
|
|
74
110
|
start: (options?: ContainerStartOptions) => Promise<void>;
|
|
75
111
|
/** Stop the instance (optionally with a signal); it can start again on the next request. */
|
|
76
112
|
stop: (signal?: number | string) => Promise<void>;
|
|
77
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* Runtime egress-firewall controls for a named instance (`handle.egress.*`).
|
|
116
|
+
* Each maps to the corresponding `@cloudflare/containers` `Container` RPC, so
|
|
117
|
+
* an app can tighten or relax a single instance's allowed/denied hosts after
|
|
118
|
+
* start without redeploying.
|
|
119
|
+
*/
|
|
120
|
+
interface ContainerEgressControls {
|
|
121
|
+
/** Add one hostname (or glob) to the allow-list. */
|
|
122
|
+
allow: (hostname: string) => Promise<void>;
|
|
123
|
+
/** Add one hostname (or glob) to the deny-list. */
|
|
124
|
+
deny: (hostname: string) => Promise<void>;
|
|
125
|
+
/** Remove one hostname from the allow-list. */
|
|
126
|
+
removeAllowed: (hostname: string) => Promise<void>;
|
|
127
|
+
/** Remove one hostname from the deny-list. */
|
|
128
|
+
removeDenied: (hostname: string) => Promise<void>;
|
|
129
|
+
/** Replace the entire allow-list. */
|
|
130
|
+
setAllowed: (hosts: ReadonlyArray<string>) => Promise<void>;
|
|
131
|
+
/** Replace the entire deny-list. */
|
|
132
|
+
setDenied: (hosts: ReadonlyArray<string>) => Promise<void>;
|
|
133
|
+
}
|
|
78
134
|
/** The per-definition accessor exposed as `ctx.containers.<exportName>`. */
|
|
79
135
|
interface ContainerAccessor {
|
|
80
136
|
/**
|
|
@@ -197,4 +253,4 @@ declare const isContainerDefinition: (value: unknown) => value is ContainerDefin
|
|
|
197
253
|
* credential it was promised yields far worse errors downstream.
|
|
198
254
|
*/
|
|
199
255
|
declare const resolveContainerEnvVariables: (definition: ContainerDefinition, workerEnv: Record<string, unknown>, exportName?: string) => Record<string, string>;
|
|
200
|
-
export { type ContainerAccessor, type ContainerBindingSpec, type ContainerConfig, type ContainerDefinition, type ContainerHandle, type ContainerImageSource, type ContainerInstanceHandle, type ContainerInstanceState, type ContainerNamespaceLike, type ContainerStartOptions, type ContainerTestHandler, type DurableObjectJurisdiction, type NormalizedContainerImage, type PoolOptions, containerBindingName, containerBuildTag, containerClassName, createContainerContext, createContainerTestContext, defineContainer, isContainerDefinition, normalizeContainerImage, resolveContainerEnvVariables as resolveContainerEnvVars };
|
|
256
|
+
export { type ContainerAccessor, type ContainerBindingSpec, type ContainerConfig, type ContainerDefinition, type ContainerEgressControls, type ContainerHandle, type ContainerImageSource, type ContainerInstanceHandle, type ContainerInstanceState, type ContainerNamespaceLike, type ContainerStartOptions, type ContainerTestHandler, type DurableObjectJurisdiction, type NormalizedContainerImage, type PoolOptions, containerBindingName, containerBuildTag, containerClassName, createContainerContext, createContainerTestContext, defineContainer, isContainerDefinition, normalizeContainerImage, resolveContainerEnvVariables as resolveContainerEnvVars };
|
package/dist/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { createContainerContext, createContainerTestContext } from './packem_shared/createContainerContext-
|
|
2
|
-
export { containerBindingName, containerBuildTag, containerClassName, defineContainer, isContainerDefinition, normalizeContainerImage, resolveContainerEnvVars } from './packem_shared/containerBindingName-
|
|
1
|
+
export { createContainerContext, createContainerTestContext } from './packem_shared/createContainerContext-DBp8oJOr.mjs';
|
|
2
|
+
export { containerBindingName, containerBuildTag, containerClassName, defineContainer, isContainerDefinition, normalizeContainerImage, resolveContainerEnvVars } from './packem_shared/containerBindingName-C7Lic2ET.mjs';
|
package/dist/packem_shared/{containerBindingName-BGdSdFNA.mjs → containerBindingName-C7Lic2ET.mjs}
RENAMED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
const NAMED_INSTANCE_TYPES = /* @__PURE__ */ new Set(["basic", "lite", "standard-1", "standard-2", "standard-3", "standard-4"]);
|
|
2
2
|
const ENV_NAME_PATTERN = /^[A-Z_]\w*$/i;
|
|
3
3
|
const SLEEP_AFTER_PATTERN = /^\d+[smh]$/;
|
|
4
|
+
const DURATION_UNIT_SECONDS = { h: 3600, m: 60, s: 1 };
|
|
5
|
+
const parseDurationSeconds = (duration) => {
|
|
6
|
+
if (typeof duration === "number") {
|
|
7
|
+
return Math.floor(duration);
|
|
8
|
+
}
|
|
9
|
+
const match = SLEEP_AFTER_PATTERN.exec(duration);
|
|
10
|
+
if (match === null) {
|
|
11
|
+
throw new TypeError(`Invalid duration "${duration}" — expected a number of seconds or "<n>[smh]"`);
|
|
12
|
+
}
|
|
13
|
+
return Number(duration.slice(0, -1)) * (DURATION_UNIT_SECONDS[duration.slice(-1)] ?? 1);
|
|
14
|
+
};
|
|
4
15
|
const basename = (path) => {
|
|
5
16
|
const trimmed = path.endsWith("/") ? path.slice(0, -1) : path;
|
|
6
17
|
const separatorIndex = trimmed.lastIndexOf("/");
|
|
@@ -72,10 +83,81 @@ const assertValidEnvAndSecrets = (config) => {
|
|
|
72
83
|
}
|
|
73
84
|
}
|
|
74
85
|
};
|
|
86
|
+
const assertValidPort = (port, field) => {
|
|
87
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
88
|
+
throw new TypeError(`defineContainer: \`${field}\` must be an integer in 1–65535 (got ${String(port)})`);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
const assertValidReadyOnChecks = (config) => {
|
|
92
|
+
for (const check of config.readyOn ?? []) {
|
|
93
|
+
if (typeof check.path !== "string" || check.path.trim().length === 0) {
|
|
94
|
+
throw new TypeError("defineContainer: `readyOn[].path` must be a non-empty HTTP path string");
|
|
95
|
+
}
|
|
96
|
+
if (check.path !== check.path.trim()) {
|
|
97
|
+
throw new TypeError("defineContainer: `readyOn[].path` must not have leading or trailing whitespace");
|
|
98
|
+
}
|
|
99
|
+
if (check.port !== void 0) {
|
|
100
|
+
assertValidPort(check.port, "readyOn[].port");
|
|
101
|
+
}
|
|
102
|
+
if (check.status !== void 0 && (!Number.isInteger(check.status) || check.status < 100 || check.status > 599)) {
|
|
103
|
+
throw new TypeError(`defineContainer: \`readyOn[].status\` must be an HTTP status code in 100–599 (got ${String(check.status)})`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const assertValidHardTimeout = (hardTimeout) => {
|
|
108
|
+
if (hardTimeout === void 0) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (typeof hardTimeout === "string") {
|
|
112
|
+
if (!SLEEP_AFTER_PATTERN.test(hardTimeout)) {
|
|
113
|
+
throw new TypeError(
|
|
114
|
+
`defineContainer: \`hardTimeout\` string "${hardTimeout}" must be a number of seconds followed by a unit, e.g. "30s", "5m", or "1h"`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
} else if (!Number.isInteger(hardTimeout) || hardTimeout < 1) {
|
|
118
|
+
throw new TypeError(
|
|
119
|
+
`defineContainer: \`hardTimeout\` must be a positive integer number of seconds or a duration string like "5m" (got ${String(hardTimeout)})`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
const assertValidEgressFields = (config) => {
|
|
124
|
+
for (const field of ["allowedHosts", "deniedHosts"]) {
|
|
125
|
+
const hosts = config[field];
|
|
126
|
+
if (hosts?.some((host) => typeof host !== "string" || host.trim().length === 0)) {
|
|
127
|
+
throw new TypeError(`defineContainer: \`${field}\` must be an array of non-empty hostname patterns`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (config.interceptHttps !== void 0 && typeof config.interceptHttps !== "boolean") {
|
|
131
|
+
throw new TypeError("defineContainer: `interceptHttps` must be a boolean, or omitted");
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
const assertValidContainerRuntimeFields = (config) => {
|
|
135
|
+
if (config.requiredPorts !== void 0) {
|
|
136
|
+
if (config.requiredPorts.length === 0) {
|
|
137
|
+
throw new TypeError("defineContainer: `requiredPorts` must be a non-empty array of ports, or omitted");
|
|
138
|
+
}
|
|
139
|
+
for (const port of config.requiredPorts) {
|
|
140
|
+
assertValidPort(port, "requiredPorts[]");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (config.entrypoint !== void 0 && (config.entrypoint.length === 0 || config.entrypoint.some((part) => typeof part !== "string" || part.trim().length === 0))) {
|
|
144
|
+
throw new TypeError("defineContainer: `entrypoint` must be a non-empty array of non-empty strings, or omitted");
|
|
145
|
+
}
|
|
146
|
+
assertValidEgressFields(config);
|
|
147
|
+
if (config.pingEndpoint !== void 0 && (typeof config.pingEndpoint !== "string" || config.pingEndpoint.trim().length === 0)) {
|
|
148
|
+
throw new TypeError("defineContainer: `pingEndpoint` must be a non-empty path string");
|
|
149
|
+
}
|
|
150
|
+
for (const [key, value] of Object.entries(config.labels ?? {})) {
|
|
151
|
+
if (key.trim().length === 0 || typeof value !== "string") {
|
|
152
|
+
throw new TypeError("defineContainer: `labels` must be a record of non-empty keys to string values");
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
assertValidReadyOnChecks(config);
|
|
156
|
+
};
|
|
75
157
|
const defineContainer = (config) => {
|
|
76
158
|
assertValidImage(config.image);
|
|
77
|
-
if (config.defaultPort !== void 0
|
|
78
|
-
|
|
159
|
+
if (config.defaultPort !== void 0) {
|
|
160
|
+
assertValidPort(config.defaultPort, "defaultPort");
|
|
79
161
|
}
|
|
80
162
|
const stepPercentage = config.rollout?.stepPercentage;
|
|
81
163
|
if (stepPercentage !== void 0 && (!Number.isInteger(stepPercentage) || stepPercentage < 1 || stepPercentage > 100)) {
|
|
@@ -94,7 +176,9 @@ const defineContainer = (config) => {
|
|
|
94
176
|
`defineContainer: \`sleepAfter\` string "${config.sleepAfter}" must be a number of seconds followed by a unit, e.g. "30s", "5m", or "1h"`
|
|
95
177
|
);
|
|
96
178
|
}
|
|
179
|
+
assertValidHardTimeout(config.hardTimeout);
|
|
97
180
|
assertValidEnvAndSecrets(config);
|
|
181
|
+
assertValidContainerRuntimeFields(config);
|
|
98
182
|
return { ...config, isLunoraContainer: true };
|
|
99
183
|
};
|
|
100
184
|
const isContainerDefinition = (value) => typeof value === "object" && value !== null && value.isLunoraContainer === true;
|
|
@@ -113,4 +197,4 @@ const resolveContainerEnvVariables = (definition, workerEnv, exportName) => {
|
|
|
113
197
|
return resolved;
|
|
114
198
|
};
|
|
115
199
|
|
|
116
|
-
export { containerBindingName, containerBuildTag, containerClassName, defineContainer, isContainerDefinition, normalizeContainerImage, resolveContainerEnvVariables as resolveContainerEnvVars };
|
|
200
|
+
export { containerBindingName, containerBuildTag, containerClassName, defineContainer, isContainerDefinition, normalizeContainerImage, parseDurationSeconds, resolveContainerEnvVariables as resolveContainerEnvVars };
|
|
@@ -11,17 +11,21 @@ const applyJurisdiction = (namespace, jurisdiction) => {
|
|
|
11
11
|
};
|
|
12
12
|
const DEFAULT_POOL_SIZE = 3;
|
|
13
13
|
const DEFAULT_MAX_BACKOFF_MS = 3e4;
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
const TARGET_PORT_HEADER = "cf-container-target-port";
|
|
15
|
+
const toRequest = (input, init, port) => {
|
|
16
|
+
const request = typeof input === "string" && input.startsWith("/") ? new Request(`http://container${input}`, init) : new Request(input, init);
|
|
17
|
+
if (port !== void 0) {
|
|
18
|
+
request.headers.set(TARGET_PORT_HEADER, String(port));
|
|
17
19
|
}
|
|
18
|
-
return
|
|
20
|
+
return request;
|
|
19
21
|
};
|
|
20
|
-
const
|
|
22
|
+
const sendingHandle = (send, port) => {
|
|
21
23
|
return {
|
|
22
|
-
fetch: async (input, init) =>
|
|
24
|
+
fetch: async (input, init) => send(toRequest(input, init, port)),
|
|
25
|
+
port: (targetPort) => sendingHandle(send, targetPort)
|
|
23
26
|
};
|
|
24
27
|
};
|
|
28
|
+
const handleFor = (namespace, instanceName) => sendingHandle(async (request) => namespace.get(namespace.idFromName(instanceName)).fetch(request));
|
|
25
29
|
const lifecycleCall = async (stub, method, binding, argument) => {
|
|
26
30
|
const rpc = stub[method];
|
|
27
31
|
if (typeof rpc !== "function") {
|
|
@@ -29,12 +33,24 @@ const lifecycleCall = async (stub, method, binding, argument) => {
|
|
|
29
33
|
}
|
|
30
34
|
return rpc(argument);
|
|
31
35
|
};
|
|
36
|
+
const egressControlsFor = (stub, binding) => {
|
|
37
|
+
return {
|
|
38
|
+
allow: async (hostname) => lifecycleCall(stub(), "allowHost", binding, hostname),
|
|
39
|
+
deny: async (hostname) => lifecycleCall(stub(), "denyHost", binding, hostname),
|
|
40
|
+
removeAllowed: async (hostname) => lifecycleCall(stub(), "removeAllowedHost", binding, hostname),
|
|
41
|
+
removeDenied: async (hostname) => lifecycleCall(stub(), "removeDeniedHost", binding, hostname),
|
|
42
|
+
setAllowed: async (hosts) => lifecycleCall(stub(), "setAllowedHosts", binding, [...hosts]),
|
|
43
|
+
setDenied: async (hosts) => lifecycleCall(stub(), "setDeniedHosts", binding, [...hosts])
|
|
44
|
+
};
|
|
45
|
+
};
|
|
32
46
|
const instanceHandleFor = (namespace, spec, instanceName) => {
|
|
33
47
|
const stub = () => namespace.get(namespace.idFromName(instanceName));
|
|
34
48
|
return {
|
|
49
|
+
...sendingHandle(async (request) => stub().fetch(request)),
|
|
35
50
|
destroy: async () => lifecycleCall(stub(), "destroy", spec.binding),
|
|
36
|
-
|
|
51
|
+
egress: egressControlsFor(stub, spec.binding),
|
|
37
52
|
getState: async () => lifecycleCall(stub(), "getState", spec.binding),
|
|
53
|
+
renewActivityTimeout: async () => lifecycleCall(stub(), "renewActivityTimeout", spec.binding),
|
|
38
54
|
start: async (options) => lifecycleCall(stub(), "start", spec.binding, options),
|
|
39
55
|
stop: async (signal) => lifecycleCall(stub(), "stop", spec.binding, signal)
|
|
40
56
|
};
|
|
@@ -52,7 +68,7 @@ const sleep = async (ms) => {
|
|
|
52
68
|
});
|
|
53
69
|
};
|
|
54
70
|
const retryOnServerError = (response) => response.status >= 500;
|
|
55
|
-
const poolHandleFor = (namespace, spec, options = {}) => {
|
|
71
|
+
const poolHandleFor = (namespace, spec, options = {}, port) => {
|
|
56
72
|
const size = options.size ?? spec.maxInstances ?? DEFAULT_POOL_SIZE;
|
|
57
73
|
const attempts = Math.max(1, options.attempts ?? 3);
|
|
58
74
|
const baseBackoff = options.backoffMs ?? 100;
|
|
@@ -65,7 +81,7 @@ const poolHandleFor = (namespace, spec, options = {}) => {
|
|
|
65
81
|
if (attempt > 0) {
|
|
66
82
|
await sleep(Math.min(baseBackoff * 2 ** (attempt - 1), maxBackoff));
|
|
67
83
|
}
|
|
68
|
-
const request = toRequest(input, init);
|
|
84
|
+
const request = toRequest(input, init, port);
|
|
69
85
|
try {
|
|
70
86
|
const response = await namespace.get(namespace.idFromName(randomPoolName(size))).fetch(request);
|
|
71
87
|
if (attempt === attempts - 1 || !shouldRetry(response)) {
|
|
@@ -76,7 +92,8 @@ const poolHandleFor = (namespace, spec, options = {}) => {
|
|
|
76
92
|
}
|
|
77
93
|
}
|
|
78
94
|
throw lastError instanceof Error ? lastError : new Error(`ctx.containers.${spec.exportName}.pool(): all ${String(attempts)} attempts failed`);
|
|
79
|
-
}
|
|
95
|
+
},
|
|
96
|
+
port: (targetPort) => poolHandleFor(namespace, spec, options, targetPort)
|
|
80
97
|
};
|
|
81
98
|
};
|
|
82
99
|
const accessorFor = (namespace, spec) => {
|
|
@@ -102,29 +119,37 @@ const createContainerContext = (env, specs, jurisdiction) => {
|
|
|
102
119
|
}
|
|
103
120
|
return containers;
|
|
104
121
|
};
|
|
122
|
+
const testNamespaceFor = (handler) => {
|
|
123
|
+
const stubFor = (name) => {
|
|
124
|
+
return {
|
|
125
|
+
allowHost: () => Promise.resolve(),
|
|
126
|
+
denyHost: () => Promise.resolve(),
|
|
127
|
+
destroy: () => Promise.resolve(),
|
|
128
|
+
fetch: (request) => Promise.resolve(handler(request, { name })),
|
|
129
|
+
getState: () => Promise.resolve({ lastChange: 0 }),
|
|
130
|
+
removeAllowedHost: () => Promise.resolve(),
|
|
131
|
+
removeDeniedHost: () => Promise.resolve(),
|
|
132
|
+
renewActivityTimeout: () => Promise.resolve(),
|
|
133
|
+
setAllowedHosts: () => Promise.resolve(),
|
|
134
|
+
setDeniedHosts: () => Promise.resolve(),
|
|
135
|
+
start: () => Promise.resolve(),
|
|
136
|
+
stop: () => Promise.resolve()
|
|
137
|
+
};
|
|
138
|
+
};
|
|
139
|
+
return { get: (id) => stubFor(String(id)), idFromName: (name) => name };
|
|
140
|
+
};
|
|
105
141
|
const createContainerTestContext = (handlers) => {
|
|
106
142
|
const containers = {};
|
|
107
143
|
for (const [exportName, handler] of Object.entries(handlers)) {
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
fetch: async (input, init) => handler(toRequest(input, init), { name: instanceName })
|
|
111
|
-
};
|
|
112
|
-
};
|
|
113
|
-
const testInstanceHandleFor = (instanceName) => {
|
|
114
|
-
return {
|
|
115
|
-
...testHandleFor(instanceName),
|
|
116
|
-
destroy: () => Promise.resolve(),
|
|
117
|
-
getState: () => Promise.resolve({ lastChange: 0 }),
|
|
118
|
-
start: () => Promise.resolve(),
|
|
119
|
-
stop: () => Promise.resolve()
|
|
120
|
-
};
|
|
121
|
-
};
|
|
144
|
+
const namespace = testNamespaceFor(handler);
|
|
145
|
+
const spec = { binding: `CONTAINER_${exportName.toUpperCase()}`};
|
|
122
146
|
containers[exportName] = {
|
|
123
|
-
any
|
|
124
|
-
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
147
|
+
// `.any()`/`.pool()` route to a fixed `pool-0` so the handler's
|
|
148
|
+
// `instance.name` is deterministic under test; the double doesn't
|
|
149
|
+
// simulate the random-pick or retry/backoff the real pool does.
|
|
150
|
+
any: () => handleFor(namespace, "pool-0"),
|
|
151
|
+
get: (name) => instanceHandleFor(namespace, spec, name),
|
|
152
|
+
pool: () => handleFor(namespace, "pool-0")
|
|
128
153
|
};
|
|
129
154
|
}
|
|
130
155
|
return containers;
|
|
@@ -52,7 +52,37 @@ interface BuildImageSource {
|
|
|
52
52
|
* the Dockerfile itself — while `{ registry }` is a pre-built image reference.
|
|
53
53
|
*/
|
|
54
54
|
type ContainerImageSource = BuildImageSource | RegistryImageSource | string;
|
|
55
|
+
/**
|
|
56
|
+
* An application-level readiness probe that gates request proxying. Layered on
|
|
57
|
+
* top of the platform's own port/`pingEndpoint` health wait, it lets you hold
|
|
58
|
+
* traffic back until the app inside the container is *functionally* ready —
|
|
59
|
+
* migrations applied, caches warmed — which an open-port check can't see.
|
|
60
|
+
*
|
|
61
|
+
* Declarative on purpose: a `defineContainer` value stays pure data (no handler
|
|
62
|
+
* functions), so codegen and the config layer can read it without evaluating
|
|
63
|
+
* code. (Upstream cloudflare/containers#188 expresses the same idea as handler
|
|
64
|
+
* functions; the Lunora config is data-only, so it's modelled as descriptors.)
|
|
65
|
+
*/
|
|
66
|
+
interface ContainerReadinessCheck {
|
|
67
|
+
/** HTTP path probed on the container, e.g. `"/ready"` (a leading slash is optional). */
|
|
68
|
+
path: string;
|
|
69
|
+
/** Port to probe. Defaults to {@link ContainerConfig.defaultPort}. */
|
|
70
|
+
port?: number;
|
|
71
|
+
/** HTTP status that means "ready". Defaults to `200`. */
|
|
72
|
+
status?: number;
|
|
73
|
+
}
|
|
55
74
|
interface ContainerConfig {
|
|
75
|
+
/**
|
|
76
|
+
* Hostnames the container may reach **even when {@link ContainerConfig.enableInternet}
|
|
77
|
+
* is `false`** — an egress allow-list (Cloudflare's `allowedHosts`). Glob
|
|
78
|
+
* patterns like `*.stripe.com` are supported. Pair with `enableInternet:
|
|
79
|
+
* false` to deny all egress except these hosts (the firewall pattern
|
|
80
|
+
* upstream issue cloudflare/containers#30 asked for). The interception path
|
|
81
|
+
* needs the `ContainerProxy` worker entrypoint, which codegen re-exports
|
|
82
|
+
* from the generated container file automatically; the named-instance
|
|
83
|
+
* handle's `egress` controls adjust the lists at runtime.
|
|
84
|
+
*/
|
|
85
|
+
allowedHosts?: ReadonlyArray<string>;
|
|
56
86
|
/**
|
|
57
87
|
* Build-time variables for a Dockerfile/Railpack image — wrangler's
|
|
58
88
|
* `image_vars` (equivalent to `docker build --build-arg`). For *runtime*
|
|
@@ -62,21 +92,46 @@ interface ContainerConfig {
|
|
|
62
92
|
buildArgs?: Readonly<Record<string, string>>;
|
|
63
93
|
/**
|
|
64
94
|
* The port the container listens on. Worker → container requests target
|
|
65
|
-
* this port. Locally the Dockerfile must also `EXPOSE` it.
|
|
95
|
+
* this port. Locally the Dockerfile must also `EXPOSE` it. For a
|
|
96
|
+
* multi-port container also declare {@link ContainerConfig.requiredPorts}
|
|
97
|
+
* and route per request with the handle's `.port(n)`.
|
|
66
98
|
*/
|
|
67
99
|
defaultPort?: number;
|
|
68
100
|
/**
|
|
101
|
+
* Hostnames the container may **never** reach — an egress deny-list
|
|
102
|
+
* (Cloudflare's `deniedHosts`). Overrides everything else, including
|
|
103
|
+
* `enableInternet: true` and {@link ContainerConfig.allowedHosts}. Glob
|
|
104
|
+
* patterns like `*.evil.com` are supported.
|
|
105
|
+
*/
|
|
106
|
+
deniedHosts?: ReadonlyArray<string>;
|
|
107
|
+
/**
|
|
69
108
|
* Whether the container may open outbound internet connections. Defaults
|
|
70
109
|
* to `true` — the platform default. Note that container egress is billed
|
|
71
|
-
* per GB by Cloudflare.
|
|
110
|
+
* per GB by Cloudflare. Combine with {@link ContainerConfig.allowedHosts} /
|
|
111
|
+
* {@link ContainerConfig.deniedHosts} for a precise egress firewall.
|
|
72
112
|
*/
|
|
73
113
|
enableInternet?: boolean;
|
|
74
114
|
/**
|
|
115
|
+
* Default command to run inside the container, overriding the image's
|
|
116
|
+
* `ENTRYPOINT`/`CMD` (Cloudflare's `entrypoint`). A per-start override is
|
|
117
|
+
* still available via the named-instance handle's `start({ entrypoint })`.
|
|
118
|
+
*/
|
|
119
|
+
entrypoint?: ReadonlyArray<string>;
|
|
120
|
+
/**
|
|
75
121
|
* Static environment variables passed to the container on every start.
|
|
76
122
|
* For secret values use {@link ContainerConfig.secrets} instead so they
|
|
77
123
|
* flow through Worker Secrets rather than source code.
|
|
78
124
|
*/
|
|
79
125
|
env?: Readonly<Record<string, string>>;
|
|
126
|
+
/**
|
|
127
|
+
* Hard cap on how long an instance may run, measured from start regardless
|
|
128
|
+
* of activity — a runaway-cost backstop on top of the idle
|
|
129
|
+
* {@link ContainerConfig.sleepAfter}. Same grammar as `sleepAfter`
|
|
130
|
+
* (`"30s"`, `"5m"`, `"1h"`, or a plain number of seconds). When it elapses,
|
|
131
|
+
* the `LunoraContainer.onHardTimeoutExpired` hook runs (default: `stop()`).
|
|
132
|
+
* (Upstream cloudflare/containers#85.)
|
|
133
|
+
*/
|
|
134
|
+
hardTimeout?: number | string;
|
|
80
135
|
/** Image source — a local Dockerfile path/directory or a registry reference. */
|
|
81
136
|
image: ContainerImageSource;
|
|
82
137
|
/**
|
|
@@ -85,6 +140,21 @@ interface ContainerConfig {
|
|
|
85
140
|
*/
|
|
86
141
|
instanceType?: ContainerInstanceType;
|
|
87
142
|
/**
|
|
143
|
+
* Intercept the container's outbound **HTTPS** traffic so the egress
|
|
144
|
+
* allow/deny lists apply to TLS connections too (Cloudflare's
|
|
145
|
+
* `interceptHttps`). Requires the image to trust the Cloudflare CA at
|
|
146
|
+
* `/etc/cloudflare/certs/cloudflare-containers-ca.crt`. Defaults to `false`
|
|
147
|
+
* (HTTP egress is gated regardless).
|
|
148
|
+
*/
|
|
149
|
+
interceptHttps?: boolean;
|
|
150
|
+
/**
|
|
151
|
+
* Key-value metadata attached to every instance for metrics/observability
|
|
152
|
+
* (Cloudflare's container `labels`), e.g. `{ tenant: "acme", env: "prod" }`.
|
|
153
|
+
* A per-start override is available via the named-instance handle's
|
|
154
|
+
* `start({ labels })`.
|
|
155
|
+
*/
|
|
156
|
+
labels?: Readonly<Record<string, string>>;
|
|
157
|
+
/**
|
|
88
158
|
* Maximum number of concurrently *running* instances. Stopped (slept)
|
|
89
159
|
* containers don't count. Also the default pool size for `.any()`.
|
|
90
160
|
*/
|
|
@@ -95,6 +165,30 @@ interface ContainerConfig {
|
|
|
95
165
|
*/
|
|
96
166
|
name?: string;
|
|
97
167
|
/**
|
|
168
|
+
* HTTP path Cloudflare polls to decide an instance is healthy
|
|
169
|
+
* (Cloudflare's `pingEndpoint`). Defaults to upstream's slash-less `"ping"`;
|
|
170
|
+
* either `"ping"` or `"/healthz"`-style paths are accepted. Set this when
|
|
171
|
+
* the container exposes its readiness check under a different route.
|
|
172
|
+
*/
|
|
173
|
+
pingEndpoint?: string;
|
|
174
|
+
/**
|
|
175
|
+
* Application-level readiness probes that gate request proxying: a
|
|
176
|
+
* `ctx.containers.<name>` fetch waits until every probe responds with its
|
|
177
|
+
* expected status before the request reaches the container — on top of the
|
|
178
|
+
* platform's port/`pingEndpoint` health wait. All probes run in parallel.
|
|
179
|
+
* Use these for readiness an open-port check can't see (migrations applied,
|
|
180
|
+
* caches warm). (Upstream cloudflare/containers#188.)
|
|
181
|
+
*/
|
|
182
|
+
readyOn?: ReadonlyArray<ContainerReadinessCheck>;
|
|
183
|
+
/**
|
|
184
|
+
* Ports the container must be listening on before it's considered ready
|
|
185
|
+
* (Cloudflare's `requiredPorts`) — for multi-port containers. Start-up
|
|
186
|
+
* waits for every listed port, and the handle's `.port(n)` routes a request
|
|
187
|
+
* to any of them; {@link ContainerConfig.defaultPort} is the target when a
|
|
188
|
+
* request doesn't pick one.
|
|
189
|
+
*/
|
|
190
|
+
requiredPorts?: ReadonlyArray<number>;
|
|
191
|
+
/**
|
|
98
192
|
* Rolling-deploy tuning. `stepPercentage` is the share of instances updated
|
|
99
193
|
* per rollout step (wrangler `rollout_step_percentage`); `gracePeriodSeconds`
|
|
100
194
|
* is how long an active instance is left running before it's eligible for
|
|
@@ -137,4 +231,4 @@ type NormalizedContainerImage = {
|
|
|
137
231
|
kind: "registry"; /** Fully-qualified image reference (wrangler `image`). */
|
|
138
232
|
reference: string;
|
|
139
233
|
};
|
|
140
|
-
export { BuildImageSource as B, ContainerDefinition as C, NormalizedContainerImage as N, RegistryImageSource as R, ContainerConfig as a, ContainerImageSource as b, ContainerInstanceType as c,
|
|
234
|
+
export { BuildImageSource as B, ContainerDefinition as C, NormalizedContainerImage as N, RegistryImageSource as R, ContainerConfig as a, ContainerImageSource as b, ContainerInstanceType as c, ContainerReadinessCheck as d, ContainerRollout as e, CustomContainerInstanceType as f, NamedContainerInstanceType as g };
|
|
@@ -52,7 +52,37 @@ interface BuildImageSource {
|
|
|
52
52
|
* the Dockerfile itself — while `{ registry }` is a pre-built image reference.
|
|
53
53
|
*/
|
|
54
54
|
type ContainerImageSource = BuildImageSource | RegistryImageSource | string;
|
|
55
|
+
/**
|
|
56
|
+
* An application-level readiness probe that gates request proxying. Layered on
|
|
57
|
+
* top of the platform's own port/`pingEndpoint` health wait, it lets you hold
|
|
58
|
+
* traffic back until the app inside the container is *functionally* ready —
|
|
59
|
+
* migrations applied, caches warmed — which an open-port check can't see.
|
|
60
|
+
*
|
|
61
|
+
* Declarative on purpose: a `defineContainer` value stays pure data (no handler
|
|
62
|
+
* functions), so codegen and the config layer can read it without evaluating
|
|
63
|
+
* code. (Upstream cloudflare/containers#188 expresses the same idea as handler
|
|
64
|
+
* functions; the Lunora config is data-only, so it's modelled as descriptors.)
|
|
65
|
+
*/
|
|
66
|
+
interface ContainerReadinessCheck {
|
|
67
|
+
/** HTTP path probed on the container, e.g. `"/ready"` (a leading slash is optional). */
|
|
68
|
+
path: string;
|
|
69
|
+
/** Port to probe. Defaults to {@link ContainerConfig.defaultPort}. */
|
|
70
|
+
port?: number;
|
|
71
|
+
/** HTTP status that means "ready". Defaults to `200`. */
|
|
72
|
+
status?: number;
|
|
73
|
+
}
|
|
55
74
|
interface ContainerConfig {
|
|
75
|
+
/**
|
|
76
|
+
* Hostnames the container may reach **even when {@link ContainerConfig.enableInternet}
|
|
77
|
+
* is `false`** — an egress allow-list (Cloudflare's `allowedHosts`). Glob
|
|
78
|
+
* patterns like `*.stripe.com` are supported. Pair with `enableInternet:
|
|
79
|
+
* false` to deny all egress except these hosts (the firewall pattern
|
|
80
|
+
* upstream issue cloudflare/containers#30 asked for). The interception path
|
|
81
|
+
* needs the `ContainerProxy` worker entrypoint, which codegen re-exports
|
|
82
|
+
* from the generated container file automatically; the named-instance
|
|
83
|
+
* handle's `egress` controls adjust the lists at runtime.
|
|
84
|
+
*/
|
|
85
|
+
allowedHosts?: ReadonlyArray<string>;
|
|
56
86
|
/**
|
|
57
87
|
* Build-time variables for a Dockerfile/Railpack image — wrangler's
|
|
58
88
|
* `image_vars` (equivalent to `docker build --build-arg`). For *runtime*
|
|
@@ -62,21 +92,46 @@ interface ContainerConfig {
|
|
|
62
92
|
buildArgs?: Readonly<Record<string, string>>;
|
|
63
93
|
/**
|
|
64
94
|
* The port the container listens on. Worker → container requests target
|
|
65
|
-
* this port. Locally the Dockerfile must also `EXPOSE` it.
|
|
95
|
+
* this port. Locally the Dockerfile must also `EXPOSE` it. For a
|
|
96
|
+
* multi-port container also declare {@link ContainerConfig.requiredPorts}
|
|
97
|
+
* and route per request with the handle's `.port(n)`.
|
|
66
98
|
*/
|
|
67
99
|
defaultPort?: number;
|
|
68
100
|
/**
|
|
101
|
+
* Hostnames the container may **never** reach — an egress deny-list
|
|
102
|
+
* (Cloudflare's `deniedHosts`). Overrides everything else, including
|
|
103
|
+
* `enableInternet: true` and {@link ContainerConfig.allowedHosts}. Glob
|
|
104
|
+
* patterns like `*.evil.com` are supported.
|
|
105
|
+
*/
|
|
106
|
+
deniedHosts?: ReadonlyArray<string>;
|
|
107
|
+
/**
|
|
69
108
|
* Whether the container may open outbound internet connections. Defaults
|
|
70
109
|
* to `true` — the platform default. Note that container egress is billed
|
|
71
|
-
* per GB by Cloudflare.
|
|
110
|
+
* per GB by Cloudflare. Combine with {@link ContainerConfig.allowedHosts} /
|
|
111
|
+
* {@link ContainerConfig.deniedHosts} for a precise egress firewall.
|
|
72
112
|
*/
|
|
73
113
|
enableInternet?: boolean;
|
|
74
114
|
/**
|
|
115
|
+
* Default command to run inside the container, overriding the image's
|
|
116
|
+
* `ENTRYPOINT`/`CMD` (Cloudflare's `entrypoint`). A per-start override is
|
|
117
|
+
* still available via the named-instance handle's `start({ entrypoint })`.
|
|
118
|
+
*/
|
|
119
|
+
entrypoint?: ReadonlyArray<string>;
|
|
120
|
+
/**
|
|
75
121
|
* Static environment variables passed to the container on every start.
|
|
76
122
|
* For secret values use {@link ContainerConfig.secrets} instead so they
|
|
77
123
|
* flow through Worker Secrets rather than source code.
|
|
78
124
|
*/
|
|
79
125
|
env?: Readonly<Record<string, string>>;
|
|
126
|
+
/**
|
|
127
|
+
* Hard cap on how long an instance may run, measured from start regardless
|
|
128
|
+
* of activity — a runaway-cost backstop on top of the idle
|
|
129
|
+
* {@link ContainerConfig.sleepAfter}. Same grammar as `sleepAfter`
|
|
130
|
+
* (`"30s"`, `"5m"`, `"1h"`, or a plain number of seconds). When it elapses,
|
|
131
|
+
* the `LunoraContainer.onHardTimeoutExpired` hook runs (default: `stop()`).
|
|
132
|
+
* (Upstream cloudflare/containers#85.)
|
|
133
|
+
*/
|
|
134
|
+
hardTimeout?: number | string;
|
|
80
135
|
/** Image source — a local Dockerfile path/directory or a registry reference. */
|
|
81
136
|
image: ContainerImageSource;
|
|
82
137
|
/**
|
|
@@ -85,6 +140,21 @@ interface ContainerConfig {
|
|
|
85
140
|
*/
|
|
86
141
|
instanceType?: ContainerInstanceType;
|
|
87
142
|
/**
|
|
143
|
+
* Intercept the container's outbound **HTTPS** traffic so the egress
|
|
144
|
+
* allow/deny lists apply to TLS connections too (Cloudflare's
|
|
145
|
+
* `interceptHttps`). Requires the image to trust the Cloudflare CA at
|
|
146
|
+
* `/etc/cloudflare/certs/cloudflare-containers-ca.crt`. Defaults to `false`
|
|
147
|
+
* (HTTP egress is gated regardless).
|
|
148
|
+
*/
|
|
149
|
+
interceptHttps?: boolean;
|
|
150
|
+
/**
|
|
151
|
+
* Key-value metadata attached to every instance for metrics/observability
|
|
152
|
+
* (Cloudflare's container `labels`), e.g. `{ tenant: "acme", env: "prod" }`.
|
|
153
|
+
* A per-start override is available via the named-instance handle's
|
|
154
|
+
* `start({ labels })`.
|
|
155
|
+
*/
|
|
156
|
+
labels?: Readonly<Record<string, string>>;
|
|
157
|
+
/**
|
|
88
158
|
* Maximum number of concurrently *running* instances. Stopped (slept)
|
|
89
159
|
* containers don't count. Also the default pool size for `.any()`.
|
|
90
160
|
*/
|
|
@@ -95,6 +165,30 @@ interface ContainerConfig {
|
|
|
95
165
|
*/
|
|
96
166
|
name?: string;
|
|
97
167
|
/**
|
|
168
|
+
* HTTP path Cloudflare polls to decide an instance is healthy
|
|
169
|
+
* (Cloudflare's `pingEndpoint`). Defaults to upstream's slash-less `"ping"`;
|
|
170
|
+
* either `"ping"` or `"/healthz"`-style paths are accepted. Set this when
|
|
171
|
+
* the container exposes its readiness check under a different route.
|
|
172
|
+
*/
|
|
173
|
+
pingEndpoint?: string;
|
|
174
|
+
/**
|
|
175
|
+
* Application-level readiness probes that gate request proxying: a
|
|
176
|
+
* `ctx.containers.<name>` fetch waits until every probe responds with its
|
|
177
|
+
* expected status before the request reaches the container — on top of the
|
|
178
|
+
* platform's port/`pingEndpoint` health wait. All probes run in parallel.
|
|
179
|
+
* Use these for readiness an open-port check can't see (migrations applied,
|
|
180
|
+
* caches warm). (Upstream cloudflare/containers#188.)
|
|
181
|
+
*/
|
|
182
|
+
readyOn?: ReadonlyArray<ContainerReadinessCheck>;
|
|
183
|
+
/**
|
|
184
|
+
* Ports the container must be listening on before it's considered ready
|
|
185
|
+
* (Cloudflare's `requiredPorts`) — for multi-port containers. Start-up
|
|
186
|
+
* waits for every listed port, and the handle's `.port(n)` routes a request
|
|
187
|
+
* to any of them; {@link ContainerConfig.defaultPort} is the target when a
|
|
188
|
+
* request doesn't pick one.
|
|
189
|
+
*/
|
|
190
|
+
requiredPorts?: ReadonlyArray<number>;
|
|
191
|
+
/**
|
|
98
192
|
* Rolling-deploy tuning. `stepPercentage` is the share of instances updated
|
|
99
193
|
* per rollout step (wrangler `rollout_step_percentage`); `gracePeriodSeconds`
|
|
100
194
|
* is how long an active instance is left running before it's eligible for
|
|
@@ -137,4 +231,4 @@ type NormalizedContainerImage = {
|
|
|
137
231
|
kind: "registry"; /** Fully-qualified image reference (wrangler `image`). */
|
|
138
232
|
reference: string;
|
|
139
233
|
};
|
|
140
|
-
export { BuildImageSource as B, ContainerDefinition as C, NormalizedContainerImage as N, RegistryImageSource as R, ContainerConfig as a, ContainerImageSource as b, ContainerInstanceType as c,
|
|
234
|
+
export { BuildImageSource as B, ContainerDefinition as C, NormalizedContainerImage as N, RegistryImageSource as R, ContainerConfig as a, ContainerImageSource as b, ContainerInstanceType as c, ContainerReadinessCheck as d, ContainerRollout as e, CustomContainerInstanceType as f, NamedContainerInstanceType as g };
|
package/package.json
CHANGED