Skip to content

Commit da2cfb8

Browse files
feat: Add version sync detection between npm package and Docker image (#133)
* feat: Add version sync detection between npm package and Docker image Implements automatic detection of version mismatches between the npm package and Docker container image to prevent compatibility issues. Changes: - Embed SANDBOX_VERSION as environment variable in Docker image - Add /api/version endpoint to container server - Add getVersion() method to UtilityClient with backward compatibility - Implement automatic version checking on sandbox startup - Log warnings when versions don't match - Add comprehensive test coverage for new functionality The version check runs asynchronously on sandbox startup and logs warnings for mismatches. Backward compatibility is maintained - old containers without the version endpoint return 'unknown' gracefully. Fixes #131 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Naresh <ghostwriternr@users.noreply.github.com> * Add version sync detection for npm and Docker Implement version synchronization detection for npm packages and Docker images. * Update SANDBOX_VERSION in changeset-version * Fix type issues * Bump version --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Naresh <ghostwriternr@users.noreply.github.com>
1 parent e517b56 commit da2cfb8

File tree

13 files changed

+257
-4
lines changed

13 files changed

+257
-4
lines changed

.changeset/spicy-hairs-watch.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@repo/sandbox-container": patch
3+
"@cloudflare/sandbox": patch
4+
---
5+
6+
feat: Add version sync detection between npm package and Docker image

.github/changeset-version.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ try {
2323

2424
// Patterns to match version references in different contexts
2525
const versionPatterns = [
26+
// SDK version constant
27+
{
28+
pattern: /export const SDK_VERSION = '[\d.]+';/g,
29+
replacement: `export const SDK_VERSION = '${newVersion}';`,
30+
description: "SDK version constant in version.ts",
31+
},
2632
// Docker image versions (production and test)
2733
{
2834
pattern: /FROM docker\.io\/cloudflare\/sandbox:[\d.]+/g,

packages/sandbox-container/src/handlers/misc-handler.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export class MiscHandler extends BaseHandler<Request, Response> {
1818
return await this.handleHealth(request, context);
1919
case '/api/shutdown':
2020
return await this.handleShutdown(request, context);
21+
case '/api/version':
22+
return await this.handleVersion(request, context);
2123
default:
2224
return this.createErrorResponse({
2325
message: 'Invalid endpoint',
@@ -54,4 +56,16 @@ export class MiscHandler extends BaseHandler<Request, Response> {
5456

5557
return this.createTypedResponse(response, context);
5658
}
59+
60+
private async handleVersion(request: Request, context: RequestContext): Promise<Response> {
61+
const version = process.env.SANDBOX_VERSION || 'unknown';
62+
63+
const response = {
64+
success: true,
65+
version,
66+
timestamp: new Date().toISOString(),
67+
};
68+
69+
return this.createTypedResponse(response, context);
70+
}
5771
}

packages/sandbox-container/src/routes/setup.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,4 +256,11 @@ export function setupRoutes(router: Router, container: Container): void {
256256
handler: async (req, ctx) => container.get('miscHandler').handle(req, ctx),
257257
middleware: [container.get('loggingMiddleware')],
258258
});
259+
260+
router.register({
261+
method: 'GET',
262+
path: '/api/version',
263+
handler: async (req, ctx) => container.get('miscHandler').handle(req, ctx),
264+
middleware: [container.get('loggingMiddleware')],
265+
});
259266
}

packages/sandbox-container/tests/handlers/misc-handler.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,57 @@ describe('MiscHandler', () => {
150150
});
151151
});
152152

153+
describe('handleVersion - GET /api/version', () => {
154+
it('should return version from environment variable', async () => {
155+
// Set environment variable for test
156+
process.env.SANDBOX_VERSION = '1.2.3';
157+
158+
const request = new Request('http://localhost:3000/api/version', {
159+
method: 'GET'
160+
});
161+
162+
const response = await miscHandler.handle(request, mockContext);
163+
164+
expect(response.status).toBe(200);
165+
expect(response.headers.get('Content-Type')).toBe('application/json');
166+
167+
const responseData = await response.json();
168+
expect(responseData.success).toBe(true);
169+
expect(responseData.version).toBe('1.2.3');
170+
expect(responseData.timestamp).toBeDefined();
171+
expect(responseData.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
172+
});
173+
174+
it('should return "unknown" when SANDBOX_VERSION is not set', async () => {
175+
// Clear environment variable
176+
delete process.env.SANDBOX_VERSION;
177+
178+
const request = new Request('http://localhost:3000/api/version', {
179+
method: 'GET'
180+
});
181+
182+
const response = await miscHandler.handle(request, mockContext);
183+
184+
expect(response.status).toBe(200);
185+
const responseData = await response.json();
186+
expect(responseData.version).toBe('unknown');
187+
});
188+
189+
it('should include CORS headers in version response', async () => {
190+
process.env.SANDBOX_VERSION = '1.0.0';
191+
192+
const request = new Request('http://localhost:3000/api/version', {
193+
method: 'GET'
194+
});
195+
196+
const response = await miscHandler.handle(request, mockContext);
197+
198+
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*');
199+
expect(response.headers.get('Access-Control-Allow-Methods')).toBe('GET, POST, OPTIONS');
200+
expect(response.headers.get('Access-Control-Allow-Headers')).toBe('Content-Type');
201+
});
202+
});
203+
153204
describe('handleShutdown - POST /api/shutdown', () => {
154205
it('should return shutdown response with JSON content type', async () => {
155206
const request = new Request('http://localhost:3000/api/shutdown', {

packages/sandbox/Dockerfile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,15 @@ RUN npm ci --production
6060
# ============================================================================
6161
FROM ubuntu:22.04 AS runtime
6262

63+
# Accept version as build argument (passed from npm_package_version)
64+
ARG SANDBOX_VERSION=unknown
65+
6366
# Prevent interactive prompts during package installation
6467
ENV DEBIAN_FRONTEND=noninteractive
6568

69+
# Set the sandbox version as an environment variable for version checking
70+
ENV SANDBOX_VERSION=${SANDBOX_VERSION}
71+
6672
# Install essential runtime packages
6773
RUN apt-get update && apt-get install -y --no-install-recommends \
6874
curl \

packages/sandbox/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@
2424
"check": "biome check && npm run typecheck",
2525
"fix": "biome check --fix && npm run typecheck",
2626
"typecheck": "tsc --noEmit",
27-
"docker:local": "cd ../.. && docker build -f packages/sandbox/Dockerfile -t cloudflare/sandbox-test:$npm_package_version .",
28-
"docker:publish": "cd ../.. && docker buildx build --platform linux/amd64,linux/arm64 -f packages/sandbox/Dockerfile -t cloudflare/sandbox:$npm_package_version --push .",
29-
"docker:publish:beta": "cd ../.. && docker buildx build --platform linux/amd64,linux/arm64 -f packages/sandbox/Dockerfile -t cloudflare/sandbox:$npm_package_version-beta --push .",
27+
"docker:local": "cd ../.. && docker build -f packages/sandbox/Dockerfile --build-arg SANDBOX_VERSION=$npm_package_version -t cloudflare/sandbox-test:$npm_package_version .",
28+
"docker:publish": "cd ../.. && docker buildx build --platform linux/amd64,linux/arm64 -f packages/sandbox/Dockerfile --build-arg SANDBOX_VERSION=$npm_package_version -t cloudflare/sandbox:$npm_package_version --push .",
29+
"docker:publish:beta": "cd ../.. && docker buildx build --platform linux/amd64,linux/arm64 -f packages/sandbox/Dockerfile --build-arg SANDBOX_VERSION=$npm_package_version -t cloudflare/sandbox:$npm_package_version-beta --push .",
3030
"test": "vitest run --config vitest.config.ts",
3131
"test:e2e": "cd ../.. && vitest run --config vitest.e2e.config.ts \"$@\""
3232
},

packages/sandbox/src/clients/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,5 +59,6 @@ export type {
5959
export type {
6060
CommandsResponse,
6161
PingResponse,
62+
VersionResponse,
6263
} from './utility-client';
6364
export { UtilityClient } from './utility-client';

packages/sandbox/src/clients/utility-client.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ export interface CommandsResponse extends BaseApiResponse {
1717
count: number;
1818
}
1919

20+
/**
21+
* Response interface for getting container version
22+
*/
23+
export interface VersionResponse extends BaseApiResponse {
24+
version: string;
25+
}
26+
2027
/**
2128
* Request interface for creating sessions
2229
*/
@@ -91,4 +98,22 @@ export class UtilityClient extends BaseHttpClient {
9198
throw error;
9299
}
93100
}
101+
102+
/**
103+
* Get the container version
104+
* Returns the version embedded in the Docker image during build
105+
*/
106+
async getVersion(): Promise<string> {
107+
try {
108+
const response = await this.get<VersionResponse>('/api/version');
109+
110+
this.logSuccess('Version retrieved', response.version);
111+
return response.version;
112+
} catch (error) {
113+
// If version endpoint doesn't exist (old container), return 'unknown'
114+
// This allows for backward compatibility
115+
this.logger.debug('Failed to get container version (may be old container)', { error });
116+
return 'unknown';
117+
}
118+
}
94119
}

packages/sandbox/src/sandbox.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
validatePort
3030
} from "./security";
3131
import { parseSSEStream } from "./sse-parser";
32+
import { SDK_VERSION } from "./version";
3233

3334
export function getSandbox(
3435
ns: DurableObjectNamespace<Sandbox>,
@@ -161,6 +162,54 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
161162

162163
override onStart() {
163164
this.logger.debug('Sandbox started');
165+
166+
// Check version compatibility asynchronously (don't block startup)
167+
this.checkVersionCompatibility().catch(error => {
168+
this.logger.error('Version compatibility check failed', error instanceof Error ? error : new Error(String(error)));
169+
});
170+
}
171+
172+
/**
173+
* Check if the container version matches the SDK version
174+
* Logs a warning if there's a mismatch
175+
*/
176+
private async checkVersionCompatibility(): Promise<void> {
177+
try {
178+
// Get the SDK version (imported from version.ts)
179+
const sdkVersion = SDK_VERSION;
180+
181+
// Get container version
182+
const containerVersion = await this.client.utils.getVersion();
183+
184+
// If container version is unknown, it's likely an old container without the endpoint
185+
if (containerVersion === 'unknown') {
186+
this.logger.warn(
187+
'Container version check: Container version could not be determined. ' +
188+
'This may indicate an outdated container image. ' +
189+
'Please update your container to match SDK version ' + sdkVersion
190+
);
191+
return;
192+
}
193+
194+
// Check if versions match
195+
if (containerVersion !== sdkVersion) {
196+
const message =
197+
`Version mismatch detected! SDK version (${sdkVersion}) does not match ` +
198+
`container version (${containerVersion}). This may cause compatibility issues. ` +
199+
`Please update your container image to version ${sdkVersion}`;
200+
201+
// Log warning - we can't reliably detect dev vs prod environment in Durable Objects
202+
// so we always use warning level as requested by the user
203+
this.logger.warn(message);
204+
} else {
205+
this.logger.debug('Version check passed', { sdkVersion, containerVersion });
206+
}
207+
} catch (error) {
208+
// Don't fail the sandbox initialization if version check fails
209+
this.logger.debug('Version compatibility check encountered an error', {
210+
error: error instanceof Error ? error.message : String(error)
211+
});
212+
}
164213
}
165214

166215
override onStop() {

0 commit comments

Comments
 (0)