feat(fleet): detect and update from a new sencho-dev:dev build (#1871)

* feat(fleet): add self dev-build detection primitives

Split compareLocalToRemoteTag into compareLocalToRemoteTagDetailed (returns
the probe's primary digest alongside the match/update/error verdict) with
compareLocalToRemoteTag now a thin wrapper, so a caller that needs both the
verdict and the digest no longer has to probe the same mutable tag twice.

Add detectSelfDevBuildUpdate, which compares the running container's own
image against the rolling ghcr.io/studio-saelix/sencho-dev:dev tag using the
new detailed comparison, laying the groundwork for surfacing dev-build
updates in Fleet.

* feat: add isSenchoDevRepository and isSenchoDevFloatingTag predicates

Add two pure predicate functions to helpers/selfUpdateCompose.ts for
identifying Sencho dev repository references and floating tag variants:

- isSenchoDevRepository: checks if a reference is to the ghcr.io/studio-saelix/sencho-dev
  repository, including digest-pinned and dev-<sha> tag variants
- isSenchoDevFloatingTag: checks if a reference is specifically the floating :dev tag
  on the Sencho dev repository (not digest-pinned, not immutable dev-<sha>)

Both functions reuse existing parsing patterns (normalizeImageRepository for repository
extraction, classifyImagePin idiom for digest and tag detection) to maintain consistency.

Add comprehensive test coverage in self-update-compose.test.ts covering all specified
test cases including edge cases (malformed refs, unrelated repos, digest pins, etc.).

* feat(gitops): wire dev-build detection into MonitorService

Adds a dev_build_update_available notification category and a new
checkSenchoDevBuild() cycle in MonitorService that detects when the
running container has fallen behind the rolling
ghcr.io/studio-saelix/sencho-dev:dev build it is pinned to, using
detectSelfDevBuildUpdate() and isSenchoDevFloatingTag(). Availability
state is written unconditionally so the Fleet update affordance never
depends on notification delivery succeeding, while a separate dedup
key prevents re-notifying for a digest already announced. Also guards
checkSenchoVersion() so a dev-repo pin no longer produces a false
positive stable-release update notification.

* feat(fleet): surface dev-image status and build availability

Fleet's GET /update-status now reports isDevImage (any reference to the
sencho-dev repository, including digest pins) and devBuildUpdateAvailable
(the exact floating :dev tag with a newer build observed, read from the
system-state key MonitorService already maintains). A dev-pinned local
node forces updateAvailable to false and clears any stale stable-release
skip, since that skip was computed before image-pin classification and
would otherwise leak a bogus "Skipped" state onto a dev row.

Made MonitorService's SENCHO_DEV_BUILD_AVAILABLE_KEY constant public so
both call sites share one string instead of duplicating it.

* fix(fleet): omit targetVersion for a dev-image update trigger

updateRequestInit() always forwarded latestVersion (the latest stable
release) as targetVersion whenever it was valid semver, even for a
dev-pinned node. The backend already ignores targetVersion safely for a
floating pin, so this never caused an actual repin, but it produced a
misleading "Update to X.Y.Z" button label and confirm-dialog copy for an
update that installs the dev image, not that stable release.

* feat(fleet): add integration-image badge and dev build update button

NodeCard now shows a persistent "Integration image" badge whenever a
node's compose image is any sencho-dev reference, independent of update
availability, visible to every role. When a newer dev build is available,
a solid brand-colored "Update dev build" button appears alongside it,
admin-only, reusing the existing update trigger and requireAdmin route.
Styled distinctly from the neutral stable "Update to X.Y.Z" button so an
operator always knows which channel they're acting on.

* feat(fleet): add dev-image copy to the local update confirm dialog

LocalUpdateConfirmDialog now recognizes isDevImage and shows a distinct
LOCAL - DEV UPDATE kicker plus copy stating the sencho-dev:dev reference
will be pulled without rewriting the compose image, and that integration
images are unsigned and carry no release attestations. Without this, a
dev-pinned node's update confirmation fell through to the generic "Pulls
Sencho the latest release" copy. FleetView.tsx threads isDevImage from
the node's update status through to the dialog, same source as its other
pin fields.

* feat(fleet): separate dev and stable availability in the Node Updates sheet

The sheet counted stable and dev availability together via the same
updateAvailable field, so a dev-pinned node with a build available fell
into neither the summary counts nor any row action, and would have
misleadingly rendered as "Up to date" once devBuildUpdateAvailable
existed. stableAvailable and devAvailable are now tracked separately: the
changelog dot lights only from stableAvailable (a dev build has no
release changelog), the summary and meta text report the combined total,
a dev row shows "Integration build" instead of a stable version in the
Latest column, and the existing Update button/badge now also fires for
devBuildUpdateAvailable. Update all and Skip stay stable-only, since both
already gate on fields a dev row never satisfies.

* feat(fleet): bring dev-build detection and update to Mobile Fleet

Mobile Fleet previously had no update capability at all: it only polled
/fleet/overview and never called useFleetUpdateStatus, so it could not
show the stable update flow either. It now fetches update status
alongside the overview poll, shows the same "integration" marker as
desktop on any dev-pinned node's card (visible to every role), and gives
admins a dev-build update action.

The action renders as a sibling of the card's own button rather than
nested inside it, since the card is itself a <button> and a nested
button is invalid HTML with broken touch semantics. It reuses the exact
same triggerNodeUpdate/confirmLocalUpdate flow and LocalUpdateConfirmDialog
/ReconnectingOverlay components desktop already renders, so there is no
parallel API implementation to keep in sync.

* feat(notifications): wire dev_build_update_available through the frontend

Adds the category to the frontend NotificationCategory union, its bell
label, the per-node "mute update notifications" bundle, and the bell's
friendly dot-color memo. The changelog navigation and "View changelog"
button stay scoped to node_update_available only: a dev build has no
release changelog entry to navigate to.

* docs: document dev-build detection and update on Fleet

Adds the dev_build_update_available notification category, the
persistent Integration image marker, and the dev-build update action
(desktop and mobile) to the alerts-notifications, verifying-images,
fleet-view, remote-updates, and upgrade pages. States the detection
cadence explicitly: it polls on a fixed interval and reflects the newest
build observed, not necessarily every individual build.

* fix(gitops): sanitize the inconclusive-reason debug log for log injection

CodeQL flagged the dev-build check's debug log as depending on a
user-influenced value (a registry probe failure reason can trace back to
external input). Wraps it with sanitizeForLog(), the existing repo-wide
remediation for this class of finding, matching how registry-api.ts
already handles the same pattern.

* test(gitops): cover the no-repin invariant on a dev-build self-update

Proves triggerUpdate(), called with neither targetVersion nor
targetImageRef (the exact dev-build update call), pulls the current
compose-declared ref unchanged and never stages a compose rewrite.

* fix(fleet): use the shared busy-button pattern on Mobile Fleet's dev update action

Replaces the local Loader2 plus boolean pending logic with BusyButton
so busy behavior and interaction locking stay in sync with the rest
of the app's async click surfaces.

* test(gitops): exercise the production call shape in the no-repin regression

Fleet substitutes the stable compare target when the request body omits
one, so SelfUpdateService receives a targetVersion even for a dev-build
update. The guard that protects a :dev install is therefore the semver
check inside the repin branch, not the absence of a target.

Drives triggerUpdate with a forwarded target against a floating :dev pin
and asserts the reference is pulled unchanged with no staged patch, and
pairs it with a semver case so the negative assertions cannot pass
vacuously.
This commit is contained in:
Anso
2026-08-30 19:54:19 +00:00
committed by GitHub
parent c6d9fb98e5
commit 275c654407
36 changed files with 1803 additions and 93 deletions
@@ -22,6 +22,10 @@ const { mockGetGlobalSettings, mockGetNodes, mockGetStackAlerts, mockAddContaine
mockGetLatestVersion,
mockGetLatestVersionInfo,
mockGetSenchoVersion,
mockGetPinInfo,
mockGetIdentity,
mockGetAuthForRegistry,
mockDetectSelfDevBuildUpdate,
} = vi.hoisted(() => ({
mockGetGlobalSettings: vi.fn().mockReturnValue({}),
mockGetNodes: vi.fn().mockReturnValue([]),
@@ -57,6 +61,16 @@ const { mockGetGlobalSettings, mockGetNodes, mockGetStackAlerts, mockAddContaine
mockGetLatestVersion: vi.fn().mockResolvedValue(null),
mockGetLatestVersionInfo: vi.fn().mockResolvedValue(null),
mockGetSenchoVersion: vi.fn().mockReturnValue(null),
// Default: no known compose pin, so checkSenchoVersion() falls through
// unchanged and checkSenchoDevBuild() is a no-op, matching pre-existing
// test expectations.
mockGetPinInfo: vi.fn().mockResolvedValue(null),
mockGetIdentity: vi.fn().mockReturnValue({
containerId: null, containerName: null, composeProjectName: null,
imageId: null, networkNames: [], volumeNames: [],
}),
mockGetAuthForRegistry: vi.fn().mockResolvedValue(null),
mockDetectSelfDevBuildUpdate: vi.fn().mockResolvedValue({ kind: 'up_to_date' }),
}));
vi.mock('../services/DatabaseService', () => ({
@@ -125,6 +139,34 @@ vi.mock('../services/NotificationService', () => ({
},
}));
vi.mock('../services/SelfUpdateService', () => ({
default: {
getInstance: () => ({
getPinInfo: mockGetPinInfo,
}),
},
}));
vi.mock('../services/SelfIdentityService', () => ({
default: {
getInstance: () => ({
getIdentity: mockGetIdentity,
}),
},
}));
vi.mock('../services/RegistryService', () => ({
RegistryService: {
getInstance: () => ({
getAuthForRegistry: mockGetAuthForRegistry,
}),
},
}));
vi.mock('../services/selfDevBuildDetect', () => ({
detectSelfDevBuildUpdate: (...args: unknown[]) => mockDetectSelfDevBuildUpdate(...args),
}));
vi.mock('../services/NodeRegistry', () => ({
NodeRegistry: {
getInstance: () => ({
@@ -165,6 +207,13 @@ beforeEach(() => {
(MonitorService as any).instance = undefined;
_resetHostAlertSuppressionStateForTests();
mockGetSystemState.mockReturnValue(null);
mockGetPinInfo.mockResolvedValue(null);
mockGetIdentity.mockReturnValue({
containerId: null, containerName: null, composeProjectName: null,
imageId: null, networkNames: [], volumeNames: [],
});
mockGetAuthForRegistry.mockResolvedValue(null);
mockDetectSelfDevBuildUpdate.mockResolvedValue({ kind: 'up_to_date' });
});
// si.mem() returns active/available alongside used (used counts reclaimable page
@@ -1378,6 +1427,202 @@ describe('MonitorService - Sencho version check', () => {
});
});
describe('MonitorService - Sencho dev build check', () => {
const DEV_PIN = { pinKind: 'floating' as const, composeImageRef: 'ghcr.io/studio-saelix/sencho-dev:dev', filePath: '/compose/docker-compose.yml' };
const DEV_SHA_PIN = { pinKind: 'floating' as const, composeImageRef: 'ghcr.io/studio-saelix/sencho-dev:dev-abc1234', filePath: '/compose/docker-compose.yml' };
const DEV_DIGEST_PIN = { pinKind: 'digest' as const, composeImageRef: `ghcr.io/studio-saelix/sencho-dev@sha256:${'a'.repeat(64)}`, filePath: '/compose/docker-compose.yml' };
const STABLE_PIN = { pinKind: 'semver' as const, composeImageRef: 'ghcr.io/studio-saelix/sencho:0.97.1', filePath: '/compose/docker-compose.yml' };
/** In-memory system_state so get/set round-trip within one evaluation. */
function wireStatefulSystemState(seed: Record<string, string> = {}) {
const store: Record<string, string> = { ...seed };
mockGetSystemState.mockImplementation((key: string) => store[key] ?? null);
mockSetSystemState.mockImplementation((key: string, value: string) => { store[key] = value; });
return store;
}
async function runEvaluate(): Promise<void> {
await (MonitorService.getInstance() as any).evaluate();
}
/**
* checkSenchoDevBuild() is cadence-gated (5 or 30 minutes), so back-to-back
* calls in a single test are otherwise short-circuited before the detector
* ever runs. Tests that exercise a second check bypass the gate directly;
* tests targeting the gate itself assert `lastDevBuildCheckGateMs` instead.
*/
function bypassCadenceGate() {
(MonitorService.getInstance() as any).lastDevBuildCheckAt = 0;
}
function devBuildCalls(): unknown[][] {
return mockDispatchAlert.mock.calls.filter(
(args: unknown[]) => args[1] === 'dev_build_update_available',
);
}
beforeEach(() => {
mockGetGlobalSettings.mockReturnValue({});
mockGetNodes.mockReturnValue([]);
mockGetStackAlerts.mockReturnValue([]);
mockGetIdentity.mockReturnValue({
containerId: null, containerName: null, composeProjectName: null,
imageId: 'deadbeefcafe0000', networkNames: [], volumeNames: [],
});
});
it('dispatches and persists dedup state on first detected update', async () => {
const store = wireStatefulSystemState();
mockGetPinInfo.mockResolvedValue(DEV_PIN);
mockDetectSelfDevBuildUpdate.mockResolvedValue({ kind: 'update', digest: 'sha256:d1' });
await runEvaluate();
expect(devBuildCalls()).toHaveLength(1);
expect(devBuildCalls()[0][2]).toContain('ghcr.io/studio-saelix/sencho-dev:dev');
expect(store.sencho_dev_build_available_digest).toBe('sha256:d1');
expect(store.last_sencho_dev_build_notified_digest).toBe('sha256:d1');
});
it('does not re-notify for the same digest (dedup)', async () => {
const store = wireStatefulSystemState();
mockGetPinInfo.mockResolvedValue(DEV_PIN);
mockDetectSelfDevBuildUpdate.mockResolvedValue({ kind: 'update', digest: 'sha256:d1' });
await runEvaluate();
expect(devBuildCalls()).toHaveLength(1);
bypassCadenceGate();
await runEvaluate();
expect(devBuildCalls()).toHaveLength(1);
expect(store.sencho_dev_build_available_digest).toBe('sha256:d1');
expect(store.last_sencho_dev_build_notified_digest).toBe('sha256:d1');
});
it('notifies again when a later digest appears', async () => {
const store = wireStatefulSystemState();
mockGetPinInfo.mockResolvedValue(DEV_PIN);
mockDetectSelfDevBuildUpdate.mockResolvedValue({ kind: 'update', digest: 'sha256:d1' });
await runEvaluate();
bypassCadenceGate();
mockDetectSelfDevBuildUpdate.mockResolvedValue({ kind: 'update', digest: 'sha256:d2' });
await runEvaluate();
expect(devBuildCalls()).toHaveLength(2);
expect(store.sencho_dev_build_available_digest).toBe('sha256:d2');
expect(store.last_sencho_dev_build_notified_digest).toBe('sha256:d2');
});
it('clears the availability key when up to date', async () => {
const store = wireStatefulSystemState({ sencho_dev_build_available_digest: 'sha256:old' });
mockGetPinInfo.mockResolvedValue(DEV_PIN);
mockDetectSelfDevBuildUpdate.mockResolvedValue({ kind: 'up_to_date' });
await runEvaluate();
expect(store.sencho_dev_build_available_digest).toBe('');
expect(devBuildCalls()).toHaveLength(0);
});
it('does not touch state when inconclusive, and uses the short cadence gate for the next check', async () => {
const store = wireStatefulSystemState({ sencho_dev_build_available_digest: 'sha256:existing' });
mockGetPinInfo.mockResolvedValue(DEV_PIN);
mockDetectSelfDevBuildUpdate.mockResolvedValue({ kind: 'inconclusive', reason: 'registry unreachable' });
await runEvaluate();
expect(store.sencho_dev_build_available_digest).toBe('sha256:existing');
expect(store.last_sencho_dev_build_notified_digest).toBeUndefined();
expect(devBuildCalls()).toHaveLength(0);
expect((MonitorService.getInstance() as any).lastDevBuildCheckGateMs).toBe(5 * 60 * 1000);
});
it('keeps the availability key set but leaves the notified key unchanged when the dispatch is not persisted, and retries next check', async () => {
const store = wireStatefulSystemState();
mockGetPinInfo.mockResolvedValue(DEV_PIN);
mockDetectSelfDevBuildUpdate.mockResolvedValue({ kind: 'update', digest: 'sha256:d1' });
mockDispatchAlert.mockResolvedValueOnce({ persisted: false });
await runEvaluate();
expect(store.sencho_dev_build_available_digest).toBe('sha256:d1');
expect(store.last_sencho_dev_build_notified_digest).toBeUndefined();
expect(devBuildCalls()).toHaveLength(1);
expect((MonitorService.getInstance() as any).lastDevBuildCheckGateMs).toBe(5 * 60 * 1000);
bypassCadenceGate();
mockDispatchAlert.mockResolvedValueOnce({ persisted: true });
await runEvaluate();
expect(devBuildCalls()).toHaveLength(2);
expect(store.last_sencho_dev_build_notified_digest).toBe('sha256:d1');
});
it('makes no registry call for an immutable dev-<sha> tag', async () => {
mockGetPinInfo.mockResolvedValue(DEV_SHA_PIN);
await runEvaluate();
expect(mockDetectSelfDevBuildUpdate).not.toHaveBeenCalled();
expect(mockSetSystemState).not.toHaveBeenCalled();
expect(devBuildCalls()).toHaveLength(0);
});
it('makes no registry call for a digest-pinned dev-repo ref', async () => {
mockGetPinInfo.mockResolvedValue(DEV_DIGEST_PIN);
await runEvaluate();
expect(mockDetectSelfDevBuildUpdate).not.toHaveBeenCalled();
expect(mockSetSystemState).not.toHaveBeenCalled();
expect(devBuildCalls()).toHaveLength(0);
});
it('is a no-op for a stable-pinned node, and leaves the existing stable version-update behavior unchanged', async () => {
mockGetPinInfo.mockResolvedValue(STABLE_PIN);
mockGetSenchoVersion.mockReturnValueOnce('0.45.0');
mockGetLatestVersionInfo.mockResolvedValueOnce({ version: '0.46.0', publishPending: false });
await runEvaluate();
expect(mockDetectSelfDevBuildUpdate).not.toHaveBeenCalled();
expect(devBuildCalls()).toHaveLength(0);
expect(mockDispatchAlert).toHaveBeenCalledWith('info', 'node_update_available', expect.stringContaining('0.46.0'));
});
it('suppresses the stable version-update notification for a dev-repo :dev pin even when a stable update is available', async () => {
mockGetPinInfo.mockResolvedValue(DEV_PIN);
mockGetSenchoVersion.mockReturnValueOnce('0.45.0');
mockGetLatestVersionInfo.mockResolvedValueOnce({ version: '0.46.0', publishPending: false });
mockDetectSelfDevBuildUpdate.mockResolvedValue({ kind: 'up_to_date' });
await runEvaluate();
expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'node_update_available', expect.anything());
});
it('suppresses the stable version-update notification for an immutable dev-<sha> pin', async () => {
mockGetPinInfo.mockResolvedValue(DEV_SHA_PIN);
mockGetSenchoVersion.mockReturnValueOnce('0.45.0');
mockGetLatestVersionInfo.mockResolvedValueOnce({ version: '0.46.0', publishPending: false });
await runEvaluate();
expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'node_update_available', expect.anything());
});
it('suppresses the stable version-update notification for a digest-pinned dev-repo ref', async () => {
mockGetPinInfo.mockResolvedValue(DEV_DIGEST_PIN);
mockGetSenchoVersion.mockReturnValueOnce('0.45.0');
mockGetLatestVersionInfo.mockResolvedValueOnce({ version: '0.46.0', publishPending: false });
await runEvaluate();
expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'node_update_available', expect.anything());
});
});
// ── Per-container parallel fan-out ────────────────────────────────────
describe('MonitorService - parallel container processing', () => {
@@ -6,6 +6,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
import { MonitorService } from '../services/MonitorService';
let tmpDir: string;
let app: import('express').Express;
@@ -13,6 +14,16 @@ let adminAuth: string;
let viewerAuth: string;
let localNodeId: number;
let db: import('../services/DatabaseService').DatabaseService;
let SelfUpdateService: typeof import('../services/SelfUpdateService').default;
function mockCompareTargetFetch() {
vi.spyOn(globalThis, 'fetch').mockImplementation(async () =>
new Response(JSON.stringify({ tag_name: 'v0.99.0' }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
}
function signToken(username: string, role: string) {
return jwt.sign(
@@ -31,6 +42,7 @@ beforeAll(async () => {
localNodeId = db.getNodes().find(n => n.type === 'local')!.id;
const index = await import('../index');
app = index.app;
SelfUpdateService = (await import('../services/SelfUpdateService')).default;
});
afterAll(() => {
@@ -204,3 +216,28 @@ describe('node_update_skips table lifecycle', () => {
expect(() => db.deleteNodeUpdateSkip(99999)).not.toThrow();
});
});
describe('stale stable skip does not leak onto a dev-pinned node', () => {
it('clears skipActive/skippedVersion in the response once the node is dev-pinned', async () => {
mockCompareTargetFetch();
// A skip stored while the node still tracked the stable release train.
db.setNodeUpdateSkip(localNodeId, '0.99.0', TEST_USERNAME);
vi.spyOn(SelfUpdateService.getInstance(), 'getPinInfo').mockResolvedValue({
pinKind: 'floating',
composeImageRef: 'ghcr.io/studio-saelix/sencho-dev:dev',
filePath: '/opt/sencho/docker-compose.yml',
});
db.setSystemState(MonitorService.SENCHO_DEV_BUILD_AVAILABLE_KEY, 'sha256:aaaa');
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.skipActive).toBe(false);
expect(local.skippedVersion).toBeNull();
expect(local.isDevImage).toBe(true);
expect(local.devBuildUpdateAvailable).toBe(true);
});
});
@@ -64,6 +64,7 @@ import {
selectLocalRepoDigest,
selectLocalRepoDigests,
compareLocalToRemoteTag,
compareLocalToRemoteTagDetailed,
MANIFEST_CLASSIFICATION_CACHE_TTL_MS,
MANIFEST_INDEX_DESCRIPTOR_CAP,
MANIFEST_INDEX_MAX_DEPTH,
@@ -1528,4 +1529,66 @@ describe('compareLocalToRemoteTag', () => {
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result.kind).toBe('error');
});
// ─── compareLocalToRemoteTagDetailed ───────────────────────
//
// Same comparison engine as compareLocalToRemoteTag, sharing the fixtures
// and route helpers above, but asserting the extra primaryDigest field and
// that compareLocalToRemoteTag itself no longer leaks it.
describe('compareLocalToRemoteTagDetailed', () => {
/** HEAD the tag for INDEX_DIGEST, then serve its index body on the pinned GET. */
const routeExpandableIndex = routePrimaryDigest(INDEX_DIGEST, { [INDEX_DIGEST]: STANDARD_INDEX_BODY });
/** HEAD the tag for INDEX_DIGEST; every digest-pinned GET fails. */
const routeHeadOnly = routePrimaryDigest(INDEX_DIGEST, {});
it('performs exactly one manifest probe and returns the primary digest alongside kind: match', async () => {
route = routeHeadOnly;
const result = await compareLocalToRemoteTagDetailed([INDEX_DIGEST], REGISTRY, REPO, TAG, AMD64);
expect(result).toEqual({ kind: 'match', primaryDigest: INDEX_DIGEST });
expect(calls.filter((c) => c.url.includes('/manifests/'))).toHaveLength(1);
});
it('returns the primary digest alongside kind: update when every local candidate is stale', async () => {
route = routeExpandableIndex;
const result = await compareLocalToRemoteTagDetailed([STALE_INDEX], REGISTRY, REPO, TAG, AMD64);
expect(result).toEqual({ kind: 'update', primaryDigest: INDEX_DIGEST });
});
it('returns the primary digest alongside kind: error from a classification failure', async () => {
// Digest-pinned GET fails: classification cannot complete.
route = routeHeadOnly;
const result = await compareLocalToRemoteTagDetailed([STALE_INDEX], REGISTRY, REPO, TAG, AMD64);
expect(result.kind).toBe('error');
expect(result.primaryDigest).toBe(INDEX_DIGEST);
expect(result.reason).toBeTruthy();
});
it('omits the primary digest on an early error before any probe (malformed local digest)', async () => {
const result = await compareLocalToRemoteTagDetailed(['not-a-digest'], REGISTRY, REPO, TAG, AMD64);
expect(result).toEqual({ kind: 'error', reason: expect.any(String) });
expect(result.primaryDigest).toBeUndefined();
});
it('compareLocalToRemoteTag wraps the detailed result and drops primaryDigest on match', async () => {
route = routeHeadOnly;
const result = await compareLocalToRemoteTag([INDEX_DIGEST], REGISTRY, REPO, TAG, AMD64);
expect(Object.keys(result)).toEqual(['kind']);
expect(result).toEqual({ kind: 'match' });
});
it('compareLocalToRemoteTag wraps the detailed result and drops primaryDigest on update', async () => {
route = routeExpandableIndex;
const result = await compareLocalToRemoteTag([STALE_INDEX], REGISTRY, REPO, TAG, AMD64);
expect(Object.keys(result)).toEqual(['kind']);
expect(result).toEqual({ kind: 'update' });
});
it('compareLocalToRemoteTag wraps the detailed result and keeps only kind + reason on error', async () => {
route = (url) => tokenOk(url) ?? { statusCode: 500, headers: {} };
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
expect(result.kind).toBe('error');
expect(Object.keys(result).sort()).toEqual(['kind', 'reason']);
});
});
});
@@ -0,0 +1,148 @@
/**
* Unit tests for detectSelfDevBuildUpdate: the self-image build detector that
* compares the running Sencho container's image against the rolling
* ghcr.io/studio-saelix/sencho-dev:dev tag. Drives the function entirely
* through the deps injection point (inspectImage, compareDetailed) so no
* real Docker socket or registry call is involved.
*/
import { describe, it, expect, vi } from 'vitest';
import {
detectSelfDevBuildUpdate,
type DetectSelfDevBuildUpdateDeps,
type SelfDevBuildDetectInput,
} from '../services/selfDevBuildDetect';
import type { compareLocalToRemoteTagDetailed } from '../services/registry-api';
const REGISTRY = 'ghcr.io';
const REPO = 'studio-saelix/sencho-dev';
const TAG = 'dev';
const LOCAL_DIGEST = `sha256:${'b'.repeat(64)}`;
const NEW_DIGEST = `sha256:${'c'.repeat(64)}`;
const LOCAL_REPO_DIGEST = `${REGISTRY}/${REPO}@${LOCAL_DIGEST}`;
const INPUT: SelfDevBuildDetectInput = {
runningImageId: 'a'.repeat(64),
registry: REGISTRY,
repo: REPO,
tag: TAG,
credentials: null,
};
function inspectReturning(repoDigests: string[]) {
return vi.fn().mockResolvedValue({ RepoDigests: repoDigests, Os: 'linux', Architecture: 'amd64' });
}
function compareReturning(
result: Awaited<ReturnType<typeof compareLocalToRemoteTagDetailed>>,
): typeof compareLocalToRemoteTagDetailed {
return vi.fn().mockResolvedValue(result);
}
describe('detectSelfDevBuildUpdate', () => {
it('returns up_to_date on a parent-index digest match (local digest equals a remote index primary digest)', async () => {
const deps: DetectSelfDevBuildUpdateDeps = {
inspectImage: inspectReturning([LOCAL_REPO_DIGEST]),
compareDetailed: compareReturning({ kind: 'match', primaryDigest: LOCAL_DIGEST }),
};
expect(await detectSelfDevBuildUpdate(INPUT, deps)).toEqual({ kind: 'up_to_date' });
});
it('returns up_to_date on platform-child membership (local RepoDigest is the platform-specific child, not the parent index digest)', async () => {
// The compareDetailed injection point owns the index-expansion logic; this
// test only needs to prove the detector trusts that verdict rather than
// doing its own raw digest equality check, which would wrongly report an
// update here since LOCAL_DIGEST != the (hypothetical) parent index digest.
const compareDetailed = compareReturning({ kind: 'match', primaryDigest: `sha256:${'d'.repeat(64)}` });
const deps: DetectSelfDevBuildUpdateDeps = { inspectImage: inspectReturning([LOCAL_REPO_DIGEST]), compareDetailed };
expect(await detectSelfDevBuildUpdate(INPUT, deps)).toEqual({ kind: 'up_to_date' });
expect(compareDetailed).toHaveBeenCalledWith(
[LOCAL_DIGEST],
REGISTRY,
REPO,
TAG,
{ os: 'linux', architecture: 'amd64' },
null,
);
});
it('returns update with the new digest when the remote has a genuinely newer build', async () => {
const deps: DetectSelfDevBuildUpdateDeps = {
inspectImage: inspectReturning([LOCAL_REPO_DIGEST]),
compareDetailed: compareReturning({ kind: 'update', primaryDigest: NEW_DIGEST }),
};
expect(await detectSelfDevBuildUpdate(INPUT, deps)).toEqual({ kind: 'update', digest: NEW_DIGEST });
});
it('returns inconclusive when RepoDigests is empty', async () => {
const compareDetailed = vi.fn();
const deps: DetectSelfDevBuildUpdateDeps = { inspectImage: inspectReturning([]), compareDetailed };
const result = await detectSelfDevBuildUpdate(INPUT, deps);
expect(result.kind).toBe('inconclusive');
expect(compareDetailed).not.toHaveBeenCalled();
});
it('returns inconclusive when RepoDigests is present but none match the target registry/repo', async () => {
const compareDetailed = vi.fn();
const deps: DetectSelfDevBuildUpdateDeps = {
inspectImage: inspectReturning([`docker.io/library/other@sha256:${'e'.repeat(64)}`]),
compareDetailed,
};
const result = await detectSelfDevBuildUpdate(INPUT, deps);
expect(result.kind).toBe('inconclusive');
expect(compareDetailed).not.toHaveBeenCalled();
});
it('returns inconclusive, not a throw, when inspectImage throws', async () => {
const deps: DetectSelfDevBuildUpdateDeps = {
inspectImage: vi.fn().mockRejectedValue(new Error('no such image')),
compareDetailed: vi.fn(),
};
const result = await detectSelfDevBuildUpdate(INPUT, deps);
expect(result).toEqual({ kind: 'inconclusive', reason: expect.stringContaining('no such image') });
});
it('returns inconclusive with the carried-through reason when compareDetailed returns kind: error', async () => {
const deps: DetectSelfDevBuildUpdateDeps = {
inspectImage: inspectReturning([LOCAL_REPO_DIGEST]),
compareDetailed: compareReturning({ kind: 'error', reason: 'Registry unreachable for ghcr.io/studio-saelix/sencho-dev:dev' }),
};
const result = await detectSelfDevBuildUpdate(INPUT, deps);
expect(result).toEqual({ kind: 'inconclusive', reason: 'Registry unreachable for ghcr.io/studio-saelix/sencho-dev:dev' });
});
it('returns inconclusive (not a fabricated update) when compareDetailed returns kind: update with no primaryDigest', async () => {
const deps: DetectSelfDevBuildUpdateDeps = {
inspectImage: inspectReturning([LOCAL_REPO_DIGEST]),
compareDetailed: compareReturning({ kind: 'update' }),
};
const result = await detectSelfDevBuildUpdate(INPUT, deps);
expect(result).toEqual({ kind: 'inconclusive', reason: expect.stringContaining('no digest') });
});
it('returns inconclusive, not a throw, when compareDetailed rejects', async () => {
const deps: DetectSelfDevBuildUpdateDeps = {
inspectImage: inspectReturning([LOCAL_REPO_DIGEST]),
compareDetailed: vi.fn().mockRejectedValue(new Error('registry socket reset')),
};
const result = await detectSelfDevBuildUpdate(INPUT, deps);
expect(result).toEqual({ kind: 'inconclusive', reason: expect.stringContaining('registry socket reset') });
});
it('returns inconclusive, not a throw, when inspectImage resolves with a non-array RepoDigests', async () => {
// A misbehaving injected inspectImage (or a future Docker SDK shape change)
// can violate its declared return type at runtime; the cast below
// constructs exactly that violation on purpose to prove the detector
// guards against it rather than trusting the type signature blindly.
const malformedInspect = vi.fn().mockResolvedValue({ RepoDigests: undefined, Os: 'linux', Architecture: 'amd64' }) as unknown as NonNullable<DetectSelfDevBuildUpdateDeps['inspectImage']>;
const compareDetailed = vi.fn();
const deps: DetectSelfDevBuildUpdateDeps = { inspectImage: malformedInspect, compareDetailed };
const result = await detectSelfDevBuildUpdate(INPUT, deps);
expect(result.kind).toBe('inconclusive');
expect(compareDetailed).not.toHaveBeenCalled();
});
});
@@ -18,6 +18,8 @@ import {
isValidImageRef,
resolveServiceImageFromContents,
patchComposeServiceImage,
isSenchoDevRepository,
isSenchoDevFloatingTag,
} from '../helpers/selfUpdateCompose';
import {
buildComposeReadArgs,
@@ -322,3 +324,105 @@ describe('buildComposeConfigValidateArgs', () => {
expect(cmd).toContain(shQuote('/opt/sencho/override.yml'));
});
});
describe('isSenchoDevRepository', () => {
it('returns true for the floating dev tag', () => {
expect(isSenchoDevRepository('ghcr.io/studio-saelix/sencho-dev:dev')).toBe(true);
});
it('returns true for an immutable dev-<sha> tag', () => {
expect(isSenchoDevRepository('ghcr.io/studio-saelix/sencho-dev:dev-a1b2c3d')).toBe(true);
});
it('returns true for a digest-pinned reference', () => {
expect(
isSenchoDevRepository('ghcr.io/studio-saelix/sencho-dev@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'),
).toBe(true);
});
it('returns true for a reference with both tag and digest', () => {
expect(
isSenchoDevRepository('ghcr.io/studio-saelix/sencho-dev:dev@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'),
).toBe(true);
});
it('returns false for the stable sencho repository', () => {
expect(isSenchoDevRepository('ghcr.io/studio-saelix/sencho:1.2.3')).toBe(false);
});
it('returns false for the hardened repository', () => {
expect(isSenchoDevRepository('ghcr.io/studio-saelix/sencho-hardened:1.2.3')).toBe(false);
});
it('returns false for the Docker Hub stable repository', () => {
expect(isSenchoDevRepository('saelix/sencho:latest')).toBe(false);
});
it('returns false for an unrelated image', () => {
expect(isSenchoDevRepository('docker.io/library/nginx:latest')).toBe(false);
});
it('returns false for an interpolated variable', () => {
expect(isSenchoDevRepository('${SENCHO_IMAGE}')).toBe(false);
});
it('returns false for a registry-with-port reference to an unrelated repository', () => {
expect(isSenchoDevRepository('localhost:5000/something:dev')).toBe(false);
});
it('returns false for empty or malformed references', () => {
expect(isSenchoDevRepository('')).toBe(false);
expect(isSenchoDevRepository(' ')).toBe(false);
});
});
describe('isSenchoDevFloatingTag', () => {
it('returns true only for the floating dev tag', () => {
expect(isSenchoDevFloatingTag('ghcr.io/studio-saelix/sencho-dev:dev')).toBe(true);
});
it('returns false for an immutable dev-<sha> tag (not the floating dev tag)', () => {
expect(isSenchoDevFloatingTag('ghcr.io/studio-saelix/sencho-dev:dev-a1b2c3d')).toBe(false);
});
it('returns false for a digest-pinned reference (disqualifies floating)', () => {
expect(
isSenchoDevFloatingTag('ghcr.io/studio-saelix/sencho-dev@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'),
).toBe(false);
});
it('returns false for a reference with both tag and digest (still digest-pinned)', () => {
expect(
isSenchoDevFloatingTag('ghcr.io/studio-saelix/sencho-dev:dev@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'),
).toBe(false);
});
it('returns false for the stable sencho repository', () => {
expect(isSenchoDevFloatingTag('ghcr.io/studio-saelix/sencho:1.2.3')).toBe(false);
});
it('returns false for the hardened repository', () => {
expect(isSenchoDevFloatingTag('ghcr.io/studio-saelix/sencho-hardened:1.2.3')).toBe(false);
});
it('returns false for the Docker Hub stable repository', () => {
expect(isSenchoDevFloatingTag('saelix/sencho:latest')).toBe(false);
});
it('returns false for an unrelated image', () => {
expect(isSenchoDevFloatingTag('docker.io/library/nginx:latest')).toBe(false);
});
it('returns false for an interpolated variable', () => {
expect(isSenchoDevFloatingTag('${SENCHO_IMAGE}')).toBe(false);
});
it('returns false for a registry-with-port reference to an unrelated repository', () => {
expect(isSenchoDevFloatingTag('localhost:5000/something:dev')).toBe(false);
});
it('returns false for empty or malformed references', () => {
expect(isSenchoDevFloatingTag('')).toBe(false);
expect(isSenchoDevFloatingTag(' ')).toBe(false);
});
});
@@ -0,0 +1,160 @@
/**
* Regression coverage for the no-repin invariant on a floating-tag self-update.
*
* The Fleet dev-build update reaches SelfUpdateService WITH a targetVersion
* even though the frontend omits one: the route substitutes the stable compare
* target (resolveUpdateTarget in routes/fleet.ts) and forwards it through
* ImageOperationService. So the guard that actually protects a :dev install is
* not the absence of a target, it is the `pinKind === 'semver'` test inside the
* repin branch. The worst-case failure is silently rewriting the compose file
* from :dev to a stable tag, which would move the install off the dev channel.
*
* These exercise the real SelfUpdateService decision rather than a mock
* standing in for it, and pair the floating cases with a semver case so the
* negative assertions cannot pass vacuously.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockExecFile, mockExecFileAsync, mockWriteFileSync } = vi.hoisted(() => ({
mockExecFile: vi.fn(),
mockExecFileAsync: vi.fn(),
mockWriteFileSync: vi.fn(),
}));
vi.mock('child_process', () => ({
exec: vi.fn(),
execFile: mockExecFile,
}));
vi.mock('util', () => ({
promisify: () => mockExecFileAsync,
}));
vi.mock('../services/DatabaseService', () => ({
DatabaseService: { getInstance: () => ({ getGlobalSettings: () => ({}) }) },
}));
// SelfUpdateService imports `fs` as a namespace; spying on the real ESM
// namespace object throws ("Module namespace is not configurable"), so the
// write path is swapped for a mock while every other fs function stays real.
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
return { ...actual, writeFileSync: mockWriteFileSync };
});
const DEV_IMAGE_REF = 'ghcr.io/studio-saelix/sencho-dev:dev';
const SEMVER_IMAGE_REF = 'saelix/sencho:0.93.3';
const WORKING_DIR = '/opt/sencho';
const COMPOSE_FILE = '/opt/sencho/docker-compose.yml';
// The stable release the Fleet route resolves and forwards when the caller
// (a dev-image update) supplies no target of its own.
const RESOLVED_TARGET = '0.99.0';
// Mirrors SelfUpdateService's private ComposeContext shape (not exported), so
// the test-only field poke below stays structurally checked against drift.
type TestComposeContext = {
workingDir: string;
configFiles: string;
serviceName: string;
imageName: string;
dataDirHost: string | null;
hostBindMounts: Array<{ source: string; destination: string }>;
};
/** The argv SelfUpdateService hands the helper container, or null if unspawned. */
function helperArgs(): string[] | null {
const call = mockExecFile.mock.calls[0] as [string, string[]] | undefined;
return call ? call[1] : null;
}
describe('SelfUpdateService.triggerUpdate (no-repin invariant)', () => {
let SelfUpdateService: typeof import('../services/SelfUpdateService').default;
/** Point the service at a compose project declaring `composeImageRef`. */
async function setupWithComposeImage(composeImageRef: string): Promise<void> {
vi.clearAllMocks();
// `docker pull` yields nothing; the throwaway `cat` container returns the
// compose file, which is what the fresh pin resolution actually parses.
mockExecFileAsync.mockImplementation(async (_cmd: string, args: string[]) =>
args[0] === 'pull'
? { stdout: '', stderr: '' }
: { stdout: `services:\n sencho:\n image: ${composeImageRef}\n`, stderr: '' },
);
({ default: SelfUpdateService } = await import('../services/SelfUpdateService'));
(SelfUpdateService.getInstance() as unknown as { composeContext: TestComposeContext }).composeContext = {
workingDir: WORKING_DIR,
configFiles: COMPOSE_FILE,
serviceName: 'sencho',
imageName: composeImageRef,
dataDirHost: '/opt/sencho/data',
hostBindMounts: [],
};
}
beforeEach(() => {
vi.clearAllMocks();
});
it('keeps a :dev pin on its own tag when the route forwards a stable target', async () => {
await setupWithComposeImage(DEV_IMAGE_REF);
// The production call shape: Fleet resolved a stable compare target and
// forwarded it, so the repin branch runs and must decline on a floating pin.
await SelfUpdateService.getInstance().triggerUpdate({ targetVersion: RESOLVED_TARGET });
expect(mockExecFileAsync).toHaveBeenCalledWith(
'docker',
['pull', DEV_IMAGE_REF],
expect.objectContaining({ timeout: 300_000 }),
);
// The forwarded stable version must never become the pulled reference.
expect(mockExecFileAsync).not.toHaveBeenCalledWith(
'docker',
['pull', expect.stringContaining(RESOLVED_TARGET)],
expect.anything(),
);
// A staged compose patch is written only when a repin is committed.
expect(mockWriteFileSync).not.toHaveBeenCalled();
const args = helperArgs();
expect(args).not.toBeNull();
expect(args).toContain(`${WORKING_DIR}:${WORKING_DIR}:ro`);
expect(args).not.toContain(`${WORKING_DIR}:${WORKING_DIR}:rw`);
expect(args![args!.length - 1]).not.toContain('cp ');
});
it('pulls the current dev image unchanged when no target is supplied at all', async () => {
await setupWithComposeImage(DEV_IMAGE_REF);
// The legacy pull-current path (no target anywhere) skips the repin branch
// outright rather than declining inside it.
await SelfUpdateService.getInstance().triggerUpdate();
expect(mockExecFileAsync).toHaveBeenCalledWith(
'docker',
['pull', DEV_IMAGE_REF],
expect.objectContaining({ timeout: 300_000 }),
);
expect(mockWriteFileSync).not.toHaveBeenCalled();
expect(helperArgs()).toContain(`${WORKING_DIR}:${WORKING_DIR}:ro`);
});
it('still repins a semver pin to the target (the assertions above are not vacuous)', async () => {
await setupWithComposeImage(SEMVER_IMAGE_REF);
await SelfUpdateService.getInstance().triggerUpdate({ targetVersion: RESOLVED_TARGET });
// Contrast case: a semver pin is exactly what the floating pin must not do.
expect(mockExecFileAsync).toHaveBeenCalledWith(
'docker',
['pull', `saelix/sencho:${RESOLVED_TARGET}`],
expect.objectContaining({ timeout: 300_000 }),
);
expect(mockWriteFileSync).toHaveBeenCalledWith(
expect.stringContaining('.sencho-compose-patch'),
expect.stringContaining(`saelix/sencho:${RESOLVED_TARGET}`),
'utf8',
);
const args = helperArgs();
expect(args).toContain(`${WORKING_DIR}:${WORKING_DIR}:rw`);
expect(args![args!.length - 1]).toContain('cp ');
});
});
@@ -8,6 +8,7 @@ import request from 'supertest';
import jwt from 'jsonwebtoken';
import { buildTargetImageRef } from '../helpers/selfUpdateCompose';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
import { MonitorService } from '../services/MonitorService';
let tmpDir: string;
let app: import('express').Express;
@@ -216,4 +217,69 @@ describe('GET /api/fleet/update-status pin projection', () => {
expect(local.updateBlocked).toBe(true);
expect(local.updateBlockedReason).toMatch(/cannot update automatically/i);
});
it('marks a :dev-pinned local node as a dev image and available when the digest key is set', async () => {
mockCompareTargetFetch();
mockSelfUpdateAvailable({
pinInfo: { pinKind: 'floating', composeImageRef: 'ghcr.io/studio-saelix/sencho-dev:dev', filePath: '/opt/sencho/docker-compose.yml' },
});
DatabaseService.getInstance().setSystemState(MonitorService.SENCHO_DEV_BUILD_AVAILABLE_KEY, 'sha256:aaaa');
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.isDevImage).toBe(true);
expect(local.devBuildUpdateAvailable).toBe(true);
expect(local.updateAvailable).toBe(false);
});
it('marks a :dev-pinned local node as a dev image but not available when no digest key is set', async () => {
mockCompareTargetFetch();
mockSelfUpdateAvailable({
pinInfo: { pinKind: 'floating', composeImageRef: 'ghcr.io/studio-saelix/sencho-dev:dev', filePath: '/opt/sencho/docker-compose.yml' },
});
DatabaseService.getInstance().setSystemState(MonitorService.SENCHO_DEV_BUILD_AVAILABLE_KEY, '');
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.isDevImage).toBe(true);
expect(local.devBuildUpdateAvailable).toBe(false);
});
it('marks an immutable dev-<sha> pin as a dev image but never eligible for the dev update action', async () => {
mockCompareTargetFetch();
mockSelfUpdateAvailable({
pinInfo: { pinKind: 'floating', composeImageRef: 'ghcr.io/studio-saelix/sencho-dev:dev-a1b2c3d', filePath: '/opt/sencho/docker-compose.yml' },
});
DatabaseService.getInstance().setSystemState(MonitorService.SENCHO_DEV_BUILD_AVAILABLE_KEY, 'sha256:aaaa');
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.isDevImage).toBe(true);
expect(local.devBuildUpdateAvailable).toBe(false);
});
it('leaves a stable-pinned local node with both dev fields false', async () => {
mockCompareTargetFetch();
mockSelfUpdateAvailable({
pinInfo: { pinKind: 'semver', composeImageRef: 'saelix/sencho:0.83.0', filePath: '/opt/sencho/docker-compose.yml' },
});
DatabaseService.getInstance().setSystemState(MonitorService.SENCHO_DEV_BUILD_AVAILABLE_KEY, 'sha256:aaaa');
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.isDevImage).toBe(false);
expect(local.devBuildUpdateAvailable).toBe(false);
});
});
+35
View File
@@ -1,5 +1,6 @@
import { parse, parseDocument } from 'yaml';
import semver from 'semver';
import { normalizeImageRepository } from './imageChannel';
/**
* How the Sencho service's compose `image:` is pinned. Drives the Fleet
@@ -78,6 +79,40 @@ export function isValidImageRef(ref: string): boolean {
return /^[a-zA-Z0-9][a-zA-Z0-9._\-/:@]*$/.test(ref);
}
/**
* True for any reference to the `studio-saelix/sencho-dev` repository on
* `ghcr.io`, including digest-pinned references and the immutable `dev-<sha>`
* tag. Normalizes the image reference to the bare repository string and
* compares against the canonical Sencho dev repository identifier.
*/
export function isSenchoDevRepository(imageRef: string): boolean {
const normalized = normalizeImageRepository(imageRef);
return normalized === 'ghcr.io/studio-saelix/sencho-dev';
}
/**
* True only when the image reference points to the Sencho dev repository with
* the floating `dev` tag, not digest-pinned. This predicate identifies the
* canonical floating tag that users configure for auto-update detection.
*/
export function isSenchoDevFloatingTag(imageRef: string): boolean {
if (!isSenchoDevRepository(imageRef)) return false;
const ref = imageRef?.trim();
if (!ref) return false;
// A digest pin disqualifies the reference (e.g., `@sha256:...`)
if (ref.includes('@sha256:') || ref.startsWith('sha256:')) return false;
// Extract the tag using the same logic as classifyImagePin.
const lastSlash = ref.lastIndexOf('/');
const lastColon = ref.lastIndexOf(':');
// A colon after the last slash is a tag separator; before it is a registry port.
const tag = lastColon > lastSlash ? ref.slice(lastColon + 1) : '';
return tag === 'dev';
}
/**
* Resolve the Sencho service's image from already-read compose file contents.
* Files are given in compose `-f` order; they are scanned in REVERSE so the
+31 -1
View File
@@ -15,6 +15,7 @@ import { FileSystemService } from '../services/FileSystemService';
import { ComposeService } from '../services/ComposeService';
import { StackOpLockService } from '../services/StackOpLockService';
import SelfUpdateService, { type PinInfo } from '../services/SelfUpdateService';
import { MonitorService } from '../services/MonitorService';
import { getSenchoVersion, isValidVersion } from '../services/CapabilityRegistry';
import { authMiddleware } from '../middleware/auth';
import { requireAdmin, requireNodeProxy, requireUserSession } from '../middleware/tierGates';
@@ -35,7 +36,13 @@ import { getErrorMessage } from '../utils/errors';
import { prepareOutboundRegistryDeliveryBody } from '../helpers/registryDeliveryOutbound';
import { parseIntParam } from '../utils/parseIntParam';
import { parseRequestedTargetVersion, pickCompareTarget } from '../utils/targetVersion';
import { buildTargetImageRef, isRepinBlocked, type ImagePinKind } from '../helpers/selfUpdateCompose';
import {
buildTargetImageRef,
isRepinBlocked,
isSenchoDevFloatingTag,
isSenchoDevRepository,
type ImagePinKind,
} from '../helpers/selfUpdateCompose';
import { withTimeout, TimeoutError } from '../utils/withTimeout';
import { foldNodeEstimate, type FleetEstimateTargetResult, type FleetNodeEstimate } from '../helpers/fleetEstimate';
@@ -1277,11 +1284,19 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
let updateBlocked = false;
let updateBlockedReason: string | null = null;
let imageChannel: 'community' | 'hardened' | 'unknown' | null = null;
// Dev-channel fields: local only. Remote nodes do not expose a full
// composeImageRef (see the comment above), so a remote's dev-image
// status cannot be determined here.
let isDevImage = false;
let devBuildUpdateAvailable = false;
if (node.type === 'local') {
const pin = await SelfUpdateService.getInstance().getPinInfo();
if (pin) {
({ imagePinKind, composeImageRef, targetImageRef, updateBlocked, updateBlockedReason, imageChannel } =
localPinStatusFields(pin, compareVersion, compareValid, REPIN_BLOCKED_REASON));
isDevImage = isSenchoDevRepository(pin.composeImageRef);
devBuildUpdateAvailable = isSenchoDevFloatingTag(pin.composeImageRef)
&& Boolean(db.getSystemState(MonitorService.SENCHO_DEV_BUILD_AVAILABLE_KEY));
}
} else {
imagePinKind = remoteImagePinKind;
@@ -1289,6 +1304,17 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
imageChannel = remoteImageChannel;
}
// A dev-pinned image tracks build freshness by digest, not the stable
// semver compare target, and its packaged version cannot be compared
// against a stable release. A dev row also carries no meaningful
// stable skip: the stored skip predates the dev pin, or targets a
// stable version this node no longer tracks.
if (isDevImage) {
updateAvailable = false;
skipActive = false;
skippedVersion = null;
}
return {
nodeId: node.id,
name: node.name,
@@ -1306,6 +1332,8 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
updateBlocked,
updateBlockedReason,
imageChannel,
isDevImage,
devBuildUpdateAvailable,
operationKind: currentTracker?.operationKind ?? null,
canReapplyCompose: node.type === 'local'
? SelfUpdateService.getInstance().isAvailable()
@@ -1329,6 +1357,8 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
skipActive: false,
skippedVersion: null,
...EMPTY_PIN_STATUS,
isDevImage: false,
devBuildUpdateAvailable: false,
operationKind: null,
canReapplyCompose: false,
};
+123
View File
@@ -11,7 +11,14 @@ import { isValidVersion, getSenchoVersion } from './CapabilityRegistry';
import { getLatestVersionInfo } from '../utils/version-check';
import { getHostMemory } from '../helpers/hostMemory';
import { isDebugEnabled } from '../utils/debug';
import { sanitizeForLog } from '../utils/safeLog';
import { withTimeout, TimeoutError } from '../utils/withTimeout';
import SelfUpdateService from './SelfUpdateService';
import SelfIdentityService from './SelfIdentityService';
import { RegistryService } from './RegistryService';
import { parseImageRef } from './registry-api';
import { detectSelfDevBuildUpdate } from './selfDevBuildDetect';
import { isSenchoDevRepository, isSenchoDevFloatingTag } from '../helpers/selfUpdateCompose';
const getMetricDetails = (metric: string): { name: string, unit: string } => {
switch (metric) {
@@ -204,6 +211,16 @@ export class MonitorService {
// exits; see backend/src/services/DockerEventService.ts.
private static readonly SENCHO_UPDATE_NOTIFIED_KEY = 'last_sencho_update_notified_version';
// Public: the Fleet route reads this same key to derive devBuildUpdateAvailable
// without a second polling loop.
static readonly SENCHO_DEV_BUILD_AVAILABLE_KEY = 'sencho_dev_build_available_digest';
private static readonly SENCHO_DEV_BUILD_NOTIFIED_KEY = 'last_sencho_dev_build_notified_digest';
// Cadence gate for checkSenchoDevBuild(): 30 minutes after a conclusive
// check (up_to_date, or update with a notification decision made), 5
// minutes after an inconclusive one so a transient failure retries soon.
private lastDevBuildCheckAt = 0;
private lastDevBuildCheckGateMs = 30 * 60 * 1000;
private constructor() { }
@@ -375,6 +392,10 @@ export class MonitorService {
// 4. Sencho version update check (cache-backed; dedup prevents re-notify)
await this.checkSenchoVersion();
// 5. Sencho dev-build update check (only applicable when self-pinned to
// the floating ghcr.io/studio-saelix/sencho-dev:dev tag)
await this.checkSenchoDevBuild();
}
/**
@@ -573,6 +594,14 @@ export class MonitorService {
* next eval can retry.
*/
private async checkSenchoVersion(): Promise<void> {
// A dev-repo pin (floating :dev, immutable dev-<sha>, or digest) tracks
// rolling builds, not stable semver releases, so the stable-release
// update check does not apply; checkSenchoDevBuild() covers it instead.
const pin = await SelfUpdateService.getInstance().getPinInfo();
if (pin && isSenchoDevRepository(pin.composeImageRef)) {
return;
}
// getSenchoVersion() reads the packaged manifest; npm_package_version
// is unset under `node dist/index.js` (Docker).
const currentVersion = getSenchoVersion();
@@ -625,6 +654,100 @@ export class MonitorService {
}
}
/**
* Notify when the running Sencho container has fallen behind the rolling
* `ghcr.io/studio-saelix/sencho-dev:dev` build it is pinned to. Only
* applicable when the compose-declared image is that exact floating tag;
* a stable pin, an immutable `dev-<sha>` tag, or a digest pin returns
* immediately. Availability key `sencho_dev_build_available_digest`
* always reflects the latest detection outcome; dedup key
* `last_sencho_dev_build_notified_digest` prevents re-notifying for a
* digest already announced.
*/
private async checkSenchoDevBuild(): Promise<void> {
try {
const pin = await SelfUpdateService.getInstance().getPinInfo();
if (!pin) return;
if (!isSenchoDevFloatingTag(pin.composeImageRef)) return;
if (Date.now() - this.lastDevBuildCheckAt < this.lastDevBuildCheckGateMs) {
return;
}
const imageId = SelfIdentityService.getInstance().getIdentity().imageId;
if (!imageId) {
if (isDebugEnabled()) console.debug('[Monitor:diag] Sencho dev-build check: own image id unknown; will retry sooner');
this.lastDevBuildCheckAt = Date.now();
this.lastDevBuildCheckGateMs = 5 * 60 * 1000;
return;
}
const parsed = parseImageRef(pin.composeImageRef);
if (!parsed) {
if (isDebugEnabled()) console.debug('[Monitor:diag] Sencho dev-build check: could not parse compose image ref; will retry sooner');
this.lastDevBuildCheckAt = Date.now();
this.lastDevBuildCheckGateMs = 5 * 60 * 1000;
return;
}
const credentials = await RegistryService.getInstance().getAuthForRegistry(parsed.registry);
const result = await detectSelfDevBuildUpdate({
runningImageId: imageId,
registry: parsed.registry,
repo: parsed.repo,
tag: parsed.tag,
credentials,
});
const db = DatabaseService.getInstance();
if (result.kind === 'up_to_date') {
db.setSystemState(MonitorService.SENCHO_DEV_BUILD_AVAILABLE_KEY, '');
this.lastDevBuildCheckAt = Date.now();
this.lastDevBuildCheckGateMs = 30 * 60 * 1000;
if (isDebugEnabled()) console.debug('[Monitor:diag] Sencho dev build is up-to-date');
return;
}
if (result.kind === 'inconclusive') {
if (isDebugEnabled()) console.debug(`[Monitor:diag] Sencho dev-build check inconclusive: ${sanitizeForLog(result.reason)}`);
this.lastDevBuildCheckAt = Date.now();
this.lastDevBuildCheckGateMs = 5 * 60 * 1000;
return;
}
// result.kind === 'update': availability reflects reality regardless
// of whether the notification itself lands.
db.setSystemState(MonitorService.SENCHO_DEV_BUILD_AVAILABLE_KEY, result.digest);
if (db.getSystemState(MonitorService.SENCHO_DEV_BUILD_NOTIFIED_KEY) === result.digest) {
if (isDebugEnabled()) console.debug('[Monitor:diag] Already notified for this Sencho dev build digest');
this.lastDevBuildCheckAt = Date.now();
this.lastDevBuildCheckGateMs = 30 * 60 * 1000;
return;
}
const { persisted } = await NotificationService.getInstance().dispatchAlert(
'info',
'dev_build_update_available',
'A new Sencho dev build is available on ghcr.io/studio-saelix/sencho-dev:dev. '
+ "Use the update action on this node's Fleet card to pull and recreate.",
);
this.lastDevBuildCheckAt = Date.now();
if (persisted) {
db.setSystemState(MonitorService.SENCHO_DEV_BUILD_NOTIFIED_KEY, result.digest);
this.lastDevBuildCheckGateMs = 30 * 60 * 1000;
} else {
// Retry sooner so an unpersisted notification is not silently
// deduped; the availability key above already reflects reality.
this.lastDevBuildCheckGateMs = 5 * 60 * 1000;
}
} catch (e) {
console.error('[MonitorService] Failed to check Sencho dev build update:', e);
}
}
private async evaluateStackAlerts(db: DatabaseService) {
const alerts = db.getStackAlerts();
const nodes = db.getNodes();
+2 -1
View File
@@ -63,6 +63,7 @@ export type NotificationCategory =
| 'git_apply_rolled_back'
| 'git_create'
| 'node_update_available'
| 'dev_build_update_available'
| 'system';
export const ALL_NOTIFICATION_CATEGORIES: readonly NotificationCategory[] = [
@@ -71,7 +72,7 @@ export const ALL_NOTIFICATION_CATEGORIES: readonly NotificationCategory[] = [
'autoheal_triggered', 'monitor_alert', 'scan_finding',
'blueprint_deployed', 'blueprint_deployment_failed',
'blueprint_drift_detected', 'blueprint_drift_correction_failed',
'node_update_available', 'system',
'node_update_available', 'dev_build_update_available', 'system',
];
/** Every category that can appear in notification history / the bell panel. */
+43 -20
View File
@@ -785,14 +785,17 @@ async function classifyManifest(
}
/**
* Compare local image digests to the registry's current manifest for a tag.
* Any candidate that equals the remote primary or is a member of that primary's
* index counts as current (Docker often lists a stale index digest ahead of the
* current one). `platform` is the local image's Os/Architecture (from
* `docker image inspect`), required to safely match against an index's platform
* descriptors; without it, an index mismatch is an error rather than a
* speculative match. Never retries against the mutable tag once a primary
* digest is established: classification always targets that digest.
* Compare local image digests to the registry's current manifest for a tag,
* returning the probe's primary digest alongside the verdict so a caller that
* needs both (e.g. a self-build detector reporting the new digest) does not
* have to re-probe the mutable tag. Any candidate that equals the remote
* primary or is a member of that primary's index counts as current (Docker
* often lists a stale index digest ahead of the current one). `platform` is
* the local image's Os/Architecture (from `docker image inspect`), required
* to safely match against an index's platform descriptors; without it, an
* index mismatch is an error rather than a speculative match. Never retries
* against the mutable tag once a primary digest is established:
* classification always targets that digest.
*
* `update` is returned only after a successful, complete remote classification
* with no candidate matching the primary, an exact member, or a same-platform
@@ -805,16 +808,20 @@ async function classifyManifest(
* instead. Empty/all-malformed candidates, unknown platform when platform
* matching is required, an index with no runnable content for the local
* platform at all (including an empty or fully-filtered index), and
* classification failures also return `error`.
* classification failures also return `error`. `primaryDigest` is present
* once a valid primary digest has been read from the registry, including on
* `error` results from a later classification failure; it is absent only
* when the local candidate digests are malformed, the probe itself failed,
* or the registry's primary digest fails validation.
*/
export async function compareLocalToRemoteTag(
export async function compareLocalToRemoteTagDetailed(
localDigests: readonly string[],
registry: string,
repo: string,
tag: string,
platform: { os: string; architecture: string },
credentials?: RegistryCredentials | null,
): Promise<DigestComparisonResult> {
): Promise<{ kind: 'match' | 'update' | 'error'; primaryDigest?: string; reason?: string }> {
const candidates = localDigests.filter((d) => SHA256_DIGEST_RE.test(d));
if (candidates.length === 0) {
return { kind: 'error', reason: 'Local digest is malformed or truncated' };
@@ -829,21 +836,21 @@ export async function compareLocalToRemoteTag(
if (!SHA256_DIGEST_RE.test(primaryDigest)) {
return { kind: 'error', reason: `Registry returned a malformed digest for ${ref}` };
}
if (candidateSet.has(primaryDigest.toLowerCase())) return { kind: 'match' };
if (candidateSet.has(primaryDigest.toLowerCase())) return { kind: 'match', primaryDigest };
let classification: ManifestClassification;
try {
classification = await classifyManifest(registry, repo, primaryDigest, contentType, body, authHeaders, ref);
} catch (e) {
return { kind: 'error', reason: getErrorMessage(e, `Failed to classify remote manifest for ${ref}`) };
return { kind: 'error', primaryDigest, reason: getErrorMessage(e, `Failed to classify remote manifest for ${ref}`) };
}
if (classification.kind === 'single') return { kind: 'update' };
if (classification.kind === 'single') return { kind: 'update', primaryDigest };
if (classification.exactDigests.some((d) => candidateSet.has(d.toLowerCase()))) return { kind: 'match' };
if (classification.exactDigests.some((d) => candidateSet.has(d.toLowerCase()))) return { kind: 'match', primaryDigest };
if (!platform.os || !platform.architecture) {
return { kind: 'error', reason: `Local image platform is unknown; cannot verify multi-arch membership for ${ref}` };
return { kind: 'error', primaryDigest, reason: `Local image platform is unknown; cannot verify multi-arch membership for ${ref}` };
}
const platformDescriptors = classification.descriptors.filter(
@@ -860,16 +867,32 @@ export async function compareLocalToRemoteTag(
// content, since nothing else claims a different one; that is the one
// case where reporting `update` instead of failing closed is safe.
if (classification.exactDigests.length === 0) {
return { kind: 'error', reason: `Remote image index has no ${platform.os}/${platform.architecture} variant for ${ref}` };
return { kind: 'error', primaryDigest, reason: `Remote image index has no ${platform.os}/${platform.architecture} variant for ${ref}` };
}
if (classification.descriptors.length > 0) {
return { kind: 'error', reason: `Remote image index has no confirmed ${platform.os}/${platform.architecture} variant for ${ref}` };
return { kind: 'error', primaryDigest, reason: `Remote image index has no confirmed ${platform.os}/${platform.architecture} variant for ${ref}` };
}
return { kind: 'update' };
return { kind: 'update', primaryDigest };
}
const isMember = platformDescriptors.some((d) => candidateSet.has(d.digest.toLowerCase()));
return isMember ? { kind: 'match' } : { kind: 'update' };
return isMember ? { kind: 'match', primaryDigest } : { kind: 'update', primaryDigest };
}
/**
* Verdict-only view of {@link compareLocalToRemoteTagDetailed} for callers
* that never need the primary digest.
*/
export async function compareLocalToRemoteTag(
localDigests: readonly string[],
registry: string,
repo: string,
tag: string,
platform: { os: string; architecture: string },
credentials?: RegistryCredentials | null,
): Promise<DigestComparisonResult> {
const result = await compareLocalToRemoteTagDetailed(localDigests, registry, repo, tag, platform, credentials);
return result.kind === 'error' ? { kind: 'error', reason: result.reason ?? 'Unknown error' } : { kind: result.kind };
}
export type TagListCode =
@@ -0,0 +1,91 @@
/**
* Detects whether the running Sencho container's own image has fallen behind
* the rolling `ghcr.io/studio-saelix/sencho-dev:dev` build it tracks. Reuses
* {@link compareLocalToRemoteTagDetailed} so the verdict and the new digest
* come from a single registry probe.
*/
import { getErrorMessage } from '../utils/errors';
import DockerController from './DockerController';
import {
compareLocalToRemoteTagDetailed,
selectLocalRepoDigests,
type RegistryCredentials,
} from './registry-api';
export interface SelfDevBuildDetectInput {
/** Container's actual running image ID (no "sha256:" prefix). */
runningImageId: string;
registry: string;
repo: string;
tag: string;
credentials: RegistryCredentials | null;
}
export type SelfDevBuildDetectResult =
| { kind: 'up_to_date' }
| { kind: 'update'; digest: string }
| { kind: 'inconclusive'; reason: string };
/** The subset of `docker image inspect` output the detector reads. */
interface InspectedImage {
RepoDigests: string[];
Os: string;
Architecture: string;
}
async function defaultInspectImage(imageId: string): Promise<InspectedImage> {
const inspect = await DockerController.getInstance().getDocker().getImage(`sha256:${imageId}`).inspect();
return { RepoDigests: inspect.RepoDigests ?? [], Os: inspect.Os, Architecture: inspect.Architecture };
}
export interface DetectSelfDevBuildUpdateDeps {
inspectImage?: typeof defaultInspectImage;
compareDetailed?: typeof compareLocalToRemoteTagDetailed;
}
export async function detectSelfDevBuildUpdate(
input: SelfDevBuildDetectInput,
deps?: DetectSelfDevBuildUpdateDeps,
): Promise<SelfDevBuildDetectResult> {
const { runningImageId, registry, repo, tag, credentials } = input;
const inspectImage = deps?.inspectImage ?? defaultInspectImage;
const compareDetailed = deps?.compareDetailed ?? compareLocalToRemoteTagDetailed;
let inspected: InspectedImage;
try {
inspected = await inspectImage(runningImageId);
} catch (e) {
return { kind: 'inconclusive', reason: getErrorMessage(e, 'Failed to inspect the running image') };
}
if (!Array.isArray(inspected.RepoDigests) || inspected.RepoDigests.length === 0) {
return { kind: 'inconclusive', reason: 'Running image has no registry digests (not registry-backed or locally built)' };
}
const localDigests = selectLocalRepoDigests(inspected.RepoDigests, { registry, repo, tag });
if (localDigests.length === 0) {
return { kind: 'inconclusive', reason: `Running image has no digest matching ${registry}/${repo}:${tag}` };
}
let result: Awaited<ReturnType<typeof compareLocalToRemoteTagDetailed>>;
try {
result = await compareDetailed(
localDigests,
registry,
repo,
tag,
{ os: inspected.Os, architecture: inspected.Architecture },
credentials,
);
} catch (e) {
return { kind: 'inconclusive', reason: getErrorMessage(e, 'Registry comparison failed') };
}
if (result.kind === 'match') return { kind: 'up_to_date' };
if (result.kind === 'error') return { kind: 'inconclusive', reason: result.reason ?? 'Registry probe failed' };
if (!result.primaryDigest) {
return { kind: 'inconclusive', reason: 'Registry reported an update but returned no digest to identify it' };
}
return { kind: 'update', digest: result.primaryDigest };
}