mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
feat: add Admiral Hardened Build channel and business assurance surfaces (#1629)
* feat: add Admiral Hardened Build channel and business assurance surfaces Introduce Studio Saelix entitlement-backed Hardened Build switching, a single-flight image operation coordinator, Recovery Vault naming, Admiral Account settings, and typed Fleet update failures while preserving Community custom-repo and targetless pull-current updates. * fix: harden image-op paths and clear CI CodeQL/pilot flake Validate operation IDs before filesystem use, use hostname checks in Fleet fetch mocks, sanitize registry probe logs, and swallow expected TCP teardown errors in the pilot reverse-route post-handshake test. * fix: sanitize image-op docker config write and probe logs Allowlist-copy registry host keys and base64 auth before writing the temp DOCKER_CONFIG, and log registry probe failures with a fixed message so CodeQL no longer flags network-to-file and log-injection mediums. * fix: address Admiral Hardened Build audit blockers Expose imageChannel so hardened Fleet peers still POST for typed rejection, claim community updates before 202, terminalize helper failures, gate Hardened on paid, and align support/docs/e2e wording. * fix: terminalize image ops on helper survival and aborted claims * fix: prevent recreating persist from overwriting helper-exit failure * test: assert helper-exit failure lands before recreating persist * fix: keep current pointer when acknowledging a stale image operation
This commit is contained in:
@@ -19,6 +19,13 @@ NODE_ENV=production
|
|||||||
# Frontend URL for CORS in production (leave empty for same-origin setups)
|
# Frontend URL for CORS in production (leave empty for same-origin setups)
|
||||||
FRONTEND_URL=
|
FRONTEND_URL=
|
||||||
|
|
||||||
|
# Base URL for the Hardened Build assurance API
|
||||||
|
SENCHO_ASSURANCE_API=https://sencho.io
|
||||||
|
|
||||||
|
# Non-production/test-only entitlement stub (1, entitled, unauthorized, unavailable).
|
||||||
|
# Ignored when NODE_ENV=production.
|
||||||
|
SENCHO_ASSURANCE_ENTITLEMENT_STUB=
|
||||||
|
|
||||||
# Global API rate limit (requests per minute per user session, production only)
|
# Global API rate limit (requests per minute per user session, production only)
|
||||||
# Authenticated requests are keyed by user ID; unauthenticated by IP.
|
# Authenticated requests are keyed by user ID; unauthenticated by IP.
|
||||||
# Internal node-to-node traffic (node_proxy tokens) bypasses this limit entirely.
|
# Internal node-to-node traffic (node_proxy tokens) bypasses this limit entirely.
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ describe('fetchRemoteMeta Authorization header', () => {
|
|||||||
updateError: null,
|
updateError: null,
|
||||||
online: false,
|
online: false,
|
||||||
imagePinKind: null,
|
imagePinKind: null,
|
||||||
updateBlocked: false,
|
updateBlocked: false, imageChannel: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -234,10 +234,10 @@ describe('GET /api/fleet/update-status (pilot-agent)', () => {
|
|||||||
updateError: null,
|
updateError: null,
|
||||||
online: true,
|
online: true,
|
||||||
imagePinKind: null,
|
imagePinKind: null,
|
||||||
updateBlocked: false,
|
updateBlocked: false, imageChannel: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { version: null, capabilities: [], startedAt: null, updateError: null, online: false, imagePinKind: null, updateBlocked: false };
|
return { version: null, capabilities: [], startedAt: null, updateError: null, online: false, imagePinKind: null, updateBlocked: false, imageChannel: null };
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await request(app).get('/api/fleet/update-status').set('Authorization', authHeader);
|
const res = await request(app).get('/api/fleet/update-status').set('Authorization', authHeader);
|
||||||
@@ -257,7 +257,7 @@ describe('GET /api/fleet/update-status (pilot-agent)', () => {
|
|||||||
updateError: null,
|
updateError: null,
|
||||||
online: false,
|
online: false,
|
||||||
imagePinKind: null,
|
imagePinKind: null,
|
||||||
updateBlocked: false,
|
updateBlocked: false, imageChannel: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await request(app).get('/api/fleet/update-status').set('Authorization', authHeader);
|
const res = await request(app).get('/api/fleet/update-status').set('Authorization', authHeader);
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const META_ONLINE_OUTDATED: RemoteMeta = {
|
|||||||
updateError: null,
|
updateError: null,
|
||||||
online: true,
|
online: true,
|
||||||
imagePinKind: null,
|
imagePinKind: null,
|
||||||
updateBlocked: false,
|
updateBlocked: false, imageChannel: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const META_OFFLINE: RemoteMeta = {
|
const META_OFFLINE: RemoteMeta = {
|
||||||
@@ -47,7 +47,7 @@ const META_OFFLINE: RemoteMeta = {
|
|||||||
updateError: null,
|
updateError: null,
|
||||||
online: false,
|
online: false,
|
||||||
imagePinKind: null,
|
imagePinKind: null,
|
||||||
updateBlocked: false,
|
updateBlocked: false, imageChannel: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const META_NO_SELF_UPDATE: RemoteMeta = {
|
const META_NO_SELF_UPDATE: RemoteMeta = {
|
||||||
@@ -255,7 +255,7 @@ describe('GET /api/fleet/update-status (remote-meta cache invalidation)', () =>
|
|||||||
mockTargetForPilot();
|
mockTargetForPilot();
|
||||||
mockCompareTargetFetch();
|
mockCompareTargetFetch();
|
||||||
// Remote now reports a different version than before the update (signal 1).
|
// Remote now reports a different version than before the update (signal 1).
|
||||||
mockMeta({ version: '0.99.0', capabilities: ['stacks'], startedAt: 2, updateError: null, online: true, imagePinKind: null, updateBlocked: false });
|
mockMeta({ version: '0.99.0', capabilities: ['stacks'], startedAt: 2, updateError: null, online: true, imagePinKind: null, updateBlocked: false, imageChannel: null });
|
||||||
|
|
||||||
const tracker = FleetUpdateTrackerService.getInstance();
|
const tracker = FleetUpdateTrackerService.getInstance();
|
||||||
tracker.set(proxyNodeId, tracker.create('updating', '0.83.0', null));
|
tracker.set(proxyNodeId, tracker.create('updating', '0.83.0', null));
|
||||||
@@ -272,7 +272,7 @@ describe('GET /api/fleet/update-status (remote-meta cache invalidation)', () =>
|
|||||||
it('does not drop the cache on a steady-state completed poll (transition guard)', async () => {
|
it('does not drop the cache on a steady-state completed poll (transition guard)', async () => {
|
||||||
mockTargetForPilot();
|
mockTargetForPilot();
|
||||||
mockCompareTargetFetch();
|
mockCompareTargetFetch();
|
||||||
mockMeta({ version: '0.99.0', capabilities: ['stacks'], startedAt: 2, updateError: null, online: true, imagePinKind: null, updateBlocked: false });
|
mockMeta({ version: '0.99.0', capabilities: ['stacks'], startedAt: 2, updateError: null, online: true, imagePinKind: null, updateBlocked: false, imageChannel: null });
|
||||||
|
|
||||||
// Already completed before this poll: no transition, so no invalidation.
|
// Already completed before this poll: no transition, so no invalidation.
|
||||||
const tracker = FleetUpdateTrackerService.getInstance();
|
const tracker = FleetUpdateTrackerService.getInstance();
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const ONLINE = (over: Partial<RemoteMeta> = {}): RemoteMeta => ({
|
|||||||
online: true,
|
online: true,
|
||||||
imagePinKind: null,
|
imagePinKind: null,
|
||||||
updateBlocked: false,
|
updateBlocked: false,
|
||||||
|
imageChannel: null,
|
||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -217,6 +218,108 @@ describe('POST /api/fleet/nodes/:id/update concurrency', () => {
|
|||||||
expect(res.status).toBe(409);
|
expect(res.status).toBe(409);
|
||||||
expect(res.body?.error).toMatch(/already in progress/i);
|
expect(res.body?.error).toMatch(/already in progress/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves a typed remote update failure in the response and tracker', async () => {
|
||||||
|
mockTarget();
|
||||||
|
mockMeta(ONLINE());
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
|
try {
|
||||||
|
if (new URL(String(input)).hostname === 'api.github.com') {
|
||||||
|
return new Response(JSON.stringify({ tag_name: 'v0.99.0' }), { status: 200 });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-URL fetch inputs fall through to the remote-update mock.
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: 'Hardened Build updates require a signed-in admin on that node.',
|
||||||
|
code: 'HARDENED_REMOTE_UPDATE_UNSUPPORTED',
|
||||||
|
}), { status: 403 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/fleet/nodes/${proxyNodeId}/update`)
|
||||||
|
.set('Authorization', adminAuth);
|
||||||
|
|
||||||
|
expect(res.status).toBe(502);
|
||||||
|
expect(res.body).toEqual({
|
||||||
|
error: 'Hardened Build updates require a signed-in admin on that node.',
|
||||||
|
code: 'HARDENED_REMOTE_UPDATE_UNSUPPORTED',
|
||||||
|
});
|
||||||
|
expect(FleetUpdateTrackerService.getInstance().get(proxyNodeId)?.code)
|
||||||
|
.toBe('HARDENED_REMOTE_UPDATE_UNSUPPORTED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe('POST /api/fleet/nodes/:id/update hardened digest pin', () => {
|
||||||
|
it('still POSTs when updateBlocked and imageChannel is hardened', async () => {
|
||||||
|
mockTarget();
|
||||||
|
mockMeta(ONLINE({ updateBlocked: true, imageChannel: 'hardened', imagePinKind: 'digest' }));
|
||||||
|
let remoteUpdateCalled = false;
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
|
try {
|
||||||
|
if (new URL(String(input)).hostname === 'api.github.com') {
|
||||||
|
return new Response(JSON.stringify({ tag_name: 'v0.99.0' }), { status: 200 });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-URL fetch inputs fall through to the remote-update mock.
|
||||||
|
}
|
||||||
|
remoteUpdateCalled = true;
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: 'Hardened Build updates require a signed-in admin on that node.',
|
||||||
|
code: 'HARDENED_REMOTE_UPDATE_UNSUPPORTED',
|
||||||
|
}), { status: 403 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/fleet/nodes/${proxyNodeId}/update`)
|
||||||
|
.set('Authorization', adminAuth);
|
||||||
|
|
||||||
|
expect(remoteUpdateCalled).toBe(true);
|
||||||
|
expect(res.status).toBe(502);
|
||||||
|
expect(res.body).toEqual({
|
||||||
|
error: 'Hardened Build updates require a signed-in admin on that node.',
|
||||||
|
code: 'HARDENED_REMOTE_UPDATE_UNSUPPORTED',
|
||||||
|
});
|
||||||
|
expect(FleetUpdateTrackerService.getInstance().get(proxyNodeId)?.code)
|
||||||
|
.toBe('HARDENED_REMOTE_UPDATE_UNSUPPORTED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/fleet/update-all typed failures', () => {
|
||||||
|
it('reports remote rejections as failed instead of skipped', async () => {
|
||||||
|
mockTarget();
|
||||||
|
mockMeta(ONLINE());
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
|
try {
|
||||||
|
if (new URL(String(input)).hostname === 'api.github.com') {
|
||||||
|
return new Response(JSON.stringify({ tag_name: 'v0.99.0' }), { status: 200 });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-URL fetch inputs fall through to the remote-update mock.
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: 'Hardened Build updates require a signed-in admin on that node.',
|
||||||
|
code: 'HARDENED_REMOTE_UPDATE_UNSUPPORTED',
|
||||||
|
}), { status: 403 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/fleet/update-all')
|
||||||
|
.set('Authorization', adminAuth);
|
||||||
|
|
||||||
|
expect(res.status).toBe(202);
|
||||||
|
expect(res.body).toEqual({
|
||||||
|
updating: [],
|
||||||
|
skipped: [],
|
||||||
|
failed: [{
|
||||||
|
nodeId: proxyNodeId,
|
||||||
|
name: 'proxy-hardening-test',
|
||||||
|
code: 'HARDENED_REMOTE_UPDATE_UNSUPPORTED',
|
||||||
|
error: 'Hardened Build updates require a signed-in admin on that node.',
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('clear-route authorization', () => {
|
describe('clear-route authorization', () => {
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const { mockGetSystemState, mockPost, mockGetTier } = vi.hoisted(() => ({
|
||||||
|
mockGetSystemState: vi.fn(),
|
||||||
|
mockPost: vi.fn(),
|
||||||
|
mockGetTier: vi.fn((): 'paid' | 'community' => 'paid'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../services/DatabaseService', () => ({
|
||||||
|
DatabaseService: {
|
||||||
|
getInstance: () => ({
|
||||||
|
getSystemState: mockGetSystemState,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../services/LicenseService', () => ({
|
||||||
|
LicenseService: {
|
||||||
|
getInstance: () => ({
|
||||||
|
getTier: mockGetTier,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('axios', () => ({
|
||||||
|
default: {
|
||||||
|
post: mockPost,
|
||||||
|
isAxiosError: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { validateAllowedImageRefAgainstRequirement } from '../helpers/allowedImageRef';
|
||||||
|
import { HardenedEntitlementService } from '../services/HardenedEntitlementService';
|
||||||
|
|
||||||
|
const registryRequirement = {
|
||||||
|
registry_host: 'ghcr.io',
|
||||||
|
package_scope: 'studio-saelix/sencho-hardened',
|
||||||
|
credential_instructions: 'Create a pull token.',
|
||||||
|
supports_pull_token: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const entitlementFixture = {
|
||||||
|
hardened_build_access: true,
|
||||||
|
channel: 'hardened',
|
||||||
|
allowed_image_ref: 'ghcr.io/studio-saelix/sencho-hardened@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||||
|
pin_recommendation: 'Use the supplied digest.',
|
||||||
|
registry_requirement: registryRequirement,
|
||||||
|
checked_at: '2026-07-13T12:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
HardenedEntitlementService.getInstance().invalidateCache();
|
||||||
|
mockGetTier.mockReturnValue('paid');
|
||||||
|
mockGetSystemState.mockImplementation((key: string) => ({
|
||||||
|
license_key: 'license-for-test',
|
||||||
|
instance_id: 'instance-for-test',
|
||||||
|
})[key] ?? '');
|
||||||
|
delete process.env.SENCHO_ASSURANCE_ENTITLEMENT_STUB;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('allowed Hardened image references', () => {
|
||||||
|
it('rejects a bare digest', () => {
|
||||||
|
expect(validateAllowedImageRefAgainstRequirement(
|
||||||
|
'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||||
|
registryRequirement,
|
||||||
|
)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an image outside the entitled package scope', () => {
|
||||||
|
expect(validateAllowedImageRefAgainstRequirement(
|
||||||
|
'ghcr.io/studio-saelix/other@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||||
|
registryRequirement,
|
||||||
|
)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HardenedEntitlementService', () => {
|
||||||
|
it('rejects entitlement checks when the tier is not paid', async () => {
|
||||||
|
mockGetTier.mockReturnValue('community');
|
||||||
|
process.env.SENCHO_ASSURANCE_ENTITLEMENT_STUB = 'entitled';
|
||||||
|
|
||||||
|
await expect(HardenedEntitlementService.getInstance().getEntitlement('status'))
|
||||||
|
.resolves.toEqual({ success: false, code: 'unauthorized' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores the entitlement stub in production', async () => {
|
||||||
|
const previous = process.env.NODE_ENV;
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
process.env.SENCHO_ASSURANCE_ENTITLEMENT_STUB = 'entitled';
|
||||||
|
mockPost.mockResolvedValue({ status: 200, data: entitlementFixture });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(HardenedEntitlementService.getInstance().getEntitlement('status'))
|
||||||
|
.resolves.toMatchObject({ success: true, entitlement: entitlementFixture });
|
||||||
|
expect(mockPost).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
process.env.NODE_ENV = previous;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the entitled stub response', async () => {
|
||||||
|
process.env.SENCHO_ASSURANCE_ENTITLEMENT_STUB = 'entitled';
|
||||||
|
|
||||||
|
await expect(HardenedEntitlementService.getInstance().getEntitlement('status'))
|
||||||
|
.resolves.toMatchObject({
|
||||||
|
success: true,
|
||||||
|
entitlement: {
|
||||||
|
...entitlementFixture,
|
||||||
|
checked_at: expect.any(String),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the unauthorized stub response', async () => {
|
||||||
|
process.env.SENCHO_ASSURANCE_ENTITLEMENT_STUB = 'unauthorized';
|
||||||
|
|
||||||
|
await expect(HardenedEntitlementService.getInstance().getEntitlement('status'))
|
||||||
|
.resolves.toEqual({ success: false, code: 'unauthorized' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidates a cached status entitlement', async () => {
|
||||||
|
mockPost.mockResolvedValue({ status: 200, data: entitlementFixture });
|
||||||
|
const service = HardenedEntitlementService.getInstance();
|
||||||
|
|
||||||
|
await service.getEntitlement('status');
|
||||||
|
await service.getEntitlement('status');
|
||||||
|
expect(mockPost).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
service.invalidateCache();
|
||||||
|
await service.getEntitlement('status');
|
||||||
|
expect(mockPost).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { classifyImageChannel, normalizeImageRepository } from '../helpers/imageChannel';
|
||||||
|
|
||||||
|
describe('image channel classification', () => {
|
||||||
|
it.each([
|
||||||
|
'saelix/sencho:latest',
|
||||||
|
'docker.io/saelix/sencho:latest',
|
||||||
|
'index.docker.io/saelix/sencho@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||||
|
'ghcr.io/studio-saelix/sencho:v1.2.3',
|
||||||
|
'ghcr.io/studio-saelix/sencho-dev:dev',
|
||||||
|
])('classifies the canonical Community alias %s', (imageRef) => {
|
||||||
|
expect(classifyImageChannel(imageRef)).toBe('community');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies the exact Hardened repository', () => {
|
||||||
|
expect(classifyImageChannel('ghcr.io/studio-saelix/sencho-hardened:v1.2.3')).toBe('hardened');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps custom and similarly named repositories unknown', () => {
|
||||||
|
expect(classifyImageChannel('registry.example.com:5000/sencho:1.0.0')).toBe('unknown');
|
||||||
|
expect(classifyImageChannel('ghcr.io/studio-saelix/sencho-hardened-mirror:1.0.0')).toBe('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes Docker Hub aliases without changing registries that use ports', () => {
|
||||||
|
expect(normalizeImageRepository('docker.io/saelix/sencho:latest')).toBe('saelix/sencho');
|
||||||
|
expect(normalizeImageRepository('index.docker.io/saelix/sencho:latest')).toBe('saelix/sencho');
|
||||||
|
expect(normalizeImageRepository('registry.example.com:5000/saelix/sencho:latest'))
|
||||||
|
.toBe('registry.example.com:5000/saelix/sencho');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { cleanupTestDb, setupTestDb } from './helpers/setupTestDb';
|
||||||
|
|
||||||
|
let tmpDir: string;
|
||||||
|
let ImageOperationService: typeof import('../services/ImageOperationService').ImageOperationService;
|
||||||
|
let SelfUpdateService: typeof import('../services/SelfUpdateService').default;
|
||||||
|
let HardenedEntitlementService: typeof import('../services/HardenedEntitlementService').HardenedEntitlementService;
|
||||||
|
let RegistryService: typeof import('../services/RegistryService').RegistryService;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tmpDir = await setupTestDb();
|
||||||
|
({ ImageOperationService } = await import('../services/ImageOperationService'));
|
||||||
|
SelfUpdateService = (await import('../services/SelfUpdateService')).default;
|
||||||
|
({ HardenedEntitlementService } = await import('../services/HardenedEntitlementService'));
|
||||||
|
({ RegistryService } = await import('../services/RegistryService'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
const selfUpdate = SelfUpdateService.getInstance() as unknown as {
|
||||||
|
pendingHelperExitError: string | undefined;
|
||||||
|
helperExitListeners: Array<(error: string | null) => void>;
|
||||||
|
};
|
||||||
|
selfUpdate.pendingHelperExitError = undefined;
|
||||||
|
selfUpdate.helperExitListeners = [];
|
||||||
|
await fs.rm(path.join(tmpDir, 'image-operation-current.json'), { force: true });
|
||||||
|
await fs.rm(path.join(tmpDir, 'image-operations'), { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => cleanupTestDb(tmpDir));
|
||||||
|
|
||||||
|
describe('ImageOperationService', () => {
|
||||||
|
it('rejects a second update while the first operation is claimed', async () => {
|
||||||
|
let releaseUpdate: (() => void) | undefined;
|
||||||
|
let markTriggered: (() => void) | undefined;
|
||||||
|
const triggered = new Promise<void>(resolve => { markTriggered = resolve; });
|
||||||
|
vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null);
|
||||||
|
vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho');
|
||||||
|
vi.spyOn(SelfUpdateService.getInstance(), 'getLastError').mockReturnValue(null);
|
||||||
|
vi.spyOn(SelfUpdateService.getInstance(), 'triggerUpdate').mockImplementation(async () => {
|
||||||
|
markTriggered!();
|
||||||
|
await new Promise<void>(resolve => { releaseUpdate = resolve; });
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = ImageOperationService.getInstance();
|
||||||
|
const first = service.runCommunityUpdate();
|
||||||
|
await triggered;
|
||||||
|
const second = await service.runCommunityUpdate();
|
||||||
|
|
||||||
|
expect(second).toEqual({ ok: false, failureCode: 'IMAGE_OPERATION_IN_FLIGHT' });
|
||||||
|
releaseUpdate!();
|
||||||
|
await expect(first).resolves.toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('claims synchronously before checking existing operations', async () => {
|
||||||
|
let releaseLookup: (() => void) | undefined;
|
||||||
|
let signalLookupStarted: (() => void) | undefined;
|
||||||
|
const lookupStarted = new Promise<void>(resolve => { signalLookupStarted = resolve; });
|
||||||
|
let lookups = 0;
|
||||||
|
const service = ImageOperationService.getInstance();
|
||||||
|
vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null);
|
||||||
|
vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho');
|
||||||
|
vi.spyOn(service, 'getCurrentOperation').mockImplementation(async () => {
|
||||||
|
lookups += 1;
|
||||||
|
if (lookups === 1) {
|
||||||
|
signalLookupStarted!();
|
||||||
|
await new Promise<void>(resolve => { releaseLookup = resolve; });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = service.claimCommunityUpdate();
|
||||||
|
await lookupStarted;
|
||||||
|
const second = await service.claimCommunityUpdate();
|
||||||
|
releaseLookup!();
|
||||||
|
|
||||||
|
await expect(first).resolves.toEqual({ ok: true });
|
||||||
|
expect(second).toEqual({ ok: false, failureCode: 'IMAGE_OPERATION_IN_FLIGHT' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists a terminal failure when the update helper reports an error', async () => {
|
||||||
|
vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null);
|
||||||
|
vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho');
|
||||||
|
vi.spyOn(SelfUpdateService.getInstance(), 'triggerUpdate').mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(SelfUpdateService.getInstance(), 'getLastError').mockReturnValue('pull failed');
|
||||||
|
|
||||||
|
const result = await ImageOperationService.getInstance().runCommunityUpdate();
|
||||||
|
const current = await ImageOperationService.getInstance().getCurrentOperation();
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: false, failureCode: 'update_failed' });
|
||||||
|
expect(current?.state).toBe('failed');
|
||||||
|
expect(current?.failureCode).toBe('update_failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails a hardened update when the helper reports an error and binds its marker to the operation', async () => {
|
||||||
|
const entitlement = {
|
||||||
|
success: true as const,
|
||||||
|
entitlement: {
|
||||||
|
hardened_build_access: true,
|
||||||
|
channel: 'hardened' as const,
|
||||||
|
allowed_image_ref: 'ghcr.io/studio-saelix/sencho-hardened:latest',
|
||||||
|
pin_recommendation: 'ghcr.io/studio-saelix/sencho-hardened:latest',
|
||||||
|
checked_at: '2026-07-13T00:00:00.000Z',
|
||||||
|
registry_requirement: {
|
||||||
|
registry_host: 'ghcr.io',
|
||||||
|
package_scope: 'studio-saelix/sencho-hardened',
|
||||||
|
credential_instructions: 'Use a pull token.',
|
||||||
|
supports_pull_token: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const resolved = {
|
||||||
|
filePath: '/compose.yml',
|
||||||
|
imageRef: 'ghcr.io/studio-saelix/sencho-hardened:latest',
|
||||||
|
pinKind: 'semver' as const,
|
||||||
|
fileContent: 'services: {}',
|
||||||
|
};
|
||||||
|
let markerFile = '';
|
||||||
|
let markerContent = '';
|
||||||
|
vi.spyOn(HardenedEntitlementService.getInstance(), 'getEntitlement').mockResolvedValue(entitlement);
|
||||||
|
vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(resolved);
|
||||||
|
vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho');
|
||||||
|
vi.spyOn(RegistryService.getInstance(), 'resolveDockerConfigForHost').mockResolvedValue({
|
||||||
|
config: { auths: { 'ghcr.io': { auth: 'credential' } } },
|
||||||
|
warnings: [],
|
||||||
|
});
|
||||||
|
vi.spyOn(SelfUpdateService.getInstance(), 'triggerUpdate').mockImplementation(async options => {
|
||||||
|
markerFile = options?.successMarkerFile ?? '';
|
||||||
|
markerContent = options?.successMarkerContent ?? '';
|
||||||
|
});
|
||||||
|
vi.spyOn(SelfUpdateService.getInstance(), 'getLastError').mockReturnValue('pull failed');
|
||||||
|
|
||||||
|
const service = ImageOperationService.getInstance();
|
||||||
|
const fingerprint = service.computePreflightFingerprint(
|
||||||
|
resolved.filePath,
|
||||||
|
resolved.imageRef,
|
||||||
|
resolved.pinKind,
|
||||||
|
entitlement.entitlement.allowed_image_ref,
|
||||||
|
);
|
||||||
|
const result = await service.switchToHardened(fingerprint);
|
||||||
|
const current = await service.getCurrentOperation();
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: false, code: 'update_failed' });
|
||||||
|
expect(current?.state).toBe('failed');
|
||||||
|
expect(markerFile).toBe(path.join(tmpDir, `image-op-success-${current?.operationId}.json`));
|
||||||
|
expect(markerContent).toBe(JSON.stringify({ ok: true, operationId: current?.operationId }));
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it('fails a recreating community op when the helper exits after handoff', async () => {
|
||||||
|
let helperExit: ((error: string | null) => void) | undefined;
|
||||||
|
const callOrder: string[] = [];
|
||||||
|
vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null);
|
||||||
|
vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho');
|
||||||
|
vi.spyOn(SelfUpdateService.getInstance(), 'onceHelperExit').mockImplementation((listener) => {
|
||||||
|
callOrder.push('watch');
|
||||||
|
helperExit = listener;
|
||||||
|
});
|
||||||
|
vi.spyOn(SelfUpdateService.getInstance(), 'triggerUpdate').mockImplementation(async () => {
|
||||||
|
callOrder.push('trigger');
|
||||||
|
});
|
||||||
|
vi.spyOn(SelfUpdateService.getInstance(), 'getLastError').mockReturnValue(null);
|
||||||
|
|
||||||
|
const service = ImageOperationService.getInstance();
|
||||||
|
const result = await service.runCommunityUpdate();
|
||||||
|
const mid = await service.getCurrentOperation();
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true });
|
||||||
|
expect(mid?.state).toBe('recreating');
|
||||||
|
expect(callOrder).toEqual(['watch', 'trigger']);
|
||||||
|
expect(helperExit).toBeTypeOf('function');
|
||||||
|
|
||||||
|
helperExit!('Helper container exited without restarting Sencho');
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
const current = await service.getCurrentOperation();
|
||||||
|
expect(current?.state).toBe('failed');
|
||||||
|
expect(current?.failureCode).toBe('update_failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let recreating persist overwrite a concurrent helper-exit failure', async () => {
|
||||||
|
let helperExit: ((error: string | null) => void) | undefined;
|
||||||
|
vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null);
|
||||||
|
vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho');
|
||||||
|
vi.spyOn(SelfUpdateService.getInstance(), 'onceHelperExit').mockImplementation((listener) => {
|
||||||
|
helperExit = listener;
|
||||||
|
});
|
||||||
|
vi.spyOn(SelfUpdateService.getInstance(), 'triggerUpdate').mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(SelfUpdateService.getInstance(), 'getLastError').mockReturnValue(null);
|
||||||
|
|
||||||
|
const service = ImageOperationService.getInstance();
|
||||||
|
const proto = Object.getPrototypeOf(service) as {
|
||||||
|
persist: (operation: unknown) => Promise<void>;
|
||||||
|
};
|
||||||
|
const realPersist = proto.persist.bind(service);
|
||||||
|
vi.spyOn(service as unknown as { persist: (operation: { state: string }) => Promise<void> }, 'persist')
|
||||||
|
.mockImplementation(async (operation) => {
|
||||||
|
if (operation.state === 'recreating' && helperExit) {
|
||||||
|
const exit = helperExit;
|
||||||
|
helperExit = undefined;
|
||||||
|
// Fail must land on disk before the recreating write runs. That is the
|
||||||
|
// overwrite order without CAS: helper-exit failed, then late recreating.
|
||||||
|
exit('Helper container exited without restarting Sencho');
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
const current = await service.getCurrentOperation();
|
||||||
|
expect(current?.state).toBe('failed');
|
||||||
|
expect(current?.failureCode).toBe('update_failed');
|
||||||
|
});
|
||||||
|
await realPersist(operation);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return realPersist(operation);
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.runCommunityUpdate();
|
||||||
|
const current = await service.getCurrentOperation();
|
||||||
|
expect(current?.state).toBe('failed');
|
||||||
|
expect(current?.failureCode).toBe('update_failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let acknowledging a stale failure replace an active current operation', async () => {
|
||||||
|
vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null);
|
||||||
|
vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho');
|
||||||
|
vi.spyOn(SelfUpdateService.getInstance(), 'triggerUpdate').mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(SelfUpdateService.getInstance(), 'getLastError').mockReturnValue('pull failed');
|
||||||
|
|
||||||
|
const service = ImageOperationService.getInstance();
|
||||||
|
await service.runCommunityUpdate();
|
||||||
|
const failedA = await service.getCurrentOperation();
|
||||||
|
expect(failedA?.state).toBe('failed');
|
||||||
|
const operationAId = failedA!.operationId;
|
||||||
|
|
||||||
|
const claimB = await service.claimCommunityUpdate();
|
||||||
|
expect(claimB).toEqual({ ok: true });
|
||||||
|
const currentB = await service.getCurrentOperation();
|
||||||
|
expect(currentB?.state).toBe('pending_pull');
|
||||||
|
expect(currentB?.operationId).not.toBe(operationAId);
|
||||||
|
|
||||||
|
expect(await service.acknowledge(operationAId)).toBe(true);
|
||||||
|
const stillB = await service.getCurrentOperation();
|
||||||
|
expect(stillB?.operationId).toBe(currentB!.operationId);
|
||||||
|
expect(stillB?.state).toBe('pending_pull');
|
||||||
|
|
||||||
|
const ackedA = await service.getOperation(operationAId);
|
||||||
|
expect(ackedA?.acknowledgedAt).toBeTruthy();
|
||||||
|
|
||||||
|
const claimC = await service.claimCommunityUpdate();
|
||||||
|
expect(claimC).toEqual({ ok: false, failureCode: 'IMAGE_OPERATION_IN_FLIGHT' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replays a pending helper exit to a late onceHelperExit listener', async () => {
|
||||||
|
const svc = SelfUpdateService.getInstance();
|
||||||
|
const internal = svc as unknown as { pendingHelperExitError: string | undefined };
|
||||||
|
internal.pendingHelperExitError = 'Helper container exited without restarting Sencho';
|
||||||
|
|
||||||
|
const seen = new Promise<string | null>((resolve) => {
|
||||||
|
svc.onceHelperExit(resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(seen).resolves.toBe('Helper container exited without restarting Sencho');
|
||||||
|
expect(internal.pendingHelperExitError).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes the fingerprint when a preflight value changes', () => {
|
||||||
|
const service = ImageOperationService.getInstance();
|
||||||
|
const baseline = service.computePreflightFingerprint('/compose.yml', 'saelix/sencho:1.0.0', 'semver', 'ghcr.io/studio-saelix/sencho-hardened@sha256:aaa');
|
||||||
|
const changed = service.computePreflightFingerprint('/compose.yml', 'saelix/sencho:1.0.0', 'semver', 'ghcr.io/studio-saelix/sencho-hardened@sha256:bbb');
|
||||||
|
|
||||||
|
expect(changed).not.toBe(baseline);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -55,7 +55,7 @@ describe('NodeRegistry.fetchMetaForNode', () => {
|
|||||||
updateError: null,
|
updateError: null,
|
||||||
online: false,
|
online: false,
|
||||||
imagePinKind: null,
|
imagePinKind: null,
|
||||||
updateBlocked: false,
|
updateBlocked: false, imageChannel: null,
|
||||||
});
|
});
|
||||||
expect(axiosSpy).not.toHaveBeenCalled();
|
expect(axiosSpy).not.toHaveBeenCalled();
|
||||||
db.deleteNode(nodeId);
|
db.deleteNode(nodeId);
|
||||||
|
|||||||
@@ -249,8 +249,12 @@ describe('R1-B: PilotTunnelBridge.acceptReverseLocal route events', () => {
|
|||||||
|
|
||||||
// Real local server. Have it close the connection immediately after
|
// Real local server. Have it close the connection immediately after
|
||||||
// accept so the bridge socket sees 'close' (and possibly 'error') on
|
// accept so the bridge socket sees 'close' (and possibly 'error') on
|
||||||
// an already-connected socket.
|
// an already-connected socket. Swallow expected teardown noise so a
|
||||||
|
// late ECONNRESET cannot surface as an unhandled Vitest exception.
|
||||||
|
const accepted: net.Socket[] = [];
|
||||||
const upstream = net.createServer((socket) => {
|
const upstream = net.createServer((socket) => {
|
||||||
|
accepted.push(socket);
|
||||||
|
socket.on('error', () => { /* expected post-handshake teardown */ });
|
||||||
socket.end();
|
socket.end();
|
||||||
});
|
});
|
||||||
await new Promise<void>((resolve) => upstream.listen(0, '127.0.0.1', () => resolve()));
|
await new Promise<void>((resolve) => upstream.listen(0, '127.0.0.1', () => resolve()));
|
||||||
@@ -284,7 +288,10 @@ describe('R1-B: PilotTunnelBridge.acceptReverseLocal route events', () => {
|
|||||||
);
|
);
|
||||||
expect(failCalls).toHaveLength(0);
|
expect(failCalls).toHaveLength(0);
|
||||||
|
|
||||||
upstream.close();
|
for (const socket of accepted) {
|
||||||
|
try { socket.destroy(); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve) => upstream.close(() => resolve()));
|
||||||
bridge.close();
|
bridge.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ afterAll(() => cleanupTestDb(tmpDir));
|
|||||||
|
|
||||||
afterEach(() => vi.restoreAllMocks());
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
const ONLINE = { startedAt: null, updateError: null, online: true, imagePinKind: null, updateBlocked: false } as const;
|
const ONLINE = { startedAt: null, updateError: null, online: true, imagePinKind: null, updateBlocked: false, imageChannel: null } as const;
|
||||||
const capable: RemoteMeta = { version: '0.93.0', capabilities: ['fleet', 'cross-node-rbac'], ...ONLINE };
|
const capable: RemoteMeta = { version: '0.93.0', capabilities: ['fleet', 'cross-node-rbac'], ...ONLINE };
|
||||||
const incapable: RemoteMeta = { version: '0.92.0', capabilities: ['fleet', 'labels'], ...ONLINE };
|
const incapable: RemoteMeta = { version: '0.92.0', capabilities: ['fleet', 'labels'], ...ONLINE };
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ describe('remoteSupportsCrossNodeRbac', () => {
|
|||||||
|
|
||||||
it('fails closed when the remote is offline (empty capabilities)', async () => {
|
it('fails closed when the remote is offline (empty capabilities)', async () => {
|
||||||
vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode')
|
vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode')
|
||||||
.mockResolvedValue({ version: null, capabilities: [], startedAt: null, updateError: null, online: false, imagePinKind: null, updateBlocked: false });
|
.mockResolvedValue({ version: null, capabilities: [], startedAt: null, updateError: null, online: false, imagePinKind: null, updateBlocked: false, imageChannel: null });
|
||||||
expect(await remoteSupportsCrossNodeRbac(NODE_ID)).toBe(false);
|
expect(await remoteSupportsCrossNodeRbac(NODE_ID)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ describe('remoteSupportsCrossNodeRbac', () => {
|
|||||||
// A 0.0.0-dev image reports version null (non-semver) but is reachable and
|
// A 0.0.0-dev image reports version null (non-semver) but is reachable and
|
||||||
// genuinely advertises the capability; it must not be wrongly denied.
|
// genuinely advertises the capability; it must not be wrongly denied.
|
||||||
vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode')
|
vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode')
|
||||||
.mockResolvedValue({ version: null, capabilities: ['fleet', 'cross-node-rbac'], startedAt: null, updateError: null, online: true, imagePinKind: null, updateBlocked: false });
|
.mockResolvedValue({ version: null, capabilities: ['fleet', 'cross-node-rbac'], startedAt: null, updateError: null, online: true, imagePinKind: null, updateBlocked: false, imageChannel: null });
|
||||||
expect(await remoteSupportsCrossNodeRbac(NODE_ID)).toBe(true);
|
expect(await remoteSupportsCrossNodeRbac(NODE_ID)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,24 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /api/system/update', () => {
|
describe('POST /api/system/update', () => {
|
||||||
|
it('rejects hardened updates from node-proxy credentials', async () => {
|
||||||
|
mockSelfUpdateAvailable({
|
||||||
|
pinInfo: {
|
||||||
|
pinKind: 'digest',
|
||||||
|
composeImageRef: 'ghcr.io/studio-saelix/sencho-hardened@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||||
|
filePath: '/opt/sencho/docker-compose.yml',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const machineAuth = `Bearer ${jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' })}`;
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/system/update')
|
||||||
|
.set('Authorization', machineAuth);
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.code).toBe('HARDENED_REMOTE_UPDATE_UNSUPPORTED');
|
||||||
|
});
|
||||||
|
|
||||||
it('returns 400 for a supplied but invalid targetVersion', async () => {
|
it('returns 400 for a supplied but invalid targetVersion', async () => {
|
||||||
mockSelfUpdateAvailable();
|
mockSelfUpdateAvailable();
|
||||||
|
|
||||||
@@ -99,12 +117,27 @@ describe('POST /api/system/update', () => {
|
|||||||
expect(res.body?.message).toMatch(/restart/i);
|
expect(res.body?.message).toMatch(/restart/i);
|
||||||
// triggerUpdate runs on res finish + delay; flush the microtask queue.
|
// triggerUpdate runs on res finish + delay; flush the microtask queue.
|
||||||
await new Promise(r => setTimeout(r, 600));
|
await new Promise(r => setTimeout(r, 600));
|
||||||
expect(triggerSpy).toHaveBeenCalledWith({ targetVersion: '0.99.0' });
|
expect(triggerSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
targetVersion: '0.99.0',
|
||||||
|
successMarkerFile: expect.stringMatching(/image-op-success-[\w-]+\.json$/),
|
||||||
|
successMarkerContent: expect.stringMatching(/"operationId":"[\w-]+"/),
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /api/meta pin subset', () => {
|
describe('GET /api/meta pin subset', () => {
|
||||||
it('exposes imagePinKind and updateBlocked but never composeImageRef', async () => {
|
it('does not expose a raw update error with a private image reference', async () => {
|
||||||
|
mockSelfUpdateAvailable();
|
||||||
|
vi.spyOn(SelfUpdateService.getInstance(), 'getLastError')
|
||||||
|
.mockReturnValue('pull private.registry.example/internal/sencho:1.0 failed');
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/meta');
|
||||||
|
|
||||||
|
expect(res.body.updateError).toBe('update_failed');
|
||||||
|
expect(JSON.stringify(res.body)).not.toContain('private.registry.example');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes imagePinKind, updateBlocked, and imageChannel but never composeImageRef', async () => {
|
||||||
mockSelfUpdateAvailable({
|
mockSelfUpdateAvailable({
|
||||||
pinInfo: { pinKind: 'semver', composeImageRef: 'saelix/sencho:0.93.3', filePath: '/opt/sencho/docker-compose.yml' },
|
pinInfo: { pinKind: 'semver', composeImageRef: 'saelix/sencho:0.93.3', filePath: '/opt/sencho/docker-compose.yml' },
|
||||||
});
|
});
|
||||||
@@ -114,19 +147,21 @@ describe('GET /api/meta pin subset', () => {
|
|||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.imagePinKind).toBe('semver');
|
expect(res.body.imagePinKind).toBe('semver');
|
||||||
expect(res.body.updateBlocked).toBe(false);
|
expect(res.body.updateBlocked).toBe(false);
|
||||||
|
expect(res.body.imageChannel).toBe('community');
|
||||||
expect(res.body).not.toHaveProperty('composeImageRef');
|
expect(res.body).not.toHaveProperty('composeImageRef');
|
||||||
expect(res.body).not.toHaveProperty('targetImageRef');
|
expect(res.body).not.toHaveProperty('targetImageRef');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports updateBlocked=true for a digest pin', async () => {
|
it('reports updateBlocked=true for a digest pin', async () => {
|
||||||
mockSelfUpdateAvailable({
|
mockSelfUpdateAvailable({
|
||||||
pinInfo: { pinKind: 'digest', composeImageRef: 'saelix/sencho@sha256:abc', filePath: '/opt/sencho/docker-compose.yml' },
|
pinInfo: { pinKind: 'digest', composeImageRef: 'ghcr.io/studio-saelix/sencho-hardened@sha256:abc', filePath: '/opt/sencho/docker-compose.yml' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await request(app).get('/api/meta');
|
const res = await request(app).get('/api/meta');
|
||||||
|
|
||||||
expect(res.body.imagePinKind).toBe('digest');
|
expect(res.body.imagePinKind).toBe('digest');
|
||||||
expect(res.body.updateBlocked).toBe(true);
|
expect(res.body.updateBlocked).toBe(true);
|
||||||
|
expect(res.body.imageChannel).toBe('hardened');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { NodeRegistry } from '../services/NodeRegistry';
|
|||||||
import { DatabaseService } from '../services/DatabaseService';
|
import { DatabaseService } from '../services/DatabaseService';
|
||||||
import { LicenseService } from '../services/LicenseService';
|
import { LicenseService } from '../services/LicenseService';
|
||||||
import SelfUpdateService from '../services/SelfUpdateService';
|
import SelfUpdateService from '../services/SelfUpdateService';
|
||||||
|
import { ImageOperationService } from '../services/ImageOperationService';
|
||||||
import SelfIdentityService from '../services/SelfIdentityService';
|
import SelfIdentityService from '../services/SelfIdentityService';
|
||||||
import { MonitorService } from '../services/MonitorService';
|
import { MonitorService } from '../services/MonitorService';
|
||||||
import { AutoHealService } from '../services/AutoHealService';
|
import { AutoHealService } from '../services/AutoHealService';
|
||||||
@@ -165,6 +166,9 @@ export async function startServer(server: Server): Promise<void> {
|
|||||||
})(),
|
})(),
|
||||||
TrivyService.getInstance().initialize(),
|
TrivyService.getInstance().initialize(),
|
||||||
]);
|
]);
|
||||||
|
void ImageOperationService.getInstance().reconcileOnStartup().catch((error) => {
|
||||||
|
console.error('[ImageOperation] Startup reconciliation failed:', error);
|
||||||
|
});
|
||||||
|
|
||||||
// Fire-and-forget housekeeping; logged but never awaited.
|
// Fire-and-forget housekeeping; logged but never awaited.
|
||||||
sweepStaleGitTempDirs().catch((err) => {
|
sweepStaleGitTempDirs().catch((err) => {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { RegistryRequirement } from '../services/hardenedEntitlementTypes';
|
||||||
|
|
||||||
|
export interface AllowedImageReference {
|
||||||
|
registryHost: string;
|
||||||
|
repository: string;
|
||||||
|
tag?: string;
|
||||||
|
digest?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHA256_DIGEST = /^sha256:[a-f0-9]{64}$/i;
|
||||||
|
|
||||||
|
export function parseAllowedImageRef(ref: string): AllowedImageReference | null {
|
||||||
|
const imageRef = ref.trim();
|
||||||
|
if (!imageRef || /\s/.test(imageRef)) return null;
|
||||||
|
|
||||||
|
const [repositoryWithTag, digest, ...extraParts] = imageRef.split('@');
|
||||||
|
if (extraParts.length > 0 || !repositoryWithTag) return null;
|
||||||
|
if (digest !== undefined && !SHA256_DIGEST.test(digest)) return null;
|
||||||
|
|
||||||
|
const segments = repositoryWithTag.split('/');
|
||||||
|
if (segments.length < 2) return null;
|
||||||
|
|
||||||
|
const registryHost = segments.shift();
|
||||||
|
if (!registryHost || !isRegistryHost(registryHost)) return null;
|
||||||
|
|
||||||
|
const lastSegment = segments.pop();
|
||||||
|
if (!lastSegment) return null;
|
||||||
|
|
||||||
|
const tagIndex = lastSegment.lastIndexOf(':');
|
||||||
|
const repositoryLeaf = tagIndex === -1 ? lastSegment : lastSegment.slice(0, tagIndex);
|
||||||
|
const tag = tagIndex === -1 ? undefined : lastSegment.slice(tagIndex + 1);
|
||||||
|
if (!repositoryLeaf || (tagIndex !== -1 && !tag)) return null;
|
||||||
|
if (tag && digest) return null;
|
||||||
|
if (!tag && !digest) return null;
|
||||||
|
|
||||||
|
const repository = [...segments, repositoryLeaf].join('/');
|
||||||
|
if (!repository || repository.split('/').some(segment => !segment || segment === '.' || segment === '..')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { registryHost: registryHost.toLowerCase(), repository: repository.toLowerCase(), tag, digest };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateAllowedImageRefAgainstRequirement(
|
||||||
|
ref: string,
|
||||||
|
requirement: RegistryRequirement,
|
||||||
|
): boolean {
|
||||||
|
const parsed = parseAllowedImageRef(ref);
|
||||||
|
if (!parsed) return false;
|
||||||
|
return parsed.registryHost === requirement.registry_host.toLowerCase()
|
||||||
|
&& parsed.repository === requirement.package_scope.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRegistryHost(value: string): boolean {
|
||||||
|
return value === 'localhost' || value.includes('.') || value.includes(':');
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
export type ImageChannel = 'community' | 'hardened' | 'unknown';
|
||||||
|
|
||||||
|
const COMMUNITY_REPOSITORIES = new Set([
|
||||||
|
'saelix/sencho',
|
||||||
|
'ghcr.io/studio-saelix/sencho',
|
||||||
|
'ghcr.io/studio-saelix/sencho-dev',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const HARDENED_REPOSITORY = 'ghcr.io/studio-saelix/sencho-hardened';
|
||||||
|
|
||||||
|
export function normalizeImageRepository(ref: string): string | null {
|
||||||
|
const imageRef = ref.trim().toLowerCase();
|
||||||
|
if (!imageRef || /\s/.test(imageRef)) return null;
|
||||||
|
|
||||||
|
const withoutDigest = imageRef.split('@');
|
||||||
|
if (withoutDigest.length > 2 || !withoutDigest[0]) return null;
|
||||||
|
|
||||||
|
const repositoryWithTag = withoutDigest[0];
|
||||||
|
const lastSlash = repositoryWithTag.lastIndexOf('/');
|
||||||
|
const lastColon = repositoryWithTag.lastIndexOf(':');
|
||||||
|
const repository = lastColon > lastSlash
|
||||||
|
? repositoryWithTag.slice(0, lastColon)
|
||||||
|
: repositoryWithTag;
|
||||||
|
|
||||||
|
if (!repository || repository.startsWith('/') || repository.endsWith('/')) return null;
|
||||||
|
|
||||||
|
if (repository.startsWith('docker.io/')) {
|
||||||
|
return repository.slice('docker.io/'.length) || null;
|
||||||
|
}
|
||||||
|
if (repository.startsWith('index.docker.io/')) {
|
||||||
|
return repository.slice('index.docker.io/'.length) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyImageChannel(imageRef: string): ImageChannel {
|
||||||
|
const repository = normalizeImageRepository(imageRef);
|
||||||
|
if (!repository) return 'unknown';
|
||||||
|
if (repository === HARDENED_REPOSITORY) return 'hardened';
|
||||||
|
if (COMMUNITY_REPOSITORIES.has(repository)) return 'community';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { authRouter } from './routes/auth';
|
|||||||
import { mfaRouter } from './routes/mfa';
|
import { mfaRouter } from './routes/mfa';
|
||||||
import { ssoRouter } from './routes/sso';
|
import { ssoRouter } from './routes/sso';
|
||||||
import { licenseRouter, systemUpdateRouter } from './routes/license';
|
import { licenseRouter, systemUpdateRouter } from './routes/license';
|
||||||
|
import { imageChannelRouter } from './routes/imageChannel';
|
||||||
import { webhooksRouter } from './routes/webhooks';
|
import { webhooksRouter } from './routes/webhooks';
|
||||||
import { usersRouter } from './routes/users';
|
import { usersRouter } from './routes/users';
|
||||||
import { gitSourcesRouter, stackGitSourceRouter } from './routes/gitSources';
|
import { gitSourcesRouter, stackGitSourceRouter } from './routes/gitSources';
|
||||||
@@ -106,6 +107,7 @@ app.use('/api', hubOnlyGuard);
|
|||||||
app.use('/api/', createRemoteProxyMiddleware());
|
app.use('/api/', createRemoteProxyMiddleware());
|
||||||
|
|
||||||
app.use('/api/license', licenseRouter);
|
app.use('/api/license', licenseRouter);
|
||||||
|
app.use('/api/license/image-channel', imageChannelRouter);
|
||||||
app.use('/api/system', systemUpdateRouter);
|
app.use('/api/system', systemUpdateRouter);
|
||||||
app.use('/api/permissions', permissionsRouter);
|
app.use('/api/permissions', permissionsRouter);
|
||||||
app.use('/api/convert', convertRouter);
|
app.use('/api/convert', convertRouter);
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ cloudBackupRouter.post('/provision', async (req: Request, res: Response): Promis
|
|||||||
res.json({ success: true, quota_bytes: result.quotaBytes });
|
res.json({ success: true, quota_bytes: result.quotaBytes });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[CloudBackup] provision error:', error);
|
console.error('[CloudBackup] provision error:', error);
|
||||||
res.status(500).json({ error: 'Failed to provision Sencho Cloud Backup' });
|
res.status(500).json({ error: 'Failed to provision Recovery Vault' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -173,7 +173,7 @@ cloudBackupRouter.get('/usage', async (req: Request, res: Response): Promise<voi
|
|||||||
try {
|
try {
|
||||||
const svc = CloudBackupService.getInstance();
|
const svc = CloudBackupService.getInstance();
|
||||||
if (svc.getProvider() !== 'sencho') {
|
if (svc.getProvider() !== 'sencho') {
|
||||||
res.status(400).json({ error: 'Usage is only available for Sencho Cloud Backup' });
|
res.status(400).json({ error: 'Usage is only available for Recovery Vault' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const usage = await svc.getSenchoCloudBackupUsage();
|
const usage = await svc.getSenchoCloudBackupUsage();
|
||||||
|
|||||||
+97
-24
@@ -17,9 +17,11 @@ import { StackOpLockService } from '../services/StackOpLockService';
|
|||||||
import SelfUpdateService, { type PinInfo } from '../services/SelfUpdateService';
|
import SelfUpdateService, { type PinInfo } from '../services/SelfUpdateService';
|
||||||
import { getSenchoVersion, isValidVersion } from '../services/CapabilityRegistry';
|
import { getSenchoVersion, isValidVersion } from '../services/CapabilityRegistry';
|
||||||
import { authMiddleware } from '../middleware/auth';
|
import { authMiddleware } from '../middleware/auth';
|
||||||
import { requirePaid, requireAdmin, requireNodeProxy } from '../middleware/tierGates';
|
import { requirePaid, requireAdmin, requireNodeProxy, requireUserSession } from '../middleware/tierGates';
|
||||||
import { requirePermission } from '../middleware/permissions';
|
import { requirePermission } from '../middleware/permissions';
|
||||||
import { respondSelfUpdatePreflight, scheduleLocalUpdate } from './license';
|
import { respondSelfUpdatePreflight } from './license';
|
||||||
|
import { ImageOperationService } from '../services/ImageOperationService';
|
||||||
|
import { classifyImageChannel } from '../helpers/imageChannel';
|
||||||
import { runPolicyGate, assertPolicyGateAllows, buildPolicyGateOptions } from '../helpers/policyGate';
|
import { runPolicyGate, assertPolicyGateAllows, buildPolicyGateOptions } from '../helpers/policyGate';
|
||||||
import { remoteSupportsCrossNodeRbac } from '../helpers/remoteCapabilities';
|
import { remoteSupportsCrossNodeRbac } from '../helpers/remoteCapabilities';
|
||||||
import { captureLocalNodeFiles, captureRemoteNodeFiles, buildSnapshotDocumentation, pickDossierFields, dossierHasContent, type SnapshotNodeData, type SnapshotDocumentation } from '../utils/snapshot-capture';
|
import { captureLocalNodeFiles, captureRemoteNodeFiles, buildSnapshotDocumentation, pickDossierFields, dossierHasContent, type SnapshotNodeData, type SnapshotDocumentation } from '../utils/snapshot-capture';
|
||||||
@@ -68,6 +70,7 @@ const EMPTY_PIN_STATUS = {
|
|||||||
targetImageRef: null,
|
targetImageRef: null,
|
||||||
updateBlocked: false,
|
updateBlocked: false,
|
||||||
updateBlockedReason: null,
|
updateBlockedReason: null,
|
||||||
|
imageChannel: null,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
function localPinStatusFields(
|
function localPinStatusFields(
|
||||||
@@ -87,6 +90,7 @@ function localPinStatusFields(
|
|||||||
: null,
|
: null,
|
||||||
updateBlocked,
|
updateBlocked,
|
||||||
updateBlockedReason: updateBlocked ? blockedReason : null,
|
updateBlockedReason: updateBlocked ? blockedReason : null,
|
||||||
|
imageChannel: classifyImageChannel(pin.composeImageRef),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Throttle the forced latest-version refresh so a caller cannot loop the recheck
|
// Throttle the forced latest-version refresh so a caller cannot loop the recheck
|
||||||
@@ -1116,6 +1120,7 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
|
|||||||
let remoteOnline = false;
|
let remoteOnline = false;
|
||||||
let remoteImagePinKind: ImagePinKind | null = null;
|
let remoteImagePinKind: ImagePinKind | null = null;
|
||||||
let remoteUpdateBlocked = false;
|
let remoteUpdateBlocked = false;
|
||||||
|
let remoteImageChannel: 'community' | 'hardened' | 'unknown' | null = null;
|
||||||
if (node.type === 'local') {
|
if (node.type === 'local') {
|
||||||
version = gatewayVersion;
|
version = gatewayVersion;
|
||||||
} else {
|
} else {
|
||||||
@@ -1126,6 +1131,7 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
|
|||||||
remoteOnline = meta.online;
|
remoteOnline = meta.online;
|
||||||
remoteImagePinKind = meta.imagePinKind;
|
remoteImagePinKind = meta.imagePinKind;
|
||||||
remoteUpdateBlocked = meta.updateBlocked;
|
remoteUpdateBlocked = meta.updateBlocked;
|
||||||
|
remoteImageChannel = meta.imageChannel;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tracker?.status === 'updating') {
|
if (tracker?.status === 'updating') {
|
||||||
@@ -1257,15 +1263,17 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
|
|||||||
let targetImageRef: string | null = null;
|
let targetImageRef: string | null = null;
|
||||||
let updateBlocked = false;
|
let updateBlocked = false;
|
||||||
let updateBlockedReason: string | null = null;
|
let updateBlockedReason: string | null = null;
|
||||||
|
let imageChannel: 'community' | 'hardened' | 'unknown' | null = null;
|
||||||
if (node.type === 'local') {
|
if (node.type === 'local') {
|
||||||
const pin = await SelfUpdateService.getInstance().getPinInfo();
|
const pin = await SelfUpdateService.getInstance().getPinInfo();
|
||||||
if (pin) {
|
if (pin) {
|
||||||
({ imagePinKind, composeImageRef, targetImageRef, updateBlocked, updateBlockedReason } =
|
({ imagePinKind, composeImageRef, targetImageRef, updateBlocked, updateBlockedReason, imageChannel } =
|
||||||
localPinStatusFields(pin, compareVersion, compareValid, REPIN_BLOCKED_REASON));
|
localPinStatusFields(pin, compareVersion, compareValid, REPIN_BLOCKED_REASON));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
imagePinKind = remoteImagePinKind;
|
imagePinKind = remoteImagePinKind;
|
||||||
updateBlocked = remoteUpdateBlocked;
|
updateBlocked = remoteUpdateBlocked;
|
||||||
|
imageChannel = remoteImageChannel;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1284,6 +1292,7 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
|
|||||||
targetImageRef,
|
targetImageRef,
|
||||||
updateBlocked,
|
updateBlocked,
|
||||||
updateBlockedReason,
|
updateBlockedReason,
|
||||||
|
imageChannel,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -1354,6 +1363,19 @@ function postSystemUpdate(target: { apiUrl: string; apiToken: string }, targetVe
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseRemoteUpdateFailure(payload: unknown): { error: string; code?: string } {
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
return { error: 'Remote node rejected update request.' };
|
||||||
|
}
|
||||||
|
const response = payload as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
error: typeof response.error === 'string' && response.error
|
||||||
|
? response.error
|
||||||
|
: 'Remote node rejected update request.',
|
||||||
|
...(typeof response.code === 'string' && response.code ? { code: response.code } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// --- Skip-version endpoints ---
|
// --- Skip-version endpoints ---
|
||||||
|
|
||||||
fleetRouter.post('/nodes/:nodeId/skip-version', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
fleetRouter.post('/nodes/:nodeId/skip-version', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||||
@@ -1446,9 +1468,42 @@ fleetRouter.post('/nodes/:nodeId/update', authMiddleware, async (req: Request, r
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const resolvedTarget = await resolveUpdateTarget(requestedTarget);
|
const resolvedTarget = await resolveUpdateTarget(requestedTarget);
|
||||||
|
const pin = await selfUpdate.getPinInfo({ fresh: true });
|
||||||
|
if (pin && classifyImageChannel(pin.composeImageRef) === 'hardened') {
|
||||||
|
if (!requireUserSession(req, res)) return;
|
||||||
|
const preflight = await ImageOperationService.getInstance().preflightSwitch();
|
||||||
|
if (!preflight.ok) {
|
||||||
|
res.status(403).json({ error: 'Hardened image access is unavailable', code: preflight.code });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await ImageOperationService.getInstance().switchToHardened(
|
||||||
|
preflight.preflightFingerprint,
|
||||||
|
'update',
|
||||||
|
);
|
||||||
|
if (!result.ok) {
|
||||||
|
res.status(result.code === 'IMAGE_OPERATION_IN_FLIGHT' ? 409 : 500)
|
||||||
|
.json({ error: 'Hardened update could not start', code: result.code });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateTracker.set(nodeId, updateTracker.create('updating', getSenchoVersion(), null));
|
||||||
|
res.status(202).json({ message: 'Update initiated on local node. The server will restart shortly.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!respondSelfUpdatePreflight(res, await selfUpdate.canSelfUpdateTarget(resolvedTarget))) return;
|
if (!respondSelfUpdatePreflight(res, await selfUpdate.canSelfUpdateTarget(resolvedTarget))) return;
|
||||||
|
const claim = await ImageOperationService.getInstance().claimCommunityUpdate({ targetVersion: resolvedTarget });
|
||||||
|
if (!claim.ok) {
|
||||||
|
res.status(409).json({ error: 'An image operation is already in progress.', code: claim.failureCode });
|
||||||
|
return;
|
||||||
|
}
|
||||||
updateTracker.set(nodeId, updateTracker.create('updating', getSenchoVersion(), null));
|
updateTracker.set(nodeId, updateTracker.create('updating', getSenchoVersion(), null));
|
||||||
scheduleLocalUpdate(res, 'Update initiated on local node. The server will restart shortly.', resolvedTarget);
|
res.status(202).json({ message: 'Update initiated on local node. The server will restart shortly.' });
|
||||||
|
// Schedule unconditionally: client abort can fire only `close` without `finish`,
|
||||||
|
// which would otherwise leave the claimed operation stuck in pending_pull.
|
||||||
|
setTimeout(() => {
|
||||||
|
ImageOperationService.getInstance().executeClaimedCommunityUpdate({ targetVersion: resolvedTarget }).catch(error => {
|
||||||
|
console.error('[ImageOperation] Unexpected community update failure:', error);
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1470,7 +1525,9 @@ fleetRouter.post('/nodes/:nodeId/update', authMiddleware, async (req: Request, r
|
|||||||
res.status(503).json({ error: 'Remote node does not support self-update. It may need to be updated manually first.' });
|
res.status(503).json({ error: 'Remote node does not support self-update. It may need to be updated manually first.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (meta.updateBlocked) {
|
// Hardened peers are digest-pinned (updateBlocked) but must still reach
|
||||||
|
// /api/system/update so machine creds get HARDENED_REMOTE_UPDATE_UNSUPPORTED.
|
||||||
|
if (meta.updateBlocked && meta.imageChannel !== 'hardened') {
|
||||||
res.status(409).json({ error: REPIN_BLOCKED_REASON, code: 'update_blocked' });
|
res.status(409).json({ error: REPIN_BLOCKED_REASON, code: 'update_blocked' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1479,10 +1536,9 @@ fleetRouter.post('/nodes/:nodeId/update', authMiddleware, async (req: Request, r
|
|||||||
const response = await postSystemUpdate(target, resolvedTarget);
|
const response = await postSystemUpdate(target, resolvedTarget);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const err = await response.json().catch(() => ({}));
|
const failure = parseRemoteUpdateFailure(await response.json().catch(() => null));
|
||||||
const errorMsg = (err as Record<string, string>)?.error || 'Remote node rejected update request.';
|
updateTracker.set(nodeId, updateTracker.create('failed', meta.version, meta.startedAt, failure.error, failure.code));
|
||||||
updateTracker.set(nodeId, updateTracker.create('failed', meta.version, meta.startedAt, errorMsg));
|
res.status(502).json(failure);
|
||||||
res.status(502).json({ error: errorMsg });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1515,8 +1571,8 @@ fleetRouter.post('/update-all', authMiddleware, async (req: Request, res: Respon
|
|||||||
if (debug) console.debug('[Fleet:debug] Update-all compare target:', { gatewayVersion, compareVersion, compareValid });
|
if (debug) console.debug('[Fleet:debug] Update-all compare target:', { gatewayVersion, compareVersion, compareValid });
|
||||||
|
|
||||||
const registry = NodeRegistry.getInstance();
|
const registry = NodeRegistry.getInstance();
|
||||||
const candidates = nodes.filter(node => {
|
const remoteNodes = nodes.filter(node => node.type === 'remote');
|
||||||
if (node.type === 'local') return false;
|
const candidates = remoteNodes.filter(node => {
|
||||||
const tracker = updateTracker.get(node.id);
|
const tracker = updateTracker.get(node.id);
|
||||||
if (tracker?.status === 'updating') return false;
|
if (tracker?.status === 'updating') return false;
|
||||||
if (registry.getProxyTarget(node.id) === null) return false;
|
if (registry.getProxyTarget(node.id) === null) return false;
|
||||||
@@ -1535,41 +1591,58 @@ fleetRouter.post('/update-all', authMiddleware, async (req: Request, res: Respon
|
|||||||
|
|
||||||
const results = await Promise.allSettled(candidates.map(async (node) => {
|
const results = await Promise.allSettled(candidates.map(async (node) => {
|
||||||
const target = registry.getProxyTarget(node.id);
|
const target = registry.getProxyTarget(node.id);
|
||||||
if (!target) return { name: node.name, triggered: false };
|
if (!target) return { nodeId: node.id, name: node.name, kind: 'skipped' as const };
|
||||||
const meta = await registry.fetchMetaForNode(node.id);
|
const meta = await registry.fetchMetaForNode(node.id);
|
||||||
if (!meta.online) {
|
if (!meta.online) {
|
||||||
return { name: node.name, triggered: false };
|
return { nodeId: node.id, name: node.name, kind: 'skipped' as const };
|
||||||
}
|
}
|
||||||
if (!meta.capabilities.includes('self-update')) {
|
if (!meta.capabilities.includes('self-update')) {
|
||||||
return { name: node.name, triggered: false };
|
return { nodeId: node.id, name: node.name, kind: 'skipped' as const };
|
||||||
}
|
}
|
||||||
if (meta.updateBlocked) {
|
if (meta.updateBlocked && meta.imageChannel !== 'hardened') {
|
||||||
return { name: node.name, triggered: false };
|
return { nodeId: node.id, name: node.name, kind: 'skipped' as const };
|
||||||
}
|
}
|
||||||
if (isValidVersion(meta.version) && compareValid && !semver.lt(meta.version, compareVersion!)) {
|
if (isValidVersion(meta.version) && compareValid && !semver.lt(meta.version, compareVersion!)) {
|
||||||
return { name: node.name, triggered: false };
|
return { nodeId: node.id, name: node.name, kind: 'skipped' as const };
|
||||||
}
|
}
|
||||||
const response = await postSystemUpdate(target, updateAllTarget);
|
const response = await postSystemUpdate(target, updateAllTarget);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
updateTracker.set(node.id, updateTracker.create('updating', meta.version, meta.startedAt));
|
updateTracker.set(node.id, updateTracker.create('updating', meta.version, meta.startedAt));
|
||||||
return { name: node.name, triggered: true };
|
return { nodeId: node.id, name: node.name, kind: 'updating' as const };
|
||||||
}
|
}
|
||||||
return { name: node.name, triggered: false };
|
const failure = parseRemoteUpdateFailure(await response.json().catch(() => null));
|
||||||
|
updateTracker.set(node.id, updateTracker.create('failed', meta.version, meta.startedAt, failure.error, failure.code));
|
||||||
|
return { nodeId: node.id, name: node.name, kind: 'failed' as const, ...failure };
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const updating: string[] = [];
|
const updating: string[] = [];
|
||||||
const skipped = nodes.filter(n => !candidates.includes(n)).map(n => n.name);
|
const skipped = remoteNodes.filter(n => !candidates.includes(n)).map(n => n.name);
|
||||||
|
const failed: Array<{ nodeId: number; name: string; code: string; error: string }> = [];
|
||||||
for (let i = 0; i < results.length; i++) {
|
for (let i = 0; i < results.length; i++) {
|
||||||
const r = results[i];
|
const r = results[i];
|
||||||
if (r.status === 'rejected') {
|
if (r.status === 'rejected') {
|
||||||
console.warn(`[Fleet] Update-all failed for node ${candidates[i].name}:`, r.reason);
|
console.warn(`[Fleet] Update-all failed for node ${candidates[i].name}:`, r.reason);
|
||||||
|
failed.push({
|
||||||
|
nodeId: candidates[i].id,
|
||||||
|
name: candidates[i].name,
|
||||||
|
code: 'REMOTE_UPDATE_REQUEST_FAILED',
|
||||||
|
error: 'Failed to reach remote node for update.',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
const val = r.status === 'fulfilled' ? r.value : { name: candidates[i].name, triggered: false };
|
if (r.value.kind === 'updating') updating.push(r.value.name);
|
||||||
(val.triggered ? updating : skipped).push(val.name);
|
else if (r.value.kind === 'failed') {
|
||||||
|
failed.push({
|
||||||
|
nodeId: r.value.nodeId,
|
||||||
|
name: r.value.name,
|
||||||
|
code: r.value.code ?? 'REMOTE_UPDATE_REQUEST_FAILED',
|
||||||
|
error: r.value.error,
|
||||||
|
});
|
||||||
|
} else skipped.push(r.value.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (debug) console.debug('[Fleet:debug] Update-all results:', { updating, skippedCount: skipped.length, candidateCount: candidates.length });
|
if (debug) console.debug('[Fleet:debug] Update-all results:', { updating, skippedCount: skipped.length, failedCount: failed.length, candidateCount: candidates.length });
|
||||||
res.status(202).json({ updating, skipped });
|
res.status(202).json({ updating, skipped, failed });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Fleet] Update all error:', error);
|
console.error('[Fleet] Update all error:', error);
|
||||||
res.status(500).json({ error: 'Failed to trigger fleet update.' });
|
res.status(500).json({ error: 'Failed to trigger fleet update.' });
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { Router, type Request, type Response } from 'express';
|
||||||
|
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
|
||||||
|
import { requireAdmin, requirePaid, requireUserSession } from '../middleware/tierGates';
|
||||||
|
import { classifyImageChannel } from '../helpers/imageChannel';
|
||||||
|
import SelfUpdateService from '../services/SelfUpdateService';
|
||||||
|
import { ImageOperationService } from '../services/ImageOperationService';
|
||||||
|
|
||||||
|
const SESSION_MESSAGE = 'API tokens cannot manage image channels.';
|
||||||
|
|
||||||
|
export const imageChannelRouter = Router();
|
||||||
|
|
||||||
|
imageChannelRouter.get('/status', async (req: Request, res: Response): Promise<void> => {
|
||||||
|
if (!requireUserSession(req, res)) return;
|
||||||
|
try {
|
||||||
|
const selfUpdate = SelfUpdateService.getInstance();
|
||||||
|
const pin = await selfUpdate.getPinInfo({ fresh: true });
|
||||||
|
const operation = await ImageOperationService.getInstance().getCurrentOperation();
|
||||||
|
const isAdmin = req.user?.role === 'admin';
|
||||||
|
const channel = pin ? classifyImageChannel(pin.composeImageRef) : 'unknown';
|
||||||
|
res.json({
|
||||||
|
channel,
|
||||||
|
...(pin && (isAdmin || channel !== 'hardened') ? { composeImageRef: pin.composeImageRef } : {}),
|
||||||
|
...(isAdmin && pin ? { pinKind: pin.pinKind } : {}),
|
||||||
|
...(isAdmin ? { operation } : {}),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ImageChannel] Status failed:', error);
|
||||||
|
res.status(500).json({ error: 'Unable to read image channel status' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
imageChannelRouter.post('/preflight', async (req: Request, res: Response): Promise<void> => {
|
||||||
|
if (rejectApiTokenScope(req, res, SESSION_MESSAGE)) return;
|
||||||
|
if (!requireUserSession(req, res) || !requireAdmin(req, res)) return;
|
||||||
|
if (!requirePaid(req, res)) return;
|
||||||
|
const result = await ImageOperationService.getInstance().preflightSwitch();
|
||||||
|
if (!result.ok) {
|
||||||
|
res.status(403).json({ error: 'Hardened image access is unavailable', code: result.code });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
imageChannelRouter.post('/switch', async (req: Request, res: Response): Promise<void> => {
|
||||||
|
if (rejectApiTokenScope(req, res, SESSION_MESSAGE)) return;
|
||||||
|
if (!requireUserSession(req, res) || !requireAdmin(req, res)) return;
|
||||||
|
if (!requirePaid(req, res)) return;
|
||||||
|
const fingerprint = req.body?.preflightFingerprint;
|
||||||
|
if (typeof fingerprint !== 'string' || !/^[a-f0-9]{64}$/.test(fingerprint)) {
|
||||||
|
res.status(400).json({ error: 'A valid preflight fingerprint is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await ImageOperationService.getInstance().switchToHardened(fingerprint);
|
||||||
|
if (!result.ok) {
|
||||||
|
const status = result.code === 'IMAGE_OPERATION_IN_FLIGHT' || result.code === 'preflight_mismatch' ? 409 : 403;
|
||||||
|
res.status(status).json({ error: 'Image channel switch could not start', code: result.code });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(202).json({ message: 'Image channel switch initiated.' });
|
||||||
|
});
|
||||||
|
|
||||||
|
imageChannelRouter.get('/operations/:operationId', async (req: Request, res: Response): Promise<void> => {
|
||||||
|
if (!requireUserSession(req, res) || !requireAdmin(req, res)) return;
|
||||||
|
const operationId = Array.isArray(req.params.operationId) ? req.params.operationId[0] : req.params.operationId;
|
||||||
|
if (!ImageOperationService.isOperationId(operationId)) {
|
||||||
|
res.status(400).json({ error: 'Invalid operation id' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const operation = await ImageOperationService.getInstance().getOperation(operationId);
|
||||||
|
if (!operation) {
|
||||||
|
res.status(404).json({ error: 'Image operation not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(operation);
|
||||||
|
});
|
||||||
|
|
||||||
|
imageChannelRouter.post('/operations/:operationId/acknowledge', async (req: Request, res: Response): Promise<void> => {
|
||||||
|
if (rejectApiTokenScope(req, res, SESSION_MESSAGE)) return;
|
||||||
|
if (!requireUserSession(req, res) || !requireAdmin(req, res)) return;
|
||||||
|
const operationId = Array.isArray(req.params.operationId) ? req.params.operationId[0] : req.params.operationId;
|
||||||
|
if (!ImageOperationService.isOperationId(operationId)) {
|
||||||
|
res.status(400).json({ error: 'Invalid operation id' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!await ImageOperationService.getInstance().acknowledge(operationId)) {
|
||||||
|
res.status(409).json({ error: 'Only failed image operations can be acknowledged' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
import { Router, type Request, type Response } from 'express';
|
import { Router, type Request, type Response } from 'express';
|
||||||
import { LicenseService } from '../services/LicenseService';
|
import { LicenseService } from '../services/LicenseService';
|
||||||
import SelfUpdateService from '../services/SelfUpdateService';
|
import SelfUpdateService from '../services/SelfUpdateService';
|
||||||
import { requireAdmin } from '../middleware/tierGates';
|
import { requireAdmin, requireUserSession } from '../middleware/tierGates';
|
||||||
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
|
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
|
||||||
import { parseRequestedTargetVersion } from '../utils/targetVersion';
|
import { parseRequestedTargetVersion } from '../utils/targetVersion';
|
||||||
import type { SelfUpdatePreflight } from '../services/SelfUpdateService';
|
import type { SelfUpdatePreflight } from '../services/SelfUpdateService';
|
||||||
|
import { classifyImageChannel } from '../helpers/imageChannel';
|
||||||
|
import { ImageOperationService } from '../services/ImageOperationService';
|
||||||
|
import { imageChannelRouter } from './imageChannel';
|
||||||
|
|
||||||
const LICENSE_SCOPE_MESSAGE = 'API tokens cannot manage licenses.';
|
const LICENSE_SCOPE_MESSAGE = 'API tokens cannot manage licenses.';
|
||||||
|
|
||||||
export const licenseRouter = Router();
|
export const licenseRouter = Router();
|
||||||
|
licenseRouter.use('/image-channel', imageChannelRouter);
|
||||||
|
|
||||||
licenseRouter.get('/', (_req: Request, res: Response): void => {
|
licenseRouter.get('/', (_req: Request, res: Response): void => {
|
||||||
try {
|
try {
|
||||||
@@ -125,8 +129,49 @@ systemUpdateRouter.post('/update', async (req: Request, res: Response): Promise<
|
|||||||
}
|
}
|
||||||
const targetVersion = parseRequestedTargetVersion(req, res);
|
const targetVersion = parseRequestedTargetVersion(req, res);
|
||||||
if (targetVersion === null) return; // invalid supplied value; 400 already sent
|
if (targetVersion === null) return; // invalid supplied value; 400 already sent
|
||||||
|
const pin = await selfUpdate.getPinInfo({ fresh: true });
|
||||||
|
const channel = pin ? classifyImageChannel(pin.composeImageRef) : 'unknown';
|
||||||
|
const machineCredential = req.user?.userId === 0 || !!req.apiTokenScope;
|
||||||
|
if (channel === 'hardened') {
|
||||||
|
if (machineCredential) {
|
||||||
|
res.status(403).json({
|
||||||
|
error: 'Hardened images can only be updated from an admin session.',
|
||||||
|
code: 'HARDENED_REMOTE_UPDATE_UNSUPPORTED',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!requireUserSession(req, res)) return;
|
||||||
|
const preflight = await ImageOperationService.getInstance().preflightSwitch();
|
||||||
|
if (!preflight.ok) {
|
||||||
|
res.status(403).json({ error: 'Hardened image access is unavailable', code: preflight.code });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await ImageOperationService.getInstance().switchToHardened(
|
||||||
|
preflight.preflightFingerprint,
|
||||||
|
'update',
|
||||||
|
);
|
||||||
|
if (!result.ok) {
|
||||||
|
res.status(result.code === 'IMAGE_OPERATION_IN_FLIGHT' ? 409 : 500)
|
||||||
|
.json({ error: 'Hardened update could not start', code: result.code });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(202).json({ message: 'Update initiated. The server will restart shortly.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Fail fast on a pin we cannot repin (digest/unknown) so the caller gets a
|
// Fail fast on a pin we cannot repin (digest/unknown) so the caller gets a
|
||||||
// 409 instead of a 202 that would later fail after the reconnect overlay.
|
// 409 instead of a 202 that would later fail after the reconnect overlay.
|
||||||
if (!respondSelfUpdatePreflight(res, await selfUpdate.canSelfUpdateTarget(targetVersion))) return;
|
if (!respondSelfUpdatePreflight(res, await selfUpdate.canSelfUpdateTarget(targetVersion))) return;
|
||||||
scheduleLocalUpdate(res, 'Update initiated. The server will restart shortly.', targetVersion);
|
const claim = await ImageOperationService.getInstance().claimCommunityUpdate({ targetVersion });
|
||||||
|
if (!claim.ok) {
|
||||||
|
res.status(409).json({ error: 'An image operation is already in progress.', code: claim.failureCode });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(202).json({ message: 'Update initiated. The server will restart shortly.' });
|
||||||
|
// Schedule unconditionally: client abort can fire only `close` without `finish`,
|
||||||
|
// which would otherwise leave the claimed operation stuck in pending_pull.
|
||||||
|
setTimeout(() => {
|
||||||
|
ImageOperationService.getInstance().executeClaimedCommunityUpdate({ targetVersion }).catch(error => {
|
||||||
|
console.error('[ImageOperation] Unexpected community update failure:', error);
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Router, type Request, type Response } from 'express';
|
import { Router, type Request, type Response } from 'express';
|
||||||
import { getActiveCapabilities, getSenchoVersion } from '../services/CapabilityRegistry';
|
import { getActiveCapabilities, getSenchoVersion } from '../services/CapabilityRegistry';
|
||||||
|
import { classifyImageChannel } from '../helpers/imageChannel';
|
||||||
import { isRepinBlocked } from '../helpers/selfUpdateCompose';
|
import { isRepinBlocked } from '../helpers/selfUpdateCompose';
|
||||||
import { MeshService } from '../services/MeshService';
|
import { MeshService } from '../services/MeshService';
|
||||||
import SelfUpdateService from '../services/SelfUpdateService';
|
import SelfUpdateService from '../services/SelfUpdateService';
|
||||||
@@ -31,9 +32,10 @@ metaRouter.get('/health', (_req: Request, res: Response): void => {
|
|||||||
// connection tests.
|
// connection tests.
|
||||||
//
|
//
|
||||||
// Image-pin fields are intentionally limited to the non-sensitive subset:
|
// Image-pin fields are intentionally limited to the non-sensitive subset:
|
||||||
// `imagePinKind` (a bounded enum) and `updateBlocked` (a boolean). The full
|
// `imagePinKind` (a bounded enum), `updateBlocked` (a boolean), and
|
||||||
// composeImageRef is NEVER exposed here because this endpoint is public and the
|
// `imageChannel` (community|hardened|unknown). The full composeImageRef is
|
||||||
// ref can carry a private registry/repository name.
|
// NEVER exposed here because this endpoint is public and the ref can carry a
|
||||||
|
// private registry/repository name.
|
||||||
metaRouter.get('/meta', async (_req: Request, res: Response): Promise<void> => {
|
metaRouter.get('/meta', async (_req: Request, res: Response): Promise<void> => {
|
||||||
const selfUpdate = SelfUpdateService.getInstance();
|
const selfUpdate = SelfUpdateService.getInstance();
|
||||||
const updateError = selfUpdate.getLastError();
|
const updateError = selfUpdate.getLastError();
|
||||||
@@ -44,8 +46,11 @@ metaRouter.get('/meta', async (_req: Request, res: Response): Promise<void> => {
|
|||||||
capabilities: getActiveCapabilities(),
|
capabilities: getActiveCapabilities(),
|
||||||
startedAt: processStartedAt,
|
startedAt: processStartedAt,
|
||||||
experimental: process.env.SENCHO_EXPERIMENTAL === 'true',
|
experimental: process.env.SENCHO_EXPERIMENTAL === 'true',
|
||||||
...(pin ? { imagePinKind: pin.pinKind } : {}),
|
...(pin ? {
|
||||||
|
imagePinKind: pin.pinKind,
|
||||||
|
imageChannel: classifyImageChannel(pin.composeImageRef),
|
||||||
|
} : {}),
|
||||||
updateBlocked,
|
updateBlocked,
|
||||||
...(updateError ? { updateError } : {}),
|
...(updateError ? { updateError: 'update_failed' } : {}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -118,6 +118,11 @@ export interface RemoteMeta {
|
|||||||
imagePinKind: ImagePinKind | null;
|
imagePinKind: ImagePinKind | null;
|
||||||
/** True when the remote reports its update is blocked (digest/unknown pin). */
|
/** True when the remote reports its update is blocked (digest/unknown pin). */
|
||||||
updateBlocked: boolean;
|
updateBlocked: boolean;
|
||||||
|
/**
|
||||||
|
* Coarse image channel from the remote public meta. Null when the remote is
|
||||||
|
* older than this field or offline. Safe to expose (no private repository path).
|
||||||
|
*/
|
||||||
|
imageChannel: 'community' | 'hardened' | 'unknown' | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runtime capability overrides; services call disableCapability() during init.
|
// Runtime capability overrides; services call disableCapability() during init.
|
||||||
@@ -166,8 +171,14 @@ export const OFFLINE_META: RemoteMeta = {
|
|||||||
online: false,
|
online: false,
|
||||||
imagePinKind: null,
|
imagePinKind: null,
|
||||||
updateBlocked: false,
|
updateBlocked: false,
|
||||||
|
imageChannel: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function parseImageChannel(value: unknown): RemoteMeta['imageChannel'] {
|
||||||
|
if (value === 'community' || value === 'hardened' || value === 'unknown') return value;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Strip any `user:pass@` userinfo from a URL so credentials never reach the logs. */
|
/** Strip any `user:pass@` userinfo from a URL so credentials never reach the logs. */
|
||||||
function redactUrlCredentials(url: string): string {
|
function redactUrlCredentials(url: string): string {
|
||||||
return url.replace(/(\/\/)[^/@]*@/, '$1');
|
return url.replace(/(\/\/)[^/@]*@/, '$1');
|
||||||
@@ -190,6 +201,7 @@ export async function fetchRemoteMeta(baseUrl: string, apiToken: string): Promis
|
|||||||
online: true,
|
online: true,
|
||||||
imagePinKind: parseImagePinKind(res.data.imagePinKind),
|
imagePinKind: parseImagePinKind(res.data.imagePinKind),
|
||||||
updateBlocked: res.data.updateBlocked === true,
|
updateBlocked: res.data.updateBlocked === true,
|
||||||
|
imageChannel: parseImageChannel(res.data.imageChannel),
|
||||||
};
|
};
|
||||||
if (isDebugEnabled()) {
|
if (isDebugEnabled()) {
|
||||||
// Diagnostic aid for "why is this feature gated?": log the resolved version
|
// Diagnostic aid for "why is this feature gated?": log the resolved version
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export class CloudBackupService {
|
|||||||
if (!licenseKey) return { success: false, error: 'No license key found. Activate an Admiral license first.' };
|
if (!licenseKey) return { success: false, error: 'No license key found. Activate an Admiral license first.' };
|
||||||
|
|
||||||
if (LicenseService.getInstance().getTier() !== 'paid') {
|
if (LicenseService.getInstance().getTier() !== 'paid') {
|
||||||
return { success: false, error: 'Sencho Cloud Backup requires the Admiral tier.' };
|
return { success: false, error: 'Recovery Vault requires the Admiral tier.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiBase = process.env.SENCHO_CLOUD_BACKUP_API || SENCHO_CLOUD_BACKUP_API_DEFAULT;
|
const apiBase = process.env.SENCHO_CLOUD_BACKUP_API || SENCHO_CLOUD_BACKUP_API_DEFAULT;
|
||||||
@@ -212,13 +212,13 @@ export class CloudBackupService {
|
|||||||
return { success: true, quotaBytes: data.quota_bytes };
|
return { success: true, quotaBytes: data.quota_bytes };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const responseError = (err as { response?: { data?: { error?: string } } }).response?.data?.error;
|
const responseError = (err as { response?: { data?: { error?: string } } }).response?.data?.error;
|
||||||
return { success: false, error: responseError || getErrorMessage(err, 'Failed to provision Sencho Cloud Backup.') };
|
return { success: false, error: responseError || getErrorMessage(err, 'Failed to provision Recovery Vault.') };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async refreshSenchoCloudBackupCredentials(): Promise<void> {
|
public async refreshSenchoCloudBackupCredentials(): Promise<void> {
|
||||||
const result = await this.provisionSenchoCloudBackup();
|
const result = await this.provisionSenchoCloudBackup();
|
||||||
if (!result.success) throw new Error(result.error || 'Failed to refresh Sencho Cloud Backup credentials.');
|
if (!result.success) throw new Error(result.error || 'Failed to refresh Recovery Vault credentials.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getSenchoCloudBackupUsage(): Promise<{ used_bytes: number; quota_bytes: number; object_count: number }> {
|
public async getSenchoCloudBackupUsage(): Promise<{ used_bytes: number; quota_bytes: number; object_count: number }> {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ export interface UpdateTracker {
|
|||||||
startedAt: number;
|
startedAt: number;
|
||||||
previousVersion: string | null;
|
previousVersion: string | null;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
/** Machine-readable failure code returned by a remote update request. */
|
||||||
|
code?: string;
|
||||||
/** Process start time of the remote node before the update was triggered. */
|
/** Process start time of the remote node before the update was triggered. */
|
||||||
previousProcessStart: number | null;
|
previousProcessStart: number | null;
|
||||||
/** True when the node became unreachable at least once during the update window. */
|
/** True when the node became unreachable at least once during the update window. */
|
||||||
@@ -65,6 +67,7 @@ export class FleetUpdateTrackerService {
|
|||||||
previousVersion: string | null,
|
previousVersion: string | null,
|
||||||
previousProcessStart: number | null,
|
previousProcessStart: number | null,
|
||||||
error?: string,
|
error?: string,
|
||||||
|
code?: string,
|
||||||
): UpdateTracker {
|
): UpdateTracker {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
return {
|
return {
|
||||||
@@ -74,6 +77,7 @@ export class FleetUpdateTrackerService {
|
|||||||
previousProcessStart,
|
previousProcessStart,
|
||||||
wasOffline: false,
|
wasOffline: false,
|
||||||
error,
|
error,
|
||||||
|
code,
|
||||||
resolvedAt: status !== 'updating' ? now : undefined,
|
resolvedAt: status !== 'updating' ? now : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { validateAllowedImageRefAgainstRequirement } from '../helpers/allowedImageRef';
|
||||||
|
import { DatabaseService } from './DatabaseService';
|
||||||
|
import type {
|
||||||
|
HardenedEntitlement,
|
||||||
|
HardenedEntitlementErrorCode,
|
||||||
|
HardenedEntitlementPurpose,
|
||||||
|
HardenedEntitlementResult,
|
||||||
|
} from './hardenedEntitlementTypes';
|
||||||
|
|
||||||
|
const ASSURANCE_API_DEFAULT = 'https://sencho.io';
|
||||||
|
const STATUS_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
const MAX_BODY_BYTES = 256 * 1024;
|
||||||
|
|
||||||
|
interface CacheEntry {
|
||||||
|
expiresAt: number;
|
||||||
|
result: HardenedEntitlementResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HardenedEntitlementService {
|
||||||
|
private static instance: HardenedEntitlementService;
|
||||||
|
private statusCache = new Map<string, CacheEntry>();
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
public static getInstance(): HardenedEntitlementService {
|
||||||
|
if (!HardenedEntitlementService.instance) {
|
||||||
|
HardenedEntitlementService.instance = new HardenedEntitlementService();
|
||||||
|
}
|
||||||
|
return HardenedEntitlementService.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public invalidateCache(): void {
|
||||||
|
this.statusCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getEntitlement(
|
||||||
|
purpose: HardenedEntitlementPurpose,
|
||||||
|
requestedVersion?: string,
|
||||||
|
): Promise<HardenedEntitlementResult> {
|
||||||
|
// Dynamic import avoids a circular dependency: LicenseService imports this service.
|
||||||
|
const { LicenseService } = await import('./LicenseService');
|
||||||
|
if (LicenseService.getInstance().getTier() !== 'paid') {
|
||||||
|
return { success: false, code: 'unauthorized' };
|
||||||
|
}
|
||||||
|
const db = DatabaseService.getInstance();
|
||||||
|
const licenseKey = db.getSystemState('license_key');
|
||||||
|
const instanceId = db.getSystemState('instance_id');
|
||||||
|
if (!licenseKey || !instanceId) return { success: false, code: 'unauthorized' };
|
||||||
|
|
||||||
|
const cacheKey = this.cacheKey(licenseKey, instanceId);
|
||||||
|
if (purpose === 'status') {
|
||||||
|
const cached = this.statusCache.get(cacheKey);
|
||||||
|
if (cached && cached.expiresAt > Date.now()) return cached.result;
|
||||||
|
this.statusCache.delete(cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = this.getStubResponse()
|
||||||
|
?? await this.fetchEntitlement(licenseKey, instanceId, purpose, requestedVersion);
|
||||||
|
|
||||||
|
if (purpose === 'status') {
|
||||||
|
this.statusCache.set(cacheKey, { result, expiresAt: Date.now() + STATUS_CACHE_TTL_MS });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchEntitlement(
|
||||||
|
licenseKey: string,
|
||||||
|
instanceId: string,
|
||||||
|
purpose: HardenedEntitlementPurpose,
|
||||||
|
requestedVersion?: string,
|
||||||
|
): Promise<HardenedEntitlementResult> {
|
||||||
|
const apiBase = process.env.SENCHO_ASSURANCE_API || ASSURANCE_API_DEFAULT;
|
||||||
|
if (!isSecureAssuranceApi(apiBase)) return { success: false, code: 'unavailable' };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post<unknown>(
|
||||||
|
`${apiBase.replace(/\/$/, '')}/api/assurance/hardened-entitlement`,
|
||||||
|
{
|
||||||
|
license_key: licenseKey,
|
||||||
|
instance_id: instanceId,
|
||||||
|
purpose,
|
||||||
|
...(requestedVersion ? { requested_version: requestedVersion } : {}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timeout: 15000,
|
||||||
|
maxRedirects: 0,
|
||||||
|
maxContentLength: MAX_BODY_BYTES,
|
||||||
|
maxBodyLength: MAX_BODY_BYTES,
|
||||||
|
validateStatus: () => true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.status < 200 || response.status >= 300) {
|
||||||
|
return { success: false, code: statusToErrorCode(response.status) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const entitlement = parseEntitlement(response.data);
|
||||||
|
return entitlement
|
||||||
|
? { success: true, entitlement }
|
||||||
|
: { success: false, code: 'unavailable' };
|
||||||
|
} catch {
|
||||||
|
return { success: false, code: 'unavailable' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getStubResponse(): HardenedEntitlementResult | null {
|
||||||
|
// Stub is non-production/test-only; ignore it entirely in production.
|
||||||
|
if (process.env.NODE_ENV === 'production') return null;
|
||||||
|
const stub = process.env.SENCHO_ASSURANCE_ENTITLEMENT_STUB;
|
||||||
|
if (!stub) return null;
|
||||||
|
if (stub === '1' || stub === 'entitled') {
|
||||||
|
return { success: true, entitlement: entitledStubFixture() };
|
||||||
|
}
|
||||||
|
if (stub === 'unauthorized') return { success: false, code: 'unauthorized' };
|
||||||
|
return { success: false, code: 'unavailable' };
|
||||||
|
}
|
||||||
|
|
||||||
|
private cacheKey(licenseKey: string, instanceId: string): string {
|
||||||
|
const fingerprint = crypto.createHash('sha256').update(licenseKey).digest('hex');
|
||||||
|
return `${fingerprint}:${instanceId}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEntitlement(value: unknown): HardenedEntitlement | null {
|
||||||
|
if (!isRecord(value) || value.hardened_build_access !== true || value.channel !== 'hardened') return null;
|
||||||
|
if (typeof value.allowed_image_ref !== 'string'
|
||||||
|
|| typeof value.pin_recommendation !== 'string'
|
||||||
|
|| typeof value.checked_at !== 'string'
|
||||||
|
|| !isRecord(value.registry_requirement)
|
||||||
|
|| typeof value.registry_requirement.registry_host !== 'string'
|
||||||
|
|| typeof value.registry_requirement.package_scope !== 'string'
|
||||||
|
|| typeof value.registry_requirement.credential_instructions !== 'string'
|
||||||
|
|| typeof value.registry_requirement.supports_pull_token !== 'boolean') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entitlement: HardenedEntitlement = {
|
||||||
|
hardened_build_access: true,
|
||||||
|
channel: 'hardened',
|
||||||
|
allowed_image_ref: value.allowed_image_ref,
|
||||||
|
pin_recommendation: value.pin_recommendation,
|
||||||
|
registry_requirement: {
|
||||||
|
registry_host: value.registry_requirement.registry_host,
|
||||||
|
package_scope: value.registry_requirement.package_scope,
|
||||||
|
credential_instructions: value.registry_requirement.credential_instructions,
|
||||||
|
supports_pull_token: value.registry_requirement.supports_pull_token,
|
||||||
|
},
|
||||||
|
checked_at: value.checked_at,
|
||||||
|
};
|
||||||
|
return validateAllowedImageRefAgainstRequirement(
|
||||||
|
entitlement.allowed_image_ref,
|
||||||
|
entitlement.registry_requirement,
|
||||||
|
) ? entitlement : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusToErrorCode(status: number): HardenedEntitlementErrorCode {
|
||||||
|
if (status === 401 || status === 403) return 'unauthorized';
|
||||||
|
if (status === 404) return 'unpublished';
|
||||||
|
if (status === 410) return 'expired';
|
||||||
|
return 'unavailable';
|
||||||
|
}
|
||||||
|
|
||||||
|
function entitledStubFixture(): HardenedEntitlement {
|
||||||
|
return {
|
||||||
|
hardened_build_access: true,
|
||||||
|
channel: 'hardened',
|
||||||
|
allowed_image_ref: 'ghcr.io/studio-saelix/sencho-hardened@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||||
|
pin_recommendation: 'Use the supplied digest.',
|
||||||
|
registry_requirement: {
|
||||||
|
registry_host: 'ghcr.io',
|
||||||
|
package_scope: 'studio-saelix/sencho-hardened',
|
||||||
|
credential_instructions: 'Create a pull token.',
|
||||||
|
supports_pull_token: true,
|
||||||
|
},
|
||||||
|
checked_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSecureAssuranceApi(apiBase: string): boolean {
|
||||||
|
try {
|
||||||
|
const url = new URL(apiBase);
|
||||||
|
const isLoopback = url.hostname === 'localhost'
|
||||||
|
|| url.hostname === '127.0.0.1'
|
||||||
|
|| url.hostname === '::1';
|
||||||
|
return url.protocol === 'https:' || (url.protocol === 'http:' && isLoopback);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,534 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import SelfUpdateService from './SelfUpdateService';
|
||||||
|
import { HardenedEntitlementService } from './HardenedEntitlementService';
|
||||||
|
import { RegistryService } from './RegistryService';
|
||||||
|
import type { ImagePinKind } from '../helpers/selfUpdateCompose';
|
||||||
|
import type { LocalRegistryAccess } from './hardenedEntitlementTypes';
|
||||||
|
import { getAuthToken, httpRequest } from './registry-api';
|
||||||
|
|
||||||
|
export type ImageOperationKind = 'switch' | 'update' | 'community_update';
|
||||||
|
export type ImageOperationState = 'pending_pull' | 'pulling' | 'patching' | 'recreating' | 'succeeded' | 'failed';
|
||||||
|
type FailureCode = 'self_update_unavailable' | 'entitlement_denied' | 'preflight_mismatch' | 'compose_unavailable' | 'registry_access_unavailable' | 'update_failed' | 'interrupted_by_restart';
|
||||||
|
|
||||||
|
export interface ImageOperation {
|
||||||
|
schemaVersion: 1;
|
||||||
|
operationId: string;
|
||||||
|
kind: ImageOperationKind;
|
||||||
|
state: ImageOperationState;
|
||||||
|
previousImageRef: string | null;
|
||||||
|
targetImageRef: string | null;
|
||||||
|
composeFilePath: string | null;
|
||||||
|
serviceName: string | null;
|
||||||
|
startedAt: string;
|
||||||
|
resolvedAt?: string;
|
||||||
|
acknowledgedAt?: string;
|
||||||
|
failureCode?: FailureCode;
|
||||||
|
rollback: { attempted: false };
|
||||||
|
preflightFingerprint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HardenedPreflight =
|
||||||
|
| { ok: true; preflightFingerprint: string; currentImageRef: string; allowedImageRef: string; composeFilePath: string; pinKind: ImagePinKind; localRegistryAccess: LocalRegistryAccess }
|
||||||
|
| { ok: false; code: FailureCode | 'entitlement_denied' };
|
||||||
|
|
||||||
|
const OPERATION_ID_RE =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
export class ImageOperationService {
|
||||||
|
private static instance: ImageOperationService;
|
||||||
|
private claimed = false;
|
||||||
|
private stateWriteQueue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
public static getInstance(): ImageOperationService {
|
||||||
|
if (!ImageOperationService.instance) ImageOperationService.instance = new ImageOperationService();
|
||||||
|
return ImageOperationService.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static isOperationId(operationId: string): boolean {
|
||||||
|
return OPERATION_ID_RE.test(operationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public computePreflightFingerprint(composePath: string, currentImage: string, pinKind: ImagePinKind, allowedImageRef: string): string {
|
||||||
|
return crypto.createHash('sha256')
|
||||||
|
.update(JSON.stringify({ composePath, currentImage, pinKind, allowedImageRef }))
|
||||||
|
.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async preflightSwitch(): Promise<HardenedPreflight> {
|
||||||
|
const entitlement = await HardenedEntitlementService.getInstance().getEntitlement('switch');
|
||||||
|
if (!entitlement.success) return { ok: false, code: 'entitlement_denied' };
|
||||||
|
|
||||||
|
const resolved = await SelfUpdateService.getInstance().getResolvedComposeImageForUpdate();
|
||||||
|
if (!resolved) return { ok: false, code: 'compose_unavailable' };
|
||||||
|
const localRegistryAccess = await this.getRegistryAccess(
|
||||||
|
entitlement.entitlement.registry_requirement.registry_host,
|
||||||
|
entitlement.entitlement.registry_requirement.package_scope,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
preflightFingerprint: this.computePreflightFingerprint(
|
||||||
|
resolved.filePath,
|
||||||
|
resolved.imageRef,
|
||||||
|
resolved.pinKind,
|
||||||
|
entitlement.entitlement.allowed_image_ref,
|
||||||
|
),
|
||||||
|
currentImageRef: resolved.imageRef,
|
||||||
|
allowedImageRef: entitlement.entitlement.allowed_image_ref,
|
||||||
|
composeFilePath: resolved.filePath,
|
||||||
|
pinKind: resolved.pinKind,
|
||||||
|
localRegistryAccess,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async switchToHardened(
|
||||||
|
preflightFingerprint: string,
|
||||||
|
kind: 'switch' | 'update' = 'switch',
|
||||||
|
): Promise<{ ok: boolean; code?: FailureCode | 'IMAGE_OPERATION_IN_FLIGHT' | 'preflight_mismatch' }> {
|
||||||
|
const entitlement = await HardenedEntitlementService.getInstance().getEntitlement('switch');
|
||||||
|
if (!entitlement.success) return { ok: false, code: 'entitlement_denied' };
|
||||||
|
const selfUpdate = SelfUpdateService.getInstance();
|
||||||
|
const resolved = await selfUpdate.getResolvedComposeImageForUpdate();
|
||||||
|
const serviceName = selfUpdate.getComposeServiceName();
|
||||||
|
if (!resolved || !serviceName) return { ok: false, code: 'compose_unavailable' };
|
||||||
|
|
||||||
|
const fingerprint = this.computePreflightFingerprint(
|
||||||
|
resolved.filePath,
|
||||||
|
resolved.imageRef,
|
||||||
|
resolved.pinKind,
|
||||||
|
entitlement.entitlement.allowed_image_ref,
|
||||||
|
);
|
||||||
|
if (fingerprint !== preflightFingerprint) return { ok: false, code: 'preflight_mismatch' };
|
||||||
|
|
||||||
|
const operation = this.newOperation(kind, resolved.imageRef, entitlement.entitlement.allowed_image_ref, resolved.filePath, serviceName, fingerprint);
|
||||||
|
if (!await this.tryClaim(operation)) return { ok: false, code: 'IMAGE_OPERATION_IN_FLIGHT' };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await RegistryService.getInstance().resolveDockerConfigForHost(
|
||||||
|
entitlement.entitlement.registry_requirement.registry_host,
|
||||||
|
);
|
||||||
|
if (Object.keys(config.config.auths).length === 0) {
|
||||||
|
await this.fail(operation, 'registry_access_unavailable');
|
||||||
|
return { ok: false, code: 'registry_access_unavailable' };
|
||||||
|
}
|
||||||
|
const configDir = await this.writeDockerConfig(operation.operationId, config.config);
|
||||||
|
operation.state = 'pulling';
|
||||||
|
await this.persist(operation);
|
||||||
|
// Register before triggerUpdate so a fast helper exit cannot drain listeners first.
|
||||||
|
this.watchHelperExit(operation);
|
||||||
|
await selfUpdate.triggerUpdate({
|
||||||
|
targetImageRef: operation.targetImageRef!,
|
||||||
|
dockerConfigPath: configDir,
|
||||||
|
successMarkerFile: this.successMarkerFile(operation),
|
||||||
|
successMarkerContent: JSON.stringify({ ok: true, operationId: operation.operationId }),
|
||||||
|
});
|
||||||
|
if (selfUpdate.getLastError()) {
|
||||||
|
await this.fail(operation, 'update_failed');
|
||||||
|
return { ok: false, code: 'update_failed' };
|
||||||
|
}
|
||||||
|
operation.state = 'recreating';
|
||||||
|
await this.persist(operation);
|
||||||
|
if (selfUpdate.getLastError()) {
|
||||||
|
await this.fail(operation, 'update_failed');
|
||||||
|
return { ok: false, code: 'update_failed' };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ImageOperation] Hardened switch failed:', error);
|
||||||
|
await this.fail(operation, 'update_failed');
|
||||||
|
return { ok: false, code: 'update_failed' };
|
||||||
|
} finally {
|
||||||
|
this.claimed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async claimCommunityUpdate(options?: { targetVersion?: string }): Promise<
|
||||||
|
{ ok: true } | { ok: false; failureCode: 'IMAGE_OPERATION_IN_FLIGHT' }
|
||||||
|
> {
|
||||||
|
const selfUpdate = SelfUpdateService.getInstance();
|
||||||
|
const resolved = await selfUpdate.getResolvedComposeImageForUpdate();
|
||||||
|
const operation = this.newOperation(
|
||||||
|
'community_update',
|
||||||
|
resolved?.imageRef ?? null,
|
||||||
|
options?.targetVersion ?? null,
|
||||||
|
resolved?.filePath ?? null,
|
||||||
|
selfUpdate.getComposeServiceName(),
|
||||||
|
);
|
||||||
|
if (!await this.tryClaim(operation)) {
|
||||||
|
return { ok: false, failureCode: 'IMAGE_OPERATION_IN_FLIGHT' };
|
||||||
|
}
|
||||||
|
// Disk non-terminal state is the concurrency lock; clear the in-memory mutex
|
||||||
|
// so a later claim can observe the persisted pending operation.
|
||||||
|
this.claimed = false;
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async executeClaimedCommunityUpdate(options?: { targetVersion?: string }): Promise<{ ok: boolean; failureCode?: string }> {
|
||||||
|
const operation = await this.getCurrentOperation();
|
||||||
|
if (!operation || operation.kind !== 'community_update') {
|
||||||
|
return { ok: false, failureCode: 'update_failed' };
|
||||||
|
}
|
||||||
|
if (!['pending_pull', 'pulling', 'patching', 'recreating'].includes(operation.state)) {
|
||||||
|
return { ok: false, failureCode: 'update_failed' };
|
||||||
|
}
|
||||||
|
const selfUpdate = SelfUpdateService.getInstance();
|
||||||
|
try {
|
||||||
|
operation.state = 'pulling';
|
||||||
|
await this.persist(operation);
|
||||||
|
// Register before triggerUpdate so a fast helper exit cannot drain listeners first.
|
||||||
|
this.watchHelperExit(operation);
|
||||||
|
await selfUpdate.triggerUpdate({
|
||||||
|
...options,
|
||||||
|
successMarkerFile: this.successMarkerFile(operation),
|
||||||
|
successMarkerContent: JSON.stringify({ ok: true, operationId: operation.operationId }),
|
||||||
|
});
|
||||||
|
if (selfUpdate.getLastError()) {
|
||||||
|
await this.fail(operation, 'update_failed');
|
||||||
|
return { ok: false, failureCode: 'update_failed' };
|
||||||
|
}
|
||||||
|
operation.state = 'recreating';
|
||||||
|
await this.persist(operation);
|
||||||
|
if (selfUpdate.getLastError()) {
|
||||||
|
await this.fail(operation, 'update_failed');
|
||||||
|
return { ok: false, failureCode: 'update_failed' };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ImageOperation] Community update failed:', error);
|
||||||
|
await this.fail(operation, 'update_failed');
|
||||||
|
return { ok: false, failureCode: 'update_failed' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async runCommunityUpdate(options?: { targetVersion?: string }): Promise<{ ok: boolean; failureCode?: string }> {
|
||||||
|
const claim = await this.claimCommunityUpdate(options);
|
||||||
|
if (!claim.ok) return claim;
|
||||||
|
return this.executeClaimedCommunityUpdate(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getOperation(operationId: string): Promise<ImageOperation | null> {
|
||||||
|
const filePath = this.operationFile(operationId);
|
||||||
|
if (!filePath) return null;
|
||||||
|
return this.readOperation(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getCurrentOperation(): Promise<ImageOperation | null> {
|
||||||
|
return this.readOperation(this.currentFile());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async acknowledge(operationId: string): Promise<boolean> {
|
||||||
|
const operation = await this.getOperation(operationId);
|
||||||
|
if (!operation || operation.state !== 'failed') return false;
|
||||||
|
operation.acknowledgedAt = new Date().toISOString();
|
||||||
|
// Write the history file always; only refresh the global current pointer when
|
||||||
|
// this operation is still current. Otherwise acknowledging a stale failure can
|
||||||
|
// replace an active claim and bypass single-flight locking.
|
||||||
|
await this.enqueueStateWrite(async () => {
|
||||||
|
const filePath = this.operationFile(operation.operationId);
|
||||||
|
if (!filePath) throw new Error('Invalid image operation id');
|
||||||
|
await fs.mkdir(this.operationsDir(), { recursive: true, mode: 0o700 });
|
||||||
|
await this.atomicWrite(filePath, JSON.stringify(operation));
|
||||||
|
const current = await this.readOperation(this.currentFile());
|
||||||
|
if (current?.operationId === operation.operationId) {
|
||||||
|
await this.atomicWrite(this.currentFile(), JSON.stringify(operation));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async reconcileOnStartup(): Promise<void> {
|
||||||
|
const operation = await this.getCurrentOperation();
|
||||||
|
if (!operation || !['pending_pull', 'pulling', 'patching', 'recreating'].includes(operation.state)) return;
|
||||||
|
const markerPath = this.successMarkerFile(operation);
|
||||||
|
for (let elapsed = 0; elapsed < 30_000; elapsed += 1_000) {
|
||||||
|
const markerOk = await this.isSuccessMarkerForOperation(markerPath, operation.operationId);
|
||||||
|
if (operation.kind === 'community_update') {
|
||||||
|
// Community success is the marker alone; floating tags may not equal targetImageRef.
|
||||||
|
if (markerOk) {
|
||||||
|
operation.state = 'succeeded';
|
||||||
|
operation.resolvedAt = new Date().toISOString();
|
||||||
|
await this.persist(operation);
|
||||||
|
await this.cleanupOperationArtifacts(operation);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const pinMatchesTarget = (await SelfUpdateService.getInstance().getPinInfo({ fresh: true }))?.composeImageRef === operation.targetImageRef;
|
||||||
|
if (markerOk && pinMatchesTarget) {
|
||||||
|
operation.state = 'succeeded';
|
||||||
|
operation.resolvedAt = new Date().toISOString();
|
||||||
|
await this.persist(operation);
|
||||||
|
await this.cleanupOperationArtifacts(operation);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1_000));
|
||||||
|
}
|
||||||
|
await this.fail(operation, 'interrupted_by_restart');
|
||||||
|
}
|
||||||
|
|
||||||
|
private watchHelperExit(operation: ImageOperation): void {
|
||||||
|
SelfUpdateService.getInstance().onceHelperExit((error) => {
|
||||||
|
// Surviving the helper callback means recreate did not take over; treat
|
||||||
|
// a null payload the same as an explicit survival failure.
|
||||||
|
const exitError = error ?? 'Helper container exited without restarting Sencho';
|
||||||
|
void (async () => {
|
||||||
|
const current = await this.getCurrentOperation();
|
||||||
|
if (!current || current.operationId !== operation.operationId) return;
|
||||||
|
if (!['pending_pull', 'pulling', 'patching', 'recreating'].includes(current.state)) return;
|
||||||
|
console.error('[ImageOperation] Helper exit while operation active:', exitError);
|
||||||
|
await this.fail(current, 'update_failed');
|
||||||
|
})().catch((listenerError) => {
|
||||||
|
console.error('[ImageOperation] Helper exit terminalization failed:', listenerError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private newOperation(kind: ImageOperationKind, previousImageRef: string | null, targetImageRef: string | null, composeFilePath: string | null, serviceName: string | null, preflightFingerprint?: string): ImageOperation {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
operationId: crypto.randomUUID(),
|
||||||
|
kind,
|
||||||
|
state: 'pending_pull',
|
||||||
|
previousImageRef,
|
||||||
|
targetImageRef,
|
||||||
|
composeFilePath,
|
||||||
|
serviceName,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
rollback: { attempted: false },
|
||||||
|
...(preflightFingerprint ? { preflightFingerprint } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tryClaim(operation: ImageOperation): Promise<boolean> {
|
||||||
|
if (this.claimed) return false;
|
||||||
|
this.claimed = true;
|
||||||
|
try {
|
||||||
|
const current = await this.getCurrentOperation();
|
||||||
|
if (current && ['pending_pull', 'pulling', 'patching', 'recreating'].includes(current.state)) {
|
||||||
|
this.claimed = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await this.removeLegacySuccessMarkers();
|
||||||
|
await this.persist(operation);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
this.claimed = false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enqueueStateWrite<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
const run = this.stateWriteQueue.then(fn, fn);
|
||||||
|
this.stateWriteQueue = run.then(() => undefined, () => undefined);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fail(operation: ImageOperation, failureCode: FailureCode): Promise<void> {
|
||||||
|
operation.state = 'failed';
|
||||||
|
operation.failureCode = failureCode;
|
||||||
|
operation.resolvedAt = new Date().toISOString();
|
||||||
|
await this.persist(operation);
|
||||||
|
await this.cleanupOperationArtifacts(operation);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persist(operation: ImageOperation): Promise<void> {
|
||||||
|
await this.enqueueStateWrite(async () => {
|
||||||
|
const current = await this.readOperation(this.currentFile());
|
||||||
|
const incomingTerminal = operation.state === 'failed' || operation.state === 'succeeded';
|
||||||
|
const currentTerminal = current?.state === 'failed' || current?.state === 'succeeded';
|
||||||
|
// Do not let a late non-terminal write (e.g. recreating) overwrite a
|
||||||
|
// concurrent helper-exit failure for the same operation.
|
||||||
|
if (
|
||||||
|
current
|
||||||
|
&& current.operationId === operation.operationId
|
||||||
|
&& currentTerminal
|
||||||
|
&& !incomingTerminal
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const filePath = this.operationFile(operation.operationId);
|
||||||
|
if (!filePath) throw new Error('Invalid image operation id');
|
||||||
|
await fs.mkdir(this.operationsDir(), { recursive: true, mode: 0o700 });
|
||||||
|
await this.atomicWrite(filePath, JSON.stringify(operation));
|
||||||
|
await this.atomicWrite(this.currentFile(), JSON.stringify(operation));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readOperation(filePath: string): Promise<ImageOperation | null> {
|
||||||
|
try {
|
||||||
|
return JSON.parse(await fs.readFile(filePath, 'utf8')) as ImageOperation;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||||
|
const code = (error as NodeJS.ErrnoException).code ?? 'unknown';
|
||||||
|
console.error(`[ImageOperation] Failed to read operation (${code})`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async atomicWrite(filePath: string, content: string): Promise<void> {
|
||||||
|
const temporary = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
||||||
|
await fs.writeFile(temporary, content, { encoding: 'utf8', mode: 0o600 });
|
||||||
|
await fs.rename(temporary, filePath);
|
||||||
|
await fs.chmod(filePath, 0o600);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async writeDockerConfig(
|
||||||
|
operationId: string,
|
||||||
|
config: { auths?: Record<string, { auth?: string }> },
|
||||||
|
): Promise<string> {
|
||||||
|
const configDir = this.resolveUnderBase(path.join(this.dataDir(), 'image-op-docker'), operationId);
|
||||||
|
if (!configDir) throw new Error('Invalid image operation id');
|
||||||
|
await fs.mkdir(configDir, { recursive: true, mode: 0o700 });
|
||||||
|
await fs.chmod(configDir, 0o700);
|
||||||
|
// Allowlist-copy host keys and base64 auth tokens, then build JSON locally so
|
||||||
|
// the on-disk DOCKER_CONFIG payload is not a direct write of network data.
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const [host, entry] of Object.entries(config.auths ?? {})) {
|
||||||
|
const safeHost = this.allowlistedRegistryHostKey(host);
|
||||||
|
const safeAuth = entry?.auth ? this.allowlistedBase64(entry.auth) : null;
|
||||||
|
if (!safeHost || !safeAuth) continue;
|
||||||
|
parts.push(`${JSON.stringify(safeHost)}:${JSON.stringify({ auth: safeAuth })}`);
|
||||||
|
}
|
||||||
|
const payload = `{"auths":{${parts.join(',')}}}`;
|
||||||
|
await this.atomicWrite(path.join(configDir, 'config.json'), payload);
|
||||||
|
return configDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copy a Docker auth host key char-by-char through an allowlist. */
|
||||||
|
private allowlistedRegistryHostKey(host: string): string | null {
|
||||||
|
if (host.length === 0 || host.length > 512) return null;
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < host.length; i++) {
|
||||||
|
const code = host.charCodeAt(i);
|
||||||
|
const ok =
|
||||||
|
(code >= 48 && code <= 57)
|
||||||
|
|| (code >= 65 && code <= 90)
|
||||||
|
|| (code >= 97 && code <= 122)
|
||||||
|
|| code === 43 || code === 45 || code === 46
|
||||||
|
|| code === 47 || code === 58 || code === 95;
|
||||||
|
if (!ok) return null;
|
||||||
|
out += String.fromCharCode(code);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copy a base64 auth token char-by-char through an allowlist. */
|
||||||
|
private allowlistedBase64(value: string): string | null {
|
||||||
|
if (value.length === 0 || value.length > 8192) return null;
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < value.length; i++) {
|
||||||
|
const code = value.charCodeAt(i);
|
||||||
|
const ok =
|
||||||
|
(code >= 48 && code <= 57)
|
||||||
|
|| (code >= 65 && code <= 90)
|
||||||
|
|| (code >= 97 && code <= 122)
|
||||||
|
|| code === 43 || code === 47 || code === 61;
|
||||||
|
if (!ok) return null;
|
||||||
|
out += String.fromCharCode(code);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getRegistryAccess(registryHost: string, packageScope: string): Promise<LocalRegistryAccess> {
|
||||||
|
try {
|
||||||
|
const config = await RegistryService.getInstance().resolveDockerConfigForHost(registryHost);
|
||||||
|
const auth = Object.values(config.config.auths)[0]?.auth;
|
||||||
|
if (!auth) return 'missing';
|
||||||
|
const decoded = Buffer.from(auth, 'base64').toString('utf8');
|
||||||
|
const delimiter = decoded.indexOf(':');
|
||||||
|
if (delimiter === -1) return 'rejected';
|
||||||
|
const credentials = {
|
||||||
|
username: decoded.slice(0, delimiter),
|
||||||
|
password: decoded.slice(delimiter + 1),
|
||||||
|
};
|
||||||
|
const token = await getAuthToken(registryHost, packageScope, credentials);
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: 'application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json',
|
||||||
|
Authorization: token
|
||||||
|
? `Bearer ${token}`
|
||||||
|
: `Basic ${auth}`,
|
||||||
|
};
|
||||||
|
const manifestUrl = `https://${registryHost}/v2/${packageScope}/manifests/latest`;
|
||||||
|
let response = await httpRequest(manifestUrl, 'HEAD', headers);
|
||||||
|
if (response.statusCode === 405 || response.statusCode === 501) {
|
||||||
|
response = await httpRequest(manifestUrl, 'GET', headers);
|
||||||
|
}
|
||||||
|
return response.statusCode === 200 ? 'ready' : 'rejected';
|
||||||
|
} catch {
|
||||||
|
console.warn('[ImageOperation] Registry package probe failed');
|
||||||
|
return 'rejected';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private dataDir(): string {
|
||||||
|
return process.env.DATA_DIR || '/app/data';
|
||||||
|
}
|
||||||
|
|
||||||
|
private operationsDir(): string {
|
||||||
|
return path.join(this.dataDir(), 'image-operations');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a path under baseDir; null if operationId is invalid or escapes the base. */
|
||||||
|
private resolveUnderBase(baseDir: string, operationId: string): string | null {
|
||||||
|
if (!ImageOperationService.isOperationId(operationId)) return null;
|
||||||
|
const base = path.resolve(baseDir);
|
||||||
|
const candidate = path.resolve(base, operationId);
|
||||||
|
if (candidate !== base && !candidate.startsWith(base + path.sep)) return null;
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private operationFile(operationId: string): string | null {
|
||||||
|
if (!ImageOperationService.isOperationId(operationId)) return null;
|
||||||
|
const base = path.resolve(this.operationsDir());
|
||||||
|
const candidate = path.resolve(base, `${operationId}.json`);
|
||||||
|
if (!candidate.startsWith(base + path.sep)) return null;
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private currentFile(): string {
|
||||||
|
return path.join(this.dataDir(), 'image-operation-current.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
private successMarkerFile(operation: ImageOperation): string {
|
||||||
|
if (!ImageOperationService.isOperationId(operation.operationId)) {
|
||||||
|
throw new Error('Invalid image operation id');
|
||||||
|
}
|
||||||
|
return path.join(this.dataDir(), `image-op-success-${operation.operationId}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async removeLegacySuccessMarkers(): Promise<void> {
|
||||||
|
await Promise.all([
|
||||||
|
fs.rm(path.join(this.dataDir(), 'image-op-success.json'), { force: true }),
|
||||||
|
fs.rm(path.join(this.dataDir(), 'hardened-switch-success.json'), { force: true }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cleanupOperationArtifacts(operation: ImageOperation): Promise<void> {
|
||||||
|
try {
|
||||||
|
const dockerDir = this.resolveUnderBase(path.join(this.dataDir(), 'image-op-docker'), operation.operationId);
|
||||||
|
await Promise.all([
|
||||||
|
dockerDir ? fs.rm(dockerDir, { recursive: true, force: true }) : Promise.resolve(),
|
||||||
|
fs.rm(this.successMarkerFile(operation), { force: true }),
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as NodeJS.ErrnoException).code ?? 'unknown';
|
||||||
|
console.error(`[ImageOperation] Failed to clean operation artifacts (${code})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async isSuccessMarkerForOperation(markerPath: string, operationId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const marker: unknown = JSON.parse(await fs.readFile(markerPath, 'utf8'));
|
||||||
|
return typeof marker === 'object'
|
||||||
|
&& marker !== null
|
||||||
|
&& 'operationId' in marker
|
||||||
|
&& marker.operationId === operationId;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
|
console.error('[ImageOperation] Failed to read success marker:', error);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { DatabaseService } from './DatabaseService';
|
import { DatabaseService } from './DatabaseService';
|
||||||
|
import { HardenedEntitlementService } from './HardenedEntitlementService';
|
||||||
import type {
|
import type {
|
||||||
LicenseInfo,
|
LicenseInfo,
|
||||||
LicenseStatus,
|
LicenseStatus,
|
||||||
@@ -231,6 +232,7 @@ export class LicenseService {
|
|||||||
private setLicenseStatus(status: LicenseStatus): void {
|
private setLicenseStatus(status: LicenseStatus): void {
|
||||||
DatabaseService.getInstance().setSystemState('license_status', status);
|
DatabaseService.getInstance().setSystemState('license_status', status);
|
||||||
this.cachedProxyHeaders = null;
|
this.cachedProxyHeaders = null;
|
||||||
|
HardenedEntitlementService.getInstance().invalidateCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -392,6 +392,24 @@ export class RegistryService {
|
|||||||
return { config: { auths }, warnings };
|
return { config: { auths }, warnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve a Docker config containing credentials for one registry host only. */
|
||||||
|
public async resolveDockerConfigForHost(registryHost: string): Promise<ResolvedDockerConfig> {
|
||||||
|
const auth = await this.getAuthForRegistry(registryHost);
|
||||||
|
if (!auth) return { config: { auths: {} }, warnings: [] };
|
||||||
|
|
||||||
|
const normalized = normalizeImageHost(registryHost);
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
auths: {
|
||||||
|
[normalized]: {
|
||||||
|
auth: Buffer.from(`${auth.username}:${auth.password}`).toString('base64'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
warnings: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve credentials for a specific registry row by ID only.
|
* Resolve credentials for a specific registry row by ID only.
|
||||||
|
|||||||
@@ -128,6 +128,8 @@ export function buildSelfUpdateComposeCmd(
|
|||||||
errorFile: string,
|
errorFile: string,
|
||||||
pruneOnUpdate: boolean,
|
pruneOnUpdate: boolean,
|
||||||
composeCopy?: ComposeCopy,
|
composeCopy?: ComposeCopy,
|
||||||
|
successMarkerFile?: string,
|
||||||
|
successMarkerContent = '{"ok":true}',
|
||||||
): string {
|
): string {
|
||||||
const recreate = ['docker compose', ...fFlags.map(shQuote), 'up -d --force-recreate', shQuote(serviceName), `2>${stderrTmp}`].join(' ');
|
const recreate = ['docker compose', ...fFlags.map(shQuote), 'up -d --force-recreate', shQuote(serviceName), `2>${stderrTmp}`].join(' ');
|
||||||
const copyStep = composeCopy
|
const copyStep = composeCopy
|
||||||
@@ -142,6 +144,9 @@ export function buildSelfUpdateComposeCmd(
|
|||||||
...(pruneOnUpdate
|
...(pruneOnUpdate
|
||||||
? [`if [ $ec -eq 0 ]; then docker image prune -f >/dev/null 2>&1 || true; fi`]
|
? [`if [ $ec -eq 0 ]; then docker image prune -f >/dev/null 2>&1 || true; fi`]
|
||||||
: []),
|
: []),
|
||||||
|
...(successMarkerFile
|
||||||
|
? [`if [ $ec -eq 0 ]; then printf %s ${shQuote(successMarkerContent)} > ${shQuote(successMarkerFile)}; fi`]
|
||||||
|
: []),
|
||||||
`cat ${stderrTmp} >&2 2>/dev/null`,
|
`cat ${stderrTmp} >&2 2>/dev/null`,
|
||||||
'exit $ec',
|
'exit $ec',
|
||||||
].join('; ');
|
].join('; ');
|
||||||
@@ -205,6 +210,9 @@ class SelfUpdateService {
|
|||||||
private canSelfUpdate = false;
|
private canSelfUpdate = false;
|
||||||
private composeContext: ComposeContext | null = null;
|
private composeContext: ComposeContext | null = null;
|
||||||
private lastUpdateError: string | null = null;
|
private lastUpdateError: string | null = null;
|
||||||
|
/** Stashed when the helper exits before any onceHelperExit listener is registered. */
|
||||||
|
private pendingHelperExitError: string | undefined = undefined;
|
||||||
|
private helperExitListeners: Array<(error: string | null) => void> = [];
|
||||||
private pinCache: { info: ResolvedComposeImage | null; at: number } | null = null;
|
private pinCache: { info: ResolvedComposeImage | null; at: number } | null = null;
|
||||||
|
|
||||||
public static getInstance(): SelfUpdateService {
|
public static getInstance(): SelfUpdateService {
|
||||||
@@ -305,6 +313,23 @@ class SelfUpdateService {
|
|||||||
return this.lastUpdateError;
|
return this.lastUpdateError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Register a one-shot listener for the helper container's execFile callback. */
|
||||||
|
onceHelperExit(listener: (error: string | null) => void): void {
|
||||||
|
if (this.pendingHelperExitError !== undefined) {
|
||||||
|
const error = this.pendingHelperExitError;
|
||||||
|
this.pendingHelperExitError = undefined;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
try {
|
||||||
|
listener(error);
|
||||||
|
} catch (listenerError) {
|
||||||
|
console.error('[SelfUpdate] Helper exit listener failed:', listenerError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.helperExitListeners.push(listener);
|
||||||
|
}
|
||||||
|
|
||||||
/** Clears the stored update error (call after reading it). */
|
/** Clears the stored update error (call after reading it). */
|
||||||
clearLastError(): void {
|
clearLastError(): void {
|
||||||
this.lastUpdateError = null;
|
this.lastUpdateError = null;
|
||||||
@@ -322,6 +347,15 @@ class SelfUpdateService {
|
|||||||
return { pinKind: resolved.pinKind, composeImageRef: resolved.imageRef, filePath: resolved.filePath };
|
return { pinKind: resolved.pinKind, composeImageRef: resolved.imageRef, filePath: resolved.filePath };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fresh compose image resolution for guarded image-channel operations. */
|
||||||
|
async getResolvedComposeImageForUpdate(): Promise<ResolvedComposeImage | null> {
|
||||||
|
return this.resolveComposeImage(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
getComposeServiceName(): string | null {
|
||||||
|
return this.composeContext?.serviceName ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Preflight the route layer runs before responding, so a blocked update fails
|
* Preflight the route layer runs before responding, so a blocked update fails
|
||||||
* fast with a 409 instead of returning 202 and stalling the reconnect overlay.
|
* fast with a 409 instead of returning 202 and stalling the reconnect overlay.
|
||||||
@@ -401,37 +435,50 @@ class SelfUpdateService {
|
|||||||
* target; when omitted this keeps the legacy behavior of pulling the running
|
* target; when omitted this keeps the legacy behavior of pulling the running
|
||||||
* image and recreating from the on-disk compose.
|
* image and recreating from the on-disk compose.
|
||||||
*/
|
*/
|
||||||
async triggerUpdate(options?: { targetVersion?: string }): Promise<void> {
|
async triggerUpdate(options?: {
|
||||||
|
targetVersion?: string;
|
||||||
|
targetImageRef?: string;
|
||||||
|
dockerConfigPath?: string;
|
||||||
|
successMarkerFile?: string;
|
||||||
|
successMarkerContent?: string;
|
||||||
|
}): Promise<void> {
|
||||||
if (!this.composeContext) return;
|
if (!this.composeContext) return;
|
||||||
const env = this.buildEnv();
|
const env = {
|
||||||
|
...this.buildEnv(),
|
||||||
|
...(options?.dockerConfigPath ? { DOCKER_CONFIG: options.dockerConfigPath } : {}),
|
||||||
|
};
|
||||||
this.lastUpdateError = null;
|
this.lastUpdateError = null;
|
||||||
|
this.pendingHelperExitError = undefined;
|
||||||
|
|
||||||
try { fs.unlinkSync(UPDATE_ERROR_FILE); } catch { /* absent is the steady state */ }
|
try { fs.unlinkSync(UPDATE_ERROR_FILE); } catch { /* absent is the steady state */ }
|
||||||
try { fs.unlinkSync(STAGED_PATCH_FILE); } catch { /* absent is the steady state */ }
|
try { fs.unlinkSync(STAGED_PATCH_FILE); } catch { /* absent is the steady state */ }
|
||||||
|
|
||||||
const { imageName, serviceName, dataDirHost } = this.composeContext;
|
const { imageName, serviceName, dataDirHost } = this.composeContext;
|
||||||
const targetVersion = options?.targetVersion;
|
const targetVersion = options?.targetVersion;
|
||||||
|
const targetImageRef = options?.targetImageRef;
|
||||||
|
|
||||||
let pullRef = imageName;
|
let pullRef = imageName;
|
||||||
let repin: { resolved: ResolvedComposeImage; ref: string } | null = null;
|
let repin: { resolved: ResolvedComposeImage; ref: string } | null = null;
|
||||||
|
|
||||||
if (targetVersion) {
|
if (targetVersion || targetImageRef) {
|
||||||
const resolved = await this.resolveComposeImage(true);
|
const resolved = await this.resolveComposeImage(true);
|
||||||
const pinKind = resolved?.pinKind ?? 'unknown';
|
const pinKind = resolved?.pinKind ?? 'unknown';
|
||||||
// Defense in depth: the route preflight already rejected these, but the
|
// Defense in depth: the route preflight already rejected these, but the
|
||||||
// compose file could change between preflight and this last-breath call.
|
// compose file could change between preflight and this last-breath call.
|
||||||
if (!resolved || isRepinBlocked(pinKind)) {
|
if (!resolved || (!targetImageRef && isRepinBlocked(pinKind))) {
|
||||||
this.lastUpdateError = resolved ? UPDATE_BLOCKED_REASON : UPDATE_READ_FAILED_REASON;
|
this.lastUpdateError = resolved ? UPDATE_BLOCKED_REASON : UPDATE_READ_FAILED_REASON;
|
||||||
console.error('[SelfUpdate] Update blocked:', this.lastUpdateError);
|
console.error('[SelfUpdate] Update blocked:', this.lastUpdateError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pullRef = pinKind === 'semver' ? buildTargetImageRef(resolved.imageRef, targetVersion) : resolved.imageRef;
|
pullRef = targetImageRef ?? (pinKind === 'semver'
|
||||||
|
? buildTargetImageRef(resolved.imageRef, targetVersion!)
|
||||||
|
: resolved.imageRef);
|
||||||
if (!isValidImageRef(pullRef)) {
|
if (!isValidImageRef(pullRef)) {
|
||||||
this.lastUpdateError = 'Aborting update: the computed image reference is invalid.';
|
this.lastUpdateError = 'Aborting update: the computed image reference is invalid.';
|
||||||
console.error('[SelfUpdate] Update blocked:', this.lastUpdateError, pullRef);
|
console.error('[SelfUpdate] Update blocked:', this.lastUpdateError, pullRef);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (pinKind === 'semver') {
|
if (pinKind === 'semver' || targetImageRef) {
|
||||||
if (!dataDirHost) {
|
if (!dataDirHost) {
|
||||||
this.lastUpdateError =
|
this.lastUpdateError =
|
||||||
'Cannot rewrite the pinned compose image: the data directory needed for the update handoff is not mounted. Change the image tag manually and update again.';
|
'Cannot rewrite the pinned compose image: the data directory needed for the update handoff is not mounted. Change the image tag manually and update again.';
|
||||||
@@ -477,7 +524,7 @@ class SelfUpdateService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.spawnHelper(env, composeCopy);
|
this.spawnHelper(env, composeCopy, options?.successMarkerFile, options?.successMarkerContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -486,7 +533,12 @@ class SelfUpdateService {
|
|||||||
* Runs attached (no -d): if the recreate fails before it kills us, execFile's
|
* Runs attached (no -d): if the recreate fails before it kills us, execFile's
|
||||||
* callback receives the helper's exit code and stderr directly.
|
* callback receives the helper's exit code and stderr directly.
|
||||||
*/
|
*/
|
||||||
private spawnHelper(env: NodeJS.ProcessEnv, composeCopy?: ComposeCopy): void {
|
private spawnHelper(
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
composeCopy?: ComposeCopy,
|
||||||
|
successMarkerFile?: string,
|
||||||
|
successMarkerContent?: string,
|
||||||
|
): void {
|
||||||
if (!this.composeContext) return;
|
if (!this.composeContext) return;
|
||||||
const { workingDir, configFiles, serviceName, imageName, dataDirHost, hostBindMounts } = this.composeContext;
|
const { workingDir, configFiles, serviceName, imageName, dataDirHost, hostBindMounts } = this.composeContext;
|
||||||
|
|
||||||
@@ -501,16 +553,41 @@ class SelfUpdateService {
|
|||||||
const stderrTmp = '/tmp/_sencho_err';
|
const stderrTmp = '/tmp/_sencho_err';
|
||||||
const pruneOnUpdate =
|
const pruneOnUpdate =
|
||||||
DatabaseService.getInstance().getGlobalSettings()['prune_on_update'] === '1';
|
DatabaseService.getInstance().getGlobalSettings()['prune_on_update'] === '1';
|
||||||
const composeCmd = buildSelfUpdateComposeCmd(fFlags, serviceName, stderrTmp, UPDATE_ERROR_FILE, pruneOnUpdate, composeCopy);
|
const composeCmd = buildSelfUpdateComposeCmd(
|
||||||
|
fFlags,
|
||||||
|
serviceName,
|
||||||
|
stderrTmp,
|
||||||
|
UPDATE_ERROR_FILE,
|
||||||
|
pruneOnUpdate,
|
||||||
|
composeCopy,
|
||||||
|
successMarkerFile,
|
||||||
|
successMarkerContent,
|
||||||
|
);
|
||||||
const args = buildSelfUpdateRunArgs({ workingDir, imageName, dataDirHost, hostBindMounts, repinWritable: !!composeCopy }, composeCmd);
|
const args = buildSelfUpdateRunArgs({ workingDir, imageName, dataDirHost, hostBindMounts, repinWritable: !!composeCopy }, composeCmd);
|
||||||
|
|
||||||
// Callback may never fire on success (we die mid-call during recreate);
|
// Callback may never fire on success (we die mid-call during recreate);
|
||||||
// that is fine because the restart itself is the success signal.
|
// that is fine because the restart itself is the success signal.
|
||||||
|
// Surviving a clean helper exit means recreate did not take over this process.
|
||||||
execFile('docker', args, { env }, (err, _stdout, stderr) => {
|
execFile('docker', args, { env }, (err, _stdout, stderr) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
const stderrText = stderr?.toString().trim();
|
const stderrText = stderr?.toString().trim();
|
||||||
this.lastUpdateError = stderrText || err.message || 'Helper container failed';
|
this.lastUpdateError = stderrText || err.message || 'Helper container failed';
|
||||||
console.error('[SelfUpdate] Helper container failed:', this.lastUpdateError);
|
console.error('[SelfUpdate] Helper container failed:', this.lastUpdateError);
|
||||||
|
} else if (!this.lastUpdateError) {
|
||||||
|
this.lastUpdateError = 'Helper container exited without restarting Sencho';
|
||||||
|
console.error('[SelfUpdate] Helper container exited cleanly without restarting Sencho');
|
||||||
|
}
|
||||||
|
const listeners = this.helperExitListeners.splice(0);
|
||||||
|
if (listeners.length === 0) {
|
||||||
|
this.pendingHelperExitError = this.lastUpdateError!;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const listener of listeners) {
|
||||||
|
try {
|
||||||
|
listener(this.lastUpdateError);
|
||||||
|
} catch (listenerError) {
|
||||||
|
console.error('[SelfUpdate] Helper exit listener failed:', listenerError);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// No code after this point is guaranteed to run: the helper recreates this container.
|
// No code after this point is guaranteed to run: the helper recreates this container.
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export type HardenedEntitlementPurpose = 'status' | 'switch' | 'update';
|
||||||
|
|
||||||
|
export type HardenedEntitlementErrorCode =
|
||||||
|
| 'unauthorized'
|
||||||
|
| 'unpublished'
|
||||||
|
| 'expired'
|
||||||
|
| 'unavailable';
|
||||||
|
|
||||||
|
export type LocalRegistryAccess = 'ready' | 'missing' | 'decrypt_failed' | 'rejected';
|
||||||
|
|
||||||
|
export interface RegistryRequirement {
|
||||||
|
registry_host: string;
|
||||||
|
package_scope: string;
|
||||||
|
credential_instructions: string;
|
||||||
|
supports_pull_token: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HardenedEntitlement {
|
||||||
|
hardened_build_access: boolean;
|
||||||
|
channel: 'hardened';
|
||||||
|
allowed_image_ref: string;
|
||||||
|
pin_recommendation: string;
|
||||||
|
registry_requirement: RegistryRequirement;
|
||||||
|
checked_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HardenedEntitlementRequest {
|
||||||
|
license_key: string;
|
||||||
|
instance_id: string;
|
||||||
|
purpose: HardenedEntitlementPurpose;
|
||||||
|
requested_version?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HardenedEntitlementResult =
|
||||||
|
| { success: true; entitlement: HardenedEntitlement }
|
||||||
|
| { success: false; code: HardenedEntitlementErrorCode };
|
||||||
@@ -98,7 +98,7 @@ export const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
|
|||||||
// Cloud backup
|
// Cloud backup
|
||||||
'PUT /cloud-backup/config': 'Updated cloud backup config',
|
'PUT /cloud-backup/config': 'Updated cloud backup config',
|
||||||
'POST /cloud-backup/test': 'Tested cloud backup connection',
|
'POST /cloud-backup/test': 'Tested cloud backup connection',
|
||||||
'POST /cloud-backup/provision': 'Provisioned Sencho Cloud Backup',
|
'POST /cloud-backup/provision': 'Provisioned Recovery Vault',
|
||||||
'POST /cloud-backup/upload': 'Uploaded snapshot to cloud',
|
'POST /cloud-backup/upload': 'Uploaded snapshot to cloud',
|
||||||
'DELETE /cloud-backup/object': 'Deleted cloud snapshot',
|
'DELETE /cloud-backup/object': 'Deleted cloud snapshot',
|
||||||
|
|
||||||
|
|||||||
@@ -361,7 +361,7 @@ See [Vulnerability scanning](/features/vulnerability-scanning).
|
|||||||
- `error`/`system`: `Scheduled task "<name>" (<action>) failed: <err>`
|
- `error`/`system`: `Scheduled task "<name>" (<action>) failed: <err>`
|
||||||
- `info`/`system` recovery: `Scheduled task "<name>" (<action>) recovered successfully`
|
- `info`/`system` recovery: `Scheduled task "<name>" (<action>) recovered successfully`
|
||||||
|
|
||||||
### Cloud Backup upload failure
|
### Recovery Vault upload failure
|
||||||
|
|
||||||
`warning`/`system`: `Cloud backup failed for scheduled snapshot <id>: <message>`. See [Fleet backups](/features/fleet-backups).
|
`warning`/`system`: `Cloud backup failed for scheduled snapshot <id>: <message>`. See [Fleet backups](/features/fleet-backups).
|
||||||
|
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ Regardless of scope, every API token is rejected from the following routes with
|
|||||||
| **Registry credentials** | View, create, update, delete, test registry creds |
|
| **Registry credentials** | View, create, update, delete, test registry creds |
|
||||||
| **Host-console session token** | Mint the short-lived ticket the browser console uses |
|
| **Host-console session token** | Mint the short-lived ticket the browser console uses |
|
||||||
| **API token self-management** | Create, list, revoke API tokens |
|
| **API token self-management** | Create, list, revoke API tokens |
|
||||||
| **Cloud Backup** | Configure or trigger Sencho Cloud Backup |
|
| **Recovery Vault** | Configure or trigger Recovery Vault |
|
||||||
|
|
||||||
The rationale is the same in every case: a programmatic credential should not be able to grant itself more authority, change the trust roots that issued it, or open an interactive shell. Those actions require a live human user logged into a browser.
|
The rationale is the same in every case: a programmatic credential should not be able to grant itself more authority, change the trust roots that issued it, or open an interactive shell. Those actions require a live human user logged into a browser.
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ Expanding a row in the Table view reveals additional detail:
|
|||||||
- Fleet replica role changes (re-anchor, demote to control)
|
- Fleet replica role changes (re-anchor, demote to control)
|
||||||
- Fleet Secrets: create, update, delete, import-from-stack, push (and push preview)
|
- Fleet Secrets: create, update, delete, import-from-stack, push (and push preview)
|
||||||
- Blueprint federation pin updates
|
- Blueprint federation pin updates
|
||||||
- Sencho Cloud Backup: config update, connection test, provisioning, snapshot upload, snapshot deletion
|
- Recovery Vault: config update, connection test, provisioning, snapshot upload, snapshot deletion
|
||||||
- User management: create, update, delete, role assignment and removal
|
- User management: create, update, delete, role assignment and removal
|
||||||
- Password changes and node token generation
|
- Password changes and node token generation
|
||||||
- License activation and deactivation
|
- License activation and deactivation
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ The table paginates at eight rows; chevrons appear in the header along with an `
|
|||||||
The Configuration Status card is the at-a-glance audit of every toggleable automation and security feature on the active node, so nothing is silently off when you expect it to be on.
|
The Configuration Status card is the at-a-glance audit of every toggleable automation and security feature on the active node, so nothing is silently off when you expect it to be on.
|
||||||
|
|
||||||
<Frame>
|
<Frame>
|
||||||
<img src="/images/dashboard/configuration-status.png" alt="Configuration Status card with four sections: Notifications (Notification agents, Alert rules, Notification routing all reading None), Automation (Auto-heal None, Auto-update '1 / 1', Webhooks None, Scheduled tasks '2 actives'), Security (MFA Off, SSO Off, Vulnerability scanning None), and Backups & Thresholds (Cloud Backup 'Sencho Cloud', Alert thresholds 'CPU 100% · RAM 100% · Disk 100%', Crash detection On)." />
|
<img src="/images/dashboard/configuration-status.png" alt="Configuration Status card with four sections: Notifications (Notification agents, Alert rules, Notification routing all reading None), Automation (Auto-heal None, Auto-update '1 / 1', Webhooks None, Scheduled tasks '2 actives'), Security (MFA Off, SSO Off, Vulnerability scanning None), and Backups & Thresholds (Recovery Vault active, Alert thresholds 'CPU 100% · RAM 100% · Disk 100%', Crash detection On)." />
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|
||||||
The card is divided into four sections.
|
The card is divided into four sections.
|
||||||
@@ -112,7 +112,7 @@ The card is divided into four sections.
|
|||||||
|
|
||||||
| Row | What it shows |
|
| Row | What it shows |
|
||||||
|-----|---------------|
|
|-----|---------------|
|
||||||
| **Cloud Backup** | The active cloud backup target: `Sencho Cloud` (Admiral only), `Custom S3` (with ` (auto)` appended when auto-upload is enabled), or `Disabled` |
|
| **Recovery Vault** | The active recovery target: `Recovery Vault` (Admiral), `Custom S3` (with ` (auto)` appended when auto-upload is enabled), or `Disabled` |
|
||||||
| **Alert thresholds** | The current host thresholds, formatted `CPU x% · RAM y% · Disk z%`, or `Off` when host threshold alerts are disabled |
|
| **Alert thresholds** | The current host thresholds, formatted `CPU x% · RAM y% · Disk z%`, or `Off` when host threshold alerts are disabled |
|
||||||
| **Crash detection** | `On` when global container-crash notifications are enabled, `Off` otherwise |
|
| **Crash detection** | `On` when global container-crash notifications are enabled, `Off` otherwise |
|
||||||
|
|
||||||
|
|||||||
@@ -99,22 +99,22 @@ Each stack is restored independently: if one stack cannot be restored (for examp
|
|||||||
|
|
||||||
Admins can delete snapshots from the list view by clicking the trash icon on the right side of each row. A confirmation dialog asks you to confirm before the snapshot is permanently removed. Deleting a snapshot removes all captured file data from the database. This action cannot be undone.
|
Admins can delete snapshots from the list view by clicking the trash icon on the right side of each row. A confirmation dialog asks you to confirm before the snapshot is permanently removed. Deleting a snapshot removes all captured file data from the database. This action cannot be undone.
|
||||||
|
|
||||||
## Cloud Backup
|
## Recovery Vault
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
Custom S3-compatible storage is available on every tier. Sencho Cloud Backup is an Admiral feature. Configure either in **Settings → Infrastructure → Cloud Backup**.
|
Custom S3-compatible storage is available on every tier. Recovery Vault is an Admiral feature. Configure either in **Settings → Infrastructure → Recovery Vault**.
|
||||||
</Note>
|
</Note>
|
||||||
|
|
||||||
Cloud Backup mirrors every fleet snapshot to off-site storage so your snapshots survive local disk failure. The Cloud Backup settings page (reached via **Settings → Infrastructure → Cloud Backup**) shows a header with your current scope, provider, storage used, and total snapshot count in the cloud. Two storage modes are supported.
|
Recovery Vault mirrors every fleet snapshot to off-site storage so your snapshots survive local disk failure. The Recovery Vault settings page (reached via **Settings → Infrastructure → Recovery Vault**) shows a header with your current scope, provider, storage used, and total snapshot count in the cloud. Two storage modes are supported.
|
||||||
|
|
||||||
### Sencho Cloud Backup (included)
|
### Recovery Vault (included)
|
||||||
|
|
||||||
A managed 500 MB allowance backed by Cloudflare R2, included with every Admiral license. Open **Settings → Infrastructure → Cloud Backup**, choose **Sencho Cloud Backup (Included)**, and click **Activate**. Sencho exchanges your license key for scoped storage credentials and starts replicating new snapshots automatically.
|
A managed 500 MB allowance, included with every Admiral subscription. Open **Settings → Infrastructure → Recovery Vault**, choose **Recovery Vault (Included)**, and click **Activate**. Sencho exchanges your license key for scoped storage credentials and starts replicating new snapshots automatically.
|
||||||
|
|
||||||
Once active, the settings page shows a storage gauge (used / 500 MB and object count), a status message confirming auto-upload is on, and a **Reprovision** button to refresh credentials if needed. You can verify connectivity at any time with the **Test** button.
|
Once active, the settings page shows a storage gauge (used / 500 MB and object count), a status message confirming auto-upload is on, and a **Reprovision** button to refresh credentials if needed. You can verify connectivity at any time with the **Test** button.
|
||||||
|
|
||||||
<Frame>
|
<Frame>
|
||||||
<img src="/images/cloud-backup/cloud-backup-active.png" alt="Cloud Backup settings page with Sencho Cloud Backup active, showing the storage gauge, auto-upload status, and the Cloud Snapshots list" />
|
<img src="/images/cloud-backup/cloud-backup-active.png" alt="Recovery Vault settings page with Recovery Vault active, showing the storage gauge, auto-upload status, and the Cloud Snapshots list" />
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|
||||||
### Custom S3 (BYOB)
|
### Custom S3 (BYOB)
|
||||||
@@ -142,7 +142,7 @@ To upload a single snapshot on demand, open the **Snapshots** tab in Fleet View.
|
|||||||
|
|
||||||
### Browsing and downloading cloud snapshots
|
### Browsing and downloading cloud snapshots
|
||||||
|
|
||||||
The **Cloud Snapshots** panel in **Settings → Infrastructure → Cloud Backup** lists every archive currently in your bucket, with size and last-modified timestamp. Click the download icon to save a `.tar.gz` archive locally for off-host disaster recovery. Each archive contains a `metadata.json` describing the snapshot and a `nodes/` tree with the captured compose and environment files, organised by node and stack. When the snapshot preserved Dossier notes, the archive also includes a `documentation.json` with those notes.
|
The **Cloud Snapshots** panel in **Settings → Infrastructure → Recovery Vault** lists every archive currently in your bucket, with size and last-modified timestamp. Click the download icon to save a `.tar.gz` archive locally for off-host disaster recovery. Each archive contains a `metadata.json` describing the snapshot and a `nodes/` tree with the captured compose and environment files, organised by node and stack. When the snapshot preserved Dossier notes, the archive also includes a `documentation.json` with those notes.
|
||||||
|
|
||||||
### Restoring from a cloud snapshot
|
### Restoring from a cloud snapshot
|
||||||
|
|
||||||
@@ -152,7 +152,7 @@ For in-place rollback, use the **Restore** action on the snapshot detail view as
|
|||||||
|
|
||||||
Every fleet snapshot action requires the **admin** role: viewing the snapshot list, browsing snapshot contents, creating, restoring, deleting, and uploading to the cloud. Because a snapshot captures the `.env` file of every stack, the snapshot list and detail views are restricted to administrators rather than read-only roles.
|
Every fleet snapshot action requires the **admin** role: viewing the snapshot list, browsing snapshot contents, creating, restoring, deleting, and uploading to the cloud. Because a snapshot captures the `.env` file of every stack, the snapshot list and detail views are restricted to administrators rather than read-only roles.
|
||||||
|
|
||||||
Cloud Backup configuration is also admin-only. Mirroring to Sencho Cloud Backup additionally requires an Admiral license; a Custom S3-compatible target works on every tier.
|
Recovery Vault configuration is also admin-only. Mirroring to Recovery Vault additionally requires an Admiral subscription; a Custom S3-compatible target works on every tier.
|
||||||
|
|
||||||
## Storage
|
## Storage
|
||||||
|
|
||||||
@@ -173,11 +173,11 @@ Snapshots are stored in Sencho's SQLite database. Captured file contents, includ
|
|||||||
<Accordion title="Restore all reports that some stacks failed">
|
<Accordion title="Restore all reports that some stacks failed">
|
||||||
Restore all applies each stack independently, so a failure on one stack does not stop the others. A stack is reported as failed when its node has been removed from the fleet since the snapshot was taken, when a remote node is offline or unreachable, or when its existing files cannot be written. The successful stacks are fully restored regardless. Resolve the underlying cause (re-add a removed node, bring an offline node back online) and run Restore all again, or restore the remaining stacks individually from the same snapshot.
|
Restore all applies each stack independently, so a failure on one stack does not stop the others. A stack is reported as failed when its node has been removed from the fleet since the snapshot was taken, when a remote node is offline or unreachable, or when its existing files cannot be written. The successful stacks are fully restored regardless. Resolve the underlying cause (re-add a removed node, bring an offline node back online) and run Restore all again, or restore the remaining stacks individually from the same snapshot.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
<Accordion title="Cloud Backup Test reports an authentication error">
|
<Accordion title="Recovery Vault Test reports an authentication error">
|
||||||
Double-check the Access Key ID, Secret Access Key, and bucket name; one wrong character is the most common cause. Some providers require S3-compatible API access to be enabled on the bucket separately from the credentials. For MinIO, confirm the user has read/write permission on the target bucket. After correcting the values, click **Test** again before saving.
|
Double-check the Access Key ID, Secret Access Key, and bucket name; one wrong character is the most common cause. Some providers require S3-compatible API access to be enabled on the bucket separately from the credentials. For MinIO, confirm the user has read/write permission on the target bucket. After correcting the values, click **Test** again before saving.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
<Accordion title="Cloud uploads fail after hitting the 500 MB quota">
|
<Accordion title="Cloud uploads fail after hitting the 500 MB quota">
|
||||||
Sencho Cloud Backup carries a 500 MB allowance per license. Once you hit the cap, new uploads fail with a quota error and the storage gauge in the settings page reads at or near 500 MB. Free space by deleting older archives from the **Cloud Snapshots** panel in **Settings → Infrastructure → Cloud Backup**. Local snapshots are unaffected by cloud deletions, so the in-place restore path stays intact. To raise the ceiling, switch the storage mode to **Custom S3 (BYOB)** and point at a bucket you control.
|
Recovery Vault carries a 500 MB allowance per subscription. Once you hit the cap, new uploads fail with a quota error and the storage gauge in the settings page reads at or near 500 MB. Free space by deleting older archives from the **Cloud Snapshots** panel in **Settings → Infrastructure → Recovery Vault**. Local snapshots are unaffected by cloud deletions, so the in-place restore path stays intact. To raise the ceiling, switch the storage mode to **Custom S3 (BYOB)** and point at a bucket you control.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
<Accordion title="Cloud upload returns a network timeout or 5xx error">
|
<Accordion title="Cloud upload returns a network timeout or 5xx error">
|
||||||
Transient errors surface as a notification and leave the local snapshot in place. Retry by clicking the cloud-upload action on the snapshot row, or wait for the next scheduled snapshot which retries on its own. Persistent failures usually indicate an endpoint outage; verify the storage provider is reachable from your Sencho host (custom S3 endpoints often sit behind a different DNS or firewall path than the rest of your traffic).
|
Transient errors surface as a notification and leave the local snapshot in place. Retry by clicking the cloud-upload action on the snapshot row, or wait for the next scheduled snapshot which retries on its own. Persistent failures usually indicate an endpoint outage; verify the storage provider is reachable from your Sencho host (custom S3 endpoints often sit behind a different DNS or firewall path than the rest of your traffic).
|
||||||
|
|||||||
@@ -49,15 +49,16 @@ See [the pricing page](https://sencho.io/pricing) for current pricing.
|
|||||||
|
|
||||||
**Admiral** is the official business assurance plan from Studio Saelix. It includes everything in Community, plus:
|
**Admiral** is the official business assurance plan from Studio Saelix. It includes everything in Community, plus:
|
||||||
|
|
||||||
- **Assurance and support:** priority email support and Studio Saelix–backed continuity for production fleets
|
- **Assurance and support:** priority email support and Studio Saelix-backed continuity for production fleets
|
||||||
- **Governance:** advanced RBAC roles (Deployer, Node Admin, Auditor), scoped permissions per stack or node, and audit log export (CSV, JSON), anomaly detection, and configurable retention beyond the recent window
|
- **Governance:** advanced RBAC roles (Deployer, Node Admin, Auditor), scoped permissions per stack or node, and audit log export (CSV, JSON), anomaly detection, and configurable retention beyond the recent window
|
||||||
- **Managed continuity:** Sencho Cloud Backup (a managed, off-site snapshot allowance)
|
- **Managed continuity:** Recovery Vault (a managed, off-site snapshot allowance)
|
||||||
|
- **Hardened Build:** an Admiral image channel with published supply-chain assurance artifacts
|
||||||
- **Directory integration:** LDAP / Active Directory authentication
|
- **Directory integration:** LDAP / Active Directory authentication
|
||||||
- **Current plan availability:** some product surfaces (including AWS ECR credentials) still require an Admiral plan today. That access rule is temporary availability, not the reason Admiral exists. Other operator surfaces may be limited-availability on a given instance and are documented on their own feature pages when enabled.
|
- **Current plan availability:** some product surfaces (including AWS ECR credentials) still require an Admiral plan today. That access rule is temporary availability, not the reason Admiral exists. Other operator surfaces may be limited-availability on a given instance and are documented on their own feature pages when enabled.
|
||||||
|
|
||||||
## Free trial
|
## Free trial
|
||||||
|
|
||||||
Sencho offers a **14-day Admiral trial** so you can evaluate Admiral assurance (priority support path, managed Cloud Backup, governance depth including advanced RBAC, LDAP / Active Directory, and audit export) with your real infrastructure before committing. The trial is offered on the monthly and annual Admiral plans.
|
Sencho offers a **14-day Admiral trial** so you can evaluate Admiral assurance (priority support path, Recovery Vault, Hardened Build, governance depth including advanced RBAC, LDAP / Active Directory, and audit export) with your real infrastructure before committing. The trial is offered on the monthly and annual Admiral plans.
|
||||||
|
|
||||||
To start a trial:
|
To start a trial:
|
||||||
|
|
||||||
@@ -68,7 +69,7 @@ To start a trial:
|
|||||||
5. Your license key is emailed to you within a few minutes.
|
5. Your license key is emailed to you within a few minutes.
|
||||||
6. Activate the key in the Sencho dashboard as described in [Activating your license](#activating-your-license).
|
6. Activate the key in the Sencho dashboard as described in [Activating your license](#activating-your-license).
|
||||||
|
|
||||||
When your trial ends, the card you provided is automatically charged and your plan continues as a paid Admiral subscription. To avoid being charged, cancel from the **Manage subscription** button in **Settings → License** (or from your receipt email) any time before day 14.
|
When your trial ends, the card you provided is automatically charged and your plan continues as a paid Admiral subscription. To avoid being charged, cancel from the **Manage subscription** button in **Settings → Admiral Account** (or from your receipt email) any time before day 14.
|
||||||
|
|
||||||
<Tip>
|
<Tip>
|
||||||
Fresh installs land on the **Community** tier until you activate a license key. All Community features (unlimited nodes, compose editor, global logs, alerts, Custom OIDC, and more) are available immediately.
|
Fresh installs land on the **Community** tier until you activate a license key. All Community features (unlimited nodes, compose editor, global logs, alerts, Custom OIDC, and more) are available immediately.
|
||||||
@@ -76,7 +77,7 @@ When your trial ends, the card you provided is automatically charged and your pl
|
|||||||
|
|
||||||
## Activating your license
|
## Activating your license
|
||||||
|
|
||||||
Open **Settings → License** in the Sencho dashboard. When the license is not active, the **Activate** section sits below the Plan card:
|
Open **Settings → Admiral Account** in the Sencho dashboard. When the license is not active, the **Activate** section sits below the Plan card:
|
||||||
|
|
||||||
<Frame>
|
<Frame>
|
||||||
<img src="/images/licensing/license-activate-section.png" alt="The Activate section of the License page on a Community-tier instance, with a License key input field and an Activate button on the right" />
|
<img src="/images/licensing/license-activate-section.png" alt="The Activate section of the License page on a Community-tier instance, with a License key input field and an Activate button on the right" />
|
||||||
@@ -89,7 +90,7 @@ Sencho validates the key and activates your plan. If activation fails, the toast
|
|||||||
|
|
||||||
## The Plan section
|
## The Plan section
|
||||||
|
|
||||||
When a license is active, **Settings → License** opens on the **Plan** section. The page masthead at the top exposes three stat pills, and the section below lists the metadata for the active license.
|
When a license is active, **Settings → Admiral Account** opens on the **Plan** section. The page masthead at the top exposes three stat pills, and the section below lists the metadata for the active license.
|
||||||
|
|
||||||
<Frame>
|
<Frame>
|
||||||
<img src="/images/licensing/license-admiral-active.png" alt="License page showing an active Sencho Admiral license, with the Plan section listing the Customer, Product, and masked License key fields and a Deactivate button" />
|
<img src="/images/licensing/license-admiral-active.png" alt="License page showing an active Sencho Admiral license, with the Plan section listing the Customer, Product, and masked License key fields and a Deactivate button" />
|
||||||
@@ -154,7 +155,7 @@ For details on how this works, see [License enforcement across nodes](/features/
|
|||||||
|
|
||||||
To transfer your license to a different instance:
|
To transfer your license to a different instance:
|
||||||
|
|
||||||
1. Go to **Settings → License**.
|
1. Go to **Settings → Admiral Account**.
|
||||||
2. Click **Deactivate** in the Plan section.
|
2. Click **Deactivate** in the Plan section.
|
||||||
3. On your new instance, paste the same license key into **License key** and click **Activate**.
|
3. On your new instance, paste the same license key into **License key** and click **Activate**.
|
||||||
|
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ When you select a remote node in the switcher, the Settings hub filters to the p
|
|||||||
| Support | Per browser | Diagnostics bundle, docs links, contact channels. |
|
| Support | Per browser | Diagnostics bundle, docs links, contact channels. |
|
||||||
| About | Per browser | Build metadata for whichever instance the page is loaded from. |
|
| About | Per browser | Build metadata for whichever instance the page is loaded from. |
|
||||||
|
|
||||||
Panels that manage control-plane concerns (Account, License, Users, SSO, API Tokens, Registries, Cloud Backup, Nodes, Routing, Webhooks) are hidden when a remote node is active.
|
Panels that manage control-plane concerns (Account, Admiral Account, Users, SSO, API Tokens, Registries, Recovery Vault, Nodes, Routing, Webhooks) are hidden when a remote node is active.
|
||||||
|
|
||||||
## License enforcement across nodes
|
## License enforcement across nodes
|
||||||
|
|
||||||
@@ -325,6 +325,6 @@ Sencho takes the opposite approach: infrastructure-level encryption (VPN, revers
|
|||||||
The control instance's license tier is authoritative for proxied requests, so a Community control plane gates Admiral features on every remote, even if the remote itself has its own Admiral license. Activate an Admiral license on the control instance to lift the gate fleet-wide. The reverse case (Admiral control plane, Community remote) works automatically because the control plane's tier is what the remote trusts.
|
The control instance's license tier is authoritative for proxied requests, so a Community control plane gates Admiral features on every remote, even if the remote itself has its own Admiral license. Activate an Admiral license on the control instance to lift the gate fleet-wide. The reverse case (Admiral control plane, Community remote) works automatically because the control plane's tier is what the remote trusts.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
<Accordion title="Settings panels disappear when I switch to a remote">
|
<Accordion title="Settings panels disappear when I switch to a remote">
|
||||||
That is intentional. Account, License, Users, SSO, API Tokens, Registries, Cloud Backup, Nodes, Routing, and Webhooks are control-plane concerns and are hidden while a remote node is selected. Switch back to **Local** from the node switcher to manage them. The full list of which panels are per-node, per-browser, and control-plane-only lives in the [What Settings apply per node](#what-settings-apply-per-node) table above.
|
That is intentional. Account, Admiral Account, Users, SSO, API Tokens, Registries, Recovery Vault, Nodes, Routing, and Webhooks are control-plane concerns and are hidden while a remote node is selected. Switch back to **Local** from the node switcher to manage them. The full list of which panels are per-node, per-browser, and control-plane-only lives in the [What Settings apply per node](#what-settings-apply-per-node) table above.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</AccordionGroup>
|
</AccordionGroup>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Open the Settings Hub by clicking the **Profile** icon in the top bar and select
|
|||||||
|-------|----------------|
|
|-------|----------------|
|
||||||
| **Personal** | Account, Appearance |
|
| **Personal** | Account, Appearance |
|
||||||
| **Access** | License, Users, SSO, API Tokens |
|
| **Access** | License, Users, SSO, API Tokens |
|
||||||
| **Infrastructure** | Nodes, Fleet, Registries, Cloud Backup, App Store, Stacks |
|
| **Infrastructure** | Nodes, Fleet, Registries, Recovery Vault, App Store, Stacks |
|
||||||
| **Monitoring** | Host Alerts, Container Alerts, Docker & Storage |
|
| **Monitoring** | Host Alerts, Container Alerts, Docker & Storage |
|
||||||
| **Notifications** | Channels, Notification Routing |
|
| **Notifications** | Channels, Notification Routing |
|
||||||
| **Automation** | Image update checks, Webhooks |
|
| **Automation** | Image update checks, Webhooks |
|
||||||
@@ -37,7 +37,7 @@ Every section renders inside the same masthead-and-sidebar layout. The masthead
|
|||||||
| **SCOPE** `operator` / `browser` / `global` | Setting applies to your account (`operator`), to this browser only (`browser`, for browser-local sections such as Appearance), or to the whole instance (`global`, every other non-node group) |
|
| **SCOPE** `operator` / `browser` / `global` | Setting applies to your account (`operator`), to this browser only (`browser`, for browser-local sections such as Appearance), or to the whole instance (`global`, every other non-node group) |
|
||||||
| **NODE** `<node name>` | Setting is per-node and is currently being edited against this node |
|
| **NODE** `<node name>` | Setting is per-node and is currently being edited against this node |
|
||||||
| **EDITED** `<count>` pending / `saved` | The current section has unsaved changes |
|
| **EDITED** `<count>` pending / `saved` | The current section has unsaved changes |
|
||||||
| Section-specific stats | Each section can publish its own pills: `2FA on`/`off` and `BACKUP <n> left` (Account); `PLAN`, `TRIAL <n>d left`, `RENEWS`, `STATUS` (License); `OPERATORS` (Users); `CHANNELS` (Channels); `ROUTES` (Notification Routing); `WEBHOOKS` and `ENABLED` (Webhooks); `LABELS` (Labels); `PROVIDER`, `USED`, `SNAPSHOTS` (Cloud Backup); `DEV MODE` (Developer Diagnostics) |
|
| Section-specific stats | Each section can publish its own pills: `2FA on`/`off` and `BACKUP <n> left` (Account); `PLAN`, `TRIAL <n>d left`, `RENEWS`, `STATUS` (Admiral Account); `OPERATORS` (Users); `CHANNELS` (Channels); `ROUTES` (Notification Routing); `WEBHOOKS` and `ENABLED` (Webhooks); `LABELS` (Labels); `PROVIDER`, `USED`, `SNAPSHOTS` (Recovery Vault); `DEV MODE` (Developer Diagnostics) |
|
||||||
|
|
||||||
### Quick search
|
### Quick search
|
||||||
|
|
||||||
@@ -328,18 +328,18 @@ See [Private Registries](/features/private-registries) for the full walkthrough.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Cloud Backup
|
## Recovery Vault
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
Custom S3-compatible storage is available on every tier. Sencho Cloud Backup is an Admiral feature.
|
Custom S3-compatible storage is available on every tier. Recovery Vault is an Admiral feature.
|
||||||
</Note>
|
</Note>
|
||||||
|
|
||||||
**Scope:** Global, admin-only
|
**Scope:** Global, admin-only
|
||||||
|
|
||||||
Mirror fleet snapshots to Sencho Cloud Backup or any S3-compatible storage so a control-plane loss does not take the recovery history with it. The masthead publishes a **PROVIDER** pill (`sencho` / `s3` / `disabled`), a **USED** pill showing storage consumption, and a **SNAPSHOTS** pill with the cloud object count.
|
Mirror fleet snapshots to Recovery Vault or any S3-compatible storage so a control-plane loss does not take the recovery history with it. The masthead publishes a **PROVIDER** pill (`sencho` / `s3` / `disabled`), a **USED** pill showing storage consumption, and a **SNAPSHOTS** pill with the cloud object count.
|
||||||
|
|
||||||
<Frame>
|
<Frame>
|
||||||
<img src="/images/settings/settings-cloud-backup.png" alt="Cloud Backup section with Sencho Cloud Backup mode selected" />
|
<img src="/images/settings/settings-cloud-backup.png" alt="Recovery Vault section with Recovery Vault mode selected" />
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|
||||||
### Storage modes
|
### Storage modes
|
||||||
@@ -347,7 +347,7 @@ Mirror fleet snapshots to Sencho Cloud Backup or any S3-compatible storage so a
|
|||||||
| Mode | When to pick it |
|
| Mode | When to pick it |
|
||||||
|------|-----------------|
|
|------|-----------------|
|
||||||
| **Disabled** | Snapshots stay on the control plane's local disk only. Default for fresh installs. |
|
| **Disabled** | Snapshots stay on the control plane's local disk only. Default for fresh installs. |
|
||||||
| **Sencho Cloud Backup (included)** | Replicate snapshots to managed storage that ships with the Admiral subscription. On first selection, click **Activate** to provision the 500 MB allowance (backed by Cloudflare R2, scoped to this Admiral license). Auto-upload is always on for this mode. The **Test** and **Reprovision** buttons appear on the activated card if you need to verify connectivity or replace the credentials. |
|
| **Recovery Vault (included)** | Replicate snapshots to managed storage that ships with the Admiral subscription. On first selection, click **Activate** to provision the 500 MB allowance. Auto-upload is always on for this mode. The **Test** and **Reprovision** buttons appear on the activated card if you need to verify connectivity or replace the credentials. |
|
||||||
| **Custom S3 (BYOB)** | Point Sencho at your own S3-compatible bucket (AWS S3, Cloudflare R2, MinIO, Backblaze B2). Required fields: **Endpoint URL**, **Region**, **Bucket**, **Path Prefix** (default `sencho/`), **Access Key ID**, **Secret Access Key**. The secret access key is masked after save. Use **Test connection** to verify credentials, then toggle **Auto-upload** to start replicating snapshots. |
|
| **Custom S3 (BYOB)** | Point Sencho at your own S3-compatible bucket (AWS S3, Cloudflare R2, MinIO, Backblaze B2). Required fields: **Endpoint URL**, **Region**, **Bucket**, **Path Prefix** (default `sencho/`), **Access Key ID**, **Secret Access Key**. The secret access key is masked after save. Use **Test connection** to verify credentials, then toggle **Auto-upload** to start replicating snapshots. |
|
||||||
|
|
||||||
The **Cloud Snapshots** list at the bottom of the section is an inventory of objects currently in the configured destination. Each row exposes a **Download** icon and a **Delete** icon.
|
The **Cloud Snapshots** list at the bottom of the section is an inventory of objects currently in the configured destination. Each row exposes a **Download** icon and a **Delete** icon.
|
||||||
@@ -691,7 +691,7 @@ Links to help resources, with an additional channel for Admiral operators.
|
|||||||
|
|
||||||
| Channel | Tier | Description |
|
| Channel | Tier | Description |
|
||||||
|---------|------|-------------|
|
|---------|------|-------------|
|
||||||
| **Priority Email Support** | Admiral | Direct email support with responses within 24 hours. |
|
| **Priority Email Support** | Admiral | Direct email support during business hours (Mon–Fri 09:00–17:00 America/New_York). We aim to first-respond within one business day. This is an operational target, not an SLA. |
|
||||||
|
|
||||||
Community operators see an upgrade callout with a link to the pricing page in place of the support channels.
|
Community operators see an upgrade callout with a link to the pricing page in place of the support channels.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Admiral Account (Settings → license) Phase 4 surfaces.
|
||||||
|
* Community state must show AGPLv3 sourcing and channel info without auto-switching images.
|
||||||
|
*/
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { loginAs } from './helpers';
|
||||||
|
|
||||||
|
test.describe('Admiral Account', () => {
|
||||||
|
test('Community settings show AGPLv3 source and image channel fields', async ({ page }) => {
|
||||||
|
await loginAs(page);
|
||||||
|
|
||||||
|
await page.goto('/nodes/local/settings/license');
|
||||||
|
await page.waitForTimeout(1_000);
|
||||||
|
|
||||||
|
// Admiral Account section under the local-node settings route.
|
||||||
|
await expect(page.getByText(/Sencho Community|Community plan/i).first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(page.getByRole('link', { name: /View source/i })).toBeVisible();
|
||||||
|
await expect(page.getByText(/^Channel$/i).first()).toBeVisible();
|
||||||
|
await expect(page.getByText(/Current image/i).first()).toBeVisible();
|
||||||
|
|
||||||
|
// Activation alone must not open a Hardened switch dialog.
|
||||||
|
await expect(page.getByText(/Review image switch/i)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Support section uses bounded business-day wording', async ({ page }) => {
|
||||||
|
await loginAs(page);
|
||||||
|
await page.goto('/nodes/local/settings/support');
|
||||||
|
await page.waitForTimeout(1_000);
|
||||||
|
|
||||||
|
// Community sees self-serve; Admiral may see priority email with business-day target.
|
||||||
|
const selfServe = page.getByText(/Documentation|GitHub Issues/i).first();
|
||||||
|
await expect(selfServe).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
const pageText = await page.locator('body').innerText();
|
||||||
|
expect(pageText).not.toMatch(/responses within 24 hours/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -228,12 +228,12 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u
|
|||||||
onDismiss={isAdmin && onDismissUpdate ? () => onDismissUpdate(node.id) : undefined}
|
onDismiss={isAdmin && onDismissUpdate ? () => onDismissUpdate(node.id) : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{updateStatus?.updateAvailable && !updateStatus.updateStatus && !updateStatus?.skipActive && !updateStatus?.updateBlocked && (
|
{updateStatus?.updateAvailable && !updateStatus.updateStatus && !updateStatus?.skipActive && !(updateStatus?.updateBlocked && updateStatus?.imageChannel !== 'hardened') && (
|
||||||
<Badge className="text-[10px] px-1.5 py-0 h-4 bg-warning/15 text-warning border-warning/30 shrink-0">
|
<Badge className="text-[10px] px-1.5 py-0 h-4 bg-warning/15 text-warning border-warning/30 shrink-0">
|
||||||
Update available
|
Update available
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{updateStatus?.updateBlocked && updateStatus?.updateAvailable && !updateStatus.updateStatus && !updateStatus?.skipActive && (
|
{(updateStatus?.updateBlocked && updateStatus?.imageChannel !== 'hardened') && updateStatus?.updateAvailable && !updateStatus.updateStatus && !updateStatus?.skipActive && (
|
||||||
<PinnedUpdateBadge reason={updateStatus.updateBlockedReason} />
|
<PinnedUpdateBadge reason={updateStatus.updateBlockedReason} />
|
||||||
)}
|
)}
|
||||||
{updateStatus?.skipActive && (
|
{updateStatus?.skipActive && (
|
||||||
@@ -314,7 +314,7 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Update button (mutating action: admin only, matches the requireAdmin route guard) */}
|
{/* Update button (mutating action: admin only, matches the requireAdmin route guard) */}
|
||||||
{isOnline && updateStatus?.updateAvailable && !updateStatus.updateStatus && !updateStatus?.skipActive && !updateStatus?.updateBlocked && onUpdate && isAdmin && (
|
{isOnline && updateStatus?.updateAvailable && !updateStatus.updateStatus && !updateStatus?.skipActive && !(updateStatus?.updateBlocked && updateStatus?.imageChannel !== 'hardened') && onUpdate && isAdmin && (
|
||||||
<div className="mt-3 pt-3 border-t border-border/50">
|
<div className="mt-3 pt-3 border-t border-border/50">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -435,13 +435,13 @@ export function NodeUpdatesSheet({
|
|||||||
Unskip
|
Unskip
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{s.updateBlocked && s.updateAvailable && !s.updateStatus && !s.skipActive && (
|
{(s.updateBlocked && s.imageChannel !== 'hardened') && s.updateAvailable && !s.updateStatus && !s.skipActive && (
|
||||||
<PinnedUpdateBadge
|
<PinnedUpdateBadge
|
||||||
reason={s.updateBlockedReason}
|
reason={s.updateBlockedReason}
|
||||||
className="text-[10px] px-1.5 py-0 h-5 bg-muted text-muted-foreground border-card-border/40"
|
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 && isAdmin && (
|
{s.updateAvailable && !s.updateStatus && !s.skipActive && !(s.updateBlocked && s.imageChannel !== 'hardened') && isAdmin && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -467,7 +467,7 @@ export function NodeUpdatesSheet({
|
|||||||
Skip
|
Skip
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{s.updateAvailable && !s.updateStatus && !s.skipActive && !s.updateBlocked && !isAdmin && (
|
{s.updateAvailable && !s.updateStatus && !s.skipActive && !(s.updateBlocked && s.imageChannel !== 'hardened') && !isAdmin && (
|
||||||
<Badge className="text-[10px] px-1.5 py-0 h-5 bg-warning/15 text-warning border-warning/30">
|
<Badge className="text-[10px] px-1.5 py-0 h-5 bg-warning/15 text-warning border-warning/30">
|
||||||
<CircleAlert className="w-2.5 h-2.5 mr-0.5" /> Available
|
<CircleAlert className="w-2.5 h-2.5 mr-0.5" /> Available
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@@ -147,6 +147,20 @@ describe('useFleetUpdateStatus', () => {
|
|||||||
expect(toastSuccess).toHaveBeenCalledWith(expect.stringContaining('2 nodes'));
|
expect(toastSuccess).toHaveBeenCalledWith(expect.stringContaining('2 nodes'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reports failed remote nodes separately after an update-all request', async () => {
|
||||||
|
apiFetchMock.mockResolvedValue(okJson({
|
||||||
|
updating: [],
|
||||||
|
skipped: [],
|
||||||
|
failed: [{ name: 'Edge', error: 'Hardened Build updates require a signed-in admin on that node.' }],
|
||||||
|
}));
|
||||||
|
const { result } = renderHook(() => useFleetUpdateStatus());
|
||||||
|
|
||||||
|
await act(async () => { await result.current.triggerUpdateAll(); });
|
||||||
|
|
||||||
|
expect(toastError).toHaveBeenCalledWith(expect.stringContaining('Edge'));
|
||||||
|
expect(toastError).toHaveBeenCalledWith(expect.stringContaining('Hardened Build'));
|
||||||
|
});
|
||||||
|
|
||||||
it('triggerNodeUpdate on a blocked node toasts and does not POST', async () => {
|
it('triggerNodeUpdate on a blocked node toasts and does not POST', async () => {
|
||||||
apiFetchMock.mockResolvedValue(okJson({ nodes: [...STATUSES, BLOCKED_STATUS] }));
|
apiFetchMock.mockResolvedValue(okJson({ nodes: [...STATUSES, BLOCKED_STATUS] }));
|
||||||
const { result } = renderHook(() => useFleetUpdateStatus());
|
const { result } = renderHook(() => useFleetUpdateStatus());
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ function parseUpdateError(err: Record<string, unknown>, fallback: string): strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toastIfUpdateBlocked(status: NodeUpdateStatus | undefined): boolean {
|
function toastIfUpdateBlocked(status: NodeUpdateStatus | undefined): boolean {
|
||||||
if (!status?.updateBlocked) return false;
|
// Hardened digests report updateBlocked but still accept a POST so the typed
|
||||||
|
// HARDENED_REMOTE_UPDATE_UNSUPPORTED path can surface.
|
||||||
|
if (!status?.updateBlocked || status.imageChannel === 'hardened') return false;
|
||||||
toast.error(status.updateBlockedReason ?? PINNED_UPDATE_BLOCKED_FALLBACK);
|
toast.error(status.updateBlockedReason ?? PINNED_UPDATE_BLOCKED_FALLBACK);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -131,12 +133,19 @@ export function useFleetUpdateStatus() {
|
|||||||
try {
|
try {
|
||||||
const res = await apiFetch('/fleet/update-all', { method: 'POST', localOnly: true });
|
const res = await apiFetch('/fleet/update-all', { method: 'POST', localOnly: true });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json() as {
|
||||||
if (data.updating?.length > 0) {
|
updating?: string[];
|
||||||
toast.success(`Update initiated on ${data.updating.length} node${data.updating.length > 1 ? 's' : ''}.`);
|
failed?: Array<{ name: string; error: string }>;
|
||||||
|
};
|
||||||
|
const updating = data.updating ?? [];
|
||||||
|
if (updating.length > 0) {
|
||||||
|
toast.success(`Update initiated on ${updating.length} node${updating.length > 1 ? 's' : ''}.`);
|
||||||
} else {
|
} else {
|
||||||
toast.success('All nodes are up to date.');
|
toast.success('All nodes are up to date.');
|
||||||
}
|
}
|
||||||
|
if (data.failed?.length) {
|
||||||
|
toast.error(`Update could not start on ${data.failed.map(node => node.name).join(', ')}: ${data.failed[0].error}`);
|
||||||
|
}
|
||||||
fetchUpdateStatus();
|
fetchUpdateStatus();
|
||||||
} else {
|
} else {
|
||||||
const err = await res.json().catch(() => ({}));
|
const err = await res.json().catch(() => ({}));
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ export interface NodeUpdateStatus {
|
|||||||
updateBlocked?: boolean;
|
updateBlocked?: boolean;
|
||||||
/** Human-readable block reason. Local node only. */
|
/** Human-readable block reason. Local node only. */
|
||||||
updateBlockedReason?: string | null;
|
updateBlockedReason?: string | null;
|
||||||
|
/** Coarse image channel from meta/update-status. Hardened digests still POST. */
|
||||||
|
imageChannel?: 'community' | 'hardened' | 'unknown' | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ViewMode = 'grid' | 'topology';
|
export type ViewMode = 'grid' | 'topology';
|
||||||
|
|||||||
@@ -213,8 +213,8 @@ export function ConfigurationStatus({ onOpenSection }: ConfigurationStatusProps
|
|||||||
<SectionHeader icon={HardDrive} label="Backups & Thresholds" />
|
<SectionHeader icon={HardDrive} label="Backups & Thresholds" />
|
||||||
{!backup.locked && (
|
{!backup.locked && (
|
||||||
<Row
|
<Row
|
||||||
label="Cloud Backup"
|
label="Recovery Vault"
|
||||||
value={backup.provider === 'disabled' ? 'Disabled' : backup.provider === 'sencho' ? 'Sencho Cloud' : `Custom S3${backup.autoUpload ? ' (auto)' : ''}`}
|
value={backup.provider === 'disabled' ? 'Disabled' : backup.provider === 'sencho' ? 'Recovery Vault' : `Custom S3${backup.autoUpload ? ' (auto)' : ''}`}
|
||||||
onClick={open('cloud-backup')}
|
onClick={open('cloud-backup')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ describe('ConfigurationStatus row visibility', () => {
|
|||||||
expect(screen.queryByText('Scheduled tasks')).toBeNull();
|
expect(screen.queryByText('Scheduled tasks')).toBeNull();
|
||||||
// Scan policies are free, so the row renders.
|
// Scan policies are free, so the row renders.
|
||||||
expect(screen.getByText('Scan policies')).toBeDefined();
|
expect(screen.getByText('Scan policies')).toBeDefined();
|
||||||
// Cloud Backup row is universal (Custom S3 is open to every tier).
|
// Recovery Vault row is universal because Custom S3 is open to every tier.
|
||||||
expect(screen.getByText('Cloud Backup')).toBeDefined();
|
expect(screen.getByText('Recovery Vault')).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows every row when the payload reports nothing locked', () => {
|
it('shows every row when the payload reports nothing locked', () => {
|
||||||
@@ -118,7 +118,7 @@ describe('ConfigurationStatus row visibility', () => {
|
|||||||
expect(screen.getByText('Webhooks')).toBeDefined();
|
expect(screen.getByText('Webhooks')).toBeDefined();
|
||||||
expect(screen.getByText('Scheduled tasks')).toBeDefined();
|
expect(screen.getByText('Scheduled tasks')).toBeDefined();
|
||||||
expect(screen.getByText('Scan policies')).toBeDefined();
|
expect(screen.getByText('Scan policies')).toBeDefined();
|
||||||
expect(screen.getByText('Cloud Backup')).toBeDefined();
|
expect(screen.getByText('Recovery Vault')).toBeDefined();
|
||||||
// SSO label maps the provider to a friendly name.
|
// SSO label maps the provider to a friendly name.
|
||||||
expect(screen.getByText('Google')).toBeDefined();
|
expect(screen.getByText('Google')).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ const BASE_PROVIDER_OPTIONS = [
|
|||||||
{ value: 'custom', label: 'Custom S3 (BYOB)' },
|
{ value: 'custom', label: 'Custom S3 (BYOB)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const SENCHO_PROVIDER_OPTION = { value: 'sencho', label: 'Sencho Cloud Backup (included)' };
|
const SENCHO_PROVIDER_OPTION = { value: 'sencho', label: 'Recovery Vault (included)' };
|
||||||
|
|
||||||
const PANEL_CLASS = 'rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4 space-y-3';
|
const PANEL_CLASS = 'rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4 space-y-3';
|
||||||
|
|
||||||
@@ -215,7 +215,7 @@ export function CloudBackupSection() {
|
|||||||
const res = await apiFetch('/cloud-backup/provision', { method: 'POST' });
|
const res = await apiFetch('/cloud-backup/provision', { method: 'POST' });
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok || data?.error) throw new Error(data?.error || 'Provisioning failed.');
|
if (!res.ok || data?.error) throw new Error(data?.error || 'Provisioning failed.');
|
||||||
toast.success('Sencho Cloud Backup activated.');
|
toast.success('Recovery Vault activated.');
|
||||||
setSenchoProvisioned(true);
|
setSenchoProvisioned(true);
|
||||||
await Promise.all([loadConfig(), loadUsage(), loadSnapshots()]);
|
await Promise.all([loadConfig(), loadUsage(), loadSnapshots()]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -316,7 +316,7 @@ export function CloudBackupSection() {
|
|||||||
<div className={PANEL_CLASS}>
|
<div className={PANEL_CLASS}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Cloud className="w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
|
<Cloud className="w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
|
||||||
<span className="font-medium text-sm">Activate Sencho Cloud Backup</span>
|
<span className="font-medium text-sm">Activate Recovery Vault</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Activates a 500 MB allowance backed by Cloudflare R2, scoped to this Admiral license.
|
Activates a 500 MB allowance backed by Cloudflare R2, scoped to this Admiral license.
|
||||||
@@ -333,7 +333,7 @@ export function CloudBackupSection() {
|
|||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CheckCircle2 className="w-4 h-4 text-success" strokeWidth={1.5} />
|
<CheckCircle2 className="w-4 h-4 text-success" strokeWidth={1.5} />
|
||||||
<span className="font-medium text-sm">Sencho Cloud Backup</span>
|
<span className="font-medium text-sm">Recovery Vault</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button size="sm" variant="outline" onClick={handleTest} disabled={testing}>
|
<Button size="sm" variant="outline" onClick={handleTest} disabled={testing}>
|
||||||
@@ -371,7 +371,7 @@ export function CloudBackupSection() {
|
|||||||
<div className="flex items-start gap-2 rounded-lg border border-glass-border bg-muted/30 px-3 py-2.5">
|
<div className="flex items-start gap-2 rounded-lg border border-glass-border bg-muted/30 px-3 py-2.5">
|
||||||
<Cloud className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" strokeWidth={1.5} />
|
<Cloud className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" strokeWidth={1.5} />
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Auto-upload is on for Sencho Cloud Backup. Every fleet snapshot is replicated within seconds.
|
Auto-upload is on for Recovery Vault. Every fleet snapshot is replicated within seconds.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { toast } from '@/components/ui/toast-store';
|
import { toast } from '@/components/ui/toast-store';
|
||||||
@@ -13,8 +13,44 @@ import { SettingsSection } from './SettingsSection';
|
|||||||
import { SettingsField } from './SettingsField';
|
import { SettingsField } from './SettingsField';
|
||||||
import { SettingsActions, SettingsPrimaryButton } from './SettingsActions';
|
import { SettingsActions, SettingsPrimaryButton } from './SettingsActions';
|
||||||
import { useMastheadStats } from './MastheadStatsContext';
|
import { useMastheadStats } from './MastheadStatsContext';
|
||||||
|
import { ConfirmModal } from '@/components/ui/modal';
|
||||||
|
|
||||||
const PRICING_URL = 'https://sencho.io/pricing';
|
const PRICING_URL = 'https://sencho.io/pricing';
|
||||||
|
const SOURCE_URL = 'https://github.com/Studio-Saelix/sencho';
|
||||||
|
|
||||||
|
type ImageChannel = 'community' | 'hardened' | 'unknown';
|
||||||
|
|
||||||
|
interface ImageOperation {
|
||||||
|
operationId: string;
|
||||||
|
state: 'pending_pull' | 'pulling' | 'patching' | 'recreating' | 'succeeded' | 'failed';
|
||||||
|
failureCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImageChannelStatus {
|
||||||
|
channel: ImageChannel;
|
||||||
|
composeImageRef?: string;
|
||||||
|
operation?: ImageOperation | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HardenedPreflight {
|
||||||
|
preflightFingerprint: string;
|
||||||
|
currentImageRef: string;
|
||||||
|
allowedImageRef: string;
|
||||||
|
composeFilePath: string;
|
||||||
|
pinKind: string;
|
||||||
|
localRegistryAccess: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatChannel(channel: ImageChannel): string {
|
||||||
|
switch (channel) {
|
||||||
|
case 'community':
|
||||||
|
return 'Community';
|
||||||
|
case 'hardened':
|
||||||
|
return 'Hardened';
|
||||||
|
default:
|
||||||
|
return 'Custom';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getTierDisplayName(tier?: string, status?: string): string {
|
function getTierDisplayName(tier?: string, status?: string): string {
|
||||||
if (tier === 'paid' && status === 'trial') return 'Sencho Admiral (Trial)';
|
if (tier === 'paid' && status === 'trial') return 'Sencho Admiral (Trial)';
|
||||||
@@ -32,6 +68,83 @@ export function LicenseSection() {
|
|||||||
const [isActivating, setIsActivating] = useState(false);
|
const [isActivating, setIsActivating] = useState(false);
|
||||||
const [isDeactivating, setIsDeactivating] = useState(false);
|
const [isDeactivating, setIsDeactivating] = useState(false);
|
||||||
const [billingLoading, setBillingLoading] = useState(false);
|
const [billingLoading, setBillingLoading] = useState(false);
|
||||||
|
const [channelStatus, setChannelStatus] = useState<ImageChannelStatus | null>(null);
|
||||||
|
const [acknowledging, setAcknowledging] = useState(false);
|
||||||
|
const [preflight, setPreflight] = useState<HardenedPreflight | null>(null);
|
||||||
|
const [preflightLoading, setPreflightLoading] = useState(false);
|
||||||
|
const [switching, setSwitching] = useState(false);
|
||||||
|
|
||||||
|
const loadImageChannel = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/license/image-channel/status', { localOnly: true });
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data?.error || 'Unable to load image channel status.');
|
||||||
|
setChannelStatus(data as ImageChannelStatus);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error((error as Error)?.message || 'Unable to load image channel status.');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadImageChannel();
|
||||||
|
}, [loadImageChannel]);
|
||||||
|
|
||||||
|
const acknowledgeOperation = async () => {
|
||||||
|
const operation = channelStatus?.operation;
|
||||||
|
if (!operation || operation.state !== 'failed') return;
|
||||||
|
setAcknowledging(true);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(`/license/image-channel/operations/${operation.operationId}/acknowledge`, {
|
||||||
|
method: 'POST',
|
||||||
|
localOnly: true,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data?.error || 'Unable to acknowledge the failed operation.');
|
||||||
|
}
|
||||||
|
toast.success('Failed image operation acknowledged.');
|
||||||
|
await loadImageChannel();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error((error as Error)?.message || 'Unable to acknowledge the failed operation.');
|
||||||
|
} finally {
|
||||||
|
setAcknowledging(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openPreflight = async () => {
|
||||||
|
setPreflightLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/license/image-channel/preflight', { method: 'POST', localOnly: true });
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data?.error || 'Hardened Build access is unavailable.');
|
||||||
|
setPreflight(data as HardenedPreflight);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error((error as Error)?.message || 'Hardened Build access is unavailable.');
|
||||||
|
} finally {
|
||||||
|
setPreflightLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const switchToHardened = async () => {
|
||||||
|
if (!preflight) return;
|
||||||
|
setSwitching(true);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/license/image-channel/switch', {
|
||||||
|
method: 'POST',
|
||||||
|
localOnly: true,
|
||||||
|
body: JSON.stringify({ preflightFingerprint: preflight.preflightFingerprint }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data?.error || 'Image channel switch could not start.');
|
||||||
|
toast.success('Hardened Build switch initiated.');
|
||||||
|
setPreflight(null);
|
||||||
|
await loadImageChannel();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error((error as Error)?.message || 'Image channel switch could not start.');
|
||||||
|
} finally {
|
||||||
|
setSwitching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const openBillingPortal = async () => {
|
const openBillingPortal = async () => {
|
||||||
setBillingLoading(true);
|
setBillingLoading(true);
|
||||||
@@ -116,6 +229,55 @@ export function LicenseSection() {
|
|||||||
</div>
|
</div>
|
||||||
</SettingsField>
|
</SettingsField>
|
||||||
|
|
||||||
|
{!isPaid ? (
|
||||||
|
<SettingsField label="License" helper="Sencho Community is released under AGPLv3.">
|
||||||
|
<a href={SOURCE_URL} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1.5 text-sm text-brand hover:underline">
|
||||||
|
View source
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
</a>
|
||||||
|
</SettingsField>
|
||||||
|
) : (
|
||||||
|
<SettingsField label="Recovery Vault" helper="Your Admiral subscription includes Recovery Vault entitlement.">
|
||||||
|
<span className="inline-flex items-center gap-2 text-sm text-success">
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
Included
|
||||||
|
</span>
|
||||||
|
</SettingsField>
|
||||||
|
)}
|
||||||
|
{isPaid && channelStatus?.channel !== 'hardened' ? (
|
||||||
|
<SettingsField label="Hardened Build" helper="Review entitlement and registry access before changing image channels.">
|
||||||
|
<SettingsPrimaryButton size="sm" onClick={openPreflight} disabled={preflightLoading}>
|
||||||
|
{preflightLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
|
||||||
|
Switch to Hardened
|
||||||
|
</SettingsPrimaryButton>
|
||||||
|
</SettingsField>
|
||||||
|
) : null}
|
||||||
|
<SettingsField
|
||||||
|
label="Current image"
|
||||||
|
helper={channelStatus?.channel === 'hardened' && !channelStatus.composeImageRef
|
||||||
|
? 'Hardened image details are available to administrators only.'
|
||||||
|
: 'Current image channel for this control plane.'}
|
||||||
|
>
|
||||||
|
<span className="font-mono text-xs text-stat-value break-all">
|
||||||
|
{channelStatus?.composeImageRef ?? formatChannel(channelStatus?.channel ?? 'unknown')}
|
||||||
|
</span>
|
||||||
|
</SettingsField>
|
||||||
|
<SettingsField label="Channel">
|
||||||
|
<span className="text-sm text-stat-value">{formatChannel(channelStatus?.channel ?? 'unknown')}</span>
|
||||||
|
</SettingsField>
|
||||||
|
{channelStatus?.operation?.state === 'failed' ? (
|
||||||
|
<SettingsField
|
||||||
|
label="Image operation"
|
||||||
|
helper={channelStatus.operation.failureCode || 'The image operation failed before completion.'}
|
||||||
|
tone="error"
|
||||||
|
>
|
||||||
|
<Button variant="outline" size="sm" onClick={acknowledgeOperation} disabled={acknowledging}>
|
||||||
|
{acknowledging ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
|
||||||
|
Acknowledge failure
|
||||||
|
</Button>
|
||||||
|
</SettingsField>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{license?.status === 'active' && license.customerName ? (
|
{license?.status === 'active' && license.customerName ? (
|
||||||
<SettingsField label="Customer">
|
<SettingsField label="Customer">
|
||||||
<span className="text-sm text-stat-value">{license.customerName}</span>
|
<span className="text-sm text-stat-value">{license.customerName}</span>
|
||||||
@@ -268,6 +430,27 @@ export function LicenseSection() {
|
|||||||
</div>
|
</div>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
) : null}
|
) : null}
|
||||||
|
<ConfirmModal
|
||||||
|
open={preflight !== null}
|
||||||
|
onOpenChange={(open) => !open && setPreflight(null)}
|
||||||
|
kicker="ADMIRAL ACCOUNT · HARDENED BUILD"
|
||||||
|
title="Review image switch"
|
||||||
|
confirmLabel={switching ? 'Switching' : 'Confirm switch'}
|
||||||
|
confirming={switching}
|
||||||
|
onConfirm={switchToHardened}
|
||||||
|
hint="BACK UP FIRST"
|
||||||
|
>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 font-mono text-xs">
|
||||||
|
<dt className="text-stat-subtitle">CURRENT</dt><dd className="break-all text-stat-value">{preflight?.currentImageRef}</dd>
|
||||||
|
<dt className="text-stat-subtitle">TARGET</dt><dd className="break-all text-stat-value">{preflight?.allowedImageRef}</dd>
|
||||||
|
<dt className="text-stat-subtitle">REGISTRY</dt><dd className="text-stat-value">{preflight?.localRegistryAccess}</dd>
|
||||||
|
<dt className="text-stat-subtitle">COMPOSE PATH</dt><dd className="break-all text-stat-value">{preflight?.composeFilePath}</dd>
|
||||||
|
<dt className="text-stat-subtitle">PIN KIND</dt><dd className="text-stat-value">{preflight?.pinKind}</dd>
|
||||||
|
</dl>
|
||||||
|
<p className="text-stat-subtitle">Create a backup first. To roll back, restore the prior image reference in the compose file and recreate the service.</p>
|
||||||
|
</div>
|
||||||
|
</ConfirmModal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function SupportSection() {
|
|||||||
<ResourceLink
|
<ResourceLink
|
||||||
icon={<Mail className="w-4 h-4" />}
|
icon={<Mail className="w-4 h-4" />}
|
||||||
title="Priority email support"
|
title="Priority email support"
|
||||||
blurb="Direct support with responses within 24 hours"
|
blurb="Monday to Friday, 09:00 to 17:00 America/New_York. We aim to first-respond within one business day. This is not a contractual SLA or 24/7 service."
|
||||||
href="mailto:support@sencho.io"
|
href="mailto:support@sencho.io"
|
||||||
external={false}
|
external={false}
|
||||||
/>
|
/>
|
||||||
@@ -75,8 +75,8 @@ export function SupportSection() {
|
|||||||
{!isPaid && (
|
{!isPaid && (
|
||||||
<SettingsCallout
|
<SettingsCallout
|
||||||
icon={<Crown className="h-4 w-4" />}
|
icon={<Crown className="h-4 w-4" />}
|
||||||
title="Need faster support?"
|
title="Need direct support?"
|
||||||
subtitle="Admiral includes direct email support and priority issue handling."
|
subtitle="Admiral includes priority email support during published support hours."
|
||||||
action={
|
action={
|
||||||
<SettingsPrimaryButton
|
<SettingsPrimaryButton
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -70,9 +70,9 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
|||||||
{
|
{
|
||||||
id: 'license',
|
id: 'license',
|
||||||
group: 'access',
|
group: 'access',
|
||||||
label: 'License',
|
label: 'Admiral Account',
|
||||||
description: 'Activation key, plan tier, and seat allocation.',
|
description: 'Edition, Admiral subscription, assurance entitlements, and image channel.',
|
||||||
keywords: ['key', 'activation', 'tier', 'plan', 'seats', 'billing'],
|
keywords: ['admiral', 'assurance', 'hardened', 'agpl', 'license', 'activation', 'subscription', 'billing'],
|
||||||
tier: null,
|
tier: null,
|
||||||
scope: 'global',
|
scope: 'global',
|
||||||
hiddenOnRemote: true,
|
hiddenOnRemote: true,
|
||||||
@@ -154,9 +154,9 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
|||||||
{
|
{
|
||||||
id: 'cloud-backup',
|
id: 'cloud-backup',
|
||||||
group: 'infrastructure',
|
group: 'infrastructure',
|
||||||
label: 'Cloud Backup',
|
label: 'Recovery Vault',
|
||||||
description: 'Mirror fleet snapshots to Sencho Cloud Backup or any S3-compatible storage.',
|
description: 'Mirror fleet snapshots to Recovery Vault or any S3-compatible storage.',
|
||||||
keywords: ['cloud', 'backup', 'snapshot', 's3', 'r2', 'minio', 'storage', 'offsite'],
|
keywords: ['recovery', 'vault', 'cloud', 'backup', 'snapshot', 's3', 'r2', 'minio', 'storage', 'offsite'],
|
||||||
tier: null,
|
tier: null,
|
||||||
scope: 'global',
|
scope: 'global',
|
||||||
adminOnly: true,
|
adminOnly: true,
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Skeleton only. Do not create Studio-Saelix/sencho-hardened until explicit authorization is given.
|
||||||
|
name: Publish Hardened Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
source_ref:
|
||||||
|
description: Immutable source commit SHA
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
base_image_digest:
|
||||||
|
description: Immutable base-image digest
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
policy_revision:
|
||||||
|
description: Immutable scanner and OpenVEX policy revision
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate-inputs:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
outputs:
|
||||||
|
source_ref: ${{ steps.inputs.outputs.source_ref }}
|
||||||
|
base_image_digest: ${{ steps.inputs.outputs.base_image_digest }}
|
||||||
|
policy_revision: ${{ steps.inputs.outputs.policy_revision }}
|
||||||
|
steps:
|
||||||
|
# Replace every placeholder with a verified, full commit SHA before enabling.
|
||||||
|
- uses: actions/checkout@<FULL_40_CHARACTER_COMMIT_SHA>
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.source_ref }}
|
||||||
|
persist-credentials: false
|
||||||
|
- id: inputs
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
[[ "${{ inputs.source_ref }}" =~ ^[0-9a-f]{40}$ ]]
|
||||||
|
[[ "${{ inputs.base_image_digest }}" =~ ^sha256:[0-9a-f]{64}$ ]]
|
||||||
|
[[ "${{ inputs.policy_revision }}" =~ ^[0-9a-f]{40}$ ]]
|
||||||
|
printf 'source_ref=%s\n' "${{ inputs.source_ref }}" >> "$GITHUB_OUTPUT"
|
||||||
|
printf 'base_image_digest=%s\n' "${{ inputs.base_image_digest }}" >> "$GITHUB_OUTPUT"
|
||||||
|
printf 'policy_revision=%s\n' "${{ inputs.policy_revision }}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
build-and-assess:
|
||||||
|
needs: validate-inputs
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
platform: [linux/amd64, linux/arm64]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@<FULL_40_CHARACTER_COMMIT_SHA>
|
||||||
|
with:
|
||||||
|
ref: ${{ needs.validate-inputs.outputs.source_ref }}
|
||||||
|
persist-credentials: false
|
||||||
|
# TODO: install pinned Buildx and build from the source ref, lockfile,
|
||||||
|
# base-image digest, and platform. Export an OCI layout for scanning.
|
||||||
|
- name: Build immutable platform image
|
||||||
|
run: exit 1 # TODO: replace with a pinned Buildx invocation
|
||||||
|
# TODO: run a pinned Trivy image scan and evaluate the approved OpenVEX file.
|
||||||
|
- name: Gate Trivy and OpenVEX findings
|
||||||
|
run: exit 1 # TODO: fail exploitable critical or high findings
|
||||||
|
# TODO: generate SPDX and CycloneDX SBOMs plus SLSA provenance.
|
||||||
|
- name: Generate supply-chain attestations
|
||||||
|
run: exit 1 # TODO: replace with pinned tooling
|
||||||
|
# TODO: sign the immutable platform digest with Cosign keyless OIDC.
|
||||||
|
- name: Sign platform digest
|
||||||
|
run: exit 1 # TODO: replace with pinned Cosign tooling
|
||||||
|
|
||||||
|
publish-manifest:
|
||||||
|
needs: build-and-assess
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
id-token: write
|
||||||
|
attestations: write
|
||||||
|
environment: hardened-production
|
||||||
|
steps:
|
||||||
|
# TODO: assemble the approved multi-architecture manifest, attach SBOM
|
||||||
|
# and provenance, sign it, then publish only this immutable target:
|
||||||
|
# ghcr.io/studio-saelix/sencho-hardened
|
||||||
|
- name: Publish approved manifest
|
||||||
|
run: exit 1 # TODO: replace after explicit authorization
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Hardened Build publish policy
|
||||||
|
|
||||||
|
> Do not create `Studio-Saelix/sencho-hardened` until explicit authorization is given.
|
||||||
|
|
||||||
|
## Publish prerequisites
|
||||||
|
|
||||||
|
1. Build from an approved immutable source revision.
|
||||||
|
2. Pin all base images by digest and retain the resolved lockfile.
|
||||||
|
3. Produce the required architecture images before creating the manifest.
|
||||||
|
4. Scan each architecture image and evaluate findings against the approved OpenVEX policy.
|
||||||
|
5. Block publication when a known critical or high finding is exploitable and lacks an approved remediation decision.
|
||||||
|
6. Generate and attach SBOM and provenance records.
|
||||||
|
7. Sign the immutable manifest digest with Cosign.
|
||||||
|
8. Publish the matching AGPLv3 corresponding source notice with the release metadata.
|
||||||
|
|
||||||
|
## Advisory and remediation
|
||||||
|
|
||||||
|
An advisory can be recommended, cautionary, blocked, or unavailable. A blocked advisory prevents a new published tag. A newly disclosed issue in an already published image is triaged, recorded in OpenVEX where appropriate, remediated in a new immutable build, and communicated through the advisory channel.
|
||||||
|
|
||||||
|
Rollback repoints the supported tag to a prior signed digest only after confirming that its advisory state is suitable for use. Rollback does not overwrite an existing immutable digest.
|
||||||
|
|
||||||
|
## Evidence retained per publication
|
||||||
|
|
||||||
|
- Source revision, dependency lockfile digest, and base-image digests
|
||||||
|
- Scanner reports and OpenVEX version
|
||||||
|
- SBOM and provenance attestations
|
||||||
|
- Cosign signature verification output
|
||||||
|
- Publication timestamp, manifest digest, and advisory decision
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Hardened Build publishing skeleton
|
||||||
|
|
||||||
|
> Do not create `Studio-Saelix/sencho-hardened` until explicit authorization is given.
|
||||||
|
|
||||||
|
This local skeleton defines the intended publishing controls for the Admiral-only Hardened Build image channel. It is preparation material, not a publishing system and not a private-source product.
|
||||||
|
|
||||||
|
The Hardened Build image is a separately published container artifact. Sencho remains AGPLv3, and every release must make the corresponding source available under the AGPLv3 terms.
|
||||||
|
|
||||||
|
## Assurance statement
|
||||||
|
|
||||||
|
At publish time, the pipeline must establish that the image has zero known critical or high exploitable CVEs according to the approved scanner policy and OpenVEX assessment. This is a point-in-time statement, not a guarantee that no vulnerability will be discovered later.
|
||||||
|
|
||||||
|
Each published image must include:
|
||||||
|
|
||||||
|
- A multi-architecture manifest and immutable digest
|
||||||
|
- An SBOM
|
||||||
|
- Build provenance
|
||||||
|
- A Cosign signature and verification material
|
||||||
|
- OpenVEX statements for assessed findings
|
||||||
|
- A documented CVE remediation and republish process
|
||||||
|
|
||||||
|
## Channel separation
|
||||||
|
|
||||||
|
Community images continue through the public channels. Hardened Build inputs are immutable source revision, lockfile, base-image digest, policy revision, and architecture list. The resulting image is published only to `ghcr.io/studio-saelix/sencho-hardened` after all gates pass.
|
||||||
|
|
||||||
|
The workflow skeleton intentionally contains placeholders instead of live credentials, action revisions, or publishing configuration. Fill them only in an authorized private delivery repository.
|
||||||
|
|
||||||
|
See [PUBLISH_POLICY.md](PUBLISH_POLICY.md) for the release gate and remediation rules.
|
||||||
Reference in New Issue
Block a user