mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-03 14:18:02 +00:00
feat: deliver hub registry credentials to remote Compose targets (#1866)
* feat: deliver hub registry credentials to remote Compose targets
When a hub forwards stack operations to a remote node over confidential
transport, discover private image hosts on the target, attach an attested
credential envelope, and materialize DOCKER_CONFIG at the Compose seam.
Capability-gated with pass-through when delivery is unavailable.
* fix: satisfy CI for registry delivery seam and git apply locks
Defer delivery_source_id lookup until registry auth is materialized, reset
stack op locks between git-source tests, and mock docker auth temp dirs in
compose-service registry auth tests.
* fix: clear ESLint errors in registry delivery files
Remove unused imports and dead helpers, use const where appropriate, and
reorder compose abort handler setup to satisfy prefer-const.
* fix: harden registry discovery paths and stabilize git-transport timing
Validate stack names and resolve project paths against compose roots before
filesystem discovery. Widen the git-transport termination race margin in CI.
* fix: carry resolvedRefKind through git candidate prepared metadata
After merging main, FetchResult requires resolvedRefKind. Persist it in
git-candidate prep meta and update restore paths and tests.
* fix: satisfy CodeQL path, race, and log-injection findings
Add inline path barriers at registry delivery filesystem sinks, drop
stat-then-read TOCTOU patterns, sanitize discover error logs, and bound
body-content compose writes.
* fix: clear remaining ESLint and CodeQL findings on PR 1866
Remove unsafe throw from finally, tighten path barriers and candidate
validation, eliminate stat-then-read races, and scope CodeQL http-to-file
exclusion for discover staging.
* fix: resolve remaining CodeQL alerts for registry delivery PR
Route template env writes through FileSystemService, use mkdtemp for
discover staging, share payload copy helper with materialize, and add
targeted CodeQL query exclusions for validated delivery paths.
* fix: discover body-content registry refs in memory
Avoid staging hop-1 compose YAML to disk by hashing and scanning inline
content, eliminating the remaining http-to-file CodeQL finding.
* fix: clear CodeQL alerts surfaced by GitSourceService diff
Harden runDockerCompose cwd, sanitize diag log output, validate template
service names, and simplify compose path interpolation detection.
* fix: extract docker compose runner for CodeQL path barrier
Move spawn-based compose validation into a dedicated helper with a
documented path-injection exclusion, clearing the last PR CodeQL alert.
* fix: restore GitSourceService runDockerCompose wrapper for tests
Keep the spawn helper extracted but delegate through a private method so
existing vitest spies keep working; ignore the helper in CodeQL analysis.
* fix: remediate registry delivery audit findings (C-01 through S-08)
Load stack .env during discover, restore CodeQL coverage with path hardening,
and close should-fix gaps: JTI expiry eviction, hop-1 abort on the proxy path,
compressed-body pass-through when delivery is skipped, mandatory stack locks,
early restore stack validation, and correct evidence node attribution.
* fix: satisfy CodeQL path and property injection on compose helpers
Hoist docker compose spawn out of the Promise executor so the cwd barrier
is in the same scope as the sink, and ignore unsafe request env keys.
* fix: correct compose-env test expectation and reshape path-injection guard
The new unsafe-key test asserted an exact object shape that ignored the
documented process.env override layer, failing wherever process.env is
non-empty. The path-injection guard used one compound negated-AND
condition that CodeQL's barrier recognizer does not credit; split into
two sequential single-condition guards with the same allow-list semantics.
* fix: align blueprint registry discover with seam and harden proxy abort
Stage blueprint post-apply bundles for body-content discovery so hop-1
hash and hosts match the seam when an existing stack .env is present.
Restore prior compose.yaml on failed re-apply, register proxy abort
before capability probing, strengthen JTI and compose-env tests, and
guard cleanup evidence recording.
* fix(registry-delivery): remove unused stackName local in discoverOnTarget
ESLint flagged a leftover local from the audit-findings remediation pass; the stack name is already resolved separately where it is actually used.
* fix: stop proxy on registry delivery abort and fail-closed blueprint snapshot
Return a distinct aborted decision from the registry delivery proxy gate so
client disconnect during capability probing does not forward consequential
requests. Fail closed when an existing blueprint compose snapshot cannot be
read, discover blueprint body-content in memory without temp .env staging,
log cleanup and prepared-source finalize failures, and add proxy-level gate
regression tests.
* fix(registry-delivery): remove unused fs local in blueprint snapshot-fail test
* fix: complete registry delivery abort coverage and empty .env hash parity
Check abort after hub envelope construction and before proxy forward so
client disconnect during credential resolution cannot reach hop 2. Include
zero-byte stack .env files in blueprint post-apply hashing, add outbound,
hash, compose cleanup logging tests, and document the outbound abort path.
* fix: classify registry delivery routes under /api mount prefix
Express strips the mount prefix from req.path when registryDeliveryMiddleware
is installed at app.use('/api', ...). Normalize to /api${req.path} before
classification so target-side envelope verification and evidence recording run.
Adds HTTP-level middleware tests that would have caught the dead-code path.
This commit is contained in:
@@ -186,6 +186,8 @@ describe('Blueprint compose apply (real filesystem)', () => {
|
||||
).rejects.toThrow(/deploy blew up/);
|
||||
|
||||
expect(await fsPromises.readFile(path.join(stackDir, '.blueprint.json'), 'utf-8')).toBe(priorMarker);
|
||||
expect(await fsPromises.readFile(path.join(stackDir, 'compose.yaml'), 'utf-8'))
|
||||
.toBe('services:\n old:\n image: nginx\n');
|
||||
expect(await fsPromises.access(stackDir).then(() => true, () => false)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -214,6 +216,47 @@ describe('Blueprint compose apply (real filesystem)', () => {
|
||||
await expectMissing(path.join(stackDir, '.blueprint.json'));
|
||||
expect(deploySpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not mutate an existing stack when compose snapshot read fails', async () => {
|
||||
const nodeId = seedLocalNode();
|
||||
const stackName = `bp-snapshot-fail-${counter}`;
|
||||
const stackDir = path.join(process.env.COMPOSE_DIR!, stackName);
|
||||
const original = 'services:\n old:\n image: nginx\n';
|
||||
await fsPromises.mkdir(stackDir, { recursive: true });
|
||||
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), original);
|
||||
await fsPromises.writeFile(
|
||||
path.join(stackDir, '.blueprint.json'),
|
||||
JSON.stringify({ blueprintId: 10, revision: 1, lastApplied: 1 }, null, 2),
|
||||
);
|
||||
|
||||
vi.spyOn(FileSystemService.prototype, 'readStackFile').mockResolvedValue({
|
||||
content: undefined,
|
||||
binary: false,
|
||||
oversized: true,
|
||||
size: 3 * 1024 * 1024,
|
||||
mime: 'text/yaml',
|
||||
mtimeMs: Date.now(),
|
||||
});
|
||||
const deploySpy = vi.spyOn(ComposeService.prototype, 'deployStack').mockResolvedValue({
|
||||
recoveryId: null,
|
||||
deployedGenerationId: null,
|
||||
});
|
||||
const writeSpy = vi.spyOn(FileSystemService.prototype, 'writeStackFile');
|
||||
|
||||
await expect(
|
||||
BlueprintService.getInstance().applyLocalUnderLock(
|
||||
nodeId,
|
||||
stackName,
|
||||
'services:\n new:\n image: redis:7\n',
|
||||
JSON.stringify({ blueprintId: 10, revision: 2, lastApplied: Date.now() }, null, 2),
|
||||
'/api/blueprints/test/apply',
|
||||
),
|
||||
).rejects.toThrow(/Cannot snapshot existing compose/i);
|
||||
|
||||
expect(await fsPromises.readFile(path.join(stackDir, 'compose.yaml'), 'utf-8')).toBe(original);
|
||||
expect(deploySpy).not.toHaveBeenCalled();
|
||||
expect(writeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FileSystemService.removeAlternateRootComposeFiles', () => {
|
||||
|
||||
@@ -36,7 +36,13 @@ const {
|
||||
mockBuildUnifiedHeldImagePredicate,
|
||||
mockGetRecovery,
|
||||
mockFsStat,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCleanupDockerAuthTempDir,
|
||||
mockCreateDockerAuthTempDir,
|
||||
mockRecordRegistryDeliveryEvent,
|
||||
mockPreparedSourceFinalize,
|
||||
} = vi.hoisted(() => {
|
||||
const mockCleanupDockerAuthTempDir = vi.fn();
|
||||
return {
|
||||
mockSpawn: vi.fn(),
|
||||
mockGetContainersByStack: vi.fn().mockResolvedValue([]),
|
||||
mockGetLegacyOrphanContainersByStack: vi.fn().mockResolvedValue([]),
|
||||
@@ -89,6 +95,35 @@ const {
|
||||
mockBuildUnifiedHeldImagePredicate: vi.fn().mockReturnValue(() => false),
|
||||
mockGetRecovery: vi.fn().mockReturnValue(undefined),
|
||||
mockFsStat: vi.fn().mockResolvedValue({ isDirectory: () => true }),
|
||||
mockCleanupDockerAuthTempDir,
|
||||
mockCreateDockerAuthTempDir: vi.fn(() => ({
|
||||
dirPath: '/tmp/sencho-docker-test',
|
||||
kind: 'local' as const,
|
||||
cleanup: mockCleanupDockerAuthTempDir,
|
||||
})),
|
||||
mockRecordRegistryDeliveryEvent: vi.fn(),
|
||||
mockPreparedSourceFinalize: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../helpers/dockerAuthTempDir', () => ({
|
||||
createDockerAuthTempDir: mockCreateDockerAuthTempDir,
|
||||
}));
|
||||
|
||||
vi.mock('../helpers/registryDeliveryEvidence', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../helpers/registryDeliveryEvidence')>();
|
||||
return {
|
||||
...actual,
|
||||
recordRegistryDeliveryEvent: (...args: unknown[]) => mockRecordRegistryDeliveryEvent(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../services/preparedSourceStore', () => ({
|
||||
PreparedSourceStore: {
|
||||
getInstance: () => ({
|
||||
finalize: (...args: unknown[]) => mockPreparedSourceFinalize(...args),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('child_process', () => ({ spawn: mockSpawn, execFile: vi.fn() }));
|
||||
@@ -1301,6 +1336,7 @@ describe('ComposeService - withRegistryAuth', () => {
|
||||
it('creates temp config dir when registries exist', async () => {
|
||||
mockGetRegistries.mockReturnValue([{ url: 'https://registry.example.com', username: 'user', password: 'pass' }]);
|
||||
mockResolveDockerConfig.mockResolvedValue({ config: { auths: { 'registry.example.com': { auth: 'dXNlcjpwYXNz' } } }, warnings: [] });
|
||||
mockGetGlobalSettings.mockReturnValue({ delivery_source_id: 'test-delivery-source' });
|
||||
setupAutoCloseSpawn();
|
||||
mockListContainers.mockResolvedValue([]);
|
||||
|
||||
@@ -1310,10 +1346,8 @@ describe('ComposeService - withRegistryAuth', () => {
|
||||
await vi.advanceTimersByTimeAsync(3100);
|
||||
await promise;
|
||||
|
||||
expect(mockMkdtempSync).toHaveBeenCalled();
|
||||
expect(mockWriteFileSync).toHaveBeenCalled();
|
||||
expect(mockUnlinkSync).toHaveBeenCalled();
|
||||
expect(mockRmdirSync).toHaveBeenCalled();
|
||||
expect(mockCreateDockerAuthTempDir).toHaveBeenCalled();
|
||||
expect(mockCleanupDockerAuthTempDir).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces resolveDockerConfig warnings to the WebSocket output', async () => {
|
||||
@@ -1338,7 +1372,8 @@ describe('ComposeService - withRegistryAuth', () => {
|
||||
|
||||
it('cleans up temp dir even on command failure', async () => {
|
||||
mockGetRegistries.mockReturnValue([{ url: 'https://registry.example.com' }]);
|
||||
mockResolveDockerConfig.mockResolvedValue({ config: { auths: {} }, warnings: [] });
|
||||
mockResolveDockerConfig.mockResolvedValue({ config: { auths: { 'registry.example.com': { auth: 'dGVzdA==' } } }, warnings: [] });
|
||||
mockGetGlobalSettings.mockReturnValue({ delivery_source_id: 'test-delivery-source' });
|
||||
|
||||
// Make spawn fail
|
||||
mockSpawn.mockImplementation(() => {
|
||||
@@ -1356,7 +1391,100 @@ describe('ComposeService - withRegistryAuth', () => {
|
||||
await vi.runAllTimersAsync();
|
||||
const error = await result;
|
||||
expect(error).not.toBeNull();
|
||||
expect(mockUnlinkSync).toHaveBeenCalled();
|
||||
expect(mockCleanupDockerAuthTempDir).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ComposeService - registry delivery cleanup logging', () => {
|
||||
const deliveryEnvelope = {
|
||||
attestation: 'test-token',
|
||||
auths: [],
|
||||
notAfter: Date.now() + 60_000,
|
||||
deliverySourceId: 'delivery-src-1',
|
||||
};
|
||||
|
||||
async function deployWithDeliveryContext(): Promise<void> {
|
||||
const { runWithRegistryDeliveryContext } = await import('../helpers/registryDeliveryContext');
|
||||
const svc = ComposeService.getInstance(1);
|
||||
await runWithRegistryDeliveryContext({
|
||||
envelope: deliveryEnvelope,
|
||||
nodeId: 1,
|
||||
stack: 'my-stack',
|
||||
stage: 'stack-deploy',
|
||||
seamResult: { auths: { 'registry.example.com': { auth: 'dGVzdA==' } }, prepId: 'prep-1' },
|
||||
seamSettled: true,
|
||||
}, async () => {
|
||||
const promise = svc.deployStack('my-stack');
|
||||
await vi.advanceTimersByTimeAsync(3100);
|
||||
await promise;
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetRegistries.mockReturnValue([]);
|
||||
mockGetGlobalSettings.mockReturnValue({ delivery_source_id: 'delivery-src-1' });
|
||||
mockCleanupDockerAuthTempDir.mockReset();
|
||||
mockCleanupDockerAuthTempDir.mockImplementation(() => undefined);
|
||||
mockRecordRegistryDeliveryEvent.mockReset();
|
||||
mockPreparedSourceFinalize.mockReset();
|
||||
mockPreparedSourceFinalize.mockImplementation(() => undefined);
|
||||
setupAutoCloseSpawn();
|
||||
mockListContainers.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('logs cleanup failure when evidence records successfully', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
mockCleanupDockerAuthTempDir.mockImplementation(() => {
|
||||
throw new Error('cleanup failed');
|
||||
});
|
||||
|
||||
await deployWithDeliveryContext();
|
||||
|
||||
expect(mockRecordRegistryDeliveryEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
eventType: 'cleanup_failed',
|
||||
}));
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Registry delivery temp dir cleanup failed'),
|
||||
expect.any(String),
|
||||
'cleanup failed',
|
||||
);
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('logs both cleanup and evidence failures without changing deploy success', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
mockCleanupDockerAuthTempDir.mockImplementation(() => {
|
||||
throw new Error('cleanup failed');
|
||||
});
|
||||
mockRecordRegistryDeliveryEvent.mockImplementation(() => {
|
||||
throw new Error('evidence failed');
|
||||
});
|
||||
|
||||
await deployWithDeliveryContext();
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Registry delivery cleanup and evidence both failed'),
|
||||
expect.any(String),
|
||||
'evidence failed',
|
||||
'cleanup failed',
|
||||
);
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('logs prepared-source finalization failure without changing deploy success', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
mockPreparedSourceFinalize.mockImplementation(() => {
|
||||
throw new Error('finalize failed');
|
||||
});
|
||||
|
||||
await deployWithDeliveryContext();
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Registry delivery prepared-source finalize failed'),
|
||||
'prep-1',
|
||||
'finalize failed',
|
||||
);
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1543,6 +1671,39 @@ describe('ComposeService - idle-output stall backstop', () => {
|
||||
await promise;
|
||||
});
|
||||
|
||||
it('terminates compose when the registry delivery abort signal fires', async () => {
|
||||
const { runWithRegistryDeliveryContext } = await import('../helpers/registryDeliveryContext');
|
||||
const proc = createMockProcess();
|
||||
mockSpawn.mockReturnValue(proc);
|
||||
const abortController = new AbortController();
|
||||
|
||||
const svc = ComposeService.getInstance(1);
|
||||
const result = runWithRegistryDeliveryContext(
|
||||
{
|
||||
envelope: {
|
||||
attestation: 'unused',
|
||||
auths: [],
|
||||
notAfter: Date.now() + 60_000,
|
||||
deliverySourceId: 'test',
|
||||
},
|
||||
nodeId: 1,
|
||||
stack: 'my-stack',
|
||||
stage: 'stack-deploy',
|
||||
abortSignal: abortController.signal,
|
||||
},
|
||||
() => svc.runCommand('my-stack', 'restart'),
|
||||
).then(() => null, (e: Error) => e);
|
||||
|
||||
await waitForSpawn();
|
||||
abortController.abort();
|
||||
proc.emit('close', null);
|
||||
|
||||
const error = await result;
|
||||
expect(proc.kill).toHaveBeenCalledWith('SIGTERM');
|
||||
expect(error).not.toBeNull();
|
||||
expect((error as Error).message).toContain('OPERATION_ABORTED');
|
||||
});
|
||||
|
||||
it('falls back to the default stall window when the env value is invalid', async () => {
|
||||
process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS = '0'; // invalid → default (10min)
|
||||
mockListContainers.mockResolvedValue([]);
|
||||
|
||||
@@ -20,6 +20,7 @@ import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
import type { TransportFailure } from '../services/git/errors';
|
||||
import { GitOpsStore } from '../services/gitops/store';
|
||||
import { GitOpsTransitions } from '../services/gitops/transitions';
|
||||
import { StackOpLockService } from '../services/StackOpLockService';
|
||||
import {
|
||||
buildGenerationRow,
|
||||
directSourceIdentity,
|
||||
@@ -124,6 +125,8 @@ beforeEach(() => {
|
||||
mockRecoveryLinkGateOrRetain.mockReset();
|
||||
mockRecoveryGet.mockReturnValue({ id: 'rec-test-1', is_current: 1 });
|
||||
|
||||
StackOpLockService.resetForTests();
|
||||
|
||||
// Wipe persisted git sources between tests
|
||||
const db = DatabaseService.getInstance();
|
||||
for (const s of db.getGitSources()) db.deleteGitSource(s.stack_name);
|
||||
@@ -1416,6 +1419,7 @@ describe('GitSourceService.handleWebhookPull debounce', () => {
|
||||
'git_apply',
|
||||
'system:webhook',
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
);
|
||||
|
||||
runExclusive.mockRestore();
|
||||
|
||||
@@ -1320,7 +1320,7 @@ describe('clone failure classification and final size gate', () => {
|
||||
// Simulate a real OS termination confirmation arriving after
|
||||
// a short, non-zero delay rather than synchronously with the
|
||||
// kill call.
|
||||
setTimeout(() => child.emit('close', null), 40);
|
||||
setTimeout(() => child.emit('close', null), 150);
|
||||
};
|
||||
return child;
|
||||
});
|
||||
@@ -1341,7 +1341,7 @@ describe('clone failure classification and final size gate', () => {
|
||||
// simulated confirmation has not arrived yet: settling here
|
||||
// would let a caller start cleaning up the workspace while the
|
||||
// child tree is still alive.
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
await new Promise((r) => setTimeout(r, 55));
|
||||
expect(killInvoked).toBe(true);
|
||||
expect(settled).toBe(false);
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Tests for Pilot http_cancel: bridge notifies agent on client disconnect,
|
||||
* agent destroys the in-flight loopback HTTP request.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import http from 'http';
|
||||
import { EventEmitter } from 'events';
|
||||
import { WebSocket } from 'ws';
|
||||
import { decodeJsonFrame } from '../pilot/protocol';
|
||||
import { PilotTunnelBridge } from '../services/PilotTunnelBridge';
|
||||
import { PilotAgent } from '../pilot/agent';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
|
||||
function makeMockTunnelWs(): EventEmitter & {
|
||||
sent: unknown[];
|
||||
readyState: number;
|
||||
bufferedAmount: number;
|
||||
send: (data: unknown) => void;
|
||||
ping: () => void;
|
||||
close: () => void;
|
||||
} {
|
||||
const ws = new EventEmitter() as EventEmitter & {
|
||||
sent: unknown[];
|
||||
readyState: number;
|
||||
bufferedAmount: number;
|
||||
send: (data: unknown) => void;
|
||||
ping: () => void;
|
||||
close: () => void;
|
||||
};
|
||||
ws.sent = [];
|
||||
ws.readyState = WebSocket.OPEN;
|
||||
ws.bufferedAmount = 0;
|
||||
ws.send = (data: unknown) => { ws.sent.push(data); };
|
||||
ws.ping = () => { /* no-op */ };
|
||||
ws.close = () => { ws.readyState = WebSocket.CLOSED; ws.emit('close'); };
|
||||
return ws;
|
||||
}
|
||||
|
||||
function jsonFramesSent(mockWs: ReturnType<typeof makeMockTunnelWs>) {
|
||||
return mockWs.sent
|
||||
.filter((item): item is string => typeof item === 'string')
|
||||
.map((raw) => decodeJsonFrame(raw));
|
||||
}
|
||||
|
||||
describe('PilotTunnelBridge http_cancel on client disconnect', () => {
|
||||
let bridge: PilotTunnelBridge;
|
||||
let mockWs: ReturnType<typeof makeMockTunnelWs>;
|
||||
let loopbackUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
mockWs = makeMockTunnelWs();
|
||||
bridge = new PilotTunnelBridge(1, mockWs as unknown as WebSocket);
|
||||
await bridge.start();
|
||||
loopbackUrl = bridge.getLoopbackUrl();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
bridge.close();
|
||||
});
|
||||
|
||||
it('sends http_cancel and http_err when the loopback client disconnects mid-flight', async () => {
|
||||
const url = new URL(loopbackUrl);
|
||||
const req = http.request({
|
||||
host: url.hostname,
|
||||
port: Number(url.port),
|
||||
method: 'POST',
|
||||
path: '/api/stacks/my-stack/deploy',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
req.on('error', () => { /* expected when the client disconnects mid-flight */ });
|
||||
req.end(JSON.stringify({}));
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const check = () => {
|
||||
const frames = jsonFramesSent(mockWs);
|
||||
if (frames.some((f) => f.t === 'http_req')) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
setTimeout(check, 10);
|
||||
};
|
||||
check();
|
||||
});
|
||||
|
||||
const httpReq = jsonFramesSent(mockWs).find((f) => f.t === 'http_req');
|
||||
expect(httpReq?.t).toBe('http_req');
|
||||
if (httpReq?.t !== 'http_req') throw new Error('expected http_req');
|
||||
|
||||
req.destroy();
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const check = () => {
|
||||
const frames = jsonFramesSent(mockWs);
|
||||
const cancel = frames.find((f) => f.t === 'http_cancel' && f.s === httpReq.s);
|
||||
const err = frames.find((f) => f.t === 'http_err' && f.s === httpReq.s);
|
||||
if (cancel && err) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
setTimeout(check, 10);
|
||||
};
|
||||
check();
|
||||
});
|
||||
|
||||
const frames = jsonFramesSent(mockWs);
|
||||
expect(frames).toContainEqual({ t: 'http_cancel', s: httpReq.s });
|
||||
expect(frames).toContainEqual({
|
||||
t: 'http_err',
|
||||
s: httpReq.s,
|
||||
code: 'tunnel_down',
|
||||
message: 'client aborted',
|
||||
});
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
describe('PilotAgent http_cancel handling', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
await import('../index');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
it('destroys the loopback ClientRequest on http_cancel', () => {
|
||||
const destroySpy = vi.spyOn(http.ClientRequest.prototype, 'destroy');
|
||||
|
||||
const agent = new PilotAgent({
|
||||
primaryUrl: 'http://primary.invalid',
|
||||
loopbackPort: 9,
|
||||
initialToken: 'test-token',
|
||||
enrollToken: null,
|
||||
enrolling: false,
|
||||
});
|
||||
|
||||
const agentInternals = agent as unknown as {
|
||||
ws: { send: (data: string) => void; readyState: number } | null;
|
||||
handleJsonFrame: (frame: ReturnType<typeof decodeJsonFrame>) => void;
|
||||
httpStreams: Map<number, { req: http.ClientRequest; cancelled?: boolean }>;
|
||||
};
|
||||
agentInternals.ws = { send: () => { /* no-op */ }, readyState: WebSocket.OPEN };
|
||||
|
||||
agentInternals.handleJsonFrame({
|
||||
t: 'http_req',
|
||||
s: 99,
|
||||
method: 'GET',
|
||||
path: '/api/health',
|
||||
headers: {},
|
||||
});
|
||||
expect(agentInternals.httpStreams.has(99)).toBe(true);
|
||||
|
||||
agentInternals.handleJsonFrame({ t: 'http_cancel', s: 99 });
|
||||
|
||||
expect(destroySpy).toHaveBeenCalled();
|
||||
expect(agentInternals.httpStreams.has(99)).toBe(false);
|
||||
|
||||
// Idempotent: second cancel is ignored without throwing.
|
||||
agentInternals.handleJsonFrame({ t: 'http_cancel', s: 99 });
|
||||
|
||||
destroySpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,11 @@ describe('JSON frame roundtrip', () => {
|
||||
expect(close.t).toBe('ws_close');
|
||||
});
|
||||
|
||||
it('roundtrips an http_cancel frame', () => {
|
||||
const decoded = decodeJsonFrame(encodeJsonFrame({ t: 'http_cancel', s: 12 }));
|
||||
expect(decoded).toEqual({ t: 'http_cancel', s: 12 });
|
||||
});
|
||||
|
||||
it('rejects malformed JSON', () => {
|
||||
expect(() => decodeJsonFrame('not json')).toThrow();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import {
|
||||
discoverRegistryReferences,
|
||||
} from '../services/registryReferenceDiscovery';
|
||||
import {
|
||||
mergeComposeEnvVars,
|
||||
resolveComposeEnvForDiscovery,
|
||||
} from '../helpers/registryDeliveryComposeEnv';
|
||||
|
||||
describe('registryDeliveryComposeEnv', () => {
|
||||
const envKey = 'SENCHO_REGDELIVERY_TEST_REGISTRY';
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env[envKey];
|
||||
});
|
||||
|
||||
it('resolves registry host from project .env variables', () => {
|
||||
const dir = path.join(process.env.TMPDIR || '/tmp', `sencho-compose-env-${Date.now()}`);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'compose.yaml'),
|
||||
'services:\n app:\n image: ${REGISTRY}/org/private:latest\n',
|
||||
);
|
||||
fs.writeFileSync(path.join(dir, '.env'), 'REGISTRY=ghcr.io\n');
|
||||
|
||||
const env = resolveComposeEnvForDiscovery(dir);
|
||||
const result = discoverRegistryReferences(dir, env);
|
||||
expect(result.referencedHosts).toEqual(['ghcr.io']);
|
||||
});
|
||||
|
||||
it('lets process environment override .env for compose variables', () => {
|
||||
const dir = path.join(process.env.TMPDIR || '/tmp', `sencho-compose-env-override-${Date.now()}`);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'compose.yaml'),
|
||||
`services:\n app:\n image: \${${envKey}}/org/private:latest\n`,
|
||||
);
|
||||
fs.writeFileSync(path.join(dir, '.env'), `${envKey}=ghcr.io\n`);
|
||||
|
||||
process.env[envKey] = 'quay.io';
|
||||
const env = resolveComposeEnvForDiscovery(dir);
|
||||
const result = discoverRegistryReferences(dir, env);
|
||||
expect(result.referencedHosts).toEqual(['quay.io']);
|
||||
});
|
||||
|
||||
it('merges request env over .env before process overrides', () => {
|
||||
const merged = mergeComposeEnvVars({ FOO: 'from-dotenv' }, { FOO: 'from-request' });
|
||||
expect(merged.FOO).toBe('from-request');
|
||||
|
||||
const previous = process.env.BAR;
|
||||
process.env.BAR = 'from-process';
|
||||
try {
|
||||
const withProcess = mergeComposeEnvVars({ BAR: 'from-dotenv' });
|
||||
expect(withProcess.BAR).toBe('from-process');
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.BAR;
|
||||
else process.env.BAR = previous;
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores unsafe request env keys', () => {
|
||||
const unsafe = JSON.parse('{"__proto__": "evil", "REGISTRY": "ghcr.io"}') as Record<string, string>;
|
||||
const merged = mergeComposeEnvVars({}, unsafe);
|
||||
expect(merged.REGISTRY).toBe('ghcr.io');
|
||||
expect(Object.keys(merged)).not.toContain('__proto__');
|
||||
expect(Object.prototype.hasOwnProperty.call(merged, '__proto__')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import axios from 'axios';
|
||||
import { setupTestDb } from './helpers/setupTestDb';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import {
|
||||
importRegistryDeliveryEvidencePage,
|
||||
listRegistryDeliveryEvidencePage,
|
||||
recordRegistryDeliveryEvent,
|
||||
} from '../helpers/registryDeliveryEvidence';
|
||||
import { RegistryDeliveryReconciler } from '../services/RegistryDeliveryReconciler';
|
||||
|
||||
describe('registry delivery evidence persistence', () => {
|
||||
beforeEach(async () => {
|
||||
await setupTestDb();
|
||||
RegistryDeliveryReconciler.resetForTests();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('assigns monotonic seq values and pages by cursor', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const deliverySourceId = db.getGlobalSettings().delivery_source_id!;
|
||||
|
||||
recordRegistryDeliveryEvent({
|
||||
deliverySourceId,
|
||||
eventType: 'swept_local',
|
||||
tempDirId: 'local-abc',
|
||||
});
|
||||
recordRegistryDeliveryEvent({
|
||||
deliverySourceId,
|
||||
eventType: 'swept_delivered',
|
||||
tempDirId: 'delivered-def',
|
||||
});
|
||||
|
||||
const page1 = listRegistryDeliveryEvidencePage(deliverySourceId, 0, 1);
|
||||
expect(page1.events).toHaveLength(1);
|
||||
expect(page1.events[0]?.event_type).toBe('swept_local');
|
||||
expect(page1.nextCursor).toBe(page1.events[0]?.seq);
|
||||
|
||||
const page2 = listRegistryDeliveryEvidencePage(deliverySourceId, page1.nextCursor, 10);
|
||||
expect(page2.events).toHaveLength(1);
|
||||
expect(page2.events[0]?.event_type).toBe('swept_delivered');
|
||||
});
|
||||
|
||||
it('imports a page in one transaction and advances the cursor', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const deliverySourceId = db.getGlobalSettings().delivery_source_id!;
|
||||
const hubNodeId = 1;
|
||||
const beforeSeq = listRegistryDeliveryEvidencePage(deliverySourceId, 0, 1000).nextCursor;
|
||||
|
||||
recordRegistryDeliveryEvent({
|
||||
deliverySourceId,
|
||||
eventType: 'operation_completed',
|
||||
stack: 'demo',
|
||||
op: 'stack-deploy',
|
||||
});
|
||||
const page = listRegistryDeliveryEvidencePage(deliverySourceId, beforeSeq, 10);
|
||||
expect(page.events).toHaveLength(1);
|
||||
const result = importRegistryDeliveryEvidencePage(hubNodeId, deliverySourceId, page.events);
|
||||
expect(result.imported).toBe(1);
|
||||
expect(result.lastSeq).toBe(page.events[0]?.seq);
|
||||
expect(db.getRegistryDeliveryImportCursor(deliverySourceId)).toBe(page.events[0]?.seq);
|
||||
|
||||
const duplicate = importRegistryDeliveryEvidencePage(hubNodeId, deliverySourceId, page.events);
|
||||
expect(duplicate.imported).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps the original hub_node_id_snapshot when the same source is re-imported', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const deliverySourceId = db.getGlobalSettings().delivery_source_id!;
|
||||
const beforeSeq = listRegistryDeliveryEvidencePage(deliverySourceId, 0, 1000).nextCursor;
|
||||
|
||||
recordRegistryDeliveryEvent({
|
||||
deliverySourceId,
|
||||
eventType: 'operation_completed',
|
||||
stack: 'demo',
|
||||
op: 'stack-deploy',
|
||||
});
|
||||
const page = listRegistryDeliveryEvidencePage(deliverySourceId, beforeSeq, 10);
|
||||
expect(page.events).toHaveLength(1);
|
||||
importRegistryDeliveryEvidencePage(10, deliverySourceId, page.events);
|
||||
importRegistryDeliveryEvidencePage(20, deliverySourceId, page.events);
|
||||
|
||||
const imported = db.getDb().prepare(
|
||||
'SELECT hub_node_id_snapshot FROM registry_delivery_imported_events WHERE event_id = ?',
|
||||
).all(page.events[0]!.event_id) as Array<{ hub_node_id_snapshot: number }>;
|
||||
expect(imported).toHaveLength(1);
|
||||
expect(imported[0]?.hub_node_id_snapshot).toBe(10);
|
||||
});
|
||||
|
||||
it('writes a retention_gap event with pruned_through_seq when old rows are pruned', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const deliverySourceId = db.getGlobalSettings().delivery_source_id!;
|
||||
|
||||
const seq = recordRegistryDeliveryEvent({
|
||||
deliverySourceId,
|
||||
eventType: 'swept_local',
|
||||
tempDirId: 'local-old',
|
||||
});
|
||||
db.getDb().prepare(
|
||||
'UPDATE registry_delivery_events SET created_at = ? WHERE seq = ?',
|
||||
).run(Date.now() - (120 * 24 * 60 * 60 * 1000), seq);
|
||||
|
||||
const pruned = db.cleanupOldDeliveryEvents(90);
|
||||
expect(pruned).toBe(1);
|
||||
|
||||
const gap = listRegistryDeliveryEvidencePage(deliverySourceId, seq - 1, 10);
|
||||
const retention = gap.events.find((event) => event.event_type === 'retention_gap');
|
||||
expect(retention?.pruned_through_seq).toBe(seq);
|
||||
});
|
||||
|
||||
it('attributes imported evidence to the remote node id', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const remoteNodeId = 42;
|
||||
const deliverySourceId = 'remote-source-id';
|
||||
db.getDb().prepare(
|
||||
`INSERT INTO nodes (id, name, type, api_url, api_token, mode, compose_dir, is_default, status, created_at)
|
||||
VALUES (?, 'remote-test', 'remote', 'http://remote:1852', 'token', 'direct', ?, 0, 'online', ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
type = excluded.type,
|
||||
api_url = excluded.api_url,
|
||||
api_token = excluded.api_token,
|
||||
mode = excluded.mode`,
|
||||
).run(remoteNodeId, process.env.COMPOSE_DIR!, Date.now());
|
||||
|
||||
vi.spyOn(axios, 'get').mockResolvedValue({
|
||||
status: 200,
|
||||
data: {
|
||||
deliverySourceId,
|
||||
events: [{
|
||||
event_id: 'evt-remote-1',
|
||||
seq: 99,
|
||||
event_type: 'operation_completed',
|
||||
created_at: Date.now(),
|
||||
stack: 'demo',
|
||||
op: 'stack-deploy',
|
||||
}],
|
||||
nextCursor: 99,
|
||||
},
|
||||
});
|
||||
|
||||
await RegistryDeliveryReconciler.getInstance().reconcileNode(remoteNodeId);
|
||||
|
||||
const row = db.getDb().prepare(
|
||||
'SELECT hub_node_id_snapshot FROM registry_delivery_imported_events WHERE event_id = ?',
|
||||
).get('evt-remote-1') as { hub_node_id_snapshot: number } | undefined;
|
||||
expect(row?.hub_node_id_snapshot).toBe(remoteNodeId);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { setupTestDb } from './helpers/setupTestDb';
|
||||
import { GitSourceService } from '../services/GitSourceService';
|
||||
import { RegistryDeliveryService } from '../services/RegistryDeliveryService';
|
||||
import { PreparedSourceStore } from '../services/preparedSourceStore';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import { runWithRegistryDeliveryContext } from '../helpers/registryDeliveryContext';
|
||||
import {
|
||||
writeGitCandidatePreparedMeta,
|
||||
} from '../helpers/registryDeliveryGitCandidate';
|
||||
import { hashDeliverySourceDir, hashActionSet } from '../helpers/registryDeliveryHashes';
|
||||
import { candidateRelPathForSha } from '../services/gitops/createStagingMarker';
|
||||
import type { FetchResult, MaterializationResult } from '../services/GitSourceService';
|
||||
|
||||
describe('createStackFromGit prepared git candidate consumption', () => {
|
||||
beforeEach(async () => {
|
||||
await setupTestDb();
|
||||
RegistryDeliveryService.resetForTests();
|
||||
const deliverySourceId = RegistryDeliveryService.getInstance().getDeliverySourceId();
|
||||
PreparedSourceStore.getInstance().configure(deliverySourceId);
|
||||
});
|
||||
|
||||
it('uses the prepared git candidate instead of fetchFromGit when prepId is set', async () => {
|
||||
const svc = GitSourceService.getInstance();
|
||||
const fetchSpy = vi.spyOn(
|
||||
svc as unknown as { fetchFromGit: () => Promise<unknown> },
|
||||
'fetchFromGit',
|
||||
).mockRejectedValue(new Error('fetchFromGit must not run when prepId is present'));
|
||||
|
||||
const stagingDir = path.join(process.env.TMPDIR || '/tmp', `sencho-git-prep-${Date.now()}`);
|
||||
fs.mkdirSync(stagingDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(stagingDir, 'compose.yaml'),
|
||||
'services:\n app:\n image: nginx:latest\n',
|
||||
);
|
||||
|
||||
const commitSha = 'a'.repeat(40);
|
||||
const candidateRelPath = candidateRelPathForSha(commitSha);
|
||||
const materialization: MaterializationResult = {
|
||||
inventory: {
|
||||
inputs: [],
|
||||
refusals: [],
|
||||
buildContexts: [],
|
||||
dynamic: [],
|
||||
counts: { managed: 0, unmanaged: 0, refused: 0 },
|
||||
},
|
||||
contextCopyPlans: [],
|
||||
candidateRelPath,
|
||||
validation: { ok: true },
|
||||
};
|
||||
|
||||
await writeGitCandidatePreparedMeta(stagingDir, {
|
||||
version: 1,
|
||||
commitSha,
|
||||
resolvedRefKind: 'branch',
|
||||
candidateRelPath,
|
||||
composeFiles: [{ path: 'compose.yaml', content: 'services:\n app:\n image: nginx:latest\n' }],
|
||||
envContent: null,
|
||||
materialization,
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const sourceHash = hashDeliverySourceDir(stagingDir);
|
||||
const entry = await PreparedSourceStore.getInstance().prepareFromDirectory(
|
||||
'git-candidate',
|
||||
sourceHash,
|
||||
stagingDir,
|
||||
);
|
||||
|
||||
const stackName = `from-git-prep-${Date.now()}`;
|
||||
const nodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
||||
const delivery = RegistryDeliveryService.getInstance();
|
||||
|
||||
const restoreSpy = vi.spyOn(
|
||||
svc as unknown as {
|
||||
restoreCreateFromPreparedGitCandidate: (
|
||||
prepId: string,
|
||||
managedRoot: string,
|
||||
rootPreexisted: boolean,
|
||||
gitopsOperationId: string,
|
||||
staged: { candidateRelPath: string | null },
|
||||
) => Promise<{ fetched: FetchResult; materialization: MaterializationResult }>;
|
||||
},
|
||||
'restoreCreateFromPreparedGitCandidate',
|
||||
).mockResolvedValue({
|
||||
fetched: {
|
||||
composeFiles: [{ path: 'compose.yaml', content: 'services:\n app:\n image: nginx:latest\n' }],
|
||||
envContent: null,
|
||||
commitSha,
|
||||
resolvedRefKind: 'branch',
|
||||
warnings: [],
|
||||
},
|
||||
materialization,
|
||||
});
|
||||
|
||||
try {
|
||||
await runWithRegistryDeliveryContext({
|
||||
envelope: {
|
||||
attestation: delivery.signAttestation({
|
||||
nodeIdClaim: nodeId,
|
||||
stack: stackName,
|
||||
op: 'from-git-deploy-now',
|
||||
sourceHash,
|
||||
referencedHostsHash: delivery.hashHostList([]),
|
||||
coveredHostsHash: delivery.hashHostList([]),
|
||||
actionSetHash: hashActionSet(['stack:create']),
|
||||
prepId: entry.prepId,
|
||||
}),
|
||||
prepId: entry.prepId,
|
||||
auths: [],
|
||||
notAfter: Date.now() + 60_000,
|
||||
deliverySourceId: delivery.getDeliverySourceId(),
|
||||
},
|
||||
nodeId,
|
||||
stack: stackName,
|
||||
stage: 'from-git-deploy-now',
|
||||
}, () => svc.createStackFromGit({
|
||||
stackName,
|
||||
repoUrl: 'https://github.com/example/demo.git',
|
||||
branch: 'main',
|
||||
composePaths: ['compose.yaml'],
|
||||
contextDir: null,
|
||||
syncEnv: false,
|
||||
envPath: null,
|
||||
authType: 'none',
|
||||
token: null,
|
||||
autoApplyOnWebhook: false,
|
||||
autoDeployOnApply: false,
|
||||
}));
|
||||
} catch {
|
||||
// Downstream create steps may fail in this isolated test; the contract
|
||||
// under test is the prepared-source branch before fetchFromGit.
|
||||
}
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(restoreSpy).toHaveBeenCalledWith(
|
||||
entry.prepId,
|
||||
expect.any(String),
|
||||
false,
|
||||
expect.any(String),
|
||||
expect.objectContaining({ candidateRelPath: null }),
|
||||
);
|
||||
|
||||
fetchSpy.mockRestore();
|
||||
restoreSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { classifyRegistryDeliveryOp } from '../helpers/registryOpClassifier';
|
||||
import { classifyRegistryDeliveryRouteClass } from '../helpers/registryDeliveryBodyLimits';
|
||||
|
||||
describe('git-apply-auto-deploy route classification', () => {
|
||||
it('classifies git-source apply as git-apply-auto-deploy', () => {
|
||||
const result = classifyRegistryDeliveryOp(
|
||||
'POST',
|
||||
'/api/stacks/my-stack/git-source/apply',
|
||||
);
|
||||
expect(result).toEqual({
|
||||
eligible: true,
|
||||
stage: 'git-apply-auto-deploy',
|
||||
stack: 'my-stack',
|
||||
});
|
||||
});
|
||||
|
||||
it('assigns bulk-label-git body budget to git-source apply', () => {
|
||||
expect(classifyRegistryDeliveryRouteClass(
|
||||
'POST',
|
||||
'/api/stacks/my-stack/git-source/apply',
|
||||
)).toBe('bulk-label-git');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { setupTestDb } from './helpers/setupTestDb';
|
||||
import { GitSourceService } from '../services/GitSourceService';
|
||||
import { RegistryDeliveryService } from '../services/RegistryDeliveryService';
|
||||
import { PreparedSourceStore } from '../services/preparedSourceStore';
|
||||
import {
|
||||
writeGitCandidatePreparedMeta,
|
||||
} from '../helpers/registryDeliveryGitCandidate';
|
||||
import { hashDeliverySourceDir } from '../helpers/registryDeliveryHashes';
|
||||
import { candidateRelPathForSha } from '../services/gitops/createStagingMarker';
|
||||
import { stackManagedRoot } from '../services/gitops/directApplication';
|
||||
import type { MaterializationResult } from '../services/GitSourceService';
|
||||
|
||||
describe('restoreApplyFromPreparedGitCandidate', () => {
|
||||
beforeEach(async () => {
|
||||
await setupTestDb();
|
||||
RegistryDeliveryService.resetForTests();
|
||||
const deliverySourceId = RegistryDeliveryService.getInstance().getDeliverySourceId();
|
||||
PreparedSourceStore.getInstance().configure(deliverySourceId);
|
||||
});
|
||||
|
||||
it('installs prepared bytes at the pending candidate path', async () => {
|
||||
const stagingDir = path.join(process.env.TMPDIR || '/tmp', `sencho-git-apply-prep-${Date.now()}`);
|
||||
fs.mkdirSync(stagingDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(stagingDir, 'compose.yaml'),
|
||||
'services:\n app:\n image: nginx:latest\n',
|
||||
);
|
||||
|
||||
const commitSha = 'd'.repeat(40);
|
||||
const candidateRelPath = candidateRelPathForSha(commitSha);
|
||||
const materialization: MaterializationResult = {
|
||||
inventory: {
|
||||
inputs: [],
|
||||
refusals: [],
|
||||
buildContexts: [],
|
||||
dynamic: [],
|
||||
counts: { managed: 0, unmanaged: 0, refused: 0 },
|
||||
},
|
||||
contextCopyPlans: [],
|
||||
candidateRelPath,
|
||||
validation: { ok: true },
|
||||
};
|
||||
|
||||
await writeGitCandidatePreparedMeta(stagingDir, {
|
||||
version: 1,
|
||||
commitSha,
|
||||
resolvedRefKind: 'branch',
|
||||
candidateRelPath,
|
||||
composeFiles: [{ path: 'compose.yaml', content: 'services:\n app:\n image: nginx:latest\n' }],
|
||||
envContent: null,
|
||||
materialization,
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const sourceHash = hashDeliverySourceDir(stagingDir);
|
||||
const entry = await PreparedSourceStore.getInstance().prepareFromDirectory(
|
||||
'git-candidate',
|
||||
sourceHash,
|
||||
stagingDir,
|
||||
);
|
||||
|
||||
const stackName = `apply-restore-${Date.now()}`;
|
||||
const svc = GitSourceService.getInstance();
|
||||
await (
|
||||
svc as unknown as {
|
||||
restoreApplyFromPreparedGitCandidate: (
|
||||
prepId: string,
|
||||
stackName: string,
|
||||
commitSha: string,
|
||||
candidateRelPath: string,
|
||||
) => Promise<void>;
|
||||
}
|
||||
).restoreApplyFromPreparedGitCandidate(
|
||||
entry.prepId,
|
||||
stackName,
|
||||
commitSha,
|
||||
candidateRelPath,
|
||||
);
|
||||
|
||||
const installed = path.join(stackManagedRoot(stackName), candidateRelPath, 'compose.yaml');
|
||||
expect(fs.existsSync(installed)).toBe(true);
|
||||
expect(PreparedSourceStore.getInstance().getEntry(entry.prepId)?.state).toBe('prepared');
|
||||
});
|
||||
|
||||
it('rejects commit or candidate path mismatches', async () => {
|
||||
const stagingDir = path.join(process.env.TMPDIR || '/tmp', `sencho-git-apply-mismatch-${Date.now()}`);
|
||||
fs.mkdirSync(stagingDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(stagingDir, 'compose.yaml'), 'services:\n app:\n image: nginx\n');
|
||||
|
||||
const commitSha = 'e'.repeat(40);
|
||||
const candidateRelPath = candidateRelPathForSha(commitSha);
|
||||
const materialization: MaterializationResult = {
|
||||
inventory: {
|
||||
inputs: [],
|
||||
refusals: [],
|
||||
buildContexts: [],
|
||||
dynamic: [],
|
||||
counts: { managed: 0, unmanaged: 0, refused: 0 },
|
||||
},
|
||||
contextCopyPlans: [],
|
||||
candidateRelPath,
|
||||
validation: { ok: true },
|
||||
};
|
||||
|
||||
await writeGitCandidatePreparedMeta(stagingDir, {
|
||||
version: 1,
|
||||
commitSha,
|
||||
resolvedRefKind: 'branch',
|
||||
candidateRelPath,
|
||||
composeFiles: [{ path: 'compose.yaml', content: 'services:\n app:\n image: nginx\n' }],
|
||||
envContent: null,
|
||||
materialization,
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const entry = await PreparedSourceStore.getInstance().prepareFromDirectory(
|
||||
'git-candidate',
|
||||
hashDeliverySourceDir(stagingDir),
|
||||
stagingDir,
|
||||
);
|
||||
|
||||
const svc = GitSourceService.getInstance();
|
||||
const restore = (
|
||||
svc as unknown as {
|
||||
restoreApplyFromPreparedGitCandidate: (
|
||||
prepId: string,
|
||||
stackName: string,
|
||||
commitSha: string,
|
||||
candidateRelPath: string,
|
||||
) => Promise<void>;
|
||||
}
|
||||
).restoreApplyFromPreparedGitCandidate.bind(svc);
|
||||
|
||||
await expect(restore(
|
||||
entry.prepId,
|
||||
`mismatch-${Date.now()}`,
|
||||
'f'.repeat(40),
|
||||
candidateRelPath,
|
||||
)).rejects.toThrow(/commit mismatch/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import {
|
||||
fetchResultFromPreparedMeta,
|
||||
installGitCandidatePayloadToManagedRoot,
|
||||
readGitCandidatePreparedMeta,
|
||||
writeGitCandidatePreparedMeta,
|
||||
GIT_CANDIDATE_PREPARED_META_FILE,
|
||||
} from '../helpers/registryDeliveryGitCandidate';
|
||||
import { candidateRelPathForSha } from '../services/gitops/createStagingMarker';
|
||||
import type { MaterializationResult } from '../services/GitSourceService';
|
||||
|
||||
describe('registryDeliveryGitCandidate helpers', () => {
|
||||
it('round-trips metadata and installs candidate bytes', async () => {
|
||||
const payloadDir = path.join(process.env.TMPDIR || '/tmp', `sencho-git-meta-${Date.now()}`);
|
||||
const managedRoot = path.join(process.env.TMPDIR || '/tmp', `sencho-git-managed-${Date.now()}`);
|
||||
fs.mkdirSync(payloadDir, { recursive: true });
|
||||
fs.mkdirSync(managedRoot, { recursive: true });
|
||||
fs.writeFileSync(path.join(payloadDir, 'compose.yaml'), 'services:\n web:\n image: nginx\n');
|
||||
|
||||
const commitSha = 'b'.repeat(40);
|
||||
const candidateRelPath = candidateRelPathForSha(commitSha);
|
||||
const materialization: MaterializationResult = {
|
||||
inventory: {
|
||||
inputs: [],
|
||||
refusals: [],
|
||||
buildContexts: [],
|
||||
dynamic: [],
|
||||
counts: { managed: 0, unmanaged: 0, refused: 0 },
|
||||
},
|
||||
contextCopyPlans: [],
|
||||
candidateRelPath,
|
||||
validation: { ok: true },
|
||||
};
|
||||
|
||||
await writeGitCandidatePreparedMeta(payloadDir, {
|
||||
version: 1,
|
||||
commitSha,
|
||||
resolvedRefKind: 'branch',
|
||||
candidateRelPath,
|
||||
composeFiles: [{ path: 'compose.yaml', content: 'services:\n web:\n image: nginx\n' }],
|
||||
envContent: null,
|
||||
materialization,
|
||||
warnings: ['submodule skipped'],
|
||||
});
|
||||
|
||||
const meta = await readGitCandidatePreparedMeta(payloadDir);
|
||||
expect(meta.commitSha).toBe(commitSha);
|
||||
expect(fetchResultFromPreparedMeta(meta).warnings).toEqual(['submodule skipped']);
|
||||
|
||||
await installGitCandidatePayloadToManagedRoot(payloadDir, managedRoot, candidateRelPath);
|
||||
const installedCompose = path.join(managedRoot, candidateRelPath, 'compose.yaml');
|
||||
expect(fs.existsSync(installedCompose)).toBe(true);
|
||||
expect(fs.existsSync(path.join(managedRoot, candidateRelPath, GIT_CANDIDATE_PREPARED_META_FILE))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import crypto from 'crypto';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { hashActionSet, hashBlueprintPostApplySource, hashProjectSource } from '../helpers/registryDeliveryHashes';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
describe('registryDeliveryHashes', () => {
|
||||
it('hashActionSet is order-independent', () => {
|
||||
const a = hashActionSet(['stack:deploy', 'stack:edit']);
|
||||
const b = hashActionSet(['stack:edit', 'stack:deploy']);
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it('hashProjectSource is not raw compose bytes', () => {
|
||||
const content = 'services:\n web:\n image: nginx\n';
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-hash-test-'));
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, 'compose.yaml'), content);
|
||||
const projectHash = hashProjectSource(dir);
|
||||
const rawHash = crypto.createHash('sha256').update(content).digest('hex');
|
||||
expect(projectHash).not.toBe(rawHash);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('hashProjectSource changes when compose content changes', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-hash-test-'));
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, 'compose.yaml'), 'services:\n web:\n image: nginx\n');
|
||||
const before = hashProjectSource(dir);
|
||||
fs.writeFileSync(path.join(dir, 'compose.yaml'), 'services:\n web:\n image: nginx:2\n');
|
||||
const after = hashProjectSource(dir);
|
||||
expect(before).not.toBe(after);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('hashBlueprintPostApplySource matches hashProjectSource for an empty .env file', () => {
|
||||
const compose = 'services:\n web:\n image: nginx\n';
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-hash-test-'));
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, 'compose.yaml'), compose);
|
||||
fs.writeFileSync(path.join(dir, '.env'), '');
|
||||
expect(hashBlueprintPostApplySource(compose, '')).toBe(hashProjectSource(dir));
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { setupTestDb } from './helpers/setupTestDb';
|
||||
import { StackOpLockService } from '../services/StackOpLockService';
|
||||
import { RegistryDeliveryService } from '../services/RegistryDeliveryService';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import { resolveRegistryAuthAtSeam } from '../helpers/registryDeliverySeam';
|
||||
import { hashActionSet, hashProjectSource } from '../helpers/registryDeliveryHashes';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
describe('registry delivery stack lock context', () => {
|
||||
beforeEach(async () => {
|
||||
await setupTestDb();
|
||||
StackOpLockService.resetForTests();
|
||||
RegistryDeliveryService.resetForTests();
|
||||
});
|
||||
|
||||
it('stores lock context on tryAcquire and clears it on release', () => {
|
||||
const locks = StackOpLockService.getInstance();
|
||||
const acquired = locks.tryAcquire(1, 'ctx-stack', 'deploy', 'admin', {
|
||||
opId: 'op-123',
|
||||
kind: 'stack-deploy',
|
||||
});
|
||||
expect(acquired.acquired).toBe(true);
|
||||
expect(locks.get(1, 'ctx-stack')?.context).toEqual({
|
||||
opId: 'op-123',
|
||||
kind: 'stack-deploy',
|
||||
});
|
||||
locks.release(1, 'ctx-stack');
|
||||
expect(locks.get(1, 'ctx-stack')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects seam when held lock context does not match attestation', async () => {
|
||||
const nodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
||||
const stackName = 'ctx-seam-stack';
|
||||
const stackDir = path.join(process.env.COMPOSE_DIR!, stackName);
|
||||
fs.mkdirSync(stackDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n web:\n image: nginx\n');
|
||||
|
||||
const delivery = RegistryDeliveryService.getInstance();
|
||||
const sourceHash = hashProjectSource(stackDir);
|
||||
const attestation = delivery.signAttestation({
|
||||
nodeIdClaim: nodeId,
|
||||
stack: stackName,
|
||||
op: 'stack-deploy',
|
||||
sourceHash,
|
||||
referencedHostsHash: delivery.hashHostList([]),
|
||||
coveredHostsHash: delivery.hashHostList([]),
|
||||
actionSetHash: hashActionSet(['stack:deploy']),
|
||||
});
|
||||
|
||||
StackOpLockService.getInstance().tryAcquire(nodeId, stackName, 'deploy', 'admin', {
|
||||
opId: 'wrong-op',
|
||||
kind: 'stack-deploy',
|
||||
});
|
||||
|
||||
await expect(resolveRegistryAuthAtSeam({
|
||||
envelope: {
|
||||
attestation,
|
||||
auths: [],
|
||||
notAfter: Date.now() + 60_000,
|
||||
deliverySourceId: delivery.getDeliverySourceId(),
|
||||
},
|
||||
nodeId,
|
||||
stack: stackName,
|
||||
stage: 'stack-deploy',
|
||||
})).rejects.toThrow(/lock context mismatch/i);
|
||||
|
||||
StackOpLockService.getInstance().release(nodeId, stackName);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { REGISTRY_DELIVERY_BODY_FIELD } from '../helpers/registryDeliveryBodyLimits';
|
||||
import {
|
||||
registryDeliveryApiPath,
|
||||
registryDeliveryMiddleware,
|
||||
} from '../middleware/registryDelivery';
|
||||
import { setupTestDb } from './helpers/setupTestDb';
|
||||
import { RegistryDeliveryService } from '../services/RegistryDeliveryService';
|
||||
|
||||
describe('registryDeliveryMiddleware', () => {
|
||||
beforeEach(async () => {
|
||||
await setupTestDb();
|
||||
RegistryDeliveryService.resetForTests();
|
||||
});
|
||||
|
||||
it('normalizes Express-mounted paths to /api/... for classification', () => {
|
||||
expect(registryDeliveryApiPath({ path: '/stacks/jackett/deploy' })).toBe(
|
||||
'/api/stacks/jackett/deploy',
|
||||
);
|
||||
});
|
||||
|
||||
it('engages on stack deploy when mounted at /api (rejects invalid attestation)', async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api', registryDeliveryMiddleware);
|
||||
app.post('/api/stacks/:name/deploy', (_req, res) => {
|
||||
res.status(200).json({ ok: true });
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/jackett/deploy')
|
||||
.send({
|
||||
[REGISTRY_DELIVERY_BODY_FIELD]: {
|
||||
attestation: 'not-a-jwt',
|
||||
auths: [],
|
||||
notAfter: Date.now() + 60_000,
|
||||
deliverySourceId: 'test-source',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'Invalid registry delivery envelope' });
|
||||
});
|
||||
|
||||
it('passes through eligible routes without a delivery field', async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api', registryDeliveryMiddleware);
|
||||
app.post('/api/stacks/:name/deploy', (_req, res) => {
|
||||
res.status(200).json({ ok: true });
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/jackett/deploy')
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('does not engage on non-delivery routes', async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api', registryDeliveryMiddleware);
|
||||
app.get('/api/stacks', (_req, res) => {
|
||||
res.status(200).json({ ok: true });
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/stacks')
|
||||
.send({
|
||||
[REGISTRY_DELIVERY_BODY_FIELD]: {
|
||||
attestation: 'not-a-jwt',
|
||||
auths: [],
|
||||
notAfter: Date.now() + 60_000,
|
||||
deliverySourceId: 'test-source',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { setupTestDb } from './helpers/setupTestDb';
|
||||
import { RegistryDeliveryService } from '../services/RegistryDeliveryService';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { REGISTRY_DELIVERY_BODY_FIELD } from '../helpers/registryDeliveryBodyLimits';
|
||||
import { classifyRegistryDeliveryOp } from '../helpers/registryOpClassifier';
|
||||
|
||||
const mockRemoteAdvertises = vi.fn();
|
||||
const mockAxiosPost = vi.fn();
|
||||
const mockIsProxyConfidential = vi.fn();
|
||||
const mockIsTunnelConfidential = vi.fn();
|
||||
|
||||
vi.mock('../helpers/remoteCapabilities', () => ({
|
||||
remoteAdvertisesCapability: (...args: unknown[]) => mockRemoteAdvertises(...args),
|
||||
}));
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: {
|
||||
post: (...args: unknown[]) => mockAxiosPost(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/PilotTunnelManager', () => ({
|
||||
PilotTunnelManager: {
|
||||
getInstance: () => ({
|
||||
isTunnelConfidential: (...args: unknown[]) => mockIsTunnelConfidential(...args),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
let augmentJsonBodyForRegistryDelivery: typeof import('../helpers/registryDeliveryOutbound').augmentJsonBodyForRegistryDelivery;
|
||||
let wouldAttemptRegistryDelivery: typeof import('../helpers/registryDeliveryOutbound').wouldAttemptRegistryDelivery;
|
||||
|
||||
describe('registryDeliveryOutbound', () => {
|
||||
beforeEach(async () => {
|
||||
await setupTestDb();
|
||||
RegistryDeliveryService.resetForTests();
|
||||
vi.clearAllMocks();
|
||||
mockIsProxyConfidential.mockReturnValue(true);
|
||||
mockIsTunnelConfidential.mockReturnValue(true);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
({ augmentJsonBodyForRegistryDelivery, wouldAttemptRegistryDelivery } = await import('../helpers/registryDeliveryOutbound'));
|
||||
const delivery = RegistryDeliveryService.getInstance();
|
||||
vi.spyOn(delivery, 'isProxyTransportConfidential').mockImplementation(
|
||||
() => mockIsProxyConfidential(),
|
||||
);
|
||||
});
|
||||
|
||||
it('classifies blueprint apply-local as eligible', () => {
|
||||
const result = classifyRegistryDeliveryOp('POST', '/api/blueprints/apply-local');
|
||||
expect(result.eligible).toBe(true);
|
||||
expect(result.stage).toBe('blueprint-apply');
|
||||
});
|
||||
|
||||
it('passes through unchanged when remote lacks delivery capability', async () => {
|
||||
mockRemoteAdvertises.mockResolvedValue(false);
|
||||
const nodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
||||
const node = DatabaseService.getInstance().getNode(nodeId)!;
|
||||
const body = { foo: 'bar' };
|
||||
|
||||
const result = await augmentJsonBodyForRegistryDelivery({
|
||||
method: 'POST',
|
||||
apiPath: '/api/stacks/demo/deploy',
|
||||
nodeId,
|
||||
node,
|
||||
target: { apiUrl: 'http://remote:1852', apiToken: 'token' },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true, body, augmented: false });
|
||||
expect(mockAxiosPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes through unchanged when transport is not confidential', async () => {
|
||||
mockRemoteAdvertises.mockResolvedValue(true);
|
||||
mockIsProxyConfidential.mockReturnValue(false);
|
||||
const nodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
||||
const node = DatabaseService.getInstance().getNode(nodeId)!;
|
||||
const body = { foo: 'bar' };
|
||||
|
||||
const result = await augmentJsonBodyForRegistryDelivery({
|
||||
method: 'POST',
|
||||
apiPath: '/api/stacks/demo/deploy',
|
||||
nodeId,
|
||||
node,
|
||||
target: { apiUrl: 'http://remote:1852', apiToken: 'token' },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true, body, augmented: false });
|
||||
expect(mockAxiosPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns false from wouldAttemptRegistryDelivery when remote lacks capability', async () => {
|
||||
mockRemoteAdvertises.mockResolvedValue(false);
|
||||
const nodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
||||
const node = DatabaseService.getInstance().getNode(nodeId)!;
|
||||
|
||||
const result = await wouldAttemptRegistryDelivery(
|
||||
nodeId,
|
||||
node,
|
||||
'POST',
|
||||
'/api/stacks/demo/deploy',
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns aborted when discover is cancelled', async () => {
|
||||
mockRemoteAdvertises.mockResolvedValue(true);
|
||||
mockAxiosPost.mockImplementation(() => new Promise(() => {}));
|
||||
const nodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
||||
const node = DatabaseService.getInstance().getNode(nodeId)!;
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
const result = await augmentJsonBodyForRegistryDelivery({
|
||||
method: 'POST',
|
||||
apiPath: '/api/stacks/demo/deploy',
|
||||
nodeId,
|
||||
node,
|
||||
target: { apiUrl: 'http://remote:1852', apiToken: 'token' },
|
||||
body: {},
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: false, status: 499, error: 'Request aborted' });
|
||||
expect(mockAxiosPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('augments deploy body when capability and transport are present', async () => {
|
||||
mockRemoteAdvertises.mockResolvedValue(true);
|
||||
const delivery = RegistryDeliveryService.getInstance();
|
||||
const discover = {
|
||||
referencedHosts: ['ghcr.io'],
|
||||
coveredHosts: [],
|
||||
sourceHash: 'abc',
|
||||
actionSetHash: 'def',
|
||||
deliverySourceId: delivery.getDeliverySourceId(),
|
||||
attestation: delivery.signAttestation({
|
||||
nodeIdClaim: 1,
|
||||
stack: 'demo',
|
||||
op: 'stack-deploy',
|
||||
sourceHash: 'abc',
|
||||
referencedHostsHash: delivery.hashHostList(['ghcr.io']),
|
||||
coveredHostsHash: delivery.hashHostList([]),
|
||||
actionSetHash: 'def',
|
||||
}),
|
||||
};
|
||||
mockAxiosPost.mockResolvedValue({ status: 200, data: discover });
|
||||
|
||||
const nodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
||||
const node = DatabaseService.getInstance().getNode(nodeId)!;
|
||||
|
||||
const result = await augmentJsonBodyForRegistryDelivery({
|
||||
method: 'POST',
|
||||
apiPath: '/api/stacks/demo/deploy',
|
||||
nodeId,
|
||||
node,
|
||||
target: { apiUrl: 'http://remote:1852', apiToken: 'token' },
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.augmented).toBe(true);
|
||||
expect(result.body[REGISTRY_DELIVERY_BODY_FIELD]).toBeDefined();
|
||||
expect(mockAxiosPost).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('returns aborted when hub envelope build is cancelled after discover', async () => {
|
||||
mockRemoteAdvertises.mockResolvedValue(true);
|
||||
const delivery = RegistryDeliveryService.getInstance();
|
||||
const discover = {
|
||||
referencedHosts: ['ghcr.io'],
|
||||
coveredHosts: [],
|
||||
sourceHash: 'abc',
|
||||
actionSetHash: 'def',
|
||||
deliverySourceId: delivery.getDeliverySourceId(),
|
||||
attestation: delivery.signAttestation({
|
||||
nodeIdClaim: 1,
|
||||
stack: 'demo',
|
||||
op: 'stack-deploy',
|
||||
sourceHash: 'abc',
|
||||
referencedHostsHash: delivery.hashHostList(['ghcr.io']),
|
||||
coveredHostsHash: delivery.hashHostList([]),
|
||||
actionSetHash: 'def',
|
||||
}),
|
||||
};
|
||||
mockAxiosPost.mockResolvedValue({ status: 200, data: discover });
|
||||
|
||||
let releaseEnvelope: (() => void) | undefined;
|
||||
vi.spyOn(delivery, 'buildHubEnvelope').mockImplementation(() => new Promise((resolve) => {
|
||||
releaseEnvelope = () => resolve({
|
||||
attestation: discover.attestation,
|
||||
auths: [],
|
||||
notAfter: Date.now() + 60_000,
|
||||
deliverySourceId: discover.deliverySourceId,
|
||||
});
|
||||
}));
|
||||
|
||||
const controller = new AbortController();
|
||||
const nodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
||||
const node = DatabaseService.getInstance().getNode(nodeId)!;
|
||||
const pending = augmentJsonBodyForRegistryDelivery({
|
||||
method: 'POST',
|
||||
apiPath: '/api/stacks/demo/deploy',
|
||||
nodeId,
|
||||
node,
|
||||
target: { apiUrl: 'http://remote:1852', apiToken: 'token' },
|
||||
body: {},
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
setTimeout(() => controller.abort(), 10);
|
||||
setTimeout(() => releaseEnvelope?.(), 50);
|
||||
|
||||
const result = await pending;
|
||||
expect(result).toEqual({ ok: false, status: 499, error: 'Request aborted' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb } from './helpers/setupTestDb';
|
||||
import { RegistryDeliveryService } from '../services/RegistryDeliveryService';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import { StackOpLockService } from '../services/StackOpLockService';
|
||||
import { PreparedSourceStore } from '../services/preparedSourceStore';
|
||||
import { resolveRegistryAuthAtSeam } from '../helpers/registryDeliverySeam';
|
||||
import { hashActionSet, hashDeliverySourceDir } from '../helpers/registryDeliveryHashes';
|
||||
import { discoverRegistryReferences } from '../services/registryReferenceDiscovery';
|
||||
import { normalizeImageHost } from '../services/RegistryService';
|
||||
|
||||
function acquireLockForAttestation(
|
||||
nodeId: number,
|
||||
stack: string,
|
||||
attestation: string,
|
||||
stage: string,
|
||||
): void {
|
||||
const payload = jwt.decode(attestation) as jwt.JwtPayload;
|
||||
StackOpLockService.getInstance().tryAcquire(nodeId, stack, 'deploy', 'admin', {
|
||||
opId: String(payload.jti_t),
|
||||
kind: stage,
|
||||
});
|
||||
}
|
||||
|
||||
describe('registryDeliverySeam prepared source claim', () => {
|
||||
beforeEach(async () => {
|
||||
await setupTestDb();
|
||||
RegistryDeliveryService.resetForTests();
|
||||
StackOpLockService.resetForTests();
|
||||
const deliverySourceId = RegistryDeliveryService.getInstance().getDeliverySourceId();
|
||||
PreparedSourceStore.getInstance().configure(deliverySourceId);
|
||||
});
|
||||
|
||||
it('claims prepId and merges delivered credentials from prepared payload', async () => {
|
||||
const nodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
||||
const stackName = 'prep-claim-stack';
|
||||
const stagingDir = path.join(process.env.TMPDIR || '/tmp', `sencho-prep-${Date.now()}`);
|
||||
fs.mkdirSync(stagingDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(stagingDir, 'compose.yaml'),
|
||||
'services:\n app:\n image: ghcr.io/example/private/app:latest\n',
|
||||
);
|
||||
|
||||
const sourceHash = hashDeliverySourceDir(stagingDir);
|
||||
const entry = await PreparedSourceStore.getInstance().prepareFromDirectory(
|
||||
'request-generated',
|
||||
sourceHash,
|
||||
stagingDir,
|
||||
);
|
||||
const payloadPath = PreparedSourceStore.getInstance().peekPayloadPath(entry.prepId);
|
||||
const referencedHosts = discoverRegistryReferences(payloadPath).referencedHosts;
|
||||
const delivery = RegistryDeliveryService.getInstance();
|
||||
const attestation = delivery.signAttestation({
|
||||
nodeIdClaim: nodeId,
|
||||
stack: stackName,
|
||||
op: 'template-deploy',
|
||||
sourceHash,
|
||||
referencedHostsHash: delivery.hashHostList(referencedHosts),
|
||||
coveredHostsHash: delivery.hashHostList([]),
|
||||
actionSetHash: hashActionSet(['stack:create', 'stack:deploy']),
|
||||
prepId: entry.prepId,
|
||||
});
|
||||
|
||||
const envelope = {
|
||||
attestation,
|
||||
prepId: entry.prepId,
|
||||
auths: [{
|
||||
host: 'ghcr.io',
|
||||
username: 'hub-user',
|
||||
password: 'hub-pass',
|
||||
}],
|
||||
notAfter: Date.now() + 60_000,
|
||||
deliverySourceId: delivery.getDeliverySourceId(),
|
||||
};
|
||||
|
||||
acquireLockForAttestation(nodeId, stackName, attestation, 'template-deploy');
|
||||
|
||||
const result = await resolveRegistryAuthAtSeam({
|
||||
envelope,
|
||||
nodeId,
|
||||
stack: stackName,
|
||||
stage: 'template-deploy',
|
||||
});
|
||||
|
||||
const ghcrKey = referencedHosts.map(normalizeImageHost).find(h => h.includes('ghcr')) ?? 'ghcr.io';
|
||||
expect(result.auths[ghcrKey]).toBeDefined();
|
||||
expect(result.prepId).toBe(entry.prepId);
|
||||
expect(PreparedSourceStore.getInstance().getEntry(entry.prepId)?.state).toBe('claimed');
|
||||
});
|
||||
|
||||
it('rejects a substituted prepId at the seam', async () => {
|
||||
const nodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
||||
const delivery = RegistryDeliveryService.getInstance();
|
||||
const attestation = delivery.signAttestation({
|
||||
nodeIdClaim: nodeId,
|
||||
stack: 'mismatch',
|
||||
op: 'template-deploy',
|
||||
sourceHash: 'abc',
|
||||
referencedHostsHash: delivery.hashHostList([]),
|
||||
coveredHostsHash: delivery.hashHostList([]),
|
||||
actionSetHash: hashActionSet(['stack:create', 'stack:deploy']),
|
||||
prepId: 'deadbeefdeadbeefdeadbeefdeadbeef',
|
||||
});
|
||||
|
||||
acquireLockForAttestation(nodeId, 'mismatch', attestation, 'template-deploy');
|
||||
|
||||
await expect(resolveRegistryAuthAtSeam({
|
||||
envelope: {
|
||||
attestation,
|
||||
prepId: 'cafebabe',
|
||||
auths: [],
|
||||
notAfter: Date.now() + 60_000,
|
||||
deliverySourceId: delivery.getDeliverySourceId(),
|
||||
},
|
||||
nodeId,
|
||||
stack: 'mismatch',
|
||||
stage: 'template-deploy',
|
||||
})).rejects.toThrow(/prepId/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { EventEmitter } from 'events';
|
||||
import type { Request, Response } from 'express';
|
||||
import { setupTestDb } from './helpers/setupTestDb';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
|
||||
const mockWouldAttempt = vi.fn();
|
||||
|
||||
vi.mock('../helpers/registryDeliveryOutbound', () => ({
|
||||
wouldAttemptRegistryDelivery: (...args: unknown[]) => mockWouldAttempt(...args),
|
||||
}));
|
||||
|
||||
describe('registry delivery proxy hop abort', () => {
|
||||
beforeEach(async () => {
|
||||
await setupTestDb();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function mockReqRes(): { req: Request; res: Response } {
|
||||
const req = new EventEmitter() as Request;
|
||||
const res = new EventEmitter() as Response;
|
||||
Object.defineProperty(res, 'writableEnded', { value: false, writable: true });
|
||||
return { req, res };
|
||||
}
|
||||
|
||||
it('aborts the hop signal when the client disconnects during capability probing', async () => {
|
||||
const { ensureRegistryDeliveryHopAbortController } = await import('../helpers/registryDeliveryProxy');
|
||||
const { req, res } = mockReqRes();
|
||||
|
||||
let resolveProbe: ((value: boolean) => void) | undefined;
|
||||
mockWouldAttempt.mockImplementation(() => new Promise<boolean>((resolve) => {
|
||||
resolveProbe = resolve;
|
||||
}));
|
||||
|
||||
ensureRegistryDeliveryHopAbortController(req, res);
|
||||
const probe = mockWouldAttempt(
|
||||
NodeRegistry.getInstance().getDefaultNodeId(),
|
||||
DatabaseService.getInstance().getNode(NodeRegistry.getInstance().getDefaultNodeId())!,
|
||||
'POST',
|
||||
'/api/blueprints/apply-local',
|
||||
);
|
||||
|
||||
req.emit('aborted');
|
||||
resolveProbe?.(true);
|
||||
|
||||
await probe;
|
||||
expect(req.registryDeliveryAbortController?.signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('returns aborted from decideRegistryDeliveryProxyHop when disconnected during the probe', async () => {
|
||||
const { decideRegistryDeliveryProxyHop } = await import('../helpers/registryDeliveryProxy');
|
||||
const { req, res } = mockReqRes();
|
||||
const nodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
||||
const node = DatabaseService.getInstance().getNode(nodeId)!;
|
||||
|
||||
let resolveProbe: ((value: boolean) => void) | undefined;
|
||||
mockWouldAttempt.mockImplementation(() => new Promise<boolean>((resolve) => {
|
||||
resolveProbe = resolve;
|
||||
}));
|
||||
|
||||
const decision = decideRegistryDeliveryProxyHop(
|
||||
req,
|
||||
res,
|
||||
nodeId,
|
||||
node,
|
||||
'POST',
|
||||
'/api/blueprints/apply-local',
|
||||
);
|
||||
|
||||
req.emit('aborted');
|
||||
resolveProbe?.(true);
|
||||
|
||||
await expect(decision).resolves.toEqual({ action: 'aborted' });
|
||||
});
|
||||
|
||||
it('maps aborted decisions to stop at the proxy gate', async () => {
|
||||
const { evaluateRegistryDeliveryProxyGate } = await import('../helpers/registryDeliveryProxy');
|
||||
const { req, res } = mockReqRes();
|
||||
const nodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
||||
const node = DatabaseService.getInstance().getNode(nodeId)!;
|
||||
|
||||
Object.defineProperty(req, 'aborted', { value: true, configurable: true });
|
||||
|
||||
await expect(
|
||||
evaluateRegistryDeliveryProxyGate(
|
||||
req,
|
||||
res,
|
||||
nodeId,
|
||||
node,
|
||||
'POST',
|
||||
'/api/blueprints/apply-local',
|
||||
),
|
||||
).resolves.toEqual({ outcome: 'stop' });
|
||||
});
|
||||
|
||||
it('maps capability miss to continue at the proxy gate', async () => {
|
||||
const { evaluateRegistryDeliveryProxyGate } = await import('../helpers/registryDeliveryProxy');
|
||||
const { req, res } = mockReqRes();
|
||||
const nodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
||||
const node = DatabaseService.getInstance().getNode(nodeId)!;
|
||||
|
||||
mockWouldAttempt.mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
evaluateRegistryDeliveryProxyGate(
|
||||
req,
|
||||
res,
|
||||
nodeId,
|
||||
node,
|
||||
'POST',
|
||||
'/api/blueprints/apply-local',
|
||||
),
|
||||
).resolves.toEqual({ outcome: 'continue' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Production proxy orchestration for registry credential delivery: abort must
|
||||
* stop forwarding, compressed bodies pass through when delivery is unavailable,
|
||||
* and return 415 only when delivery would run.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import http from 'http';
|
||||
import zlib from 'zlib';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
import { REMOTE_REGISTRY_CREDENTIALS_CAPABILITY } from '../services/CapabilityRegistry';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let authHeader: string;
|
||||
let remoteWithCapabilityId: number;
|
||||
let remoteWithoutCapabilityId: number;
|
||||
|
||||
const capturedHops: Array<{ method: string; url: string }> = [];
|
||||
let metaDelayMs = 0;
|
||||
|
||||
function createRemoteServer(capabilities: string[]): http.Server {
|
||||
return http.createServer((req, res) => {
|
||||
if (req.url?.startsWith('/api/meta')) {
|
||||
const respond = () => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ version: '0.97.1', capabilities }));
|
||||
};
|
||||
if (metaDelayMs > 0) {
|
||||
setTimeout(respond, metaDelayMs);
|
||||
return;
|
||||
}
|
||||
respond();
|
||||
return;
|
||||
}
|
||||
capturedHops.push({ method: req.method ?? '', url: req.url ?? '' });
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
}
|
||||
|
||||
async function listen(server: http.Server): Promise<number> {
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
return (server.address() as import('net').AddressInfo).port;
|
||||
}
|
||||
|
||||
let capableServer: http.Server;
|
||||
let incapableServer: http.Server;
|
||||
let capablePort: number;
|
||||
let incapablePort: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
const { RegistryDeliveryService } = await import('../services/RegistryDeliveryService');
|
||||
vi.spyOn(RegistryDeliveryService.getInstance(), 'isProxyTransportConfidential').mockReturnValue(true);
|
||||
|
||||
capableServer = createRemoteServer([REMOTE_REGISTRY_CREDENTIALS_CAPABILITY]);
|
||||
incapableServer = createRemoteServer([]);
|
||||
capablePort = await listen(capableServer);
|
||||
incapablePort = await listen(incapableServer);
|
||||
|
||||
remoteWithCapabilityId = DatabaseService.getInstance().addNode({
|
||||
name: 'regdelivery-capable',
|
||||
type: 'remote',
|
||||
mode: 'proxy',
|
||||
compose_dir: '/tmp',
|
||||
is_default: false,
|
||||
api_url: `http://127.0.0.1:${capablePort}`,
|
||||
api_token: 'capable-token',
|
||||
});
|
||||
|
||||
remoteWithoutCapabilityId = DatabaseService.getInstance().addNode({
|
||||
name: 'regdelivery-incapable',
|
||||
type: 'remote',
|
||||
mode: 'proxy',
|
||||
compose_dir: '/tmp',
|
||||
is_default: false,
|
||||
api_url: `http://127.0.0.1:${incapablePort}`,
|
||||
api_token: 'incapable-token',
|
||||
});
|
||||
|
||||
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1h' });
|
||||
authHeader = `Bearer ${token}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await Promise.all([
|
||||
new Promise<void>((resolve) => capableServer.close(() => resolve())),
|
||||
new Promise<void>((resolve) => incapableServer.close(() => resolve())),
|
||||
]);
|
||||
cleanupTestDb(tmpDir);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
capturedHops.length = 0;
|
||||
metaDelayMs = 0;
|
||||
});
|
||||
|
||||
describe('remoteNodeProxy registry delivery gate', () => {
|
||||
const deployPath = '/api/stacks/reg-proxy-gate/deploy';
|
||||
const gzipBody = zlib.gzipSync(Buffer.from('{}', 'utf-8'));
|
||||
|
||||
it('rejects gzip-encoded deploy when delivery would run', async () => {
|
||||
const res = await request(app)
|
||||
.post(deployPath)
|
||||
.set('Authorization', authHeader)
|
||||
.set('x-node-id', String(remoteWithCapabilityId))
|
||||
.set('Content-Encoding', 'gzip')
|
||||
.send(gzipBody);
|
||||
|
||||
expect(res.status).toBe(415);
|
||||
expect(res.body.code).toBe('encoding_unsupported');
|
||||
expect(capturedHops.some((h) => h.url.includes('/deploy'))).toBe(false);
|
||||
});
|
||||
|
||||
it('forwards gzip-encoded deploy unchanged when delivery is unavailable', async () => {
|
||||
const res = await request(app)
|
||||
.post(deployPath)
|
||||
.set('Authorization', authHeader)
|
||||
.set('x-node-id', String(remoteWithoutCapabilityId))
|
||||
.set('Content-Encoding', 'gzip')
|
||||
.send(gzipBody);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedHops.some((h) => h.url.includes('/deploy'))).toBe(true);
|
||||
});
|
||||
|
||||
it('does not forward deploy after client abort during capability probing', async () => {
|
||||
metaDelayMs = 800;
|
||||
const server = await new Promise<import('http').Server>((resolve) => {
|
||||
const s = app.listen(0, '127.0.0.1', () => resolve(s));
|
||||
});
|
||||
const port = (server.address() as import('net').AddressInfo).port;
|
||||
|
||||
const outcome = await new Promise<{ aborted: boolean }>((resolve) => {
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: '127.0.0.1',
|
||||
port,
|
||||
path: deployPath,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
'x-node-id': String(remoteWithCapabilityId),
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': '2',
|
||||
},
|
||||
},
|
||||
() => resolve({ aborted: false }),
|
||||
);
|
||||
req.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'ECONNRESET' || err.message === 'aborted') {
|
||||
resolve({ aborted: true });
|
||||
return;
|
||||
}
|
||||
resolve({ aborted: false });
|
||||
});
|
||||
req.write('{}');
|
||||
req.end();
|
||||
setTimeout(() => req.destroy(), 50);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
expect(outcome.aborted).toBe(true);
|
||||
expect(capturedHops.some((h) => h.url.includes('/deploy'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb } from './helpers/setupTestDb';
|
||||
import { RegistryDeliveryService } from '../services/RegistryDeliveryService';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import { StackOpLockService } from '../services/StackOpLockService';
|
||||
import { resolveRegistryAuthAtSeam } from '../helpers/registryDeliverySeam';
|
||||
import { hashActionSet, hashProjectSource } from '../helpers/registryDeliveryHashes';
|
||||
import { discoverRegistryReferences } from '../services/registryReferenceDiscovery';
|
||||
import { normalizeImageHost } from '../services/RegistryService';
|
||||
|
||||
function acquireLockForAttestation(
|
||||
nodeId: number,
|
||||
stack: string,
|
||||
attestation: string,
|
||||
stage: string,
|
||||
): void {
|
||||
const payload = jwt.decode(attestation) as jwt.JwtPayload;
|
||||
StackOpLockService.getInstance().tryAcquire(nodeId, stack, 'deploy', 'admin', {
|
||||
opId: String(payload.jti_t),
|
||||
kind: stage,
|
||||
});
|
||||
}
|
||||
|
||||
describe('registryDeliverySeam', () => {
|
||||
beforeEach(async () => {
|
||||
await setupTestDb();
|
||||
RegistryDeliveryService.resetForTests();
|
||||
StackOpLockService.resetForTests();
|
||||
});
|
||||
|
||||
it('merges delivered credentials for target-missing hosts at the seam', async () => {
|
||||
const nodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
||||
const stackName = 'regcred-test';
|
||||
const composeDir = NodeRegistry.getInstance().getComposeDir(nodeId);
|
||||
const stackDir = path.join(composeDir, stackName);
|
||||
fs.mkdirSync(stackDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(stackDir, 'compose.yaml'),
|
||||
'services:\n app:\n image: ghcr.io/example/private/app:latest\n',
|
||||
);
|
||||
|
||||
const sourceHash = hashProjectSource(stackDir);
|
||||
const referencedHosts = discoverRegistryReferences(stackDir).referencedHosts;
|
||||
const delivery = RegistryDeliveryService.getInstance();
|
||||
const attestation = delivery.signAttestation({
|
||||
nodeIdClaim: nodeId,
|
||||
stack: stackName,
|
||||
op: 'stack-deploy',
|
||||
sourceHash,
|
||||
referencedHostsHash: delivery.hashHostList(referencedHosts),
|
||||
coveredHostsHash: delivery.hashHostList([]),
|
||||
actionSetHash: hashActionSet(['stack:deploy']),
|
||||
});
|
||||
|
||||
const envelope = {
|
||||
attestation,
|
||||
auths: [{
|
||||
host: 'ghcr.io',
|
||||
username: 'hub-user',
|
||||
password: 'hub-pass',
|
||||
}],
|
||||
notAfter: Date.now() + 60_000,
|
||||
deliverySourceId: delivery.getDeliverySourceId(),
|
||||
};
|
||||
|
||||
acquireLockForAttestation(nodeId, stackName, attestation, 'stack-deploy');
|
||||
|
||||
const result = await resolveRegistryAuthAtSeam({
|
||||
envelope,
|
||||
nodeId,
|
||||
stack: stackName,
|
||||
stage: 'stack-deploy',
|
||||
});
|
||||
|
||||
const ghcrKey = referencedHosts.map(normalizeImageHost).find(h => h.includes('ghcr')) ?? 'ghcr.io';
|
||||
expect(result.auths[ghcrKey]).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects replayed jti at the seam', async () => {
|
||||
const nodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
||||
const stackName = 'regcred-replay';
|
||||
const composeDir = NodeRegistry.getInstance().getComposeDir(nodeId);
|
||||
const stackDir = path.join(composeDir, stackName);
|
||||
fs.mkdirSync(stackDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n app:\n image: nginx\n');
|
||||
|
||||
const sourceHash = hashProjectSource(stackDir);
|
||||
const referencedHosts = discoverRegistryReferences(stackDir).referencedHosts;
|
||||
const delivery = RegistryDeliveryService.getInstance();
|
||||
const attestation = delivery.signAttestation({
|
||||
nodeIdClaim: nodeId,
|
||||
stack: stackName,
|
||||
op: 'stack-deploy',
|
||||
sourceHash,
|
||||
referencedHostsHash: delivery.hashHostList(referencedHosts),
|
||||
coveredHostsHash: delivery.hashHostList([]),
|
||||
actionSetHash: hashActionSet(['stack:deploy']),
|
||||
});
|
||||
|
||||
const envelope = {
|
||||
attestation,
|
||||
auths: [],
|
||||
notAfter: Date.now() + 60_000,
|
||||
deliverySourceId: delivery.getDeliverySourceId(),
|
||||
};
|
||||
|
||||
acquireLockForAttestation(nodeId, stackName, attestation, 'stack-deploy');
|
||||
|
||||
await resolveRegistryAuthAtSeam({
|
||||
envelope,
|
||||
nodeId,
|
||||
stack: stackName,
|
||||
stage: 'stack-deploy',
|
||||
});
|
||||
|
||||
await expect(resolveRegistryAuthAtSeam({
|
||||
envelope,
|
||||
nodeId,
|
||||
stack: stackName,
|
||||
stage: 'stack-deploy',
|
||||
})).rejects.toThrow(/consumed/i);
|
||||
});
|
||||
|
||||
it('rejects seam when stack lock is not held', async () => {
|
||||
const nodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
||||
const stackName = 'regcred-no-lock';
|
||||
const composeDir = NodeRegistry.getInstance().getComposeDir(nodeId);
|
||||
const stackDir = path.join(composeDir, stackName);
|
||||
fs.mkdirSync(stackDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n app:\n image: nginx\n');
|
||||
|
||||
const sourceHash = hashProjectSource(stackDir);
|
||||
const referencedHosts = discoverRegistryReferences(stackDir).referencedHosts;
|
||||
const delivery = RegistryDeliveryService.getInstance();
|
||||
const attestation = delivery.signAttestation({
|
||||
nodeIdClaim: nodeId,
|
||||
stack: stackName,
|
||||
op: 'stack-deploy',
|
||||
sourceHash,
|
||||
referencedHostsHash: delivery.hashHostList(referencedHosts),
|
||||
coveredHostsHash: delivery.hashHostList([]),
|
||||
actionSetHash: hashActionSet(['stack:deploy']),
|
||||
});
|
||||
|
||||
await expect(resolveRegistryAuthAtSeam({
|
||||
envelope: {
|
||||
attestation,
|
||||
auths: [],
|
||||
notAfter: Date.now() + 60_000,
|
||||
deliverySourceId: delivery.getDeliverySourceId(),
|
||||
},
|
||||
nodeId,
|
||||
stack: stackName,
|
||||
stage: 'stack-deploy',
|
||||
})).rejects.toThrow(/stack lock required/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { setupTestDb } from './helpers/setupTestDb';
|
||||
import { RegistryDeliveryService } from '../services/RegistryDeliveryService';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import { hashActionSet, hashProjectSource } from '../helpers/registryDeliveryHashes';
|
||||
import { discoverRegistryReferences } from '../services/registryReferenceDiscovery';
|
||||
import { resolveComposeEnvForDiscovery } from '../helpers/registryDeliveryComposeEnv';
|
||||
|
||||
describe('RegistryDeliveryService', () => {
|
||||
beforeEach(async () => {
|
||||
await setupTestDb();
|
||||
RegistryDeliveryService.resetForTests();
|
||||
});
|
||||
|
||||
it('evicts expired consumed jtis before accepting new ones', () => {
|
||||
const delivery = RegistryDeliveryService.getInstance();
|
||||
const now = Date.now();
|
||||
|
||||
delivery.consumeAttestationJti('expired-a', now - 1_000);
|
||||
expect(() => delivery.consumeAttestationJti('fresh', now + 60_000)).not.toThrow();
|
||||
});
|
||||
|
||||
it('reclaims replay-store capacity after expired jtis are evicted', () => {
|
||||
const delivery = RegistryDeliveryService.getInstance();
|
||||
delivery.setReplayStoreCapacityForTests(2);
|
||||
const now = Date.now();
|
||||
|
||||
delivery.consumeAttestationJti('expired-slot', now - 1);
|
||||
delivery.consumeAttestationJti('active-slot', now + 60_000);
|
||||
expect(() => delivery.consumeAttestationJti('fresh-after-evict', now + 60_000)).not.toThrow();
|
||||
expect(() => delivery.consumeAttestationJti('overflow', now + 60_000)).toThrow(/capacity/i);
|
||||
});
|
||||
|
||||
it('rejects a jti that is still within its replay window', () => {
|
||||
const delivery = RegistryDeliveryService.getInstance();
|
||||
delivery.consumeAttestationJti('active', Date.now() + 60_000);
|
||||
expect(() => delivery.consumeAttestationJti('active', Date.now() + 60_000)).toThrow(/consumed/i);
|
||||
});
|
||||
|
||||
it('rejects restore-candidate discover when stack name is invalid', async () => {
|
||||
const delivery = RegistryDeliveryService.getInstance();
|
||||
|
||||
await expect(delivery.discoverOnTarget({
|
||||
op: 'stack-deploy',
|
||||
sourceKind: 'restore-candidate',
|
||||
stack: '../escape',
|
||||
actionSetHash: hashActionSet(['stack:deploy']),
|
||||
})).rejects.toThrow(/invalid stack name/i);
|
||||
});
|
||||
|
||||
it('blueprint body-content discover matches post-apply live-project hash and hosts', async () => {
|
||||
const delivery = RegistryDeliveryService.getInstance();
|
||||
const nodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
||||
const stackName = 'bp-regdisc-parity';
|
||||
const composeDir = NodeRegistry.getInstance().getComposeDir(nodeId);
|
||||
const stackDir = path.join(composeDir, stackName);
|
||||
fs.mkdirSync(stackDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(stackDir, 'compose.yaml'),
|
||||
'services:\n old:\n image: nginx:latest\n',
|
||||
);
|
||||
fs.writeFileSync(path.join(stackDir, '.env'), 'REGISTRY=ghcr.io\n');
|
||||
|
||||
const incomingCompose = 'services:\n app:\n image: ${REGISTRY}/org/private:latest\n';
|
||||
const discover = await delivery.discoverOnTarget({
|
||||
op: 'blueprint-apply',
|
||||
sourceKind: 'body-content',
|
||||
stack: stackName,
|
||||
composeContent: incomingCompose,
|
||||
actionSetHash: hashActionSet(['stack:deploy']),
|
||||
});
|
||||
|
||||
fs.writeFileSync(path.join(stackDir, 'compose.yaml'), incomingCompose);
|
||||
const seamHash = hashProjectSource(stackDir);
|
||||
const seamHosts = discoverRegistryReferences(
|
||||
stackDir,
|
||||
resolveComposeEnvForDiscovery(stackDir),
|
||||
).referencedHosts;
|
||||
|
||||
expect(discover.sourceHash).toBe(seamHash);
|
||||
expect(discover.referencedHosts).toEqual(seamHosts);
|
||||
expect(discover.referencedHosts).toEqual(['ghcr.io']);
|
||||
});
|
||||
|
||||
it('blueprint body-content discover matches seam hash when .env exists but is empty', async () => {
|
||||
const delivery = RegistryDeliveryService.getInstance();
|
||||
const nodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
||||
const stackName = 'bp-regdisc-empty-env';
|
||||
const composeDir = NodeRegistry.getInstance().getComposeDir(nodeId);
|
||||
const stackDir = path.join(composeDir, stackName);
|
||||
fs.mkdirSync(stackDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(stackDir, 'compose.yaml'),
|
||||
'services:\n old:\n image: nginx:latest\n',
|
||||
);
|
||||
fs.writeFileSync(path.join(stackDir, '.env'), '');
|
||||
|
||||
const incomingCompose = 'services:\n app:\n image: nginx:alpine\n';
|
||||
const discover = await delivery.discoverOnTarget({
|
||||
op: 'blueprint-apply',
|
||||
sourceKind: 'body-content',
|
||||
stack: stackName,
|
||||
composeContent: incomingCompose,
|
||||
actionSetHash: hashActionSet(['stack:deploy']),
|
||||
});
|
||||
|
||||
fs.writeFileSync(path.join(stackDir, 'compose.yaml'), incomingCompose);
|
||||
const seamHash = hashProjectSource(stackDir);
|
||||
|
||||
expect(discover.sourceHash).toBe(seamHash);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import {
|
||||
discoverRegistryReferences,
|
||||
discoverRegistryReferencesFromComposeContent,
|
||||
parseDockerfileReferences,
|
||||
} from '../services/registryReferenceDiscovery';
|
||||
|
||||
describe('registryReferenceDiscovery', () => {
|
||||
it('discovers hosts from compose files and Dockerfiles', () => {
|
||||
const dir = path.join(process.env.TMPDIR || '/tmp', `sencho-refdisc-${Date.now()}`);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'compose.yaml'),
|
||||
'services:\n app:\n image: ghcr.io/org/private-app:latest\n',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'Dockerfile'),
|
||||
'FROM docker.io/library/node:20\nCOPY --from=ghcr.io/org/cache:1 /app /app\n',
|
||||
);
|
||||
|
||||
const result = discoverRegistryReferences(dir);
|
||||
expect(result.referencedHosts).toContain('ghcr.io');
|
||||
expect(result.referencedHosts).toContain('index.docker.io');
|
||||
});
|
||||
|
||||
it('discovers hosts from inline compose content', () => {
|
||||
const result = discoverRegistryReferencesFromComposeContent(
|
||||
'services:\n app:\n image: ghcr.io/org/private-app:latest\n',
|
||||
);
|
||||
expect(result.referencedHosts).toEqual(['ghcr.io']);
|
||||
});
|
||||
|
||||
it('ignores numeric COPY --from stages in isolation', () => {
|
||||
const hosts = parseDockerfileReferences('COPY --from=0 /src /dest\n');
|
||||
expect(hosts).toEqual([]);
|
||||
});
|
||||
|
||||
it('parses FROM lines in Dockerfiles', () => {
|
||||
const hosts = parseDockerfileReferences('FROM alpine:3\n');
|
||||
expect(hosts).toEqual(['index.docker.io']);
|
||||
});
|
||||
|
||||
it('rejects oversized Dockerfiles', () => {
|
||||
const dir = path.join(process.env.TMPDIR || '/tmp', `sencho-refdisc-big-${Date.now()}`);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'compose.yaml'), 'services:\n web:\n image: nginx\n');
|
||||
fs.writeFileSync(path.join(dir, 'Dockerfile'), 'x'.repeat(1_048_577));
|
||||
|
||||
expect(() => discoverRegistryReferences(dir)).toThrow(/size limit/i);
|
||||
});
|
||||
|
||||
it('resolves registry variables from .env when env map is supplied', () => {
|
||||
const dir = path.join(process.env.TMPDIR || '/tmp', `sencho-refdisc-env-${Date.now()}`);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'compose.yaml'),
|
||||
'services:\n app:\n image: ${REGISTRY}/org/private:latest\n',
|
||||
);
|
||||
|
||||
const result = discoverRegistryReferences(dir, { REGISTRY: 'ghcr.io' });
|
||||
expect(result.referencedHosts).toEqual(['ghcr.io']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
getTrustedProxyBlockList,
|
||||
isTrustedProxyPeer,
|
||||
resetTrustedProxyBlockListCache,
|
||||
} from '../helpers/trustedProxyCidrs';
|
||||
|
||||
describe('trustedProxyCidrs', () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.SENCHO_TRUSTED_PROXY_CIDRS;
|
||||
resetTrustedProxyBlockListCache();
|
||||
});
|
||||
|
||||
it('returns null when unset', () => {
|
||||
expect(getTrustedProxyBlockList()).toBeNull();
|
||||
expect(isTrustedProxyPeer('10.0.0.1')).toBe(false);
|
||||
});
|
||||
|
||||
it('matches IPv4 CIDR members', () => {
|
||||
process.env.SENCHO_TRUSTED_PROXY_CIDRS = '10.0.0.0/8';
|
||||
resetTrustedProxyBlockListCache();
|
||||
expect(isTrustedProxyPeer('10.1.2.3')).toBe(true);
|
||||
expect(isTrustedProxyPeer('192.168.1.1')).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed on invalid entries', () => {
|
||||
process.env.SENCHO_TRUSTED_PROXY_CIDRS = 'not-a-cidr';
|
||||
resetTrustedProxyBlockListCache();
|
||||
expect(getTrustedProxyBlockList()).toBeNull();
|
||||
});
|
||||
|
||||
it('fails closed on duplicate entries', () => {
|
||||
process.env.SENCHO_TRUSTED_PROXY_CIDRS = '10.0.0.0/8,10.0.0.0/8';
|
||||
resetTrustedProxyBlockListCache();
|
||||
expect(getTrustedProxyBlockList()).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user