diff --git a/examples/sandbox_list_connect.js b/examples/sandbox_list_connect.js index 21f11684..eb426ade 100644 --- a/examples/sandbox_list_connect.js +++ b/examples/sandbox_list_connect.js @@ -1,16 +1,19 @@ const { qiniu, sandboxClient, + sandboxTemplate, cleanupSandbox, runExample } = require('./sandbox_common'); runExample(() => { const client = sandboxClient(); + const template = sandboxTemplate(); let sandbox; return qiniu.sandbox.Sandbox.create({ client, + template, timeout: 300, metadata: { example: 'sandbox_list_connect' @@ -20,7 +23,10 @@ runExample(() => { console.log('Created:', sandbox.sandboxId); return qiniu.sandbox.Sandbox.list({ client, - limit: 10 + limit: 10, + query: { + template: [template] + } }); }).then(items => { console.log('List result:', Array.isArray(items) ? items.map(item => item.sandboxId || item.sandboxID) : items); diff --git a/examples/sandbox_request_injections.js b/examples/sandbox_request_injections.js index 9a91490c..a3a649a6 100644 --- a/examples/sandbox_request_injections.js +++ b/examples/sandbox_request_injections.js @@ -7,25 +7,29 @@ const { runExample(() => { let sandbox; + const httpInjection = { + type: 'http', + baseUrl: 'https://httpbin.org/bearer', + ifHeaders: { + 'X-Sandbox-Example': 'qiniu-nodejs-sdk' + }, + headers: { + Authorization: `Bearer ${env('QINIU_SANDBOX_HTTP_INJECTION_TOKEN', 'real_token')}` + } + }; return createSandboxAndWait({ - injections: [ - { - type: 'http', - baseUrl: 'https://httpbin.org/bearer', - ifHeaders: { - 'X-Sandbox-Example': 'qiniu-nodejs-sdk' - }, - headers: { - Authorization: `Bearer ${env('QINIU_SANDBOX_HTTP_INJECTION_TOKEN', 'real_token')}` - } - } - ], + injections: [httpInjection], metadata: { example: 'sandbox_request_injections' } }).then(created => { sandbox = created; + return sandbox.getInjections(); + }).then(result => { + console.log('Current runtime injections:', result.injections); + return sandbox.updateInjections([httpInjection]); + }).then(() => { return sandbox.commands.run('curl --max-time 20 -sSL https://httpbin.org/bearer -H "Authorization: Bearer fake_token" -H "X-Sandbox-Example: qiniu-nodejs-sdk"', { timeout: 30000 }); diff --git a/examples/sandbox_resources.js b/examples/sandbox_resources.js index b4736334..137060b2 100644 --- a/examples/sandbox_resources.js +++ b/examples/sandbox_resources.js @@ -41,6 +41,9 @@ function runGitResourceExample () { return sandbox.waitForReady({ interval: 3000, timeout: 180000 }); }).then(() => { console.log('Git resource sandbox ready:', sandbox.sandboxId); + return sandbox.updateGithubToken(token); + }).then(() => { + console.log('GitHub token updated for the running sandbox.'); return sandbox.commands.run(`ls -la ${shellQuote(mountPath)} | head -20`); }).then(result => { console.log(result.stdout); diff --git a/examples/sandbox_templates.js b/examples/sandbox_templates.js index 0c3ba104..eb66b5ea 100644 --- a/examples/sandbox_templates.js +++ b/examples/sandbox_templates.js @@ -25,7 +25,13 @@ runExample(() => { } 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); + console.log('First template detail:', { + templateID: detail.templateID || detail.template_id || id, + names: detail.names, + aliases: detail.aliases, + isOwner: detail.isOwner, + buildCount: Array.isArray(detail.builds) ? detail.builds.length : 0 + }); }); }).then(() => { const advanced = qiniu.sandbox.Template() diff --git a/index.d.ts b/index.d.ts index e6824463..941a05e1 100644 --- a/index.d.ts +++ b/index.d.ts @@ -130,6 +130,24 @@ export declare namespace sandbox { type Injection = HttpInjection | OpenaiInjection | AnthropicInjection | GeminiInjection | QiniuInjection | GithubInjection; type SandboxInjection = InjectionById | InjectionRuleReference | Injection; + interface SandboxInjectionsResponse { + injections: SandboxInjection[]; + } + + interface TemplateInfo { + templateID: string; + public: boolean; + /** @deprecated use names instead */ + aliases: string[]; + names: string[]; + [key: string]: any; + } + + interface TemplateWithBuilds extends TemplateInfo { + isOwner: boolean; + builds: any[]; + } + interface InjectionRule { ruleID?: string; id?: string; @@ -456,6 +474,9 @@ export declare namespace sandbox { getSandboxesMetrics(sandboxIDs: string[] | any): Promise; getSandboxLogs(sandboxID: string, options?: any): Promise; getSandbox(sandboxID: string): Promise; + getSandboxInjections(sandboxID: string): Promise; + updateSandboxInjections(sandboxID: string, injections: SandboxInjection[]): Promise; + updateSandboxGithubToken(sandboxID: string, authorizationToken: string): Promise; deleteSandbox(sandboxID: string): Promise; killSandbox(sandboxID: string): Promise; getSandboxMetrics(sandboxID: string, options?: any): Promise; @@ -472,7 +493,7 @@ export declare namespace sandbox { getTemplateFiles(templateID: string, hash: string): Promise; listDefaultTemplates(): Promise; listTemplates(options?: any): Promise; - getTemplate(templateID: string, 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; @@ -536,6 +557,9 @@ export declare namespace sandbox { betaPause(): Promise; connect(options?: SandboxConnectOptions): Promise; getInfo(): Promise; + getInjections(): Promise; + updateInjections(injections: SandboxInjection[]): Promise; + updateGithubToken(authorizationToken: string): Promise; getMetrics(options?: any): Promise; getLogs(options?: any): Promise; createSnapshot(options?: any): Promise; diff --git a/qiniu/sandbox/client.js b/qiniu/sandbox/client.js index b5e825c7..7955f6b0 100644 --- a/qiniu/sandbox/client.js +++ b/qiniu/sandbox/client.js @@ -100,6 +100,25 @@ function normalizeSandboxListOptions (opts) { if (query && query.state) { opts.state = query.state; } + if (query && query.template) { + const normalizeTemplate = template => { + if (typeof template === 'string') { + return template; + } + return template && (template.templateID || template.template_id || template.id); + }; + if (Array.isArray(query.template)) { + const templates = query.template.map(normalizeTemplate).filter(Boolean); + if (templates.length) { + opts.template = templates; + } + } else { + const template = normalizeTemplate(query.template); + if (template) { + opts.template = template; + } + } + } return opts; } @@ -276,6 +295,46 @@ SandboxClient.prototype.getSandbox = function (sandboxID) { return this._request('GET', `/sandboxes/${encodePath(sandboxID)}`); }; +SandboxClient.prototype.getSandboxInjections = function (sandboxID) { + if (!sandboxID) { + return Promise.reject(new SandboxError('sandboxID is required')); + } + return this._request('GET', `/sandboxes/${encodePath(sandboxID)}/injections`); +}; + +SandboxClient.prototype.updateSandboxInjections = function (sandboxID, injections) { + if (!sandboxID) { + return Promise.reject(new SandboxError('sandboxID is required')); + } + if (!Array.isArray(injections)) { + return Promise.reject(new SandboxError('injections must be an array')); + } + if (injections.some(injection => !injection || typeof injection !== 'object' || Array.isArray(injection))) { + return Promise.reject(new SandboxError('injections must contain objects')); + } + return this._request('PUT', `/sandboxes/${encodePath(sandboxID)}/injections`, { + body: { + injections: injections.map(normalizeInjection) + }, + empty: true + }); +}; + +SandboxClient.prototype.updateSandboxGithubToken = function (sandboxID, authorizationToken) { + if (!sandboxID) { + return Promise.reject(new SandboxError('sandboxID is required')); + } + if (!authorizationToken) { + return Promise.reject(new SandboxError('authorizationToken is required')); + } + return this._request('PUT', `/sandboxes/${encodePath(sandboxID)}/github-token`, { + body: { + authorization_token: authorizationToken + }, + empty: true + }); +}; + SandboxClient.prototype.deleteSandbox = function (sandboxID) { if (!sandboxID) { return Promise.reject(new SandboxError('sandboxID is required')); diff --git a/qiniu/sandbox/sandbox.js b/qiniu/sandbox/sandbox.js index 4e4b08f6..00d26cf5 100644 --- a/qiniu/sandbox/sandbox.js +++ b/qiniu/sandbox/sandbox.js @@ -216,6 +216,18 @@ Sandbox.prototype.getInfo = function () { return this.client.getSandbox(this.sandboxId); }; +Sandbox.prototype.getInjections = function () { + return this.client.getSandboxInjections(this.sandboxId); +}; + +Sandbox.prototype.updateInjections = function (injections) { + return this.client.updateSandboxInjections(this.sandboxId, injections); +}; + +Sandbox.prototype.updateGithubToken = function (authorizationToken) { + return this.client.updateSandboxGithubToken(this.sandboxId, authorizationToken); +}; + Sandbox.prototype.refreshEnvdTokenIfNeeded = function () { if (this.envdAccessToken) { return Promise.resolve(this); diff --git a/test/sandbox_client.test.js b/test/sandbox_client.test.js index 17a1d137..f2db9f2d 100644 --- a/test/sandbox_client.test.js +++ b/test/sandbox_client.test.js @@ -553,6 +553,119 @@ describe('test sandbox client module', function () { }); }); + it('maps sandbox template filters and runtime injection APIs', function () { + return startServer((req, res) => { + res.setHeader('Content-Type', 'application/json'); + if (req.method === 'GET' && req.url === '/v2/sandboxes?limit=5&template=tpl_1%2Ctpl_2%2Ctpl_3%2Ctpl_4') { + res.statusCode = 200; + res.end(JSON.stringify([])); + return; + } + if (req.method === 'GET' && req.url === '/sandboxes/sbx_runtime/injections') { + res.statusCode = 200; + res.end(JSON.stringify({ + injections: [{ type: 'github', token: 'ghp_****' }] + })); + return; + } + if (req.method === 'PUT' && req.url === '/sandboxes/sbx_runtime/injections') { + res.statusCode = 204; + res.end(); + return; + } + if (req.method === 'PUT' && req.url === '/sandboxes/sbx_runtime/github-token') { + res.statusCode = 204; + res.end(); + 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' + }); + + return client.listSandboxesV2({ + limit: 5, + query: { + template: [ + 'tpl_1', + { templateID: 'tpl_2' }, + { template_id: 'tpl_3' }, + { id: 'tpl_4' }, + null + ] + } + }).then(() => client.getSandboxInjections('sbx_runtime')) + .then(result => { + result.injections[0].token.should.eql('ghp_****'); + return client.updateSandboxInjections('sbx_runtime', [{ + type: 'openai', + apiKey: 'secret', + baseUrl: 'https://api.openai.com/v1/*' + }]); + }) + .then(() => client.updateSandboxGithubToken('sbx_runtime', 'github-token')) + .then(() => { + JSON.parse(fixture.requests[2].body).should.eql({ + injections: [{ + type: 'openai', + api_key: 'secret', + base_url: 'https://api.openai.com/v1/*' + }] + }); + JSON.parse(fixture.requests[3].body).should.eql({ + authorization_token: 'github-token' + }); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('validates runtime injection API arguments before sending requests', function () { + return startServer((req, res) => { + res.statusCode = 404; + res.end(); + }).then(fixture => { + const client = new qiniu.sandbox.SandboxClient({ + endpoint: fixture.endpoint, + apiKey: 'sandbox-key' + }); + const calls = [ + () => client.getSandboxInjections(), + () => client.updateSandboxInjections(undefined, []), + () => client.updateSandboxGithubToken(undefined, 'github-token'), + () => client.updateSandboxInjections('sbx_runtime', { type: 'id', id: 'rule_1' }), + () => client.updateSandboxGithubToken('sbx_runtime'), + () => client.updateSandboxInjections('sbx_runtime', [null]) + ]; + + return calls.reduce((promise, call, index) => { + return promise.then(() => call().then(() => { + throw new Error('expected runtime injection argument validation to fail'); + }, err => { + err.name.should.eql('SandboxError'); + const expected = index < 3 + ? 'sandboxID is required' + : (index === 3 + ? 'injections must be an array' + : (index === 4 ? 'authorizationToken is required' : 'injections must contain objects')); + err.message.should.eql(expected); + })); + }, Promise.resolve()).then(() => { + fixture.requests.should.have.length(0); + }).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') { diff --git a/test/sandbox_facade.test.js b/test/sandbox_facade.test.js index 4b9d50db..c4c5764d 100644 --- a/test/sandbox_facade.test.js +++ b/test/sandbox_facade.test.js @@ -5,6 +5,51 @@ const { } = require('./sandbox_helpers'); describe('test sandbox facade module', function () { + it('manages runtime injections and GitHub tokens through Sandbox instances', function () { + return startServer((req, res) => { + res.setHeader('Content-Type', 'application/json'); + if (req.method === 'GET' && req.url === '/sandboxes/sbx_runtime/injections') { + res.statusCode = 200; + res.end(JSON.stringify({ injections: [{ type: 'id', id: 'rule_1' }] })); + return; + } + if (req.method === 'PUT' && (req.url === '/sandboxes/sbx_runtime/injections' || req.url === '/sandboxes/sbx_runtime/github-token')) { + res.statusCode = 204; + res.end(); + return; + } + res.statusCode = 404; + res.end(JSON.stringify({ message: req.method + ' ' + req.url })); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_runtime', + client: new qiniu.sandbox.SandboxClient({ + endpoint: fixture.endpoint, + apiKey: 'sandbox-key' + }), + info: {} + }); + + return sandbox.getInjections() + .then(result => { + result.injections[0].id.should.eql('rule_1'); + return sandbox.updateInjections([{ type: 'id', id: 'rule_2' }]); + }) + .then(() => sandbox.updateGithubToken('github-token')) + .then(() => { + fixture.requests.map(req => `${req.method} ${req.url}`).should.eql([ + 'GET /sandboxes/sbx_runtime/injections', + 'PUT /sandboxes/sbx_runtime/injections', + 'PUT /sandboxes/sbx_runtime/github-token' + ]); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('covers Sandbox.connect, Sandbox.list, wait polling, and stopped health checks', function () { let infoCalls = 0; return startServer((req, res) => { diff --git a/test/sandbox_template.test.js b/test/sandbox_template.test.js index 2c5b1560..56839754 100644 --- a/test/sandbox_template.test.js +++ b/test/sandbox_template.test.js @@ -32,7 +32,14 @@ describe('test sandbox template module', function () { } if (req.method === 'GET' && req.url === '/templates/tpl_1?limit=5&nextToken=n2') { res.statusCode = 200; - res.end(JSON.stringify({ templateID: 'tpl_1' })); + res.end(JSON.stringify({ + templateID: 'tpl_1', + public: true, + aliases: ['nodejs'], + names: ['qiniu/nodejs'], + isOwner: true, + builds: [] + })); return; } if (req.method === 'PATCH' && req.url === '/templates/tpl_1') { @@ -99,6 +106,12 @@ describe('test sandbox template module', function () { .then(() => client.createTemplate({ name: 'node:v1' })) .then(() => client.createTemplateV2({ alias: 'old' })) .then(() => client.getTemplate('tpl_1', { limit: 5, nextToken: 'n2' })) + .then(template => { + template.names.should.eql(['qiniu/nodejs']); + template.aliases.should.eql(['nodejs']); + template.isOwner.should.eql(true); + template.builds.should.eql([]); + }) .then(() => client.updateTemplate('tpl_1', { public: true })) .then(() => client.deleteTemplate('tpl_1')) .then(() => client.getTemplateFiles('tpl_1', 'hash')) diff --git a/test/sandbox_types.ts b/test/sandbox_types.ts index f341f2f8..82a653c5 100644 --- a/test/sandbox_types.ts +++ b/test/sandbox_types.ts @@ -136,6 +136,13 @@ async function useSandboxTypes () { .runCmd(['echo one', 'echo two'], { user: 'root' }); await template.build({ client, name: 'typed-template:test' }); await sandbox.updateNetwork({ allowOut: [qiniu.sandbox.ALL_TRAFFIC] }); + const injections: qiniu.sandbox.SandboxInjection[] = (await sandbox.getInjections()).injections; + await sandbox.updateInjections(injections); + await sandbox.updateGithubToken('github-token'); + const templateInfo: qiniu.sandbox.TemplateWithBuilds = await client.getTemplate('typed-template'); + const templateNames: string[] = templateInfo.names; + const isOwner: boolean = templateInfo.isOwner; + qiniu.Sandbox.list({ client, query: { template: templateNames } }); qiniu.CommandExitError.name; bytes.length; text.length; @@ -145,6 +152,9 @@ async function useSandboxTypes () { if (gitUser) { gitUser.length; } + if (isOwner) { + isOwner.valueOf(); + } } void useSandboxTypes;