diff --git a/backend/src/__tests__/monitor-service.test.ts b/backend/src/__tests__/monitor-service.test.ts index 39003a15..a35e387b 100644 --- a/backend/src/__tests__/monitor-service.test.ts +++ b/backend/src/__tests__/monitor-service.test.ts @@ -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 = {}) { + const store: Record = { ...seed }; + mockGetSystemState.mockImplementation((key: string) => store[key] ?? null); + mockSetSystemState.mockImplementation((key: string, value: string) => { store[key] = value; }); + return store; + } + + async function runEvaluate(): Promise { + 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- 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- 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', () => { diff --git a/backend/src/__tests__/node-update-skips.test.ts b/backend/src/__tests__/node-update-skips.test.ts index 2f75926e..3e72fe57 100644 --- a/backend/src/__tests__/node-update-skips.test.ts +++ b/backend/src/__tests__/node-update-skips.test.ts @@ -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); + }); +}); diff --git a/backend/src/__tests__/registry-api.test.ts b/backend/src/__tests__/registry-api.test.ts index 4e93cab1..ca469f8e 100644 --- a/backend/src/__tests__/registry-api.test.ts +++ b/backend/src/__tests__/registry-api.test.ts @@ -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']); + }); + }); }); diff --git a/backend/src/__tests__/self-dev-build-detect.test.ts b/backend/src/__tests__/self-dev-build-detect.test.ts new file mode 100644 index 00000000..45b774b5 --- /dev/null +++ b/backend/src/__tests__/self-dev-build-detect.test.ts @@ -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>, +): 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; + 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(); + }); +}); diff --git a/backend/src/__tests__/self-update-compose.test.ts b/backend/src/__tests__/self-update-compose.test.ts index 2f9409e9..9b32033c 100644 --- a/backend/src/__tests__/self-update-compose.test.ts +++ b/backend/src/__tests__/self-update-compose.test.ts @@ -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- 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- 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); + }); +}); diff --git a/backend/src/__tests__/self-update-no-repin.test.ts b/backend/src/__tests__/self-update-no-repin.test.ts new file mode 100644 index 00000000..b564adba --- /dev/null +++ b/backend/src/__tests__/self-update-no-repin.test.ts @@ -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(); + 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 { + 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 '); + }); +}); diff --git a/backend/src/__tests__/self-update-pinned-routes.test.ts b/backend/src/__tests__/self-update-pinned-routes.test.ts index 051de0bb..1503aac6 100644 --- a/backend/src/__tests__/self-update-pinned-routes.test.ts +++ b/backend/src/__tests__/self-update-pinned-routes.test.ts @@ -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- 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); + }); }); diff --git a/backend/src/helpers/selfUpdateCompose.ts b/backend/src/helpers/selfUpdateCompose.ts index a34688f6..2c5f9c17 100644 --- a/backend/src/helpers/selfUpdateCompose.ts +++ b/backend/src/helpers/selfUpdateCompose.ts @@ -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-` + * 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 diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts index 059e181c..0b7704dd 100644 --- a/backend/src/routes/fleet.ts +++ b/backend/src/routes/fleet.ts @@ -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, }; diff --git a/backend/src/services/MonitorService.ts b/backend/src/services/MonitorService.ts index 7b86be5f..ede30fe8 100644 --- a/backend/src/services/MonitorService.ts +++ b/backend/src/services/MonitorService.ts @@ -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 { + // A dev-repo pin (floating :dev, immutable dev-, 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-` 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 { + 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(); diff --git a/backend/src/services/NotificationService.ts b/backend/src/services/NotificationService.ts index 535685b5..76159725 100644 --- a/backend/src/services/NotificationService.ts +++ b/backend/src/services/NotificationService.ts @@ -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. */ diff --git a/backend/src/services/registry-api.ts b/backend/src/services/registry-api.ts index 13125668..8e973e58 100644 --- a/backend/src/services/registry-api.ts +++ b/backend/src/services/registry-api.ts @@ -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 { +): 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 { + 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 = diff --git a/backend/src/services/selfDevBuildDetect.ts b/backend/src/services/selfDevBuildDetect.ts new file mode 100644 index 00000000..65cf4cfa --- /dev/null +++ b/backend/src/services/selfDevBuildDetect.ts @@ -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 { + 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 { + 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>; + 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 }; +} diff --git a/docs/features/alerts-notifications.mdx b/docs/features/alerts-notifications.mdx index 9741fb4c..30b96a34 100644 --- a/docs/features/alerts-notifications.mdx +++ b/docs/features/alerts-notifications.mdx @@ -201,6 +201,7 @@ Every alert Sencho dispatches carries a category that you can filter on in the b | `stack_taken_down` | Stack taken down | Stack taken down via the dashboard or API | | `image_update_available` | Update available | Image-update poll found a newer digest | | `node_update_available` | Node update | Sencho self-update available for this node | +| `dev_build_update_available` | Dev build update | A node pinned to the `:dev` integration image has a newer build published | | `image_update_applied` | Update applied | Manual or scheduled auto-update applied new images | | `autoheal_triggered` | Auto-heal | Auto-heal restarted, failed to restart, or auto-disabled a policy | | `monitor_alert` | Monitor alert | Per-stack threshold breach, host CPU/RAM/disk warning, or healthcheck failure | diff --git a/docs/features/fleet-view.mdx b/docs/features/fleet-view.mdx index 8f4df16d..44ab46c7 100644 --- a/docs/features/fleet-view.mdx +++ b/docs/features/fleet-view.mdx @@ -93,6 +93,7 @@ Every node renders as a card. The local node is pinned at the top of the grid wi | **Version badge** | The node's Sencho version in mono tabular numerals (e.g. `v0.76.3`). Hidden if the node cannot report a version. | | **Update available** badge | Warning pill shown when a newer Sencho release is published for this node. | | **Pinned** badge | Shown instead of **Update available** when the node's own Sencho image is pinned to a digest (or an unrecognised pin) and cannot be updated automatically from here. A tooltip explains why. | +| **Integration image** badge | Warning pill shown, for every role, whenever the local node's Sencho image is pinned to the `sencho-dev` integration repository (see [Verifying images](/operations/verifying-images)). Persistent: it shows whether or not a newer build is currently available. | | **Critical** badge | Destructive pill with a triangle icon, surfaced when the online node is above 90% CPU or 90% disk. | | **Cordoned** badge | Warning pill with a Ban icon, surfaced when the node is cordoned. The badge tooltip carries the cordon reason or the default *Unschedulable: new blueprint deployments skip this node*. See [Fleet Federation](/features/fleet-federation) for the full cordon and pin flow. | | **Networking** badge | Warning pill reading `Networking · exposed`, `Networking · drift`, or `Networking · unknown exposure`, shown when the node has a stack with published ports, detected network drift, or unresolved exposure. Click it to jump to that node's Networking page. | @@ -101,6 +102,7 @@ Every node renders as a card. The local node is pinned at the top of the grid wi | **Container stats grid** | Three cells: **Running** (active containers), **Stopped** (exited containers), **Stacks** (count, or `-` if the node has not reported). Hidden on offline nodes. | | **CPU / RAM / Disk bars** | Each row shows the metric icon, the percent (CPU) or `used / total` (RAM, Disk), and a horizontal bar that tints amber at 60%, destructive at 80% (CPU/RAM), or amber at 75% / destructive at 90% (Disk). Hidden on offline nodes. | | **Update to v…** button | Admin-only outline button that runs along the bottom of the card when an update is available. The label includes the latest version. | +| **Update dev build** button | Admin-only, brand-colored button that replaces **Update to v…** when the local node is pinned to `sencho-dev:dev` and a newer integration build has been published. Pulls and recreates without rewriting the pinned tag. | | **Stack details** trigger | Footer button that toggles the stack drill-down. The label carries the stack count for the node. | Offline nodes render dimmed, with no stats grid, no usage bars, and no update affordance. @@ -267,7 +269,7 @@ Four tiles tell you how the fleet is split across update states: | Card | Meaning | |------|---------| | **Up to date** | Nodes whose current version equals the latest published Sencho release. | -| **Available** | Nodes with a published update they are not yet running. | +| **Available** | Nodes with a published update they are not yet running. Combines a pending stable release with a pending dev build on a `:dev`-pinned local node. | | **Updating** | Nodes that are currently pulling and recreating with the new image. | | **Failed** | Nodes whose most-recent update attempt did not succeed (failure or timeout). | @@ -280,8 +282,8 @@ The table lists every registered node, filtered by the search box at the top. Co | **Node** | Node name with a Monitor icon for local nodes and a Globe icon for remotes | | **Type** | `local` or `remote` outline pill | | **Current** | The node's reported Sencho version, in mono. Reads `unknown` if the node has not reported (offline, unreachable, or never connected). | -| **Latest** | The newest published Sencho release. Highlighted when newer than Current. | -| **Status** | Either an `Up to date` success badge, an `Update` button when a newer release is available, an icon-only **Reapply configuration** control (tooltip) for Compose-managed nodes (including up-to-date rows), an in-progress / failed badge with retry and dismiss controls, or a `Skipped` badge when the version has been deferred. | +| **Latest** | The newest published Sencho release. Highlighted when newer than Current. Reads **Integration build** on a `:dev`-pinned local node, since a dev build has no version number to compare against. | +| **Status** | Either an `Up to date` success badge, an `Update` button when a newer release or dev build is available, an icon-only **Reapply configuration** control (tooltip) for Compose-managed nodes (including up-to-date rows), an in-progress / failed badge with retry and dismiss controls, or a `Skipped` badge when the version has been deferred. | The latest-version label is resolved from the GitHub Releases API (with a Docker Hub fallback) and cached for 30 minutes. **Recheck** flushes the cache and re-resolves immediately. See [Remote Updates · Reapply configuration](/features/remote-updates#reapply-configuration) for what reapply does and when to use it. @@ -293,7 +295,7 @@ Skipped nodes show a **Skipped vX.Y.Z** badge and an **Unskip** button. Unskippi ### Changelog tab -The **Changelog** tab shows the release notes for the latest published Sencho version, fetched from the GitHub Releases page. It displays the raw release notes alongside a link to view the full release on GitHub. If the release notes cannot be loaded, a message is shown in place. +The **Changelog** tab shows the release notes for the latest published Sencho version, fetched from the GitHub Releases page. It displays the raw release notes alongside a link to view the full release on GitHub. If the release notes cannot be loaded, a message is shown in place. A pending dev build does not light the changelog indicator or appear here: it has no release notes to show. ### What happens when you click Update diff --git a/docs/features/remote-updates.mdx b/docs/features/remote-updates.mdx index 0867bc3d..df662ea9 100644 --- a/docs/features/remote-updates.mdx +++ b/docs/features/remote-updates.mdx @@ -71,6 +71,8 @@ Eligible admins can also run the same procedure from the Compose editor: on Senc Updating the gateway is special because the dashboard is hosted by the very container that is about to restart. Clicking **Update** on the local row, or **Update to vX.Y.Z** on the Local card, opens a confirmation dialog (kicker **LOCAL · UPDATE**, title **Update local node**, with **Cancel** and **Update & restart** buttons) before anything happens on disk. The body text depends on how the compose file pins the image: for a semver pin it names the exact rewrite (for example, "This install pins `saelix/sencho:0.94.1`. Updating rewrites it to `saelix/sencho:0.95.0`..."); for a floating tag it reads more generally ("Pulls Sencho v0.95.0 and restarts the server..."). Both variants end with the same note that the dashboard briefly disconnects and reconnects automatically. +A node pinned to the `sencho-dev:dev` integration image gets a third variant instead: kicker **LOCAL · DEV UPDATE**, stating the `:dev` reference will be pulled and recreated without rewriting the pinned tag, plus a note that integration images are unsigned and carry no release attestations. Dev-build detection and its update action are local-node only: a remote node's dev-image status is not surfaced in Fleet, since a full compose image reference is not exposed for remotes (see [Pinned image tags](#pinned-image-tags)). + If the local node is running the Admiral **Hardened Build** image instead of a Community image, clicking **Update** here runs a different, entitlement-gated switch flow instead of the compose-repin steps below: it requires an authenticated browser session (not an API token) and can fail with its own codes (`entitlement_denied`, `preflight_mismatch`, `compose_unavailable`, `registry_access_unavailable`) surfaced on the same **Failed** badge. See [Licensing · Feature breakdown](/features/licensing#feature-breakdown) for what Hardened Build is. @@ -97,7 +99,7 @@ If the new container does not come up within 5 minutes, the overlay surfaces a * ## Pinned image tags -Fleet self-update respects how each node compose file declares the Sencho image. Semver pins are rewritten to the target release before recreate. Floating tags such as latest are pulled without changing the compose file. Digest pins and unresolved interpolated values block automatic updates; those rows show a **Pinned** badge instead of an **Update** button. +Fleet self-update respects how each node compose file declares the Sencho image. Semver pins are rewritten to the target release before recreate. Floating tags such as `latest` and `sencho-dev:dev` are pulled without changing the compose file. Digest pins and unresolved interpolated values block automatic updates; those rows show a **Pinned** badge instead of an **Update** button. A reference to the `sencho-dev` repository is always treated as an integration image, whether pinned to `:dev`, the immutable `:dev-`, or a digest, and never compared against the stable release train. The image pull always runs before any compose rewrite, so a failed pull never leaves the compose file half-updated. diff --git a/docs/operations/upgrade.mdx b/docs/operations/upgrade.mdx index 4d7f4ddc..7da91d47 100644 --- a/docs/operations/upgrade.mdx +++ b/docs/operations/upgrade.mdx @@ -78,6 +78,10 @@ Fleet can update semver pins directly from the [Node updates](/features/remote-u Check [GitHub Releases](https://github.com/studio-saelix/sencho/releases) for available versions and changelogs. + + Pinning to the `sencho-dev:dev` integration image instead of a release tag is a separate, unsigned track meant for pre-release testing, not this upgrade flow. See [Verifying images · Integration tag](/operations/verifying-images#integration-tag-post-merge-pre-release) and [Remote Updates · Updating the local (gateway) node](/features/remote-updates#updating-the-local-gateway-node) for how detection and the in-app update action work there, and how it differs from **Reapply configuration** (which recreates from the current Compose project without pulling a new image at all). + + --- ## Version policy diff --git a/docs/operations/verifying-images.mdx b/docs/operations/verifying-images.mdx index 7d94ace6..07d25f12 100644 --- a/docs/operations/verifying-images.mdx +++ b/docs/operations/verifying-images.mdx @@ -144,6 +144,8 @@ Every push to `main` publishes the current integration build so you can pull and The `:dev` image is not for production. It is unsigned, is never published to Docker Hub, and does not carry the SBOM, provenance, or VEX attestations described above. +An instance pinned to `:dev` shows a persistent "Integration image" marker on its Fleet card, and Sencho notifies you when a newer build has been published to the tag, with an in-app action to pull and recreate. Detection polls the tag on a fixed interval and reflects the newest build observed, so if several builds publish in quick succession you get one notification for the latest rather than one per build. + ### Preview tags (pre-merge only) Maintainers publish these from open PRs for external validation. They are unsigned and not for production. diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index 7aededc2..4051cd69 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -400,6 +400,7 @@ export function FleetView({ composeImageRef={confirmStatus?.composeImageRef} targetImageRef={confirmStatus?.targetImageRef} targetVersion={confirmStatus?.latestVersion} + isDevImage={confirmStatus?.isDevImage} /> {NodeActionModals} diff --git a/frontend/src/components/FleetView/LocalUpdateConfirmDialog.tsx b/frontend/src/components/FleetView/LocalUpdateConfirmDialog.tsx index dddf12e0..a5af3e0f 100644 --- a/frontend/src/components/FleetView/LocalUpdateConfirmDialog.tsx +++ b/frontend/src/components/FleetView/LocalUpdateConfirmDialog.tsx @@ -1,4 +1,4 @@ -import { Download, RefreshCw } from 'lucide-react'; +import { Download, FlaskConical, RefreshCw } from 'lucide-react'; import type { ReactNode } from 'react'; import { ConfirmModal } from '@/components/ui/modal'; import { formatVersion } from '@/lib/version'; @@ -15,19 +15,24 @@ interface LocalUpdateConfirmDialogProps { composeImageRef?: string | null; targetImageRef?: string | null; targetVersion?: string | null; + /** True when the node's compose image is any sencho-dev reference. Only + * meaningful in update mode; ignored for reapply. */ + isDevImage?: boolean; } export function LocalUpdateConfirmDialog({ open, onOpenChange, onConfirm, mode = 'update', nodeType = 'local', - imagePinKind, composeImageRef, targetImageRef, targetVersion, + imagePinKind, composeImageRef, targetImageRef, targetVersion, isDevImage, }: LocalUpdateConfirmDialogProps) { const isReapply = mode === 'reapply'; const isRemoteReapply = isReapply && nodeType === 'remote'; + const isDevUpdate = !isReapply && isDevImage; const versionLabel = formatVersion(targetVersion) ?? 'the latest release'; let kicker = 'LOCAL · UPDATE'; if (isRemoteReapply) kicker = 'REMOTE · REAPPLY'; else if (isReapply) kicker = 'LOCAL · REAPPLY'; + else if (isDevUpdate) kicker = 'LOCAL · DEV UPDATE'; let body: ReactNode; if (isRemoteReapply) { @@ -47,6 +52,15 @@ export function LocalUpdateConfirmDialog({ reconnects automatically when the restart completes.

); + } else if (isDevUpdate) { + body = ( +

+ Pulls ghcr.io/studio-saelix/sencho-dev:dev and + restarts the server. The image reference is not rewritten. Integration + images are unsigned and carry no release attestations. The dashboard + briefly disconnects and reconnects automatically when the update completes. +

+ ); } else if (imagePinKind === 'semver' && composeImageRef && targetImageRef) { body = (

@@ -74,6 +88,11 @@ export function LocalUpdateConfirmDialog({ Reapply & restart + ) : isDevUpdate ? ( + <> + + Update & restart + ) : ( <> diff --git a/frontend/src/components/FleetView/NodeCard.tsx b/frontend/src/components/FleetView/NodeCard.tsx index ceaac74b..ab703480 100644 --- a/frontend/src/components/FleetView/NodeCard.tsx +++ b/frontend/src/components/FleetView/NodeCard.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { Server, Cpu, MemoryStick, HardDrive, ChevronDown, ChevronRight, Layers, Wifi, WifiOff, AlertTriangle, Download, Loader2, - MoreVertical, Ban, Pencil, Trash2, Info, + MoreVertical, Ban, Pencil, Trash2, Info, FlaskConical, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -257,6 +257,11 @@ export function NodeCard({ node, onNavigate, onOpenNetworking, networkingSignal, Skipped )} + {updateStatus?.isDevImage && ( + + Integration image + + )} {isOnline && isCritical(node) && ( Critical @@ -359,6 +364,27 @@ export function NodeCard({ node, onNavigate, onOpenNetworking, networkingSignal, )} + {/* Dev-build update button: mutating action, admin only, same requireAdmin + route as the stable update above. No skipActive term: the backend + already clears skipActive for a dev row (fleet.ts), so adding it here + would reintroduce that stale-skip leak. */} + {isOnline && updateStatus?.devBuildUpdateAvailable && !updateStatus.updateStatus && onUpdate && isAdmin && ( +

+ +
+ )} + {/* Offline placeholder */} {!isOnline && (
diff --git a/frontend/src/components/FleetView/NodeUpdatesSheet.tsx b/frontend/src/components/FleetView/NodeUpdatesSheet.tsx index 839fa168..40ef4b60 100644 --- a/frontend/src/components/FleetView/NodeUpdatesSheet.tsx +++ b/frontend/src/components/FleetView/NodeUpdatesSheet.tsx @@ -208,8 +208,15 @@ export function NodeUpdatesSheet({ } }; - const upToDate = updateStatuses.filter(s => !s.updateAvailable && (!s.updateStatus || s.updateStatus === 'completed')).length; - const available = updateStatuses.filter(s => s.updateAvailable && !s.updateStatus).length; + // Dev availability tracks build freshness by digest, not the stable + // semver compare target, so it is counted separately from stableAvailable. + // A dev row's updateAvailable is always false (fleet.ts), so upToDate must + // exclude it too, or a dev-pinned node with a build available would render + // as "Up to date". + const stableAvailable = updateStatuses.filter(s => s.updateAvailable && !s.updateStatus).length; + const devAvailable = updateStatuses.filter(s => s.devBuildUpdateAvailable && !s.updateStatus).length; + const totalAvailable = stableAvailable + devAvailable; + const upToDate = updateStatuses.filter(s => !s.updateAvailable && !s.devBuildUpdateAvailable && (!s.updateStatus || s.updateStatus === 'completed')).length; const updating = updateStatuses.filter(s => s.updateStatus === 'updating').length; const failed = updateStatuses.filter(s => s.updateStatus === 'failed' || s.updateStatus === 'timeout').length; const updatableRemoteCount = updateStatuses.filter(s => s.updateAvailable && !s.updateStatus && s.type === 'remote').length; @@ -222,11 +229,11 @@ export function NodeUpdatesSheet({ const meta = updateStatuses.length === 0 ? 'No nodes' - : `${updateStatuses.length} nodes · ${available} update${available === 1 ? '' : 's'} available`; + : `${updateStatuses.length} nodes · ${totalAvailable} update${totalAvailable === 1 ? '' : 's'} available`; const footerContext = updateStatuses.length === 0 ? undefined - : (gatewayLabel ? `Latest version ${gatewayLabel}` : `${available} update${available === 1 ? '' : 's'} available`); + : (gatewayLabel ? `Latest version ${gatewayLabel}` : `${totalAvailable} update${totalAvailable === 1 ? '' : 's'} available`); const secondaryActions = isAdmin && updatableRemoteCount > 0 ? [{ @@ -236,7 +243,9 @@ export function NodeUpdatesSheet({ }] : undefined; - const showChangelogDot = available > 0 && !hasSeenChangelog; + // A dev build has no release changelog entry, so only a stable release + // lights the changelog dot. + const showChangelogDot = stableAvailable > 0 && !hasSeenChangelog; const showSkip = (s: NodeUpdateStatus) => s.updateAvailable && !s.updateStatus && isAdmin && isValidVersion(s.version) && isValidVersion(s.latestVersion); @@ -347,7 +356,7 @@ export function NodeUpdatesSheet({
-
{available}
+
{totalAvailable}
Available
@@ -405,7 +414,9 @@ export function NodeUpdatesSheet({ {formatVersion(s.version) ?? unknown} - {formatVersion(s.latestVersion) ?? unknown} + {s.isDevImage + ? Integration build + : formatVersion(s.latestVersion) ?? unknown}
{s.updateStatus && ( @@ -421,7 +432,7 @@ export function NodeUpdatesSheet({ onDismiss={isAdmin ? () => dismissNodeUpdate(s.nodeId) : undefined} /> )} - {!s.updateStatus && !s.updateAvailable && !s.skipActive && ( + {!s.updateStatus && !s.updateAvailable && !s.devBuildUpdateAvailable && !s.skipActive && ( Up to date @@ -448,7 +459,7 @@ export function NodeUpdatesSheet({ className="text-[10px] px-1.5 py-0 h-5 bg-muted text-muted-foreground border-card-border/40" /> )} - {s.updateAvailable && !s.updateStatus && !s.skipActive && !(s.updateBlocked && s.imageChannel !== 'hardened') && isAdmin && ( + {(s.updateAvailable || s.devBuildUpdateAvailable) && !s.updateStatus && !s.skipActive && !(s.updateBlocked && s.imageChannel !== 'hardened') && isAdmin && ( @@ -47,6 +48,56 @@ describe('LocalUpdateConfirmDialog', () => { expect(screen.queryByText(/rewrites it to/i)).not.toBeInTheDocument(); }); + it('explains a dev-image update with the dev kicker and no-repin, unsigned-image copy', () => { + render( + , + ); + expect(screen.getByText('LOCAL · DEV UPDATE')).toBeInTheDocument(); + expect(screen.getByText(/ghcr\.io\/studio-saelix\/sencho-dev:dev/)).toBeInTheDocument(); + expect(screen.getByText(/image reference is not rewritten/i)).toBeInTheDocument(); + expect(screen.getByText(/unsigned/i)).toBeInTheDocument(); + }); + + it('keeps the reapply kicker and copy for a dev image in reapply mode', () => { + render( + , + ); + expect(screen.getByText('LOCAL · REAPPLY')).toBeInTheDocument(); + expect(screen.queryByText('LOCAL · DEV UPDATE')).not.toBeInTheDocument(); + expect(screen.getByText(/current Compose configuration/i)).toBeInTheDocument(); + }); + + it('uses the generic update copy and kicker when isDevImage is absent', () => { + render( + , + ); + expect(screen.getByText('LOCAL · UPDATE')).toBeInTheDocument(); + expect(screen.queryByText('LOCAL · DEV UPDATE')).not.toBeInTheDocument(); + }); + it('explains local reapply without a version change or image rewrite', () => { render( { expect(screen.queryByRole('button', { name: /Update/ })).not.toBeInTheDocument(); }); + it('shows the Integration image badge regardless of update availability', () => { + render( + , + ); + expect(screen.getByText('Integration image')).toBeInTheDocument(); + }); + + it('does not show the Integration image badge for a non-dev node', () => { + render(); + expect(screen.queryByText('Integration image')).not.toBeInTheDocument(); + }); + + it('shows the dev-build update button for an admin when a dev build is available', async () => { + const onUpdate = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + const button = screen.getByRole('button', { name: /Update dev build/ }); + expect(button).toBeInTheDocument(); + expect(screen.getByText('Integration image')).toBeInTheDocument(); + await user.click(button); + expect(onUpdate).toHaveBeenCalledWith(2); + }); + + it('hides the dev-build update button for a non-admin', () => { + useAuthMock.mockReturnValue({ isAdmin: false, can: vi.fn(() => false) }); + render( + , + ); + expect(screen.queryByRole('button', { name: /Update dev build/ })).not.toBeInTheDocument(); + expect(screen.getByText('Integration image')).toBeInTheDocument(); + }); + + it('hides the dev-build update button when no dev build is available', () => { + render( + , + ); + expect(screen.queryByRole('button', { name: /Update dev build/ })).not.toBeInTheDocument(); + }); + + it('never shows both update buttons for a well-formed dev row (mutual exclusion by construction)', () => { + // The backend (fleet.ts) guarantees updateAvailable=false whenever isDevImage + // is true, so the two buttons' gating conditions can never both be satisfied + // for real data; the component intentionally adds no redundant isDevImage + // check to the stable button. This fixture reflects what the backend can + // actually send, not an artificial one. + render( + , + ); + expect(screen.getByRole('button', { name: /Update dev build/ })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Update to/ })).not.toBeInTheDocument(); + }); + it('shows the networking signal badge and switches to the node on click', async () => { const onOpenNetworking = vi.fn(); const user = userEvent.setup(); diff --git a/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx b/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx index 04c9654a..522123db 100644 --- a/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx +++ b/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { render, screen, within, fireEvent, waitFor } from '@testing-library/react'; const apiFetchMock = vi.fn(); vi.mock('@/lib/api', () => ({ apiFetch: (...a: unknown[]) => apiFetchMock(...a) })); @@ -303,6 +303,74 @@ describe('NodeUpdatesSheet', () => { expect(screen.getByLabelText('Retry update')).toBeInTheDocument(); }); + const DEV_STATUSES: NodeUpdateStatus[] = [ + { nodeId: 1, name: 'Local', type: 'local', version: '1.0.0', latestVersion: '1.1.0', updateAvailable: false, updateStatus: null, isDevImage: true, devBuildUpdateAvailable: true }, + { nodeId: 2, name: 'Edge', type: 'remote', version: '1.0.0', latestVersion: '1.1.0', updateAvailable: true, updateStatus: null }, + ]; + + it('counts stable and dev availability separately in the summary and meta text', () => { + render(); + // 2 total available (1 stable + 1 dev). + expect(screen.getByText('2')).toBeInTheDocument(); + }); + + it('does not light the changelog dot from a dev-only update', () => { + const devOnly: NodeUpdateStatus[] = [ + { nodeId: 1, name: 'Local', type: 'local', version: '1.0.0', latestVersion: '1.1.0', updateAvailable: false, updateStatus: null, isDevImage: true, devBuildUpdateAvailable: true }, + ]; + render(); + const changelogTab = screen.getByRole('tab', { name: /Changelog/ }); + expect(changelogTab.querySelector('.animate-ping')).toBeNull(); + }); + + it('lights the changelog dot from a stable-only update', () => { + render(); + const changelogTab = screen.getByRole('tab', { name: /Changelog/ }); + expect(changelogTab.querySelector('.animate-ping')).not.toBeNull(); + }); + + it('does not show the per-row Up to date badge for a dev row with a build available', () => { + render(); + const row = screen.getByText('Local').closest('.grid') as HTMLElement; + // The summary section always renders a static "Up to date" category + // label regardless of count, so this must be scoped to the row itself. + expect(within(row).queryByText('Up to date')).not.toBeInTheDocument(); + }); + + it('shows Integration build instead of a stable version in the Latest column for a dev row', () => { + render(); + expect(screen.getByText('Integration build')).toBeInTheDocument(); + }); + + it('shows the Update action for an admin on a dev-available row', () => { + const triggerNodeUpdate = vi.fn(); + render(); + const buttons = screen.getAllByRole('button', { name: /Update$/ }); + // One for the dev row (nodeId 1), one for the stable row (nodeId 2). + expect(buttons).toHaveLength(2); + fireEvent.click(buttons[0]); + expect(triggerNodeUpdate).toHaveBeenCalledWith(1); + }); + + it('shows the read-only Available badge for a non-admin on a dev-available row', () => { + render(); + expect(screen.getAllByText('Available').length).toBeGreaterThan(0); + expect(screen.queryByRole('button', { name: /Update$/ })).not.toBeInTheDocument(); + }); + + it('excludes a dev row from Update all and Skip (both remain stable-only)', () => { + render(); + // Only the remote stable row (nodeId 2) counts toward Update all. + expect(screen.getByRole('button', { name: 'Update all (1)' })).toBeInTheDocument(); + // Skip requires updateAvailable (stable), which is false for the dev row, + // so it never renders one, even though the stable "Edge" row legitimately + // gets one in this same fixture. + const devRow = screen.getByText('Local').closest('.grid') as HTMLElement; + expect(within(devRow).queryByRole('button', { name: 'Skip' })).not.toBeInTheDocument(); + const stableRow = screen.getByText('Edge').closest('.grid') as HTMLElement; + expect(within(stableRow).getByRole('button', { name: 'Skip' })).toBeInTheDocument(); + }); + it('toasts when a recheck is throttled by the server (rechecked:false)', async () => { apiFetchMock.mockResolvedValue({ ok: true, json: async () => ({ rechecked: false }) }); render(); diff --git a/frontend/src/components/FleetView/hooks/__tests__/useFleetUpdateStatus.test.tsx b/frontend/src/components/FleetView/hooks/__tests__/useFleetUpdateStatus.test.tsx index 60971f18..0d3c5538 100644 --- a/frontend/src/components/FleetView/hooks/__tests__/useFleetUpdateStatus.test.tsx +++ b/frontend/src/components/FleetView/hooks/__tests__/useFleetUpdateStatus.test.tsx @@ -248,6 +248,56 @@ describe('useFleetUpdateStatus', () => { vi.unstubAllGlobals(); }); + it('confirmLocalUpdate omits targetVersion for a dev image even when latestVersion is a valid stable version', async () => { + const devStatuses: NodeUpdateStatus[] = [ + { ...STATUSES[0], isDevImage: true }, + STATUSES[1], + ]; + apiFetchMock.mockResolvedValue(okJson({ nodes: devStatuses })); + const { result } = renderHook(() => useFleetUpdateStatus()); + await act(async () => { await result.current.fetchUpdateStatus(); }); + + await act(async () => { await result.current.triggerNodeUpdate(1); }); + expect(result.current.localUpdateConfirm).toBe(1); + + apiFetchMock.mockResolvedValue(okJson({ message: 'ok' })); + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve( + new Response(JSON.stringify({ startedAt: 1000 }), { status: 200, headers: { 'Content-Type': 'application/json' } }), + ))); + + await act(async () => { await result.current.confirmLocalUpdate(); }); + + expect(apiFetchMock).toHaveBeenCalledWith( + '/fleet/nodes/1/update', + expect.objectContaining({ method: 'POST', localOnly: true }), + ); + const call = apiFetchMock.mock.calls.find(([url]) => url === '/fleet/nodes/1/update'); + expect(call![1]).not.toHaveProperty('body'); + vi.unstubAllGlobals(); + }); + + it('confirmLocalUpdate still omits targetVersion for a dev image with no valid latestVersion', async () => { + const devStatuses: NodeUpdateStatus[] = [ + { ...STATUSES[0], isDevImage: true, latestVersion: null }, + STATUSES[1], + ]; + apiFetchMock.mockResolvedValue(okJson({ nodes: devStatuses })); + const { result } = renderHook(() => useFleetUpdateStatus()); + await act(async () => { await result.current.fetchUpdateStatus(); }); + + await act(async () => { await result.current.triggerNodeUpdate(1); }); + apiFetchMock.mockResolvedValue(okJson({ message: 'ok' })); + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve( + new Response(JSON.stringify({ startedAt: 1000 }), { status: 200, headers: { 'Content-Type': 'application/json' } }), + ))); + + await act(async () => { await result.current.confirmLocalUpdate(); }); + + const call = apiFetchMock.mock.calls.find(([url]) => url === '/fleet/nodes/1/update'); + expect(call![1]).not.toHaveProperty('body'); + vi.unstubAllGlobals(); + }); + it('dismisses the reconnecting overlay when the local update resolves failed', async () => { apiFetchMock.mockResolvedValue(okJson({ nodes: STATUSES })); const { result } = renderHook(() => useFleetUpdateStatus()); diff --git a/frontend/src/components/FleetView/hooks/useFleetUpdateStatus.ts b/frontend/src/components/FleetView/hooks/useFleetUpdateStatus.ts index a1779a8c..c00fe680 100644 --- a/frontend/src/components/FleetView/hooks/useFleetUpdateStatus.ts +++ b/frontend/src/components/FleetView/hooks/useFleetUpdateStatus.ts @@ -7,10 +7,18 @@ import { useComposeReapplyAction } from './useComposeReapplyAction'; /** POST body for an update trigger: forward the target release when it is a * valid version so the receiving node can repin a semver pin to it; omit - * otherwise so the backend falls back to its compare target. */ + * otherwise so the backend falls back to its compare target. + * + * A dev image never gets a targetVersion, even when latestVersion is a + * valid stable release: latestVersion there is the latest STABLE release, + * unrelated to what a dev-channel update actually installs. The backend + * already ignores targetVersion safely for a floating pin (it only repins + * a semver-classified pin), so this is a copy-accuracy fix, not a safety + * fix: without it, the button label and confirm-dialog would claim a + * stable version number the update isn't installing. */ function updateRequestInit(status: NodeUpdateStatus | undefined): RequestInit & { localOnly: true } { const base = { method: 'POST', localOnly: true } as const; - return isValidVersion(status?.latestVersion) + return !status?.isDevImage && isValidVersion(status?.latestVersion) ? { ...base, body: JSON.stringify({ targetVersion: status!.latestVersion }) } : base; } diff --git a/frontend/src/components/FleetView/types.ts b/frontend/src/components/FleetView/types.ts index 6b1ba012..565767de 100644 --- a/frontend/src/components/FleetView/types.ts +++ b/frontend/src/components/FleetView/types.ts @@ -77,6 +77,14 @@ export interface NodeUpdateStatus { operationKind?: 'update' | 'reapply_configuration' | null; /** True when this Compose-managed node can reapply its on-disk configuration. */ canReapplyCompose?: boolean; + /** True when the compose-declared image is any reference to the sencho-dev + * repository, including digest pins and dev-. Reflects what compose + * DECLARES, not necessarily what the container is currently running if + * compose was edited without a reapply. Local node only. */ + isDevImage?: boolean; + /** True only when isDevImage is true, the pin is the exact floating :dev + * tag, and a newer build digest has been observed. Local node only. */ + devBuildUpdateAvailable?: boolean; } export type ViewMode = 'grid' | 'topology'; diff --git a/frontend/src/components/NotificationPanel.tsx b/frontend/src/components/NotificationPanel.tsx index 7df312d4..052780da 100644 --- a/frontend/src/components/NotificationPanel.tsx +++ b/frontend/src/components/NotificationPanel.tsx @@ -153,7 +153,8 @@ export function NotificationPanel({ ); const hasNodeUpdateNotifs = useMemo( - () => notifications.some((n) => !n.is_read && n.category === 'node_update_available'), + () => notifications.some((n) => !n.is_read + && (n.category === 'node_update_available' || n.category === 'dev_build_update_available')), [notifications], ); diff --git a/frontend/src/components/dashboard/types.ts b/frontend/src/components/dashboard/types.ts index d2485b7d..cd40ed78 100644 --- a/frontend/src/components/dashboard/types.ts +++ b/frontend/src/components/dashboard/types.ts @@ -69,6 +69,7 @@ export type NotificationCategory = | 'health_gate_failed' | 'rollback_generation_released' | 'node_update_available' + | 'dev_build_update_available' | 'system'; export interface NotificationItem { diff --git a/frontend/src/components/mobile/MobileFleet.tsx b/frontend/src/components/mobile/MobileFleet.tsx index 6eefb642..cb6d8455 100644 --- a/frontend/src/components/mobile/MobileFleet.tsx +++ b/frontend/src/components/mobile/MobileFleet.tsx @@ -1,15 +1,19 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; -import { ChevronRight, Loader2 } from 'lucide-react'; +import { ChevronRight, FlaskConical, Loader2 } from 'lucide-react'; import { apiFetch } from '@/lib/api'; import { useAuth } from '@/context/AuthContext'; import { useNodes } from '@/context/NodeContext'; import { cordonNode, uncordonNode } from '@/lib/nodesApi'; import { toast } from '@/components/ui/toast-store'; import { ConfirmModal } from '@/components/ui/modal'; +import { BusyButton } from '@/components/ui/busy-button'; import { formatBytes } from '@/lib/utils'; import { getNodeCpu, getNodeMem, getNodeMemUsed, getNodeMemTotal, getNodeDisk, isCritical } from '@/components/FleetView/nodeUtils'; import { NodeDetailsSheet } from '@/components/FleetView/NodeDetailsSheet'; -import type { FleetNode } from '@/components/FleetView/types'; +import { LocalUpdateConfirmDialog } from '@/components/FleetView/LocalUpdateConfirmDialog'; +import { ReconnectingOverlay } from '@/components/FleetView/ReconnectingOverlay'; +import { useFleetUpdateStatus } from '@/components/FleetView/hooks/useFleetUpdateStatus'; +import type { FleetNode, NodeUpdateStatus } from '@/components/FleetView/types'; import { Bar, BackChip, Kicker, Masthead, MBtn, SectionHead, StateDot, StatePill } from './mobile-ui'; import type { Tone as UiTone } from './mobile-ui'; @@ -94,7 +98,7 @@ function StatCell({ label, value }: { label: string; value: string }) { ); } -function NodeCard({ node, isActive, onOpen }: { node: FleetNode; isActive: boolean; onOpen: () => void }) { +function NodeCard({ node, isActive, isDevImage, onOpen }: { node: FleetNode; isActive: boolean; isDevImage: boolean; onOpen: () => void }) { const tone = nodeTone(node); const local = node.type === 'local'; const stateLabel = node.status !== 'online' ? 'offline' : isCritical(node) ? 'critical' : 'online'; @@ -118,6 +122,11 @@ function NodeCard({ node, isActive, onOpen }: { node: FleetNode; isActive: boole active ) : null} + {isDevImage ? ( + + integration + + ) : null} {node.cordoned ? 'cordoned' : stateLabel} @@ -131,6 +140,25 @@ function NodeCard({ node, isActive, onOpen }: { node: FleetNode; isActive: boole ); } +// Sibling to NodeCard's outer