Skip to content
Open
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
34 changes: 34 additions & 0 deletions rock/ts-sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions rock/ts-sdk/src/env_vars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
},
};

/**
Expand Down
216 changes: 215 additions & 1 deletion rock/ts-sdk/src/sandbox/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -1002,6 +1007,215 @@ export class Sandbox extends AbstractSandbox {
});
}

// ========== Logs Methods ==========

/**
* Static host IP resolver for destroyed sandboxes
*/
private static hostIpResolver: ((sandboxId: string) => Promise<string>) | null = null;

/**
* Set custom host IP resolver for destroyed sandboxes
*/
static setHostIpResolver(resolver: (sandboxId: string) => Promise<string>): void {
Sandbox.hostIpResolver = resolver;
}

/**
* Use built-in Kmon resolver
*/
static async useKmonHostIpResolver(config?: import('./kmon.js').KmonConfig): Promise<void> {
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<import('./logs.js').HostInfo> {
// 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<import('../types/responses.js').CommandResponse> {
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<import('./logs.js').LogFileInfo[]> {
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<import('./logs.js').DownloadLogResponse> {
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<import('./logs.js').DownloadLogResponse> {
// 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<void> {
await this.stop();
Expand Down
5 changes: 3 additions & 2 deletions rock/ts-sdk/src/sandbox/constants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
25 changes: 11 additions & 14 deletions rock/ts-sdk/src/sandbox/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,28 +23,25 @@ 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
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/
Expand All @@ -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
`;
2 changes: 2 additions & 0 deletions rock/ts-sdk/src/sandbox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading
Loading