diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..20c939f5 --- /dev/null +++ b/.env.example @@ -0,0 +1,33 @@ +# Copy this file to .env in the project root before running sandbox examples +# or sandbox integration tests. + +# Sandbox API key auth. +QINIU_SANDBOX_API_KEY= + +# Optional custom endpoint. +QINIU_SANDBOX_ENDPOINT= + +# Optional template alias or ID. Defaults to base. +QINIU_SANDBOX_TEMPLATE=base + +# Required only for injection-rule and Kodo resource examples. +QINIU_SANDBOX_ACCESS_KEY= +QINIU_SANDBOX_SECRET_KEY= + +# Optional Git remote examples. +GIT_REPO_URL= +GIT_USERNAME= +GIT_PASSWORD= + +# Optional Git repository resource example. +GITHUB_TOKEN= +QINIU_SANDBOX_GIT_MOUNT_PATH=/workspace/repo + +# Optional Kodo resource example. +QINIU_SANDBOX_KODO_BUCKET= +QINIU_SANDBOX_KODO_MOUNT_PATH=/workspace/kodo +QINIU_SANDBOX_KODO_PREFIX= + +# Optional request injection examples. +QINIU_SANDBOX_HTTP_INJECTION_TOKEN=real_token +QINIU_SANDBOX_OPENAI_API_KEY= diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 7afa6927..8c608cd3 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -24,7 +24,12 @@ jobs: - name: Run cases run: | npm run check-type - nyc --reporter=lcov npm test + nyc --reporter=lcov --include 'qiniu/sandbox/**/*.js' npm run test:sandbox + if [ -n "$QINIU_ACCESS_KEY" ] && [ -n "$QINIU_SECRET_KEY" ] && [ -n "$QINIU_TEST_BUCKET" ] && [ -n "$QINIU_TEST_DOMAIN" ]; then + nyc --no-clean --reporter=lcov npm test + else + npm test + fi env: QINIU_ACCESS_KEY: ${{ secrets.QINIU_ACCESS_KEY }} QINIU_SECRET_KEY: ${{ secrets.QINIU_SECRET_KEY }} diff --git a/.gitignore b/.gitignore index 918f1ad7..438e513b 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ coverage/ yarn.lock .claude/settings.local.json +.env diff --git a/examples/sandbox_common.js b/examples/sandbox_common.js new file mode 100644 index 00000000..2e357600 --- /dev/null +++ b/examples/sandbox_common.js @@ -0,0 +1,135 @@ +const fs = require('fs'); +const path = require('path'); + +const qiniu = require('../index'); +const { shellQuote } = require('../qiniu/sandbox/util'); + +function loadDotEnvIfPresent () { + const files = [ + path.join(process.cwd(), '.env') + ]; + + files.forEach(filepath => { + if (!fs.existsSync(filepath)) { + return; + } + + fs.readFileSync(filepath, 'utf8') + .split(/\r?\n/) + .forEach(line => { + line = line.trim(); + if (!line || line[0] === '#') { + return; + } + const index = line.indexOf('='); + if (index < 0) { + return; + } + const key = line.slice(0, index).trim(); + let value = line.slice(index + 1).trim(); + if ( + (value[0] === '"' && value[value.length - 1] === '"') || + (value[0] === '\'' && value[value.length - 1] === '\'') + ) { + value = value.slice(1, -1); + } + if (process.env[key] === undefined) { + process.env[key] = value; + } + }); + }); +} + +function env (key, fallback) { + return process.env[key] || fallback; +} + +function requiredEnv (key) { + if (process.env[key]) { + return process.env[key]; + } + throw new Error(`Please set ${key}`); +} + +function sandboxEndpoint () { + return env('QINIU_SANDBOX_ENDPOINT'); +} + +function sandboxApiKey () { + return requiredEnv('QINIU_SANDBOX_API_KEY'); +} + +function sandboxTemplate () { + return env('QINIU_SANDBOX_TEMPLATE', 'base'); +} + +function sandboxClient (options) { + options = Object.assign({ + endpoint: sandboxEndpoint(), + apiKey: sandboxApiKey() + }, options || {}); + return new qiniu.sandbox.SandboxClient(options); +} + +function sandboxMac () { + const accessKey = requiredEnv('QINIU_SANDBOX_ACCESS_KEY'); + const secretKey = requiredEnv('QINIU_SANDBOX_SECRET_KEY'); + return new qiniu.auth.digest.Mac(accessKey, secretKey); +} + +function createSandboxAndWait (options, pollOptions) { + const client = options && options.client ? options.client : sandboxClient(); + const params = Object.assign({ + client, + template: sandboxTemplate(), + timeout: 300 + }, options || {}); + + return qiniu.sandbox.Sandbox.create(params).then(sandbox => { + console.log('Sandbox created:', sandbox.sandboxId); + return sandbox.waitForReady(Object.assign({ + interval: 3000, + timeout: 180000 + }, pollOptions || {})).then(info => { + console.log('Sandbox ready:', sandbox.sandboxId, info.state || ''); + return sandbox; + }); + }); +} + +function cleanupSandbox (sandbox) { + if (!sandbox) { + return Promise.resolve(); + } + return sandbox.kill() + .then(() => { + console.log('Sandbox killed:', sandbox.sandboxId); + }, err => { + console.log('Failed to kill sandbox:', sandbox.sandboxId, err.message); + }); +} + +function runExample (fn) { + loadDotEnvIfPresent(); + Promise.resolve() + .then(fn) + .catch(err => { + console.error(err && err.stack ? err.stack : err); + process.exitCode = 1; + }); +} + +module.exports = { + qiniu, + shellQuote, + env, + requiredEnv, + sandboxEndpoint, + sandboxApiKey, + sandboxTemplate, + sandboxClient, + sandboxMac, + createSandboxAndWait, + cleanupSandbox, + runExample +}; diff --git a/examples/sandbox_create.js b/examples/sandbox_create.js new file mode 100644 index 00000000..48b09d48 --- /dev/null +++ b/examples/sandbox_create.js @@ -0,0 +1,25 @@ +const { + qiniu, + sandboxClient, + sandboxTemplate, + cleanupSandbox, + runExample +} = require('./sandbox_common'); + +runExample(() => { + const client = sandboxClient(); + let sandbox; + + return qiniu.sandbox.Sandbox.create(sandboxTemplate(), { + client, + timeout: 300, + metadata: { + example: 'sandbox_create' + } + }).then(created => { + sandbox = created; + console.log('Sandbox created:', sandbox.sandboxId); + console.log('Template:', sandbox.info.templateID || sandbox.info.template_id || sandboxTemplate()); + return cleanupSandbox(sandbox); + }); +}); diff --git a/examples/sandbox_envd.js b/examples/sandbox_envd.js new file mode 100644 index 00000000..62df8663 --- /dev/null +++ b/examples/sandbox_envd.js @@ -0,0 +1,68 @@ +const { + shellQuote, + createSandboxAndWait, + cleanupSandbox, + runExample +} = require('./sandbox_common'); + +runExample(() => { + let sandbox; + const workdir = '/tmp/qiniu-nodejs-sdk-envd'; + const filePath = `${workdir}/hello.txt`; + + return createSandboxAndWait({ + metadata: { + example: 'sandbox_envd' + } + }).then(created => { + sandbox = created; + console.log('Public host for port 8080:', sandbox.getHost(8080)); + return sandbox.commands.run(`mkdir -p ${shellQuote(workdir)}`); + }).then(result => { + console.log('mkdir exit:', result.exitCode); + return sandbox.files.write(filePath, 'Hello from Qiniu Node.js SDK\n'); + }).then(entry => { + console.log('Wrote:', entry.path || entry); + return sandbox.files.readText(filePath); + }).then(text => { + console.log('ReadText:', JSON.stringify(text)); + return sandbox.files.writeFiles([ + { path: `${workdir}/batch-a.txt`, data: 'file A content\n' }, + { path: `${workdir}/batch-b.txt`, data: 'file B content\n' } + ]); + }).then(entries => { + console.log('WriteFiles:', entries.map(item => item.path)); + return sandbox.files.read(`${workdir}/batch-a.txt`, { format: 'bytes' }); + }).then(bytes => { + console.log('Read bytes:', bytes.length); + return sandbox.files.list(workdir); + }).then(entries => { + console.log('List:', entries.map(item => `${item.type}:${item.name}`)); + return sandbox.files.exists(filePath); + }).then(exists => { + console.log('Exists:', exists); + return sandbox.files.getInfo(filePath); + }).then(info => { + console.log('GetInfo:', info.name, info.size); + return sandbox.files.rename(`${workdir}/batch-b.txt`, `${workdir}/batch-b-renamed.txt`); + }).then(info => { + console.log('Renamed to:', info.path); + return sandbox.files.remove(`${workdir}/batch-b-renamed.txt`); + }).then(() => { + console.log('Removed renamed file'); + return sandbox.commands.run('echo $MY_VAR && pwd', { + cwd: workdir, + envs: { + MY_VAR: 'sandbox-value' + } + }); + }).then(result => { + console.log('Command exit:', result.exitCode); + console.log('stdout:\n' + result.stdout); + return cleanupSandbox(sandbox); + }, err => { + return cleanupSandbox(sandbox).then(() => { + throw err; + }); + }); +}); diff --git a/examples/sandbox_filesystem_encoding.js b/examples/sandbox_filesystem_encoding.js new file mode 100644 index 00000000..55d25c0c --- /dev/null +++ b/examples/sandbox_filesystem_encoding.js @@ -0,0 +1,34 @@ +const { + createSandboxAndWait, + cleanupSandbox, + runExample +} = require('./sandbox_common'); + +runExample(() => { + let sandbox; + const filePath = '/tmp/qiniu-nodejs-sdk-encoding.txt'; + + return createSandboxAndWait({ + metadata: { + example: 'sandbox_filesystem_encoding' + } + }).then(created => { + sandbox = created; + return sandbox.files.write(filePath, 'hello with octet-stream and gzip\n', { + useOctetStream: true, + gzip: true + }); + }).then(entry => { + console.log('Wrote:', entry.path || entry); + return sandbox.files.read(filePath, { + gzip: true + }); + }).then(text => { + console.log('Read:', JSON.stringify(text)); + return cleanupSandbox(sandbox); + }, err => { + return cleanupSandbox(sandbox).then(() => { + throw err; + }); + }); +}); diff --git a/examples/sandbox_git.js b/examples/sandbox_git.js new file mode 100644 index 00000000..1cb0e07b --- /dev/null +++ b/examples/sandbox_git.js @@ -0,0 +1,177 @@ +const path = require('path'); + +const { + env, + shellQuote, + createSandboxAndWait, + cleanupSandbox, + runExample +} = require('./sandbox_common'); + +function remoteGitConfigured () { + return gitRepoUrl() && gitUsername() && gitPassword(); +} + +function gitRepoUrl () { + return env('GIT_REPO_URL'); +} + +function gitUsername () { + return env('GIT_USERNAME'); +} + +function gitPassword () { + return env('GIT_PASSWORD'); +} + +function assertGitOK (step, result) { + if (!result || result.exitCode !== 0) { + throw new Error(`${step} failed with exit ${result && result.exitCode}: ${(result && (result.stderr || result.stdout)) || ''}`); + } + console.log(`${step}:`, result.exitCode); + return result; +} + +runExample(() => { + let sandbox; + let defaultBranch = 'master'; + const repoPath = '/tmp/qiniu-nodejs-sdk-git/repo'; + const remotePath = '/tmp/qiniu-nodejs-sdk-git/remote.git'; + const clonePath = '/tmp/qiniu-nodejs-sdk-git/clone'; + + return createSandboxAndWait({ + metadata: { + example: 'sandbox_git' + } + }).then(created => { + sandbox = created; + return sandbox.commands.run('mkdir -p /tmp/qiniu-nodejs-sdk-git /tmp/qiniu-nodejs-sdk-git/repo /tmp/qiniu-nodejs-sdk-git/remote.git'); + }).then(() => { + return sandbox.git.init(repoPath); + }).then(result => { + assertGitOK('git init', result); + return sandbox.git.init(remotePath, { bare: true }); + }).then(result => { + assertGitOK('git init --bare', result); + return sandbox.git.configureUser(repoPath, 'Sandbox Demo', 'sandbox-demo@example.com'); + }).then(result => { + assertGitOK('configure user', result); + return sandbox.files.write(`${repoPath}/README.md`, '# sandbox git demo\n'); + }).then(() => { + return sandbox.git.add(repoPath, { all: true }); + }).then(result => { + assertGitOK('git add', result); + return sandbox.git.commit(repoPath, 'feat: initial commit', { + authorName: 'Sandbox Demo', + authorEmail: 'sandbox-demo@example.com' + }); + }).then(result => { + assertGitOK('commit', result); + return sandbox.git.status(repoPath); + }).then(status => { + defaultBranch = status.currentBranch || defaultBranch; + return sandbox.git.remoteAdd(repoPath, 'origin', remotePath, { overwrite: true }); + }).then(() => { + return sandbox.git.push(repoPath, { remote: 'origin', branch: defaultBranch }); + }).then(result => { + assertGitOK('push local bare remote', result); + return sandbox.git.clone(remotePath, { path: clonePath }); + }).then(result => { + assertGitOK('clone local bare remote', result); + return sandbox.git.branches(clonePath); + }).then(branches => { + console.log('branches:', branches); + return sandbox.git.configureUser(clonePath, 'Sandbox Demo', 'sandbox-demo@example.com'); + }).then(result => { + assertGitOK('configure clone user', result); + return sandbox.git.createBranch(clonePath, 'feature/example'); + }).then(result => { + assertGitOK('create branch', result); + return sandbox.files.write(`${clonePath}/feature.txt`, 'hello feature\n'); + }).then(() => { + return sandbox.git.add(clonePath, { files: ['feature.txt'] }); + }).then(result => { + assertGitOK('git add feature', result); + return sandbox.git.commit(clonePath, 'feat: add feature file', { allowEmpty: false }); + }).then(result => { + assertGitOK('commit feature', result); + return sandbox.git.status(clonePath); + }).then(status => { + console.log('status raw:\n' + status.raw); + return sandbox.git.checkoutBranch(clonePath, defaultBranch); + }).then(result => { + assertGitOK('checkout default branch', result); + return sandbox.git.deleteBranch(clonePath, 'feature/example', { force: true }); + }).then(result => { + assertGitOK('delete branch', result); + return sandbox.files.write(`${clonePath}/dirty.txt`, 'dirty\n'); + }).then(() => { + return sandbox.git.add(clonePath, { files: ['dirty.txt'] }); + }).then(result => { + assertGitOK('git add dirty', result); + return sandbox.git.restore(clonePath, { staged: true, paths: ['dirty.txt'] }); + }).then(result => { + assertGitOK('restore staged', result); + return sandbox.git.reset(clonePath, { hard: true, ref: 'HEAD' }); + }).then(result => { + assertGitOK('reset hard', result); + if (!remoteGitConfigured()) { + console.log('Skip HTTPS clone: set GIT_REPO_URL, GIT_USERNAME and GIT_PASSWORD.'); + return null; + } + + const remoteRepo = gitRepoUrl(); + const remoteClonePath = '/tmp/qiniu-nodejs-sdk-git/remote-clone'; + const remoteGitOptions = { + timeout: 120000, + config: { + 'http.version': 'HTTP/1.1' + } + }; + return sandbox.git.clone(remoteRepo, { + path: remoteClonePath, + depth: 1, + username: gitUsername(), + password: gitPassword(), + timeout: remoteGitOptions.timeout, + config: remoteGitOptions.config + }).then(cloneResult => { + assertGitOK('HTTPS clone', cloneResult); + return sandbox.git.remoteGet(remoteClonePath, 'origin'); + }).then(origin => { + console.log('sanitized origin:', origin); + const filename = `nodejs-sdk-example-${Date.now()}.txt`; + const branch = `nodejs-sdk-example-${Date.now()}`; + return sandbox.git.configureUser(remoteClonePath, 'Sandbox Demo', 'sandbox-demo@example.com') + .then(result => { + assertGitOK('configure remote clone user', result); + return sandbox.git.createBranch(remoteClonePath, branch); + }) + .then(result => { + assertGitOK('create remote push branch', result); + return sandbox.files.write(path.join(remoteClonePath, filename), 'sandbox git push example\n'); + }) + .then(() => sandbox.git.add(remoteClonePath, { files: [filename] })) + .then(result => { + assertGitOK('add remote push file', result); + return sandbox.git.commit(remoteClonePath, 'test: sandbox git example'); + }) + .then(result => { + assertGitOK('commit remote push file', result); + return sandbox.git.status(remoteClonePath); + }) + .then(status => { + console.log('remote clone status raw:\n' + status.raw); + }); + }); + }).then(() => { + return sandbox.commands.run(`find ${shellQuote('/tmp/qiniu-nodejs-sdk-git')} -maxdepth 2 -type d | sort`); + }).then(result => { + console.log(result.stdout); + return cleanupSandbox(sandbox); + }, err => { + return cleanupSandbox(sandbox).then(() => { + throw err; + }); + }); +}); diff --git a/examples/sandbox_injection_rules.js b/examples/sandbox_injection_rules.js new file mode 100644 index 00000000..0b45f673 --- /dev/null +++ b/examples/sandbox_injection_rules.js @@ -0,0 +1,85 @@ +const { + qiniu, + env, + sandboxEndpoint, + sandboxApiKey, + sandboxMac, + createSandboxAndWait, + cleanupSandbox, + runExample +} = require('./sandbox_common'); + +runExample(() => { + const client = new qiniu.sandbox.SandboxClient({ + endpoint: sandboxEndpoint(), + apiKey: sandboxApiKey(), + mac: sandboxMac() + }); + const ruleName = `nodejs-sdk-example-${Date.now()}`; + let ruleID; + let sandbox; + + return client.createInjectionRule({ + name: ruleName, + injection: { + type: 'http', + baseUrl: 'https://httpbin.org', + headers: { + Authorization: `Bearer ${env('QINIU_SANDBOX_HTTP_INJECTION_TOKEN', 'real_token')}` + } + } + }).then(rule => { + ruleID = rule.id || rule.ruleID || rule.rule_id; + console.log('Injection rule created:', ruleID); + return client.getInjectionRule(ruleID); + }).then(rule => { + console.log('Injection rule detail:', rule.name || rule); + return client.updateInjectionRule(ruleID, { + name: `${ruleName}-updated`, + injection: { + type: 'http', + baseUrl: 'https://httpbin.org', + headers: { + Authorization: `Bearer ${env('QINIU_SANDBOX_HTTP_INJECTION_TOKEN', 'updated_token')}`, + 'X-Sandbox-Example': 'qiniu-nodejs-sdk' + } + } + }); + }).then(updated => { + console.log('Injection rule updated:', updated.name || updated); + return client.listInjectionRules(); + }).then(rules => { + console.log('Injection rules:', Array.isArray(rules) ? rules.length : rules); + return createSandboxAndWait({ + client, + injections: [ + { + type: 'id', + id: ruleID + } + ], + metadata: { + example: 'sandbox_injection_rules' + } + }); + }).then(created => { + sandbox = created; + return sandbox.commands.run('curl --max-time 20 -sSL https://httpbin.org/bearer -H "Authorization: Bearer fake_token"', { + timeout: 30000 + }); + }).then(result => { + console.log('curl exit:', result.exitCode); + console.log(result.stdout); + return cleanupSandbox(sandbox); + }).then(() => { + return client.deleteInjectionRule(ruleID); + }).then(() => { + console.log('Injection rule deleted:', ruleID); + }, err => { + return cleanupSandbox(sandbox) + .then(() => ruleID ? client.deleteInjectionRule(ruleID).catch(() => null) : null) + .then(() => { + throw err; + }); + }); +}); diff --git a/examples/sandbox_lifecycle.js b/examples/sandbox_lifecycle.js new file mode 100644 index 00000000..adaf4942 --- /dev/null +++ b/examples/sandbox_lifecycle.js @@ -0,0 +1,58 @@ +const { + qiniu, + sandboxClient, + sandboxTemplate, + cleanupSandbox, + runExample +} = require('./sandbox_common'); + +runExample(() => { + const client = sandboxClient(); + let sandbox; + + return client.listTemplates({ limit: 5 }).then(templates => { + console.log('Templates:', Array.isArray(templates) ? templates.length : templates); + return qiniu.sandbox.Sandbox.create({ + client, + template: sandboxTemplate(), + timeout: 300, + metadata: { + example: 'sandbox_lifecycle' + }, + network: { + allowOut: [qiniu.sandbox.ALL_TRAFFIC] + } + }); + }).then(created => { + sandbox = created; + return sandbox.waitForReady({ + interval: 3000, + timeout: 180000 + }); + }).then(info => { + console.log('Sandbox ready:', sandbox.sandboxId, info.state || ''); + console.log('Host for port 8080:', sandbox.getHost(8080)); + return sandbox.isRunning(); + }).then(running => { + console.log('Is running:', running); + return sandbox.setTimeout({ timeout: 300 }); + }).then(() => { + console.log('Timeout updated'); + return sandbox.refresh({ duration: 300 }); + }).then(() => { + console.log('Sandbox refreshed'); + return sandbox.updateNetwork({ + allowOut: [qiniu.sandbox.ALL_TRAFFIC] + }).then(info => { + console.log('Network updated:', info.network || info); + }, err => { + console.log('Network update skipped:', err.message); + }); + }).then(() => { + return cleanupSandbox(sandbox); + }, err => { + return cleanupSandbox(sandbox).then(() => { + throw err; + }); + }); +}); diff --git a/examples/sandbox_list_connect.js b/examples/sandbox_list_connect.js new file mode 100644 index 00000000..21f11684 --- /dev/null +++ b/examples/sandbox_list_connect.js @@ -0,0 +1,39 @@ +const { + qiniu, + sandboxClient, + cleanupSandbox, + runExample +} = require('./sandbox_common'); + +runExample(() => { + const client = sandboxClient(); + let sandbox; + + return qiniu.sandbox.Sandbox.create({ + client, + timeout: 300, + metadata: { + example: 'sandbox_list_connect' + } + }).then(created => { + sandbox = created; + console.log('Created:', sandbox.sandboxId); + return qiniu.sandbox.Sandbox.list({ + client, + limit: 10 + }); + }).then(items => { + console.log('List result:', Array.isArray(items) ? items.map(item => item.sandboxId || item.sandboxID) : items); + return qiniu.sandbox.Sandbox.connect(sandbox.sandboxId, { + client, + timeout: 300 + }); + }).then(connected => { + console.log('Connected:', connected.sandboxId); + return cleanupSandbox(sandbox); + }, err => { + return cleanupSandbox(sandbox).then(() => { + throw err; + }); + }); +}); diff --git a/examples/sandbox_metrics_logs.js b/examples/sandbox_metrics_logs.js new file mode 100644 index 00000000..3f12ffab --- /dev/null +++ b/examples/sandbox_metrics_logs.js @@ -0,0 +1,37 @@ +const { + createSandboxAndWait, + cleanupSandbox, + runExample +} = require('./sandbox_common'); + +function wait (ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +runExample(() => { + let sandbox; + + return createSandboxAndWait({ + metadata: { + example: 'sandbox_metrics_logs' + } + }).then(created => { + sandbox = created; + return sandbox.commands.run('echo "hello metrics and logs"'); + }).then(result => { + console.log('Command exit:', result.exitCode); + return wait(5000); + }).then(() => { + return sandbox.getMetrics(); + }).then(metrics => { + console.log('Metrics:', metrics); + return sandbox.getLogs(); + }).then(logs => { + console.log('Logs:', logs); + return cleanupSandbox(sandbox); + }, err => { + return cleanupSandbox(sandbox).then(() => { + throw err; + }); + }); +}); diff --git a/examples/sandbox_paginator_snapshot_mcp.js b/examples/sandbox_paginator_snapshot_mcp.js new file mode 100644 index 00000000..1c612572 --- /dev/null +++ b/examples/sandbox_paginator_snapshot_mcp.js @@ -0,0 +1,67 @@ +const { + qiniu, + sandboxClient, + createSandboxAndWait, + cleanupSandbox, + runExample +} = require('./sandbox_common'); + +runExample(() => { + const client = sandboxClient(); + let sandbox; + + return createSandboxAndWait({ + client, + metadata: { + example: 'sandbox_paginator_snapshot_mcp' + } + }).then(created => { + sandbox = created; + + const paginator = qiniu.sandbox.Sandbox.list({ + client, + limit: 5, + query: { + metadata: { + example: 'sandbox_paginator_snapshot_mcp' + }, + state: ['running'] + } + }); + + return paginator.nextItems().then(items => { + console.log('First page:', items.map(item => item.sandboxId)); + console.log('Has next page:', paginator.hasNext); + console.log('Next token:', paginator.nextToken || ''); + }); + }).then(() => { + return sandbox.createSnapshot({ + name: `nodejs-sdk-example-${Date.now()}` + }).then(snapshot => { + console.log('Snapshot created:', snapshot.snapshotId || snapshot.snapshotID || snapshot); + }, err => { + console.log('Snapshot skipped:', err.message); + }); + }).then(() => { + return sandbox.listSnapshots({ + limit: 5 + }).nextItems().then(snapshots => { + console.log('Snapshots:', snapshots.map(item => item.snapshotId || item.snapshotID || item.id)); + }, err => { + console.log('List snapshots skipped:', err.message); + }); + }).then(() => { + console.log('MCP URL:', sandbox.getMcpUrl()); + return sandbox.getMcpToken().then(token => { + console.log('MCP token:', token ? '' : ''); + }, err => { + console.log('MCP token skipped:', err.message); + }); + }).then(() => { + return cleanupSandbox(sandbox); + }, err => { + return cleanupSandbox(sandbox).then(() => { + throw err; + }); + }); +}); diff --git a/examples/sandbox_pty.js b/examples/sandbox_pty.js new file mode 100644 index 00000000..37533f1e --- /dev/null +++ b/examples/sandbox_pty.js @@ -0,0 +1,68 @@ +const { + createSandboxAndWait, + cleanupSandbox, + runExample +} = require('./sandbox_common'); + +runExample(() => { + let sandbox; + let handle; + const output = []; + + return createSandboxAndWait({ + metadata: { + example: 'sandbox_pty' + } + }).then(created => { + sandbox = created; + return sandbox.pty.create({ + cols: 80, + rows: 24, + onData: data => { + output.push(Buffer.from(data).toString()); + } + }); + }).then(createdHandle => { + handle = createdHandle; + console.log('PTY pid:', handle.pid); + return sandbox.pty.sendInput(handle.pid, 'echo hello from pty\n'); + }, err => { + console.log('PTY create skipped:', err.message); + return null; + }).then(() => { + if (!handle) { + return null; + } + return sandbox.pty.resize(handle.pid, { + cols: 100, + rows: 30 + }); + }).then(() => { + if (!handle) { + return null; + } + return sandbox.pty.sendInput(handle.pid, 'exit\n'); + }).then(() => { + if (!handle) { + return null; + } + return handle.wait(); + }).then(result => { + if (result) { + console.log('PTY exit:', result.exitCode); + } + console.log('PTY output:\n' + output.join('')); + return cleanupSandbox(sandbox); + }, err => { + if (handle) { + return handle.disconnect().then(() => { + console.log('PTY input/update skipped:', err.message); + console.log('PTY output:\n' + output.join('')); + return cleanupSandbox(sandbox); + }); + } + return cleanupSandbox(sandbox).then(() => { + throw err; + }); + }); +}); diff --git a/examples/sandbox_request_injections.js b/examples/sandbox_request_injections.js new file mode 100644 index 00000000..189dde77 --- /dev/null +++ b/examples/sandbox_request_injections.js @@ -0,0 +1,61 @@ +const { + env, + createSandboxAndWait, + cleanupSandbox, + runExample +} = require('./sandbox_common'); + +runExample(() => { + let sandbox; + + return createSandboxAndWait({ + injections: [ + { + type: 'http', + base_url: 'https://httpbin.org', + headers: { + Authorization: `Bearer ${env('QINIU_SANDBOX_HTTP_INJECTION_TOKEN', 'real_token')}` + } + } + ], + metadata: { + example: 'sandbox_request_injections' + } + }).then(created => { + sandbox = created; + return sandbox.commands.run('curl --max-time 20 -sSL https://httpbin.org/bearer -H "Authorization: Bearer fake_token"', { + timeout: 30000 + }); + }).then(result => { + console.log('HTTP injection curl exit:', result.exitCode); + console.log(result.stdout); + if (!env('QINIU_SANDBOX_OPENAI_API_KEY')) { + console.log('Skip OpenAI-compatible injection: set QINIU_SANDBOX_OPENAI_API_KEY to try it.'); + return null; + } + return cleanupSandbox(sandbox).then(() => { + return createSandboxAndWait({ + injections: [ + { + type: 'openai', + api_key: env('QINIU_SANDBOX_OPENAI_API_KEY') + } + ], + metadata: { + example: 'sandbox_openai_injection' + } + }); + }).then(created => { + sandbox = created; + return sandbox.commands.run('python3 - <<\'PY\'\nimport os\nprint("OpenAI-compatible request injection is configured outside the sandbox")\nPY'); + }).then(openaiResult => { + console.log(openaiResult.stdout); + }); + }).then(() => { + return cleanupSandbox(sandbox); + }, err => { + return cleanupSandbox(sandbox).then(() => { + throw err; + }); + }); +}); diff --git a/examples/sandbox_resources.js b/examples/sandbox_resources.js new file mode 100644 index 00000000..b4736334 --- /dev/null +++ b/examples/sandbox_resources.js @@ -0,0 +1,121 @@ +const { + qiniu, + env, + sandboxEndpoint, + sandboxClient, + sandboxMac, + shellQuote, + cleanupSandbox, + runExample +} = require('./sandbox_common'); + +function runGitResourceExample () { + const repoUrl = env('GIT_REPO_URL'); + const token = env('GITHUB_TOKEN'); + if (!repoUrl || !token) { + console.log('Skip GitHub repository resource: set GIT_REPO_URL and GITHUB_TOKEN.'); + return Promise.resolve(); + } + + const client = sandboxClient(); + const mountPath = env('QINIU_SANDBOX_GIT_MOUNT_PATH', '/workspace/repo'); + let sandbox; + + return qiniu.sandbox.Sandbox.create({ + client, + template: env('QINIU_SANDBOX_TEMPLATE', 'base'), + timeout: 300, + resources: [ + { + type: 'github_repository', + url: repoUrl, + mount_path: mountPath, + authorization_token: token + } + ], + metadata: { + example: 'sandbox_resources_git' + } + }).then(created => { + sandbox = created; + return sandbox.waitForReady({ interval: 3000, timeout: 180000 }); + }).then(() => { + console.log('Git resource sandbox ready:', sandbox.sandboxId); + return sandbox.commands.run(`ls -la ${shellQuote(mountPath)} | head -20`); + }).then(result => { + console.log(result.stdout); + const filename = `sandbox-resource-write-test-${Date.now()}.txt`; + return sandbox.files.write(`${mountPath}/${filename}`, `sandbox resource write test\nsandbox=${sandbox.sandboxId}\n`) + .then(() => sandbox.git.configureUser(mountPath, 'Sandbox Resource Demo', 'sandbox-resource-demo@example.com')) + .then(() => sandbox.git.add(mountPath, { files: [filename] })) + .then(() => sandbox.git.commit(mountPath, 'test: update sandbox resource write file')) + .then(() => sandbox.git.status(mountPath)) + .then(status => { + console.log(status.raw); + }); + }).then(() => cleanupSandbox(sandbox), err => { + return cleanupSandbox(sandbox).then(() => { + throw err; + }); + }); +} + +function runKodoResourceExample () { + const bucket = env('QINIU_SANDBOX_KODO_BUCKET'); + if (!bucket) { + console.log('Skip Kodo resource: set QINIU_SANDBOX_KODO_BUCKET.'); + return Promise.resolve(); + } + + const client = new qiniu.sandbox.SandboxClient({ + endpoint: sandboxEndpoint(), + mac: sandboxMac() + }); + const mountPath = env('QINIU_SANDBOX_KODO_MOUNT_PATH', '/workspace/kodo'); + const resource = { + type: 'kodo', + bucket, + mount_path: mountPath + }; + if (env('QINIU_SANDBOX_KODO_PREFIX')) { + resource.prefix = env('QINIU_SANDBOX_KODO_PREFIX'); + } + + let sandbox; + const testFile = `sandbox-resource-write-test-${Date.now()}.txt`; + const testPath = `${mountPath}/${testFile}`; + const prefix = env('QINIU_SANDBOX_KODO_PREFIX'); + const objectKey = prefix ? `${prefix.replace(/\/+$/, '')}/${testFile}` : testFile; + return qiniu.sandbox.Sandbox.create({ + client, + template: env('QINIU_SANDBOX_TEMPLATE', 'base'), + timeout: 300, + resources: [resource], + metadata: { + example: 'sandbox_resources_kodo' + } + }).then(created => { + sandbox = created; + return sandbox.waitForReady({ interval: 3000, timeout: 180000 }); + }).then(() => { + console.log('Kodo resource sandbox ready:', sandbox.sandboxId); + return sandbox.commands.run(`ls -la ${shellQuote(mountPath)} | head -20`); + }).then(result => { + console.log(result.stdout); + return sandbox.commands.run(`sh -c "echo sandbox-kodo-write-test > ${shellQuote(testPath)} && cat ${shellQuote(testPath)}"`); + }).then(writeResult => { + if (writeResult.exitCode !== 0) { + throw new Error(`Kodo write failed: ${writeResult.stderr || writeResult.stdout}`); + } + console.log('Read Kodo resource file:', JSON.stringify(writeResult.stdout)); + console.log('Kodo resource file kept in bucket:', `${bucket}/${objectKey}`); + }).then(() => cleanupSandbox(sandbox), err => { + return cleanupSandbox(sandbox).then(() => { + throw err; + }); + }); +} + +runExample(() => { + return runGitResourceExample().then(runKodoResourceExample); +}); diff --git a/examples/sandbox_templates.js b/examples/sandbox_templates.js new file mode 100644 index 00000000..0c3ba104 --- /dev/null +++ b/examples/sandbox_templates.js @@ -0,0 +1,95 @@ +const { + qiniu, + env, + sandboxClient, + sandboxTemplate, + runExample +} = require('./sandbox_common'); + +runExample(() => { + const client = sandboxClient({ + accessToken: env('QINIU_SANDBOX_ACCESS_TOKEN') + }); + const templateName = `nodejs-sdk-example-${Date.now()}`; + let templateID; + let buildID; + + return client.listDefaultTemplates().then(defaultTemplates => { + console.log('Default templates:', Array.isArray(defaultTemplates) ? defaultTemplates.length : defaultTemplates); + return client.listTemplates({ limit: 10 }); + }).then(templates => { + console.log('Templates:', Array.isArray(templates) ? templates.map(item => item.templateID || item.template_id || item.name) : templates); + const first = Array.isArray(templates) && templates[0]; + if (!first) { + return null; + } + const id = first.templateID || first.template_id || first.id || sandboxTemplate(); + return client.getTemplate(id).then(detail => { + console.log('First template detail:', detail.templateID || detail.template_id || id); + }); + }).then(() => { + const advanced = qiniu.sandbox.Template() + .fromDockerfile('FROM node:22\nWORKDIR /app\nENV NODE_ENV=production\nRUN npm ci') + .copyItems([{ src: 'package.json', dest: '/app/' }]) + .setEnvs({ PORT: '3000' }) + .npmInstall('tsx', { dev: true }) + .pipInstall(undefined, { g: false }) + .gitClone('https://github.com/qiniu/nodejs-sdk.git', '/src/sdk', { depth: 1 }) + .makeDir('/app/data') + .makeSymlink('/usr/bin/node', '/usr/local/bin/node', { force: true }); + console.log('Advanced builder steps:', advanced.buildConfig.steps.length); + return qiniu.sandbox.Template() + .fromImage('ubuntu:22.04') + .aptInstall(['curl', 'git']) + .runCmd('echo "hello from template build"') + .setReadyCmd('echo ready') + .build({ + client, + name: templateName, + tags: ['nodejs-sdk-example'] + }); + }).then(result => { + templateID = result.templateID || result.template_id || result.id; + buildID = result.buildID || result.build_id; + console.log('Template created:', templateID, 'build:', buildID); + if (!templateID || !buildID) { + return null; + } + return client.getTemplateBuildStatus(templateID, buildID) + .then(status => { + console.log('Build status:', status.status || status); + return client.getTemplateBuildLogs(templateID, buildID, { limit: 5 }); + }) + .then(logs => { + console.log('Build logs:', logs.logs || logs); + return client.assignTemplateTags({ + target: `${templateName}:v1`, + tags: ['latest'] + }); + }) + .then(tags => { + console.log('Assigned tags:', tags); + return client.deleteTemplateTags({ + name: templateName, + tags: ['latest'] + }); + }) + .then(() => { + return client.getTemplateFiles(templateID, 'example-hash'); + }) + .then(fileInfo => { + console.log('Template file info:', fileInfo); + }, err => { + console.log('Template file lookup skipped:', err.message); + }); + }).then(() => { + if (!templateID) { + return null; + } + return client.deleteTemplate(templateID).then(() => { + console.log('Template deleted:', templateID); + }, err => { + console.log('Failed to delete template:', templateID, err.message); + }); + }); +}); diff --git a/examples/sandbox_watch_dir.js b/examples/sandbox_watch_dir.js new file mode 100644 index 00000000..ab4af77f --- /dev/null +++ b/examples/sandbox_watch_dir.js @@ -0,0 +1,67 @@ +const { + createSandboxAndWait, + cleanupSandbox, + runExample +} = require('./sandbox_common'); + +runExample(() => { + let sandbox; + let handle; + const watchedDir = '/tmp/qiniu-watch-dir'; + const watchedFile = `${watchedDir}/hello.txt`; + + function waitForEvent () { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error('Timed out waiting for filesystem event')); + }, 10000); + + sandbox.files.watchDir(watchedDir, event => { + console.log('Watch event:', event.type, event.name); + if (event.name === 'hello.txt') { + clearTimeout(timer); + resolve(event); + } + }, { + recursive: true, + requestTimeoutMs: 10000, + onExit: err => { + if (err) { + console.log('Watch exited:', err.message); + } + } + }).then(created => { + handle = created; + return sandbox.files.write(watchedFile, 'hello watch\n'); + }).catch(err => { + clearTimeout(timer); + reject(err); + }); + }); + } + + return createSandboxAndWait({ + metadata: { + example: 'sandbox_watch_dir' + } + }).then(created => { + sandbox = created; + return sandbox.files.makeDir(watchedDir); + }).then(() => { + return waitForEvent(); + }).then(event => { + console.log('Received event:', event.type); + return handle ? handle.stop() : null; + }).then(() => { + return cleanupSandbox(sandbox); + }, err => { + if (handle) { + return handle.stop().then(() => { + console.log('WatchDir skipped:', err.message); + return cleanupSandbox(sandbox); + }); + } + console.log('WatchDir skipped:', err.message); + return cleanupSandbox(sandbox); + }); +}); diff --git a/index.d.ts b/index.d.ts index 6df2665f..33f2f50e 100644 --- a/index.d.ts +++ b/index.d.ts @@ -38,6 +38,455 @@ export declare namespace auth { } } +export declare namespace sandbox { + interface SandboxClientOptions { + endpoint?: string; + apiUrl?: string; + apiKey?: string; + accessToken?: string; + mac?: auth.digest.Mac; + accessKey?: string; + secretKey?: string; + macOptions?: auth.digest.MacOptions; + timeout?: number | number[]; + httpAgent?: HttpAgent; + httpsAgent?: HttpsAgent; + } + + interface GitRepositoryResource { + type: 'github_repository'; + url: string; + mount_path: string; + authorization_token?: string; + } + + interface KodoResource { + type: 'kodo'; + bucket: string; + mount_path: string; + prefix?: string; + read_only?: boolean; + } + + type SandboxResource = GitRepositoryResource | KodoResource; + + interface SandboxCreateOptions extends SandboxClientOptions { + template?: string; + templateID?: string; + timeout?: number; + timeoutMs?: number; + autoPause?: boolean; + secure?: boolean; + allow_internet_access?: boolean; + allowInternetAccess?: boolean; + network?: any; + metadata?: {[key: string]: string}; + envVars?: {[key: string]: string}; + envs?: {[key: string]: string}; + mcp?: any; + injections?: any[]; + resources?: SandboxResource[]; + client?: SandboxClient; + } + + interface SandboxConnectOptions extends SandboxClientOptions { + timeout?: number; + timeoutMs?: number; + client?: SandboxClient; + } + + interface PollOptions { + interval?: number; + intervalMs?: number; + timeout?: number; + timeoutMs?: number; + } + + interface FileUrlOptions { + user?: string; + signatureExpiration?: number; + signature_expiration?: number; + gzip?: boolean; + useOctetStream?: boolean; + } + + interface EntryInfo { + name?: string; + type?: string; + path?: string; + size?: number; + mode?: number; + permissions?: string; + owner?: string; + group?: string; + modifiedTime?: string | Date; + symlinkTarget?: string; + } + + interface FilesystemEvent { + name: string; + type: 'create' | 'write' | 'remove' | 'rename' | 'chmod'; + } + + interface WatchOptions { + user?: string; + recursive?: boolean; + timeout?: number; + timeoutMs?: number; + requestTimeoutMs?: number; + onExit?: (err?: Error) => void | Promise; + } + + interface CommandResult { + pid?: number; + exitCode: number; + stdout: string; + stderr: string; + error?: string; + } + + interface CommandOptions { + background?: boolean; + cwd?: string; + user?: string; + envs?: {[key: string]: string}; + tag?: string; + stdin?: boolean; + timeout?: number; + timeoutMs?: number; + requestTimeoutMs?: number; + throwOnError?: boolean; + config?: {[key: string]: string | number | boolean}; + onStdout?: (data: string) => void; + onStderr?: (data: string) => void; + } + + interface GitStatus { + currentBranch: string; + changedFiles: string[]; + untrackedFiles: string[]; + raw: string; + } + + interface GitConfigOptions extends CommandOptions { + path?: string; + scope?: 'local' | 'global' | 'system'; + } + + interface TemplateCopyItem { + src: string | string[]; + dest: string; + forceUpload?: true; + user?: string; + mode?: number; + resolveSymlinks?: boolean; + } + + interface SnapshotInfo { + snapshotId?: string; + snapshotID?: string; + [key: string]: any; + } + + class SandboxPaginator { + readonly hasNext: boolean; + readonly nextToken?: string; + nextItems(options?: SandboxClientOptions): Promise; + then( + onfulfilled?: ((value: Sandbox[]) => TResult1 | PromiseLike) | undefined | null, + onrejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null + ): Promise; + } + + class SnapshotPaginator { + readonly hasNext: boolean; + readonly nextToken?: string; + nextItems(options?: SandboxClientOptions): Promise; + then( + onfulfilled?: ((value: SnapshotInfo[]) => TResult1 | PromiseLike) | undefined | null, + onrejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null + ): Promise; + } + + class Filesystem { + read(path: string, options?: FileUrlOptions & {format?: string}): Promise; + readText(path: string, options?: FileUrlOptions): Promise; + write(path: string, data: string | Buffer, options?: FileUrlOptions): Promise; + write(files: Array<{path: string; data: string | Buffer}>, options?: FileUrlOptions): Promise; + writeFiles(files: Array<{path: string; data: string | Buffer}>, options?: FileUrlOptions): Promise; + getInfo(path: string, options?: {user?: string}): Promise; + stat(path: string, options?: {user?: string}): Promise; + list(path: string, options?: {user?: string; depth?: number}): Promise; + exists(path: string, options?: {user?: string}): Promise; + makeDir(path: string, options?: {user?: string}): Promise; + mkdir(path: string, options?: {user?: string}): Promise; + remove(path: string, options?: {user?: string}): Promise; + rename(oldPath: string, newPath: string, options?: {user?: string}): Promise; + move(oldPath: string, newPath: string, options?: {user?: string}): Promise; + watchDir(path: string, onEvent: (event: FilesystemEvent) => void | Promise, options?: WatchOptions): Promise; + } + + class WatchHandle { + stop(): Promise; + } + + class CommandHandle { + pid: number; + result?: CommandResult; + wait(): Promise; + kill(): Promise; + disconnect(): Promise; + } + + class Commands { + run(command: string, options?: CommandOptions & {background?: false}): Promise; + run(command: string, options: CommandOptions & {background: true}): Promise; + start(command: string, options?: CommandOptions): Promise; + connect(pid: number, options?: CommandOptions): Promise; + list(options?: {user?: string}): Promise; + sendStdin(pid: number, data: string | Buffer, options?: {user?: string}): Promise; + closeStdin(pid: number, options?: {user?: string}): Promise; + kill(pid: number, options?: {user?: string}): Promise; + } + + class Git { + clone(repoUrl: string, options?: CommandOptions & {path?: string; depth?: number; branch?: string; username?: string; password?: string; dangerouslyStoreCredentials?: boolean}): Promise; + clone(repoUrl: string, path: string, options?: CommandOptions & {depth?: number; branch?: string; username?: string; password?: string; dangerouslyStoreCredentials?: boolean}): Promise; + init(repoPath: string, options?: CommandOptions & {bare?: boolean; initialBranch?: string}): Promise; + status(repoPath: string, options?: CommandOptions): Promise; + add(repoPath: string, options?: CommandOptions & {files?: string[]; all?: boolean}): Promise; + commit(repoPath: string, message: string, options?: CommandOptions & {allowEmpty?: boolean; authorName?: string; authorEmail?: string}): Promise; + pull(repoPath: string, options?: CommandOptions & {remote?: string; branch?: string; username?: string; password?: string}): Promise; + push(repoPath: string, options?: CommandOptions & {remote?: string; branch?: string; username?: string; password?: string}): Promise; + createBranch(repoPath: string, branch: string, options?: CommandOptions): Promise; + checkoutBranch(repoPath: string, branch: string, options?: CommandOptions): Promise; + deleteBranch(repoPath: string, branch: string, options?: CommandOptions & {force?: boolean}): Promise; + branches(repoPath: string, options?: CommandOptions): Promise>; + reset(repoPath: string, options?: CommandOptions & {hard?: boolean; soft?: boolean; mixed?: boolean; ref?: string; mode?: 'soft' | 'mixed' | 'hard' | 'merge' | 'keep'; target?: string; paths?: string[]}): Promise; + restore(repoPath: string, options?: CommandOptions & {staged?: boolean; worktree?: boolean; source?: string; paths?: string[]; files?: string[]}): Promise; + remoteAdd(repoPath: string, name: string, repoUrl: string, options?: CommandOptions & {overwrite?: boolean; fetch?: boolean}): Promise; + remoteGet(repoPath: string, name: string, options?: CommandOptions): Promise; + setConfig(repoPath: string, key: string, value: string, options?: CommandOptions & {scope?: 'local' | 'global' | 'system'}): Promise; + setConfig(key: string, value: string, options?: GitConfigOptions): Promise; + getConfig(repoPath: string, key: string, options?: CommandOptions & {scope?: 'local' | 'global' | 'system'}): Promise; + getConfig(key: string, options?: GitConfigOptions): Promise; + configureUser(repoPath: string, name: string, email: string, options?: CommandOptions): Promise; + configureUser(name: string, email: string, options?: GitConfigOptions): Promise; + dangerouslyAuthenticate(repoPath: string, remote: string, username: string, password: string, options?: CommandOptions): Promise; + } + + class Pty { + create(options?: CommandOptions & {cmd?: string; args?: string[]; cols?: number; rows?: number; onData?: (data: Buffer) => void}): Promise; + connect(pid: number, options?: CommandOptions & {onData?: (data: Buffer) => void}): Promise; + sendInput(pid: number, data: string | Buffer, options?: {user?: string; requestTimeoutMs?: number}): Promise; + resize(pid: number, size: {cols: number; rows: number}, options?: {user?: string; requestTimeoutMs?: number}): Promise; + kill(pid: number, options?: {user?: string; requestTimeoutMs?: number}): Promise; + } + + const DEFAULT_ENDPOINT: string; + const DEFAULT_TEMPLATE: string; + const DEFAULT_SANDBOX_TIMEOUT_MS: number; + const FileType: { + FILE: 'file'; + DIR: 'dir'; + }; + const FilesystemEventType: { + CREATE: 'create'; + WRITE: 'write'; + REMOVE: 'remove'; + RENAME: 'rename'; + CHMOD: 'chmod'; + }; + const ALL_TRAFFIC: string; + + class SandboxError extends Error { + response?: IncomingMessage; + data?: any; + } + + class CommandExitError extends SandboxError { + command?: string; + exitCode: number; + stdout: string; + stderr: string; + } + + class TimeoutError extends Error {} + class NotImplementedError extends Error {} + class InvalidArgumentError extends Error {} + class NotFoundError extends Error {} + class FileNotFoundError extends Error {} + class SandboxNotFoundError extends Error {} + class GitAuthError extends Error {} + class GitUpstreamError extends Error {} + class TemplateBuildError extends Error {} + + interface TemplateBuilder { + fromImage(image: string, credentials?: {username: string; password: string}): this; + fromAWSRegistry(image: string, credentials: {accessKeyId: string; secretAccessKey: string; region: string}): this; + fromGCPRegistry(image: string, credentials: {serviceAccountJSON: string | object}): this; + fromTemplate(templateID: string): this; + fromDockerfile(dockerfileContentOrPath: string): this; + aptInstall(packages: string | string[], options?: {noInstallRecommends?: boolean; fixMissing?: boolean}): this; + runCmd(command: string | string[], options?: {user?: string}): this; + copy(src: string | string[], dest: string, options?: {forceUpload?: true; user?: string; mode?: number; resolveSymlinks?: boolean}): this; + copyItems(items: TemplateCopyItem[]): this; + remove(path: string | string[], options?: {force?: boolean; recursive?: boolean; user?: string}): this; + rename(src: string, dest: string, options?: {force?: boolean; user?: string}): this; + makeDir(path: string | string[], options?: {mode?: number; user?: string}): this; + makeSymlink(src: string, dest: string, options?: {force?: boolean; user?: string}): this; + setWorkdir(workdir: string): this; + setUser(user: string): this; + pipInstall(options?: {g?: boolean}): this; + pipInstall(packages?: string | string[], options?: {g?: boolean}): this; + npmInstall(options?: {g?: boolean; dev?: boolean}): this; + npmInstall(packages?: string | string[], options?: {g?: boolean; dev?: boolean}): this; + bunInstall(options?: {g?: boolean; dev?: boolean}): this; + bunInstall(packages?: string | string[], options?: {g?: boolean; dev?: boolean}): this; + gitClone(url: string, options?: {branch?: string; depth?: number; user?: string}): this; + gitClone(url: string, path?: string, options?: {branch?: string; depth?: number; user?: string}): this; + setEnvs(envs: {[key: string]: string}): this; + skipCache(): this; + setStartCmd(command: string): this; + setReadyCmd(command: string): this; + build(options?: SandboxClientOptions & {client?: SandboxClient; name?: string; alias?: string; tags?: string[]}): Promise; + } + + const Template: { + new(): TemplateBuilder; + (): TemplateBuilder; + }; + + class Volume { + create(): Promise; + delete(): Promise; + list(): Promise; + } + + class SandboxClient { + endpoint: string; + apiKey?: string; + accessToken?: string; + mac?: auth.digest.Mac; + + constructor(options?: SandboxClientOptions); + + listSandboxes(options?: any): Promise; + listSandboxesV2(options?: any): Promise; + createSandbox(options?: SandboxCreateOptions): Promise; + getSandboxesMetrics(sandboxIDs: string[] | any): Promise; + getSandboxLogs(sandboxID: string, options?: any): Promise; + getSandbox(sandboxID: string): Promise; + deleteSandbox(sandboxID: string): Promise; + killSandbox(sandboxID: string): Promise; + getSandboxMetrics(sandboxID: string, options?: any): Promise; + pauseSandbox(sandboxID: string): Promise; + resumeSandbox(sandboxID: string, options?: any): Promise; + connectSandbox(sandboxID: string, options?: SandboxConnectOptions): Promise; + updateSandboxTimeout(sandboxID: string, timeoutOrOptions: number | {timeout?: number; timeoutMs?: number}): Promise; + updateSandbox(sandboxID: string, options?: any): Promise; + refreshSandbox(sandboxID: string, options?: {duration?: number}): Promise; + + createTemplate(options?: any): Promise; + createTemplateV3(options?: any): Promise; + createTemplateV2(options?: any): Promise; + getTemplateFiles(templateID: string, hash: string): Promise; + listDefaultTemplates(): Promise; + listTemplates(options?: any): Promise; + getTemplate(templateID: string, options?: any): Promise; + deleteTemplate(templateID: string): Promise; + updateTemplate(templateID: string, options?: any): Promise; + startTemplateBuildV2(templateID: string, buildID: string, options?: any): Promise; + getTemplateBuildStatus(templateID: string, buildID: string, options?: any): Promise; + getTemplateBuildLogs(templateID: string, buildID: string, options?: any): Promise; + assignTemplateTags(options?: any): Promise; + deleteTemplateTags(options?: any): Promise; + getTemplateByAlias(alias: string): Promise; + + listInjectionRules(): Promise; + createInjectionRule(options?: any): Promise; + getInjectionRule(ruleID: string): Promise; + updateInjectionRule(ruleID: string, options?: any): Promise; + deleteInjectionRule(ruleID: string): Promise; + + create(options?: SandboxCreateOptions): Promise; + connect(sandboxID: string, options?: SandboxConnectOptions): Promise; + list(options?: any): Promise; + kill(sandboxID: string): Promise; + setTimeout(sandboxID: string, timeoutOrOptions: number | {timeout?: number; timeoutMs?: number}): Promise; + getInfo(sandboxID: string): Promise; + getMetrics(sandboxID: string, options?: any): Promise; + getLogs(sandboxID: string, options?: any): Promise; + createAndWait(options?: SandboxCreateOptions, pollOptions?: PollOptions): Promise; + rebuildTemplate(templateID: string, options?: any): Promise; + startTemplateBuild(templateID: string, buildID: string, options?: any): Promise; + waitForBuild(templateID: string, buildID: string, options?: PollOptions): Promise; + createSnapshot(sandboxID: string, options?: any): Promise; + listSnapshots(options?: any): Promise; + deleteSnapshot(snapshotID: string): Promise; + } + + class Sandbox { + sandboxId: string; + sandboxID: string; + sandboxDomain?: string; + domain?: string; + info: any; + client: SandboxClient; + files: Filesystem; + filesystem: Filesystem; + commands: Commands; + pty: Pty; + git: Git; + + constructor(options?: any); + + static create(options?: SandboxCreateOptions): Promise; + static create(template: string, options?: SandboxCreateOptions): Promise; + static connect(sandboxID: string, options?: SandboxConnectOptions): Promise; + static list(options?: SandboxClientOptions & {client?: SandboxClient; limit?: number; nextToken?: string; query?: any}): SandboxPaginator; + + kill(): Promise; + setTimeout(timeoutOrOptions: number | {timeout?: number; timeoutMs?: number}): Promise; + refresh(options?: {duration?: number}): Promise; + updateNetwork(network: any): Promise; + pause(): Promise; + /** + * @deprecated use pause() instead + */ + betaPause(): Promise; + connect(options?: SandboxConnectOptions): Promise; + getInfo(): Promise; + getMetrics(options?: any): Promise; + getLogs(options?: any): Promise; + createSnapshot(options?: any): Promise; + listSnapshots(options?: any): SnapshotPaginator; + getMcpUrl(): string; + getMcpToken(): Promise; + waitForReady(options?: PollOptions): Promise; + isRunning(): Promise; + getHost(port: number): string; + envdUrl(): string; + fileUrl(path: string, operation: string, options?: FileUrlOptions): string; + downloadUrl(path: string, options?: FileUrlOptions): string; + DownloadURL(path: string, options?: FileUrlOptions): string; + uploadUrl(path: string, options?: FileUrlOptions): string; + UploadURL(path: string, options?: FileUrlOptions): string; + batchUploadUrl(options?: string | FileUrlOptions): string; + } +} + +export declare const Sandbox: typeof sandbox.Sandbox; +export declare const SandboxClient: typeof sandbox.SandboxClient; +export declare const SandboxPaginator: typeof sandbox.SandboxPaginator; +export declare const SnapshotPaginator: typeof sandbox.SnapshotPaginator; +export declare const DEFAULT_SANDBOX_TIMEOUT_MS: typeof sandbox.DEFAULT_SANDBOX_TIMEOUT_MS; +export declare const FileType: typeof sandbox.FileType; +export declare const FilesystemEventType: typeof sandbox.FilesystemEventType; +export declare const WatchHandle: typeof sandbox.WatchHandle; +export declare const CommandExitError: typeof sandbox.CommandExitError; +export declare const SandboxError: typeof sandbox.SandboxError; + export declare namespace cdn { class CdnManager { mac: auth.digest.Mac; diff --git a/index.js b/index.js index 8a3fbfbe..31b67fc0 100644 --- a/index.js +++ b/index.js @@ -28,5 +28,17 @@ module.exports = { Credentials: require('./qiniu/rtc/credentials.js'), sms: { message: require('./qiniu/sms/message.js') - } + }, + sandbox: require('./qiniu/sandbox.js') }; + +module.exports.Sandbox = module.exports.sandbox.Sandbox; +module.exports.SandboxClient = module.exports.sandbox.SandboxClient; +module.exports.SandboxPaginator = module.exports.sandbox.SandboxPaginator; +module.exports.SnapshotPaginator = module.exports.sandbox.SnapshotPaginator; +module.exports.DEFAULT_SANDBOX_TIMEOUT_MS = module.exports.sandbox.DEFAULT_SANDBOX_TIMEOUT_MS; +module.exports.FileType = module.exports.sandbox.FileType; +module.exports.FilesystemEventType = module.exports.sandbox.FilesystemEventType; +module.exports.WatchHandle = module.exports.sandbox.WatchHandle; +module.exports.CommandExitError = module.exports.sandbox.CommandExitError; +module.exports.SandboxError = module.exports.sandbox.SandboxError; diff --git a/package.json b/package.json index 71d904c9..5823e950 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "scripts": { "check-type": "tsc --noEmit", "test": "NODE_ENV=test mocha -t 300000 --retries 3", + "test:sandbox": "NODE_ENV=test mocha -t 300000 --retries 3 test/sandbox_client.test.js test/sandbox_commands.test.js test/sandbox_facade.test.js test/sandbox_filesystem.test.js test/sandbox_git.test.js test/sandbox_pty.test.js test/sandbox_template.test.js", "cover": "nyc npm run test", "report": "nyc report --reporter=html", "lint": "eslint ." diff --git a/qiniu/sandbox.js b/qiniu/sandbox.js new file mode 100644 index 00000000..48e1639b --- /dev/null +++ b/qiniu/sandbox.js @@ -0,0 +1 @@ +module.exports = require('./sandbox/index.js'); diff --git a/qiniu/sandbox/client.js b/qiniu/sandbox/client.js new file mode 100644 index 00000000..12fdddff --- /dev/null +++ b/qiniu/sandbox/client.js @@ -0,0 +1,466 @@ +const http = require('http'); +const https = require('https'); + +const { HttpClient } = require('../httpc/client'); +const { QiniuAuthMiddleware } = require('../httpc/middleware/qiniuAuth'); +const digest = require('../auth/digest'); +const { DEFAULT_TEMPLATE } = require('./constants'); +const { SandboxError, TemplateBuildError } = require('./errors'); +const { + appendQuery, + copyDefined, + encodePath, + normalizeEndpoint, + poll, + timeoutSecondsFromOptions +} = require('./util'); + +function normalizeSandboxCreateOptions (opts) { + opts = opts || {}; + const body = {}; + body.templateID = opts.templateID || opts.template || DEFAULT_TEMPLATE; + + const timeout = timeoutSecondsFromOptions(opts); + if (timeout !== undefined) { + body.timeout = timeout; + } + + copyDefined(body, opts, 'autoPause'); + copyDefined(body, opts, 'secure'); + copyDefined(body, opts, 'allow_internet_access'); + copyDefined(body, opts, 'allowInternetAccess', 'allow_internet_access'); + copyDefined(body, opts, 'network'); + copyDefined(body, opts, 'metadata'); + copyDefined(body, opts, 'envVars'); + copyDefined(body, opts, 'envs', 'envVars'); + copyDefined(body, opts, 'mcp'); + if (Array.isArray(opts.injections)) { + body.injections = opts.injections.map(normalizeInjection); + } else { + copyDefined(body, opts, 'injections'); + } + copyDefined(body, opts, 'resources'); + + return body; +} + +function hasKodoResource (body) { + return Array.isArray(body.resources) && body.resources.some(resource => { + return resource && resource.type === 'kodo'; + }); +} + +function normalizeInjection (injection) { + if (!injection || typeof injection !== 'object' || Array.isArray(injection)) { + return injection; + } + + const normalized = Object.assign({}, injection); + if (normalized.apiKey !== undefined && normalized.api_key === undefined) { + normalized.api_key = normalized.apiKey; + delete normalized.apiKey; + } + if (normalized.baseUrl !== undefined && normalized.base_url === undefined) { + normalized.base_url = normalized.baseUrl; + delete normalized.baseUrl; + } + if (normalized.ruleId !== undefined && normalized.ruleID === undefined) { + normalized.ruleID = normalized.ruleId; + delete normalized.ruleId; + } + return normalized; +} + +function normalizeInjectionRuleOptions (opts) { + opts = Object.assign({}, opts || {}); + if (opts.injection) { + opts.injection = normalizeInjection(opts.injection); + } + return opts; +} + +function normalizeSandboxListOptions (opts) { + opts = Object.assign({}, opts || {}); + const query = opts.query; + delete opts.query; + + if (query && query.metadata) { + Object.keys(query.metadata).forEach(key => { + opts[`metadata[${key}]`] = query.metadata[key]; + }); + } + if (query && query.state) { + opts.state = query.state; + } + return opts; +} + +function normalizeClientOptions (opts) { + opts = opts || {}; + if (!opts.mac && ((opts.accessKey && !opts.secretKey) || (!opts.accessKey && opts.secretKey))) { + throw new SandboxError('Both accessKey and secretKey must be provided'); + } + const mac = opts.mac || (opts.accessKey || opts.secretKey + ? new digest.Mac(opts.accessKey, opts.secretKey, opts.macOptions) + : null); + + return { + endpoint: normalizeEndpoint(opts.endpoint || opts.apiUrl), + apiKey: opts.apiKey || process.env.QINIU_SANDBOX_API_KEY, + accessToken: opts.accessToken || process.env.QINIU_SANDBOX_ACCESS_TOKEN, + mac, + httpAgent: opts.httpAgent || http.globalAgent, + httpsAgent: opts.httpsAgent || https.globalAgent, + timeout: opts.timeout + }; +} + +function SandboxClient (opts) { + const normalized = normalizeClientOptions(opts); + this.endpoint = normalized.endpoint; + this.apiKey = normalized.apiKey; + this.accessToken = normalized.accessToken; + this.mac = normalized.mac; + this.timeout = normalized.timeout; + this.httpClient = new HttpClient({ + httpAgent: normalized.httpAgent, + httpsAgent: normalized.httpsAgent + }); +} + +SandboxClient.prototype._headers = function (authType) { + const headers = { + 'Content-Type': 'application/json' + }; + + if (authType === 'accessToken') { + if (this.accessToken) { + headers.Authorization = `Bearer ${this.accessToken}`; + } + return headers; + } + + if (authType === 'qiniu') { + return headers; + } + + if (this.apiKey) { + headers['X-API-Key'] = this.apiKey; + headers.Authorization = `Bearer ${this.apiKey}`; + } else if (this.accessToken) { + headers.Authorization = `Bearer ${this.accessToken}`; + } + + return headers; +}; + +SandboxClient.prototype._middlewares = function (authType) { + if (authType === 'qiniu') { + if (!this.mac) { + throw new SandboxError('Qiniu Mac credentials (accessKey/secretKey) are required for this operation'); + } + return [new QiniuAuthMiddleware({ mac: this.mac })]; + } + if (!this.apiKey && !this.accessToken && this.mac) { + return [new QiniuAuthMiddleware({ mac: this.mac })]; + } + return []; +}; + +SandboxClient.prototype._request = function (method, path, options) { + options = options || {}; + const body = options.body; + const hasBody = body !== undefined && body !== null; + const headers = this._headers(options.authType); + const urllibOptions = { + method, + headers, + dataType: 'json', + gzip: true, + followRedirect: true + }; + if (this.timeout !== undefined) { + urllibOptions.timeout = this.timeout; + } + + if (hasBody) { + urllibOptions.content = JSON.stringify(body); + urllibOptions.contentType = 'application/json'; + } else if (method !== 'GET' && method !== 'HEAD') { + urllibOptions.contentType = urllibOptions.headers['Content-Type']; + urllibOptions.headers['Content-Length'] = '0'; + } + + let middlewares; + try { + middlewares = this._middlewares(options.authType); + } catch (err) { + return Promise.reject(err); + } + + return this.httpClient.sendRequest({ + url: this.endpoint + path, + middlewares, + urllibOptions + }).then(wrapper => this._handleResponse(wrapper, options.empty)); +}; + +SandboxClient.prototype._handleResponse = function (wrapper, empty) { + if (wrapper.ok()) { + return empty ? null : wrapper.data; + } + + const statusCode = wrapper.resp && wrapper.resp.statusCode; + let message = `Sandbox API request failed with status ${statusCode}`; + const data = wrapper.data; + if (data && data.message) { + message += `: ${data.message}`; + } else if (typeof data === 'string' && data) { + message += `: ${data}`; + } + throw new SandboxError(message, wrapper.resp, data); +}; + +SandboxClient.prototype.listSandboxes = function (opts) { + return this._request('GET', appendQuery('/sandboxes', opts)); +}; + +SandboxClient.prototype.listSandboxesV2 = function (opts) { + return this._request('GET', appendQuery('/v2/sandboxes', normalizeSandboxListOptions(opts))); +}; + +SandboxClient.prototype.createSandbox = function (opts) { + const body = normalizeSandboxCreateOptions(opts); + return this._request('POST', '/sandboxes', { + authType: hasKodoResource(body) ? 'qiniu' : undefined, + body + }); +}; + +SandboxClient.prototype.getSandboxesMetrics = function (sandboxIDs) { + const raw = (sandboxIDs && !Array.isArray(sandboxIDs) && typeof sandboxIDs === 'object') + ? sandboxIDs.sandbox_ids || sandboxIDs.sandboxIDs || sandboxIDs + : sandboxIDs; + const values = Array.isArray(raw) ? raw : [raw]; + const ids = values.map(value => { + if (typeof value === 'string') { + return value; + } + return value && (value.sandboxId || value.sandboxID || value.sandbox_id || value.id); + }).filter(Boolean); + if (!ids.length) { + return Promise.reject(new SandboxError('At least one sandbox ID must be provided')); + } + return this._request('GET', appendQuery('/sandboxes/metrics', { sandbox_ids: ids })); +}; + +SandboxClient.prototype.getSandboxLogs = function (sandboxID, opts) { + return this._request('GET', appendQuery(`/sandboxes/${encodePath(sandboxID)}/logs`, opts)); +}; + +SandboxClient.prototype.getSandbox = function (sandboxID) { + if (!sandboxID) { + return Promise.reject(new SandboxError('sandboxID is required')); + } + return this._request('GET', `/sandboxes/${encodePath(sandboxID)}`); +}; + +SandboxClient.prototype.deleteSandbox = function (sandboxID) { + if (!sandboxID) { + return Promise.reject(new SandboxError('sandboxID is required')); + } + return this._request('DELETE', `/sandboxes/${encodePath(sandboxID)}`, { empty: true }); +}; + +SandboxClient.prototype.killSandbox = SandboxClient.prototype.deleteSandbox; + +SandboxClient.prototype.pauseSandbox = function (sandboxID) { + return this._request('POST', `/sandboxes/${encodePath(sandboxID)}/pause`, { empty: true }); +}; + +SandboxClient.prototype.resumeSandbox = function (sandboxID, opts) { + return this._request('POST', `/sandboxes/${encodePath(sandboxID)}/resume`, { body: opts || {} }); +}; + +SandboxClient.prototype.connectSandbox = function (sandboxID, opts) { + const timeout = timeoutSecondsFromOptions(opts); + return this._request('POST', `/sandboxes/${encodePath(sandboxID)}/connect`, { + body: { + timeout: timeout === undefined ? 15 : timeout + } + }); +}; + +SandboxClient.prototype.updateSandboxTimeout = function (sandboxID, timeoutOrOpts) { + const timeout = typeof timeoutOrOpts === 'number' ? timeoutOrOpts : timeoutSecondsFromOptions(timeoutOrOpts); + return this._request('POST', `/sandboxes/${encodePath(sandboxID)}/timeout`, { + body: { timeout }, + empty: true + }); +}; + +SandboxClient.prototype.refreshSandbox = function (sandboxID, opts) { + return this._request('POST', `/sandboxes/${encodePath(sandboxID)}/refreshes`, { + body: opts || {}, + empty: true + }); +}; + +SandboxClient.prototype.updateSandbox = function (sandboxID, opts) { + return this._request('PATCH', `/sandboxes/${encodePath(sandboxID)}`, { + body: opts || {} + }); +}; + +SandboxClient.prototype.getSandboxMetrics = function (sandboxID, opts) { + return this._request('GET', appendQuery(`/sandboxes/${encodePath(sandboxID)}/metrics`, opts)); +}; + +SandboxClient.prototype.createSnapshot = function (sandboxID, opts) { + return this._request('POST', `/sandboxes/${encodePath(sandboxID)}/snapshots`, { + body: opts || {} + }); +}; + +SandboxClient.prototype.listSnapshots = function (opts) { + return this._request('GET', appendQuery('/snapshots', opts)); +}; + +SandboxClient.prototype.deleteSnapshot = function (snapshotID) { + return this._request('DELETE', `/snapshots/${encodePath(snapshotID)}`, { empty: true }); +}; + +SandboxClient.prototype.createTemplate = function (opts) { + return this._request('POST', '/v3/templates', { body: opts || {} }); +}; + +SandboxClient.prototype.createTemplateV3 = SandboxClient.prototype.createTemplate; + +SandboxClient.prototype.createTemplateV2 = function (opts) { + return this._request('POST', '/v2/templates', { body: opts || {} }); +}; + +SandboxClient.prototype.getTemplateFiles = function (templateID, hash) { + return this._request('GET', `/templates/${encodePath(templateID)}/files/${encodePath(hash)}`); +}; + +SandboxClient.prototype.listDefaultTemplates = function () { + return this._request('GET', '/default-templates'); +}; + +SandboxClient.prototype.listTemplates = function (opts) { + return this._request('GET', appendQuery('/templates', opts)); +}; + +SandboxClient.prototype.getTemplate = function (templateID, opts) { + return this._request('GET', appendQuery(`/templates/${encodePath(templateID)}`, opts)); +}; + +SandboxClient.prototype.deleteTemplate = function (templateID) { + return this._request('DELETE', `/templates/${encodePath(templateID)}`, { empty: true }); +}; + +SandboxClient.prototype.updateTemplate = function (templateID, opts) { + return this._request('PATCH', `/templates/${encodePath(templateID)}`, { body: opts || {} }); +}; + +SandboxClient.prototype.startTemplateBuildV2 = function (templateID, buildID, opts) { + return this._request('POST', `/v2/templates/${encodePath(templateID)}/builds/${encodePath(buildID)}`, { + body: opts || {}, + empty: true + }); +}; + +SandboxClient.prototype.getTemplateBuildStatus = function (templateID, buildID, opts) { + return this._request('GET', appendQuery(`/templates/${encodePath(templateID)}/builds/${encodePath(buildID)}/status`, opts)); +}; + +SandboxClient.prototype.getTemplateBuildLogs = function (templateID, buildID, opts) { + return this._request('GET', appendQuery(`/templates/${encodePath(templateID)}/builds/${encodePath(buildID)}/logs`, opts)); +}; + +SandboxClient.prototype.assignTemplateTags = function (opts) { + return this._request('POST', '/templates/tags', { body: opts || {} }); +}; + +SandboxClient.prototype.deleteTemplateTags = function (opts) { + return this._request('DELETE', '/templates/tags', { + body: opts || {}, + empty: true + }); +}; + +SandboxClient.prototype.getTemplateByAlias = function (alias) { + return this._request('GET', `/templates/aliases/${encodePath(alias)}`); +}; + +SandboxClient.prototype.listInjectionRules = function () { + return this._request('GET', '/injection-rules', { authType: 'qiniu' }); +}; + +SandboxClient.prototype.createInjectionRule = function (opts) { + return this._request('POST', '/injection-rules', { + authType: 'qiniu', + body: normalizeInjectionRuleOptions(opts) + }); +}; + +SandboxClient.prototype.getInjectionRule = function (ruleID) { + return this._request('GET', `/injection-rules/${encodePath(ruleID)}`, { authType: 'qiniu' }); +}; + +SandboxClient.prototype.updateInjectionRule = function (ruleID, opts) { + return this._request('PUT', `/injection-rules/${encodePath(ruleID)}`, { + authType: 'qiniu', + body: normalizeInjectionRuleOptions(opts) + }); +}; + +SandboxClient.prototype.deleteInjectionRule = function (ruleID) { + return this._request('DELETE', `/injection-rules/${encodePath(ruleID)}`, { + authType: 'qiniu', + empty: true + }); +}; + +SandboxClient.prototype.create = SandboxClient.prototype.createSandbox; +SandboxClient.prototype.connect = SandboxClient.prototype.connectSandbox; +SandboxClient.prototype.list = SandboxClient.prototype.listSandboxesV2; +SandboxClient.prototype.kill = SandboxClient.prototype.deleteSandbox; +SandboxClient.prototype.setTimeout = SandboxClient.prototype.updateSandboxTimeout; +SandboxClient.prototype.getInfo = SandboxClient.prototype.getSandbox; +SandboxClient.prototype.getMetrics = SandboxClient.prototype.getSandboxMetrics; +SandboxClient.prototype.getLogs = SandboxClient.prototype.getSandboxLogs; + +SandboxClient.prototype.createAndWait = function (opts, pollOpts) { + return this.createSandbox(opts).then(info => { + const Sandbox = require('./sandbox').Sandbox; + const sb = new Sandbox({ client: this, info }); + return sb.waitForReady(pollOpts).then(() => sb); + }); +}; + +SandboxClient.prototype.waitForBuild = function (templateID, buildID, opts) { + return poll(() => this.getTemplateBuildStatus(templateID, buildID), opts, info => { + return info && (info.status === 'ready' || info.status === 'error'); + }).then(info => { + if (info && info.status === 'error') { + const errorMessage = (info.error && typeof info.error === 'object' ? info.error.message : info.error) || + info.message || + 'Sandbox template build failed'; + throw new TemplateBuildError(errorMessage, { info }); + } + return info; + }); +}; + +SandboxClient.prototype.rebuildTemplate = function (templateID, opts) { + return this._request('POST', `/templates/${encodePath(templateID)}`, { + authType: 'accessToken', + body: opts || {} + }); +}; + +SandboxClient.prototype.startTemplateBuild = SandboxClient.prototype.startTemplateBuildV2; + +exports.SandboxClient = SandboxClient; diff --git a/qiniu/sandbox/commands.js b/qiniu/sandbox/commands.js new file mode 100644 index 00000000..724aa0d7 --- /dev/null +++ b/qiniu/sandbox/commands.js @@ -0,0 +1,478 @@ +const { connectEndStreamError, connectRPC, envdHeaders, MAX_CONNECT_ENVELOPE_BYTES } = require('./envd'); +const { CommandExitError } = require('./errors'); +const { agentFromClient, millisecondsFromOptions, parseJSON, parseRequestUrl } = require('./util'); +const http = require('http'); +const https = require('https'); + +function eventPayload (event) { + return event.event || event; +} + +function bytesToString (value) { + return bytesToBuffer(value).toString(); +} + +function bytesToBuffer (value) { + if (!value) { + return Buffer.alloc(0); + } + if (Buffer.isBuffer(value)) { + return value; + } + if (Array.isArray(value)) { + return Buffer.from(value); + } + if (typeof value === 'string') { + return Buffer.from(value, 'base64'); + } + return Buffer.from(String(value)); +} + +function commandResultFromEvents (events, callbacks) { + callbacks = callbacks || {}; + let pid = 0; + let stdout = ''; + let stderr = ''; + let exitCode = -1; + let error = ''; + + events.forEach(raw => { + const event = eventPayload(raw); + const start = event.start || (event.event && event.event.start); + const data = event.data || (event.event && event.event.data); + const end = event.end || (event.event && event.event.end); + if (start) { + pid = start.pid || pid; + } + if (data) { + const out = data.stdout !== undefined ? bytesToString(data.stdout) : ''; + const err = data.stderr !== undefined ? bytesToString(data.stderr) : ''; + const pty = data.pty !== undefined ? data.pty : undefined; + if (out) { + stdout += out; + if (callbacks.onStdout) { + callbacks.onStdout(out); + } + } + if (err) { + stderr += err; + if (callbacks.onStderr) { + callbacks.onStderr(err); + } + } + if (pty !== undefined && callbacks.onData) { + callbacks.onData(bytesToBuffer(pty)); + } + } + if (end) { + exitCode = end.exitCode === undefined ? 0 : end.exitCode; + error = end.error || ''; + } + }); + + return { pid, exitCode, stdout, stderr, error }; +} + +function requestTimeout (opts) { + opts = opts || {}; + return opts.requestTimeoutMs !== undefined + ? opts.requestTimeoutMs + : millisecondsFromOptions(opts, 'timeout'); +} + +function encodeConnectEnvelope (message) { + const payload = Buffer.from(JSON.stringify(message || {})); + const header = Buffer.alloc(5); + header[0] = 0; + header.writeUInt32BE(payload.length, 1); + return Buffer.concat([header, payload]); +} + +function eventListFromResponse (data) { + if (Array.isArray(data.events)) { + return data.events; + } + if (Array.isArray(data)) { + return data; + } + if (data.event) { + return [data]; + } + return []; +} + +function CommandHandle (commands, pid, result, opts, request, waitPromise) { + this.commands = commands; + this.pid = pid; + this.result = result; + this.opts = opts || {}; + this._request = request; + this._waitPromise = waitPromise; + this._resolveWait = null; + this.stdout = result && result.stdout ? result.stdout : ''; + this.stderr = result && result.stderr ? result.stderr : ''; +} + +CommandHandle.prototype.wait = function () { + const finish = result => { + if (this.opts.throwOnError && result && result.exitCode) { + throw new CommandExitError(result); + } + return result; + }; + if (this._waitPromise) { + return this._waitPromise.then(finish); + } + return Promise.resolve(this.result).then(finish); +}; + +CommandHandle.prototype.kill = function () { + return this.commands.kill(this.pid, { + user: this.opts.user, + requestTimeoutMs: this.opts.requestTimeoutMs, + timeoutMs: this.opts.timeoutMs, + timeout: this.opts.timeout + }); +}; + +CommandHandle.prototype.disconnect = function () { + this._disconnected = true; + if (this._request) { + this._request.destroy(); + this._request = null; + } + if (this._resolveWait) { + const result = this.result || { + pid: this.pid, + exitCode: 0, + stdout: this.stdout, + stderr: this.stderr, + error: '' + }; + if (result.exitCode === -1) { + result.exitCode = 0; + } + this.result = result; + this._resolveWait(result); + this._resolveWait = null; + } + return Promise.resolve(); +}; + +function connectLiveCommand (commands, procedure, body, opts, fallbackPid) { + opts = opts || {}; + return new Promise((resolve, reject) => { + const target = parseRequestUrl(commands.sandbox.envdUrl() + procedure); + const transport = target.protocol === 'https:' ? https : http; + const headers = Object.assign({ + 'Content-Type': 'application/connect+json', + 'Keepalive-Ping-Interval': '50' + }, envdHeaders(commands.sandbox, opts.user)); + const req = transport.request({ + method: 'POST', + protocol: target.protocol, + hostname: target.hostname, + port: target.port, + path: target.path, + headers, + agent: agentFromClient(commands.sandbox.client, target.protocol) + }); + + let settled = false; + let handle; + let responseBuffer = Buffer.alloc(0); + let responseOffset = 0; + const jsonChunks = []; + let isConnectStream = true; + let result = { + pid: fallbackPid || 0, + exitCode: -1, + stdout: '', + stderr: '', + error: '' + }; + let resolveWait; + let rejectWait; + const waitPromise = new Promise((resolve, reject) => { + resolveWait = resolve; + rejectWait = reject; + }); + waitPromise.catch(() => {}); + let startTimer; + + function cleanupStartTimer () { + if (startTimer) { + clearTimeout(startTimer); + startTimer = null; + } + } + + function fail (err) { + cleanupStartTimer(); + if (handle && handle._disconnected) { + if (result.exitCode === -1) { + result.exitCode = 0; + } + resolveWait(result); + return; + } + if (!settled) { + settled = true; + reject(err); + } + rejectWait(err); + } + + function ensureHandle (pid) { + if (!handle) { + cleanupStartTimer(); + result.pid = pid || result.pid; + handle = new CommandHandle(commands, result.pid, null, opts, req, waitPromise); + handle._resolveWait = resolveWait; + settled = true; + resolve(handle); + } + } + + function appendData (data) { + if (data.stdout !== undefined) { + const out = bytesToString(data.stdout); + result.stdout += out; + if (handle) { + handle.stdout += out; + } + if (out && opts.onStdout) { + opts.onStdout(out); + } + } + if (data.stderr !== undefined) { + const err = bytesToString(data.stderr); + result.stderr += err; + if (handle) { + handle.stderr += err; + } + if (err && opts.onStderr) { + opts.onStderr(err); + } + } + if (data.pty !== undefined && opts.onData) { + opts.onData(bytesToBuffer(data.pty)); + } + } + + function finish (end) { + result.exitCode = end && end.exitCode !== undefined ? end.exitCode : 0; + result.error = end && end.error ? end.error : ''; + if (handle) { + handle.result = result; + handle._resolveWait = null; + } + resolveWait(result); + } + + function handleMessage (message) { + const event = eventPayload(message); + if (event.start) { + ensureHandle(event.start.pid); + } + if (event.data) { + appendData(event.data); + } + if (event.end) { + finish(event.end); + } + } + + req.on('response', res => { + if (res.statusCode < 200 || res.statusCode >= 300) { + fail(new Error(`Sandbox envd request failed with status ${res.statusCode}`)); + res.resume(); + return; + } + const contentType = (res.headers && res.headers['content-type']) || ''; + isConnectStream = contentType.indexOf('application/connect+json') >= 0; + if (!isConnectStream) { + cleanupStartTimer(); + } + res.on('data', chunk => { + if (!isConnectStream) { + jsonChunks.push(chunk); + return; + } + responseBuffer = responseOffset < responseBuffer.length + ? Buffer.concat([responseBuffer.slice(responseOffset), chunk]) + : chunk; + responseOffset = 0; + while (responseBuffer.length - responseOffset >= 5) { + const flags = responseBuffer[responseOffset]; + const length = responseBuffer.readUInt32BE(responseOffset + 1); + if (length > MAX_CONNECT_ENVELOPE_BYTES) { + fail(new Error(`Sandbox envd stream envelope too large: ${length}`)); + req.destroy(); + return; + } + if (responseBuffer.length - responseOffset < 5 + length) { + break; + } + const payload = responseBuffer.toString('utf8', responseOffset + 5, responseOffset + 5 + length); + responseOffset += 5 + length; + if (flags & 2) { + try { + const err = connectEndStreamError(payload); + if (err) { + fail(err); + req.destroy(); + return; + } + } catch (err) { + fail(err); + req.destroy(); + return; + } + continue; + } + if (!(flags & 2) && payload) { + try { + handleMessage(JSON.parse(payload)); + } catch (err) { + fail(err); + req.destroy(); + return; + } + } + } + }); + res.on('error', fail); + res.on('end', () => { + if (responseBuffer.length - responseOffset > 0) { + fail(new Error('Sandbox envd stream truncated unexpectedly')); + return; + } + if (!isConnectStream) { + let events; + try { + events = eventListFromResponse(parseJSON(Buffer.concat(jsonChunks))); + } catch (err) { + fail(err); + return; + } + result = commandResultFromEvents(events, opts); + if (!result.pid && fallbackPid) { + result.pid = fallbackPid; + } + handle = new CommandHandle(commands, result.pid, result, opts); + settled = true; + resolve(handle); + resolveWait(result); + return; + } + if (!settled) { + fail(new Error('Command stream ended before process start')); + return; + } + if (result.exitCode === -1) { + fail(new Error('Command stream ended before process end')); + } + }); + }); + req.on('error', fail); + const startTimeout = requestTimeout(opts); + if (startTimeout) { + startTimer = setTimeout(() => { + fail(new Error('Command stream start timed out')); + req.destroy(); + }, startTimeout); + } + req.end(encodeConnectEnvelope(body)); + }); +} + +function Commands (sandbox) { + this.sandbox = sandbox; +} + +Commands.prototype.run = function (cmd, opts) { + opts = opts || {}; + return this.start(cmd, opts).then(handle => opts.background ? handle : handle.wait()); +}; + +Commands.prototype.start = function (cmd, opts) { + opts = opts || {}; + const body = { + process: { + cmd: '/bin/bash', + args: ['-l', '-c', cmd] + }, + stdin: opts.stdin || false + }; + if (opts.cwd) { + body.process.cwd = opts.cwd; + } + if (opts.envs) { + body.process.envs = opts.envs; + } + if (opts.tag) { + body.tag = opts.tag; + } + + return connectLiveCommand(this, '/process.Process/Start', body, Object.assign({}, opts, { + requestTimeoutMs: requestTimeout(opts) + })); +}; + +Commands.prototype.list = function (opts) { + return connectRPC(this.sandbox, '/process.Process/List', {}, opts).then(data => { + return (data.processes || []).map(p => ({ + pid: p.pid, + tag: p.tag, + cmd: p.config && p.config.cmd, + args: p.config && p.config.args, + envs: p.config && p.config.envs, + cwd: p.config && p.config.cwd + })); + }); +}; + +Commands.prototype.connect = function (pid, opts) { + opts = opts || {}; + return connectLiveCommand(this, '/process.Process/Connect', { + process: { + selector: { pid } + } + }, Object.assign({}, opts, { + requestTimeoutMs: requestTimeout(opts) + }), pid); +}; + +Commands.prototype.sendStdin = function (pid, data, opts) { + const stdin = Buffer.isBuffer(data) ? data.toString('base64') : Buffer.from(String(data)).toString('base64'); + return connectRPC(this.sandbox, '/process.Process/SendInput', { + process: { + selector: { pid } + }, + input: { + stdin + } + }, opts).then(() => null); +}; + +Commands.prototype.closeStdin = function (pid, opts) { + return connectRPC(this.sandbox, '/process.Process/CloseStdin', { + process: { + selector: { pid } + } + }, opts).then(() => null); +}; + +Commands.prototype.kill = function (pid, opts) { + return connectRPC(this.sandbox, '/process.Process/SendSignal', { + process: { + selector: { pid } + }, + signal: 'SIGNAL_SIGKILL' + }, opts).then(() => null); +}; + +exports.Commands = Commands; +exports.CommandHandle = CommandHandle; +exports.commandResultFromEvents = commandResultFromEvents; diff --git a/qiniu/sandbox/constants.js b/qiniu/sandbox/constants.js new file mode 100644 index 00000000..464c56e6 --- /dev/null +++ b/qiniu/sandbox/constants.js @@ -0,0 +1,5 @@ +exports.DEFAULT_ENDPOINT = 'https://cn-yangzhou-1-sandbox.qiniuapi.com'; +exports.DEFAULT_TEMPLATE = 'base'; +exports.DEFAULT_SANDBOX_TIMEOUT_MS = 300000; +exports.ENVD_PORT = 49983; +exports.DEFAULT_USER = 'user'; diff --git a/qiniu/sandbox/envd.js b/qiniu/sandbox/envd.js new file mode 100644 index 00000000..ec2f37ef --- /dev/null +++ b/qiniu/sandbox/envd.js @@ -0,0 +1,145 @@ +const { agentFromClient, basicAuth, millisecondsFromOptions, parseJSON, parseRequestUrl, rawRequest } = require('./util'); + +const MAX_CONNECT_ENVELOPE_BYTES = 10 * 1024 * 1024; + +function envdHeaders (sandbox, user) { + const headers = { + Authorization: basicAuth(user) + }; + if (sandbox.envdAccessToken) { + headers['X-Access-Token'] = sandbox.envdAccessToken; + } + return headers; +} + +function parseConnectResponse (data) { + if (data && data.result !== undefined) { + return data.result; + } + return data; +} + +function connectRPC (sandbox, procedure, body, opts) { + opts = opts || {}; + const headers = Object.assign({ + 'Content-Type': 'application/json' + }, envdHeaders(sandbox, opts.user)); + if (opts.keepalive) { + headers['Keepalive-Ping-Interval'] = '50'; + } + + const requestUrl = sandbox.envdUrl() + procedure; + const target = parseRequestUrl(requestUrl); + return rawRequest(requestUrl, { + method: 'POST', + content: JSON.stringify(body || {}), + dataType: 'text', + headers, + agent: agentFromClient(sandbox.client, target.protocol), + timeout: opts.requestTimeoutMs !== undefined + ? opts.requestTimeoutMs + : millisecondsFromOptions(opts, 'timeout') + }).then(({ data }) => parseConnectResponse(parseJSON(data))); +} + +function encodeConnectEnvelope (message) { + const payload = Buffer.from(JSON.stringify(message || {})); + const header = Buffer.alloc(5); + header[0] = 0; + header.writeUInt32BE(payload.length, 1); + return Buffer.concat([header, payload]); +} + +function connectEndStreamError (payload) { + if (!payload) { + return null; + } + const data = JSON.parse(String(payload)); + if (!data || !data.error) { + return null; + } + const error = data.error; + const err = new Error(error.message || 'Sandbox envd stream failed'); + err.code = error.code; + err.details = error.details; + return err; +} + +function decodeConnectEnvelopes (data) { + data = Buffer.isBuffer(data) ? data : Buffer.from(data || ''); + const messages = []; + let offset = 0; + while (offset + 5 <= data.length) { + const flags = data[offset]; + const length = data.readUInt32BE(offset + 1); + offset += 5; + if (length > MAX_CONNECT_ENVELOPE_BYTES) { + throw new Error(`Sandbox envd stream envelope too large: ${length}`); + } + if (offset + length > data.length) { + throw new Error('Sandbox envd stream truncated unexpectedly'); + } + const payload = data.slice(offset, offset + length).toString(); + offset += length; + if (flags & 2) { + const err = connectEndStreamError(payload); + if (err) { + throw err; + } + continue; + } + if (payload) { + messages.push(JSON.parse(payload)); + } + } + return messages; +} + +function eventListFromResponse (data) { + if (Array.isArray(data.events)) { + return data.events; + } + if (Array.isArray(data)) { + return data; + } + if (data.event) { + return [data]; + } + return []; +} + +function connectStreamRPC (sandbox, procedure, body, opts) { + opts = opts || {}; + const headers = Object.assign({ + 'Content-Type': 'application/connect+json' + }, envdHeaders(sandbox, opts.user)); + if (opts.keepalive) { + headers['Keepalive-Ping-Interval'] = '50'; + } + + const requestUrl = sandbox.envdUrl() + procedure; + const target = parseRequestUrl(requestUrl); + return rawRequest(requestUrl, { + method: 'POST', + content: encodeConnectEnvelope(body), + dataType: 'buffer', + headers, + agent: agentFromClient(sandbox.client, target.protocol), + timeout: opts.requestTimeoutMs !== undefined + ? opts.requestTimeoutMs + : millisecondsFromOptions(opts, 'timeout') + }).then(({ data, resp }) => { + const contentType = (resp.headers && resp.headers['content-type']) || ''; + if (contentType.indexOf('application/connect+json') >= 0) { + return decodeConnectEnvelopes(data); + } + return eventListFromResponse(parseJSON(data)); + }); +} + +exports.connectRPC = connectRPC; +exports.connectStreamRPC = connectStreamRPC; +exports.connectEndStreamError = connectEndStreamError; +exports.decodeConnectEnvelopes = decodeConnectEnvelopes; +exports.envdHeaders = envdHeaders; +exports.MAX_CONNECT_ENVELOPE_BYTES = MAX_CONNECT_ENVELOPE_BYTES; diff --git a/qiniu/sandbox/errors.js b/qiniu/sandbox/errors.js new file mode 100644 index 00000000..6af0a0a0 --- /dev/null +++ b/qiniu/sandbox/errors.js @@ -0,0 +1,53 @@ +function defineError (name) { + function TypedSandboxError (message, props) { + Error.call(this); + this.name = name; + this.message = message || name; + if (props) { + Object.assign(this, props); + } + if (Error.captureStackTrace) { + Error.captureStackTrace(this, TypedSandboxError); + } + } + + TypedSandboxError.prototype = Object.create(Error.prototype); + TypedSandboxError.prototype.constructor = TypedSandboxError; + return TypedSandboxError; +} + +function SandboxError (message, response, data) { + Error.call(this); + this.name = 'SandboxError'; + this.message = message; + this.response = response; + this.data = data; + if (Error.captureStackTrace) { + Error.captureStackTrace(this, SandboxError); + } +} + +SandboxError.prototype = Object.create(Error.prototype); +SandboxError.prototype.constructor = SandboxError; + +function CommandExitError (result) { + result = result || {}; + SandboxError.call(this, `Command exited with code ${result.exitCode}`, null, result); + this.name = 'CommandExitError'; + Object.assign(this, result); +} + +CommandExitError.prototype = Object.create(SandboxError.prototype); +CommandExitError.prototype.constructor = CommandExitError; + +exports.SandboxError = SandboxError; +exports.CommandExitError = CommandExitError; +exports.TimeoutError = defineError('TimeoutError'); +exports.NotImplementedError = defineError('NotImplementedError'); +exports.InvalidArgumentError = defineError('InvalidArgumentError'); +exports.NotFoundError = defineError('NotFoundError'); +exports.FileNotFoundError = defineError('FileNotFoundError'); +exports.SandboxNotFoundError = defineError('SandboxNotFoundError'); +exports.GitAuthError = defineError('GitAuthError'); +exports.GitUpstreamError = defineError('GitUpstreamError'); +exports.TemplateBuildError = defineError('TemplateBuildError'); diff --git a/qiniu/sandbox/filesystem.js b/qiniu/sandbox/filesystem.js new file mode 100644 index 00000000..5affa372 --- /dev/null +++ b/qiniu/sandbox/filesystem.js @@ -0,0 +1,551 @@ +const { connectEndStreamError, connectRPC, envdHeaders, MAX_CONNECT_ENVELOPE_BYTES } = require('./envd'); +const { SandboxError } = require('./errors'); +const { agentFromClient, millisecondsFromOptions, parseRequestUrl, rawRequest } = require('./util'); +const { Readable } = require('stream'); +const zlib = require('zlib'); +const http = require('http'); +const https = require('https'); + +const FileType = { + FILE: 'file', + DIR: 'dir' +}; + +const FilesystemEventType = { + CREATE: 'create', + WRITE: 'write', + REMOVE: 'remove', + RENAME: 'rename', + CHMOD: 'chmod' +}; + +const ENVD_VERSION_RECURSIVE_WATCH = '0.1.4'; + +function versionGte (version, minimum) { + if (!version) { + return false; + } + const left = String(version).split('.').map(value => parseInt(value, 10) || 0); + const right = String(minimum).split('.').map(value => parseInt(value, 10) || 0); + const length = Math.max(left.length, right.length); + for (let i = 0; i < length; i++) { + const a = left[i] || 0; + const b = right[i] || 0; + if (a > b) { + return true; + } + if (a < b) { + return false; + } + } + return true; +} + +function normalizeFileType (type) { + if (type === 'FILE_TYPE_DIRECTORY' || type === 'DIRECTORY' || type === 'dir') { + return 'dir'; + } + if (type === 'FILE_TYPE_FILE' || type === 'FILE' || type === 'file') { + return 'file'; + } + return type || 'unknown'; +} + +function normalizeEntry (entry) { + entry = entry || {}; + return Object.assign({}, entry, { + type: normalizeFileType(entry.type) + }); +} + +function encodeConnectEnvelope (message) { + const payload = Buffer.from(JSON.stringify(message || {})); + const header = Buffer.alloc(5); + header[0] = 0; + header.writeUInt32BE(payload.length, 1); + return Buffer.concat([header, payload]); +} + +function eventPayload (message) { + return message.event || message; +} + +function normalizeWatchEventPayload (message) { + const event = eventPayload(message); + if (!event) { + return {}; + } + if (event.case) { + const result = {}; + result[event.case] = event.value || {}; + return result; + } + return event; +} + +function normalizeFilesystemEventType (type) { + if (type === 1 || type === 'EVENT_TYPE_CREATE' || type === 'CREATE' || type === 'create') { + return FilesystemEventType.CREATE; + } + if (type === 2 || type === 'EVENT_TYPE_WRITE' || type === 'WRITE' || type === 'write') { + return FilesystemEventType.WRITE; + } + if (type === 3 || type === 'EVENT_TYPE_REMOVE' || type === 'REMOVE' || type === 'remove') { + return FilesystemEventType.REMOVE; + } + if (type === 4 || type === 'EVENT_TYPE_RENAME' || type === 'RENAME' || type === 'rename') { + return FilesystemEventType.RENAME; + } + if (type === 5 || type === 'EVENT_TYPE_CHMOD' || type === 'CHMOD' || type === 'chmod') { + return FilesystemEventType.CHMOD; + } + return undefined; +} + +function WatchHandle (request, onExit) { + this._request = request; + this._onExit = onExit; + this._stopped = false; +} + +WatchHandle.prototype.stop = function () { + this._stopped = true; + if (this._request) { + this._request.destroy(); + this._request = null; + } + return Promise.resolve(); +}; + +WatchHandle.prototype._finish = function (err) { + if (this._stopped) { + return; + } + this._stopped = true; + if (this._onExit) { + Promise.resolve(this._onExit(err)).catch(() => {}); + } +}; + +function multipartFilename (value) { + return String(value || '') + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\r/g, '%0D') + .replace(/\n/g, '%0A'); +} + +function dataToUploadBuffer (data) { + if (Buffer.isBuffer(data)) { + return data; + } + if (data instanceof Uint8Array) { + return Buffer.from(data.buffer, data.byteOffset, data.byteLength); + } + if (data instanceof ArrayBuffer) { + return Buffer.from(data); + } + return Buffer.from(String(data !== undefined && data !== null ? data : '')); +} + +function multipartBody (boundary, parts) { + const chunks = []; + parts.forEach(part => { + chunks.push(Buffer.from(`--${boundary}\r\n`)); + chunks.push(Buffer.from(`Content-Disposition: form-data; name="${part.field}"; filename="${multipartFilename(part.filename)}"\r\n`)); + chunks.push(Buffer.from('Content-Type: application/octet-stream\r\n\r\n')); + chunks.push(dataToUploadBuffer(part.data)); + chunks.push(Buffer.from('\r\n')); + }); + chunks.push(Buffer.from(`--${boundary}--\r\n`)); + return Buffer.concat(chunks); +} + +function Filesystem (sandbox) { + this.sandbox = sandbox; +} + +function formatReadResult (data, opts) { + opts = opts || {}; + const buffer = Buffer.isBuffer(data) ? data : Buffer.from(String(data || '')); + const format = opts.format || 'text'; + if (format === 'bytes') { + return buffer; + } + if (format === 'stream') { + if (typeof Readable.from === 'function') { + return Readable.from([buffer]); + } + const stream = new Readable(); + stream._read = function () {}; + stream.push(buffer); + stream.push(null); + return stream; + } + if (format === 'blob') { + return typeof global.Blob !== 'undefined' ? new global.Blob([buffer]) : buffer; + } + return buffer.toString(); +} + +function envdAgent (sandbox, requestUrl) { + return agentFromClient(sandbox.client, parseRequestUrl(requestUrl).protocol); +} + +function gzipAsync (content) { + return new Promise((resolve, reject) => { + zlib.gzip(content, (err, result) => { + if (err) { + reject(err); + return; + } + resolve(result); + }); + }); +} + +Filesystem.prototype.read = function (path, opts) { + opts = opts || {}; + const headers = {}; + if (opts.gzip) { + headers['Accept-Encoding'] = 'gzip'; + } + const requestUrl = this.sandbox.downloadUrl(path, opts); + return rawRequest(requestUrl, { + method: 'GET', + dataType: 'buffer', + headers, + agent: envdAgent(this.sandbox, requestUrl), + gzip: !!opts.gzip + }).then(({ data }) => formatReadResult(data, opts)); +}; + +Filesystem.prototype.readText = function (path, opts) { + return this.read(path, opts).then(data => Buffer.isBuffer(data) ? data.toString() : data); +}; + +Filesystem.prototype.write = function (pathOrFiles, dataOrOpts, maybeOpts) { + if (Array.isArray(pathOrFiles)) { + return this.writeFiles(pathOrFiles, dataOrOpts); + } + + const path = pathOrFiles; + const opts = maybeOpts || {}; + if (dataOrOpts && typeof dataOrOpts === 'object' && !Buffer.isBuffer(dataOrOpts) && typeof dataOrOpts.pipe === 'function') { + return Promise.reject(new TypeError('Streams are not supported as data in filesystem.write')); + } + const supportsEncodedUpload = versionGte(this.sandbox.envdVersion, '0.5.7'); + if (opts.useOctetStream && supportsEncodedUpload) { + const headers = { + 'Content-Type': 'application/octet-stream' + }; + const content = dataToUploadBuffer(dataOrOpts); + const compressed = opts.gzip && supportsEncodedUpload + ? gzipAsync(content).then(result => { + headers['Content-Encoding'] = 'gzip'; + return result; + }) + : Promise.resolve(content); + + return compressed.then(content => { + const requestUrl = this.sandbox.uploadUrl(path, opts); + return rawRequest(requestUrl, { + method: 'POST', + content, + dataType: 'json', + agent: envdAgent(this.sandbox, requestUrl), + headers + }); + }).then(({ data }) => Array.isArray(data) ? normalizeEntry(data[0]) : normalizeEntry(data)); + } + + const boundary = `qiniu-sandbox-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const body = multipartBody(boundary, [{ + field: 'file', + filename: path, + data: dataOrOpts + }]); + const headers = { + 'Content-Type': `multipart/form-data; boundary=${boundary}` + }; + const compressed = opts.gzip && supportsEncodedUpload + ? gzipAsync(body).then(result => { + headers['Content-Encoding'] = 'gzip'; + return result; + }) + : Promise.resolve(body); + + return compressed.then(body => { + const requestUrl = this.sandbox.uploadUrl(path, opts); + return rawRequest(requestUrl, { + method: 'POST', + content: body, + dataType: 'json', + agent: envdAgent(this.sandbox, requestUrl), + headers + }); + }).then(({ data }) => Array.isArray(data) ? normalizeEntry(data[0]) : normalizeEntry(data)); +}; + +Filesystem.prototype.writeFiles = function (files, opts) { + opts = opts || {}; + if (!Array.isArray(files)) { + return Promise.reject(new TypeError('files must be an array')); + } + for (let i = 0; i < files.length; i += 1) { + const file = files[i]; + if (!file || typeof file !== 'object') { + return Promise.reject(new TypeError('Each file must be an object')); + } + if (file.data && typeof file.data === 'object' && !Buffer.isBuffer(file.data) && typeof file.data.pipe === 'function') { + return Promise.reject(new TypeError('Streams are not supported as data in filesystem.writeFiles')); + } + } + const boundary = `qiniu-sandbox-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const parts = files.map(file => ({ + field: 'file', + filename: file.path, + data: file.data + })); + + const requestUrl = this.sandbox.batchUploadUrl(opts); + return rawRequest(requestUrl, { + method: 'POST', + content: multipartBody(boundary, parts), + dataType: 'json', + agent: envdAgent(this.sandbox, requestUrl), + headers: { + 'Content-Type': `multipart/form-data; boundary=${boundary}` + } + }).then(({ data }) => (data || []).map(normalizeEntry)); +}; + +Filesystem.prototype.getInfo = function (path, opts) { + return connectRPC(this.sandbox, '/filesystem.Filesystem/Stat', { path }, opts) + .then(data => normalizeEntry(data.entry)); +}; + +Filesystem.prototype.stat = Filesystem.prototype.getInfo; + +Filesystem.prototype.list = function (path, opts) { + opts = opts || {}; + return connectRPC(this.sandbox, '/filesystem.Filesystem/ListDir', { + path, + depth: opts.depth || 1 + }, opts).then(data => (data.entries || []).map(normalizeEntry)); +}; + +Filesystem.prototype.exists = function (path, opts) { + return this.getInfo(path, opts).then(() => true, err => { + const resp = err.response || err.resp; + if (resp && resp.statusCode === 404) { + return false; + } + throw err; + }); +}; + +Filesystem.prototype.makeDir = function (path, opts) { + return connectRPC(this.sandbox, '/filesystem.Filesystem/MakeDir', { path }, opts) + .then(data => normalizeEntry(data.entry)); +}; + +Filesystem.prototype.mkdir = Filesystem.prototype.makeDir; + +Filesystem.prototype.remove = function (path, opts) { + return connectRPC(this.sandbox, '/filesystem.Filesystem/Remove', { path }, opts) + .then(() => null); +}; + +Filesystem.prototype.rename = function (oldPath, newPath, opts) { + return connectRPC(this.sandbox, '/filesystem.Filesystem/Move', { + source: oldPath, + destination: newPath + }, opts).then(data => normalizeEntry(data.entry)); +}; + +Filesystem.prototype.move = Filesystem.prototype.rename; + +Filesystem.prototype.watchDir = function (path, onEvent, opts) { + opts = opts || {}; + if (opts.recursive && !versionGte(this.sandbox.envdVersion, ENVD_VERSION_RECURSIVE_WATCH)) { + return Promise.reject(new SandboxError('You need to update the template to use recursive watching.')); + } + + return watchDir(this.sandbox, path, onEvent, opts); +}; + +exports.Filesystem = Filesystem; +exports.FileType = FileType; +exports.FilesystemEventType = FilesystemEventType; +exports.WatchHandle = WatchHandle; +exports.normalizeEntry = normalizeEntry; + +function watchDir (sandbox, path, onEvent, opts) { + return new Promise((resolve, reject) => { + const target = parseRequestUrl(sandbox.envdUrl() + '/filesystem.Filesystem/WatchDir'); + const transport = target.protocol === 'https:' ? https : http; + const headers = Object.assign({ + 'Content-Type': 'application/connect+json', + 'Keepalive-Ping-Interval': '50' + }, envdHeaders(sandbox, opts.user)); + const req = transport.request({ + method: 'POST', + protocol: target.protocol, + hostname: target.hostname, + port: target.port, + path: target.path, + headers, + agent: agentFromClient(sandbox.client, target.protocol) + }); + + let settled = false; + let handle; + let responseBuffer = Buffer.alloc(0); + let startTimer; + let pendingCallbacks = 0; + let streamEnded = false; + + function cleanupStartTimer () { + if (startTimer) { + clearTimeout(startTimer); + startTimer = null; + } + } + + function finishIfIdle () { + if (streamEnded && handle && pendingCallbacks === 0) { + handle._finish(); + } + } + + function fail (err) { + cleanupStartTimer(); + if (!settled) { + settled = true; + reject(err); + return; + } + if (handle) { + handle._finish(err); + } + } + + function handleMessage (message) { + const event = normalizeWatchEventPayload(message); + if (event.start && !handle) { + cleanupStartTimer(); + handle = new WatchHandle(req, opts.onExit); + settled = true; + resolve(handle); + return; + } + if (event.filesystem && onEvent) { + const type = normalizeFilesystemEventType(event.filesystem.type); + if (type) { + try { + pendingCallbacks += 1; + Promise.resolve(onEvent({ + name: event.filesystem.name, + type + })).then(() => { + pendingCallbacks -= 1; + finishIfIdle(); + }, err => { + pendingCallbacks -= 1; + fail(err); + req.destroy(); + }); + } catch (err) { + pendingCallbacks -= 1; + fail(err); + req.destroy(); + } + } + } + } + + const startTimeout = opts.requestTimeoutMs !== undefined + ? opts.requestTimeoutMs + : millisecondsFromOptions(opts, 'timeout'); + if (startTimeout) { + startTimer = setTimeout(() => { + fail(new SandboxError('Sandbox filesystem watch start timed out')); + req.destroy(); + }, startTimeout); + } + + req.on('response', res => { + if (res.statusCode < 200 || res.statusCode >= 300) { + fail(new SandboxError(`Sandbox envd request failed with status ${res.statusCode}`, res)); + res.resume(); + return; + } + res.on('data', chunk => { + responseBuffer = Buffer.concat([responseBuffer, chunk]); + while (responseBuffer.length >= 5) { + const flags = responseBuffer[0]; + const length = responseBuffer.readUInt32BE(1); + if (length > MAX_CONNECT_ENVELOPE_BYTES) { + fail(new SandboxError(`Sandbox envd stream envelope too large: ${length}`)); + req.destroy(); + return; + } + if (responseBuffer.length < 5 + length) { + break; + } + const payload = responseBuffer.slice(5, 5 + length).toString(); + responseBuffer = responseBuffer.slice(5 + length); + if (flags & 2) { + try { + const err = connectEndStreamError(payload); + if (err) { + fail(err); + req.destroy(); + return; + } + } catch (err) { + fail(err); + req.destroy(); + return; + } + continue; + } + if (!(flags & 2) && payload) { + try { + handleMessage(JSON.parse(payload)); + } catch (err) { + fail(err); + req.destroy(); + return; + } + } + } + }); + res.on('error', fail); + res.on('end', () => { + if (responseBuffer.length > 0) { + fail(new SandboxError('Sandbox envd stream truncated unexpectedly')); + return; + } + cleanupStartTimer(); + if (!settled) { + fail(new Error('WatchDir stream ended before start event')); + return; + } + streamEnded = true; + finishIfIdle(); + }); + }); + req.on('error', err => { + if (handle && handle._stopped) { + return; + } + fail(err); + }); + req.end(encodeConnectEnvelope({ + path, + recursive: !!opts.recursive + })); + }); +} diff --git a/qiniu/sandbox/git.js b/qiniu/sandbox/git.js new file mode 100644 index 00000000..7b64861c --- /dev/null +++ b/qiniu/sandbox/git.js @@ -0,0 +1,428 @@ +const { shellQuote } = require('./util'); +const { GitAuthError, GitUpstreamError, InvalidArgumentError } = require('./errors'); + +function Git (commands) { + this.commands = commands; +} + +function gitConfigArgs (opts) { + opts = opts || {}; + const config = opts.config || {}; + return Object.keys(config).map(key => { + return ['-c', shellQuote(`${key}=${config[key]}`)]; + }).reduce((acc, item) => acc.concat(item), []); +} + +function pathAndOptions (pathOrOpts, maybeOpts) { + if (typeof pathOrOpts === 'string') { + return { path: pathOrOpts, opts: Object.assign({}, maybeOpts || {}) }; + } + const opts = Object.assign({}, pathOrOpts || {}); + return { path: opts.path, opts }; +} + +function authUrl (repoUrl, opts) { + opts = opts || {}; + if (!opts.username && !opts.password) { + return repoUrl; + } + if (!opts.username || !opts.password) { + throw new GitAuthError('Both username and password are required for git authentication'); + } + return stripAuth(repoUrl).replace(/^(https?):\/\//, `$1://${encodeURIComponent(opts.username)}:${encodeURIComponent(opts.password)}@`); +} + +function stripAuth (repoUrl) { + return String(repoUrl || '').replace(/^(https?):\/\/[^/@]+@/, '$1://'); +} + +function credentialHelperArgs (opts) { + opts = opts || {}; + if (!opts.username && !opts.password) { + return []; + } + if (!opts.username || !opts.password) { + throw new GitAuthError('Both username and password are required for git authentication'); + } + return [ + '-c', + shellQuote('credential.helper=!f() { printf "username=%s\\npassword=%s\\n" "$GIT_USERNAME" "$GIT_PASSWORD"; }; f'), + '-c', + shellQuote('credential.useHttpPath=true') + ]; +} + +function gitCredentialOptions (opts) { + opts = Object.assign({}, opts || {}); + if (opts.username || opts.password) { + if (!opts.username || !opts.password) { + throw new GitAuthError('Both username and password are required for git authentication'); + } + opts.envs = Object.assign({}, opts.envs || {}, { + GIT_USERNAME: opts.username, + GIT_PASSWORD: opts.password + }); + } + return opts; +} + +function configScopeArg (opts) { + opts = opts || {}; + if (opts.scope === 'global') { + return '--global'; + } + if (opts.scope === 'system') { + return '--system'; + } + if (opts.scope === 'local') { + return '--local'; + } + return null; +} + +function pathFromOpts (opts) { + return opts && opts.path; +} + +function normalizeConfigCall (args) { + if (args.length === 2) { + return { + repoPath: undefined, + key: args[0], + value: args[1], + opts: {} + }; + } + if (typeof args[2] === 'object' && args[2] !== null) { + return { + repoPath: pathFromOpts(args[2]), + key: args[0], + value: args[1], + opts: Object.assign({}, args[2]) + }; + } + return { + repoPath: args[0], + key: args[1], + value: args[2], + opts: Object.assign({}, args[3] || {}) + }; +} + +function normalizeGetConfigCall (args) { + if (args.length === 1) { + return { + repoPath: undefined, + key: args[0], + opts: {} + }; + } + if (typeof args[1] === 'object' && args[1] !== null) { + return { + repoPath: pathFromOpts(args[1]), + key: args[0], + opts: Object.assign({}, args[1]) + }; + } + return { + repoPath: args[0], + key: args[1], + opts: Object.assign({}, args[2] || {}) + }; +} + +function normalizeConfigureUserCall (args) { + if (args.length === 2) { + return { + repoPath: undefined, + name: args[0], + email: args[1], + opts: {} + }; + } + if (typeof args[2] === 'object' && args[2] !== null) { + return { + repoPath: pathFromOpts(args[2]), + name: args[0], + email: args[1], + opts: Object.assign({}, args[2]) + }; + } + return { + repoPath: args[0], + name: args[1], + email: args[2], + opts: Object.assign({}, args[3] || {}) + }; +} + +Git.prototype._runGit = function (repoPath, args, opts) { + opts = Object.assign({}, opts || {}); + if (repoPath) { + opts.cwd = repoPath; + } + return this.commands.run(`git ${gitConfigArgs(opts).concat(args).join(' ')}`, opts); +}; + +Git.prototype.clone = function (repoUrl, pathOrOpts, maybeOpts) { + const normalized = pathAndOptions(pathOrOpts, maybeOpts); + const opts = gitCredentialOptions(normalized.opts); + const targetUrl = opts.username || opts.password ? stripAuth(repoUrl) : repoUrl; + const args = gitConfigArgs(opts).concat(credentialHelperArgs(opts)).concat(['clone', shellQuote(targetUrl)]); + if (opts.depth) { + args.push('--depth', shellQuote(opts.depth)); + } + if (opts.branch) { + args.push('--branch', shellQuote(opts.branch)); + } + if (normalized.path) { + args.push(shellQuote(normalized.path)); + } + return this.commands.run(`git ${args.join(' ')}`, opts); +}; + +Git.prototype.init = function (repoPath, opts) { + opts = opts || {}; + const args = ['init']; + if (opts.bare) { + args.push('--bare'); + } + if (opts.initialBranch) { + args.push('--initial-branch', shellQuote(opts.initialBranch)); + } + return this._runGit(repoPath, args, opts); +}; + +Git.prototype.status = function (repoPath, opts) { + return this._runGit(repoPath, ['status', '--porcelain=v1', '-b'], opts) + .then(result => parseGitStatus(result.stdout)); +}; + +Git.prototype.add = function (repoPath, opts) { + opts = opts || {}; + const files = opts.all ? ['--all'] : (opts.files || ['.']).map(shellQuote); + return this._runGit(repoPath, ['add'].concat(files), opts); +}; + +Git.prototype.commit = function (repoPath, message, opts) { + opts = opts || {}; + const args = ['commit', '-m', shellQuote(message)]; + if (opts.authorName || opts.authorEmail) { + if (!opts.authorName || !opts.authorEmail) { + throw new GitAuthError('Both authorName and authorEmail are required for git commit author'); + } + args.push('--author', shellQuote(`${opts.authorName} <${opts.authorEmail}>`)); + } + if (opts.allowEmpty) { + args.push('--allow-empty'); + } + return this._runGit(repoPath, args, opts); +}; + +Git.prototype.pull = function (repoPath, opts) { + opts = opts || {}; + const args = ['pull']; + if (opts.remote) { + args.push(shellQuote(opts.remote)); + } + if (opts.branch) { + args.push(shellQuote(opts.branch)); + } + return this._runGitWithTemporaryAuth(repoPath, args, opts); +}; + +Git.prototype.push = function (repoPath, opts) { + opts = opts || {}; + const args = ['push']; + if (opts.remote) { + args.push(shellQuote(opts.remote)); + } + if (opts.branch) { + args.push(shellQuote(opts.branch)); + } + return this._runGitWithTemporaryAuth(repoPath, args, opts); +}; + +Git.prototype.createBranch = function (repoPath, branch, opts) { + return this._runGit(repoPath, ['checkout', '-b', shellQuote(branch)], opts); +}; + +Git.prototype.checkoutBranch = function (repoPath, branch, opts) { + return this._runGit(repoPath, ['checkout', shellQuote(branch)], opts); +}; + +Git.prototype.deleteBranch = function (repoPath, branch, opts) { + opts = opts || {}; + return this._runGit(repoPath, ['branch', opts.force ? '-D' : '-d', shellQuote(branch)], opts); +}; + +Git.prototype.remoteAdd = function (repoPath, name, repoUrl, opts) { + opts = opts || {}; + const add = () => this._runGit(repoPath, ['remote', 'add', shellQuote(name), shellQuote(repoUrl)], opts); + const afterAdd = result => opts.fetch && (!result || result.exitCode === 0) ? this._runGit(repoPath, ['fetch', shellQuote(name)], opts) : result; + if (opts.overwrite) { + return this._runGit(repoPath, ['remote', 'remove', shellQuote(name)], opts) + .then(add, add) + .then(afterAdd); + } + return add().then(afterAdd); +}; + +Git.prototype.remoteGet = function (repoPath, name, opts) { + return this._runGit(repoPath, ['remote', 'get-url', shellQuote(name)], opts) + .then(result => result.exitCode ? undefined : result.stdout.trim()); +}; + +Git.prototype.setConfig = function () { + const normalized = normalizeConfigCall(arguments); + const opts = normalized.opts; + delete opts.path; + const scope = configScopeArg(opts); + const args = ['config']; + if (scope) { + args.push(scope); + } + args.push(shellQuote(normalized.key), shellQuote(normalized.value)); + return this._runGit(normalized.repoPath, args, opts); +}; + +Git.prototype.getConfig = function () { + const normalized = normalizeGetConfigCall(arguments); + const opts = normalized.opts; + delete opts.path; + const scope = configScopeArg(opts); + const args = ['config']; + if (scope) { + args.push(scope); + } + args.push('--get', shellQuote(normalized.key)); + return this._runGit(normalized.repoPath, args, opts) + .then(result => result.exitCode ? undefined : result.stdout.trim()); +}; + +Git.prototype.configureUser = function () { + const normalized = normalizeConfigureUserCall(arguments); + const opts = normalized.opts; + if (normalized.repoPath && opts.path === undefined) { + opts.path = normalized.repoPath; + } + return this.setConfig('user.name', normalized.name, opts) + .then(() => this.setConfig('user.email', normalized.email, opts)); +}; + +Git.prototype.branches = function (repoPath, opts) { + return this._runGit(repoPath, ['branch', shellQuote('--format=%(HEAD) %(refname:short)')], opts) + .then(result => String(result.stdout || '').split(/\r?\n/) + .filter(Boolean) + .map(line => ({ + current: line.charAt(0) === '*', + name: line.slice(2).trim() + }))); +}; + +Git.prototype.reset = function (repoPath, opts) { + opts = opts || {}; + const args = ['reset']; + const rawPaths = opts.paths || opts.files || []; + const paths = Array.isArray(rawPaths) ? rawPaths : [rawPaths]; + const mode = opts.mode || (opts.hard ? 'hard' : null) || (opts.soft ? 'soft' : null) || (opts.mixed ? 'mixed' : null); + if (mode) { + if (paths.length) { + throw new InvalidArgumentError('Git reset mode cannot be used when paths are specified'); + } + if (['soft', 'mixed', 'hard', 'merge', 'keep'].indexOf(mode) === -1) { + throw new InvalidArgumentError(`Invalid git reset mode: ${mode}`); + } + args.push(`--${mode}`); + } + const target = opts.target || opts.ref; + if (target) { + args.push(shellQuote(target)); + } + if (paths.length) { + args.push('--'); + paths.forEach(path => args.push(shellQuote(path))); + } + return this._runGit(repoPath, args, opts); +}; + +Git.prototype.restore = function (repoPath, opts) { + opts = opts || {}; + const args = ['restore']; + const staged = opts.staged; + let worktree = opts.worktree; + if (staged === undefined && worktree === undefined) { + worktree = true; + } else if (staged === true && worktree === undefined) { + worktree = false; + } + if (worktree) { + args.push('--worktree'); + } + if (staged) { + args.push('--staged'); + } + if (opts.source) { + args.push('--source', shellQuote(opts.source)); + } + const rawPaths = opts.paths || opts.files || []; + const paths = Array.isArray(rawPaths) ? rawPaths : [rawPaths]; + if (!paths.length) { + throw new InvalidArgumentError('At least one path must be specified for git restore'); + } + args.push('--'); + paths.forEach(path => args.push(shellQuote(path))); + return this._runGit(repoPath, args, opts); +}; + +Git.prototype.dangerouslyAuthenticate = function (repoPath, remote, username, password, opts) { + opts = opts || {}; + return this.remoteGet(repoPath, remote, opts).then(repoUrl => { + if (!repoUrl) { + throw new GitUpstreamError(`Remote ${remote} does not exist`); + } + return this._runGit(repoPath, ['remote', 'set-url', shellQuote(remote), shellQuote(authUrl(repoUrl, { + username, + password + }))], opts); + }); +}; + +Git.prototype._runGitWithTemporaryAuth = function (repoPath, args, opts) { + opts = opts || {}; + if (!opts.username && !opts.password) { + return this._runGit(repoPath, args, opts); + } + const runOpts = gitCredentialOptions(opts); + const authArgs = credentialHelperArgs(runOpts).concat(args); + return this._runGit(repoPath, authArgs, runOpts); +}; + +function parseGitStatus (stdout) { + stdout = String(stdout || '').replace(/\\n/g, '\n'); + const status = { + currentBranch: '', + changedFiles: [], + untrackedFiles: [], + raw: stdout + }; + stdout.split(/\r?\n/).forEach(line => { + if (!line) { + return; + } + if (line.indexOf('## ') === 0) { + status.currentBranch = line.slice(3).split('...')[0].trim(); + return; + } + if (line.indexOf('?? ') === 0) { + status.untrackedFiles.push(line.slice(3).trim()); + return; + } + status.changedFiles.push(line.slice(3).trim()); + }); + return status; +} + +exports.Git = Git; +exports.parseGitStatus = parseGitStatus; +exports.stripAuth = stripAuth; diff --git a/qiniu/sandbox/index.js b/qiniu/sandbox/index.js new file mode 100644 index 00000000..996694fa --- /dev/null +++ b/qiniu/sandbox/index.js @@ -0,0 +1,38 @@ +const { ResponseWrapper } = require('../httpc/responseWrapper'); +const constants = require('./constants'); +const errors = require('./errors'); +const network = require('./network'); + +module.exports = { + DEFAULT_ENDPOINT: constants.DEFAULT_ENDPOINT, + DEFAULT_TEMPLATE: constants.DEFAULT_TEMPLATE, + DEFAULT_SANDBOX_TIMEOUT_MS: constants.DEFAULT_SANDBOX_TIMEOUT_MS, + DEFAULT_USER: constants.DEFAULT_USER, + ALL_TRAFFIC: network.ALL_TRAFFIC, + SandboxClient: require('./client').SandboxClient, + Sandbox: require('./sandbox').Sandbox, + SandboxPaginator: require('./sandbox').SandboxPaginator, + SnapshotPaginator: require('./sandbox').SnapshotPaginator, + Filesystem: require('./filesystem').Filesystem, + FileType: require('./filesystem').FileType, + FilesystemEventType: require('./filesystem').FilesystemEventType, + WatchHandle: require('./filesystem').WatchHandle, + Commands: require('./commands').Commands, + CommandHandle: require('./commands').CommandHandle, + Git: require('./git').Git, + Pty: require('./pty').Pty, + Template: require('./template').Template, + Volume: require('./volume').Volume, + SandboxError: errors.SandboxError, + CommandExitError: errors.CommandExitError, + TimeoutError: errors.TimeoutError, + NotImplementedError: errors.NotImplementedError, + InvalidArgumentError: errors.InvalidArgumentError, + NotFoundError: errors.NotFoundError, + FileNotFoundError: errors.FileNotFoundError, + SandboxNotFoundError: errors.SandboxNotFoundError, + GitAuthError: errors.GitAuthError, + GitUpstreamError: errors.GitUpstreamError, + TemplateBuildError: errors.TemplateBuildError, + ResponseWrapper +}; diff --git a/qiniu/sandbox/network.js b/qiniu/sandbox/network.js new file mode 100644 index 00000000..fc88c787 --- /dev/null +++ b/qiniu/sandbox/network.js @@ -0,0 +1 @@ +exports.ALL_TRAFFIC = '0.0.0.0/0'; diff --git a/qiniu/sandbox/pty.js b/qiniu/sandbox/pty.js new file mode 100644 index 00000000..31335b4a --- /dev/null +++ b/qiniu/sandbox/pty.js @@ -0,0 +1,345 @@ +const { connectEndStreamError, connectRPC, envdHeaders, MAX_CONNECT_ENVELOPE_BYTES } = require('./envd'); +const { agentFromClient, millisecondsFromOptions, parseRequestUrl } = require('./util'); +const http = require('http'); +const https = require('https'); + +function encodeConnectEnvelope (message) { + const payload = Buffer.from(JSON.stringify(message || {})); + const header = Buffer.alloc(5); + header[0] = 0; + header.writeUInt32BE(payload.length, 1); + return Buffer.concat([header, payload]); +} + +function eventPayload (message) { + return message.event || message; +} + +function dataToBuffer (value) { + if (Buffer.isBuffer(value)) { + return value; + } + if (Array.isArray(value)) { + return Buffer.from(value); + } + if (typeof value === 'string') { + return Buffer.from(value, 'base64'); + } + return Buffer.from(String(value || '')); +} + +function LivePtyHandle (pty, pid, request, waitPromise, opts) { + this.pty = pty; + this.pid = pid; + this._request = request; + this._waitPromise = waitPromise; + this._resolveWait = null; + this.opts = opts || {}; + this.stdout = ''; + this.stderr = ''; +} + +LivePtyHandle.prototype.wait = function () { + return this._waitPromise; +}; + +LivePtyHandle.prototype.kill = function () { + return this.pty.kill(this.pid, { + user: this.opts.user, + requestTimeoutMs: this.opts.requestTimeoutMs, + timeoutMs: this.opts.timeoutMs, + timeout: this.opts.timeout + }); +}; + +LivePtyHandle.prototype.disconnect = function () { + this._disconnected = true; + if (this._request) { + this._request.destroy(); + this._request = null; + } + if (this._resolveWait) { + const result = { + exitCode: 0, + stdout: this.stdout, + stderr: this.stderr + }; + this._resolveWait(result); + this._resolveWait = null; + } + return Promise.resolve(); +}; + +function connectLivePty (sandbox, procedure, body, opts, pty) { + opts = opts || {}; + return new Promise((resolve, reject) => { + const target = parseRequestUrl(sandbox.envdUrl() + procedure); + const transport = target.protocol === 'https:' ? https : http; + const headers = Object.assign({ + 'Content-Type': 'application/connect+json', + 'Keepalive-Ping-Interval': '50' + }, envdHeaders(sandbox, opts.user)); + const req = transport.request({ + method: 'POST', + protocol: target.protocol, + hostname: target.hostname, + port: target.port, + path: target.path, + headers, + agent: agentFromClient(sandbox.client, target.protocol) + }); + + let settled = false; + let handle; + let responseBuffer = Buffer.alloc(0); + let result = { + exitCode: -1, + stdout: '', + stderr: '' + }; + let resolveWait; + let rejectWait; + const waitPromise = new Promise((resolve, reject) => { + resolveWait = resolve; + rejectWait = reject; + }); + waitPromise.catch(() => {}); + let startTimer; + + function cleanupStartTimer () { + if (startTimer) { + clearTimeout(startTimer); + startTimer = null; + } + } + + function fail (err) { + cleanupStartTimer(); + if (handle && handle._disconnected) { + if (result.exitCode === -1) { + result.exitCode = 0; + } + resolveWait(result); + return; + } + if (!settled) { + settled = true; + reject(err); + } + rejectWait(err); + } + + function handleMessage (message) { + const event = eventPayload(message); + if (event.start && !handle) { + cleanupStartTimer(); + handle = new LivePtyHandle(pty, event.start.pid, req, waitPromise, opts); + handle._resolveWait = resolveWait; + settled = true; + resolve(handle); + } + if (event.data) { + if (event.data.pty !== undefined && opts.onData) { + opts.onData(dataToBuffer(event.data.pty)); + } + if (event.data.stdout !== undefined) { + const out = dataToBuffer(event.data.stdout).toString(); + result.stdout += out; + if (handle) { + handle.stdout += out; + } + } + if (event.data.stderr !== undefined) { + const err = dataToBuffer(event.data.stderr).toString(); + result.stderr += err; + if (handle) { + handle.stderr += err; + } + } + } + if (event.end) { + result = Object.assign(result, { + exitCode: event.end.exitCode === undefined ? 0 : event.end.exitCode, + error: event.end.error || '' + }); + if (handle) { + handle._resolveWait = null; + } + resolveWait(result); + } + } + + req.on('response', res => { + if (res.statusCode < 200 || res.statusCode >= 300) { + fail(new Error(`Sandbox envd request failed with status ${res.statusCode}`)); + res.resume(); + return; + } + res.on('data', chunk => { + responseBuffer = Buffer.concat([responseBuffer, chunk]); + while (responseBuffer.length >= 5) { + const flags = responseBuffer[0]; + const length = responseBuffer.readUInt32BE(1); + if (length > MAX_CONNECT_ENVELOPE_BYTES) { + fail(new Error(`Sandbox envd stream envelope too large: ${length}`)); + req.destroy(); + return; + } + if (responseBuffer.length < 5 + length) { + break; + } + const payload = responseBuffer.slice(5, 5 + length).toString(); + responseBuffer = responseBuffer.slice(5 + length); + if (flags & 2) { + try { + const err = connectEndStreamError(payload); + if (err) { + fail(err); + req.destroy(); + return; + } + } catch (err) { + fail(err); + req.destroy(); + return; + } + continue; + } + if (!(flags & 2) && payload) { + try { + handleMessage(JSON.parse(payload)); + } catch (err) { + fail(err); + req.destroy(); + return; + } + } + } + }); + res.on('error', fail); + res.on('end', () => { + if (responseBuffer.length > 0) { + fail(new Error('Sandbox envd stream truncated unexpectedly')); + return; + } + if (!settled) { + fail(new Error('PTY stream ended before process start')); + return; + } + if (result.exitCode === -1) { + fail(new Error('PTY stream ended before process end')); + return; + } + resolveWait(result); + }); + }); + req.on('error', fail); + const startTimeout = opts.requestTimeoutMs !== undefined + ? opts.requestTimeoutMs + : millisecondsFromOptions(opts, 'timeout'); + if (startTimeout) { + startTimer = setTimeout(() => { + fail(new Error('PTY stream start timed out')); + req.destroy(); + }, startTimeout); + } + req.end(encodeConnectEnvelope(body)); + }); +} + +function Pty (sandbox) { + this.sandbox = sandbox; + this.commands = sandbox.commands; +} + +Pty.prototype.create = function (opts) { + opts = opts || {}; + const envs = Object.assign({}, opts.envs || {}); + if (!envs.TERM) { + envs.TERM = 'xterm-256color'; + } + if (!envs.LANG) { + envs.LANG = 'C.UTF-8'; + } + if (!envs.LC_ALL) { + envs.LC_ALL = 'C.UTF-8'; + } + + const body = { + process: { + cmd: opts.cmd || '/bin/bash', + args: opts.args || ['-i', '-l'], + envs + }, + pty: { + size: { + cols: opts.cols, + rows: opts.rows + } + } + }; + if (opts.cwd) { + body.process.cwd = opts.cwd; + } + + return connectLivePty(this.sandbox, '/process.Process/Start', body, { + user: opts.user, + onData: opts.onData, + requestTimeoutMs: opts.requestTimeoutMs, + timeoutMs: opts.timeoutMs, + timeout: opts.timeout + }, this); +}; + +Pty.prototype.connect = function (pid, opts) { + opts = opts || {}; + return connectLivePty(this.sandbox, '/process.Process/Connect', { + process: { + selector: { pid } + } + }, { + user: opts.user, + onData: opts.onData, + requestTimeoutMs: opts.requestTimeoutMs, + timeoutMs: opts.timeoutMs, + timeout: opts.timeout + }, this); +}; + +Pty.prototype.sendInput = function (pid, data, opts) { + return connectRPC(this.sandbox, '/process.Process/SendInput', { + process: { + selector: { pid } + }, + input: { + pty: Buffer.isBuffer(data) ? data.toString('base64') : Buffer.from(String(data)).toString('base64') + } + }, opts).then(() => null); +}; + +Pty.prototype.resize = function (pid, size, opts) { + return connectRPC(this.sandbox, '/process.Process/Update', { + process: { + selector: { pid } + }, + pty: { + size + } + }, opts).then(() => null); +}; + +Pty.prototype.kill = function (pid, opts) { + return connectRPC(this.sandbox, '/process.Process/SendSignal', { + process: { + selector: { pid } + }, + signal: 'SIGNAL_SIGKILL' + }, opts).then(() => true, err => { + if ((err.response && err.response.statusCode === 404) || (err.resp && err.resp.statusCode === 404)) { + return false; + } + throw err; + }); +}; + +exports.Pty = Pty; diff --git a/qiniu/sandbox/sandbox.js b/qiniu/sandbox/sandbox.js new file mode 100644 index 00000000..4e4b08f6 --- /dev/null +++ b/qiniu/sandbox/sandbox.js @@ -0,0 +1,373 @@ +const { Commands } = require('./commands'); +const { DEFAULT_USER, ENVD_PORT } = require('./constants'); +const { Filesystem } = require('./filesystem'); +const { Git } = require('./git'); +const { Pty } = require('./pty'); +const { SandboxClient } = require('./client'); +const { agentFromClient, appendQuery, fileSignature, parseRequestUrl, poll, rawRequest } = require('./util'); + +function getSandboxID (data) { + return data && (data.sandboxID || data.sandboxId || data.sandbox_id || data.id); +} + +function getInfoValue (info, camelKey, snakeKey) { + return info && (info[camelKey] !== undefined ? info[camelKey] : info[snakeKey]); +} + +function normalizeItems (data) { + if (Array.isArray(data)) { + return { + items: data + }; + } + data = data || {}; + return { + items: data.items || data.sandboxes || data.snapshots || [], + nextToken: data.nextToken || data.next_token + }; +} + +function normalizeSnapshot (info) { + info = info || {}; + if (info.snapshotId === undefined && info.snapshotID !== undefined) { + info.snapshotId = info.snapshotID; + } + if (info.snapshotID === undefined && info.snapshotId !== undefined) { + info.snapshotID = info.snapshotId; + } + return info; +} + +function SandboxPaginator (opts) { + opts = opts || {}; + this.client = opts.client || new SandboxClient(opts); + this.opts = Object.assign({}, opts); + delete this.opts.client; + this._nextToken = opts.nextToken; + this._hasNext = true; +} + +Object.defineProperty(SandboxPaginator.prototype, 'nextToken', { + get: function () { + return this._nextToken; + } +}); + +Object.defineProperty(SandboxPaginator.prototype, 'hasNext', { + get: function () { + return !!this._nextToken || this._hasNext; + } +}); + +SandboxPaginator.prototype.nextItems = function (opts) { + const requestOpts = Object.assign({}, this.opts, opts || {}); + if (this._nextToken && requestOpts.nextToken === undefined) { + requestOpts.nextToken = this._nextToken; + } + return this.client.listSandboxesV2(requestOpts).then(data => { + const page = normalizeItems(data); + this._nextToken = page.nextToken; + this._hasNext = !!page.nextToken; + return page.items.map(info => new Sandbox({ client: this.client, info })); + }); +}; + +SandboxPaginator.prototype.then = function (resolve, reject) { + return this.nextItems().then(resolve, reject); +}; + +function SnapshotPaginator (client, opts) { + this.client = client; + this.opts = Object.assign({}, opts || {}); + this._nextToken = this.opts.nextToken; + this._hasNext = true; +} + +Object.defineProperty(SnapshotPaginator.prototype, 'nextToken', { + get: function () { + return this._nextToken; + } +}); + +Object.defineProperty(SnapshotPaginator.prototype, 'hasNext', { + get: function () { + return !!this._nextToken || this._hasNext; + } +}); + +SnapshotPaginator.prototype.nextItems = function (opts) { + const requestOpts = Object.assign({}, this.opts, opts || {}); + if (this._nextToken && requestOpts.nextToken === undefined) { + requestOpts.nextToken = this._nextToken; + } + return this.client.listSnapshots(requestOpts).then(data => { + const page = normalizeItems(data); + this._nextToken = page.nextToken; + this._hasNext = !!page.nextToken; + return page.items.map(normalizeSnapshot); + }); +}; + +SnapshotPaginator.prototype.then = function (resolve, reject) { + return this.nextItems().then(resolve, reject); +}; + +function Sandbox (opts) { + opts = opts || {}; + this.client = opts.client || new SandboxClient(opts); + this.info = opts.info || {}; + this.sandboxId = opts.sandboxId || opts.sandboxID || getSandboxID(this.info); + this.sandboxID = this.sandboxId; + this.sandboxDomain = this.info.domain || this.info.sandboxDomain || this.info.sandbox_domain; + this.domain = this.sandboxDomain; + this.envdVersion = getInfoValue(this.info, 'envdVersion', 'envd_version'); + this.envdAccessToken = opts.envdAccessToken || getInfoValue(this.info, 'envdAccessToken', 'envd_access_token'); + this.trafficAccessToken = getInfoValue(this.info, 'trafficAccessToken', 'traffic_access_token'); + this._envdUrl = opts.envdUrl; + this.files = new Filesystem(this); + this.filesystem = this.files; + this.commands = new Commands(this); + this.pty = new Pty(this); + this.git = new Git(this.commands); +} + +function sandboxClientOptions (opts) { + const clientOpts = {}; + [ + 'endpoint', + 'apiUrl', + 'apiKey', + 'accessToken', + 'mac', + 'accessKey', + 'secretKey', + 'macOptions', + 'httpAgent', + 'httpsAgent' + ].forEach(key => { + if (opts[key] !== undefined) { + clientOpts[key] = opts[key]; + } + }); + if (opts.requestTimeoutMs !== undefined) { + clientOpts.timeout = opts.requestTimeoutMs; + } + return clientOpts; +} + +Sandbox.create = function (templateOrOpts, maybeOpts) { + const opts = typeof templateOrOpts === 'string' + ? Object.assign({}, maybeOpts || {}, { templateID: templateOrOpts }) + : (templateOrOpts || {}); + const client = opts.client || new SandboxClient(sandboxClientOptions(opts)); + return client.createSandbox(opts).then(info => { + const sandbox = new Sandbox({ client, info }); + return sandbox.refreshEnvdTokenIfNeeded(); + }); +}; + +Sandbox.connect = function (sandboxID, opts) { + opts = opts || {}; + const client = opts.client || new SandboxClient(sandboxClientOptions(opts)); + return client.connectSandbox(sandboxID, opts).then(info => { + const sandbox = new Sandbox({ + client, + sandboxId: sandboxID, + info + }); + return sandbox.refreshEnvdTokenIfNeeded(); + }); +}; + +Sandbox.list = function (opts) { + return new SandboxPaginator(opts); +}; + +Sandbox.prototype.kill = function () { + return this.client.deleteSandbox(this.sandboxId); +}; + +Sandbox.prototype.setTimeout = function (timeoutOrOpts) { + return this.client.updateSandboxTimeout(this.sandboxId, timeoutOrOpts); +}; + +Sandbox.prototype.refresh = function (opts) { + return this.client.refreshSandbox(this.sandboxId, opts); +}; + +Sandbox.prototype.updateNetwork = function (network) { + return this.client.updateSandbox(this.sandboxId, { network }); +}; + +Sandbox.prototype.pause = function () { + return this.client.pauseSandbox(this.sandboxId); +}; + +Sandbox.prototype.betaPause = Sandbox.prototype.pause; + +Sandbox.prototype.connect = function (opts) { + return this.client.connectSandbox(this.sandboxId, opts).then(info => { + this.updateInfo(info); + return this; + }); +}; + +Sandbox.prototype.getInfo = function () { + return this.client.getSandbox(this.sandboxId); +}; + +Sandbox.prototype.refreshEnvdTokenIfNeeded = function () { + if (this.envdAccessToken) { + return Promise.resolve(this); + } + return this.getInfo().then(info => { + this.updateInfo(info); + return this; + }); +}; + +Sandbox.prototype.updateInfo = function (info) { + if (info) { + this.info = info; + this.envdAccessToken = getInfoValue(info, 'envdAccessToken', 'envd_access_token') || this.envdAccessToken; + this.envdVersion = getInfoValue(info, 'envdVersion', 'envd_version') || this.envdVersion; + this.domain = info.domain || info.sandboxDomain || info.sandbox_domain || this.domain; + this.sandboxDomain = this.domain; + } + return this; +}; + +Sandbox.prototype.getMetrics = function (opts) { + return this.client.getSandboxMetrics(this.sandboxId, opts); +}; + +Sandbox.prototype.getLogs = function (opts) { + return this.client.getSandboxLogs(this.sandboxId, opts); +}; + +Sandbox.prototype.createSnapshot = function (opts) { + return this.client.createSnapshot(this.sandboxId, opts).then(normalizeSnapshot); +}; + +Sandbox.prototype.listSnapshots = function (opts) { + return new SnapshotPaginator(this.client, Object.assign({}, opts || {}, { + sandboxId: this.sandboxId + })); +}; + +Sandbox.prototype.getMcpUrl = function () { + return `https://${this.getHost(50005)}/mcp`; +}; + +Sandbox.prototype.getMcpToken = function () { + if (this.mcpToken) { + return Promise.resolve(this.mcpToken); + } + if (this._mcpTokenPromise) { + return this._mcpTokenPromise; + } + this._mcpTokenPromise = this.files.read('/etc/mcp-gateway/.token', { + user: 'root' + }).then(token => { + const trimmedToken = typeof token === 'string' ? token.trim() : token; + this.mcpToken = trimmedToken; + this._mcpTokenPromise = null; + return trimmedToken; + }, err => { + this._mcpTokenPromise = null; + throw err; + }); + return this._mcpTokenPromise; +}; + +Sandbox.prototype.waitForReady = function (opts) { + return poll(() => this.getInfo(), opts, info => info && info.state === 'running') + .then(info => { + this.updateInfo(info); + return info; + }); +}; + +Sandbox.prototype.isRunning = function () { + const requestUrl = this.envdUrl() + '/health'; + const target = parseRequestUrl(requestUrl); + return rawRequest(requestUrl, { + method: 'GET', + dataType: 'text', + agent: agentFromClient(this.client, target.protocol) + }).then(() => true, err => { + const resp = err.response || err.resp; + if (resp && [502, 503, 504].indexOf(resp.statusCode) >= 0) { + return false; + } + if (['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND'].indexOf(err.code) >= 0) { + return false; + } + throw err; + }); +}; + +Sandbox.prototype.getHost = function (port) { + if (!this.domain) { + return ''; + } + return `${port}-${this.sandboxId}.${this.domain}`; +}; + +Sandbox.prototype.envdUrl = function () { + return this._envdUrl || `https://${this.getHost(ENVD_PORT)}`; +}; + +Sandbox.prototype.fileUrl = function (path, operation, opts) { + opts = opts || {}; + const user = opts.user || DEFAULT_USER; + const query = { + path, + username: user + }; + if (this.envdAccessToken) { + let expiration = opts.signatureExpiration || opts.signature_expiration || 300; + if (expiration < 1000000000) { + expiration = Math.floor(Date.now() / 1000) + expiration; + } + query.signature = fileSignature(path, operation, user, this.envdAccessToken, expiration); + query.signature_expiration = expiration; + } + return this.envdUrl() + appendQuery('/files', query); +}; + +Sandbox.prototype.downloadUrl = function (path, opts) { + return this.fileUrl(path, 'read', opts); +}; + +Sandbox.prototype.DownloadURL = Sandbox.prototype.downloadUrl; + +Sandbox.prototype.uploadUrl = function (path, opts) { + return this.fileUrl(path, 'write', opts); +}; + +Sandbox.prototype.UploadURL = Sandbox.prototype.uploadUrl; + +Sandbox.prototype.batchUploadUrl = function (opts) { + if (typeof opts === 'string') { + opts = { user: opts }; + } + opts = opts || {}; + const user = opts.user || DEFAULT_USER; + const query = { + username: user + }; + if (this.envdAccessToken) { + let expiration = opts.signatureExpiration || opts.signature_expiration || 300; + if (expiration < 1000000000) { + expiration = Math.floor(Date.now() / 1000) + expiration; + } + query.signature = fileSignature('', 'write', user, this.envdAccessToken, expiration); + query.signature_expiration = expiration; + } + return this.envdUrl() + appendQuery('/files', query); +}; + +exports.Sandbox = Sandbox; +exports.SandboxPaginator = SandboxPaginator; +exports.SnapshotPaginator = SnapshotPaginator; diff --git a/qiniu/sandbox/template.js b/qiniu/sandbox/template.js new file mode 100644 index 00000000..84b19b02 --- /dev/null +++ b/qiniu/sandbox/template.js @@ -0,0 +1,592 @@ +const { SandboxClient } = require('./client'); +const { shellQuote } = require('./util'); +const fs = require('fs'); + +function Template () { + if (!(this instanceof Template)) { + return new Template(); + } + this.buildConfig = { + steps: [] + }; + this._forceNextLayer = false; +} + +Template.prototype.fromImage = function (image, credentials) { + this.buildConfig.fromImage = image; + delete this.buildConfig.fromTemplate; + if (credentials) { + this.buildConfig.fromImageRegistry = { + type: 'registry', + username: credentials.username, + password: credentials.password + }; + } else { + delete this.buildConfig.fromImageRegistry; + } + if (this._forceNextLayer) { + this.buildConfig.force = true; + } + return this; +}; + +Template.prototype.fromAWSRegistry = function (image, credentials) { + this.buildConfig.fromImage = image; + delete this.buildConfig.fromTemplate; + this.buildConfig.fromImageRegistry = { + type: 'aws', + awsAccessKeyId: credentials.accessKeyId, + awsSecretAccessKey: credentials.secretAccessKey, + awsRegion: credentials.region + }; + if (this._forceNextLayer) { + this.buildConfig.force = true; + } + return this; +}; + +Template.prototype.fromGCPRegistry = function (image, credentials) { + this.buildConfig.fromImage = image; + delete this.buildConfig.fromTemplate; + const serviceAccountJSON = credentials.serviceAccountJSON; + this.buildConfig.fromImageRegistry = { + type: 'gcp', + serviceAccountJson: typeof serviceAccountJSON === 'string' + ? serviceAccountJSON + : JSON.stringify(serviceAccountJSON) + }; + if (this._forceNextLayer) { + this.buildConfig.force = true; + } + return this; +}; + +Template.prototype.fromTemplate = function (templateID) { + this.buildConfig.fromTemplate = templateID; + delete this.buildConfig.fromImage; + delete this.buildConfig.fromImageRegistry; + if (this._forceNextLayer) { + this.buildConfig.force = true; + } + return this; +}; + +function padOctal (value) { + let text = typeof value === 'number' ? value.toString(8) : String(value || ''); + if (text.startsWith('0o') || text.startsWith('0O')) { + text = text.slice(2); + } + while (text.length < 4) { + text = `0${text}`; + } + return text; +} + +function asArray (value) { + return Array.isArray(value) ? value : [value]; +} + +function addStep (template, type, args, extra) { + const step = Object.assign({ + type, + args: args.map(value => String(value)) + }, extra || {}); + if (template._forceNextLayer && step.force === undefined) { + step.force = true; + } + template.buildConfig.steps.push(step); + return template; +} + +function runShellStep (template, command, options) { + const args = [Array.isArray(command) ? command.join(' && ') : command]; + if (options && options.user) { + args.push(options.user); + } + return addStep(template, 'RUN', args); +} + +function parseEnvArgs (value) { + const args = []; + const index = value.search(/\s+/); + if (index > 0) { + const firstWord = value.slice(0, index); + if (firstWord.indexOf('=') < 0) { + args.push(firstWord.trim(), unquoteDockerfileValue(value.slice(index + 1).trim())); + return args; + } + } else if (value.indexOf('=') < 0) { + return args; + } + const pattern = /([A-Za-z_][A-Za-z0-9_]*)=("(\\.|[^"\\])*"|'(\\.|[^'\\])*'|\S+)/g; + let match; + while ((match = pattern.exec(value))) { + args.push(match[1], unquoteDockerfileValue(match[2])); + } + return args; +} + +function unquoteDockerfileValue (value) { + if ( + (value[0] === '"' && value[value.length - 1] === '"') || + (value[0] === '\'' && value[value.length - 1] === '\'') + ) { + return value.slice(1, -1) + .replace(/\\"/g, '"') + .replace(/\\'/g, '\'') + .replace(/\\\\/g, '\\'); + } + return value; +} + +function joinDockerfileLines (content) { + const lines = []; + let current = ''; + String(content || '').split(/\r?\n/).forEach(rawLine => { + const line = rawLine.trim(); + if (!line || line[0] === '#') { + if (current) { + lines.push(current); + current = ''; + } + lines.push(line); + return; + } + const trailingBackslashes = (line.match(/\\+$/) || [''])[0].length; + if (trailingBackslashes % 2 === 1) { + current += line.slice(0, -1) + ' '; + return; + } + lines.push(current + line); + current = ''; + }); + if (current) { + lines.push(current); + } + return lines; +} + +function splitDockerfileArgs (value) { + value = String(value || '').trim(); + if (value.charAt(0) === '[' && value.charAt(value.length - 1) === ']') { + try { + const parsed = JSON.parse(value); + if (Array.isArray(parsed)) { + return parsed.map(item => String(item)); + } + } catch (err) { + // Fall through to the shell-style parser below. + } + } + const args = []; + let current = ''; + let quote = ''; + let escape = false; + for (let i = 0; i < value.length; i += 1) { + const ch = value[i]; + if (escape) { + current += ch; + escape = false; + continue; + } + if (ch === '\\') { + escape = true; + continue; + } + if (quote) { + if (ch === quote) { + quote = ''; + } else { + current += ch; + } + continue; + } + if (ch === '"' || ch === '\'') { + quote = ch; + continue; + } + if (/\s/.test(ch)) { + if (current) { + args.push(current); + current = ''; + } + continue; + } + current += ch; + } + if (escape) { + current += '\\'; + } + if (current) { + args.push(current); + } + return args; +} + +function dockerfileCopyArgs (value) { + const args = splitDockerfileArgs(value); + const flagsWithValue = { + '--checksum': true, + '--chmod': true, + '--chown': true, + '--exclude': true + }; + while (args.length && /^--/.test(args[0])) { + const flag = args.shift(); + if (/^--from(?:=|$)/i.test(flag)) { + return []; + } + const flagName = flag.split('=')[0].toLowerCase(); + if (flag.indexOf('=') < 0 && flagsWithValue[flagName] && args.length > 2) { + args.shift(); + } + } + return args; +} + +function dockerfileFromImage (value) { + const parts = splitDockerfileArgs(value); + for (let i = 0; i < parts.length; i += 1) { + const part = parts[i]; + if (/^--/.test(part)) { + continue; + } + if (/^AS$/i.test(part)) { + break; + } + return part; + } + return null; +} + +Template.prototype.fromDockerfile = function (dockerfileContentOrPath) { + if (typeof dockerfileContentOrPath !== 'string') { + throw new TypeError('Dockerfile content or path must be a string'); + } + const hasNewlines = dockerfileContentOrPath.indexOf('\n') >= 0 || dockerfileContentOrPath.indexOf('\r') >= 0; + const startsWithInstruction = /^\s*(ADD|ARG|CMD|COPY|ENTRYPOINT|ENV|EXPOSE|FROM|HEALTHCHECK|LABEL|ONBUILD|RUN|SHELL|STOPSIGNAL|USER|VOLUME|WORKDIR)\b/i.test(dockerfileContentOrPath); + const startsWithComment = /^\s*#/.test(dockerfileContentOrPath); + const existingPath = dockerfileContentOrPath.length < 1024 && !hasNewlines && fs.existsSync(dockerfileContentOrPath); + const looksLikePath = !startsWithComment && (/[\\/]/.test(dockerfileContentOrPath) || /(^|[\\/])Dockerfile(?:\.[^\\/]+)?$/i.test(dockerfileContentOrPath)); + const isLikelyPath = existingPath || (dockerfileContentOrPath.length < 1024 && !hasNewlines && !startsWithInstruction && looksLikePath); + if (isLikelyPath && !fs.existsSync(dockerfileContentOrPath)) { + throw new Error(`Dockerfile file not found at path: ${dockerfileContentOrPath}`); + } + const isPath = isLikelyPath && fs.existsSync(dockerfileContentOrPath); + let content = dockerfileContentOrPath; + if (isPath) { + try { + content = fs.readFileSync(dockerfileContentOrPath, 'utf8'); + } catch (err) { + throw new Error(`Failed to read Dockerfile at ${dockerfileContentOrPath}: ${err.message}`); + } + } + joinDockerfileLines(content).forEach(line => { + line = line.trim(); + if (!line || line[0] === '#') { + return; + } + const match = line.match(/^([A-Z]+)\s+(.+)$/i); + if (!match) { + return; + } + const instruction = match[1].toUpperCase(); + const rest = match[2].trim(); + if (instruction === 'FROM') { + const image = dockerfileFromImage(rest); + if (image) { + this.fromImage(image); + } + } else if (instruction === 'RUN') { + this.runCmd(rest); + } else if (instruction === 'WORKDIR') { + this.setWorkdir(rest); + } else if (instruction === 'USER') { + this.setUser(rest); + } else if (instruction === 'ENV') { + const args = parseEnvArgs(rest); + if (args.length) { + addStep(this, 'ENV', args); + } + } else if (instruction === 'COPY' || instruction === 'ADD') { + const parts = dockerfileCopyArgs(rest); + if (parts.length >= 2) { + this.copy(parts.slice(0, -1), parts[parts.length - 1]); + } + } + }); + return this; +}; + +Template.prototype.aptInstall = function (packages, options) { + if (options) { + const packageList = asArray(packages).map(shellQuote); + return this.runCmd([ + 'apt-get update', + `DEBIAN_FRONTEND=noninteractive DEBCONF_NOWARNINGS=yes apt-get install -y ${options.noInstallRecommends ? '--no-install-recommends ' : ''}${options.fixMissing ? '--fix-missing ' : ''}${packageList.join(' ')}` + ], { user: 'root' }); + } + this.buildConfig.steps.push({ + type: 'apt', + packages: asArray(packages) + }); + return this; +}; + +Template.prototype.runCmd = function (cmd, options) { + if (Array.isArray(cmd) || options || this._forceNextLayer) { + return runShellStep(this, cmd, options); + } + this.buildConfig.steps.push({ + type: 'run', + cmd + }); + return this; +}; + +Template.prototype.copy = function (src, dest, options) { + if (Array.isArray(src) || options) { + asArray(src).forEach(item => { + const args = [item, dest]; + if (options && options.user) { + args.push(options.user); + } else if (options && options.mode) { + args.push(''); + } + if (options && options.mode) { + args.push(padOctal(options.mode)); + } + const extra = {}; + if (options && options.forceUpload) { + extra.forceUpload = options.forceUpload; + } + if (options && options.resolveSymlinks !== undefined) { + extra.resolveSymlinks = options.resolveSymlinks; + } + addStep(this, 'COPY', args, extra); + }); + return this; + } + this.buildConfig.steps.push({ + type: 'copy', + src, + dest + }); + return this; +}; + +Template.prototype.copyItems = function (items) { + items.forEach(item => { + this.copy(item.src, item.dest, { + forceUpload: item.forceUpload, + user: item.user, + mode: item.mode, + resolveSymlinks: item.resolveSymlinks + }); + }); + return this; +}; + +Template.prototype.remove = function (path, options) { + options = options || {}; + const args = ['rm']; + if (options.recursive) { + args.push('-r'); + } + if (options.force) { + args.push('-f'); + } + args.push.apply(args, asArray(path).map(shellQuote)); + return this.runCmd(args.join(' '), { user: options.user }); +}; + +Template.prototype.rename = function (src, dest, options) { + options = options || {}; + const args = ['mv']; + if (options.force) { + args.push('-f'); + } + args.push(shellQuote(src), shellQuote(dest)); + return this.runCmd(args.join(' '), { user: options.user }); +}; + +Template.prototype.makeDir = function (path, options) { + options = options || {}; + const args = ['mkdir', '-p']; + if (options.mode) { + args.push(`-m ${padOctal(options.mode)}`); + } + args.push.apply(args, asArray(path).map(shellQuote)); + return this.runCmd(args.join(' '), { user: options.user }); +}; + +Template.prototype.makeSymlink = function (src, dest, options) { + options = options || {}; + const args = ['ln', '-s']; + if (options.force) { + args.push('-f'); + } + args.push(shellQuote(src), shellQuote(dest)); + return this.runCmd(args.join(' '), { user: options.user }); +}; + +Template.prototype.setWorkdir = function (workdir) { + return addStep(this, 'WORKDIR', [workdir]); +}; + +Template.prototype.setUser = function (user) { + return addStep(this, 'USER', [user]); +}; + +Template.prototype.pipInstall = function (packages, options) { + if (packages && typeof packages === 'object' && !Array.isArray(packages) && !Buffer.isBuffer(packages) && options === undefined) { + options = packages; + packages = undefined; + } + options = options || {}; + const args = ['pip', 'install']; + if (options.g === false) { + args.push('--user'); + } + const hasPackages = packages && (!Array.isArray(packages) || packages.length > 0); + if (hasPackages) { + args.push.apply(args, asArray(packages).map(shellQuote)); + } else { + args.push('.'); + } + return this.runCmd(args.join(' '), { user: options.g === false ? undefined : 'root' }); +}; + +Template.prototype.npmInstall = function (packages, options) { + if (packages && typeof packages === 'object' && !Array.isArray(packages) && !Buffer.isBuffer(packages) && options === undefined) { + options = packages; + packages = undefined; + } + options = options || {}; + const args = ['npm', 'install']; + if (options.g) { + args.push('-g'); + } + if (options.dev) { + args.push('--save-dev'); + } + if (packages) { + args.push.apply(args, asArray(packages).map(shellQuote)); + } + return this.runCmd(args.join(' '), { user: options.g ? 'root' : undefined }); +}; + +Template.prototype.bunInstall = function (packages, options) { + if (packages && typeof packages === 'object' && !Array.isArray(packages) && !Buffer.isBuffer(packages) && options === undefined) { + options = packages; + packages = undefined; + } + options = options || {}; + const hasPackages = packages && (!Array.isArray(packages) || packages.length > 0); + if (hasPackages) { + const args = ['bun', 'add']; + if (options.g) { + args.push('-g'); + } + if (options.dev) { + args.push('--dev'); + } + args.push.apply(args, asArray(packages).map(shellQuote)); + return this.runCmd(args.join(' '), { user: options.g ? 'root' : undefined }); + } + return this.runCmd('bun install', { user: options.g ? 'root' : undefined }); +}; + +Template.prototype.gitClone = function (url, path, options) { + if (path && typeof path === 'object' && !Array.isArray(path) && !Buffer.isBuffer(path) && options === undefined) { + options = path; + path = undefined; + } + options = options || {}; + const args = ['git', 'clone', shellQuote(url)]; + if (options.branch) { + args.push('--branch', shellQuote(options.branch), '--single-branch'); + } + if (options.depth) { + args.push('--depth', shellQuote(options.depth)); + } + if (path) { + args.push(shellQuote(path)); + } + return this.runCmd(args.join(' '), { user: options.user }); +}; + +Template.prototype.setEnvs = function (envs) { + const keys = Object.keys(envs || {}); + if (!keys.length) { + return this; + } + const args = []; + keys.forEach(key => { + args.push(key, envs[key]); + }); + return addStep(this, 'ENV', args); +}; + +Template.prototype.skipCache = function () { + this._forceNextLayer = true; + return this; +}; + +Template.prototype.setStartCmd = function (cmd) { + this.buildConfig.startCmd = cmd; + return this; +}; + +Template.prototype.setReadyCmd = function (cmd) { + this.buildConfig.readyCmd = cmd; + return this; +}; + +function templateClientOptions (opts) { + const clientOpts = {}; + [ + 'endpoint', + 'apiUrl', + 'apiKey', + 'accessToken', + 'mac', + 'accessKey', + 'secretKey', + 'macOptions', + 'httpAgent', + 'httpsAgent' + ].forEach(key => { + if (opts[key] !== undefined) { + clientOpts[key] = opts[key]; + } + }); + if (opts.requestTimeoutMs !== undefined) { + clientOpts.timeout = opts.requestTimeoutMs; + } + return clientOpts; +} + +Template.prototype.build = function (opts) { + opts = opts || {}; + const client = opts.client || new SandboxClient(templateClientOptions(opts)); + const body = Object.assign({}, opts, { + buildConfig: this.buildConfig + }); + delete body.client; + delete body.endpoint; + delete body.apiUrl; + delete body.apiKey; + delete body.accessToken; + delete body.mac; + delete body.accessKey; + delete body.secretKey; + delete body.macOptions; + delete body.httpAgent; + delete body.httpsAgent; + delete body.timeout; + delete body.requestTimeoutMs; + return client.createTemplateV3(body); +}; + +exports.Template = Template; diff --git a/qiniu/sandbox/util.js b/qiniu/sandbox/util.js new file mode 100644 index 00000000..4f81eb09 --- /dev/null +++ b/qiniu/sandbox/util.js @@ -0,0 +1,208 @@ +const crypto = require('crypto'); +const urllib = require('urllib'); + +const { DEFAULT_ENDPOINT, DEFAULT_USER } = require('./constants'); +const { SandboxError, TimeoutError } = require('./errors'); + +function normalizeEndpoint (endpoint) { + endpoint = endpoint || process.env.QINIU_SANDBOX_ENDPOINT || DEFAULT_ENDPOINT; + return endpoint.replace(/\/+$/, ''); +} + +function encodePath (value) { + return encodeURIComponent(value); +} + +function appendQuery (path, query) { + const pairs = []; + query = query || {}; + + Object.keys(query).forEach(key => { + const value = query[key]; + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(value.join(','))}`); + return; + } + pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`); + }); + + return pairs.length ? `${path}?${pairs.join('&')}` : path; +} + +function timeoutSecondsFromOptions (opts) { + opts = opts || {}; + if (opts.timeout !== undefined) { + return opts.timeout; + } + if (opts.timeoutMs !== undefined) { + return Math.ceil(opts.timeoutMs / 1000); + } + return undefined; +} + +function millisecondsFromOptions (opts, key, defaultValue) { + opts = opts || {}; + const msKey = `${key}Ms`; + if (opts[msKey] !== undefined) { + return opts[msKey]; + } + if (opts[key] !== undefined) { + return opts[key] * 1000; + } + return defaultValue; +} + +function copyDefined (target, source, key, outKey) { + if (source[key] !== undefined) { + target[outKey || key] = source[key]; + } +} + +function poll (fn, opts, done) { + opts = opts || {}; + const interval = millisecondsFromOptions(opts, 'interval', 1000); + const timeout = millisecondsFromOptions(opts, 'timeout', 60000); + const startedAt = Date.now(); + + function tick () { + return fn().then(value => { + if (done(value)) { + return value; + } + if (Date.now() - startedAt >= timeout) { + throw new TimeoutError('Sandbox poll timed out'); + } + return new Promise(resolve => setTimeout(resolve, interval)).then(tick); + }, err => { + const statusCode = (err.response && err.response.statusCode) || (err.resp && err.resp.statusCode); + const fatalClientError = statusCode >= 400 && statusCode < 500 && statusCode !== 408 && statusCode !== 429; + const programmingError = err instanceof TypeError || + err instanceof ReferenceError || + err instanceof SyntaxError; + if (fatalClientError || programmingError || Date.now() - startedAt >= timeout) { + throw err; + } + return new Promise(resolve => setTimeout(resolve, interval)).then(tick); + }); + } + + return tick(); +} + +function basicAuth (user) { + user = user || DEFAULT_USER; + return `Basic ${Buffer.from(`${user}:`).toString('base64')}`; +} + +function fileSignature (path, operation, user, accessToken, expiration) { + const raw = `${path}:${operation}:${user}:${accessToken}:${expiration}`; + return `v1_${crypto.createHash('sha256').update(raw).digest('hex')}`; +} + +function rawRequest (requestUrl, options) { + options = options || {}; + return new Promise((resolve, reject) => { + urllib.request(requestUrl, options, (err, data, resp) => { + if (err) { + err.resp = resp; + reject(err); + return; + } + if (resp && Math.floor(resp.statusCode / 100) !== 2) { + reject(new SandboxError(`Sandbox envd request failed with status ${resp.statusCode}`, resp, data)); + return; + } + resolve({ + data, + resp + }); + }); + }); +} + +function agentFromClient (client, protocol) { + if (!client) { + return undefined; + } + const source = client.httpClient || client; + return protocol === 'https:' ? source.httpsAgent : source.httpAgent; +} + +function parseRequestUrl (requestUrl) { + const URLParser = (typeof URL !== 'undefined' && URL) || null; + if (URLParser) { + try { + const parsed = new URLParser(requestUrl); + return { + protocol: parsed.protocol, + hostname: parsed.hostname.replace(/^\[|\]$/g, ''), + port: parsed.port, + path: parsed.pathname + parsed.search + }; + } catch (err) { + throw new SandboxError(`Invalid request URL: ${requestUrl}`); + } + } + + const match = String(requestUrl).match(/^(https?:)\/\/([^/?#]+)([^?#]*)(\?[^#]*)?/); + if (!match) { + throw new SandboxError(`Invalid request URL: ${requestUrl}`); + } + + const host = match[2]; + let hostname = host; + let port = ''; + if (host.charAt(0) === '[') { + const end = host.indexOf(']'); + hostname = host.slice(1, end); + if (host.charAt(end + 1) === ':') { + port = host.slice(end + 2); + } + } else { + const colon = host.lastIndexOf(':'); + if (colon > 0 && host.indexOf(':') === colon) { + hostname = host.slice(0, colon); + port = host.slice(colon + 1); + } + } + + return { + protocol: match[1], + hostname, + port, + path: (match[3] || '/') + (match[4] || '') + }; +} + +function parseJSON (data) { + if (Buffer.isBuffer(data)) { + data = data.toString(); + } + if (typeof data === 'string') { + return data ? JSON.parse(data) : {}; + } + return data || {}; +} + +function shellQuote (value) { + const quote = String.fromCharCode(39); + return quote + String(value).replace(/'/g, quote + '\\' + quote + quote) + quote; +} + +exports.normalizeEndpoint = normalizeEndpoint; +exports.encodePath = encodePath; +exports.appendQuery = appendQuery; +exports.timeoutSecondsFromOptions = timeoutSecondsFromOptions; +exports.millisecondsFromOptions = millisecondsFromOptions; +exports.copyDefined = copyDefined; +exports.poll = poll; +exports.basicAuth = basicAuth; +exports.fileSignature = fileSignature; +exports.rawRequest = rawRequest; +exports.agentFromClient = agentFromClient; +exports.parseRequestUrl = parseRequestUrl; +exports.parseJSON = parseJSON; +exports.shellQuote = shellQuote; diff --git a/qiniu/sandbox/volume.js b/qiniu/sandbox/volume.js new file mode 100644 index 00000000..fd7d4c1d --- /dev/null +++ b/qiniu/sandbox/volume.js @@ -0,0 +1,17 @@ +const { NotImplementedError } = require('./errors'); + +function Volume () {} + +Volume.prototype.create = function () { + return Promise.reject(new NotImplementedError('Volume is not supported by Qiniu Sandbox OpenAPI')); +}; + +Volume.prototype.delete = function () { + return Promise.reject(new NotImplementedError('Volume is not supported by Qiniu Sandbox OpenAPI')); +}; + +Volume.prototype.list = function () { + return Promise.reject(new NotImplementedError('Volume is not supported by Qiniu Sandbox OpenAPI')); +}; + +exports.Volume = Volume; diff --git a/test/sandbox_client.test.js b/test/sandbox_client.test.js new file mode 100644 index 00000000..69d968a8 --- /dev/null +++ b/test/sandbox_client.test.js @@ -0,0 +1,900 @@ +const { + should, + qiniu, + startServer, + closeServer, + parseUrl +} = require('./sandbox_helpers'); + +describe('test sandbox client module', function () { + it('creates sandbox with E2B compatible options and API key auth', function () { + return startServer((req, res) => { + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + sandboxID: 'sbx_1', + domain: 'sbx.local', + envdVersion: '0.0.1', + envdAccessToken: 'token' + })); + }).then(fixture => { + const client = new qiniu.sandbox.SandboxClient({ + apiKey: 'sandbox-key', + endpoint: fixture.endpoint + }); + + return client.createSandbox({ + template: 'base', + timeoutMs: 15000, + metadata: { + user: 'alice' + }, + envs: { + FOO: 'bar' + }, + injections: [ + { + type: 'qiniu', + apiKey: 'ak', + baseUrl: 'https://example.com', + ruleId: 'rule_1' + } + ] + }).then(ret => { + should.equal(ret.sandboxID, 'sbx_1'); + fixture.requests.length.should.eql(1); + fixture.requests[0].method.should.eql('POST'); + fixture.requests[0].url.should.eql('/sandboxes'); + fixture.requests[0].headers['x-api-key'].should.eql('sandbox-key'); + fixture.requests[0].headers.authorization.should.eql('Bearer sandbox-key'); + fixture.requests[0].headers['content-type'].should.eql('application/json'); + JSON.parse(fixture.requests[0].body).should.eql({ + templateID: 'base', + timeout: 15, + metadata: { + user: 'alice' + }, + envVars: { + FOO: 'bar' + }, + injections: [ + { + type: 'qiniu', + api_key: 'ak', + base_url: 'https://example.com', + ruleID: 'rule_1' + } + ] + }); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('passes client timeout to urllib requests and validates metrics ids', function () { + const client = new qiniu.sandbox.SandboxClient({ + endpoint: 'http://sandbox.test', + apiKey: 'sandbox-key', + timeout: 1234 + }); + const urls = []; + client.httpClient.sendRequest = req => { + urls.push(req.url); + req.urllibOptions.timeout.should.eql(1234); + should.not.exist(req.urllibOptions.headers['Content-Length']); + should.not.exist(req.urllibOptions.contentType); + return Promise.resolve({ + ok: () => true, + data: { ok: true } + }); + }; + + return client.listSandboxes() + .then(() => client.getSandboxesMetrics().then(() => { + throw new Error('expected empty metrics ids to fail'); + }, err => { + err.name.should.eql('SandboxError'); + err.message.should.match(/At least one sandbox ID/); + })) + .then(() => client.getSandboxesMetrics('sbx_one')) + .then(() => client.getSandboxesMetrics({ sandbox_ids: 'sbx_field' })) + .then(() => client.getSandboxesMetrics({ sandboxId: 'sbx_object' })) + .then(() => client.getSandboxesMetrics([{ sandboxID: 'sbx_array_object' }, 'sbx_array_string'])) + .then(() => { + urls.should.eql([ + 'http://sandbox.test/sandboxes', + 'http://sandbox.test/sandboxes/metrics?sandbox_ids=sbx_one', + 'http://sandbox.test/sandboxes/metrics?sandbox_ids=sbx_field', + 'http://sandbox.test/sandboxes/metrics?sandbox_ids=sbx_object', + 'http://sandbox.test/sandboxes/metrics?sandbox_ids=sbx_array_object%2Csbx_array_string' + ]); + }); + }); + + it('throws a clear error when Qiniu-auth APIs are called without AK/SK credentials', function () { + const client = new qiniu.sandbox.SandboxClient({ + endpoint: 'http://sandbox.test', + apiKey: 'sandbox-key' + }); + + (() => new qiniu.sandbox.SandboxClient({ + endpoint: 'http://sandbox.test', + apiKey: 'sandbox-key', + accessKey: 'ak' + })).should.throw(/Both accessKey and secretKey/); + const mac = new qiniu.auth.digest.Mac('ak', 'sk'); + const clientWithMac = new qiniu.sandbox.SandboxClient({ + endpoint: 'http://sandbox.test', + apiKey: 'sandbox-key', + accessKey: 'ak', + mac + }); + clientWithMac.mac.should.equal(mac); + + return client.listInjectionRules().then(() => { + throw new Error('expected missing Qiniu credentials rejection'); + }, err => { + err.name.should.eql('SandboxError'); + err.message.should.eql('Qiniu Mac credentials (accessKey/secretKey) are required for this operation'); + }); + }); + + it('requires Qiniu AK/SK before creating sandboxes with Kodo resources', function () { + const client = new qiniu.sandbox.SandboxClient({ + endpoint: 'http://sandbox.test', + apiKey: 'sandbox-key' + }); + + return client.createSandbox({ + template: 'base', + resources: [{ + type: 'kodo', + bucket: 'bucket', + mountPath: '/workspace/kodo' + }] + }).then(() => { + throw new Error('expected missing Qiniu credentials error'); + }, err => { + err.name.should.eql('SandboxError'); + err.message.should.eql('Qiniu Mac credentials (accessKey/secretKey) are required for this operation'); + }); + }); + + it('keeps Qiniu sandbox extensions in create body', function () { + return startServer((req, res) => { + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + sandboxID: 'sbx_qiniu', + domain: 'sbx.local', + envdAccessToken: 'token' + })); + }).then(fixture => { + return qiniu.sandbox.Sandbox.create({ + apiKey: 'sandbox-key', + endpoint: fixture.endpoint, + mcp: { enabled: true }, + injections: [{ injectionRuleID: 'rule_1' }], + resources: [{ type: 'github_repository', url: 'https://github.com/acme/repo' }] + }).then(() => { + JSON.parse(fixture.requests[0].body).should.eql({ + templateID: 'base', + mcp: { enabled: true }, + injections: [{ injectionRuleID: 'rule_1' }], + resources: [{ type: 'github_repository', url: 'https://github.com/acme/repo' }] + }); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('uses Qiniu AK/SK signing when creating sandbox with Kodo resources', function () { + return startServer((req, res) => { + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + sandboxID: 'sbx_kodo', + domain: 'sbx.local', + envdAccessToken: 'token' + })); + }).then(fixture => { + const mac = new qiniu.auth.digest.Mac('ak', 'sk', { + disableQiniuTimestampSignature: true + }); + const client = new qiniu.sandbox.SandboxClient({ + endpoint: fixture.endpoint, + apiKey: 'sandbox-key', + mac + }); + + return client.createSandbox({ + template: 'base', + resources: [ + { + type: 'kodo', + bucket: 'bucket', + mount_path: '/workspace/kodo', + read_only: true + } + ] + }).then(() => { + should(fixture.requests[0].headers.authorization).startWith('Qiniu ak:'); + should.not.exist(fixture.requests[0].headers['x-api-key']); + JSON.parse(fixture.requests[0].body).resources[0].type.should.eql('kodo'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('exposes E2B style Sandbox.create and kill helpers', function () { + return startServer((req, res) => { + if (req.method === 'POST' && req.url === '/sandboxes') { + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + sandboxID: 'sbx_2', + domain: 'sbx.local', + envdAccessToken: 'token' + })); + return; + } + + if (req.method === 'DELETE' && req.url === '/sandboxes/sbx_2') { + res.statusCode = 204; + res.end(); + return; + } + + res.statusCode = 404; + res.end(); + }).then(fixture => { + return qiniu.sandbox.Sandbox.create({ + apiKey: 'sandbox-key', + endpoint: fixture.endpoint, + template: 'base' + }).then(sandbox => { + sandbox.sandboxId.should.eql('sbx_2'); + return sandbox.kill(); + }).then(() => { + fixture.requests.map(req => `${req.method} ${req.url}`).should.eql([ + 'POST /sandboxes', + 'DELETE /sandboxes/sbx_2' + ]); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('exports E2B style top-level Sandbox and client classes', function () { + qiniu.Sandbox.should.equal(qiniu.sandbox.Sandbox); + qiniu.SandboxClient.should.equal(qiniu.sandbox.SandboxClient); + qiniu.CommandExitError.should.equal(qiniu.sandbox.CommandExitError); + }); + + it('supports Sandbox.create(template, opts) overload', function () { + return startServer((req, res) => { + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + sandboxID: 'sbx_template', + domain: 'sbx.local', + envdAccessToken: 'token' + })); + }).then(fixture => { + return qiniu.sandbox.Sandbox.create('nodejs', { + apiKey: 'sandbox-key', + endpoint: fixture.endpoint, + metadata: { + source: 'e2b-overload' + } + }).then(sandbox => { + sandbox.sandboxId.should.eql('sbx_template'); + JSON.parse(fixture.requests[0].body).should.eql({ + templateID: 'nodejs', + metadata: { + source: 'e2b-overload' + } + }); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('keeps sandbox lifetime timeout separate from HTTP request timeout in static helpers', function () { + return startServer((req, res) => { + setTimeout(() => { + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + sandboxID: 'sbx_timeout', + domain: 'sbx.local', + envdAccessToken: 'token' + })); + }, 40); + }).then(fixture => { + return qiniu.sandbox.Sandbox.create({ + apiKey: 'sandbox-key', + endpoint: fixture.endpoint, + template: 'base', + timeout: 30 + }).then(sandbox => { + sandbox.sandboxId.should.eql('sbx_timeout'); + JSON.parse(fixture.requests[0].body).timeout.should.eql(30); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('exposes typed sandbox compatibility errors', function () { + const err = new qiniu.sandbox.CommandExitError({ + command: 'false', + exitCode: 1, + stdout: 'out', + stderr: 'err' + }); + + err.should.be.instanceOf(Error); + err.name.should.eql('CommandExitError'); + err.exitCode.should.eql(1); + err.stdout.should.eql('out'); + err.stderr.should.eql('err'); + new qiniu.sandbox.GitAuthError('bad credentials').name.should.eql('GitAuthError'); + new qiniu.sandbox.GitUpstreamError('missing upstream').name.should.eql('GitUpstreamError'); + new qiniu.sandbox.NotImplementedError('volume').name.should.eql('NotImplementedError'); + }); + + it('uses Qiniu AK/SK signing for injection rule APIs', function () { + return startServer((req, res) => { + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + id: 'rule_1', + name: 'openai', + injection: { + type: 'openai', + apiKey: 'secret' + } + })); + }).then(fixture => { + const mac = new qiniu.auth.digest.Mac('ak', 'sk', { + disableQiniuTimestampSignature: true + }); + const client = new qiniu.sandbox.SandboxClient({ + mac, + endpoint: fixture.endpoint + }); + + return client.createInjectionRule({ + name: 'openai', + injection: { + type: 'openai', + apiKey: 'secret' + } + }).then(() => { + fixture.requests[0].method.should.eql('POST'); + fixture.requests[0].url.should.eql('/injection-rules'); + should(fixture.requests[0].headers.authorization).startWith('Qiniu ak:'); + should.not.exist(fixture.requests[0].headers['x-api-key']); + JSON.parse(fixture.requests[0].body).should.eql({ + name: 'openai', + injection: { + type: 'openai', + api_key: 'secret' + } + }); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('maps sandbox lifecycle and metrics APIs', function () { + return startServer((req, res) => { + res.setHeader('Content-Type', 'application/json'); + if (req.method === 'GET' && req.url === '/v2/sandboxes?metadata=user%3Dalice&state=running%2Cpaused&limit=10&nextToken=n1') { + res.statusCode = 200; + res.end(JSON.stringify([{ sandboxID: 'sbx_1' }])); + return; + } + if (req.method === 'GET' && req.url === '/sandboxes/sbx_1') { + res.statusCode = 200; + res.end(JSON.stringify({ sandboxID: 'sbx_1', state: 'running' })); + return; + } + if (req.method === 'POST' && req.url === '/sandboxes/sbx_1/connect') { + res.statusCode = 200; + res.end(JSON.stringify({ sandboxID: 'sbx_1', envdAccessToken: 'token' })); + return; + } + if (req.method === 'POST' && req.url === '/sandboxes/sbx_1/timeout') { + res.statusCode = 204; + res.end(); + return; + } + if (req.method === 'POST' && req.url === '/sandboxes/sbx_1/refreshes') { + res.statusCode = 204; + res.end(); + return; + } + if (req.method === 'POST' && req.url === '/sandboxes/sbx_1/pause') { + res.statusCode = 204; + res.end(); + return; + } + if (req.method === 'GET' && req.url === '/sandboxes/sbx_1/metrics?start=1&end=2') { + res.statusCode = 200; + res.end(JSON.stringify([{ cpuCount: 1 }])); + return; + } + if (req.method === 'GET' && req.url === '/sandboxes/sbx_1/logs?start=10&limit=20') { + res.statusCode = 200; + res.end(JSON.stringify({ logs: [] })); + return; + } + if (req.method === 'GET' && req.url === '/sandboxes/metrics?sandbox_ids=sbx_1%2Csbx_2') { + res.statusCode = 200; + res.end(JSON.stringify({ sandboxes: [] })); + return; + } + res.statusCode = 404; + res.end(JSON.stringify({ message: req.method + ' ' + req.url })); + }).then(fixture => { + const client = new qiniu.sandbox.SandboxClient({ + apiKey: 'sandbox-key', + endpoint: fixture.endpoint + }); + + return client.deleteSandbox().then(() => { + throw new Error('expected missing sandboxID to fail'); + }, err => { + err.name.should.eql('SandboxError'); + err.message.should.match(/sandboxID is required/); + }).then(() => client.list({ + metadata: 'user=alice', + state: ['running', 'paused'], + limit: 10, + nextToken: 'n1' + })).then(() => client.getInfo('sbx_1')) + .then(() => client.connect('sbx_1', { timeoutMs: 20000 })) + .then(() => client.setTimeout('sbx_1', { timeoutMs: 30000 })) + .then(() => client.refreshSandbox('sbx_1', { duration: 60 })) + .then(() => client.pauseSandbox('sbx_1')) + .then(() => client.getMetrics('sbx_1', { start: 1, end: 2 })) + .then(() => client.getLogs('sbx_1', { start: 10, limit: 20 })) + .then(() => client.getSandboxesMetrics(['sbx_1', 'sbx_2'])) + .then(() => { + JSON.parse(fixture.requests[3].body).should.eql({ timeout: 30 }); + JSON.parse(fixture.requests[4].body).should.eql({ duration: 60 }); + fixture.requests.every(req => req.headers['x-api-key'] === 'sandbox-key').should.eql(true); + fixture.requests.every(req => req.headers.authorization === 'Bearer sandbox-key').should.eql(true); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('surfaces sandbox API string errors and default connect timeout', function () { + return startServer((req, res) => { + if (req.method === 'POST' && req.url === '/sandboxes/sbx_default/connect') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ sandboxID: 'sbx_default' })); + return; + } + res.statusCode = 418; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify('teapot')); + }).then(fixture => { + const client = new qiniu.sandbox.SandboxClient({ + endpoint: fixture.endpoint, + apiKey: 'sandbox-key' + }); + + return client.connectSandbox('sbx_default') + .then(() => client.getSandbox('sbx_error')) + .then(() => { + throw new Error('expected string error'); + }, err => { + JSON.parse(fixture.requests[0].body).should.eql({ timeout: 15 }); + err.name.should.eql('SandboxError'); + err.message.should.containEql('teapot'); + return client.getSandbox(''); + }) + .then(() => { + throw new Error('expected missing sandboxID to fail'); + }, err => { + err.name.should.eql('SandboxError'); + err.message.should.eql('sandboxID is required'); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('exposes network constants and maps updateNetwork to Qiniu API', function () { + return startServer((req, res) => { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + sandboxID: 'sbx_net', + network: JSON.parse(req.body).network + })); + }).then(fixture => { + const client = new qiniu.sandbox.SandboxClient({ + endpoint: fixture.endpoint, + apiKey: 'sandbox-key' + }); + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_net', + client, + info: {} + }); + qiniu.sandbox.ALL_TRAFFIC.should.eql('0.0.0.0/0'); + return sandbox.updateNetwork({ allowOut: [qiniu.sandbox.ALL_TRAFFIC] }) + .then(info => { + info.network.allowOut[0].should.eql('0.0.0.0/0'); + fixture.requests[0].method.should.eql('PATCH'); + fixture.requests[0].url.should.eql('/sandboxes/sbx_net'); + JSON.parse(fixture.requests[0].body).should.eql({ + network: { + allowOut: ['0.0.0.0/0'] + } + }); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('returns typed unsupported errors for E2B volume compatibility', function () { + const volume = new qiniu.sandbox.Volume(); + return volume.create().then(() => { + throw new Error('expected volume.create to fail'); + }, err => { + err.name.should.eql('NotImplementedError'); + err.message.should.containEql('Volume'); + return volume.delete(); + }).then(() => { + throw new Error('expected volume.delete to fail'); + }, err => { + err.name.should.eql('NotImplementedError'); + return volume.list(); + }).then(() => { + throw new Error('expected volume.list to fail'); + }, err => { + err.name.should.eql('NotImplementedError'); + }); + }); + + it('maps injection rule CRUD APIs with Qiniu signing', function () { + return startServer((req, res) => { + res.setHeader('Content-Type', 'application/json'); + if (req.method === 'GET' && req.url === '/injection-rules') { + res.statusCode = 200; + res.end(JSON.stringify([{ id: 'rule_1' }])); + return; + } + if (req.method === 'GET' && req.url === '/injection-rules/rule_1') { + res.statusCode = 200; + res.end(JSON.stringify({ id: 'rule_1' })); + return; + } + if (req.method === 'PUT' && req.url === '/injection-rules/rule_1') { + res.statusCode = 200; + res.end(JSON.stringify({ id: 'rule_1', name: 'updated' })); + return; + } + if (req.method === 'DELETE' && req.url === '/injection-rules/rule_1') { + res.statusCode = 204; + res.end(); + return; + } + res.statusCode = 404; + res.end(JSON.stringify({ message: req.method + ' ' + req.url })); + }).then(fixture => { + const mac = new qiniu.auth.digest.Mac('ak', 'sk', { + disableQiniuTimestampSignature: true + }); + const client = new qiniu.sandbox.SandboxClient({ + mac, + endpoint: fixture.endpoint + }); + + return client.listInjectionRules() + .then(() => client.getInjectionRule('rule_1')) + .then(() => client.updateInjectionRule('rule_1', { name: 'updated' })) + .then(() => client.deleteInjectionRule('rule_1')) + .then(() => { + fixture.requests.length.should.eql(4); + fixture.requests.forEach(req => { + should(req.headers.authorization).startWith('Qiniu ak:'); + }); + JSON.parse(fixture.requests[2].body).should.eql({ name: 'updated' }); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('normalizes snake_case sandbox info and camelCase injection inputs', function () { + return startServer((req, res) => { + res.setHeader('Content-Type', 'application/json'); + if (req.method === 'POST' && req.url === '/sandboxes') { + res.statusCode = 201; + res.end(JSON.stringify({ + sandbox_id: 'sbx_snake', + sandbox_domain: 'snake.example.com' + })); + return; + } + if (req.method === 'GET' && req.url === '/sandboxes/sbx_snake') { + res.statusCode = 200; + res.end(JSON.stringify({ + sandbox_id: 'sbx_snake', + domain: 'snake.example.com', + envd_access_token: 'snake-token', + envd_version: '1.2.3' + })); + return; + } + if (req.method === 'POST' && req.url === '/injection-rules') { + res.statusCode = 201; + res.end(JSON.stringify({ ruleID: 'rule_1' })); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const mac = new qiniu.auth.digest.Mac('ak', 'sk', { + disableQiniuTimestampSignature: true + }); + const client = new qiniu.sandbox.SandboxClient({ + endpoint: fixture.endpoint, + apiKey: 'sandbox-key', + mac + }); + + return qiniu.sandbox.Sandbox.create({ client, template: 'base' }) + .then(sandbox => { + sandbox.sandboxId.should.eql('sbx_snake'); + sandbox.envdAccessToken.should.eql('snake-token'); + sandbox.envdVersion.should.eql('1.2.3'); + return client.createInjectionRule({ + name: 'qiniu', + injection: { + type: 'qiniu', + baseUrl: 'https://api.qnaigc.com', + apiKey: 'secret' + } + }); + }) + .then(() => { + JSON.parse(fixture.requests[2].body).should.eql({ + name: 'qiniu', + injection: { + type: 'qiniu', + base_url: 'https://api.qnaigc.com', + api_key: 'secret' + } + }); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('supports E2B style sandbox paginator, snapshots, and MCP helpers', function () { + let tokenReads = 0; + return startServer((req, res) => { + res.setHeader('Content-Type', 'application/json'); + if (req.method === 'GET' && req.url === '/v2/sandboxes?limit=2&nextToken=n1&metadata%5Buser%5D=alice&state=running') { + res.statusCode = 200; + res.end(JSON.stringify({ + items: [{ sandboxID: 'sbx_page', domain: 'page.example.com' }], + nextToken: 'n2' + })); + return; + } + if (req.method === 'POST' && req.url === '/sandboxes/sbx_page/snapshots') { + res.statusCode = 201; + res.end(JSON.stringify({ snapshotID: 'snap_1', snapshotId: 'snap_1' })); + return; + } + if (req.method === 'GET' && req.url === '/snapshots?limit=1&sandboxId=sbx_page') { + res.statusCode = 200; + res.end(JSON.stringify({ + items: [{ snapshotID: 'snap_1', snapshotId: 'snap_1' }], + nextToken: 'snap_next' + })); + return; + } + const parsed = parseUrl(req.url); + if (req.method === 'GET' && parsed.pathname === '/files' && parsed.searchParams.get('path') === '/etc/mcp-gateway/.token') { + tokenReads += 1; + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + res.end('mcp-token\n'); + return; + } + res.statusCode = 404; + res.end(JSON.stringify({ message: req.method + ' ' + req.url })); + }).then(fixture => { + const client = new qiniu.sandbox.SandboxClient({ + endpoint: fixture.endpoint, + apiKey: 'sandbox-key' + }); + const paginator = qiniu.sandbox.Sandbox.list({ + client, + limit: 2, + nextToken: 'n1', + query: { + metadata: { user: 'alice' }, + state: ['running'] + } + }); + + return paginator.nextItems().then(items => { + items[0].sandboxId.should.eql('sbx_page'); + paginator.hasNext.should.eql(true); + paginator.nextToken.should.eql('n2'); + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_page', + envdUrl: fixture.endpoint, + info: { + domain: 'page.example.com', + envdAccessToken: 'token' + }, + client + }); + sandbox.getMcpUrl().should.eql('https://50005-sbx_page.page.example.com/mcp'); + return Promise.all([ + sandbox.getMcpToken(), + sandbox.getMcpToken() + ]).then(tokens => { + tokens.should.eql(['mcp-token', 'mcp-token']); + tokenReads.should.eql(1); + return sandbox.getMcpToken(); + }).then(token => { + token.should.eql('mcp-token'); + tokenReads.should.eql(1); + return sandbox.createSnapshot({ name: 'snap' }); + }).then(snapshot => { + snapshot.snapshotId.should.eql('snap_1'); + return sandbox.listSnapshots({ limit: 1 }).nextItems(); + }); + }).then(snapshots => { + snapshots[0].snapshotId.should.eql('snap_1'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('exports sandbox constants and typed helpers aligned with common runtime names', function () { + qiniu.sandbox.DEFAULT_SANDBOX_TIMEOUT_MS.should.eql(300000); + qiniu.sandbox.FileType.FILE.should.eql('file'); + qiniu.sandbox.FileType.DIR.should.eql('dir'); + qiniu.DEFAULT_SANDBOX_TIMEOUT_MS.should.eql(qiniu.sandbox.DEFAULT_SANDBOX_TIMEOUT_MS); + qiniu.FileType.should.equal(qiniu.sandbox.FileType); + new qiniu.sandbox.InvalidArgumentError('bad arg').name.should.eql('InvalidArgumentError'); + new qiniu.sandbox.FileNotFoundError('missing').name.should.eql('FileNotFoundError'); + }); + + it('supports instance connect and betaPause aliases', function () { + return startServer((req, res) => { + res.setHeader('Content-Type', 'application/json'); + if (req.method === 'POST' && req.url === '/sandboxes/sbx_alias/connect') { + res.statusCode = 200; + res.end(JSON.stringify({ + sandboxID: 'sbx_alias', + domain: 'alias.example.com', + envdAccessToken: 'token2', + envdVersion: '0.5.7' + })); + return; + } + if (req.method === 'POST' && req.url === '/sandboxes/sbx_alias/pause') { + res.statusCode = 204; + res.end(); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_alias', + client: new qiniu.sandbox.SandboxClient({ + endpoint: fixture.endpoint, + apiKey: 'sandbox-key' + }), + info: {} + }); + + return sandbox.connect({ timeoutMs: 30000 }) + .then(connected => { + connected.should.equal(sandbox); + sandbox.envdAccessToken.should.eql('token2'); + sandbox.envdVersion.should.eql('0.5.7'); + sandbox.domain.should.eql('alias.example.com'); + return sandbox.betaPause(); + }) + .then(paused => { + should(paused).equal(null); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('rejects missing sandbox ids and encodes nested template file paths', function () { + const client = new qiniu.sandbox.SandboxClient({ + endpoint: 'http://sandbox.test', + apiKey: 'sandbox-key' + }); + const requests = []; + client.httpClient.sendRequest = req => { + requests.push(req); + return Promise.resolve({ + ok: () => true, + data: { ok: true } + }); + }; + + return client.getSandbox('').then(() => { + throw new Error('expected getSandbox to reject missing id'); + }, err => { + err.name.should.eql('SandboxError'); + err.message.should.eql('sandboxID is required'); + return client.deleteSandbox(''); + }).then(() => { + throw new Error('expected deleteSandbox to reject missing id'); + }, err => { + err.name.should.eql('SandboxError'); + err.message.should.eql('sandboxID is required'); + return client.getSandboxLogs('sbx/with space', { cursor: 'next/page' }); + }).then(() => client.getTemplateFiles('tpl/with space', 'dir/file hash')) + .then(() => { + requests.map(req => req.url).should.eql([ + 'http://sandbox.test/sandboxes/sbx%2Fwith%20space/logs?cursor=next%2Fpage', + 'http://sandbox.test/templates/tpl%2Fwith%20space/files/dir%2Ffile%20hash' + ]); + }); + }); +}); diff --git a/test/sandbox_commands.test.js b/test/sandbox_commands.test.js new file mode 100644 index 00000000..25937be2 --- /dev/null +++ b/test/sandbox_commands.test.js @@ -0,0 +1,1049 @@ +const { + should, + http, + qiniu, + startServer, + closeServer, + decodeConnectEnvelope, + encodeConnectEnvelope, + encodeRawConnectEnvelope, + encodeConnectEndEnvelope, + encodeOversizedConnectHeader, + encodeTruncatedConnectHeader, + handleGitAndPty +} = require('./sandbox_helpers'); + +describe('test sandbox commands module', function () { + it('runs commands and git operations through process RPC', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + const body = decodeConnectEnvelope(req.rawBody); + body.process.cmd.should.eql('/bin/bash'); + body.process.args.should.eql(['-l', '-c', 'git status --porcelain=v1 -b']); + body.process.cwd.should.eql('/repo'); + req.headers['content-type'].should.eql('application/connect+json'); + req.headers['keepalive-ping-interval'].should.eql('50'); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 101 } } }), + encodeConnectEnvelope({ event: { data: { stdout: Buffer.from('## main\n M a.txt\n?? b.txt\n').toString('base64') } } }), + encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_6', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.git.status('/repo').then(status => { + status.currentBranch.should.eql('main'); + status.changedFiles.should.eql(['a.txt']); + status.untrackedFiles.should.eql(['b.txt']); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('passes git config options to git commands', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + const body = decodeConnectEnvelope(req.rawBody); + body.process.args.should.eql([ + '-l', + '-c', + 'git -c \'http.version=HTTP/1.1\' clone \'https://example.com/repo.git\' --depth \'1\' --branch \'main\' \'/repo\'' + ]); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 103 } } }), + encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_6c', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.git.clone('https://example.com/repo.git', { + path: '/repo', + depth: 1, + branch: 'main', + config: { + 'http.version': 'HTTP/1.1' + } + }).then(result => { + result.exitCode.should.eql(0); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('decodes base64 process byte fields from Connect JSON', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 102 } } }), + encodeConnectEnvelope({ event: { data: { stdout: Buffer.from('hello sandbox').toString('base64') } } }), + encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_6b', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.commands.run('cat /tmp/hello.txt') + .then(result => { + result.stdout.should.eql('hello sandbox'); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('covers command management, callbacks, background handles, pty, and git wrappers', function () { + const commandsSeen = []; + const fakeCommands = { + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + return Promise.resolve({ + exitCode: 0, + stdout: 'value\n', + stderr: '' + }); + }, + start: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + return Promise.resolve({ pid: 9, wait: function () {} }); + } + }; + const git = new qiniu.sandbox.Git(fakeCommands); + + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 12 } } }), + encodeConnectEnvelope({ event: { data: { stdout: [111, 117, 116], stderr: [101, 114, 114] } } }), + encodeConnectEnvelope({ event: { end: { exitCode: 2, error: 'boom' } } }) + ])); + return; + } + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + if (req.url === '/process.Process/List') { + res.end(JSON.stringify({ + processes: [ + { pid: 1, tag: 't', config: { cmd: 'bash', args: ['-l'], envs: { A: '1' }, cwd: '/w' } } + ] + })); + return; + } + res.end('{}'); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_8', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + const seen = []; + + return sandbox.commands.run('echo hi', { + cwd: '/work', + envs: { A: '1' }, + tag: 'tag1', + stdin: true, + onStdout: data => seen.push('out:' + data), + onStderr: data => seen.push('err:' + data) + }).then(result => { + result.exitCode.should.eql(2); + result.error.should.eql('boom'); + seen.should.eql(['out:out', 'err:err']); + const firstStartBody = decodeConnectEnvelope(fixture.requests[0].rawBody); + firstStartBody.process.cwd.should.eql('/work'); + firstStartBody.process.envs.should.eql({ A: '1' }); + firstStartBody.tag.should.eql('tag1'); + firstStartBody.stdin.should.eql(true); + return sandbox.commands.run('sleep 1', { background: true }); + }).then(handle => { + handle.pid.should.eql(12); + return sandbox.commands.list(); + }).then(list => { + list[0].cwd.should.eql('/w'); + return sandbox.commands.sendStdin(12, Buffer.from('hello')); + }).then(() => { + const sendStdinBody = JSON.parse(fixture.requests[3].body); + sendStdinBody.input.stdin.should.eql(Buffer.from('hello').toString('base64')); + return sandbox.commands.closeStdin(12); + }) + .then(() => sandbox.commands.kill(12)) + .then(() => handleGitAndPty(git, sandbox.pty, commandsSeen, fixture)) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('returns command background handles before the process stream ends', function () { + let commandResponse; + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + commandResponse = res; + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.write(encodeConnectEnvelope({ event: { start: { pid: 88 } } })); + return; + } + if (req.url === '/process.Process/SendSignal') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end('{}'); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_live_cmd', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + const seen = []; + + return sandbox.commands.run('sleep 5', { + background: true, + user: 'root', + requestTimeoutMs: 1000, + onStdout: data => seen.push(data) + }).then(handle => { + handle.pid.should.eql(88); + should.not.exist(handle.result); + return handle.kill().then(() => handle); + }).then(handle => { + fixture.requests[1].headers.authorization.should.eql('Basic ' + Buffer.from('root:').toString('base64')); + commandResponse.write(encodeConnectEnvelope({ + event: { + data: { + stdout: Buffer.from('ready').toString('base64') + } + } + })); + commandResponse.end(encodeConnectEnvelope({ event: { end: { exitCode: 0 } } })); + return handle.wait(); + }).then(result => { + result.stdout.should.eql('ready'); + seen.should.eql(['ready']); + }).then(() => closeServer(fixture.server), err => { + if (commandResponse) { + commandResponse.end(); + } + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('rejects malformed command stream and JSON fallback payloads', function () { + let calls = 0; + return startServer((req, res) => { + calls += 1; + if (req.url === '/process.Process/Start' && calls === 1) { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(encodeRawConnectEnvelope('not-json')); + return; + } + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end('not-json'); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_cmd_bad_json', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.commands.start('echo bad').then(() => { + throw new Error('expected command stream parse error'); + }, err => { + err.message.should.match(/Unexpected token/); + return sandbox.commands.start('echo bad'); + }).then(() => { + throw new Error('expected command JSON fallback parse error'); + }, err => { + err.message.should.match(/Unexpected token/); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('rejects command wait when Connect end-stream carries an error', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 91 } } }), + encodeConnectEndEnvelope({ error: { code: 'internal', message: 'stream failed' } }) + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_cmd_trailer', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.commands.start('echo bad').then(handle => { + handle.pid.should.eql(91); + return handle.wait(); + }).then(() => { + throw new Error('expected command wait to reject'); + }, err => { + err.message.should.eql('stream failed'); + err.code.should.eql('internal'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('rejects oversized command stream envelopes', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(encodeOversizedConnectHeader()); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_cmd_huge', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.commands.start('echo huge').then(() => { + throw new Error('expected command stream to reject oversized frame'); + }, err => { + err.message.should.containEql('envelope too large'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('rejects command start when the process stream does not start before timeout', function () { + let commandResponse; + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + commandResponse = res; + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.flushHeaders(); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_cmd_timeout', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.commands.start('sleep 5', { + requestTimeoutMs: 5 + }).then(() => { + throw new Error('expected command start to time out'); + }, err => { + err.message.should.eql('Command stream start timed out'); + if (commandResponse) { + commandResponse.end(); + } + }).then(() => closeServer(fixture.server), err => { + if (commandResponse) { + commandResponse.end(); + } + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('treats command timeout alias as seconds while waiting for stream start', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + setTimeout(() => { + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 81 } } }), + encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) + ])); + }, 20); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_cmd_timeout_seconds', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.commands.start('sleep 1', { + timeout: 1 + }).then(handle => { + handle.pid.should.eql(81); + return handle.wait(); + }).then(result => { + result.exitCode.should.eql(0); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('does not reject command wait after disconnecting the live stream', function () { + let commandResponse; + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + commandResponse = res; + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.write(encodeConnectEnvelope({ event: { start: { pid: 83 } } })); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_cmd_disconnect', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.commands.start('sleep 30').then(handle => { + return handle.disconnect() + .then(() => handle.wait()) + .then(result => { + result.exitCode.should.eql(0); + }); + }).then(() => { + if (commandResponse) { + commandResponse.end(); + } + return closeServer(fixture.server); + }, err => { + if (commandResponse) { + commandResponse.end(); + } + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('rejects command wait when the live Connect stream ends with a partial frame after start', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 82 } } }), + encodeTruncatedConnectHeader() + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_cmd_truncated_tail', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.commands.start('echo bad').then(handle => { + handle.pid.should.eql(82); + return handle.wait(); + }).then(() => { + throw new Error('expected command wait to reject'); + }, err => { + err.message.should.eql('Sandbox envd stream truncated unexpectedly'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('rejects command wait when the live Connect stream ends before process end', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(encodeConnectEnvelope({ event: { start: { pid: 84 } } })); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_cmd_missing_end', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.commands.start('echo bad').then(handle => { + handle.pid.should.eql(84); + return handle.wait(); + }).then(() => { + throw new Error('expected command wait to reject'); + }, err => { + err.message.should.eql('Command stream ended before process end'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('uses configured HTTP agents for live envd streams', function () { + const agent = new http.Agent(); + const requestsThroughAgent = []; + const originalAddRequest = agent.addRequest; + agent.addRequest = function (req, options) { + requestsThroughAgent.push(options.path); + return originalAddRequest.call(this, req, options); + }; + + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 83 } } }), + encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) + ])); + return; + } + if (req.url === '/filesystem.Filesystem/WatchDir') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(encodeConnectEnvelope({ event: { start: {} } })); + return; + } + if (req.url === '/filesystem.Filesystem/Stat') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + result: { + entry: { name: 'agent.txt', path: '/agent.txt', type: 'FILE_TYPE_FILE', size: 5 } + } + })); + return; + } + if (req.method === 'GET' && req.url.indexOf('/files') === 0) { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/octet-stream'); + res.end('agent'); + return; + } + if (req.method === 'POST' && req.url.indexOf('/files') === 0) { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify([{ name: 'agent.txt', path: '/agent.txt', type: 'file' }])); + return; + } + if (req.url === '/health') { + res.statusCode = 204; + res.end(); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_agent', + envdUrl: fixture.endpoint, + httpAgent: agent, + info: { + envdAccessToken: 'token', + envdVersion: '0.5.7' + } + }); + + return sandbox.commands.start('echo ok') + .then(handle => handle.wait()) + .then(() => sandbox.files.watchDir('/workspace', () => {}, { + recursive: true + })) + .then(handle => { + handle._stopped.should.eql(true); + return sandbox.files.getInfo('/agent.txt'); + }) + .then(info => { + info.path.should.eql('/agent.txt'); + return sandbox.files.readText('/agent.txt'); + }) + .then(text => { + text.should.eql('agent'); + return sandbox.files.write('/agent.txt', 'agent'); + }) + .then(info => { + info.path.should.eql('/agent.txt'); + return sandbox.isRunning(); + }) + .then(running => { + running.should.eql(true); + return sandbox.pty.create({ + cols: 80, + rows: 24 + }); + }) + .then(handle => handle.wait()) + .then(() => { + requestsThroughAgent[0].should.eql('/process.Process/Start'); + requestsThroughAgent[1].should.eql('/filesystem.Filesystem/WatchDir'); + requestsThroughAgent[2].should.eql('/filesystem.Filesystem/Stat'); + requestsThroughAgent[3].should.startWith('/files?path=%2Fagent.txt&username=user&signature='); + requestsThroughAgent[4].should.startWith('/files?path=%2Fagent.txt&username=user&signature='); + requestsThroughAgent[5].should.eql('/health'); + requestsThroughAgent[6].should.eql('/process.Process/Start'); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }).then(result => { + agent.destroy(); + return result; + }, err => { + agent.destroy(); + throw err; + }); + }); + + it('supports JSON fallback for process stream responses and poll timeout errors', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.flushHeaders(); + setTimeout(() => { + res.end(JSON.stringify({ + events: [ + { event: { start: { pid: 22 } } }, + { event: { data: { stdout: Buffer.from('ok').toString('base64') } } }, + { event: { end: { exitCode: 0 } } } + ] + })); + }, 20); + return; + } + if (req.url === '/sandboxes/sbx_pending') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ sandboxID: 'sbx_pending', state: 'pending' })); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_pending', + envdUrl: fixture.endpoint, + client: new qiniu.sandbox.SandboxClient({ + endpoint: fixture.endpoint, + apiKey: 'sandbox-key' + }), + info: {} + }); + + return sandbox.commands.run('echo ok', { timeoutMs: 5 }) + .then(result => { + result.stdout.should.eql('ok'); + return sandbox.waitForReady({ intervalMs: 1, timeoutMs: 5 }); + }) + .then(() => { + throw new Error('expected waitForReady timeout'); + }, err => { + err.name.should.eql('TimeoutError'); + err.message.should.eql('Sandbox poll timed out'); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('treats envd RPC timeout alias as seconds', function () { + const envd = require('../qiniu/sandbox/envd'); + return startServer((req, res) => { + if (req.url === '/test.RPC/Call') { + setTimeout(() => { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ result: { ok: true } })); + }, 20); + return; + } + if (req.url === '/test.RPC/Stream') { + setTimeout(() => { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(encodeConnectEnvelope({ event: { ok: true } })); + }, 20); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_envd_timeout_seconds', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return envd.connectRPC(sandbox, '/test.RPC/Call', {}, { + timeout: 1 + }).then(result => { + result.should.eql({ ok: true }); + return envd.connectStreamRPC(sandbox, '/test.RPC/Stream', {}, { + timeout: 1 + }); + }).then(events => { + events.should.eql([{ event: { ok: true } }]); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('rejects truncated buffered Connect stream responses', function () { + try { + require('../qiniu/sandbox/envd').decodeConnectEnvelopes(encodeTruncatedConnectHeader()); + throw new Error('expected buffered decoder to reject truncated stream'); + } catch (err) { + err.message.should.eql('Sandbox envd stream truncated unexpectedly'); + } + + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(encodeTruncatedConnectHeader()); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_truncated', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.commands.run('echo bad').then(() => { + throw new Error('expected truncated stream error'); + }, err => { + err.message.should.eql('Sandbox envd stream truncated unexpectedly'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('supports process stream JSON array and single event fallback responses', function () { + let calls = 0; + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + calls += 1; + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + if (calls === 1) { + res.end(JSON.stringify([ + { event: { start: { pid: 31 } } }, + { event: { data: { stdout: Buffer.from('array').toString('base64') } } }, + { event: { end: { exitCode: 0 } } } + ])); + return; + } + res.end(JSON.stringify({ + event: { + end: { + exitCode: 0 + } + } + })); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_json_fallback', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.commands.run('echo array') + .then(result => { + result.pid.should.eql(31); + result.stdout.should.eql('array'); + return sandbox.commands.run('true'); + }) + .then(result => { + result.exitCode.should.eql(0); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('supports E2B command timeout aliases and optional exit throwing', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 77 } } }), + encodeConnectEnvelope({ event: { data: { stdout: Buffer.from('out').toString('base64'), stderr: Buffer.from('err').toString('base64') } } }), + encodeConnectEnvelope({ event: { end: { exitCode: 7 } } }) + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_cmd', + envdUrl: fixture.endpoint, + envdAccessToken: 'token', + info: {} + }); + + return sandbox.commands.run('false', { + requestTimeoutMs: 12000 + }).then(result => { + result.exitCode.should.eql(7); + result.stdout.should.eql('out'); + result.stderr.should.eql('err'); + return sandbox.commands.run('false', { + requestTimeoutMs: 12000, + throwOnError: true + }); + }).then(() => { + throw new Error('expected command to throw'); + }, err => { + err.name.should.eql('CommandExitError'); + err.exitCode.should.eql(7); + err.stdout.should.eql('out'); + err.stderr.should.eql('err'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('supports commands.connect with E2B style command handle semantics', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Connect') { + const body = decodeConnectEnvelope(req.rawBody); + body.process.selector.pid.should.eql(55); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 55 } } }), + encodeConnectEnvelope({ event: { data: { stdout: Buffer.from('connected').toString('base64') } } }), + encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_connect_cmd', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.commands.connect(55, { + requestTimeoutMs: 9000 + }).then(handle => { + handle.pid.should.eql(55); + return handle.wait(); + }).then(result => { + result.stdout.should.eql('connected'); + result.exitCode.should.eql(0); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('parses envd stream fallback responses shaped as events, arrays, and single events', function () { + const envd = require('../qiniu/sandbox/envd'); + return startServer((req, res) => { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + if (req.url === '/stream.EventsObject') { + res.end(JSON.stringify({ + events: [{ event: { start: { pid: 1 } } }] + })); + return; + } + if (req.url === '/stream.Array') { + res.end(JSON.stringify([{ event: { end: { exitCode: 0 } } }])); + return; + } + if (req.url === '/stream.Single') { + res.end(JSON.stringify({ event: { data: { stdout: 'aGVsbG8=' } } })); + return; + } + if (req.url === '/stream.Empty') { + res.end(JSON.stringify({ ok: true })); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_envd_fallbacks', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return envd.connectStreamRPC(sandbox, '/stream.EventsObject', {}) + .then(events => { + events.should.eql([{ event: { start: { pid: 1 } } }]); + return envd.connectStreamRPC(sandbox, '/stream.Array', {}); + }) + .then(events => { + events.should.eql([{ event: { end: { exitCode: 0 } } }]); + return envd.connectStreamRPC(sandbox, '/stream.Single', {}); + }) + .then(events => { + events.should.eql([{ event: { data: { stdout: 'aGVsbG8=' } } }]); + return envd.connectStreamRPC(sandbox, '/stream.Empty', {}); + }) + .then(events => { + events.should.eql([]); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); +}); diff --git a/test/sandbox_facade.test.js b/test/sandbox_facade.test.js new file mode 100644 index 00000000..4b9d50db --- /dev/null +++ b/test/sandbox_facade.test.js @@ -0,0 +1,267 @@ +const { + qiniu, + startServer, + closeServer +} = require('./sandbox_helpers'); + +describe('test sandbox facade module', function () { + it('covers Sandbox.connect, Sandbox.list, wait polling, and stopped health checks', function () { + let infoCalls = 0; + return startServer((req, res) => { + res.setHeader('Content-Type', 'application/json'); + if (req.method === 'POST' && req.url === '/sandboxes/sbx_9/connect') { + res.statusCode = 200; + res.end(JSON.stringify({ sandboxID: 'sbx_9', domain: 'd.example.com', envdAccessToken: 'token' })); + return; + } + if (req.method === 'GET' && req.url === '/v2/sandboxes?limit=1') { + res.statusCode = 200; + res.end(JSON.stringify([{ sandboxID: 'sbx_9', domain: 'd.example.com', envdAccessToken: 'token' }])); + return; + } + if (req.method === 'POST' && req.url === '/sandboxes') { + res.statusCode = 201; + res.end(JSON.stringify({ sandboxID: 'sbx_10', envdAccessToken: 'token' })); + return; + } + if (req.method === 'GET' && req.url === '/sandboxes/sbx_10') { + infoCalls += 1; + res.statusCode = 200; + res.end(JSON.stringify({ sandboxID: 'sbx_10', state: infoCalls > 1 ? 'running' : 'pending' })); + return; + } + if (req.method === 'GET' && req.url === '/health') { + res.statusCode = 502; + res.end(JSON.stringify({ message: 'stopped' })); + return; + } + res.statusCode = 404; + res.end(JSON.stringify({ message: req.method + ' ' + req.url })); + }).then(fixture => { + const client = new qiniu.sandbox.SandboxClient({ + apiKey: 'sandbox-key', + endpoint: fixture.endpoint + }); + + return qiniu.sandbox.Sandbox.connect('sbx_9', { + client, + timeout: 12 + }).then(sandbox => { + sandbox.sandboxId.should.eql('sbx_9'); + sandbox.envdAccessToken.should.eql('token'); + return qiniu.sandbox.Sandbox.list({ client, limit: 1 }); + }).then(sandboxes => { + sandboxes[0].sandboxId.should.eql('sbx_9'); + return client.createAndWait({ template: 'base' }, { intervalMs: 1, timeoutMs: 100 }); + }).then(sandbox => { + sandbox.sandboxId.should.eql('sbx_10'); + sandbox.envdAccessToken.should.eql('token'); + infoCalls.should.eql(2); + const stopped = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_11', + envdUrl: fixture.endpoint, + info: {} + }); + return stopped.isRunning(); + }).then(running => { + running.should.eql(false); + return closeServer(fixture.server); + }, err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('retries transient waitForReady polling errors before timeout', function () { + let infoCalls = 0; + return startServer((req, res) => { + if (req.method === 'GET' && req.url === '/sandboxes/sbx_retry') { + infoCalls += 1; + if (infoCalls === 1) { + res.statusCode = 502; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ message: 'temporary' })); + return; + } + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ sandboxID: 'sbx_retry', state: 'running' })); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_retry', + client: new qiniu.sandbox.SandboxClient({ + endpoint: fixture.endpoint, + apiKey: 'sandbox-key' + }), + info: {} + }); + + return sandbox.waitForReady({ + intervalMs: 1, + timeoutMs: 50 + }).then(info => { + info.state.should.eql('running'); + infoCalls.should.eql(2); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('treats waitForReady timeout alias as seconds while polling', function () { + let infoCalls = 0; + return startServer((req, res) => { + if (req.method === 'GET' && req.url === '/sandboxes/sbx_poll_seconds') { + infoCalls += 1; + if (infoCalls === 1) { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ sandboxID: 'sbx_poll_seconds', state: 'starting' })); + return; + } + setTimeout(() => { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ sandboxID: 'sbx_poll_seconds', state: 'running' })); + }, 20); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_poll_seconds', + client: new qiniu.sandbox.SandboxClient({ + endpoint: fixture.endpoint, + apiKey: 'sandbox-key' + }), + info: {} + }); + + return sandbox.waitForReady({ + intervalMs: 1, + timeout: 1 + }).then(info => { + info.state.should.eql('running'); + infoCalls.should.eql(2); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('does not retry fatal client errors while polling', function () { + let calls = 0; + return startServer((req, res) => { + calls += 1; + res.statusCode = 404; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ message: 'missing' })); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_missing', + client: new qiniu.sandbox.SandboxClient({ + endpoint: fixture.endpoint, + apiKey: 'sandbox-key' + }), + info: {} + }); + + return sandbox.waitForReady({ intervalMs: 1, timeoutMs: 100 }).then(() => { + throw new Error('expected waitForReady to fail'); + }, err => { + err.response.statusCode.should.eql(404); + calls.should.eql(1); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('does not retry programming errors while polling', function () { + let calls = 0; + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_programming_error', + info: {} + }); + sandbox.getInfo = function () { + calls += 1; + return Promise.reject(new TypeError('bad poll logic')); + }; + + return sandbox.waitForReady({ intervalMs: 1, timeoutMs: 100 }).then(() => { + throw new Error('expected waitForReady to fail'); + }, err => { + err.message.should.eql('bad poll logic'); + calls.should.eql(1); + }); + }); + + it('returns false for transient envd gateway health errors and rethrows others', function () { + let calls = 0; + return startServer((req, res) => { + calls += 1; + if (calls === 1) { + res.statusCode = 503; + res.end('starting'); + return; + } + if (calls === 2) { + res.statusCode = 504; + res.end('timeout'); + return; + } + res.statusCode = 500; + res.end('broken'); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_health_error', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.isRunning().then(running => { + running.should.eql(false); + return sandbox.isRunning(); + }).then(running => { + running.should.eql(false); + return sandbox.isRunning(); + }).then(() => { + throw new Error('expected health error'); + }, err => { + err.name.should.eql('SandboxError'); + err.message.should.containEql('500'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('returns false from isRunning on connection failures', function () { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_down', + envdUrl: 'http://127.0.0.1:9', + info: {} + }); + + return sandbox.isRunning().then(running => { + running.should.eql(false); + }); + }); +}); diff --git a/test/sandbox_filesystem.test.js b/test/sandbox_filesystem.test.js new file mode 100644 index 00000000..f91119ca --- /dev/null +++ b/test/sandbox_filesystem.test.js @@ -0,0 +1,980 @@ +const { + should, + stream, + zlib, + qiniu, + startServer, + closeServer, + parseUrl, + decodeConnectEnvelope, + encodeConnectEnvelope, + encodeRawConnectEnvelope, + encodeConnectEndEnvelope, + encodeOversizedConnectHeader, + encodeTruncatedConnectHeader +} = require('./sandbox_helpers'); + +describe('test sandbox filesystem module', function () { + it('builds envd hosts and signed file urls', function () { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_3', + info: { + domain: 'sandbox.example.com', + envdAccessToken: 'token' + } + }); + + sandbox.getHost(8080).should.eql('8080-sbx_3.sandbox.example.com'); + + const parsed = parseUrl(sandbox.downloadUrl('/home/user/a.txt', { + user: 'admin', + signatureExpiration: 60 + })); + parsed.protocol.should.eql('https:'); + parsed.host.should.eql('49983-sbx_3.sandbox.example.com'); + parsed.pathname.should.eql('/files'); + parsed.searchParams.get('path').should.eql('/home/user/a.txt'); + parsed.searchParams.get('username').should.eql('admin'); + const expiration = Number(parsed.searchParams.get('signature_expiration')); + expiration.should.be.above(Math.floor(Date.now() / 1000)); + expiration.should.be.below(Math.floor(Date.now() / 1000) + 120); + should(parsed.searchParams.get('signature')).startWith('v1_'); + + const absolute = parseUrl(sandbox.uploadUrl('/home/user/a.txt', { + signatureExpiration: 2000000000 + })); + absolute.searchParams.get('signature_expiration').should.eql('2000000000'); + }); + + it('reads and writes files through envd HTTP API', function () { + return startServer((req, res) => { + const parsed = parseUrl(req.url); + if (req.method === 'GET' && parsed.pathname === '/files') { + parsed.searchParams.get('path').should.eql('/hello.txt'); + parsed.searchParams.get('username').should.eql('user'); + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + res.end('hello'); + return; + } + + if (req.method === 'POST' && parsed.pathname === '/files') { + parsed.searchParams.get('path').should.eql('/hello.txt'); + should(req.headers['content-type']).startWith('multipart/form-data; boundary='); + req.body.should.containEql('filename="/hello.txt"'); + req.body.should.containEql('hello'); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify([{ name: 'hello.txt', path: '/hello.txt', type: 'file' }])); + return; + } + + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_4', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.files.readText('/hello.txt').then(text => { + text.should.eql('hello'); + return sandbox.files.write('/hello.txt', 'hello'); + }).then(info => { + info.path.should.eql('/hello.txt'); + fixture.requests.map(req => `${req.method} ${parseUrl(req.url).pathname}`).should.eql([ + 'GET /files', + 'POST /files' + ]); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('reads files as text, bytes, blob, and stream formats', function () { + return startServer((req, res) => { + const parsed = parseUrl(req.url); + if (req.method === 'GET' && parsed.pathname === '/files') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/octet-stream'); + res.end(Buffer.from('hello')); + return; + } + + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_files', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.files.read('/tmp/a.txt') + .then(text => { + text.should.eql('hello'); + return sandbox.files.read('/tmp/a.txt', { format: 'bytes' }); + }) + .then(bytes => { + Buffer.isBuffer(bytes).should.eql(true); + bytes.toString().should.eql('hello'); + return sandbox.files.read('/tmp/a.txt', { format: 'stream' }); + }) + .then(stream => { + (typeof stream.pipe).should.eql('function'); + return new Promise((resolve, reject) => { + const chunks = []; + stream.on('data', chunk => chunks.push(chunk)); + stream.on('end', () => resolve(Buffer.concat(chunks).toString())); + stream.on('error', reject); + }); + }) + .then(streamText => { + streamText.should.eql('hello'); + return sandbox.files.read('/tmp/a.txt', { format: 'blob' }); + }) + .then(blob => { + if (typeof global.Blob !== 'undefined') { + blob.should.be.instanceOf(global.Blob); + } else { + Buffer.isBuffer(blob).should.eql(true); + } + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('escapes file paths in multipart filenames', function () { + const unsafePath = '/tmp/a"\r\nX-Injected: y.txt'; + return startServer((req, res) => { + const parsed = parseUrl(req.url); + if (req.method === 'POST' && parsed.pathname === '/files') { + parsed.searchParams.get('path').should.eql(unsafePath); + req.body.should.containEql('filename="/tmp/a\\"%0D%0AX-Injected: y.txt"'); + req.body.should.not.containEql('\r\nX-Injected'); + req.body.should.not.containEql('a"\r\n'); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify([{ name: 'a.txt', path: unsafePath, type: 'file' }])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_multipart_safe', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token', + envdVersion: '0.5.5' + } + }); + + return sandbox.files.write(unsafePath, 'hello') + .then(info => { + info.path.should.eql(unsafePath); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('rejects Readable streams passed directly to filesystem write', function () { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_stream_write', + envdUrl: 'http://127.0.0.1:9', + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.files.write('/stream.txt', new stream.Readable({ + read: function () {} + })).then(() => { + throw new Error('expected stream write to reject'); + }, err => { + err.message.should.eql('Streams are not supported as data in filesystem.write'); + }); + }); + + it('preserves falsy multipart payload values', function () { + return startServer((req, res) => { + const parsed = parseUrl(req.url); + if (req.method === 'POST' && parsed.pathname === '/files') { + parsed.searchParams.get('path').should.eql('/zero.txt'); + req.body.should.containEql('\r\n0\r\n'); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify([{ name: 'zero.txt', path: '/zero.txt', type: 'file' }])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_multipart_falsy', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token', + envdVersion: '0.5.5' + } + }); + + return sandbox.files.write('/zero.txt', 0) + .then(info => { + info.path.should.eql('/zero.txt'); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('uses Connect RPC paths for filesystem metadata operations', function () { + return startServer((req, res) => { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + if (req.url === '/filesystem.Filesystem/Stat') { + res.end(JSON.stringify({ + entry: { name: 'hello.txt', path: '/hello.txt', type: 'FILE_TYPE_FILE', size: 5 } + })); + return; + } + if (req.url === '/filesystem.Filesystem/ListDir') { + res.end(JSON.stringify({ + entries: [{ name: 'hello.txt', path: '/hello.txt', type: 'FILE_TYPE_FILE', size: 5 }] + })); + return; + } + if (req.url === '/filesystem.Filesystem/MakeDir') { + res.end(JSON.stringify({ + entry: { name: 'tmp', path: '/tmp', type: 'FILE_TYPE_DIRECTORY' } + })); + return; + } + if (req.url === '/filesystem.Filesystem/Move') { + res.end(JSON.stringify({ + entry: { name: 'b.txt', path: '/b.txt', type: 'FILE_TYPE_FILE' } + })); + return; + } + if (req.url === '/filesystem.Filesystem/Remove') { + res.end('{}'); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_5', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.files.getInfo('/hello.txt') + .then(info => { + info.type.should.eql('file'); + return sandbox.files.list('/'); + }) + .then(entries => { + entries[0].name.should.eql('hello.txt'); + return sandbox.files.makeDir('/tmp'); + }) + .then(info => { + info.type.should.eql('dir'); + return sandbox.files.rename('/a.txt', '/b.txt'); + }) + .then(info => { + info.path.should.eql('/b.txt'); + return sandbox.files.remove('/b.txt'); + }) + .then(() => { + fixture.requests.map(req => req.url).should.eql([ + '/filesystem.Filesystem/Stat', + '/filesystem.Filesystem/ListDir', + '/filesystem.Filesystem/MakeDir', + '/filesystem.Filesystem/Move', + '/filesystem.Filesystem/Remove' + ]); + fixture.requests[0].headers.authorization.should.eql('Basic dXNlcjo='); + fixture.requests[0].headers['x-access-token'].should.eql('token'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('watches directory changes and returns a stoppable handle after start event', function () { + let watchResponse; + return startServer((req, res) => { + if (req.url === '/filesystem.Filesystem/WatchDir') { + const body = decodeConnectEnvelope(req.rawBody); + body.path.should.eql('/workspace'); + body.recursive.should.eql(true); + req.headers['content-type'].should.eql('application/connect+json'); + req.headers['keepalive-ping-interval'].should.eql('50'); + watchResponse = res; + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.write(encodeConnectEnvelope({ event: { start: {} } })); + setTimeout(() => { + res.write(encodeConnectEnvelope({ + event: { + filesystem: { + name: 'created.txt', + type: 'EVENT_TYPE_CREATE' + } + } + })); + res.write(encodeConnectEnvelope({ + event: { + filesystem: { + name: 'written.txt', + type: 2 + } + } + })); + res.write(encodeConnectEnvelope({ event: { keepalive: {} } })); + }, 10); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const events = []; + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_watch', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token', + envdVersion: '0.5.7' + } + }); + + return sandbox.files.watchDir('/workspace', event => { + events.push(event); + }, { + recursive: true, + requestTimeoutMs: 1000 + }).then(handle => { + (typeof handle.stop).should.eql('function'); + return new Promise(resolve => setTimeout(resolve, 40)).then(() => { + events.should.eql([ + { name: 'created.txt', type: qiniu.sandbox.FilesystemEventType.CREATE }, + { name: 'written.txt', type: qiniu.sandbox.FilesystemEventType.WRITE } + ]); + return handle.stop(); + }); + }).then(() => { + if (watchResponse) { + watchResponse.end(); + } + return closeServer(fixture.server); + }, err => { + if (watchResponse) { + watchResponse.end(); + } + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('rejects recursive directory watching on envd versions without support', function () { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_old_watch', + envdUrl: 'http://127.0.0.1:9', + info: { + envdVersion: '0.1.3' + } + }); + + return sandbox.files.watchDir('/workspace', () => {}, { + recursive: true + }).then(() => { + throw new Error('expected watchDir to reject'); + }, err => { + err.message.should.match(/recursive watching/i); + }); + }); + + it('rejects recursive directory watching when envd version is unknown', function () { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_unknown_watch', + envdUrl: 'http://127.0.0.1:9', + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.files.watchDir('/workspace', () => {}, { + recursive: true + }).then(() => { + throw new Error('expected watchDir to reject without envd version'); + }, err => { + err.name.should.eql('SandboxError'); + err.message.should.match(/recursive watching/i); + }); + }); + + it('returns false from exists for err.resp 404 responses', function () { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_exists_resp', + envdUrl: 'http://127.0.0.1:9', + info: {} + }); + sandbox.files.getInfo = function () { + const err = new Error('missing'); + err.resp = { statusCode: 404 }; + return Promise.reject(err); + }; + return sandbox.files.exists('/missing').then(exists => { + exists.should.eql(false); + }); + }); + + it('rejects malformed filesystem watch stream payloads', function () { + return startServer((req, res) => { + if (req.url === '/filesystem.Filesystem/WatchDir') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: {} } }), + encodeRawConnectEnvelope('not-json') + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_watch_bad_json', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token', + envdVersion: '0.5.7' + } + }); + let handle; + + return sandbox.files.watchDir('/workspace', () => {}, { + recursive: true, + onExit: err => { + err.message.should.match(/Unexpected token/); + } + }).then(ret => { + handle = ret; + return new Promise(resolve => setTimeout(resolve, 20)); + }).then(() => { + handle._stopped.should.eql(true); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('rejects oversized filesystem watch stream envelopes', function () { + return startServer((req, res) => { + if (req.url === '/filesystem.Filesystem/WatchDir') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(encodeOversizedConnectHeader()); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_watch_huge', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token', + envdVersion: '0.5.7' + } + }); + + return sandbox.files.watchDir('/workspace', () => {}, { + recursive: true + }).then(() => { + throw new Error('expected watchDir to reject oversized frame'); + }, err => { + err.message.should.containEql('envelope too large'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('covers filesystem writeFiles, bytes, exists false, and envd health', function () { + return startServer((req, res) => { + const parsed = parseUrl(req.url); + if (req.method === 'GET' && parsed.pathname === '/files') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/octet-stream'); + res.end(Buffer.from([1, 2, 3])); + return; + } + if (req.method === 'POST' && parsed.pathname === '/files') { + parsed.searchParams.get('path') === null ? true.should.eql(true) : false.should.eql(true); + parsed.searchParams.get('username').should.eql('user'); + should(parsed.searchParams.get('signature')).startWith('v1_'); + Number(parsed.searchParams.get('signature_expiration')).should.be.above(Math.floor(Date.now() / 1000)); + req.body.should.containEql('/a.txt'); + req.body.should.containEql('/b.txt'); + req.rawBody.includes(Buffer.from([1, 2, 3])).should.eql(true); + req.rawBody.includes(Buffer.from([4, 5, 6])).should.eql(true); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify([ + { name: 'a.txt', path: '/a.txt', type: 'FILE_TYPE_FILE' }, + { name: 'b.txt', path: '/b.txt', type: 'FILE_TYPE_FILE' } + ])); + return; + } + if (req.method === 'POST' && req.url === '/filesystem.Filesystem/Stat') { + res.statusCode = 404; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ message: 'not found' })); + return; + } + if (req.method === 'GET' && req.url === '/health') { + res.statusCode = 204; + res.end(); + return; + } + res.statusCode = 500; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_7', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.files.read('/bin', { format: 'bytes' }) + .then(data => { + Buffer.isBuffer(data).should.eql(true); + data.length.should.eql(3); + return sandbox.files.writeFiles([ + { path: '/a.txt', data: new Uint8Array([1, 2, 3]) }, + { path: '/b.txt', data: new Uint8Array([4, 5, 6]).buffer } + ], { user: 'user' }); + }) + .then(entries => { + entries.map(entry => entry.path).should.eql(['/a.txt', '/b.txt']); + return sandbox.files.writeFiles(null).then(() => { + throw new Error('expected writeFiles invalid files to fail'); + }, err => { + err.name.should.eql('TypeError'); + err.message.should.eql('files must be an array'); + }); + }) + .then(() => { + return sandbox.files.writeFiles([null]).then(() => { + throw new Error('expected writeFiles invalid file item to fail'); + }, err => { + err.name.should.eql('TypeError'); + err.message.should.eql('Each file must be an object'); + }); + }) + .then(() => { + return sandbox.files.writeFiles([ + { path: '/stream.txt', data: new stream.Readable() } + ]).then(() => { + throw new Error('expected writeFiles stream data to fail'); + }, err => { + err.name.should.eql('TypeError'); + err.message.should.match(/Streams are not supported/); + }); + }) + .then(() => { + return sandbox.files.exists('/missing.txt'); + }) + .then(exists => { + exists.should.eql(false); + return sandbox.isRunning(); + }) + .then(running => { + running.should.eql(true); + sandbox.getHost(1234).should.eql(''); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('fails watchDir on Connect end-stream errors after start', function () { + let exitError; + return startServer((req, res) => { + if (req.url === '/filesystem.Filesystem/WatchDir') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: {} } }), + encodeConnectEndEnvelope({ error: { code: 'internal', message: 'watch failed' } }) + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_watch_trailer', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token', + envdVersion: '0.5.7' + } + }); + + return sandbox.files.watchDir('/workspace', () => {}, { + recursive: true, + onExit: err => { + exitError = err; + } + }).then(handle => { + return new Promise(resolve => setTimeout(resolve, 20)).then(() => handle); + }).then(handle => { + handle._stopped.should.eql(true); + exitError.message.should.eql('watch failed'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('fails watchDir when the event callback throws', function () { + let exitError; + return startServer((req, res) => { + if (req.url === '/filesystem.Filesystem/WatchDir') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.write(encodeConnectEnvelope({ event: { start: {} } })); + res.write(encodeConnectEnvelope({ + event: { + filesystem: { + name: 'created.txt', + type: 'EVENT_TYPE_CREATE' + } + } + })); + setTimeout(() => res.end(), 50); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_watch_callback', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token', + envdVersion: '0.5.7' + } + }); + + return sandbox.files.watchDir('/workspace', () => { + throw new Error('callback failed'); + }, { + recursive: true, + onExit: err => { + exitError = err; + } + }).then(handle => { + return new Promise(resolve => setTimeout(resolve, 20)).then(() => handle); + }).then(handle => { + handle._stopped.should.eql(true); + exitError.message.should.eql('callback failed'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('fails watchDir when the event callback rejects asynchronously', function () { + let exitError; + return startServer((req, res) => { + if (req.url === '/filesystem.Filesystem/WatchDir') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: {} } }), + encodeConnectEnvelope({ + event: { + filesystem: { + name: 'created.txt', + type: 'EVENT_TYPE_CREATE' + } + } + }) + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_watch_async_callback', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token', + envdVersion: '0.5.7' + } + }); + + return sandbox.files.watchDir('/workspace', () => Promise.reject(new Error('async callback failed')), { + recursive: true, + onExit: err => { + exitError = err; + } + }).then(handle => { + return new Promise(resolve => setTimeout(resolve, 20)).then(() => handle); + }).then(handle => { + handle._stopped.should.eql(true); + exitError.message.should.eql('async callback failed'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('handles rejected async watchDir onExit callbacks', function () { + let exitCalled = false; + return startServer((req, res) => { + if (req.url === '/filesystem.Filesystem/WatchDir') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(encodeConnectEnvelope({ event: { start: {} } })); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_watch_async_exit', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token', + envdVersion: '0.5.7' + } + }); + + return sandbox.files.watchDir('/workspace', () => {}, { + recursive: true, + onExit: () => { + exitCalled = true; + return Promise.reject(new Error('async exit failed')); + } + }).then(handle => { + return new Promise(resolve => setTimeout(resolve, 20)).then(() => handle); + }).then(handle => { + handle._stopped.should.eql(true); + exitCalled.should.eql(true); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('fails watchDir when the live Connect stream ends with a partial frame after start', function () { + let exitError; + return startServer((req, res) => { + if (req.url === '/filesystem.Filesystem/WatchDir') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: {} } }), + encodeTruncatedConnectHeader() + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_watch_truncated_tail', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token', + envdVersion: '0.5.7' + } + }); + + return sandbox.files.watchDir('/workspace', () => {}, { + recursive: true, + onExit: err => { + exitError = err; + } + }).then(handle => { + return new Promise(resolve => setTimeout(resolve, 20)).then(() => handle); + }).then(handle => { + handle._stopped.should.eql(true); + exitError.message.should.eql('Sandbox envd stream truncated unexpectedly'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('treats watchDir timeout alias as seconds while waiting for stream start', function () { + return startServer((req, res) => { + if (req.url === '/filesystem.Filesystem/WatchDir') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + setTimeout(() => { + res.end(encodeConnectEnvelope({ event: { start: {} } })); + }, 20); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_watch_timeout_seconds', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token', + envdVersion: '0.5.7' + } + }); + + return sandbox.files.watchDir('/workspace', () => {}, { + recursive: true, + timeout: 1 + }).then(handle => { + handle._stopped.should.eql(true); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('supports filesystem gzip and octet-stream write compatibility options', function () { + return startServer((req, res) => { + const parsed = parseUrl(req.url); + if (req.method === 'GET' && parsed.pathname === '/files') { + req.headers['accept-encoding'].should.eql('gzip'); + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + res.end('zip'); + return; + } + if (req.method === 'POST' && parsed.pathname === '/files') { + req.headers['content-type'].should.eql('application/octet-stream'); + req.headers['content-encoding'].should.eql('gzip'); + parsed.searchParams.get('path').should.eql('/zip.txt'); + Array.from(zlib.gunzipSync(req.rawBody)).should.eql([1, 2, 3]); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify([{ name: 'zip.txt', path: '/zip.txt', type: 'file' }])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_zip', + envdUrl: fixture.endpoint, + info: { + envdVersion: '0.5.7', + envdAccessToken: 'token' + } + }); + + return sandbox.files.read('/zip.txt', { gzip: true }) + .then(text => { + text.should.eql('zip'); + return sandbox.files.write('/zip.txt', new Uint8Array([1, 2, 3]), { + gzip: true, + useOctetStream: true + }); + }) + .then(info => { + info.path.should.eql('/zip.txt'); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('falls back to multipart uploads when envd does not support octet-stream', function () { + return startServer((req, res) => { + const parsed = parseUrl(req.url); + if (req.method === 'POST' && parsed.pathname === '/files') { + should(req.headers['content-type']).startWith('multipart/form-data; boundary='); + should.not.exist(req.headers['content-encoding']); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify([{ name: 'zip.txt', path: '/zip.txt', type: 'file' }])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_zip_old', + envdUrl: fixture.endpoint, + info: { + envdVersion: '0.5.5', + envdAccessToken: 'token' + } + }); + + return sandbox.files.write('/zip.txt', 'zip', { + gzip: true, + useOctetStream: true + }).then(info => { + info.path.should.eql('/zip.txt'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); +}); diff --git a/test/sandbox_git.test.js b/test/sandbox_git.test.js new file mode 100644 index 00000000..7c43926f --- /dev/null +++ b/test/sandbox_git.test.js @@ -0,0 +1,471 @@ +const { + should, + qiniu +} = require('./sandbox_helpers'); + +describe('test sandbox git module', function () { + it('supports E2B git auth, branches, reset, restore, and safe remote cleanup', function () { + const commandsSeen = []; + const git = new qiniu.sandbox.Git({ + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + if (cmd.indexOf('branch ') >= 0 && cmd.indexOf('%(refname:short)') >= 0) { + return Promise.resolve({ stdout: '* main\n feature\n', exitCode: 0 }); + } + if (cmd.indexOf('remote get-url') >= 0) { + return Promise.resolve({ stdout: 'https://github.com/acme/repo.git\n', exitCode: 0 }); + } + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + }); + + return git.clone('https://github.com/acme/repo.git', '/repo', { + username: 'u', + password: 'p', + depth: 1, + branch: 'main' + }).then(() => git.branches('/repo')) + .then(branches => { + branches.should.eql([ + { name: 'main', current: true }, + { name: 'feature', current: false } + ]); + return git.reset('/repo', { hard: true, ref: 'HEAD~1' }); + }) + .then(() => git.restore('/repo', { staged: true, paths: ['a.txt'] })) + .then(() => git.reset('/repo', { ref: 'HEAD', paths: 'single.txt' })) + .then(() => git.restore('/repo', { files: 'restore.txt' })) + .then(() => git.remoteAdd('/repo', 'origin', 'https://github.com/acme/repo.git', { overwrite: true, fetch: true })) + .then(() => git.commit('/repo', 'msg', { + authorName: 'Alice', + authorEmail: 'alice@example.com', + allowEmpty: true + })) + .then(() => git.setConfig('/repo', 'user.name', 'Alice', { scope: 'global' })) + .then(() => { + const commandText = commandsSeen.map(item => item.cmd).join('\n'); + commandText.should.containEql('credential.helper='); + commandText.should.containEql('clone \'https://github.com/acme/repo.git\''); + commandText.should.not.containEql('u:p'); + commandsSeen[0].opts.envs.should.eql({ + GIT_USERNAME: 'u', + GIT_PASSWORD: 'p' + }); + commandText.should.containEql('branch \'--format=%(HEAD) %(refname:short)\''); + commandText.should.containEql('reset --hard \'HEAD~1\''); + commandText.should.containEql('restore --staged -- \'a.txt\''); + commandText.should.containEql('reset \'HEAD\' -- \'single.txt\''); + commandText.should.containEql('restore --worktree -- \'restore.txt\''); + commandText.should.containEql('remote remove \'origin\''); + commandText.should.containEql('remote add \'origin\''); + commandText.should.containEql('fetch \'origin\''); + commandText.should.containEql('commit -m \'msg\' --author \'Alice \' --allow-empty'); + commandText.should.containEql('config --global \'user.name\' \'Alice\''); + }); + }); + + it('returns git remote add result when fetch is not requested', function () { + const git = new qiniu.sandbox.Git({ + run: function () { + return Promise.resolve({ + stdout: 'added', + stderr: '', + exitCode: 0 + }); + } + }); + + return git.remoteAdd('/repo', 'origin', 'https://github.com/acme/repo.git').then(result => { + result.should.eql({ + stdout: 'added', + stderr: '', + exitCode: 0 + }); + }); + }); + + it('does not fetch after git remote add fails', function () { + const commandsSeen = []; + const git = new qiniu.sandbox.Git({ + run: function (cmd) { + commandsSeen.push(cmd); + if (cmd.indexOf(' remote add ') >= 0) { + return Promise.resolve({ stdout: '', stderr: 'bad remote', exitCode: 1 }); + } + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + }); + + return git.remoteAdd('/repo', 'origin', 'bad-url', { fetch: true }).then(result => { + result.exitCode.should.eql(1); + commandsSeen.should.eql([ + 'git remote add \'origin\' \'bad-url\'' + ]); + }); + }); + + it('passes git push credentials through a helper when push fails', function () { + const commandsSeen = []; + const git = new qiniu.sandbox.Git({ + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + if (cmd.indexOf(' push ') >= 0) { + return Promise.reject(new Error('push failed')); + } + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + }); + + return git.push('/repo', { + username: 'u', + password: 'p', + remote: 'origin', + branch: 'main' + }).then(() => { + throw new Error('expected git push to fail'); + }, err => { + err.message.should.eql('push failed'); + commandsSeen.length.should.eql(1); + commandsSeen[0].cmd.should.containEql('credential.helper='); + commandsSeen[0].cmd.should.containEql('printf "username=%s\\npassword=%s\\n" "$GIT_USERNAME" "$GIT_PASSWORD"'); + commandsSeen[0].cmd.should.containEql('push \'origin\' \'main\''); + commandsSeen[0].cmd.should.not.containEql('u:p'); + commandsSeen[0].opts.envs.should.eql({ + GIT_USERNAME: 'u', + GIT_PASSWORD: 'p' + }); + }); + }); + + it('passes git pull credentials through a helper without rewriting remotes', function () { + const commandsSeen = []; + const git = new qiniu.sandbox.Git({ + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + }); + + return git.pull('/repo', { + username: 'u', + password: 'p', + remote: 'origin', + branch: 'main' + }).then(() => { + commandsSeen.length.should.eql(1); + commandsSeen[0].cmd.should.containEql('credential.helper='); + commandsSeen[0].cmd.should.containEql('pull \'origin\' \'main\''); + commandsSeen[0].cmd.should.not.containEql('u:p'); + commandsSeen[0].opts.envs.should.eql({ + GIT_USERNAME: 'u', + GIT_PASSWORD: 'p' + }); + }); + }); + + it('passes git clone credentials through a helper instead of the command line', function () { + const commandsSeen = []; + const git = new qiniu.sandbox.Git({ + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + }); + + return git.clone('https://github.com/acme/private.git', { + username: 'u', + password: 'p' + }).then(() => { + commandsSeen.length.should.eql(1); + commandsSeen[0].cmd.should.containEql('credential.helper='); + commandsSeen[0].cmd.should.containEql('clone \'https://github.com/acme/private.git\''); + commandsSeen[0].cmd.should.not.containEql('u:p'); + commandsSeen[0].opts.envs.should.eql({ + GIT_USERNAME: 'u', + GIT_PASSWORD: 'p' + }); + }); + }); + + it('keeps embedded git clone credentials when no helper credentials are provided', function () { + const commandsSeen = []; + const git = new qiniu.sandbox.Git({ + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + }); + + return git.clone('https://u:p@github.com/acme/private.git').then(() => { + commandsSeen.length.should.eql(1); + commandsSeen[0].cmd.should.containEql('clone \'https://u:p@github.com/acme/private.git\''); + should.not.exist(commandsSeen[0].opts.envs); + }); + }); + + it('passes http git clone credentials through a helper instead of the command line', function () { + const commandsSeen = []; + const git = new qiniu.sandbox.Git({ + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + }); + + return git.clone('http://git.example.com/acme/private.git', { + username: 'u', + password: 'p' + }).then(() => { + commandsSeen.length.should.eql(1); + commandsSeen[0].cmd.should.containEql('credential.helper='); + commandsSeen[0].cmd.should.containEql('clone \'http://git.example.com/acme/private.git\''); + commandsSeen[0].cmd.should.not.containEql('u:p'); + commandsSeen[0].opts.envs.should.eql({ + GIT_USERNAME: 'u', + GIT_PASSWORD: 'p' + }); + }); + }); + + it('rejects unsafe git reset modes', function () { + const git = new qiniu.sandbox.Git({ + run: function () { + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + }); + + try { + git.reset('/repo', { + mode: 'hard; touch /tmp/pwned' + }); + throw new Error('expected git reset to reject unsafe mode'); + } catch (err) { + err.name.should.eql('InvalidArgumentError'); + err.message.should.match(/Invalid git reset mode/); + } + + try { + git.reset('/repo', { + mode: 'hard', + paths: ['a.txt'] + }); + throw new Error('expected git reset to reject mode with paths'); + } catch (err) { + err.name.should.eql('InvalidArgumentError'); + err.message.should.match(/mode cannot be used when paths are specified/); + } + + try { + git.restore('/repo'); + throw new Error('expected git restore to reject missing paths'); + } catch (err) { + err.name.should.eql('InvalidArgumentError'); + err.message.should.match(/At least one path/); + } + }); + + it('surfaces git auth and validation errors on auth helpers', function () { + const git = new qiniu.sandbox.Git({ + run: function () { + return Promise.resolve({ stdout: '', stderr: '', exitCode: 1 }); + } + }); + + try { + git.push('/repo', { + username: 'u' + }); + throw new Error('expected missing password'); + } catch (err) { + err.name.should.eql('GitAuthError'); + } + + return git.dangerouslyAuthenticate('/repo', 'origin', 'u', 'p').then(() => { + throw new Error('expected missing upstream'); + }, err => { + err.name.should.eql('GitUpstreamError'); + return git.commit('/repo', 'msg', { + authorName: 'Alice' + }); + }).then(() => { + throw new Error('expected missing author email'); + }, err => { + err.name.should.eql('GitAuthError'); + }); + }); + + it('replaces existing git remote credentials when dangerously authenticating', function () { + const commandsSeen = []; + const git = new qiniu.sandbox.Git({ + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + if (cmd.indexOf('remote get-url') >= 0) { + return Promise.resolve({ + stdout: 'https://old:secret@example.com/acme/repo.git\n', + stderr: '', + exitCode: 0 + }); + } + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + }); + + return git.dangerouslyAuthenticate('/repo', 'origin', 'new user', 'new/pass').then(() => { + commandsSeen[1].cmd.should.eql('git remote set-url \'origin\' \'https://new%20user:new%2Fpass@example.com/acme/repo.git\''); + commandsSeen[1].cmd.should.not.containEql('old:secret'); + }); + }); + + it('replaces token-only git remote credentials when dangerously authenticating', function () { + const commandsSeen = []; + const git = new qiniu.sandbox.Git({ + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + if (cmd.indexOf('remote get-url') >= 0) { + return Promise.resolve({ + stdout: 'https://old-token@example.com/acme/repo.git\n', + stderr: '', + exitCode: 0 + }); + } + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + }); + + return git.dangerouslyAuthenticate('/repo', 'origin', 'new user', 'new/pass').then(() => { + commandsSeen[1].cmd.should.eql('git remote set-url \'origin\' \'https://new%20user:new%2Fpass@example.com/acme/repo.git\''); + commandsSeen[1].cmd.should.not.containEql('old-token@'); + }); + }); + + it('keeps original git push error without credential cleanup', function () { + const commandsSeen = []; + const git = new qiniu.sandbox.Git({ + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + if (cmd.indexOf(' push ') >= 0) { + return Promise.reject(new Error('push failed')); + } + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + }); + + return git.push('/repo', { + username: 'u', + password: 'p', + remote: 'origin', + branch: 'main' + }).then(() => { + throw new Error('expected git push to fail'); + }, err => { + err.message.should.eql('push failed'); + commandsSeen.length.should.eql(1); + commandsSeen[0].cmd.should.containEql('credential.helper='); + commandsSeen[0].cmd.should.containEql('push \'origin\' \'main\''); + commandsSeen[0].cmd.should.not.containEql('remote set-url'); + }); + }); + + it('normalizes git config helpers when options are omitted', function () { + const commandsSeen = []; + let missingConfig = false; + const git = new qiniu.sandbox.Git({ + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + if (missingConfig && cmd.indexOf(' config --get ') >= 0) { + return Promise.resolve({ stdout: '', stderr: '', exitCode: 1 }); + } + return Promise.resolve({ stdout: 'Alice\n', stderr: '', exitCode: 0 }); + } + }); + + return git.setConfig('user.name', 'Alice') + .then(() => git.getConfig('user.name')) + .then(value => { + value.should.eql('Alice'); + return git.configureUser('Alice', 'alice@example.com'); + }) + .then(() => { + missingConfig = true; + return git.getConfig('missing.name'); + }) + .then(value => { + should.not.exist(value); + }) + .then(() => { + const shellQuote = require('../qiniu/sandbox/util').shellQuote; + commandsSeen.map(item => item.cmd).should.eql([ + 'git config ' + shellQuote('user.name') + ' ' + shellQuote('Alice'), + 'git config --get ' + shellQuote('user.name'), + 'git config ' + shellQuote('user.name') + ' ' + shellQuote('Alice'), + 'git config ' + shellQuote('user.email') + ' ' + shellQuote('alice@example.com'), + 'git config --get ' + shellQuote('missing.name') + ]); + commandsSeen.forEach(item => { + should.not.exist(item.opts.cwd); + }); + }); + }); + + it('supports E2B style git option signatures for config and restore helpers', function () { + const commandsSeen = []; + const git = new qiniu.sandbox.Git({ + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + return Promise.resolve({ stdout: 'Alice\n', stderr: '', exitCode: 0 }); + } + }); + + return git.setConfig('user.name', 'Alice', { + path: '/repo', + scope: 'local' + }).then(() => git.getConfig('user.name', { + path: '/repo', + scope: 'local' + })).then(value => { + value.should.eql('Alice'); + return git.configureUser('Alice', 'alice@example.com', { + path: '/repo', + scope: 'local' + }); + }).then(() => git.reset('/repo', { + mode: 'hard', + target: 'HEAD~1' + })).then(() => git.restore('/repo', { + paths: ['a.txt'] + })).then(() => { + commandsSeen.map(item => item.cmd).should.eql([ + 'git config --local \'user.name\' \'Alice\'', + 'git config --local --get \'user.name\'', + 'git config --local \'user.name\' \'Alice\'', + 'git config --local \'user.email\' \'alice@example.com\'', + 'git reset --hard \'HEAD~1\'', + 'git restore --worktree -- \'a.txt\'' + ]); + commandsSeen.every(item => item.opts.cwd === '/repo').should.eql(true); + }); + }); + + it('keeps legacy git configureUser repo path when delegating to config helpers', function () { + const commandsSeen = []; + const git = new qiniu.sandbox.Git({ + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + }); + + return git.configureUser('/repo', 'Alice', 'alice@example.com', { + config: { + 'http.version': 'HTTP/1.1' + } + }).then(result => { + result.exitCode.should.eql(0); + commandsSeen.map(item => item.cmd).should.eql([ + 'git -c \'http.version=HTTP/1.1\' config \'user.name\' \'Alice\'', + 'git -c \'http.version=HTTP/1.1\' config \'user.email\' \'alice@example.com\'' + ]); + commandsSeen.every(item => item.opts.cwd === '/repo').should.eql(true); + }); + }); +}); diff --git a/test/sandbox_helpers.js b/test/sandbox_helpers.js new file mode 100644 index 00000000..8f404450 --- /dev/null +++ b/test/sandbox_helpers.js @@ -0,0 +1,157 @@ +const should = require('should'); +const http = require('http'); +const fs = require('fs'); +const stream = require('stream'); +const legacyUrl = require('url'); +const zlib = require('zlib'); + +const qiniu = require('../index'); + +function startServer (handler) { + const requests = []; + const server = http.createServer((req, res) => { + const chunks = []; + req.on('data', chunk => { + chunks.push(chunk); + }); + req.on('end', () => { + const rawBody = Buffer.concat(chunks); + const record = { + method: req.method, + url: req.url, + headers: req.headers, + body: rawBody.toString(), + rawBody + }; + requests.push(record); + handler(record, res); + }); + }); + + return new Promise(resolve => { + server.listen(0, '127.0.0.1', () => { + resolve({ + server, + requests, + endpoint: `http://127.0.0.1:${server.address().port}` + }); + }); + }); +} + +function closeServer (server) { + return new Promise(resolve => server.close(resolve)); +} + +function parseUrl (value) { + const href = /^https?:\/\//.test(value) ? value : 'http://127.0.0.1' + value; + if (typeof URL !== 'undefined') { + return new URL(href); + } + // eslint-disable-next-line node/no-deprecated-api, dot-notation + const parsed = legacyUrl['parse'](href, true); + parsed.searchParams = { + get: function (key) { + const value = parsed.query[key]; + if (typeof value === 'undefined') { + return null; + } + return Array.isArray(value) ? value[0] : value; + } + }; + return parsed; +} + +function decodeConnectEnvelope (body) { + body[0].should.eql(0); + const length = body.readUInt32BE(1); + return JSON.parse(body.slice(5, 5 + length).toString()); +} + +function encodeConnectEnvelope (message) { + const payload = Buffer.from(JSON.stringify(message)); + const header = Buffer.alloc(5); + header[0] = 0; + header.writeUInt32BE(payload.length, 1); + return Buffer.concat([header, payload]); +} + +function encodeRawConnectEnvelope (payload, flags) { + payload = Buffer.from(payload); + const header = Buffer.alloc(5); + header[0] = flags || 0; + header.writeUInt32BE(payload.length, 1); + return Buffer.concat([header, payload]); +} + +function encodeConnectEndEnvelope (message) { + return encodeRawConnectEnvelope(JSON.stringify(message || {}), 2); +} + +function encodeOversizedConnectHeader () { + const header = Buffer.alloc(5); + header[0] = 0; + header.writeUInt32BE(10 * 1024 * 1024 + 1, 1); + return header; +} + +function encodeTruncatedConnectHeader () { + const header = Buffer.alloc(5); + header[0] = 0; + header.writeUInt32BE(20, 1); + return header; +} + +function handleGitAndPty (git, pty, commandsSeen, fixture) { + return git.clone('https://example.com/repo.git', { path: '/repo' }) + .then(() => git.init('/repo')) + .then(() => git.add('/repo', { all: true })) + .then(() => git.commit('/repo', 'msg', { allowEmpty: true })) + .then(() => git.pull('/repo', { remote: 'origin', branch: 'main' })) + .then(() => git.push('/repo', { remote: 'origin', branch: 'main' })) + .then(() => git.createBranch('/repo', 'feature')) + .then(() => git.checkoutBranch('/repo', 'main')) + .then(() => git.deleteBranch('/repo', 'feature', { force: true })) + .then(() => git.remoteAdd('/repo', 'origin', 'https://example.com/repo.git')) + .then(() => git.remoteGet('/repo', 'origin')) + .then(value => { + value.should.eql('value'); + return git.setConfig('/repo', 'user.name', 'Alice'); + }) + .then(() => git.getConfig('/repo', 'user.name')) + .then(value => { + value.should.eql('value'); + return git.configureUser('/repo', 'Alice', 'alice@example.com'); + }) + .then(() => pty.create({ cmd: 'bash', cwd: '/repo' })) + .then(handle => { + handle.pid.should.eql(12); + commandsSeen[0].cmd.should.eql('git clone \'https://example.com/repo.git\' \'/repo\''); + commandsSeen[1].cmd.should.eql('git init'); + commandsSeen[1].opts.cwd.should.eql('/repo'); + commandsSeen.some(item => item.cmd.indexOf('git commit -m') === 0).should.eql(true); + const ptyBody = decodeConnectEnvelope(fixture.requests[6].rawBody); + ptyBody.process.cmd.should.eql('bash'); + ptyBody.process.cwd.should.eql('/repo'); + should.exist(ptyBody.pty); + }); +} + +module.exports = { + should, + http, + fs, + stream, + zlib, + qiniu, + startServer, + closeServer, + parseUrl, + decodeConnectEnvelope, + encodeConnectEnvelope, + encodeRawConnectEnvelope, + encodeConnectEndEnvelope, + encodeOversizedConnectHeader, + encodeTruncatedConnectHeader, + handleGitAndPty +}; diff --git a/test/sandbox_integration.test.js b/test/sandbox_integration.test.js new file mode 100644 index 00000000..7a78b4b8 --- /dev/null +++ b/test/sandbox_integration.test.js @@ -0,0 +1,441 @@ +const fs = require('fs'); +const path = require('path'); +const should = require('should'); + +const qiniu = require('../index'); +const { shellQuote } = require('../qiniu/sandbox/util'); + +function loadDotEnvIfPresent () { + [ + path.join(process.cwd(), '.env') + ].forEach(filepath => { + if (!fs.existsSync(filepath)) { + return; + } + + fs.readFileSync(filepath, 'utf8') + .split(/\r?\n/) + .forEach(line => { + line = line.trim(); + if (!line || line[0] === '#') { + return; + } + const index = line.indexOf('='); + if (index < 0) { + return; + } + const key = line.slice(0, index).trim(); + let value = line.slice(index + 1).trim(); + if ( + (value[0] === '"' && value[value.length - 1] === '"') || + (value[0] === '\'' && value[value.length - 1] === '\'') + ) { + value = value.slice(1, -1); + } + if (process.env[key] === undefined) { + process.env[key] = value; + } + }); + }); +} + +loadDotEnvIfPresent(); + +function integrationLog () { + const args = Array.prototype.slice.call(arguments); + args.unshift('[sandbox integration]'); + console.log.apply(console, args); +} + +function integrationConfig () { + return { + endpoint: process.env.QINIU_SANDBOX_ENDPOINT, + apiKey: process.env.QINIU_SANDBOX_API_KEY, + template: process.env.QINIU_SANDBOX_TEMPLATE || 'base', + accessKey: process.env.QINIU_SANDBOX_ACCESS_KEY, + secretKey: process.env.QINIU_SANDBOX_SECRET_KEY, + kodoBucket: process.env.QINIU_SANDBOX_KODO_BUCKET, + kodoPrefix: process.env.QINIU_SANDBOX_KODO_PREFIX, + kodoMountPath: process.env.QINIU_SANDBOX_KODO_MOUNT_PATH || '/mnt/qiniu-nodejs-sdk-it', + gitRepoUrl: process.env.GIT_REPO_URL, + gitUsername: process.env.GIT_USERNAME, + gitPassword: process.env.GIT_PASSWORD + }; +} + +const config = integrationConfig(); +const describeIntegration = config.apiKey ? describe : describe.skip; + +function authedGitUrl (repoUrl, username, password) { + const parsed = new URL(repoUrl); + parsed.username = username; + parsed.password = password; + return parsed.toString(); +} + +function scrubSecrets (text) { + text = String(text || ''); + [config.gitPassword, config.gitUsername].forEach(secret => { + if (secret) { + text = text.split(secret).join('[redacted]'); + text = text.split(encodeURIComponent(secret)).join('[redacted]'); + } + }); + return text.replace(/https?:\/\/[^@\s]+@/g, 'https://[redacted]@'); +} + +function hasGitCredentials () { + return Boolean(config.gitRepoUrl && config.gitUsername && config.gitPassword); +} + +function hasQiniuCredentials () { + return Boolean(config.accessKey && config.secretKey); +} + +function sandboxClient (opts) { + opts = opts || {}; + return new qiniu.sandbox.SandboxClient(Object.assign({ + endpoint: config.endpoint, + apiKey: config.apiKey + }, opts)); +} + +function exerciseRemoteGitIfConfigured (sandbox, runID) { + if (!hasGitCredentials()) { + integrationLog('skip remote git: set GIT_REPO_URL, GIT_USERNAME, and GIT_PASSWORD to run'); + return Promise.resolve(); + } + + const cloneDir = `/tmp/${runID}-clone`; + const cloneUrl = authedGitUrl(config.gitRepoUrl, config.gitUsername, config.gitPassword); + const branch = `nodejs-sdk-it-${runID}`; + const pushedFile = `${cloneDir}/qiniu-nodejs-sdk-integration-${runID}.txt`; + const gitOptions = { + timeout: 120000, + config: { + 'http.version': 'HTTP/1.1' + } + }; + + integrationLog('cloning configured git repository', cloneDir); + return sandbox.git.clone(cloneUrl, Object.assign({}, gitOptions, { + path: cloneDir, + depth: 1 + })).then(result => { + if (result.exitCode !== 0) { + throw new Error(`git clone failed with exit ${result.exitCode}: ${scrubSecrets(result.stderr || result.stdout)}`); + } + result.exitCode.should.eql(0); + integrationLog('cloned configured git repository', cloneDir); + return sandbox.git.configureUser(cloneDir, 'qiniu-nodejs-sdk', 'qiniu-nodejs-sdk@example.com', gitOptions); + }).then(result => { + result.exitCode.should.eql(0); + return sandbox.git.createBranch(cloneDir, branch, gitOptions); + }).then(result => { + result.exitCode.should.eql(0); + integrationLog('created git branch', branch); + return sandbox.files.write(pushedFile, `sandbox integration ${runID}\n`); + }).then(entry => { + entry.path.should.eql(pushedFile); + return sandbox.git.add(cloneDir, { + files: [path.basename(pushedFile)], + timeout: gitOptions.timeout, + config: gitOptions.config + }); + }).then(result => { + result.exitCode.should.eql(0); + return sandbox.git.commit(cloneDir, `test: sandbox integration ${runID}`, gitOptions); + }).then(result => { + result.exitCode.should.eql(0); + integrationLog('committed git branch', branch); + return sandbox.git.push(cloneDir, Object.assign({}, gitOptions, { + remote: cloneUrl, + branch: `HEAD:refs/heads/${branch}` + })); + }).then(result => { + if (result.exitCode !== 0) { + throw new Error(`git push failed with exit ${result.exitCode}: ${scrubSecrets(result.stderr || result.stdout)}`); + } + result.exitCode.should.eql(0); + integrationLog('pushed git branch', branch); + return sandbox.commands.run(`git -C ${shellQuote(cloneDir)} remote set-url origin ${shellQuote(config.gitRepoUrl)}`); + }).then(result => { + result.exitCode.should.eql(0); + return sandbox.git.remoteGet(cloneDir, 'origin'); + }).then(remoteUrl => { + remoteUrl.should.eql(config.gitRepoUrl); + integrationLog('sanitized git remote url', cloneDir); + return sandbox.git.status(cloneDir); + }).then(status => { + status.raw.should.be.String(); + integrationLog('verified cloned git status', cloneDir); + }); +} + +describeIntegration('sandbox integration', function () { + this.timeout(600000); + + let sandbox; + + after(function () { + if (!sandbox) { + return null; + } + return sandbox.kill() + .catch(err => { + console.log('failed to cleanup sandbox', sandbox.sandboxId, err.message); + }); + }); + + it('creates a sandbox and exercises files, commands, and git', function () { + const client = new qiniu.sandbox.SandboxClient({ + endpoint: config.endpoint, + apiKey: config.apiKey + }); + const runID = `nodejs-sdk-${Date.now()}`; + const workdir = `/tmp/${runID}`; + const filePath = `${workdir}/hello.txt`; + + return qiniu.sandbox.Sandbox.create({ + client, + template: config.template, + timeout: 300, + metadata: { + sdk: 'qiniu-nodejs-sdk', + test: runID + } + }).then(created => { + sandbox = created; + sandbox.sandboxId.should.be.String(); + integrationLog('created sandbox', sandbox.sandboxId); + return sandbox.waitForReady({ + interval: 3000, + timeout: 180000 + }); + }).then(info => { + info.state.should.eql('running'); + integrationLog('sandbox ready', sandbox.sandboxId); + return sandbox.isRunning(); + }).then(running => { + running.should.eql(true); + integrationLog('envd health ok', sandbox.sandboxId); + return sandbox.commands.run(`mkdir -p ${workdir}`); + }).then(result => { + result.exitCode.should.eql(0); + integrationLog('created workdir', workdir); + return sandbox.files.write(filePath, 'hello sandbox'); + }).then(entry => { + entry.path.should.eql(filePath); + integrationLog('wrote file', filePath); + return sandbox.files.readText(filePath); + }).then(text => { + text.should.eql('hello sandbox'); + integrationLog('read file', filePath); + return sandbox.commands.run(`cat ${filePath}`); + }).then(result => { + result.exitCode.should.eql(0); + result.stdout.should.containEql('hello sandbox'); + integrationLog('ran cat command', filePath); + return sandbox.git.init(workdir); + }).then(result => { + result.exitCode.should.eql(0); + integrationLog('initialized git repo', workdir); + return sandbox.git.status(workdir); + }).then(status => { + status.raw.should.be.String(); + status.untrackedFiles.should.containEql('hello.txt'); + sandbox.getHost(8080).should.be.String(); + integrationLog('verified git status', workdir); + return exerciseRemoteGitIfConfigured(sandbox, runID); + }); + }); + + it('creates and deletes an injection rule when AK/SK is configured', function () { + if (!hasQiniuCredentials()) { + this.skip(); + } + + const client = new qiniu.sandbox.SandboxClient({ + endpoint: config.endpoint, + apiKey: config.apiKey, + mac: new qiniu.auth.digest.Mac(config.accessKey, config.secretKey) + }); + const name = `nodejs-sdk-it-${Date.now()}`; + let ruleID; + + return client.createInjectionRule({ + name, + injection: { + type: 'openai', + apiKey: 'test-key' + } + }).then(rule => { + ruleID = rule.id || rule.ruleID || rule.ruleId; + should(ruleID).be.ok(); + return client.getInjectionRule(ruleID); + }).then(rule => { + rule.name.should.eql(name); + return client.updateInjectionRule(ruleID, { + name: name + '-updated' + }); + }).then(rule => { + rule.name.should.eql(name + '-updated'); + return client.deleteInjectionRule(ruleID); + }); + }); + + it('creates a sandbox with an injection rule when AK/SK is configured', function () { + if (!hasQiniuCredentials()) { + this.skip(); + } + + const client = sandboxClient({ + mac: new qiniu.auth.digest.Mac(config.accessKey, config.secretKey) + }); + const name = `nodejs-sdk-create-it-${Date.now()}`; + let ruleID; + let createdSandbox; + + return client.createInjectionRule({ + name, + injection: { + type: 'openai', + apiKey: 'test-key' + } + }).then(rule => { + ruleID = rule.id || rule.ruleID || rule.ruleId; + should(ruleID).be.ok(); + return qiniu.sandbox.Sandbox.create({ + client, + template: config.template, + timeout: 120, + injections: [ + { + type: 'id', + id: ruleID + } + ], + metadata: { + sdk: 'qiniu-nodejs-sdk', + test: 'injection-create' + } + }); + }).then(created => { + createdSandbox = created; + return createdSandbox.waitForReady({ + interval: 3000, + timeout: 180000 + }); + }).then(info => { + info.state.should.eql('running'); + integrationLog('created sandbox with injection rule', createdSandbox.sandboxId); + }).finally(() => { + const cleanup = []; + if (createdSandbox) { + cleanup.push(createdSandbox.kill().catch(() => null)); + } + if (ruleID) { + cleanup.push(client.deleteInjectionRule(ruleID).catch(() => null)); + } + return Promise.all(cleanup); + }); + }); + + it('creates a sandbox with a Kodo resource mount when bucket is configured', function () { + if (!hasQiniuCredentials() || !config.kodoBucket) { + this.skip(); + } + + const client = new qiniu.sandbox.SandboxClient({ + endpoint: config.endpoint, + mac: new qiniu.auth.digest.Mac(config.accessKey, config.secretKey) + }); + const runID = `nodejs-sdk-kodo-${Date.now()}`; + let kodoSandbox; + const resource = { + type: 'kodo', + bucket: config.kodoBucket, + mount_path: config.kodoMountPath + }; + if (config.kodoPrefix) { + resource.prefix = config.kodoPrefix; + } + + return qiniu.sandbox.Sandbox.create({ + client, + template: config.template, + timeout: 300, + resources: [resource], + metadata: { + sdk: 'qiniu-nodejs-sdk', + test: runID + } + }).then(created => { + kodoSandbox = created; + return kodoSandbox.waitForReady({ + interval: 3000, + timeout: 240000 + }); + }).then(info => { + info.state.should.eql('running'); + return kodoSandbox.commands.run(`test -d ${shellQuote(config.kodoMountPath)}`); + }).then(result => { + result.exitCode.should.eql(0); + const filePath = path.posix.join(config.kodoMountPath, `${runID}.txt`); + return kodoSandbox.commands.run(`sh -c "echo ${shellQuote(runID)} > ${shellQuote(filePath)} && cat ${shellQuote(filePath)}"`) + .then(writeResult => { + writeResult.exitCode.should.eql(0); + writeResult.stdout.should.containEql(runID); + return kodoSandbox.commands.run(`rm -f ${shellQuote(filePath)}`); + }) + .then(removeResult => { + removeResult.exitCode.should.eql(0); + }); + }).finally(() => { + if (kodoSandbox) { + return kodoSandbox.kill().catch(() => null); + } + return null; + }); + }); + + it('creates, inspects, and deletes a template', function () { + const client = sandboxClient(); + const name = `nodejs-sdk-it-${Date.now()}`; + let templateID; + let buildID; + const template = qiniu.sandbox.Template() + .fromImage('ubuntu:22.04') + .runCmd('echo qiniu-nodejs-sdk'); + + return template.build({ + client, + name, + tags: ['nodejs-sdk-it'] + }).then(result => { + templateID = result.templateID || result.templateId || result.id || name; + buildID = result.buildID || result.buildId; + should(templateID).be.ok(); + if (!buildID) { + return result; + } + return client.getTemplateBuildStatus(templateID, buildID) + .then(status => { + should(status).be.ok(); + return client.getTemplateBuildLogs(templateID, buildID, { limit: 5 }); + }); + }).then(result => { + should(result).be.ok(); + integrationLog('template integration completed', templateID, buildID); + }).finally(() => { + if (templateID) { + return client.deleteTemplate(templateID).catch(() => null); + } + return null; + }); + }); +}); + +if (!config.apiKey) { + console.log('skip sandbox integration: set QINIU_SANDBOX_API_KEY to run'); +} diff --git a/test/sandbox_pty.test.js b/test/sandbox_pty.test.js new file mode 100644 index 00000000..b560467c --- /dev/null +++ b/test/sandbox_pty.test.js @@ -0,0 +1,494 @@ +const { + should, + qiniu, + startServer, + closeServer, + decodeConnectEnvelope, + encodeConnectEnvelope, + encodeRawConnectEnvelope, + encodeConnectEndEnvelope, + encodeOversizedConnectHeader, + encodeTruncatedConnectHeader +} = require('./sandbox_helpers'); + +describe('test sandbox pty module', function () { + it('supports E2B style PTY connect, input, resize, and kill operations', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start' || req.url === '/process.Process/Connect') { + const body = decodeConnectEnvelope(req.rawBody); + if (req.url === '/process.Process/Start') { + body.pty.size.should.eql({ cols: 80, rows: 24 }); + body.process.envs.TERM.should.eql('xterm-256color'); + } else { + body.process.selector.pid.should.eql(44); + } + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 44 } } }), + encodeConnectEnvelope({ event: { data: { pty: Buffer.from('ok').toString('base64') } } }), + encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) + ])); + return; + } + if (req.url === '/process.Process/SendInput' || req.url === '/process.Process/Update' || req.url === '/process.Process/SendSignal') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end('{}'); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_pty', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + const data = []; + + return sandbox.pty.create({ + cols: 80, + rows: 24, + user: 'root', + requestTimeoutMs: 1000, + onData: chunk => data.push(Buffer.from(chunk).toString()) + }).then(handle => { + handle.pid.should.eql(44); + return handle.kill().then(killed => { + killed.should.eql(true); + fixture.requests[1].headers.authorization.should.eql('Basic ' + Buffer.from('root:').toString('base64')); + return handle.wait(); + }); + }).then(() => sandbox.pty.connect(44, { + onData: chunk => data.push(Buffer.from(chunk).toString()) + })).then(handle => handle.wait()) + .then(() => sandbox.pty.sendInput(44, 123)) + .then(() => sandbox.pty.resize(44, { cols: 100, rows: 30 })) + .then(() => sandbox.pty.kill(44)) + .then(killed => { + killed.should.eql(true); + data.should.eql(['ok', 'ok']); + const sendBody = JSON.parse(fixture.requests[3].body); + sendBody.input.pty.should.eql(Buffer.from('123').toString('base64')); + const resizeBody = JSON.parse(fixture.requests[4].body); + resizeBody.pty.size.should.eql({ cols: 100, rows: 30 }); + const killBody = JSON.parse(fixture.requests[5].body); + killBody.signal.should.eql('SIGNAL_SIGKILL'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('rejects malformed live PTY stream payloads', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(encodeRawConnectEnvelope('not-json')); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_pty_bad_json', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.pty.create({ + cols: 80, + rows: 24 + }).then(() => { + throw new Error('expected pty stream parse error'); + }, err => { + err.message.should.match(/Unexpected token/); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('uses live PTY creation for default and args-based create calls', function () { + let starts = 0; + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + starts += 1; + const body = decodeConnectEnvelope(req.rawBody); + if (starts === 1) { + body.process.cmd.should.eql('/bin/bash'); + body.process.args.should.eql(['-i', '-l']); + } else { + body.process.cmd.should.eql('node'); + body.process.args.should.eql(['-i']); + } + should.exist(body.pty); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 48 + starts } } }), + encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_pty_args', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.pty.create() + .then(handle => handle.wait()) + .then(() => sandbox.pty.create({ cmd: 'node', args: ['-i'] })) + .then(handle => handle.wait()) + .then(result => { + result.exitCode.should.eql(0); + starts.should.eql(2); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('rejects PTY wait when Connect end-stream carries an error', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 45 } } }), + encodeConnectEndEnvelope({ error: { code: 'internal', message: 'pty failed' } }) + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_pty_trailer', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.pty.create({ + cols: 80, + rows: 24 + }).then(handle => { + handle.pid.should.eql(45); + return handle.wait(); + }).then(() => { + throw new Error('expected pty wait to reject'); + }, err => { + err.message.should.eql('pty failed'); + err.code.should.eql('internal'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('rejects PTY wait when the live Connect stream ends with a partial frame after start', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 46 } } }), + encodeTruncatedConnectHeader() + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_pty_truncated_tail', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.pty.create({ + cols: 80, + rows: 24 + }).then(handle => { + handle.pid.should.eql(46); + return handle.wait(); + }).then(() => { + throw new Error('expected pty wait to reject'); + }, err => { + err.message.should.eql('Sandbox envd stream truncated unexpectedly'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('rejects PTY wait when the live Connect stream ends before process end', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(encodeConnectEnvelope({ event: { start: { pid: 47 } } })); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_pty_missing_end', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.pty.create({ + cols: 80, + rows: 24 + }).then(handle => { + handle.pid.should.eql(47); + return handle.wait(); + }).then(() => { + throw new Error('expected pty wait to reject'); + }, err => { + err.message.should.eql('PTY stream ended before process end'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('does not reject PTY wait after disconnecting the live stream', function () { + let ptyResponse; + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + ptyResponse = res; + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.write(encodeConnectEnvelope({ event: { start: { pid: 48 } } })); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_pty_disconnect', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.pty.create({ + cols: 80, + rows: 24 + }).then(handle => { + return handle.disconnect() + .then(() => handle.wait()) + .then(result => { + result.exitCode.should.eql(0); + }); + }).then(() => { + if (ptyResponse) { + ptyResponse.end(); + } + return closeServer(fixture.server); + }, err => { + if (ptyResponse) { + ptyResponse.end(); + } + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('rejects oversized PTY stream envelopes', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(encodeOversizedConnectHeader()); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_pty_huge', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.pty.create({ + cols: 80, + rows: 24 + }).then(() => { + throw new Error('expected pty stream to reject oversized frame'); + }, err => { + err.message.should.containEql('envelope too large'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('rejects live PTY start when the process stream does not start before timeout', function () { + let ptyResponse; + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + ptyResponse = res; + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_pty_timeout', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.pty.create({ + cols: 80, + rows: 24, + requestTimeoutMs: 5 + }).then(() => { + throw new Error('expected pty start to time out'); + }, err => { + err.message.should.eql('PTY stream start timed out'); + if (ptyResponse) { + ptyResponse.end(); + } + }).then(() => closeServer(fixture.server), err => { + if (ptyResponse) { + ptyResponse.end(); + } + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('treats PTY timeout alias as seconds while waiting for stream start', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + setTimeout(() => { + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 47 } } }), + encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) + ])); + }, 20); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_pty_timeout_seconds', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.pty.create({ + cols: 80, + rows: 24, + timeout: 1 + }).then(handle => { + handle.pid.should.eql(47); + return handle.wait(); + }).then(result => { + result.exitCode.should.eql(0); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('returns false for PTY kill 404 responses and rethrows other failures', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/SendSignal') { + const body = JSON.parse(req.body); + if (body.process.selector.pid === 404) { + res.statusCode = 404; + res.end('missing'); + return; + } + res.statusCode = 500; + res.end('boom'); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_pty_kill', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.pty.kill(404) + .then(killed => { + killed.should.eql(false); + return sandbox.pty.kill(500); + }) + .then(() => { + throw new Error('expected non-404 PTY kill to reject'); + }, err => { + err.name.should.eql('SandboxError'); + err.message.should.containEql('status 500'); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); +}); diff --git a/test/sandbox_template.test.js b/test/sandbox_template.test.js new file mode 100644 index 00000000..2c5b1560 --- /dev/null +++ b/test/sandbox_template.test.js @@ -0,0 +1,551 @@ +const { + should, + fs, + qiniu, + startServer, + closeServer +} = require('./sandbox_helpers'); + +describe('test sandbox template module', function () { + it('maps template, build, tag, and access-token APIs', function () { + return startServer((req, res) => { + res.setHeader('Content-Type', 'application/json'); + if (req.method === 'GET' && req.url === '/templates?teamID=team') { + res.statusCode = 200; + res.end(JSON.stringify([{ templateID: 'tpl_1' }])); + return; + } + if (req.method === 'GET' && req.url === '/default-templates') { + res.statusCode = 200; + res.end(JSON.stringify([{ templateID: 'base' }])); + return; + } + if (req.method === 'POST' && req.url === '/v3/templates') { + res.statusCode = 202; + res.end(JSON.stringify({ templateID: 'tpl_1', buildID: 'b1' })); + return; + } + if (req.method === 'POST' && req.url === '/v2/templates') { + res.statusCode = 202; + res.end(JSON.stringify({ templateID: 'tpl_2', buildID: 'b2' })); + return; + } + if (req.method === 'GET' && req.url === '/templates/tpl_1?limit=5&nextToken=n2') { + res.statusCode = 200; + res.end(JSON.stringify({ templateID: 'tpl_1' })); + return; + } + if (req.method === 'PATCH' && req.url === '/templates/tpl_1') { + res.statusCode = 200; + res.end(JSON.stringify({ ok: true })); + return; + } + if (req.method === 'DELETE' && req.url === '/templates/tpl_1') { + res.statusCode = 204; + res.end(); + return; + } + if (req.method === 'GET' && req.url === '/templates/tpl_1/files/hash') { + res.statusCode = 201; + res.end(JSON.stringify({ url: 'https://upload' })); + return; + } + if (req.method === 'POST' && req.url === '/v2/templates/tpl_1/builds/b1') { + res.statusCode = 202; + res.end(); + return; + } + if (req.method === 'GET' && req.url === '/templates/tpl_1/builds/b1/status?logsOffset=1&limit=2&level=info') { + res.statusCode = 200; + res.end(JSON.stringify({ status: 'ready' })); + return; + } + if (req.method === 'GET' && req.url === '/templates/tpl_1/builds/b1/logs?cursor=1&limit=2&direction=asc&level=info&source=builder') { + res.statusCode = 200; + res.end(JSON.stringify({ logs: [] })); + return; + } + if (req.method === 'POST' && req.url === '/templates/tags') { + res.statusCode = 201; + res.end(JSON.stringify({ tags: ['v1'] })); + return; + } + if (req.method === 'DELETE' && req.url === '/templates/tags') { + res.statusCode = 204; + res.end(); + return; + } + if (req.method === 'GET' && req.url === '/templates/aliases/base') { + res.statusCode = 200; + res.end(JSON.stringify({ exists: true })); + return; + } + if (req.method === 'POST' && req.url === '/templates/tpl_1') { + res.statusCode = 202; + res.end(JSON.stringify({ templateID: 'tpl_1', buildID: 'b3' })); + return; + } + res.statusCode = 404; + res.end(JSON.stringify({ message: req.method + ' ' + req.url })); + }).then(fixture => { + const client = new qiniu.sandbox.SandboxClient({ + apiKey: 'sandbox-key', + accessToken: 'access-token', + endpoint: fixture.endpoint + }); + + return client.listTemplates({ teamID: 'team' }) + .then(() => client.listDefaultTemplates()) + .then(() => client.createTemplate({ name: 'node:v1' })) + .then(() => client.createTemplateV2({ alias: 'old' })) + .then(() => client.getTemplate('tpl_1', { limit: 5, nextToken: 'n2' })) + .then(() => client.updateTemplate('tpl_1', { public: true })) + .then(() => client.deleteTemplate('tpl_1')) + .then(() => client.getTemplateFiles('tpl_1', 'hash')) + .then(() => client.startTemplateBuild('tpl_1', 'b1', { cpuCount: 2 })) + .then(() => client.getTemplateBuildStatus('tpl_1', 'b1', { logsOffset: 1, limit: 2, level: 'info' })) + .then(() => client.getTemplateBuildLogs('tpl_1', 'b1', { + cursor: 1, + limit: 2, + direction: 'asc', + level: 'info', + source: 'builder' + })) + .then(() => client.assignTemplateTags({ templateID: 'tpl_1', tags: ['v1'] })) + .then(() => client.deleteTemplateTags({ templateID: 'tpl_1', tags: ['v1'] })) + .then(() => client.getTemplateByAlias('base')) + .then(() => client.rebuildTemplate('tpl_1', { name: 'again' })) + .then(() => { + JSON.parse(fixture.requests[2].body).should.eql({ name: 'node:v1' }); + JSON.parse(fixture.requests[5].body).should.eql({ public: true }); + fixture.requests[14].headers.authorization.should.eql('Bearer access-token'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('builds templates through an E2B style Template facade', function () { + return startServer((req, res) => { + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + templateID: 'tpl_1', + buildID: 'bld_1', + status: 'building' + })); + }).then(fixture => { + const template = qiniu.sandbox.Template() + .fromImage('ubuntu:22.04') + .aptInstall(['git']) + .runCmd('node --version') + .copy('/src', '/app') + .setStartCmd('node server.js') + .setReadyCmd('curl -f http://localhost:3000/health'); + + return template.build({ + apiKey: 'sandbox-key', + accessKey: 'ak', + secretKey: 'sk', + macOptions: { + disableQiniuTimestampSignature: true + }, + endpoint: fixture.endpoint, + timeout: 1000, + requestTimeoutMs: 1000, + name: 'node-template:test' + }).then(result => { + result.templateID.should.eql('tpl_1'); + const body = JSON.parse(fixture.requests[0].body); + body.name.should.eql('node-template:test'); + should.not.exist(body.apiKey); + should.not.exist(body.accessKey); + should.not.exist(body.secretKey); + should.not.exist(body.macOptions); + should.not.exist(body.timeout); + should.not.exist(body.requestTimeoutMs); + body.buildConfig.fromImage.should.eql('ubuntu:22.04'); + body.buildConfig.steps.should.eql([ + { type: 'apt', packages: ['git'] }, + { type: 'run', cmd: 'node --version' }, + { type: 'copy', src: '/src', dest: '/app' } + ]); + body.buildConfig.startCmd.should.eql('node server.js'); + body.buildConfig.readyCmd.should.eql('curl -f http://localhost:3000/health'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('supports Template.fromTemplate in builder payloads', function () { + return startServer((req, res) => { + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ templateID: 'tpl_child', buildID: 'bld_child' })); + }).then(fixture => { + return qiniu.sandbox.Template() + .fromTemplate('base-template') + .runCmd('echo child') + .build({ + apiKey: 'sandbox-key', + endpoint: fixture.endpoint, + name: 'child-template:test' + }).then(() => { + const body = JSON.parse(fixture.requests[0].body); + body.buildConfig.fromTemplate.should.eql('base-template'); + body.buildConfig.steps[0].cmd.should.eql('echo child'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('supports E2B style Template filesystem, env, package, and git helpers', function () { + return startServer((req, res) => { + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ templateID: 'tpl_helpers', buildID: 'bld_helpers' })); + }).then(fixture => { + return qiniu.sandbox.Template() + .fromImage('ubuntu:22.04') + .copyItems([ + { src: 'app.js', dest: '/app/', user: 'root', mode: 0o755 }, + { src: ['package.json', 'package-lock.json'], dest: '/app/' } + ]) + .remove(['/tmp/cache dir', '/tmp/old'], { recursive: true, force: true, user: 'root' }) + .rename('/tmp/a file', '/tmp/b file', { force: true }) + .makeDir(['/app/data dir', '/app/logs'], { mode: 0o755 }) + .makeSymlink('/usr/bin/node', '/usr/local/bin/node link', { force: true, user: 'root' }) + .setWorkdir('/app') + .setUser('node') + .setEnvs({ NODE_ENV: 'production', PORT: '8080' }) + .pipInstall(['numpy', 'pandas'], { g: false }) + .pipInstall({ g: false }) + .pipInstall([], { g: false }) + .npmInstall('typescript', { dev: true }) + .npmInstall('tsx', { g: true }) + .npmInstall({ dev: true }) + .bunInstall(['elysia'], { dev: true }) + .bunInstall({ dev: true }) + .bunInstall([]) + .bunInstall(undefined, { g: true }) + .aptInstall(['curl'], { noInstallRecommends: true, fixMissing: true }) + .gitClone('https://github.com/qiniu/nodejs-sdk.git', '/src/sdk dir', { + branch: 'sandbox', + depth: 1, + user: 'root' + }) + .gitClone('https://github.com/qiniu/nodejs-sdk.git', { + branch: 'sandbox', + depth: 1 + }) + .runCmd(['echo one', 'echo two'], { user: 'root' }) + .build({ + apiKey: 'sandbox-key', + endpoint: fixture.endpoint, + name: 'helper-template:test' + }).then(() => { + const body = JSON.parse(fixture.requests[0].body); + body.buildConfig.steps.should.eql([ + { type: 'COPY', args: ['app.js', '/app/', 'root', '0755'] }, + { type: 'COPY', args: ['package.json', '/app/'] }, + { type: 'COPY', args: ['package-lock.json', '/app/'] }, + { type: 'RUN', args: ['rm -r -f \'/tmp/cache dir\' \'/tmp/old\'', 'root'] }, + { type: 'RUN', args: ['mv -f \'/tmp/a file\' \'/tmp/b file\''] }, + { type: 'RUN', args: ['mkdir -p -m 0755 \'/app/data dir\' \'/app/logs\''] }, + { type: 'RUN', args: ['ln -s -f \'/usr/bin/node\' \'/usr/local/bin/node link\'', 'root'] }, + { type: 'WORKDIR', args: ['/app'] }, + { type: 'USER', args: ['node'] }, + { type: 'ENV', args: ['NODE_ENV', 'production', 'PORT', '8080'] }, + { type: 'RUN', args: ['pip install --user \'numpy\' \'pandas\''] }, + { type: 'RUN', args: ['pip install --user .'] }, + { type: 'RUN', args: ['pip install --user .'] }, + { type: 'RUN', args: ['npm install --save-dev \'typescript\''] }, + { type: 'RUN', args: ['npm install -g \'tsx\'', 'root'] }, + { type: 'RUN', args: ['npm install --save-dev'] }, + { type: 'RUN', args: ['bun add --dev \'elysia\''] }, + { type: 'RUN', args: ['bun install'] }, + { type: 'RUN', args: ['bun install'] }, + { type: 'RUN', args: ['bun install', 'root'] }, + { type: 'RUN', args: ['apt-get update && DEBIAN_FRONTEND=noninteractive DEBCONF_NOWARNINGS=yes apt-get install -y --no-install-recommends --fix-missing \'curl\'', 'root'] }, + { type: 'RUN', args: ['git clone \'https://github.com/qiniu/nodejs-sdk.git\' --branch \'sandbox\' --single-branch --depth \'1\' \'/src/sdk dir\'', 'root'] }, + { type: 'RUN', args: ['git clone \'https://github.com/qiniu/nodejs-sdk.git\' --branch \'sandbox\' --single-branch --depth \'1\''] }, + { type: 'RUN', args: ['echo one && echo two', 'root'] } + ]); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('supports Template registry, Dockerfile, and skipCache helpers', function () { + return startServer((req, res) => { + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ templateID: 'tpl_dockerfile', buildID: 'bld_dockerfile' })); + }).then(fixture => { + return qiniu.sandbox.Template() + .skipCache() + .fromImage('registry.example.com/private/app:latest', { + username: 'alice', + password: 'secret' + }) + .runCmd('echo forced') + .fromDockerfile('FROM node:22\nWORKDIR /app\nENV NODE_ENV=production PORT=3000\nRUN npm ci\nCOPY package.json /app/\nUSER node') + .fromAWSRegistry('123456789.dkr.ecr.us-west-2.amazonaws.com/app:latest', { + accessKeyId: 'ak', + secretAccessKey: 'sk', + region: 'us-west-2' + }) + .fromGCPRegistry('gcr.io/project/app:latest', { + serviceAccountJSON: { project_id: 'project' } + }) + .build({ + apiKey: 'sandbox-key', + endpoint: fixture.endpoint, + name: 'dockerfile-template:test' + }).then(() => { + const body = JSON.parse(fixture.requests[0].body); + body.buildConfig.fromImage.should.eql('gcr.io/project/app:latest'); + body.buildConfig.fromImageRegistry.should.eql({ + type: 'gcp', + serviceAccountJson: JSON.stringify({ project_id: 'project' }) + }); + body.buildConfig.force.should.eql(true); + body.buildConfig.steps.should.eql([ + { type: 'RUN', args: ['echo forced'], force: true }, + { type: 'WORKDIR', args: ['/app'], force: true }, + { type: 'ENV', args: ['NODE_ENV', 'production', 'PORT', '3000'], force: true }, + { type: 'RUN', args: ['npm ci'], force: true }, + { type: 'COPY', args: ['package.json', '/app/'], force: true }, + { type: 'USER', args: ['node'], force: true } + ]); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('clears stale registry credentials when switching to a public image', function () { + return startServer((req, res) => { + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ templateID: 'tpl_public', buildID: 'bld_public' })); + }).then(fixture => { + return qiniu.sandbox.Template() + .fromImage('registry.example.com/private/app:latest', { + username: 'alice', + password: 'secret' + }) + .fromImage('node:22') + .build({ + apiKey: 'sandbox-key', + endpoint: fixture.endpoint, + name: 'public-template:test' + }).then(() => { + const body = JSON.parse(fixture.requests[0].body); + body.buildConfig.fromImage.should.eql('node:22'); + should.not.exist(body.buildConfig.fromImageRegistry); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('treats long Dockerfile text as content instead of probing it as a path', function () { + const content = 'FROM node:22\nRUN ' + new Array(1200).join('x'); + const template = qiniu.sandbox.Template().fromDockerfile(content); + template.buildConfig.fromImage.should.eql('node:22'); + template.buildConfig.steps[0].cmd.should.match(/^x+$/); + }); + + it('wraps Dockerfile path read errors with path context', function () { + const originalReadFileSync = fs.readFileSync; + fs.readFileSync = function (path) { + if (path === __filename) { + throw new Error('permission denied'); + } + return originalReadFileSync.apply(this, arguments); + }; + try { + (() => qiniu.sandbox.Template().fromDockerfile(__filename)).should.throw(/Failed to read Dockerfile/); + } finally { + fs.readFileSync = originalReadFileSync; + } + }); + + it('throws when Dockerfile path does not exist', function () { + (() => qiniu.sandbox.Template().fromDockerfile('/tmp/missing-qiniu-sdk-Dockerfile')).should.throw(/Dockerfile file not found/); + }); + + it('reads existing custom Dockerfile paths', function () { + const file = `/tmp/qiniu-sdk-Containerfile-${Date.now()}`; + fs.writeFileSync(file, 'FROM node:22\nRUN echo ok'); + try { + const template = qiniu.sandbox.Template().fromDockerfile(file); + template.buildConfig.fromImage.should.eql('node:22'); + template.buildConfig.steps.should.eql([ + { type: 'run', cmd: 'echo ok' } + ]); + } finally { + fs.unlinkSync(file); + } + }); + + it('allows single-line Dockerfile comments as content', function () { + const template = qiniu.sandbox.Template().fromDockerfile('# syntax=docker/dockerfile:1'); + template.buildConfig.steps.should.eql([]); + }); + + it('preserves octal permission strings in Template helpers', function () { + const template = qiniu.sandbox.Template() + .copy('app.js', '/app/', { mode: '755' }) + .copy('bin.js', '/app/', { mode: '0o755' }) + .makeDir('/app/cache', { mode: '0755' }); + + template.buildConfig.steps.should.eql([ + { type: 'COPY', args: ['app.js', '/app/', '', '0755'] }, + { type: 'COPY', args: ['bin.js', '/app/', '', '0755'] }, + { type: 'RUN', args: ['mkdir -p -m 0755 \'/app/cache\''] } + ]); + }); + + it('parses escaped quotes in Dockerfile ENV values', function () { + const template = qiniu.sandbox.Template() + .fromDockerfile('FROM node:22\nENV FOO="bar\\"baz" QUOTED=\'it\\\'s ok\'\nENV MY_VAR some=value'); + template.buildConfig.steps.should.eql([ + { type: 'ENV', args: ['FOO', 'bar"baz', 'QUOTED', 'it\'s ok'] }, + { type: 'ENV', args: ['MY_VAR', 'some=value'] } + ]); + }); + + it('joins Dockerfile lines continued with backslash before parsing', function () { + const template = qiniu.sandbox.Template() + .fromDockerfile('FROM --platform=linux/amd64 ubuntu:22.04 AS build\nRUN apt-get update && \\\n apt-get install -y curl\nENV FOO=bar \\\n BAZ=qux\nENV PORT 3000\nCOPY "file name.txt" "/app/data dir/"'); + template.buildConfig.fromImage.should.eql('ubuntu:22.04'); + template.buildConfig.steps.should.eql([ + { type: 'run', cmd: 'apt-get update && apt-get install -y curl' }, + { type: 'ENV', args: ['FOO', 'bar', 'BAZ', 'qux'] }, + { type: 'ENV', args: ['PORT', '3000'] }, + { type: 'COPY', args: ['file name.txt', '/app/data dir/'] } + ]); + }); + + it('does not continue Dockerfile lines with escaped trailing backslashes', function () { + const template = qiniu.sandbox.Template() + .fromDockerfile('FROM node:22\nRUN echo \\\\\nRUN echo next'); + template.buildConfig.steps.should.eql([ + { type: 'run', cmd: 'echo \\\\' }, + { type: 'run', cmd: 'echo next' } + ]); + }); + + it('does not merge Dockerfile comments or blank lines that end with backslash', function () { + const template = qiniu.sandbox.Template() + .fromDockerfile('FROM node:22\n# ignored comment \\\nRUN echo ok\n\nRUN echo next'); + template.buildConfig.steps.should.eql([ + { type: 'run', cmd: 'echo ok' }, + { type: 'run', cmd: 'echo next' } + ]); + }); + + it('parses JSON Dockerfile COPY args and skips unsupported COPY --from flags', function () { + const template = qiniu.sandbox.Template() + .fromDockerfile('FROM node:22\nCOPY ["file name.txt", "/app/data dir/"]\nCOPY --chown=node package.json /app/\nCOPY --link linked.txt /linked/\nCOPY --from=builder /app/dist /app/dist'); + template.buildConfig.steps.should.eql([ + { type: 'COPY', args: ['file name.txt', '/app/data dir/'] }, + { type: 'COPY', args: ['package.json', '/app/'] }, + { type: 'COPY', args: ['linked.txt', '/linked/'] } + ]); + }); + + it('throws TemplateBuildError when a template build finishes with error status', function () { + return startServer((req, res) => { + if (req.url === '/templates/tpl_1/builds/bld_1/status') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ status: 'error', error: { message: 'compile failed' } })); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const client = new qiniu.sandbox.SandboxClient({ + endpoint: fixture.endpoint, + apiKey: 'sandbox-key' + }); + + return client.waitForBuild('tpl_1', 'bld_1', { intervalMs: 1, timeoutMs: 20 }).then(() => { + throw new Error('expected template build error'); + }, err => { + err.name.should.eql('TemplateBuildError'); + err.message.should.eql('compile failed'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('covers Template package helper option branches and build option cleanup', function () { + const bodySeen = []; + const client = { + createTemplateV3: body => { + bodySeen.push(body); + return Promise.resolve({ templateID: 'tpl_1' }); + } + }; + const template = qiniu.sandbox.Template() + .pipInstall({ g: false }) + .npmInstall({ g: true, dev: true }) + .bunInstall({ g: true }) + .setEnvs({}) + .setStartCmd('npm start') + .setReadyCmd('curl -f http://localhost:3000'); + + return template.build({ + client, + endpoint: 'https://sandbox.example.com', + apiKey: 'api-key', + accessToken: 'access-token', + accessKey: 'ak', + secretKey: 'sk', + timeout: 1, + requestTimeoutMs: 2000, + alias: 'tpl-alias' + }).then(ret => { + ret.templateID.should.eql('tpl_1'); + bodySeen.length.should.eql(1); + bodySeen[0].alias.should.eql('tpl-alias'); + should.not.exist(bodySeen[0].client); + should.not.exist(bodySeen[0].endpoint); + should.not.exist(bodySeen[0].apiKey); + should.not.exist(bodySeen[0].accessToken); + should.not.exist(bodySeen[0].accessKey); + should.not.exist(bodySeen[0].secretKey); + should.not.exist(bodySeen[0].timeout); + should.not.exist(bodySeen[0].requestTimeoutMs); + bodySeen[0].buildConfig.startCmd.should.eql('npm start'); + bodySeen[0].buildConfig.readyCmd.should.eql('curl -f http://localhost:3000'); + bodySeen[0].buildConfig.steps.should.eql([ + { type: 'RUN', args: ['pip install --user .'] }, + { type: 'RUN', args: ['npm install -g --save-dev', 'root'] }, + { type: 'RUN', args: ['bun install', 'root'] } + ]); + }); + }); +}); diff --git a/test/sandbox_types.ts b/test/sandbox_types.ts new file mode 100644 index 00000000..09bc149f --- /dev/null +++ b/test/sandbox_types.ts @@ -0,0 +1,122 @@ +import * as qiniu from '../index'; + +async function useSandboxTypes () { + const client = new qiniu.sandbox.SandboxClient({ + endpoint: 'https://sandbox.example.com', + apiKey: 'key' + }); + + const kodoResource: qiniu.sandbox.KodoResource = { + type: 'kodo', + bucket: 'bucket', + mount_path: '/mnt/kodo', + prefix: 'prefix', + read_only: true + }; + const gitResource: qiniu.sandbox.GitRepositoryResource = { + type: 'github_repository', + url: 'https://github.com/qiniu/nodejs-sdk', + mount_path: '/workspace/repo', + authorization_token: 'token' + }; + + const sandbox = await qiniu.Sandbox.create('base', { + client, + resources: [kodoResource, gitResource], + network: { + allowOut: [qiniu.sandbox.ALL_TRAFFIC] + } + }); + + const timeoutMs: number = qiniu.DEFAULT_SANDBOX_TIMEOUT_MS; + const fileType: 'file' = qiniu.FileType.FILE; + const eventType: 'write' = qiniu.FilesystemEventType.WRITE; + await sandbox.connect({ timeoutMs }); + await sandbox.betaPause(); + const bytes: Buffer = await sandbox.files.read('/tmp/a.bin', { format: 'bytes' }); + const text: string = await sandbox.files.read('/tmp/a.txt', { format: 'text' }); + const stream: NodeJS.ReadableStream = await sandbox.files.read('/tmp/a.txt', { format: 'stream' }); + const watchHandle = await sandbox.files.watchDir('/tmp', event => { + const eventName: string = event.name; + const eventKind: 'create' | 'write' | 'remove' | 'rename' | 'chmod' = event.type; + eventName.length; + eventKind.length; + }, { + recursive: true, + requestTimeoutMs: 1000, + onExit: err => { + if (err) { + err.message.length; + } + } + }); + await watchHandle.stop(); + const handle = await sandbox.commands.connect(123); + await handle.disconnect(); + await sandbox.commands.run('false', { + requestTimeoutMs: 1000, + throwOnError: true + }); + await sandbox.git.clone('https://github.com/qiniu/nodejs-sdk', '/repo', { + depth: 1, + username: 'u', + password: 'p' + }); + await sandbox.git.push('/repo', { + remote: 'origin', + branch: 'main', + username: 'u', + password: 'p' + }); + await sandbox.git.setConfig('user.name', 'Alice', { path: '/repo', scope: 'local' }); + const gitUser: string | undefined = await sandbox.git.getConfig('user.name', { path: '/repo', scope: 'local' }); + await sandbox.git.configureUser('Alice', 'alice@example.com', { path: '/repo', scope: 'local' }); + await sandbox.git.reset('/repo', { mode: 'hard', target: 'HEAD~1' }); + await sandbox.git.restore('/repo', { paths: ['a.txt'] }); + const template = qiniu.sandbox.Template() + .fromImage('ubuntu:22.04') + .aptInstall(['git']) + .runCmd('git --version'); + qiniu.sandbox.Template() + .skipCache() + .fromImage('registry.example.com/app:latest', { username: 'u', password: 'p' }) + .fromAWSRegistry('123456789.dkr.ecr.us-west-2.amazonaws.com/app:latest', { + accessKeyId: 'ak', + secretAccessKey: 'sk', + region: 'us-west-2' + }) + .fromGCPRegistry('gcr.io/project/app:latest', { + serviceAccountJSON: { project_id: 'project' } + }) + .fromDockerfile('FROM node:22\nRUN npm ci') + .copyItems([{ src: ['package.json'], dest: '/app/', mode: 0o644 }]) + .remove('/tmp/cache', { recursive: true, force: true }) + .rename('/tmp/a', '/tmp/b') + .makeDir(['/app/data'], { mode: 0o755 }) + .makeSymlink('/usr/bin/node', '/usr/local/bin/node', { force: true }) + .setWorkdir('/app') + .setUser('node') + .setEnvs({ NODE_ENV: 'production' }) + .pipInstall({ g: false }) + .pipInstall(['numpy'], { g: false }) + .npmInstall({ dev: true }) + .npmInstall('typescript', { dev: true }) + .bunInstall({ dev: true }) + .bunInstall(undefined, { g: true }) + .gitClone('https://github.com/qiniu/nodejs-sdk.git', { branch: 'sandbox', depth: 1 }) + .gitClone('https://github.com/qiniu/nodejs-sdk.git', '/src/sdk', { branch: 'sandbox', depth: 1 }) + .runCmd(['echo one', 'echo two'], { user: 'root' }); + await template.build({ client, name: 'typed-template:test' }); + await sandbox.updateNetwork({ allowOut: [qiniu.sandbox.ALL_TRAFFIC] }); + qiniu.CommandExitError.name; + bytes.length; + text.length; + stream.read; + fileType.length; + eventType.length; + if (gitUser) { + gitUser.length; + } +} + +void useSandboxTypes; diff --git a/tsconfig.json b/tsconfig.json index 9b8e4fc2..b9bc9d13 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -108,5 +108,6 @@ }, "include": [ "index.d.ts", + "test/sandbox_types.ts", ] }