Skip to content

Commit 33ac27b

Browse files
authored
Add sandbox container labels option (#798)
* Add sandbox container labels option * Delete PLAN.md
1 parent decb46a commit 33ac27b

6 files changed

Lines changed: 299 additions & 2 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@cloudflare/sandbox': patch
3+
---
4+
5+
Add `labels` to `SandboxOptions` so `getSandbox()` can attach Cloudflare Container labels for analytics and observability. Labels are applied on container start; updates while running apply on the next start.

packages/sandbox/README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,26 @@ export default {
9898
};
9999
```
100100

101+
## Sandbox options
102+
103+
Pass options as the third argument to `getSandbox()` to configure sandbox
104+
lifetime and container startup metadata:
105+
106+
```ts
107+
const sandbox = getSandbox(env.Sandbox, 'tenant-workspace', {
108+
sleepAfter: '30m',
109+
labels: {
110+
tenantId: 'tenant_123',
111+
workload: 'code-workspace'
112+
}
113+
});
114+
```
115+
116+
Container labels are attached to the underlying Cloudflare Container for
117+
analytics and observability. Labels are applied when the container starts; if
118+
labels are changed while a container is already running, the new labels apply
119+
the next time the container starts.
120+
101121
## Quick tunnels
102122

103123
`sandbox.tunnels.get(port)` exposes a service running inside the

packages/sandbox/src/sandbox.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ type SandboxConfiguration = {
217217
keepAlive?: boolean;
218218
containerTimeouts?: NonNullable<SandboxOptions['containerTimeouts']>;
219219
transport?: SandboxTransport;
220+
labels?: NonNullable<SandboxOptions['labels']>;
220221
};
221222

222223
type CachedSandboxConfiguration = {
@@ -226,6 +227,7 @@ type CachedSandboxConfiguration = {
226227
keepAlive?: boolean;
227228
containerTimeouts?: NonNullable<SandboxOptions['containerTimeouts']>;
228229
transport?: SandboxTransport;
230+
labels?: NonNullable<SandboxOptions['labels']>;
229231
};
230232

231233
type EgressContainerState = DurableObjectState<{}> & {
@@ -347,6 +349,7 @@ type ConfigurableSandboxStub = {
347349
timeouts: NonNullable<SandboxOptions['containerTimeouts']>
348350
) => Promise<void>;
349351
setTransport?: (transport: SandboxTransport) => Promise<void>;
352+
setLabels?: (labels: Record<string, string>) => Promise<void>;
350353
};
351354

352355
type SandboxProxyStub = ConfigurableSandboxStub & {
@@ -504,6 +507,28 @@ function sameContainerTimeouts(
504507
);
505508
}
506509

510+
function sameLabels(
511+
left?: Record<string, string>,
512+
right?: Record<string, string>
513+
): boolean {
514+
if (left === right) return true;
515+
if (!left || !right) return false;
516+
517+
const leftEntries = Object.entries(left);
518+
const rightEntries = Object.entries(right);
519+
if (leftEntries.length !== rightEntries.length) return false;
520+
521+
for (const [key, value] of leftEntries) {
522+
if (right[key] !== value) return false;
523+
}
524+
525+
return true;
526+
}
527+
528+
function cloneLabels(labels: Record<string, string>): Record<string, string> {
529+
return { ...labels };
530+
}
531+
507532
function buildSandboxConfiguration(
508533
effectiveId: string,
509534
options: SandboxOptions | undefined,
@@ -549,6 +574,13 @@ function buildSandboxConfiguration(
549574
configuration.transport = options.transport;
550575
}
551576

577+
if (
578+
options?.labels !== undefined &&
579+
!sameLabels(cached?.labels, options.labels)
580+
) {
581+
configuration.labels = cloneLabels(options.labels);
582+
}
583+
552584
return configuration;
553585
}
554586

@@ -558,7 +590,8 @@ function hasSandboxConfiguration(configuration: SandboxConfiguration): boolean {
558590
configuration.sleepAfter !== undefined ||
559591
configuration.keepAlive !== undefined ||
560592
configuration.containerTimeouts !== undefined ||
561-
configuration.transport !== undefined
593+
configuration.transport !== undefined ||
594+
configuration.labels !== undefined
562595
);
563596
}
564597

@@ -583,6 +616,9 @@ function mergeSandboxConfiguration(
583616
}),
584617
...(configuration.transport !== undefined && {
585618
transport: configuration.transport
619+
}),
620+
...(configuration.labels !== undefined && {
621+
labels: cloneLabels(configuration.labels)
586622
})
587623
};
588624
}
@@ -631,6 +667,12 @@ function applySandboxConfiguration(
631667
);
632668
}
633669

670+
if (configuration.labels !== undefined) {
671+
operations.push(
672+
stub.setLabels?.(configuration.labels) ?? Promise.resolve()
673+
);
674+
}
675+
634676
return Promise.all(operations).then(() => undefined);
635677
}
636678

@@ -1368,6 +1410,12 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
13681410
this.hasStoredTransport = true;
13691411
}
13701412

1413+
const storedLabels =
1414+
await this.ctx.storage.get<Record<string, string>>('labels');
1415+
if (storedLabels !== undefined && storedLabels !== null) {
1416+
this.labels = cloneLabels(storedLabels);
1417+
}
1418+
13711419
if (this.interceptHttps) {
13721420
this.envVars = { ...this.envVars, SANDBOX_INTERCEPT_HTTPS: '1' };
13731421
}
@@ -1418,6 +1466,10 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
14181466
if (configuration.transport !== undefined) {
14191467
await this.setTransport(configuration.transport);
14201468
}
1469+
1470+
if (configuration.labels !== undefined) {
1471+
await this.setLabels(configuration.labels);
1472+
}
14211473
}
14221474

14231475
// RPC method to set the sleep timeout. Idempotent: re-applying the same
@@ -1572,6 +1624,21 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
15721624
this.logger.debug('Transport updated', { transport });
15731625
}
15741626

1627+
async setLabels(labels: Record<string, string>): Promise<void> {
1628+
const nextLabels = cloneLabels(labels);
1629+
1630+
if (sameLabels(this.labels, nextLabels)) return;
1631+
1632+
await this.ctx.storage.put('labels', nextLabels);
1633+
this.labels = nextLabels;
1634+
1635+
if (this.ctx.container?.running === true) {
1636+
this.logger.warn(
1637+
'Container labels updated while container is running; new labels apply on the next container start'
1638+
);
1639+
}
1640+
}
1641+
15751642
private validateTimeout(
15761643
value: number,
15771644
name: string,

packages/sandbox/tests/get-sandbox.test.ts

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ describe('getSandbox', () => {
5353
setSleepAfter: vi.fn((value: string | number) => {
5454
mockStub.sleepAfter = value;
5555
}),
56-
setKeepAlive: vi.fn()
56+
setKeepAlive: vi.fn(),
57+
setLabels: vi.fn()
5758
};
5859

5960
// Mock getContainer to return our stub
@@ -333,6 +334,112 @@ describe('getSandbox', () => {
333334
});
334335
});
335336

337+
it('should apply labels option', () => {
338+
const mockNamespace = {} as any;
339+
340+
getSandbox(mockNamespace, 'test-sandbox', {
341+
labels: {
342+
tenantId: 'tenant_123',
343+
workload: 'code-workspace'
344+
}
345+
});
346+
347+
expect(mockStub.configure).toHaveBeenCalledWith({
348+
sandboxName: {
349+
name: 'test-sandbox',
350+
normalizeId: undefined
351+
},
352+
labels: {
353+
tenantId: 'tenant_123',
354+
workload: 'code-workspace'
355+
}
356+
});
357+
});
358+
359+
it('should apply labels alongside other options', () => {
360+
const mockNamespace = {} as any;
361+
362+
getSandbox(mockNamespace, 'test-sandbox', {
363+
sleepAfter: '5m',
364+
keepAlive: true,
365+
transport: 'websocket',
366+
labels: { workload: 'code-workspace' }
367+
});
368+
369+
expect(mockStub.configure).toHaveBeenCalledWith({
370+
sandboxName: {
371+
name: 'test-sandbox',
372+
normalizeId: undefined
373+
},
374+
sleepAfter: '5m',
375+
keepAlive: true,
376+
transport: 'websocket',
377+
labels: { workload: 'code-workspace' }
378+
});
379+
});
380+
381+
it('should skip repeated labels configuration for the same sandbox', async () => {
382+
const mockNamespace = {} as any;
383+
384+
getSandbox(mockNamespace, 'test-sandbox', {
385+
labels: { tenantId: 'tenant_123' }
386+
});
387+
await Promise.resolve();
388+
389+
getSandbox(mockNamespace, 'test-sandbox', {
390+
labels: { tenantId: 'tenant_123' }
391+
});
392+
393+
expect(mockStub.configure).toHaveBeenCalledTimes(1);
394+
});
395+
396+
it('should treat labels with the same key-values as identical regardless of insertion order', async () => {
397+
const mockNamespace = {} as any;
398+
399+
getSandbox(mockNamespace, 'test-sandbox', {
400+
labels: { tenantId: 'tenant_123', workload: 'code-workspace' }
401+
});
402+
await Promise.resolve();
403+
404+
getSandbox(mockNamespace, 'test-sandbox', {
405+
labels: { workload: 'code-workspace', tenantId: 'tenant_123' }
406+
});
407+
408+
expect(mockStub.configure).toHaveBeenCalledTimes(1);
409+
});
410+
411+
it('should reconfigure when labels change', async () => {
412+
const mockNamespace = {} as any;
413+
414+
getSandbox(mockNamespace, 'test-sandbox', {
415+
labels: { tenantId: 'tenant_123' }
416+
});
417+
await Promise.resolve();
418+
419+
getSandbox(mockNamespace, 'test-sandbox', {
420+
labels: { tenantId: 'tenant_456' }
421+
});
422+
423+
expect(mockStub.configure).toHaveBeenCalledTimes(2);
424+
expect(mockStub.configure).toHaveBeenNthCalledWith(2, {
425+
labels: { tenantId: 'tenant_456' }
426+
});
427+
});
428+
429+
it('should allow empty labels to clear configured labels for future starts', async () => {
430+
const mockNamespace = {} as any;
431+
432+
getSandbox(mockNamespace, 'test-sandbox', {
433+
labels: { tenantId: 'tenant_123' }
434+
});
435+
await Promise.resolve();
436+
437+
getSandbox(mockNamespace, 'test-sandbox', { labels: {} });
438+
439+
expect(mockStub.configure).toHaveBeenCalledTimes(2);
440+
expect(mockStub.configure).toHaveBeenNthCalledWith(2, { labels: {} });
441+
});
442+
336443
describe('proxy method routing', () => {
337444
it('should preserve this binding for fetch()', async () => {
338445
// fetch() is a native DurableObjectStub method that requires correct

0 commit comments

Comments
 (0)