Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/spicy-hairs-watch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@repo/sandbox-container": patch
"@cloudflare/sandbox": patch
---

feat: Add version sync detection between npm package and Docker image
6 changes: 6 additions & 0 deletions .github/changeset-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ try {

// Patterns to match version references in different contexts
const versionPatterns = [
// SDK version constant
{
pattern: /export const SDK_VERSION = '[\d.]+';/g,
replacement: `export const SDK_VERSION = '${newVersion}';`,
description: "SDK version constant in version.ts",
},
// Docker image versions (production and test)
{
pattern: /FROM docker\.io\/cloudflare\/sandbox:[\d.]+/g,
Expand Down
14 changes: 14 additions & 0 deletions packages/sandbox-container/src/handlers/misc-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export class MiscHandler extends BaseHandler<Request, Response> {
return await this.handleHealth(request, context);
case '/api/shutdown':
return await this.handleShutdown(request, context);
case '/api/version':
return await this.handleVersion(request, context);
default:
return this.createErrorResponse({
message: 'Invalid endpoint',
Expand Down Expand Up @@ -54,4 +56,16 @@ export class MiscHandler extends BaseHandler<Request, Response> {

return this.createTypedResponse(response, context);
}

private async handleVersion(request: Request, context: RequestContext): Promise<Response> {
const version = process.env.SANDBOX_VERSION || 'unknown';

const response = {
success: true,
version,
timestamp: new Date().toISOString(),
};

return this.createTypedResponse(response, context);
}
}
7 changes: 7 additions & 0 deletions packages/sandbox-container/src/routes/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,4 +256,11 @@ export function setupRoutes(router: Router, container: Container): void {
handler: async (req, ctx) => container.get('miscHandler').handle(req, ctx),
middleware: [container.get('loggingMiddleware')],
});

router.register({
method: 'GET',
path: '/api/version',
handler: async (req, ctx) => container.get('miscHandler').handle(req, ctx),
middleware: [container.get('loggingMiddleware')],
});
}
51 changes: 51 additions & 0 deletions packages/sandbox-container/tests/handlers/misc-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,57 @@ describe('MiscHandler', () => {
});
});

describe('handleVersion - GET /api/version', () => {
it('should return version from environment variable', async () => {
// Set environment variable for test
process.env.SANDBOX_VERSION = '1.2.3';

const request = new Request('http://localhost:3000/api/version', {
method: 'GET'
});

const response = await miscHandler.handle(request, mockContext);

expect(response.status).toBe(200);
expect(response.headers.get('Content-Type')).toBe('application/json');

const responseData = await response.json();
expect(responseData.success).toBe(true);
expect(responseData.version).toBe('1.2.3');
expect(responseData.timestamp).toBeDefined();
expect(responseData.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
});

it('should return "unknown" when SANDBOX_VERSION is not set', async () => {
// Clear environment variable
delete process.env.SANDBOX_VERSION;

const request = new Request('http://localhost:3000/api/version', {
method: 'GET'
});

const response = await miscHandler.handle(request, mockContext);

expect(response.status).toBe(200);
const responseData = await response.json();
expect(responseData.version).toBe('unknown');
});

it('should include CORS headers in version response', async () => {
process.env.SANDBOX_VERSION = '1.0.0';

const request = new Request('http://localhost:3000/api/version', {
method: 'GET'
});

const response = await miscHandler.handle(request, mockContext);

expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*');
expect(response.headers.get('Access-Control-Allow-Methods')).toBe('GET, POST, OPTIONS');
expect(response.headers.get('Access-Control-Allow-Headers')).toBe('Content-Type');
});
});

describe('handleShutdown - POST /api/shutdown', () => {
it('should return shutdown response with JSON content type', async () => {
const request = new Request('http://localhost:3000/api/shutdown', {
Expand Down
6 changes: 6 additions & 0 deletions packages/sandbox/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,15 @@ RUN npm ci --production
# ============================================================================
FROM ubuntu:22.04 AS runtime

# Accept version as build argument (passed from npm_package_version)
ARG SANDBOX_VERSION=unknown

# Prevent interactive prompts during package installation
ENV DEBIAN_FRONTEND=noninteractive

# Set the sandbox version as an environment variable for version checking
ENV SANDBOX_VERSION=${SANDBOX_VERSION}

# Install essential runtime packages
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
Expand Down
6 changes: 3 additions & 3 deletions packages/sandbox/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@
"check": "biome check && npm run typecheck",
"fix": "biome check --fix && npm run typecheck",
"typecheck": "tsc --noEmit",
"docker:local": "cd ../.. && docker build -f packages/sandbox/Dockerfile -t cloudflare/sandbox-test:$npm_package_version .",
"docker:publish": "cd ../.. && docker buildx build --platform linux/amd64,linux/arm64 -f packages/sandbox/Dockerfile -t cloudflare/sandbox:$npm_package_version --push .",
"docker:publish:beta": "cd ../.. && docker buildx build --platform linux/amd64,linux/arm64 -f packages/sandbox/Dockerfile -t cloudflare/sandbox:$npm_package_version-beta --push .",
"docker:local": "cd ../.. && docker build -f packages/sandbox/Dockerfile --build-arg SANDBOX_VERSION=$npm_package_version -t cloudflare/sandbox-test:$npm_package_version .",
"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 .",
"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 .",
"test": "vitest run --config vitest.config.ts",
"test:e2e": "cd ../.. && vitest run --config vitest.e2e.config.ts \"$@\""
},
Expand Down
1 change: 1 addition & 0 deletions packages/sandbox/src/clients/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,5 +59,6 @@ export type {
export type {
CommandsResponse,
PingResponse,
VersionResponse,
} from './utility-client';
export { UtilityClient } from './utility-client';
25 changes: 25 additions & 0 deletions packages/sandbox/src/clients/utility-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ export interface CommandsResponse extends BaseApiResponse {
count: number;
}

/**
* Response interface for getting container version
*/
export interface VersionResponse extends BaseApiResponse {
version: string;
}

/**
* Request interface for creating sessions
*/
Expand Down Expand Up @@ -91,4 +98,22 @@ export class UtilityClient extends BaseHttpClient {
throw error;
}
}

/**
* Get the container version
* Returns the version embedded in the Docker image during build
*/
async getVersion(): Promise<string> {
try {
const response = await this.get<VersionResponse>('/api/version');

this.logSuccess('Version retrieved', response.version);
return response.version;
} catch (error) {
// If version endpoint doesn't exist (old container), return 'unknown'
// This allows for backward compatibility
this.logger.debug('Failed to get container version (may be old container)', { error });
return 'unknown';
}
}
}
49 changes: 49 additions & 0 deletions packages/sandbox/src/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
validatePort
} from "./security";
import { parseSSEStream } from "./sse-parser";
import { SDK_VERSION } from "./version";

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

override onStart() {
this.logger.debug('Sandbox started');

// Check version compatibility asynchronously (don't block startup)
this.checkVersionCompatibility().catch(error => {
this.logger.error('Version compatibility check failed', error instanceof Error ? error : new Error(String(error)));
});
}

/**
* Check if the container version matches the SDK version
* Logs a warning if there's a mismatch
*/
private async checkVersionCompatibility(): Promise<void> {
try {
// Get the SDK version (imported from version.ts)
const sdkVersion = SDK_VERSION;

// Get container version
const containerVersion = await this.client.utils.getVersion();

// If container version is unknown, it's likely an old container without the endpoint
if (containerVersion === 'unknown') {
this.logger.warn(
'Container version check: Container version could not be determined. ' +
'This may indicate an outdated container image. ' +
'Please update your container to match SDK version ' + sdkVersion
);
return;
}

// Check if versions match
if (containerVersion !== sdkVersion) {
const message =
`Version mismatch detected! SDK version (${sdkVersion}) does not match ` +
`container version (${containerVersion}). This may cause compatibility issues. ` +
`Please update your container image to version ${sdkVersion}`;

// Log warning - we can't reliably detect dev vs prod environment in Durable Objects
// so we always use warning level as requested by the user
this.logger.warn(message);
} else {
this.logger.debug('Version check passed', { sdkVersion, containerVersion });
}
} catch (error) {
// Don't fail the sandbox initialization if version check fails
this.logger.debug('Version compatibility check encountered an error', {
error: error instanceof Error ? error.message : String(error)
});
}
}

override onStop() {
Expand Down
6 changes: 6 additions & 0 deletions packages/sandbox/src/version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* SDK version - automatically synchronized with package.json by Changesets
* This file is auto-updated by .github/changeset-version.ts during releases
* DO NOT EDIT MANUALLY - Changes will be overwritten on the next version bump
*/
export const SDK_VERSION = '0.4.5';
68 changes: 67 additions & 1 deletion packages/sandbox/tests/utility-client.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type {
CommandsResponse,
PingResponse
PingResponse,
VersionResponse
} from '../src/clients';
import { UtilityClient } from '../src/clients/utility-client';
import {
Expand All @@ -25,6 +26,13 @@ const mockCommandsResponse = (commands: string[], overrides: Partial<CommandsRes
...overrides
});

const mockVersionResponse = (version: string = '0.4.5', overrides: Partial<VersionResponse> = {}): VersionResponse => ({
success: true,
version,
timestamp: '2023-01-01T00:00:00Z',
...overrides
});

describe('UtilityClient', () => {
let client: UtilityClient;
let mockFetch: ReturnType<typeof vi.fn>;
Expand Down Expand Up @@ -249,6 +257,64 @@ describe('UtilityClient', () => {
});
});

describe('version checking', () => {
it('should get container version successfully', async () => {
mockFetch.mockResolvedValue(new Response(
JSON.stringify(mockVersionResponse('0.4.5')),
{ status: 200 }
));

const result = await client.getVersion();

expect(result).toBe('0.4.5');
});

it('should handle different version formats', async () => {
const versions = ['1.0.0', '2.5.3-beta', '0.0.1', '10.20.30'];

for (const version of versions) {
mockFetch.mockResolvedValueOnce(new Response(
JSON.stringify(mockVersionResponse(version)),
{ status: 200 }
));

const result = await client.getVersion();
expect(result).toBe(version);
}
});

it('should return "unknown" when version endpoint does not exist (backward compatibility)', async () => {
// Simulate 404 or other error for old containers
mockFetch.mockResolvedValue(new Response(
JSON.stringify({ error: 'Not Found' }),
{ status: 404 }
));

const result = await client.getVersion();

expect(result).toBe('unknown');
});

it('should return "unknown" on network failure (backward compatibility)', async () => {
mockFetch.mockRejectedValue(new Error('Network connection failed'));

const result = await client.getVersion();

expect(result).toBe('unknown');
});

it('should handle version response with unknown value', async () => {
mockFetch.mockResolvedValue(new Response(
JSON.stringify(mockVersionResponse('unknown')),
{ status: 200 }
));

const result = await client.getVersion();

expect(result).toBe('unknown');
});
});

describe('constructor options', () => {
it('should initialize with minimal options', () => {
const minimalClient = new UtilityClient();
Expand Down
16 changes: 16 additions & 0 deletions packages/sandbox/tests/version.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, expect, test } from 'vitest';
import packageJson from '../package.json';
import { SDK_VERSION } from '../src/version';

describe('Version Sync', () => {
test('SDK_VERSION matches package.json version', () => {
// Verify versions match
expect(SDK_VERSION).toBe(packageJson.version);
});

test('SDK_VERSION is a valid semver version', () => {
// Check if version matches semver pattern (major.minor.patch)
const semverPattern = /^\d+\.\d+\.\d+(-[\w.]+)?$/;
expect(SDK_VERSION).toMatch(semverPattern);
});
});
Loading