Add automatic agent updates with verified rollback
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { validateInventory } from '../../src/lib/server/hosts/index.js';
|
||||
|
||||
const update = { enabled: true, state: 'updated', currentVersion: '1.2.0', targetVersion: '1.2.0', checkedAt: '2026-10-04T12:00:00Z' };
|
||||
describe('agent update inventory', () => {
|
||||
it('accepts legacy inventory and only publishes recognized update display fields', () => {
|
||||
expect(validateInventory({ protocolVersion: 1 })).toEqual({ protocolVersion: 1 });
|
||||
expect(validateInventory({ agentUpdate: { ...update, credential: 'private', journal: { backups: 'local-path' } } }).agentUpdate).toEqual(update);
|
||||
});
|
||||
it.each([null, [], 'updated', { ...update, enabled: 'yes' }, { ...update, state: '<script>' },
|
||||
{ ...update, currentVersion: {} }, { ...update, targetVersion: 'invalid' }, { ...update, checkedAt: 'invalid' },
|
||||
{ ...update, error: {} }, { ...update, error: 'x'.repeat(2001) }])('rejects invalid update metadata %j', agentUpdate => {
|
||||
expect(() => validateInventory({ agentUpdate })).toThrow('Invalid agent update inventory');
|
||||
});
|
||||
});
|
||||
@@ -185,10 +185,10 @@ describe('machine and route identity boundaries', () => {
|
||||
expect((await rawConfig.invoke()).status).toBe(403);
|
||||
});
|
||||
it('exempts only static assets on unresolved routes and does not use arbitrary file extensions as public access', async () => {
|
||||
for (const path of ['/_app/immutable/start.js', '/favicon.svg', '/agent/ec-agent.py', '/agent/install.sh', '/agent/manifest.json']) {
|
||||
for (const path of ['/_app/immutable/start.js', '/favicon.svg', '/agent/ec-agent.py', '/agent/ec-agent-update.py', '/agent/install.sh', '/agent/manifest.json']) {
|
||||
const call = requestFor(null, { path }); expect((await call.invoke()).status).toBe(200); expect(call.resolve).toHaveBeenCalledOnce();
|
||||
}
|
||||
for (const path of ['/private.json', '/secrets.css', '/settings/users/__data.json']) {
|
||||
for (const path of ['/private.json', '/secrets.css', '/settings/users/__data.json', '/agent/ec-agent-update.py.bak', '/agent/update-status.json']) {
|
||||
const call = requestFor(null, { path }); await expect(call.invoke()).rejects.toMatchObject({ status: 303 }); expect(call.resolve).not.toHaveBeenCalled();
|
||||
}
|
||||
const write = requestFor(null, { path: '/_app/anything.js', method: 'POST', body: {} });
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { resolve } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const files = vi.hoisted(() => ({ readFile: vi.fn() }));
|
||||
vi.mock('node:fs/promises', () => ({ readFile: files.readFile }));
|
||||
import { agentReleaseAsset } from '../../src/lib/server/hosts/releases.js';
|
||||
import { GET } from '../../src/routes/api/agent/release/[file]/+server.js';
|
||||
|
||||
beforeEach(() => { files.readFile.mockReset(); vi.stubEnv('NODE_ENV', 'production'); });
|
||||
afterEach(() => { vi.unstubAllEnvs(); });
|
||||
|
||||
describe('public agent release artifacts', () => {
|
||||
it('keeps every development artifact on the fresh static release when an older build also exists', async () => {
|
||||
vi.stubEnv('NODE_ENV', 'development');
|
||||
files.readFile.mockImplementation(async (path: string) => path.startsWith(resolve('static/agent')) ? 'fresh-release' : 'old-build');
|
||||
for (const file of ['install.sh', 'ec-agent.py', 'ec-agent-update.py', 'manifest.json'] as const) {
|
||||
expect(await agentReleaseAsset(file)).toBe('fresh-release');
|
||||
}
|
||||
expect(files.readFile.mock.calls.map(call => call[0])).toEqual([
|
||||
resolve('static/agent/install.sh'), resolve('static/agent/ec-agent.py'),
|
||||
resolve('static/agent/ec-agent-update.py'), resolve('static/agent/manifest.json')
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not fill a missing development artifact from an unrelated older build', async () => {
|
||||
vi.stubEnv('NODE_ENV', 'development');
|
||||
const missing = Object.assign(new Error('Not packaged yet'), { code: 'ENOENT' });
|
||||
files.readFile.mockRejectedValueOnce(missing).mockResolvedValue('old-build');
|
||||
await expect(agentReleaseAsset('ec-agent-update.py')).rejects.toBe(missing);
|
||||
expect(files.readFile.mock.calls).toEqual([[resolve('static/agent/ec-agent-update.py'), 'utf8']]);
|
||||
});
|
||||
|
||||
it('uses the packaged production directory for every artifact even when source assets exist', async () => {
|
||||
files.readFile.mockImplementation(async (path: string) => path.startsWith(resolve('build/client/agent')) ? 'packaged-release' : 'source-release');
|
||||
for (const file of ['install.sh', 'ec-agent.py', 'ec-agent-update.py', 'manifest.json'] as const) {
|
||||
expect(await agentReleaseAsset(file)).toBe('packaged-release');
|
||||
}
|
||||
expect(files.readFile.mock.calls.map(call => call[0])).toEqual([
|
||||
resolve('build/client/agent/install.sh'), resolve('build/client/agent/ec-agent.py'),
|
||||
resolve('build/client/agent/ec-agent-update.py'), resolve('build/client/agent/manifest.json')
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(['ec-agent.py', 'ec-agent-update.py', 'manifest.json'])('serves the packaged %s without a browser session or host credential', async file => {
|
||||
const content = file.endsWith('.json') ? '{"version":"1.2.0","updaterProtocolVersion":1}' : '# verified release source\n';
|
||||
files.readFile.mockResolvedValue(content);
|
||||
const response = await GET({ params: { file } });
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe(content);
|
||||
expect(response.headers.get('cache-control')).toBe('no-cache');
|
||||
expect(response.headers.get('content-type')).toContain(file.endsWith('.json') ? 'application/json' : 'text/plain');
|
||||
expect(files.readFile.mock.calls).toEqual([[resolve('build/client/agent', file), 'utf8']]);
|
||||
});
|
||||
|
||||
it('serves the updater from development assets when no packaged asset exists', async () => {
|
||||
files.readFile.mockRejectedValueOnce(Object.assign(new Error('missing'), { code: 'ENOENT' })).mockResolvedValueOnce('# updater\n');
|
||||
expect(await agentReleaseAsset('ec-agent-update.py')).toBe('# updater\n');
|
||||
expect(files.readFile.mock.calls).toEqual([
|
||||
[resolve('build/client/agent/ec-agent-update.py'), 'utf8'],
|
||||
[resolve('static/agent/ec-agent-update.py'), 'utf8']
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not conceal a damaged packaged release by falling back to a different artifact', async () => {
|
||||
const failure = Object.assign(new Error('permission denied'), { code: 'EACCES' });
|
||||
files.readFile.mockRejectedValue(failure);
|
||||
await expect(agentReleaseAsset('ec-agent-update.py')).rejects.toBe(failure);
|
||||
expect(files.readFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(['../ec-agent.py', '%2e%2e%2fmanifest.json', 'agent-health.json', 'update-status.json', 'config.json', 'install.sh', 'ec-agent-update.py/anything'])('rejects non-release file %s before reading disk', async file => {
|
||||
await expect(GET({ params: { file } })).rejects.toMatchObject({ status: 404 });
|
||||
expect(files.readFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { hostAgentUpdateLabel, hostAgentUpdateMessage, hostAgentUpdatesEnabled, type HostInventory } from '../../src/lib/hosts.js';
|
||||
|
||||
const installed: HostInventory = {
|
||||
agentVersion: '1.2.0', capabilities: ['host.decommission', 'agent.update'],
|
||||
agentUpdate: { enabled: true, state: 'current', currentVersion: '1.2.0', checkedAt: '2026-10-04T12:00:00Z' }
|
||||
};
|
||||
|
||||
describe('host automatic update status', () => {
|
||||
it('requires the installed updater capability instead of inferring setup from agent version', () => {
|
||||
for (const inventory of [
|
||||
{}, { agentVersion: '1.2.0' },
|
||||
{ ...installed, capabilities: ['host.decommission'] },
|
||||
{ ...installed, agentUpdate: { ...installed.agentUpdate!, enabled: false } }
|
||||
]) {
|
||||
expect(hostAgentUpdatesEnabled(inventory)).toBe(false);
|
||||
expect(hostAgentUpdateLabel(inventory)).toBe('Setup required');
|
||||
expect(hostAgentUpdateMessage(inventory)).toMatch(/installer once/);
|
||||
}
|
||||
expect(hostAgentUpdatesEnabled(installed)).toBe(true);
|
||||
expect(hostAgentUpdateLabel(installed)).toBe('Current');
|
||||
});
|
||||
|
||||
it('does not claim an unchecked fresh installation has verified the latest release', () => {
|
||||
const fresh = { ...installed, agentUpdate: { ...installed.agentUpdate!, checkedAt: undefined } };
|
||||
expect(hostAgentUpdateLabel(fresh)).toBe('Awaiting first check');
|
||||
expect(hostAgentUpdateMessage(fresh)).toContain('will check');
|
||||
expect(hostAgentUpdateMessage(installed)).toContain('last check');
|
||||
});
|
||||
|
||||
it('distinguishes a deferred check from a failed update without assuming every hold is an active operation', () => {
|
||||
const deferred = { ...installed, agentUpdate: { ...installed.agentUpdate!, state: 'deferred' as const, targetVersion: '1.3.0' } };
|
||||
expect(hostAgentUpdateLabel(deferred)).toBe('Deferred');
|
||||
expect(hostAgentUpdateMessage(deferred)).toContain('reported condition to clear');
|
||||
expect(hostAgentUpdateMessage(deferred)).toContain('checks again automatically');
|
||||
const failed = { ...installed, agentUpdate: { ...installed.agentUpdate!, state: 'failed' as const, error: 'Checksum mismatch' } };
|
||||
expect(hostAgentUpdateLabel(failed)).toBe('Needs attention');
|
||||
expect(hostAgentUpdateMessage(failed)).toContain('did not complete');
|
||||
});
|
||||
|
||||
it('describes connection verification until the updater confirms success', () => {
|
||||
const updating = { ...installed, agentUpdate: { ...installed.agentUpdate!, state: 'updating' as const, targetVersion: '1.3.0' } };
|
||||
expect(hostAgentUpdateLabel(updating)).toBe('Updating');
|
||||
expect(hostAgentUpdateMessage(updating)).toContain('checking that the new process connects');
|
||||
const updated = { ...installed, agentUpdate: { ...installed.agentUpdate!, state: 'updated' as const, currentVersion: '1.3.0' } };
|
||||
expect(hostAgentUpdateLabel(updated)).toBe('Updated');
|
||||
expect(hostAgentUpdateMessage(updated)).toContain('connected successfully');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user