diff --git a/.changeset/spicy-hairs-watch.md b/.changeset/spicy-hairs-watch.md new file mode 100644 index 00000000..ca7161f4 --- /dev/null +++ b/.changeset/spicy-hairs-watch.md @@ -0,0 +1,6 @@ +--- +"@repo/sandbox-container": patch +"@cloudflare/sandbox": patch +--- + +feat: Add version sync detection between npm package and Docker image diff --git a/.github/changeset-version.ts b/.github/changeset-version.ts index 3297f960..ff5737a3 100644 --- a/.github/changeset-version.ts +++ b/.github/changeset-version.ts @@ -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, diff --git a/packages/sandbox-container/src/handlers/misc-handler.ts b/packages/sandbox-container/src/handlers/misc-handler.ts index c227374e..4f8aa012 100644 --- a/packages/sandbox-container/src/handlers/misc-handler.ts +++ b/packages/sandbox-container/src/handlers/misc-handler.ts @@ -18,6 +18,8 @@ export class MiscHandler extends BaseHandler { 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', @@ -54,4 +56,16 @@ export class MiscHandler extends BaseHandler { return this.createTypedResponse(response, context); } + + private async handleVersion(request: Request, context: RequestContext): Promise { + const version = process.env.SANDBOX_VERSION || 'unknown'; + + const response = { + success: true, + version, + timestamp: new Date().toISOString(), + }; + + return this.createTypedResponse(response, context); + } } \ No newline at end of file diff --git a/packages/sandbox-container/src/routes/setup.ts b/packages/sandbox-container/src/routes/setup.ts index 056c226d..ce2af8ea 100644 --- a/packages/sandbox-container/src/routes/setup.ts +++ b/packages/sandbox-container/src/routes/setup.ts @@ -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')], + }); } \ No newline at end of file diff --git a/packages/sandbox-container/tests/handlers/misc-handler.test.ts b/packages/sandbox-container/tests/handlers/misc-handler.test.ts index adbf3cac..d0212388 100644 --- a/packages/sandbox-container/tests/handlers/misc-handler.test.ts +++ b/packages/sandbox-container/tests/handlers/misc-handler.test.ts @@ -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', { diff --git a/packages/sandbox/Dockerfile b/packages/sandbox/Dockerfile index 3fe70c9c..67a1de8d 100644 --- a/packages/sandbox/Dockerfile +++ b/packages/sandbox/Dockerfile @@ -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 \ diff --git a/packages/sandbox/package.json b/packages/sandbox/package.json index cfde9fd4..8c30e1a3 100644 --- a/packages/sandbox/package.json +++ b/packages/sandbox/package.json @@ -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 \"$@\"" }, diff --git a/packages/sandbox/src/clients/index.ts b/packages/sandbox/src/clients/index.ts index e09c90ba..9c4c2cfe 100644 --- a/packages/sandbox/src/clients/index.ts +++ b/packages/sandbox/src/clients/index.ts @@ -59,5 +59,6 @@ export type { export type { CommandsResponse, PingResponse, + VersionResponse, } from './utility-client'; export { UtilityClient } from './utility-client'; \ No newline at end of file diff --git a/packages/sandbox/src/clients/utility-client.ts b/packages/sandbox/src/clients/utility-client.ts index 417c0827..171e6fba 100644 --- a/packages/sandbox/src/clients/utility-client.ts +++ b/packages/sandbox/src/clients/utility-client.ts @@ -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 */ @@ -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 { + try { + const response = await this.get('/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'; + } + } } \ No newline at end of file diff --git a/packages/sandbox/src/sandbox.ts b/packages/sandbox/src/sandbox.ts index f2ec2534..86b0f4d8 100644 --- a/packages/sandbox/src/sandbox.ts +++ b/packages/sandbox/src/sandbox.ts @@ -29,6 +29,7 @@ import { validatePort } from "./security"; import { parseSSEStream } from "./sse-parser"; +import { SDK_VERSION } from "./version"; export function getSandbox( ns: DurableObjectNamespace, @@ -161,6 +162,54 @@ export class Sandbox extends Container 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 { + 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() { diff --git a/packages/sandbox/src/version.ts b/packages/sandbox/src/version.ts new file mode 100644 index 00000000..d7db4c0b --- /dev/null +++ b/packages/sandbox/src/version.ts @@ -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'; diff --git a/packages/sandbox/tests/utility-client.test.ts b/packages/sandbox/tests/utility-client.test.ts index e38ef2ec..6e59a95d 100644 --- a/packages/sandbox/tests/utility-client.test.ts +++ b/packages/sandbox/tests/utility-client.test.ts @@ -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 { @@ -25,6 +26,13 @@ const mockCommandsResponse = (commands: string[], overrides: Partial = {}): VersionResponse => ({ + success: true, + version, + timestamp: '2023-01-01T00:00:00Z', + ...overrides +}); + describe('UtilityClient', () => { let client: UtilityClient; let mockFetch: ReturnType; @@ -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(); diff --git a/packages/sandbox/tests/version.test.ts b/packages/sandbox/tests/version.test.ts new file mode 100644 index 00000000..81374777 --- /dev/null +++ b/packages/sandbox/tests/version.test.ts @@ -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); + }); +});