mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-28 11:17:07 +00:00
fix: repin semver compose tags during fleet self-update (#1587)
* fix: repin semver compose tags during fleet self-update Fleet updates failed when docker-compose.yml pinned a semver tag because recreate reused the on-disk pin. Pull the target image first, rewrite semver pins via the update helper, and block digest or unresolved pins with fast 409s. * fix: update OFFLINE_META shape in capability and node-registry meta tests
This commit is contained in:
@@ -61,6 +61,8 @@ describe('fetchRemoteMeta Authorization header', () => {
|
||||
startedAt: null,
|
||||
updateError: null,
|
||||
online: false,
|
||||
imagePinKind: null,
|
||||
updateBlocked: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -233,9 +233,11 @@ describe('GET /api/fleet/update-status (pilot-agent)', () => {
|
||||
startedAt: 1700000000,
|
||||
updateError: null,
|
||||
online: true,
|
||||
imagePinKind: null,
|
||||
updateBlocked: false,
|
||||
};
|
||||
}
|
||||
return { version: null, capabilities: [], startedAt: null, updateError: null, online: false };
|
||||
return { version: null, capabilities: [], startedAt: null, updateError: null, online: false, imagePinKind: null, updateBlocked: false };
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/fleet/update-status').set('Authorization', authHeader);
|
||||
@@ -254,6 +256,8 @@ describe('GET /api/fleet/update-status (pilot-agent)', () => {
|
||||
startedAt: null,
|
||||
updateError: null,
|
||||
online: false,
|
||||
imagePinKind: null,
|
||||
updateBlocked: false,
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/fleet/update-status').set('Authorization', authHeader);
|
||||
|
||||
@@ -36,6 +36,8 @@ const META_ONLINE_OUTDATED: RemoteMeta = {
|
||||
startedAt: 1,
|
||||
updateError: null,
|
||||
online: true,
|
||||
imagePinKind: null,
|
||||
updateBlocked: false,
|
||||
};
|
||||
|
||||
const META_OFFLINE: RemoteMeta = {
|
||||
@@ -44,6 +46,8 @@ const META_OFFLINE: RemoteMeta = {
|
||||
startedAt: null,
|
||||
updateError: null,
|
||||
online: false,
|
||||
imagePinKind: null,
|
||||
updateBlocked: false,
|
||||
};
|
||||
|
||||
const META_NO_SELF_UPDATE: RemoteMeta = {
|
||||
@@ -251,7 +255,7 @@ describe('GET /api/fleet/update-status (remote-meta cache invalidation)', () =>
|
||||
mockTargetForPilot();
|
||||
mockCompareTargetFetch();
|
||||
// Remote now reports a different version than before the update (signal 1).
|
||||
mockMeta({ version: '0.99.0', capabilities: ['stacks'], startedAt: 2, updateError: null, online: true });
|
||||
mockMeta({ version: '0.99.0', capabilities: ['stacks'], startedAt: 2, updateError: null, online: true, imagePinKind: null, updateBlocked: false });
|
||||
|
||||
const tracker = FleetUpdateTrackerService.getInstance();
|
||||
tracker.set(proxyNodeId, tracker.create('updating', '0.83.0', null));
|
||||
@@ -268,7 +272,7 @@ describe('GET /api/fleet/update-status (remote-meta cache invalidation)', () =>
|
||||
it('does not drop the cache on a steady-state completed poll (transition guard)', async () => {
|
||||
mockTargetForPilot();
|
||||
mockCompareTargetFetch();
|
||||
mockMeta({ version: '0.99.0', capabilities: ['stacks'], startedAt: 2, updateError: null, online: true });
|
||||
mockMeta({ version: '0.99.0', capabilities: ['stacks'], startedAt: 2, updateError: null, online: true, imagePinKind: null, updateBlocked: false });
|
||||
|
||||
// Already completed before this poll: no transition, so no invalidation.
|
||||
const tracker = FleetUpdateTrackerService.getInstance();
|
||||
|
||||
@@ -39,6 +39,8 @@ const ONLINE = (over: Partial<RemoteMeta> = {}): RemoteMeta => ({
|
||||
startedAt: 1,
|
||||
updateError: null,
|
||||
online: true,
|
||||
imagePinKind: null,
|
||||
updateBlocked: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
|
||||
@@ -54,6 +54,8 @@ describe('NodeRegistry.fetchMetaForNode', () => {
|
||||
startedAt: null,
|
||||
updateError: null,
|
||||
online: false,
|
||||
imagePinKind: null,
|
||||
updateBlocked: false,
|
||||
});
|
||||
expect(axiosSpy).not.toHaveBeenCalled();
|
||||
db.deleteNode(nodeId);
|
||||
|
||||
@@ -25,7 +25,7 @@ afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
const ONLINE = { startedAt: null, updateError: null, online: true } as const;
|
||||
const ONLINE = { startedAt: null, updateError: null, online: true, imagePinKind: null, updateBlocked: false } as const;
|
||||
const capable: RemoteMeta = { version: '0.93.0', capabilities: ['fleet', 'cross-node-rbac'], ...ONLINE };
|
||||
const incapable: RemoteMeta = { version: '0.92.0', capabilities: ['fleet', 'labels'], ...ONLINE };
|
||||
|
||||
@@ -42,7 +42,7 @@ describe('remoteSupportsCrossNodeRbac', () => {
|
||||
|
||||
it('fails closed when the remote is offline (empty capabilities)', async () => {
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode')
|
||||
.mockResolvedValue({ version: null, capabilities: [], startedAt: null, updateError: null, online: false });
|
||||
.mockResolvedValue({ version: null, capabilities: [], startedAt: null, updateError: null, online: false, imagePinKind: null, updateBlocked: false });
|
||||
expect(await remoteSupportsCrossNodeRbac(NODE_ID)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('remoteSupportsCrossNodeRbac', () => {
|
||||
// A 0.0.0-dev image reports version null (non-semver) but is reachable and
|
||||
// genuinely advertises the capability; it must not be wrongly denied.
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode')
|
||||
.mockResolvedValue({ version: null, capabilities: ['fleet', 'cross-node-rbac'], startedAt: null, updateError: null, online: true });
|
||||
.mockResolvedValue({ version: null, capabilities: ['fleet', 'cross-node-rbac'], startedAt: null, updateError: null, online: true, imagePinKind: null, updateBlocked: false });
|
||||
expect(await remoteSupportsCrossNodeRbac(NODE_ID)).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* Pure-unit coverage for the compose image handling that powers pinned
|
||||
* self-update (GitHub issue: a version-pinned compose image could never update
|
||||
* because recreate reused the running image). Split across the pure helper
|
||||
* module and the three self-update argv builders that carry the repin handoff:
|
||||
* - classifyImagePin / buildTargetImageRef / isValidImageRef,
|
||||
* - resolveServiceImageFromContents (reverse -f precedence),
|
||||
* - patchComposeServiceImage (comment-preserving round-trip),
|
||||
* - buildComposeReadArgs (throwaway cat container),
|
||||
* - the composeCopy branch of buildSelfUpdateComposeCmd,
|
||||
* - the repinWritable branch of buildSelfUpdateRunArgs.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
classifyImagePin,
|
||||
buildTargetImageRef,
|
||||
isRepinBlocked,
|
||||
isValidImageRef,
|
||||
resolveServiceImageFromContents,
|
||||
patchComposeServiceImage,
|
||||
} from '../helpers/selfUpdateCompose';
|
||||
import {
|
||||
buildComposeReadArgs,
|
||||
buildSelfUpdateComposeCmd,
|
||||
buildSelfUpdateRunArgs,
|
||||
shQuote,
|
||||
} from '../services/SelfUpdateService';
|
||||
|
||||
describe('isRepinBlocked', () => {
|
||||
it('blocks digest and unknown pins only', () => {
|
||||
expect(isRepinBlocked('digest')).toBe(true);
|
||||
expect(isRepinBlocked('unknown')).toBe(true);
|
||||
expect(isRepinBlocked('semver')).toBe(false);
|
||||
expect(isRepinBlocked('floating')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyImagePin', () => {
|
||||
it('classifies an explicit semver tag (with and without v prefix) as semver', () => {
|
||||
expect(classifyImagePin('saelix/sencho:0.93.3')).toBe('semver');
|
||||
expect(classifyImagePin('saelix/sencho:v1.2.3')).toBe('semver');
|
||||
expect(classifyImagePin('ghcr.io/studio-saelix/sencho:1.0.0-rc.1')).toBe('semver');
|
||||
});
|
||||
|
||||
it('classifies latest, implicit, and other moving tags as floating', () => {
|
||||
expect(classifyImagePin('saelix/sencho:latest')).toBe('floating');
|
||||
expect(classifyImagePin('saelix/sencho')).toBe('floating'); // implicit latest
|
||||
expect(classifyImagePin('saelix/sencho:dev')).toBe('floating');
|
||||
expect(classifyImagePin('saelix/sencho:edge')).toBe('floating');
|
||||
});
|
||||
|
||||
it('does not mistake a registry port for a tag', () => {
|
||||
// The colon before the last slash is a registry port, not a tag separator,
|
||||
// so an untagged image on a custom-port registry is floating, not semver.
|
||||
expect(classifyImagePin('registry.example.com:5000/sencho')).toBe('floating');
|
||||
expect(classifyImagePin('registry.example.com:5000/sencho:0.93.3')).toBe('semver');
|
||||
});
|
||||
|
||||
it('classifies a digest pin as digest', () => {
|
||||
expect(classifyImagePin('saelix/sencho@sha256:abc123')).toBe('digest');
|
||||
expect(classifyImagePin('saelix/sencho:0.93.3@sha256:abc123')).toBe('digest');
|
||||
});
|
||||
|
||||
it('classifies interpolated, empty, or blank references as unknown', () => {
|
||||
expect(classifyImagePin('saelix/sencho:${SENCHO_TAG}')).toBe('unknown');
|
||||
expect(classifyImagePin('${SENCHO_IMAGE}')).toBe('unknown');
|
||||
expect(classifyImagePin('')).toBe('unknown');
|
||||
expect(classifyImagePin(' ')).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTargetImageRef', () => {
|
||||
it('swaps only the tag and keeps the registry and repository', () => {
|
||||
expect(buildTargetImageRef('saelix/sencho:0.93.3', '0.94.0')).toBe('saelix/sencho:0.94.0');
|
||||
expect(buildTargetImageRef('ghcr.io/studio-saelix/sencho:1.0.0', '1.1.0')).toBe(
|
||||
'ghcr.io/studio-saelix/sencho:1.1.0',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves a v prefix on the current tag', () => {
|
||||
expect(buildTargetImageRef('saelix/sencho:v0.93.3', '0.94.0')).toBe('saelix/sencho:v0.94.0');
|
||||
});
|
||||
|
||||
it('preserves a -dev repository variant (only the tag is replaced)', () => {
|
||||
expect(buildTargetImageRef('ghcr.io/studio-saelix/sencho-dev:0.93.3', '0.94.0')).toBe(
|
||||
'ghcr.io/studio-saelix/sencho-dev:0.94.0',
|
||||
);
|
||||
});
|
||||
|
||||
it('drops any digest suffix on the current ref', () => {
|
||||
expect(buildTargetImageRef('saelix/sencho:0.93.3@sha256:abc123', '0.94.0')).toBe(
|
||||
'saelix/sencho:0.94.0',
|
||||
);
|
||||
});
|
||||
|
||||
it('adds a tag when the current ref has none', () => {
|
||||
expect(buildTargetImageRef('saelix/sencho', '0.94.0')).toBe('saelix/sencho:0.94.0');
|
||||
expect(buildTargetImageRef('registry.example.com:5000/sencho', '0.94.0')).toBe(
|
||||
'registry.example.com:5000/sencho:0.94.0',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidImageRef', () => {
|
||||
it('accepts plausible registry/repo:tag and digest references', () => {
|
||||
expect(isValidImageRef('saelix/sencho:0.93.3')).toBe(true);
|
||||
expect(isValidImageRef('ghcr.io/studio-saelix/sencho-dev:v1.2.3')).toBe(true);
|
||||
expect(isValidImageRef('registry.example.com:5000/sencho@sha256:abc')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty, whitespace, and control-character references', () => {
|
||||
expect(isValidImageRef('')).toBe(false);
|
||||
expect(isValidImageRef('saelix/sencho :0.93.3')).toBe(false);
|
||||
expect(isValidImageRef('saelix/sencho\n')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects references with shell metacharacters or a leading separator', () => {
|
||||
expect(isValidImageRef('saelix/sencho;rm -rf /')).toBe(false);
|
||||
expect(isValidImageRef('$(touch pwned)')).toBe(false);
|
||||
expect(isValidImageRef(':leadingcolon')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an implausibly long reference', () => {
|
||||
expect(isValidImageRef('a'.repeat(513))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveServiceImageFromContents', () => {
|
||||
const base = 'services:\n sencho:\n image: saelix/sencho:0.93.3\n';
|
||||
|
||||
it('returns the service image and classifies its pin', () => {
|
||||
const resolved = resolveServiceImageFromContents([{ filePath: '/c/base.yml', content: base }], 'sencho');
|
||||
expect(resolved).toEqual({
|
||||
filePath: '/c/base.yml',
|
||||
imageRef: 'saelix/sencho:0.93.3',
|
||||
fileContent: base,
|
||||
pinKind: 'semver',
|
||||
});
|
||||
});
|
||||
|
||||
it('lets a later -f override win (reverse precedence)', () => {
|
||||
// Docker Compose merges later -f files over earlier ones, so the resolver
|
||||
// must scan in reverse and return the highest-precedence declaration.
|
||||
const override = 'services:\n sencho:\n image: saelix/sencho:latest\n';
|
||||
const resolved = resolveServiceImageFromContents(
|
||||
[
|
||||
{ filePath: '/c/base.yml', content: base },
|
||||
{ filePath: '/c/override.yml', content: override },
|
||||
],
|
||||
'sencho',
|
||||
);
|
||||
expect(resolved?.filePath).toBe('/c/override.yml');
|
||||
expect(resolved?.imageRef).toBe('saelix/sencho:latest');
|
||||
expect(resolved?.pinKind).toBe('floating');
|
||||
});
|
||||
|
||||
it('falls back to the base file when the override does not set the image', () => {
|
||||
// A common override only tweaks ports/env; it must not shadow the base image.
|
||||
const override = 'services:\n sencho:\n ports:\n - "1852:1852"\n';
|
||||
const resolved = resolveServiceImageFromContents(
|
||||
[
|
||||
{ filePath: '/c/base.yml', content: base },
|
||||
{ filePath: '/c/override.yml', content: override },
|
||||
],
|
||||
'sencho',
|
||||
);
|
||||
expect(resolved?.filePath).toBe('/c/base.yml');
|
||||
expect(resolved?.imageRef).toBe('saelix/sencho:0.93.3');
|
||||
});
|
||||
|
||||
it('skips a malformed override and resolves from the next readable file', () => {
|
||||
const malformed = 'services:\n sencho:\n image: : : bad';
|
||||
const resolved = resolveServiceImageFromContents(
|
||||
[
|
||||
{ filePath: '/c/base.yml', content: base },
|
||||
{ filePath: '/c/broken.yml', content: malformed },
|
||||
],
|
||||
'sencho',
|
||||
);
|
||||
expect(resolved?.filePath).toBe('/c/base.yml');
|
||||
});
|
||||
|
||||
it('returns null when no file declares a string image for the service', () => {
|
||||
const noImage = 'services:\n sencho:\n build: .\n';
|
||||
expect(resolveServiceImageFromContents([{ filePath: '/c/x.yml', content: noImage }], 'sencho')).toBeNull();
|
||||
expect(resolveServiceImageFromContents([{ filePath: '/c/x.yml', content: base }], 'other')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('patchComposeServiceImage', () => {
|
||||
it('rewrites only the target service image and preserves comments and other keys', () => {
|
||||
const content = [
|
||||
'# Sencho self-hosted',
|
||||
'services:',
|
||||
' sencho:',
|
||||
' image: saelix/sencho:0.93.3 # pinned',
|
||||
' ports:',
|
||||
' - "1852:1852"',
|
||||
' db:',
|
||||
' image: postgres:16',
|
||||
'',
|
||||
].join('\n');
|
||||
const patched = patchComposeServiceImage(content, 'sencho', 'saelix/sencho:0.94.0');
|
||||
expect(patched).toContain('image: saelix/sencho:0.94.0');
|
||||
expect(patched).not.toContain('saelix/sencho:0.93.3');
|
||||
// Comments, sibling service, and unrelated keys survive the round-trip.
|
||||
expect(patched).toContain('# Sencho self-hosted');
|
||||
expect(patched).toContain('image: postgres:16');
|
||||
expect(patched).toContain('- "1852:1852"');
|
||||
});
|
||||
|
||||
it('throws when the service has no image to patch (never silently no-ops)', () => {
|
||||
const content = 'services:\n sencho:\n build: .\n';
|
||||
expect(() => patchComposeServiceImage(content, 'sencho', 'saelix/sencho:0.94.0')).toThrow(/no image/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildComposeReadArgs', () => {
|
||||
it('emits a throwaway root cat container that mounts the working dir read-only', () => {
|
||||
const args = buildComposeReadArgs('/opt/sencho', 'saelix/sencho:0.93.3', '/opt/sencho/docker-compose.yml');
|
||||
expect(args).toEqual([
|
||||
'run', '--rm',
|
||||
'--user', 'root',
|
||||
'--entrypoint', 'cat',
|
||||
'-v', '/opt/sencho:/opt/sencho:ro',
|
||||
'-w', '/opt/sencho',
|
||||
'saelix/sencho:0.93.3',
|
||||
'/opt/sencho/docker-compose.yml',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps operator paths as discrete argv data (execFile spawns no shell)', () => {
|
||||
const args = buildComposeReadArgs('/srv/$(touch pwned)', 'img', '/srv/$(touch pwned)/compose.yml');
|
||||
expect(args).toContain('/srv/$(touch pwned):/srv/$(touch pwned):ro');
|
||||
expect(args[args.length - 1]).toBe('/srv/$(touch pwned)/compose.yml');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSelfUpdateComposeCmd (repin copy branch)', () => {
|
||||
const fFlags = ['-f', '/opt/sencho/docker-compose.yml'];
|
||||
const stderrTmp = '/tmp/_sencho_err';
|
||||
const errorFile = '/app/data/.sencho-update-error';
|
||||
|
||||
it('copies the staged compose file onto the host before recreate', () => {
|
||||
const cmd = buildSelfUpdateComposeCmd(fFlags, 'sencho', stderrTmp, errorFile, false, {
|
||||
stagedPath: '/app/data/.sencho-compose-patch',
|
||||
targetPath: '/opt/sencho/docker-compose.yml',
|
||||
});
|
||||
// The copy runs before the recreate so a failed write never half-applies.
|
||||
expect(cmd.indexOf('cp ')).toBeLessThan(cmd.indexOf('up -d --force-recreate'));
|
||||
expect(cmd).toContain(
|
||||
`cp ${shQuote('/app/data/.sencho-compose-patch')} ${shQuote('/opt/sencho/docker-compose.yml')}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('aborts before recreate when the copy fails and records the error', () => {
|
||||
const cmd = buildSelfUpdateComposeCmd(fFlags, 'sencho', stderrTmp, errorFile, false, {
|
||||
stagedPath: '/app/data/.sencho-compose-patch',
|
||||
targetPath: '/opt/sencho/docker-compose.yml',
|
||||
});
|
||||
// A failed copy writes the error file and exits 1 before any recreate.
|
||||
expect(cmd).toContain('exit 1');
|
||||
expect(cmd.indexOf('exit 1')).toBeLessThan(cmd.indexOf('up -d --force-recreate'));
|
||||
expect(cmd).toContain('Failed to write the updated compose file');
|
||||
});
|
||||
|
||||
it('shell-quotes the copy paths so metacharacters cannot break the command', () => {
|
||||
const cmd = buildSelfUpdateComposeCmd(fFlags, 'sencho', stderrTmp, errorFile, false, {
|
||||
stagedPath: '/app/data/x; rm -rf /',
|
||||
targetPath: '/opt/sencho/y; echo pwned',
|
||||
});
|
||||
expect(cmd).toContain(shQuote('/app/data/x; rm -rf /'));
|
||||
expect(cmd).toContain(shQuote('/opt/sencho/y; echo pwned'));
|
||||
expect(cmd).not.toContain('cp /app/data/x; rm -rf /');
|
||||
});
|
||||
|
||||
it('omits the copy step entirely when no composeCopy is supplied', () => {
|
||||
const cmd = buildSelfUpdateComposeCmd(fFlags, 'sencho', stderrTmp, errorFile, false);
|
||||
expect(cmd).not.toContain('cp ');
|
||||
expect(cmd).not.toContain('Failed to write the updated compose file');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSelfUpdateRunArgs (repinWritable branch)', () => {
|
||||
const COMPOSE = 'COMPOSE_CMD';
|
||||
|
||||
it('mounts the working dir read-write only when a repin is staged', () => {
|
||||
const args = buildSelfUpdateRunArgs(
|
||||
{ workingDir: '/opt/sencho', imageName: 'img', dataDirHost: '/opt/sencho/data', hostBindMounts: [], repinWritable: true },
|
||||
COMPOSE,
|
||||
);
|
||||
expect(args).toContain('/opt/sencho:/opt/sencho:rw');
|
||||
expect(args).not.toContain('/opt/sencho:/opt/sencho:ro');
|
||||
});
|
||||
|
||||
it('keeps the working dir read-only when no repin is staged (minimal write scope)', () => {
|
||||
const args = buildSelfUpdateRunArgs(
|
||||
{ workingDir: '/opt/sencho', imageName: 'img', dataDirHost: '/opt/sencho/data', hostBindMounts: [], repinWritable: false },
|
||||
COMPOSE,
|
||||
);
|
||||
expect(args).toContain('/opt/sencho:/opt/sencho:ro');
|
||||
expect(args).not.toContain('/opt/sencho:/opt/sencho:rw');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Route coverage for pinned-compose self-update: targetVersion validation,
|
||||
* synchronous preflight (409 before 202), and the safe public /api/meta pin
|
||||
* subset (imagePinKind + updateBlocked, never composeImageRef).
|
||||
*/
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { buildTargetImageRef } from '../helpers/selfUpdateCompose';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let adminAuth: string;
|
||||
let localNodeId: number;
|
||||
let SelfUpdateService: typeof import('../services/SelfUpdateService').default;
|
||||
let FleetUpdateTrackerService: typeof import('../services/FleetUpdateTrackerService').FleetUpdateTrackerService;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
|
||||
function mockSelfUpdateAvailable(over: {
|
||||
pinInfo?: { pinKind: 'semver' | 'floating' | 'digest' | 'unknown'; composeImageRef: string; filePath: string } | null;
|
||||
preflight?: { ok: true } | { ok: false; reason: string };
|
||||
available?: boolean;
|
||||
} = {}) {
|
||||
const svc = SelfUpdateService.getInstance();
|
||||
vi.spyOn(svc, 'isAvailable').mockReturnValue(over.available ?? true);
|
||||
vi.spyOn(svc, 'getPinInfo').mockResolvedValue(over.pinInfo ?? null);
|
||||
vi.spyOn(svc, 'canSelfUpdateTarget').mockResolvedValue(over.preflight ?? { ok: true });
|
||||
vi.spyOn(svc, 'triggerUpdate').mockResolvedValue(undefined);
|
||||
vi.spyOn(svc, 'getLastError').mockReturnValue(null);
|
||||
}
|
||||
|
||||
function mockCompareTargetFetch() {
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async () =>
|
||||
new Response(JSON.stringify({ tag_name: 'v0.99.0' }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
SelfUpdateService = (await import('../services/SelfUpdateService')).default;
|
||||
({ FleetUpdateTrackerService } = await import('../services/FleetUpdateTrackerService'));
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
|
||||
localNodeId = DatabaseService.getInstance().getNodes().find(n => n.type === 'local')!.id;
|
||||
adminAuth = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' })}`;
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
const tracker = FleetUpdateTrackerService.getInstance();
|
||||
for (const [id] of tracker.entries()) tracker.delete(id);
|
||||
});
|
||||
|
||||
describe('POST /api/system/update', () => {
|
||||
it('returns 400 for a supplied but invalid targetVersion', async () => {
|
||||
mockSelfUpdateAvailable();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/system/update')
|
||||
.set('Authorization', adminAuth)
|
||||
.send({ targetVersion: 'not-a-version' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body?.error).toMatch(/invalid target version/i);
|
||||
});
|
||||
|
||||
it('returns 409 when preflight blocks a digest or unknown pin', async () => {
|
||||
mockSelfUpdateAvailable({
|
||||
preflight: { ok: false, reason: 'Cannot repin digest pin.' },
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/system/update')
|
||||
.set('Authorization', adminAuth)
|
||||
.send({ targetVersion: '0.99.0' });
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body?.code).toBe('update_blocked');
|
||||
expect(res.body?.error).toMatch(/cannot repin/i);
|
||||
});
|
||||
|
||||
it('returns 202 and schedules the update when preflight passes', async () => {
|
||||
const triggerSpy = vi.spyOn(SelfUpdateService.getInstance(), 'triggerUpdate').mockResolvedValue(undefined);
|
||||
mockSelfUpdateAvailable({ preflight: { ok: true } });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/system/update')
|
||||
.set('Authorization', adminAuth)
|
||||
.send({ targetVersion: '0.99.0' });
|
||||
|
||||
expect(res.status).toBe(202);
|
||||
expect(res.body?.message).toMatch(/restart/i);
|
||||
// triggerUpdate runs on res finish + delay; flush the microtask queue.
|
||||
await new Promise(r => setTimeout(r, 600));
|
||||
expect(triggerSpy).toHaveBeenCalledWith({ targetVersion: '0.99.0' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/meta pin subset', () => {
|
||||
it('exposes imagePinKind and updateBlocked but never composeImageRef', async () => {
|
||||
mockSelfUpdateAvailable({
|
||||
pinInfo: { pinKind: 'semver', composeImageRef: 'saelix/sencho:0.93.3', filePath: '/opt/sencho/docker-compose.yml' },
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/meta');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.imagePinKind).toBe('semver');
|
||||
expect(res.body.updateBlocked).toBe(false);
|
||||
expect(res.body).not.toHaveProperty('composeImageRef');
|
||||
expect(res.body).not.toHaveProperty('targetImageRef');
|
||||
});
|
||||
|
||||
it('reports updateBlocked=true for a digest pin', async () => {
|
||||
mockSelfUpdateAvailable({
|
||||
pinInfo: { pinKind: 'digest', composeImageRef: 'saelix/sencho@sha256:abc', filePath: '/opt/sencho/docker-compose.yml' },
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/meta');
|
||||
|
||||
expect(res.body.imagePinKind).toBe('digest');
|
||||
expect(res.body.updateBlocked).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/fleet/nodes/:nodeId/update (local preflight)', () => {
|
||||
it('returns 409 before 202 when the local pin cannot be repinned', async () => {
|
||||
mockCompareTargetFetch();
|
||||
mockSelfUpdateAvailable({
|
||||
preflight: { ok: false, reason: 'Blocked by digest pin.' },
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/fleet/nodes/${localNodeId}/update`)
|
||||
.set('Authorization', adminAuth)
|
||||
.send({ targetVersion: '0.99.0' });
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body?.code).toBe('update_blocked');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/fleet/update-status pin projection', () => {
|
||||
it('includes compose pin fields for the local node on the authenticated route', async () => {
|
||||
mockCompareTargetFetch();
|
||||
mockSelfUpdateAvailable({
|
||||
pinInfo: { pinKind: 'semver', composeImageRef: 'saelix/sencho:0.83.0', filePath: '/opt/sencho/docker-compose.yml' },
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/fleet/update-status')
|
||||
.set('Authorization', adminAuth);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const local = res.body.nodes.find((n: { nodeId: number }) => n.nodeId === localNodeId);
|
||||
expect(local.imagePinKind).toBe('semver');
|
||||
expect(local.composeImageRef).toBe('saelix/sencho:0.83.0');
|
||||
expect(local.targetImageRef).toBe(buildTargetImageRef(local.composeImageRef, local.latestVersion));
|
||||
expect(local.updateBlocked).toBe(false);
|
||||
});
|
||||
|
||||
it('marks the local node updateBlocked when the pin is unknown', async () => {
|
||||
mockCompareTargetFetch();
|
||||
mockSelfUpdateAvailable({
|
||||
pinInfo: { pinKind: 'unknown', composeImageRef: 'saelix/sencho:${TAG}', filePath: '/opt/sencho/docker-compose.yml' },
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/fleet/update-status')
|
||||
.set('Authorization', adminAuth);
|
||||
|
||||
const local = res.body.nodes.find((n: { nodeId: number }) => n.nodeId === localNodeId);
|
||||
expect(local.updateBlocked).toBe(true);
|
||||
expect(local.updateBlockedReason).toMatch(/cannot update automatically/i);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user