From e305014f7cafbdabde9c311f229014cd83c9f433 Mon Sep 17 00:00:00 2001 From: berstpander Date: Tue, 31 Mar 2026 11:37:52 +0800 Subject: [PATCH] feat(ts-sdk): add sandbox logs support with Kmon host IP resolution Add comprehensive sandbox logs functionality that works for both alive and destroyed sandboxes: New Methods: - listLogs(options?): List log files in /data/logs/ directory - Supports recursive listing and glob pattern filtering - Returns LogFileInfo with name, path, size, modifiedTime - downloadLog(logPath, localPath, options?): Download log file via OSS - Path security: rejects absolute paths and directory traversal - Supports timeout and onProgress callback --- rock/ts-sdk/CHANGELOG.md | 34 ++ rock/ts-sdk/src/env_vars.ts | 14 + rock/ts-sdk/src/sandbox/client.ts | 216 +++++++++++- rock/ts-sdk/src/sandbox/constants.test.ts | 5 +- rock/ts-sdk/src/sandbox/constants.ts | 25 +- rock/ts-sdk/src/sandbox/index.ts | 2 + rock/ts-sdk/src/sandbox/kmon.test.ts | 373 +++++++++++++++++++++ rock/ts-sdk/src/sandbox/kmon.ts | 159 +++++++++ rock/ts-sdk/src/sandbox/logs.test.ts | 195 +++++++++++ rock/ts-sdk/src/sandbox/logs.ts | 196 +++++++++++ rock/ts-sdk/tests/integration/logs.test.ts | 212 ++++++++++++ 11 files changed, 1414 insertions(+), 17 deletions(-) create mode 100644 rock/ts-sdk/src/sandbox/kmon.test.ts create mode 100644 rock/ts-sdk/src/sandbox/kmon.ts create mode 100644 rock/ts-sdk/src/sandbox/logs.test.ts create mode 100644 rock/ts-sdk/src/sandbox/logs.ts create mode 100644 rock/ts-sdk/tests/integration/logs.test.ts diff --git a/rock/ts-sdk/CHANGELOG.md b/rock/ts-sdk/CHANGELOG.md index 269f7fe79..962073c6e 100644 --- a/rock/ts-sdk/CHANGELOG.md +++ b/rock/ts-sdk/CHANGELOG.md @@ -5,6 +5,40 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.4.0] - 2026-03-30 + +### Added + +- **Sandbox Logs Support** - New methods to view and download log files from sandboxes + - `listLogs(options?)`: List log files in `/data/logs/` directory + - Options: `recursive` (default: true), `pattern` (glob filter like `*.log`) + - Returns: Array of `LogFileInfo` with name, path, size, modifiedTime + - `downloadLog(logPath, localPath, options?)`: Download log file via OSS + - Path security: rejects absolute paths and directory traversal (`..`) + - Options: `timeout`, `onProgress` callback + - Works transparently for both alive and destroyed sandboxes + +- **KmonHostIpResolver** - Resolve hostIp for destroyed sandboxes via Kmon metrics API + - `Sandbox.setHostIpResolver(resolver)`: Set custom host IP resolver + - `Sandbox.useKmonHostIpResolver(config?)`: Use built-in Kmon resolver + - Config options: `token`, `baseUrl`, `tenants`, `maxQueryDays`, `maxQueryRangeMs` + - Automatic tenant fallback (default → gen_ai) + - QPS rate limiting (max 5 requests/second) + - Query segmentation for large time ranges (max 2 days per query) + +- **New Environment Variables** + - `ROCK_KMON_TOKEN`: Kmon API authentication token + - `ROCK_KMON_BASE_URL`: Kmon API base URL (default: `https://kmon-metric.alibaba-inc.com`) + - `ROCK_KMON_TENANTS`: Comma-separated list of tenants to try (default: `default,gen_ai`) + +- **New Types** + - `LogFileInfo`: Log file metadata (name, path, size, modifiedTime, isDirectory) + - `ListLogsOptions`: Options for listLogs (recursive, pattern) + - `DownloadLogOptions`: Options for downloadLog (timeout, onProgress) + - `DownloadLogResponse`: Response from downloadLog (success, message) + - `HostInfo`: Internal type for host information (hostIp, isAlive) + - `KmonConfig`: Configuration for KmonHostIpResolver + ## [1.3.9] - 2026-03-29 ### Added diff --git a/rock/ts-sdk/src/env_vars.ts b/rock/ts-sdk/src/env_vars.ts index e7683e4b7..63130cda0 100644 --- a/rock/ts-sdk/src/env_vars.ts +++ b/rock/ts-sdk/src/env_vars.ts @@ -271,6 +271,20 @@ export const envVars = { get ROCK_DEFAULT_STATUS_CHECK_INTERVAL(): number { return parseInt(getEnv('ROCK_DEFAULT_STATUS_CHECK_INTERVAL', '3')!, 10); }, + + // ========== Kmon Configuration ========== + + get ROCK_KMON_TOKEN(): string | undefined { + return getEnv('ROCK_KMON_TOKEN'); + }, + + get ROCK_KMON_BASE_URL(): string | undefined { + return getEnv('ROCK_KMON_BASE_URL'); + }, + + get ROCK_KMON_TENANTS(): string | undefined { + return getEnv('ROCK_KMON_TENANTS'); + }, }; /** diff --git a/rock/ts-sdk/src/sandbox/client.ts b/rock/ts-sdk/src/sandbox/client.ts index c9d8fd3b4..7fc9de58f 100644 --- a/rock/ts-sdk/src/sandbox/client.ts +++ b/rock/ts-sdk/src/sandbox/client.ts @@ -982,10 +982,15 @@ export class Sandbox extends AbstractSandbox { // Priority: parameter > env var > default (300000ms = 5 minutes) const ossTimeout = timeout ?? envVars.ROCK_OSS_TIMEOUT; + // Build endpoint: use ROCK_OSS_BUCKET_ENDPOINT if set, otherwise construct from region + const region = (envVars.ROCK_OSS_BUCKET_REGION ?? '').replace(/^oss-/, ''); + const endpoint = envVars.ROCK_OSS_BUCKET_ENDPOINT ?? `oss-${region}.aliyuncs.com`; + this.ossBucket = new OSS({ secure: true, // Use HTTPS for OSS connections timeout: ossTimeout, - region: envVars.ROCK_OSS_BUCKET_REGION ?? '', + endpoint: endpoint, // Explicit endpoint to avoid incorrect URL construction + region: `oss-${region}`, // Ensure region has oss- prefix accessKeyId: credentials.accessKeyId, accessKeySecret: credentials.accessKeySecret, stsToken: credentials.securityToken, @@ -1002,6 +1007,215 @@ export class Sandbox extends AbstractSandbox { }); } + // ========== Logs Methods ========== + + /** + * Static host IP resolver for destroyed sandboxes + */ + private static hostIpResolver: ((sandboxId: string) => Promise) | null = null; + + /** + * Set custom host IP resolver for destroyed sandboxes + */ + static setHostIpResolver(resolver: (sandboxId: string) => Promise): void { + Sandbox.hostIpResolver = resolver; + } + + /** + * Use built-in Kmon resolver + */ + static async useKmonHostIpResolver(config?: import('./kmon.js').KmonConfig): Promise { + const { KmonHostIpResolver } = await import('./kmon.js'); + const resolver = new KmonHostIpResolver(config); + Sandbox.hostIpResolver = (sandboxId) => resolver.resolve(sandboxId); + } + + /** + * Get host info (hostIp and isAlive status) + */ + private async getHostInfo(): Promise { + // First try get_status + try { + const status = await this.getStatus(); + if (status.hostIp) { + return { hostIp: status.hostIp, isAlive: status.isAlive }; + } + } catch { + // Sandbox may be destroyed + } + + // Use resolver for destroyed sandbox + if (!Sandbox.hostIpResolver) { + throw new Error('Sandbox is destroyed and no hostIpResolver is set'); + } + + const hostIp = await Sandbox.hostIpResolver(this.sandboxId ?? ''); + return { hostIp, isAlive: false }; + } + + /** + * Execute command on host via host proxy + * Note: Host proxy returns direct Rocklet response (not wrapped in RockResponse) + */ + private async hostProxyExecute( + hostIp: string, + command: import('../types/requests.js').Command + ): Promise { + const url = `${this.url}/host/proxy/execute`; + const headers = { + ...this.buildHeaders(), + 'rock-host-ip': hostIp, + 'Content-Type': 'application/json', + }; + + // Wrap string command with bash -c for shell execution + // Rocklet execute expects array format for shell commands + const cmdValue = typeof command.command === 'string' + ? ['bash', '-c', command.command] + : command.command; + + const data = { + sandbox_id: this.sandboxId, // Required: host/proxy/* interfaces need sandbox_id + command: cmdValue, + timeout: command.timeout, + cwd: command.cwd, + env: command.env, + }; + + // Use axios directly because host proxy returns Rocklet response directly + // (not wrapped in { status: "Success", result: {...} } format) + const axios = (await import('axios')).default; + const { objectToSnake, objectToCamel } = await import('../utils/case.js'); + + const response = await axios.post(url, objectToSnake(data), { headers }); + const camelData = objectToCamel(response.data) as import('../types/responses.js').CommandResponse; + + return CommandResponseSchema.parse(camelData); + } + + /** + * List log files in /data/logs/ directory + */ + async listLogs(options?: import('./logs.js').ListLogsOptions): Promise { + const { validateLogPath, getLogBasePath, parseFileList, buildListCommand } = await import('./logs.js'); + + const { hostIp, isAlive } = await this.getHostInfo(); + const basePath = getLogBasePath(this.sandboxId ?? '', isAlive); + const cmd = buildListCommand(basePath, options); + + const result = await this.hostProxyExecute(hostIp, { command: cmd, timeout: 60 }); + + if (result.exitCode !== 0) { + logger.warn(`Failed to list logs: ${result.stderr}`); + return []; + } + + return parseFileList(result.stdout, basePath); + } + + /** + * Download log file via OSS + */ + async downloadLog( + logPath: string, + localPath: string, + options?: import('./logs.js').DownloadLogOptions + ): Promise { + const { validateLogPath, getLogBasePath } = await import('./logs.js'); + + // Validate path security + validateLogPath(logPath); + + const { hostIp, isAlive } = await this.getHostInfo(); + const basePath = getLogBasePath(this.sandboxId ?? '', isAlive); + const fullRemotePath = `${basePath}/${logPath}`; + + // Download via OSS from host + return this.downloadViaOssFromHost(hostIp, fullRemotePath, localPath, options); + } + + /** + * Download file from host via OSS + */ + private async downloadViaOssFromHost( + hostIp: string, + remotePath: string, + localPath: string, + options?: import('./logs.js').DownloadLogOptions + ): Promise { + // Check OSS is enabled + if (!envVars.ROCK_OSS_ENABLE) { + return { + success: false, + message: 'OSS download is not enabled. Please set ROCK_OSS_ENABLE=true', + }; + } + + try { + // Setup OSS bucket if needed + if (this.ossBucket === null || this.isTokenExpired()) { + await this.setupOss(options?.timeout); + } + + if (!this.ossBucket) { + return { success: false, message: 'Failed to setup OSS bucket' }; + } + + // Install ossutil on host via host proxy + const installResult = await this.hostProxyExecute(hostIp, { command: ENSURE_OSSUTIL_SCRIPT, timeout: 300 }); + if (installResult.exitCode !== 0) { + return { success: false, message: `Failed to install ossutil on host: ${installResult.stderr || installResult.stdout}` }; + } + + // Generate unique object name + const timestamp = Date.now(); + const fileName = remotePath.split('/').pop() ?? 'file'; + const objectName = `download-${timestamp}-${fileName}`; + + // Get STS credentials for ossutil + const credentials = await this.getOssStsCredentials(); + const bucketName = envVars.ROCK_OSS_BUCKET_NAME ?? ''; + const region = (envVars.ROCK_OSS_BUCKET_REGION ?? '').replace(/^oss-/, ''); + const endpoint = envVars.ROCK_OSS_BUCKET_ENDPOINT ?? `oss-${region}.aliyuncs.com`; + + // Upload from host to OSS via ossutil (use full path to ensure it's found) + const ossutilCmd = `/usr/local/bin/ossutil cp '${remotePath}' 'oss://${bucketName}/${objectName}' --access-key-id '${credentials.accessKeyId}' --access-key-secret '${credentials.accessKeySecret}' --sts-token '${credentials.securityToken}' --endpoint '${endpoint}' --region '${region}'`; + const uploadResult = await this.hostProxyExecute(hostIp, { command: ossutilCmd, timeout: 600 }); + if (uploadResult.exitCode !== 0) { + return { success: false, message: `Host to OSS upload failed: ${uploadResult.stderr}` }; + } + + // Notify progress: download phase + options?.onProgress?.({ + phase: 'download-to-local', + percent: 0, + }); + + // Download from OSS to local via ali-oss + const ossTimeout = options?.timeout ?? envVars.ROCK_OSS_TIMEOUT; + await this.ossBucket.get(objectName, localPath, { + timeout: ossTimeout, + progress: (p: number) => { + options?.onProgress?.({ + phase: 'download-to-local', + percent: Math.round(p * 100), + }); + }, + }); + + // Cleanup OSS object + try { + await this.ossBucket.delete(objectName); + } catch { + // Ignore cleanup errors + } + + return { success: true, message: `Successfully downloaded ${remotePath} to ${localPath}` }; + } catch (e) { + return { success: false, message: `OSS download failed: ${e}` }; + } + } + // Close override async close(): Promise { await this.stop(); diff --git a/rock/ts-sdk/src/sandbox/constants.test.ts b/rock/ts-sdk/src/sandbox/constants.test.ts index 236a89ff3..fdec510bd 100644 --- a/rock/ts-sdk/src/sandbox/constants.test.ts +++ b/rock/ts-sdk/src/sandbox/constants.test.ts @@ -26,8 +26,9 @@ describe('ENSURE_OSSUTIL_SCRIPT', () => { expect(ENSURE_OSSUTIL_SCRIPT).toContain('command -v curl'); }); - test('should check for unzip availability', () => { - expect(ENSURE_OSSUTIL_SCRIPT).toContain('command -v unzip'); + test('should use Python to extract zip (more universal than unzip)', () => { + expect(ENSURE_OSSUTIL_SCRIPT).toContain('python3 -c "import zipfile'); + expect(ENSURE_OSSUTIL_SCRIPT).toContain('python -c "import zipfile'); }); test('should skip installation if ossutil already exists', () => { diff --git a/rock/ts-sdk/src/sandbox/constants.ts b/rock/ts-sdk/src/sandbox/constants.ts index 8b0b72df1..759c6b607 100644 --- a/rock/ts-sdk/src/sandbox/constants.ts +++ b/rock/ts-sdk/src/sandbox/constants.ts @@ -5,9 +5,9 @@ /** * Ensure ossutil is installed in the sandbox. * - Checks wget/curl availability (fails fast if neither is present) - * - Checks unzip availability (fails fast if missing) - * - Skips installation if ossutil is already in PATH + * - Skips installation if ossutil is already in PATH or /usr/local/bin * - Downloads ossutil v2.2.1 for linux-amd64 + * - Uses Python to extract zip (more universal than unzip command) * - Installs to /usr/local/bin */ export const ENSURE_OSSUTIL_SCRIPT = `#!/bin/bash @@ -23,19 +23,13 @@ else exit 1 fi -# Check unzip -if ! command -v unzip >/dev/null 2>&1; then - echo "ERROR: unzip is not available. Please install unzip first." >&2 - exit 1 -fi - -# Skip if already installed -if command -v ossutil >/dev/null 2>&1; then +# Skip if already installed (check both PATH and /usr/local/bin) +if command -v ossutil >/dev/null 2>&1 || [ -x /usr/local/bin/ossutil ]; then echo "ossutil already installed, skipping." exit 0 fi -# Download +# Download zip cd /tmp if [ "$DOWNLOADER" = "wget" ]; then wget -q https://gosspublic.alicdn.com/ossutil/v2/2.2.1/ossutil-2.2.1-linux-amd64.zip -O /tmp/ossutil.zip @@ -43,8 +37,11 @@ else curl -sL -o /tmp/ossutil.zip https://gosspublic.alicdn.com/ossutil/v2/2.2.1/ossutil-2.2.1-linux-amd64.zip fi -# Extract and install -unzip -o -q ossutil.zip +# Extract using Python (more universal than unzip command) +python3 -c "import zipfile; zipfile.ZipFile('/tmp/ossutil.zip').extractall('/tmp')" || \\ +python -c "import zipfile; zipfile.ZipFile('/tmp/ossutil.zip').extractall('/tmp')" + +# Install chmod 755 /tmp/ossutil-2.2.1-linux-amd64/ossutil mkdir -p /usr/local/bin mv /tmp/ossutil-2.2.1-linux-amd64/ossutil /usr/local/bin/ @@ -53,5 +50,5 @@ mv /tmp/ossutil-2.2.1-linux-amd64/ossutil /usr/local/bin/ rm -rf /tmp/ossutil.zip /tmp/ossutil-2.2.1-linux-amd64 # Verify -ossutil version +/usr/local/bin/ossutil version `; diff --git a/rock/ts-sdk/src/sandbox/index.ts b/rock/ts-sdk/src/sandbox/index.ts index a2280e0ac..32e378fd1 100644 --- a/rock/ts-sdk/src/sandbox/index.ts +++ b/rock/ts-sdk/src/sandbox/index.ts @@ -10,6 +10,8 @@ export * from './network.js'; export * from './process.js'; export * from './remote_user.js'; export * from './utils.js'; +export * from './logs.js'; +export * from './kmon.js'; // Re-export types from their new locations export { SpeedupType } from './network.js'; diff --git a/rock/ts-sdk/src/sandbox/kmon.test.ts b/rock/ts-sdk/src/sandbox/kmon.test.ts new file mode 100644 index 000000000..c61dc7292 --- /dev/null +++ b/rock/ts-sdk/src/sandbox/kmon.test.ts @@ -0,0 +1,373 @@ +/** + * KmonHostIpResolver unit tests + */ + +import axios from 'axios'; + +// Mock axios +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +// Import after mocking +import { KmonHostIpResolver } from './kmon.js'; + +describe('KmonHostIpResolver', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + // Reset environment variables + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('constructor', () => { + test('should create instance with token from parameter', () => { + const resolver = new KmonHostIpResolver({ token: 'test-token' }); + expect(resolver).toBeInstanceOf(KmonHostIpResolver); + }); + + test('should create instance with token from environment variable', () => { + process.env.ROCK_KMON_TOKEN = 'env-token'; + const resolver = new KmonHostIpResolver(); + expect(resolver).toBeInstanceOf(KmonHostIpResolver); + }); + + test('should throw error when token is not provided', () => { + delete process.env.ROCK_KMON_TOKEN; + expect(() => new KmonHostIpResolver()).toThrow('ROCK_KMON_TOKEN is required'); + }); + + test('should use default values when not provided', () => { + const resolver = new KmonHostIpResolver({ token: 'test-token' }); + // Access private config through reflection for testing + const config = (resolver as unknown as { config: Required }).config; + + expect(config.baseUrl).toBe('https://kmon-metric.alibaba-inc.com'); + expect(config.tenants).toEqual(['default', 'gen_ai']); + expect(config.maxQueryDays).toBe(7); + expect(config.maxQueryRangeMs).toBe(2 * 24 * 60 * 60 * 1000); // 2 days + }); + + test('should use custom baseUrl from parameter', () => { + const resolver = new KmonHostIpResolver({ + token: 'test-token', + baseUrl: 'https://custom.kmon.com', + }); + const config = (resolver as unknown as { config: Required }).config; + expect(config.baseUrl).toBe('https://custom.kmon.com'); + }); + + test('should use custom baseUrl from environment variable', () => { + process.env.ROCK_KMON_TOKEN = 'env-token'; + process.env.ROCK_KMON_BASE_URL = 'https://env.kmon.com'; + const resolver = new KmonHostIpResolver(); + const config = (resolver as unknown as { config: Required }).config; + expect(config.baseUrl).toBe('https://env.kmon.com'); + }); + + test('should use custom tenants from parameter', () => { + const resolver = new KmonHostIpResolver({ + token: 'test-token', + tenants: ['tenant1', 'tenant2'], + }); + const config = (resolver as unknown as { config: Required }).config; + expect(config.tenants).toEqual(['tenant1', 'tenant2']); + }); + + test('should use custom tenants from environment variable', () => { + process.env.ROCK_KMON_TOKEN = 'env-token'; + process.env.ROCK_KMON_TENANTS = 'env_tenant1,env_tenant2'; + const resolver = new KmonHostIpResolver(); + const config = (resolver as unknown as { config: Required }).config; + expect(config.tenants).toEqual(['env_tenant1', 'env_tenant2']); + }); + + test('should use custom maxQueryDays', () => { + const resolver = new KmonHostIpResolver({ + token: 'test-token', + maxQueryDays: 14, + }); + const config = (resolver as unknown as { config: Required }).config; + expect(config.maxQueryDays).toBe(14); + }); + + test('should use custom maxQueryRangeMs', () => { + const resolver = new KmonHostIpResolver({ + token: 'test-token', + maxQueryRangeMs: 24 * 60 * 60 * 1000, // 1 day + }); + const config = (resolver as unknown as { config: Required }).config; + expect(config.maxQueryRangeMs).toBe(24 * 60 * 60 * 1000); + }); + + test('parameter should override environment variable', () => { + process.env.ROCK_KMON_TOKEN = 'env-token'; + process.env.ROCK_KMON_BASE_URL = 'https://env.kmon.com'; + + const resolver = new KmonHostIpResolver({ + token: 'param-token', + baseUrl: 'https://param.kmon.com', + }); + const config = (resolver as unknown as { config: Required }).config; + + expect(config.token).toBe('param-token'); + expect(config.baseUrl).toBe('https://param.kmon.com'); + }); + }); + + describe('resolve', () => { + test('should return hostIp when found in default tenant', async () => { + // Response is an array directly (not { results: [...] }) + mockedAxios.post.mockResolvedValueOnce({ + data: [{ + tags: { ip: '192.168.1.100' }, + }], + }); + + const resolver = new KmonHostIpResolver({ token: 'test-token' }); + const hostIp = await resolver.resolve('sandbox-123'); + + expect(hostIp).toBe('192.168.1.100'); + expect(mockedAxios.post).toHaveBeenCalledTimes(1); + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.stringContaining('tenant=default'), + expect.objectContaining({ + queries: expect.arrayContaining([ + expect.objectContaining({ + tags: { ip: '*', sandbox_id: 'sandbox-123' }, + }), + ]), + }), + expect.any(Object) + ); + }); + + test('should fallback to gen_ai tenant when not found in default', async () => { + // First call (default tenant) returns empty array + mockedAxios.post.mockResolvedValueOnce({ + data: [], + }); + // Second call (gen_ai tenant) returns result + mockedAxios.post.mockResolvedValueOnce({ + data: [{ + tags: { ip: '10.0.0.50' }, + }], + }); + + const resolver = new KmonHostIpResolver({ + token: 'test-token', + maxQueryDays: 1, // Reduce to minimize API calls + maxQueryRangeMs: 24 * 60 * 60 * 1000, // 1 day = 1 segment + }); + const hostIp = await resolver.resolve('sandbox-456'); + + expect(hostIp).toBe('10.0.0.50'); + // Should have called both tenants + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.stringContaining('tenant=default'), + expect.any(Object), + expect.any(Object) + ); + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.stringContaining('tenant=gen_ai'), + expect.any(Object), + expect.any(Object) + ); + }); + + test('should throw error when hostIp not found in any tenant', async () => { + // All tenants return empty array + mockedAxios.post.mockResolvedValue({ + data: [], + }); + + const resolver = new KmonHostIpResolver({ + token: 'test-token', + maxQueryDays: 1, + maxQueryRangeMs: 24 * 60 * 60 * 1000, + }); + + await expect(resolver.resolve('sandbox-not-found')).rejects.toThrow( + 'Cannot find hostIp for sandbox sandbox-not-found in any tenant' + ); + }); + + test('should handle API error gracefully and continue to next tenant', async () => { + // First tenant throws error + mockedAxios.post.mockRejectedValueOnce(new Error('Network error')); + // Second tenant returns result + mockedAxios.post.mockResolvedValueOnce({ + data: [{ + tags: { ip: '172.16.0.1' }, + }], + }); + + const resolver = new KmonHostIpResolver({ + token: 'test-token', + maxQueryDays: 1, + maxQueryRangeMs: 24 * 60 * 60 * 1000, + }); + const hostIp = await resolver.resolve('sandbox-789'); + + expect(hostIp).toBe('172.16.0.1'); + }); + + test('should handle empty ip in response', async () => { + mockedAxios.post.mockResolvedValueOnce({ + data: [{ + tags: { ip: undefined }, + }], + }); + mockedAxios.post.mockResolvedValueOnce({ + data: [{ + tags: { ip: '10.0.0.1' }, + }], + }); + + const resolver = new KmonHostIpResolver({ + token: 'test-token', + maxQueryDays: 1, + maxQueryRangeMs: 24 * 60 * 60 * 1000, + }); + const hostIp = await resolver.resolve('sandbox-empty-ip'); + + expect(hostIp).toBe('10.0.0.1'); + }); + }); + + describe('time segmentation', () => { + test('should segment queries when maxQueryDays exceeds maxQueryRangeMs', async () => { + // Configure to require multiple segments + // 3 days / 1 day per segment = 3 segments + mockedAxios.post.mockResolvedValue({ + data: [], // No results, to force all segments to be queried + }); + + const resolver = new KmonHostIpResolver({ + token: 'test-token', + tenants: ['single_tenant'], // Use single tenant to simplify + maxQueryDays: 3, + maxQueryRangeMs: 24 * 60 * 60 * 1000, // 1 day + }); + + try { + await resolver.resolve('sandbox-segments'); + } catch { + // Expected to throw "not found" + } + + // Should have called 3 times (3 segments) + expect(mockedAxios.post).toHaveBeenCalledTimes(3); + }); + + test('should stop querying segments once hostIp is found', async () => { + // First segment returns result immediately + mockedAxios.post.mockResolvedValueOnce({ + data: [{ + tags: { ip: '192.168.1.1' }, + }], + }); + + const resolver = new KmonHostIpResolver({ + token: 'test-token', + tenants: ['single_tenant'], + maxQueryDays: 7, + maxQueryRangeMs: 24 * 60 * 60 * 1000, + }); + + const hostIp = await resolver.resolve('sandbox-early-find'); + + expect(hostIp).toBe('192.168.1.1'); + // Should only have called once (found in first segment) + expect(mockedAxios.post).toHaveBeenCalledTimes(1); + }); + + test('should query correct time ranges', async () => { + const now = Date.now(); + const oneDayMs = 24 * 60 * 60 * 1000; + + mockedAxios.post.mockResolvedValueOnce({ + data: [{ + tags: { ip: '192.168.1.1' }, + }], + }); + + const resolver = new KmonHostIpResolver({ + token: 'test-token', + tenants: ['single_tenant'], + maxQueryDays: 2, + maxQueryRangeMs: 2 * oneDayMs, // 2 days = single segment + }); + + await resolver.resolve('sandbox-time-range'); + + // Verify the time range in the API call + expect(mockedAxios.post).toHaveBeenCalledTimes(1); + const callArgs = mockedAxios.post.mock.calls[0]; + expect(callArgs).toBeDefined(); + const requestBody = callArgs![1] as { start: number; end: number }; + + // start should be ~2 days ago, end should be ~now + expect(requestBody.end).toBeGreaterThanOrEqual(now - 1000); + expect(requestBody.start).toBeLessThan(requestBody.end); + expect(requestBody.end - requestBody.start).toBeLessThanOrEqual(2 * oneDayMs); + }); + }); + + describe('rate limiting', () => { + test('should wait between requests to respect QPS limit', async () => { + // Need at least 2 requests to test rate limiting + mockedAxios.post.mockResolvedValue({ + data: [], + }); + + const resolver = new KmonHostIpResolver({ + token: 'test-token', + tenants: ['single_tenant'], + maxQueryDays: 2, + maxQueryRangeMs: 24 * 60 * 60 * 1000, // 1 day, so 2 segments + }); + + const startTime = Date.now(); + + try { + await resolver.resolve('sandbox-rate-limit'); + } catch { + // Expected to throw "not found" + } + + const elapsed = Date.now() - startTime; + + // Should have 2 requests with at least 200ms between them + expect(mockedAxios.post).toHaveBeenCalledTimes(2); + // Total time should be at least 200ms (1 interval between 2 requests) + expect(elapsed).toBeGreaterThanOrEqual(190); // Allow some tolerance + }); + + test('should not rate limit first request', async () => { + mockedAxios.post.mockResolvedValueOnce({ + data: [{ + tags: { ip: '192.168.1.1' }, + }], + }); + + const resolver = new KmonHostIpResolver({ + token: 'test-token', + tenants: ['single_tenant'], + maxQueryDays: 1, + maxQueryRangeMs: 24 * 60 * 60 * 1000, + }); + + const startTime = Date.now(); + await resolver.resolve('sandbox-first-request'); + const elapsed = Date.now() - startTime; + + // First request should complete quickly (no rate limit wait) + expect(elapsed).toBeLessThan(100); + }); + }); +}); diff --git a/rock/ts-sdk/src/sandbox/kmon.ts b/rock/ts-sdk/src/sandbox/kmon.ts new file mode 100644 index 000000000..5cf67b6e3 --- /dev/null +++ b/rock/ts-sdk/src/sandbox/kmon.ts @@ -0,0 +1,159 @@ +/** + * KmonHostIpResolver - Query hostIp for destroyed sandboxes via Kmon metrics API + */ + +import axios from 'axios'; +import { initLogger } from '../logger.js'; +import { envVars } from '../env_vars.js'; +import { sleep } from '../utils/retry.js'; + +const logger = initLogger('rock.sandbox.kmon'); + +/** + * Kmon configuration + */ +export interface KmonConfig { + /** Kmon API Token (required) */ + token?: string; + /** Kmon API base URL */ + baseUrl?: string; + /** Tenant list to search */ + tenants?: string[]; + /** Maximum query days */ + maxQueryDays?: number; + /** Maximum query range in milliseconds (default: 2 days) */ + maxQueryRangeMs?: number; +} + +/** + * HostIp resolver function type + */ +export type HostIpResolver = (sandboxId: string) => Promise; + +/** + * KmonHostIpResolver - Query hostIp via Kmon metrics API + * + * Features: + * - Token from parameter or ROCK_KMON_TOKEN environment variable + * - Tenant fallback: default -> gen_ai + * - Auto-segment queries (max 2 days per query) + * - Rate limiting (QPS <= 5, interval >= 200ms) + */ +export class KmonHostIpResolver { + private config: Required; + private lastRequestTime: number = 0; + private readonly MIN_REQUEST_INTERVAL = 200; // 1000ms / 5 QPS = 200ms + + constructor(config: KmonConfig = {}) { + const token = config.token ?? envVars.ROCK_KMON_TOKEN; + if (!token) { + throw new Error('ROCK_KMON_TOKEN is required'); + } + + this.config = { + token, + baseUrl: config.baseUrl ?? envVars.ROCK_KMON_BASE_URL ?? 'https://kmon-metric.alibaba-inc.com', + tenants: config.tenants ?? envVars.ROCK_KMON_TENANTS?.split(',') ?? ['default', 'gen_ai'], + maxQueryDays: config.maxQueryDays ?? 7, + maxQueryRangeMs: config.maxQueryRangeMs ?? 2 * 24 * 60 * 60 * 1000, // 2 days + }; + } + + /** + * Resolve sandboxId to hostIp + * - Searches through all tenants + * - Auto-segments time range + * - Rate limited + */ + async resolve(sandboxId: string): Promise { + // Search through each tenant + for (const tenant of this.config.tenants) { + const hostIp = await this.queryWithTenant(sandboxId, tenant); + if (hostIp) { + logger.info(`Found hostIp ${hostIp} for sandbox ${sandboxId} in tenant ${tenant}`); + return hostIp; + } + } + throw new Error(`Cannot find hostIp for sandbox ${sandboxId} in any tenant`); + } + + /** + * Query a specific tenant with auto time segmentation + */ + private async queryWithTenant(sandboxId: string, tenant: string): Promise { + const now = Date.now(); + const maxAgeMs = this.config.maxQueryDays * 24 * 60 * 60 * 1000; + const startTime = now - maxAgeMs; + + // Segment query, max 2 days per segment + let currentStart = startTime; + while (currentStart < now) { + const currentEnd = Math.min(currentStart + this.config.maxQueryRangeMs, now); + + // Rate limit + await this.waitForRateLimit(); + + const hostIp = await this.queryRange(sandboxId, tenant, currentStart, currentEnd); + if (hostIp) { + return hostIp; + } + + currentStart = currentEnd; + } + + return null; + } + + /** + * Wait for rate limit + */ + private async waitForRateLimit(): Promise { + const now = Date.now(); + const elapsed = now - this.lastRequestTime; + if (elapsed < this.MIN_REQUEST_INTERVAL) { + await sleep(this.MIN_REQUEST_INTERVAL - elapsed); + } + this.lastRequestTime = Date.now(); + } + + /** + * Query a specific time range + */ + private async queryRange( + sandboxId: string, + tenant: string, + start: number, + end: number + ): Promise { + const url = `${this.config.baseUrl}/api/query?token=${this.config.token}&tenant=${tenant}`; + + try { + const response = await axios.post(url, { + start, + end, + queries: [{ + metric: 'xrl_gateway.system.cpu', + tags: { ip: '*', sandbox_id: sandboxId }, + downsample: 'avg', + aggregator: 'avg', + granularity: '1m', + }], + }, { + headers: { 'Content-Type': 'application/json' }, + }); + + // Response is an array directly, not { results: [...] } + const data = response.data as Array<{ tags?: { ip?: string } }>; + + // Extract hostIp from response + if (Array.isArray(data) && data.length > 0 && data[0]?.tags?.ip) { + return data[0].tags.ip; + } + + return null; + } catch (e) { + logger.warn(`Kmon query error: ${e}`); + return null; + } + } +} diff --git a/rock/ts-sdk/src/sandbox/logs.test.ts b/rock/ts-sdk/src/sandbox/logs.test.ts new file mode 100644 index 000000000..28c7d8e09 --- /dev/null +++ b/rock/ts-sdk/src/sandbox/logs.test.ts @@ -0,0 +1,195 @@ +/** + * Sandbox Logs unit tests + */ + +import { + validateLogPath, + getLogBasePath, + parseFileList, + buildListCommand, + matchPattern, +} from './logs.js'; + +describe('validateLogPath', () => { + describe('absolute path rejection', () => { + test('should reject path starting with /', () => { + expect(() => validateLogPath('/etc/passwd')).toThrow('logPath must be relative path'); + }); + + test('should reject root path', () => { + expect(() => validateLogPath('/')).toThrow('logPath must be relative path'); + }); + + test('should reject path with leading slash', () => { + expect(() => validateLogPath('/data/logs/app.log')).toThrow('logPath must be relative path'); + }); + }); + + describe('directory traversal rejection', () => { + test('should reject path with ..', () => { + expect(() => validateLogPath('../etc/passwd')).toThrow("logPath cannot contain '..'"); + }); + + test('should reject path with embedded ..', () => { + expect(() => validateLogPath('subdir/../../../etc/passwd')).toThrow("logPath cannot contain '..'"); + }); + + test('should reject just ..', () => { + expect(() => validateLogPath('..')).toThrow("logPath cannot contain '..'"); + }); + + test('should reject .. at end', () => { + expect(() => validateLogPath('subdir/..')).toThrow("logPath cannot contain '..'"); + }); + }); + + describe('valid paths', () => { + test('should accept simple filename', () => { + expect(() => validateLogPath('app.log')).not.toThrow(); + }); + + test('should accept filename with subdirectory', () => { + expect(() => validateLogPath('subdir/app.log')).not.toThrow(); + }); + + test('should accept nested subdirectory path', () => { + expect(() => validateLogPath('a/b/c/app.log')).not.toThrow(); + }); + + test('should accept path with dots in filename', () => { + expect(() => validateLogPath('app.2024.01.01.log')).not.toThrow(); + }); + + test('should accept path with single dot', () => { + expect(() => validateLogPath('./app.log')).not.toThrow(); + }); + }); +}); + +describe('getLogBasePath', () => { + test('should return /data/logs for alive sandbox', () => { + expect(getLogBasePath('sandbox-123', true)).toBe('/data/logs'); + }); + + test('should return /data/logs/{sandboxId} for destroyed sandbox', () => { + expect(getLogBasePath('sandbox-123', false)).toBe('/data/logs/sandbox-123'); + }); + + test('should handle different sandbox IDs', () => { + expect(getLogBasePath('abc-def-123', false)).toBe('/data/logs/abc-def-123'); + expect(getLogBasePath('test_sandbox', false)).toBe('/data/logs/test_sandbox'); + }); +}); + +describe('parseFileList', () => { + test('should parse find output with tab-separated values', () => { + const output = `/data/logs/app.log\t1024\t1711900000.000 +/data/logs/error.log\t512\t1711900100.000`; + + const result = parseFileList(output, '/data/logs'); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + name: 'app.log', + path: 'app.log', + size: 1024, + modifiedTime: expect.any(String), + isDirectory: false, + }); + expect(result[1]).toEqual({ + name: 'error.log', + path: 'error.log', + size: 512, + modifiedTime: expect.any(String), + isDirectory: false, + }); + }); + + test('should handle nested paths', () => { + const output = `/data/logs/subdir/app.log\t1024\t1711900000.000`; + + const result = parseFileList(output, '/data/logs'); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + name: 'app.log', + path: 'subdir/app.log', + size: 1024, + modifiedTime: expect.any(String), + isDirectory: false, + }); + }); + + test('should handle empty output', () => { + const result = parseFileList('', '/data/logs'); + expect(result).toHaveLength(0); + }); + + test('should handle invalid lines', () => { + const output = `invalid line +/data/logs/app.log\t1024\t1711900000.000`; + + const result = parseFileList(output, '/data/logs'); + + expect(result).toHaveLength(1); + }); +}); + +describe('buildListCommand', () => { + test('should build basic find command', () => { + const cmd = buildListCommand('/data/logs'); + + expect(cmd).toContain("find '/data/logs'"); + expect(cmd).toContain('-type f'); + expect(cmd).toContain("-printf '%p\\t%s\\t%T@\\n'"); + }); + + test('should add -maxdepth 1 when recursive is false', () => { + const cmd = buildListCommand('/data/logs', { recursive: false }); + + expect(cmd).toContain('-maxdepth 1'); + }); + + test('should not add -maxdepth when recursive is true', () => { + const cmd = buildListCommand('/data/logs', { recursive: true }); + + expect(cmd).not.toContain('-maxdepth'); + }); + + test('should add -name filter with pattern', () => { + const cmd = buildListCommand('/data/logs', { pattern: '*.log' }); + + expect(cmd).toContain("-name '*.log'"); + }); + + test('should combine options', () => { + const cmd = buildListCommand('/data/logs', { recursive: false, pattern: '*.txt' }); + + expect(cmd).toContain('-maxdepth 1'); + expect(cmd).toContain("-name '*.txt'"); + }); +}); + +describe('matchPattern', () => { + test('should match wildcard pattern *.log', () => { + expect(matchPattern('app.log', '*.log')).toBe(true); + expect(matchPattern('error.log', '*.log')).toBe(true); + expect(matchPattern('app.txt', '*.log')).toBe(false); + }); + + test('should match exact filename', () => { + expect(matchPattern('app.log', 'app.log')).toBe(true); + expect(matchPattern('error.log', 'app.log')).toBe(false); + }); + + test('should match question mark wildcard', () => { + expect(matchPattern('app1.log', 'app?.log')).toBe(true); + expect(matchPattern('app2.log', 'app?.log')).toBe(true); + expect(matchPattern('app.log', 'app?.log')).toBe(false); + }); + + test('should match complex patterns', () => { + expect(matchPattern('app.2024.log', 'app.*.log')).toBe(true); + expect(matchPattern('error.2024.01.01.log', '*.log')).toBe(true); + }); +}); diff --git a/rock/ts-sdk/src/sandbox/logs.ts b/rock/ts-sdk/src/sandbox/logs.ts new file mode 100644 index 000000000..95beedd02 --- /dev/null +++ b/rock/ts-sdk/src/sandbox/logs.ts @@ -0,0 +1,196 @@ +/** + * Sandbox Logs - View and download sandbox log files + * + * Features: + * - List log files in /data/logs/ directory + * - Download log files via OSS + * - Transparent handling of sandbox alive/destroyed state + * - Path security validation + */ + +import { initLogger } from '../logger.js'; +import type { ProgressInfo, DownloadPhase } from '../types/requests.js'; + +const logger = initLogger('rock.sandbox.logs'); + +/** + * Log file information + */ +export interface LogFileInfo { + /** File name */ + name: string; + /** Relative path from /data/logs/ */ + path: string; + /** File size in bytes */ + size: number; + /** Last modified time (ISO 8601) */ + modifiedTime: string; + /** Is directory */ + isDirectory: boolean; +} + +/** + * Options for listing logs + */ +export interface ListLogsOptions { + /** Recursively list subdirectories (default: true) */ + recursive?: boolean; + /** File name pattern (glob-like), e.g. "*.log" */ + pattern?: string; +} + +/** + * Options for downloading logs + */ +export interface DownloadLogOptions { + /** Timeout in milliseconds */ + timeout?: number; + /** Progress callback */ + onProgress?: (info: ProgressInfo) => void; +} + +/** + * Response from downloadLog + */ +export interface DownloadLogResponse { + success: boolean; + message: string; +} + +/** + * Host info with sandbox alive status + */ +export interface HostInfo { + hostIp: string; + isAlive: boolean; +} + +/** + * Validate log path for security + * - Must be relative path + * - Cannot contain ".." + * - Cannot escape /data/logs/ + */ +export function validateLogPath(logPath: string): void { + // Check for absolute path + if (logPath.startsWith('/')) { + throw new Error('logPath must be relative path'); + } + + // Check for directory traversal + if (logPath.includes('..')) { + throw new Error("logPath cannot contain '..'"); + } + + // Normalize and check for escape + const normalized = normalizePath(logPath); + if (normalized.startsWith('..') || normalized.startsWith('/')) { + throw new Error('Invalid logPath'); + } +} + +/** + * Normalize path (remove redundant slashes, resolve . and ..) + */ +function normalizePath(path: string): string { + const parts = path.split('/').filter(p => p !== '' && p !== '.'); + const result: string[] = []; + + for (const part of parts) { + if (part === '..') { + if (result.length > 0 && result[result.length - 1] !== '..') { + result.pop(); + } else { + result.push('..'); + } + } else { + result.push(part); + } + } + + return result.join('/'); +} + +/** + * Get log base path based on sandbox alive status + */ +export function getLogBasePath(sandboxId: string, isAlive: boolean): string { + if (isAlive) { + return '/data/logs'; + } else { + return `/data/logs/${sandboxId}`; + } +} + +/** + * Parse file list output from find or ls command + */ +export function parseFileList(output: string, basePath: string): LogFileInfo[] { + const files: LogFileInfo[] = []; + const lines = output.trim().split('\n').filter(line => line.trim()); + + for (const line of lines) { + // Format: path\tsize\tmodifiedTime (from find -printf '%p\t%s\t%T@\n') + const parts = line.split('\t'); + if (parts.length >= 3) { + const fullPath = parts[0]; + const sizeStr = parts[1]; + const modTimeStr = parts[2]; + + if (!fullPath || !sizeStr || !modTimeStr) { + continue; + } + + const relativePath = fullPath.replace(basePath + '/', ''); + + if (relativePath && relativePath !== basePath) { + files.push({ + name: relativePath.split('/').pop() ?? '', + path: relativePath, + size: parseInt(sizeStr, 10) || 0, + modifiedTime: new Date(parseFloat(modTimeStr) * 1000).toISOString(), + isDirectory: false, + }); + } + } + } + + return files; +} + +/** + * Build find command for listing logs + */ +export function buildListCommand(basePath: string, options?: ListLogsOptions): string { + const recursive = options?.recursive ?? true; + const pattern = options?.pattern; + + let cmd = `find '${basePath}'`; + + if (!recursive) { + cmd += ' -maxdepth 1'; + } + + cmd += ' -type f'; + + if (pattern) { + cmd += ` -name '${pattern}'`; + } + + cmd += " -printf '%p\\t%s\\t%T@\\n'"; + + return cmd; +} + +/** + * Check if a glob pattern matches a filename + */ +export function matchPattern(filename: string, pattern: string): boolean { + // Convert glob pattern to regex + const regexPattern = pattern + .replace(/\./g, '\\.') + .replace(/\*/g, '.*') + .replace(/\?/g, '.'); + + return new RegExp(`^${regexPattern}$`).test(filename); +} diff --git a/rock/ts-sdk/tests/integration/logs.test.ts b/rock/ts-sdk/tests/integration/logs.test.ts new file mode 100644 index 000000000..678f04ec7 --- /dev/null +++ b/rock/ts-sdk/tests/integration/logs.test.ts @@ -0,0 +1,212 @@ +/** + * Integration test for Sandbox Logs operations + * + * Prerequisites: + * - ROCK_BASE_URL environment variable or default baseUrl + * - Access to the ROCK sandbox service + * - ROCK_OSS_ENABLE=true for downloadLog tests + * - Valid OSS configuration (ROCK_OSS_BUCKET_NAME, ROCK_OSS_BUCKET_REGION, etc.) + */ + +import { Sandbox, RunMode } from '../../src'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +const TEST_CONFIG = { + baseUrl: process.env.ROCK_BASE_URL || 'http://11.166.8.116:8080', + image: 'reg.docker.alibaba-inc.com/yanan/python:3.11', + cluster: 'zb', + startupTimeout: 120, +}; + +describe('Sandbox Logs Integration', () => { + let sandbox: Sandbox; + let tempDir: string; + + beforeEach(async () => { + sandbox = new Sandbox(TEST_CONFIG); + await sandbox.start(); + + // Create default session + await sandbox.createSession({ session: 'default', startupSource: [], envEnable: false }); + + // Create test log files in sandbox + await sandbox.arun('mkdir -p /data/logs', { mode: RunMode.NORMAL }); + await sandbox.arun('echo "test log content" > /data/logs/test.log', { mode: RunMode.NORMAL }); + await sandbox.arun('echo "error log" > /data/logs/error.log', { mode: RunMode.NORMAL }); + await sandbox.arun('mkdir -p /data/logs/subdir', { mode: RunMode.NORMAL }); + await sandbox.arun('echo "nested log" > /data/logs/subdir/nested.log', { mode: RunMode.NORMAL }); + + // Create a temporary directory for downloaded files + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rock-logs-test-')); + }, 180000); // 3 minutes timeout for sandbox startup + + afterEach(async () => { + // Cleanup: ensure sandbox is stopped even if test fails + if (sandbox) { + try { + await sandbox.close(); + } catch (e) { + // Ignore cleanup errors + } + } + + // Cleanup local temp directory + if (tempDir && fs.existsSync(tempDir)) { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch (e) { + // Ignore cleanup errors + } + } + }); + + describe('listLogs - Alive Sandbox', () => { + test('should list all log files recursively', async () => { + // Act: List all logs + const files = await sandbox.listLogs(); + + // Assert: Should find all log files + expect(files.length).toBeGreaterThanOrEqual(3); + const fileNames = files.map(f => f.name); + expect(fileNames).toContain('test.log'); + expect(fileNames).toContain('error.log'); + expect(fileNames).toContain('nested.log'); + }, 60000); + + test('should list files with pattern filter', async () => { + // Act: List only *.log files (should include all since they're all .log) + const files = await sandbox.listLogs({ pattern: '*.log' }); + + // Assert: Should find log files matching pattern + expect(files.length).toBeGreaterThanOrEqual(3); + const fileNames = files.map(f => f.name); + expect(fileNames).toContain('test.log'); + expect(fileNames).toContain('error.log'); + }, 60000); + + test('should list files non-recursively', async () => { + // Act: List only top-level files (no recursion) + const files = await sandbox.listLogs({ recursive: false }); + + // Assert: Should find only top-level files (test.log, error.log) + const fileNames = files.map(f => f.name); + expect(fileNames).toContain('test.log'); + expect(fileNames).toContain('error.log'); + // Should NOT include nested.log + expect(fileNames).not.toContain('nested.log'); + }, 60000); + + test('should return file metadata', async () => { + // Act + const files = await sandbox.listLogs({ pattern: 'test.log' }); + + // Assert: File metadata should be present + expect(files.length).toBeGreaterThanOrEqual(1); + const testLog = files.find(f => f.name === 'test.log'); + expect(testLog).toBeDefined(); + expect(testLog!.size).toBeGreaterThan(0); + expect(testLog!.modifiedTime).toBeTruthy(); + expect(testLog!.path).toBe('test.log'); + expect(testLog!.isDirectory).toBe(false); + }, 60000); + + test('should return empty array for empty directory', async () => { + // Arrange: Create empty log directory + await sandbox.arun('rm -rf /data/logs/*', { mode: RunMode.NORMAL }); + + // Act + const files = await sandbox.listLogs(); + + // Assert + expect(files).toEqual([]); + }, 60000); + }); + + describe('downloadLog - Alive Sandbox', () => { + // Skip these tests if OSS is not enabled + const ossEnabled = process.env.ROCK_OSS_ENABLE === 'true'; + const conditionalTest = ossEnabled ? test : test.skip; + + conditionalTest('should download a log file to local', async () => { + // Arrange + const localPath = path.join(tempDir, 'downloaded.log'); + + // Act + const result = await sandbox.downloadLog('test.log', localPath); + + // Assert + expect(result.success).toBe(true); + expect(fs.existsSync(localPath)).toBe(true); + const content = fs.readFileSync(localPath, 'utf-8'); + expect(content).toContain('test log content'); + }, 120000); + + conditionalTest('should download a nested log file', async () => { + // Arrange + const localPath = path.join(tempDir, 'nested.log'); + + // Act + const result = await sandbox.downloadLog('subdir/nested.log', localPath); + + // Assert + expect(result.success).toBe(true); + expect(fs.existsSync(localPath)).toBe(true); + const content = fs.readFileSync(localPath, 'utf-8'); + expect(content).toContain('nested log'); + }, 120000); + + test('should reject absolute path', async () => { + // Arrange + const localPath = path.join(tempDir, 'test.log'); + + // Act & Assert + await expect( + sandbox.downloadLog('/data/logs/test.log', localPath) + ).rejects.toThrow('logPath must be relative path'); + }, 60000); + + test('should reject path with directory traversal', async () => { + // Arrange + const localPath = path.join(tempDir, 'test.log'); + + // Act & Assert + await expect( + sandbox.downloadLog('../etc/passwd', localPath) + ).rejects.toThrow("logPath cannot contain '..'"); + }, 60000); + }); +}); + +/** + * Integration tests for destroyed sandbox logs + * + * NOTE: These tests require Kmon API access which may not be available in CI. + * They are intended to be run manually with proper Kmon configuration: + * - ROCK_KMON_TOKEN environment variable + * - Network access to kmon-metric.alibaba-inc.com + */ +describe.skip('Sandbox Logs Integration - Destroyed Sandbox', () => { + // These tests require a previously destroyed sandbox and Kmon access + // They are skipped by default as they need manual setup + + test('should list logs from destroyed sandbox via Kmon', async () => { + // Setup: Configure Kmon resolver + await Sandbox.useKmonHostIpResolver(); + + // Create a sandbox instance without starting + // (in real scenario, you would have the sandboxId of a destroyed sandbox) + const sandbox = new Sandbox({ + ...TEST_CONFIG, + // sandboxId would be set from a destroyed sandbox + }); + + // This test requires a real destroyed sandbox to work + // The resolver would query Kmon to get the hostIp + }); + + test('should download log from destroyed sandbox via Kmon', async () => { + // Similar to above, requires real destroyed sandbox + }); +});