diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 2364989..79707b6 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -8,6 +8,8 @@ Container operations: start, stop, restart, kill, pause, unpause, rename, update Docker events reconnect automatically. External container lifecycle events update the matching NetBox container by host and name. Lifecycle events triggered during an agent operation are gated; pause/unpause events remain synchronized. The startup check reports the agent version and requests a host refresh after an upgrade. Endpoint refresh removes the plugin's supplied resource records and repopulates inventory. +Settings saves (`operation: none`) still apply the restart policy and optional rename. Settings saves and explicit renames write their journals without PATCHing the container back to the manager, matching the legacy flow. This prevents callback webhooks from starting a journal loop and preserves configuration staged for recreation. Docker events send one PATCH containing runtime state to the container detail endpoint. + The terminal keeps the existing `/ws/engine/containers/:id/exec` route and byte-code protocol. Request its path with `POST /api/engine/containers/:id/ws` first. The synchronous HTTP exec route retains its historical `text/plain` JSON envelope. With `DISABLE_EXEC` set it returns a 200 exec-options echo without executing; disabled WebSocket setup returns 403. Both setup and upgrade require API credentials, and browser upgrades must be same-origin. The dashboard supports shell selection, keyboard input, paste, terminal resize, output and session closure. External clients can use `src/exec-client.js` or the existing protocol with authenticated upgrades. ## Dashboard diff --git a/src/agent.js b/src/agent.js index 35e5f2b..b2bc45f 100644 --- a/src/agent.js +++ b/src/agent.js @@ -82,17 +82,13 @@ export class Agent { await this.netbox.resource('PATCH', 'hosts', { id: host, endpoint: this.store.config.endpoint, state: 'running' }, `${host}/`); await this.netbox.resource('PATCH', 'hosts', [{ id: Number(host || 0), docker_api_version: version.ApiVersion, agent_version: `${this.version}-${this.store.config.flows}`, state: 'running', operation: 'none' }]); } else if (type === 'containers') { for (const c of await this.containers()) await this.netbox.resource('POST', type, containerRecord(c, host)); } - else if (type === 'networks') { for (const n of await this.docker.listNetworks()) await this.netbox.resource('POST', type, { host, name: n.Name, driver: n.Driver ?? 'None', NetworkID: n.Id, state: 'created' }); } + else if (type === 'networks') { for (const n of await this.docker.listNetworks()) await this.netbox.resource('POST', type, { host, name: n.Name, driver: n.Driver === 'null' ? 'None' : n.Driver ?? 'None', NetworkID: n.Id, state: 'created' }); } else if (type === 'volumes') { for (const v of (await this.docker.listVolumes()).Volumes || []) await this.netbox.resource('POST', type, { host, name: v.Name, driver: v.Driver }); } else if (type === 'images') { await this.registries(); for (const i of await this.docker.listImages({ digests: true })) await this.netbox.resource('POST', type, imageRecord(i, host, this.store.registries)); } } async callback(c, d) { const record = containerRecord(await c.inspect(), d.host?.id || this.store.config.id, d.id); - // A settings save only updates Docker's restart policy and optional name. - // Preserve the manager's staged configuration until recreation applies it. - const fields = ['id', 'host', 'name', 'ContainerID', 'state', 'operation', 'status', 'exitCode', 'restart_policy']; - const result = ['none', 'rename'].includes(d.operation) ? Object.fromEntries(fields.map(key => [key, record[key]])) : record; - return this.netbox.resource('PATCH', 'containers', [result]); + return this.netbox.resource('PATCH', 'containers', [record]); } async write(method, type, d = {}) { const host = this.store.config.id; @@ -160,7 +156,11 @@ export class Agent { } else if (['start', 'stop', 'restart', 'kill', 'pause', 'unpause'].includes(d.operation)) await c[d.operation](); else return; - await this.callback(c, d); await this.netbox.journal('container', d.id, 'success', `operation ${d.operation}: ok`); + // Manager callbacks can produce another webhook with operation "none". + // As in the legacy flow, settings saves and renames finish without a + // container PATCH, breaking that cycle and preserving staged settings. + if (!['none', 'rename'].includes(d.operation)) await this.callback(c, d); + await this.netbox.journal('container', d.id, 'success', `operation ${d.operation}: ok`); } } catch (error) { await this.netbox.journal(type === 'images' ? 'image' : 'container', d.id, 'danger', `operation ${d.operation || method}: failed ${error.message}`).catch(e => this.log(e)); throw error; } finally { @@ -177,8 +177,6 @@ export class Agent { if (found?.count === 1) { const record = containerRecord(c, this.store.config.id, found.results[0].id); await this.netbox.resource('PATCH', 'containers', { state: record.state, ContainerID: c.Id, operation: 'none', exitCode: record.exitCode, status: record.status }, `${record.id}/`); - // Bulk callback is also used by operations; both forms are supported by the plugin. - await this.netbox.resource('PATCH', 'containers', [record]); } } async watch() { diff --git a/tests/hurl/events.hurl b/tests/hurl/events.hurl index 892c1a0..62f7757 100644 --- a/tests/hurl/events.hurl +++ b/tests/hurl/events.hurl @@ -92,10 +92,9 @@ retry-interval: 1000 ```json { "method": "PATCH", - "urlPath": "/api/plugins/docker/containers/", + "urlPath": "/api/plugins/docker/containers/777/", "bodyPatterns": [ - { "matchesJsonPath": "$[?(@.name == 'evt-container')]" }, - { "matchesJsonPath": "$[?(@.state == 'paused')]" } + { "matchesJsonPath": { "expression": "$.state", "equalTo": "paused" } } ] } ``` diff --git a/tests/integration/agent.test.js b/tests/integration/agent.test.js index b8f60e4..1e3cbd4 100644 --- a/tests/integration/agent.test.js +++ b/tests/integration/agent.test.js @@ -89,6 +89,27 @@ test('all container operations and resource lifecycles reach Engine endpoints',a test('container recreation preserves settings and rolls back a failed replacement',async t=>{const {agent,calls,docker}=await setup(t);await agent.write('PUT','containers',{id:7,name:'demo',ContainerID:'demo',operation:'recreate',image:{ImageID:'sha256:image'},env:[{var_name:'A',value:"a'b"}],network_settings:[]});const created=calls.find(x=>x.path==='/containers/create');assert.deepEqual(created.body.Env,["A=a'b"]);assert.ok(calls.some(x=>x.method==='DELETE'&&x.path==='/containers/abc'));docker.createContainer=async()=>{throw Error('create failed')};await assert.rejects(agent.write('PUT','containers',{id:7,name:'demo',ContainerID:'abc',operation:'recreate',image:{ImageID:'sha256:image'}}),/create failed/);assert.ok(calls.filter(x=>x.path==='/containers/abc/rename').some(x=>x.query==='?name=demo'));}); test('exec, logs and stats retain the legacy response envelopes',async t=>{const {api}=await setup(t);const result=await(await api('/api/engine/containers/abc/exec','PUT',{cmd:['echo','hello']})).json();assert.ok(result.stdout.includes('hello'));assert.equal(await(await api('/api/engine/containers/abc/logs')).text(),'hello\n');assert.equal((await(await api('/api/engine/containers/abc/stats')).json()).cpu_stats.cpu_usage.total_usage,5);assert.equal((await api('/api/engine/containers/abc/exec','PUT',{cmd:'whoami'})).status,400);}); test('NetBox inventory synchronization covers every resource and host metadata',async t=>{const {agent,callbacks}=await setup(t);for(const type of ['endpoint','networks','volumes','images','containers','registries'])await agent.sync(type);assert.ok(callbacks.some(x=>x.url==='/api/plugins/docker/hosts/'&&x.body[0].agent_version==='2.0.0-0'));assert.ok(callbacks.some(x=>x.url==='/api/plugins/docker/networks/'&&x.body.NetworkID==='net'));assert.ok(callbacks.some(x=>x.url==='/api/plugins/docker/containers/'&&x.body.ContainerID==='abc'));assert.ok(callbacks.some(x=>x.url==='/api/plugins/docker/images/'&&x.body.name==='nginx'));}); +test('network discovery maps the Docker null driver to the legacy None callback value', async t => { + const { api, agent, docker, callbacks } = await setup(t); + docker.listNetworks = async () => [ + { Id: 'none-id', Name: 'none', Driver: 'null' }, + { Id: 'null-id', Name: 'null-driver', Driver: null }, + { Id: 'missing-id', Name: 'missing-driver' }, + { Id: 'bridge-id', Name: 'bridge', Driver: 'bridge' }, + { Id: 'host-id', Name: 'host', Driver: 'host' }, + ]; + assert.equal((await api('/api/engine/networks')).status, 202); + await Promise.all(agent.jobs); + const records = callbacks.filter(c => c.method === 'POST' && c.url === '/api/plugins/docker/networks/').map(c => c.body); + assert.deepEqual(records, [ + { host: 9, name: 'none', driver: 'None', NetworkID: 'none-id', state: 'created' }, + { host: 9, name: 'null-driver', driver: 'None', NetworkID: 'null-id', state: 'created' }, + { host: 9, name: 'missing-driver', driver: 'None', NetworkID: 'missing-id', state: 'created' }, + { host: 9, name: 'bridge', driver: 'bridge', NetworkID: 'bridge-id', state: 'created' }, + { host: 9, name: 'host', driver: 'host', NetworkID: 'host-id', state: 'created' }, + ]); +}); + test('event stream reconnects after disconnect and synchronizes external state changes',async t=>{const {agent,getEvents,callbacks}=await setup(t);await agent.watch();await new Promise(r=>setTimeout(r,20));getEvents().write(JSON.stringify({Type:'container',Action:'pause',Actor:{ID:'abc'}})+'\n');await new Promise(r=>setTimeout(r,80));await Promise.all(agent.jobs);assert.ok(callbacks.some(x=>x.method==='PATCH'&&x.url==='/api/plugins/docker/containers/7/'));const first=getEvents();first.end();await new Promise(r=>setTimeout(r,1200));assert.notEqual(getEvents(),first);}); test('security rejects missing/wrong auth, cross-origin writes, malformed/oversized JSON and disabled exec',async t=>{const {api,base}=await setup(t);assert.equal((await fetch(base+'/api/containers')).status,401);assert.equal((await api('/api/containers','GET',undefined,{authorization:'Basic eDp5'})).status,401);assert.equal((await api('/api/engine/containers','POST',{data:{}},{origin:'https://evil.example'})).status,403);assert.equal((await api('/api/engine/endpoint?url=http://localhost','GET',undefined,{origin:'https://evil.example'})).status,403);assert.equal((await fetch(base+'/api/engine/containers',{method:'POST',headers:{authorization:auth},body:'{'})).status,400);assert.equal((await api('/api/engine/containers','POST',{data:{name:'x'.repeat(1024*1024)}})).status,413);assert.equal((await api('/../package.json')).status,404);process.env.DISABLE_EXEC='1';try{const disabled=await api('/api/engine/containers/abc/exec','PUT',{cmd:['id']});assert.equal(disabled.status,200);assert.deepEqual(await disabled.json(),{AttachStdin:false,AttachStdout:true,AttachStderr:true,Tty:false,Cmd:['id']});assert.equal((await api('/api/engine/containers/abc/ws','POST')).status,403);}finally{delete process.env.DISABLE_EXEC;}}); test('WebSocket upgrade requires credentials and same-origin even after reservation',async t=>{const {api,base}=await setup(t);const result=await(await api('/api/engine/containers/abc/ws','POST')).json();assert.equal(result.path,'/ws/engine/containers/abc/exec');for(const headers of [{},{authorization:auth,origin:'https://evil.example'}]){const ws=new WebSocket(base.replace('http:','ws:')+result.path,{headers});const [err]=await once(ws,'error');assert.match(err.message,/403/);}}); @@ -147,7 +168,7 @@ test('Docker progress error frames fail image pulls and create danger journals', test('terminal styles use unique per-page CSP nonces without inline script permissions',async t=>{const {api}=await setup(t);const first=await api('/'),second=await api('/');const csp=first.headers.get('content-security-policy');const html=await first.text();assert.ok(!csp.includes('unsafe-inline'));assert.match(csp,/script-src 'self';/);const nonce=html.match(/name="style-nonce" content="([^"]+)"/)[1];assert.ok(csp.includes(`'nonce-${nonce}'`));assert.notEqual(csp,second.headers.get('content-security-policy'));}); test('volume size options follow the daemon storage driver and registry discovery creates remote associations',async t=>{const {agent,docker,calls,netbox}=await setup(t);docker.info=async()=>({Driver:'btrfs'});await agent.write('POST','volumes',{name:'bounded',driver:'local',max_size:128});assert.deepEqual(calls.find(x=>x.path==='/volumes/create').body.DriverOpts,{o:'size=128m'});docker.listImages=async()=>[{Id:'sha256:registry',RepoTags:['private.example:5000/ns/app:v1'],Size:1}];const original=netbox.resource.bind(netbox);netbox.resource=async(method,type,data,suffix)=>type==='registries'?(method==='GET'?{results:[]}:[{id:42,name:'private.example:5000'}]):original(method,type,data,suffix);const registries=await agent.registries();assert.equal(registries[0].name,'private.example:5000');assert.equal(registries[0].id,42);assert.deepEqual(registries[0].images,['private.example:5000/ns/app:v1']);}); test('lifecycle events are gated during operations while pause/unpause continue to synchronize',async t=>{const {agent,docker,callbacks}=await setup(t);let release,started;const entered=new Promise(resolve=>{started=resolve});const blocked=new Promise(resolve=>{release=resolve});const get=docker.getContainer.bind(docker);docker.getContainer=id=>{const container=get(id);container.start=async()=>{started();await blocked;};return container;};const operation=agent.write('PUT','containers',{id:7,ContainerID:'abc',operation:'start'});await entered;assert.equal(agent.activeOperations,1);const before=callbacks.filter(x=>x.method==='PATCH').length;await agent.event({Type:'container',Action:'start',Actor:{ID:'abc'}});assert.equal(callbacks.filter(x=>x.method==='PATCH').length,before);await agent.event({Type:'container',Action:'pause',Actor:{ID:'abc'}});assert.ok(callbacks.filter(x=>x.method==='PATCH').length>before);release();await operation;assert.equal(agent.activeOperations,0);const after=callbacks.length;await agent.event({Type:'container',Action:'die',Actor:{ID:'abc'}});assert.ok(callbacks.length>after);}); -test('operation none retains the legacy combined restart-policy update and optional rename',async t=>{const {agent,calls,callbacks}=await setup(t);await agent.write('PUT','containers',{id:7,ContainerID:'demo',name:'saved-name',operation:'none',restart_policy:'always'});assert.ok(calls.some(x=>x.path==='/containers/abc/update'&&x.body.RestartPolicy.Name==='always'));assert.ok(calls.some(x=>x.path==='/containers/abc/rename'&&x.query==='?name=saved-name'));assert.ok(callbacks.some(x=>x.url==='/api/plugins/docker/containers/'&&x.body[0].ContainerID==='abc'));}); +test('operation none retains the legacy combined restart-policy update and optional rename',async t=>{const {agent,calls,callbacks}=await setup(t);await agent.write('PUT','containers',{id:7,ContainerID:'demo',name:'saved-name',operation:'none',restart_policy:'always'});assert.ok(calls.some(x=>x.path==='/containers/abc/update'&&x.body.RestartPolicy.Name==='always'));assert.ok(calls.some(x=>x.path==='/containers/abc/rename'&&x.query==='?name=saved-name'));assert.equal(callbacks.some(x=>x.method==='PATCH'),false);}); test('cross-site browser GETs cannot register an endpoint or trigger inventory writes without an Origin header',async t=>{const {api,store}=await setup(t);const original=store.config.netbox_url;const headers={'sec-fetch-site':'cross-site'};assert.equal((await api('/api/engine/endpoint?url=https://attacker.example','GET',undefined,headers)).status,403);assert.equal(store.config.netbox_url,original);assert.equal((await api('/api/engine/containers','GET',undefined,headers)).status,403);assert.equal((await api('/api/engine/endpoint','GET')).status,202);}); test('configuration URL validation and environment overrides retain their contracts', async t => { @@ -199,28 +220,57 @@ test('an HTTP client disconnect during a read does not turn into an unhandled re finish({configured:'ok'});await new Promise(setImmediate); }); -test('settings and rename callbacks preserve configuration staged for recreation', async t => { +test('settings and rename finish without callbacks that overwrite staged configuration', async t => { const {api,agent,callbacks}=await setup(t); for(const operation of ['none','rename']) { const before=callbacks.length; const data={id:7,ContainerID:'abc',name:'demo',operation,restart_policy:'always',env:[{var_name:'SAASHUP_OWNER',value:'owner@example.test'}],labels:[{key:'traefik.enable',value:'true'}],ports:[{private_port:80,public_port:8080,type:'tcp'}]}; assert.equal((await api('/api/engine/containers','PUT',{data})).status,202); await Promise.all(agent.jobs); - const callback=callbacks.slice(before).find(c=>c.method==='PATCH'&&c.url==='/api/plugins/docker/containers/'); - assert.equal(callback.body[0].ContainerID,'abc'); - assert.equal(callback.body[0].operation,'none'); - for(const key of ['env','labels','ports','mounts','binds','network_settings','image','cmd','log_driver','log_driver_options','sysctls'])assert.equal(Object.hasOwn(callback.body[0],key),false,key); + assert.equal(callbacks.slice(before).some(c=>c.method==='PATCH'),false); + assert.ok(callbacks.slice(before).some(c=>c.body?.comments===`operation ${operation}: ok`)); } }); +for (const trigger of ['start', 'stop', 'restart', 'kill', 'pause', 'unpause', 'none', 'rename', 'event:start', 'event:stop', 'event:restart', 'event:kill', 'event:die', 'event:oom', 'event:pause', 'event:unpause']) { + test(`${trigger} settles when manager PATCHes echo back as container webhooks`, async t => { + const { api, agent, netbox, calls, callbacks } = await setup(t); + const errors = []; + agent.log = error => errors.push(error.message); + const resource = netbox.resource.bind(netbox); + let patches = 0; + let managed = { id: 7, host: { id: 9 }, ContainerID: 'abc', name: 'demo', restart_policy: 'always', env: [{ var_name: 'STAGED', value: 'keep' }] }; + netbox.resource = async (method, type, body, suffix) => { + const result = await resource(method, type, body, suffix); + if (method === 'PATCH' && type === 'containers') { + // Bound a regression so a broken implementation fails without hanging. + if (++patches > 3) throw Error('container webhook loop'); + managed = { ...managed, ...(Array.isArray(body) ? body[0] : body) }; + assert.equal((await api('/api/engine/containers', 'PUT', { data: managed })).status, 202); + } + return result; + }; + if (trigger.startsWith('event:')) await agent.event({ Type: 'container', Action: trigger.slice(6), Actor: { ID: 'abc' } }); + else assert.equal((await api('/api/engine/containers', 'PUT', { data: { ...managed, operation: trigger } })).status, 202); + while (agent.jobs.size) await Promise.all([...agent.jobs]); + assert.deepEqual(errors, []); + const settingsOnly = ['none', 'rename'].includes(trigger); + assert.equal(patches, settingsOnly ? 0 : 1); + const journals = callbacks.filter(c => c.url === '/api/extras/journal-entries/'); + assert.equal(journals.length, trigger.startsWith('event:') || settingsOnly ? 2 : 4); + assert.equal(calls.filter(c => c.path === '/containers/abc/update').length, trigger === 'rename' ? 0 : 1); + if (trigger.startsWith('event:') || settingsOnly) assert.deepEqual(managed.env, [{ var_name: 'STAGED', value: 'keep' }]); + }); +} + test('saved owner metadata and configuration survive the next HTTP recreation', async t => { const {api,agent,docker,callbacks}=await setup(t); const desired={id:7,ContainerID:'abc',name:'demo',operation:'none',restart_policy:'always',image:{ImageID:'sha256:upgraded'},env:[{var_name:'SAASHUP_OWNER',value:'owner@example.test'},{var_name:'EMPTY',value:''}],labels:[{key:'traefik.enable',value:'true'}],ports:[{private_port:80,public_port:8080,type:'tcp'}],mounts:[{source:'/data',volume:{name:'persistent-data'},read_only:false}],log_driver:'local',log_driver_options:[{option_name:'max-size',value:'10m'}]}; assert.equal((await api('/api/engine/containers','PUT',{data:desired})).status,202); await Promise.all(agent.jobs); - // Apply the agent's callback as the manager would before sending recreate. - const saved=callbacks.find(c=>c.method==='PATCH'&&c.url==='/api/plugins/docker/containers/').body[0]; - const next={...desired,...saved,operation:'recreate'}; + // The manager retains its staged configuration until recreation applies it. + assert.equal(callbacks.some(c=>c.method==='PATCH'),false); + const next={...desired,operation:'recreate'}; let created; docker.createContainer=async options=>{ created=options;