fix(mesh): trust only complete, contained compose declarations

Read every declaration layer through one guarded helper: real-target
containment, a non-blocking open, a descriptor type check, and a
before/after stat so a layer that changes mid-read is unknown. A link out
of the compose root and a special file such as a FIFO can no longer be
read as evidence that a service was removed, and no longer stall the
refresh.

A layer written within the last few seconds is treated as not settled, so
a valid prefix of a save that is still in progress cannot release a saved
port claim. Supported compose saves now publish by staging a sibling and
renaming it into place, which keeps in-stack links and the file mode and
leaves the previous document intact when publication fails.

Seed a new membership from the inspection opt-in already performed, so a
failed or stopped follow-up refresh cannot leave it with no saved ports
and no reservation.
This commit is contained in:
Anso
2026-10-24 19:06:12 -04:00
parent e9c47a5e49
commit 357920cd6c
7 changed files with 545 additions and 37 deletions
@@ -0,0 +1,79 @@
/**
* Compose saves must publish whole files. The mesh declaration reader (and
* every other consumer) treats a readable compose document as the stack's
* authored state, so a save that exposes a truncated prefix can be read as a
* service removal. Saving through a staging sibling and renaming it into place
* means a reader sees the previous file or the new one, never a prefix.
*/
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fsp } from 'fs';
import fsSync from 'fs';
import path from 'path';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
let tmpDir: string;
let FileSystemService: typeof import('../services/FileSystemService').FileSystemService;
let stackDir: string;
const STACK = 'atomic-save-stack';
function composeContent(services: string[]): string {
return `services:\n${services.map((svc) => ` ${svc}:\n image: busybox:latest`).join('\n')}\n`;
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ FileSystemService } = await import('../services/FileSystemService'));
stackDir = path.join(process.env.COMPOSE_DIR as string, STACK);
fsSync.mkdirSync(stackDir, { recursive: true });
fsSync.writeFileSync(path.join(stackDir, 'compose.yaml'), composeContent(['web']), 'utf8');
});
beforeEach(() => {
vi.restoreAllMocks();
fsSync.writeFileSync(path.join(stackDir, 'compose.yaml'), composeContent(['web']), 'utf8');
});
afterAll(() => {
vi.restoreAllMocks();
cleanupTestDb(tmpDir);
});
describe('compose save publication', () => {
it('publishes the new document by renaming a staged file over it', async () => {
const rename = vi.spyOn(fsp, 'rename');
const svc = FileSystemService.getInstance();
const before = await svc.getStackContentWithMtime(STACK);
const saved = await svc.saveStackContentIfUnchanged(
STACK,
composeContent(['web', 'db']),
before.mtimeMs,
'compose.yaml',
);
expect(saved).toMatchObject({ ok: true, filename: 'compose.yaml' });
expect(rename).toHaveBeenCalledTimes(1);
const [staged, target] = rename.mock.calls[0] as [string, string];
expect(path.basename(staged)).toContain('sencho-tmp');
expect(target).toBe(path.join(stackDir, 'compose.yaml'));
expect(fsSync.readFileSync(target, 'utf8')).toBe(composeContent(['web', 'db']));
expect(fsSync.readdirSync(stackDir).filter((name) => name.includes('sencho-tmp'))).toEqual([]);
});
it('leaves the previous document intact when publication fails', async () => {
vi.spyOn(fsp, 'rename').mockRejectedValue(new Error('rename failed'));
const svc = FileSystemService.getInstance();
const before = await svc.getStackContentWithMtime(STACK);
const previous = fsSync.readFileSync(path.join(stackDir, 'compose.yaml'), 'utf8');
await expect(svc.saveStackContentIfUnchanged(
STACK,
composeContent(['web', 'db']),
before.mtimeMs,
'compose.yaml',
)).rejects.toThrow('rename failed');
expect(fsSync.readFileSync(path.join(stackDir, 'compose.yaml'), 'utf8')).toBe(previous);
expect(fsSync.readdirSync(stackDir).filter((name) => name.includes('sencho-tmp'))).toEqual([]);
});
});
@@ -8,6 +8,7 @@
* unreachable nodes, and a stopped stack keeps its ports reserved.
*/
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { execFileSync } from 'child_process';
import fs from 'fs/promises';
import fsSync from 'fs';
import path from 'path';
@@ -87,18 +88,40 @@ function seedLastKnown(
* network shape.
*/
function writeRawComposeFile(stack: string, contents: string, fileName = 'compose.yaml'): void {
writeRawComposeFileNow(stack, contents, fileName);
settleComposeFile(stack, fileName);
}
/**
* A file written moments ago may be the visible prefix of a save that is still
* in progress, so the declaration reader does not trust it as evidence of a
* service removal. Tests that model a completed save backdate the mtime, which
* is what a file at rest looks like on a live node.
*/
function settleComposeFile(stack: string, fileName = 'compose.yaml'): void {
const file = path.join(process.env.COMPOSE_DIR as string, stack, fileName);
const past = new Date(Date.now() - 60_000);
fsSync.utimesSync(file, past, past);
}
function writeRawComposeFileNow(stack: string, contents: string, fileName = 'compose.yaml'): void {
const composeDir = process.env.COMPOSE_DIR as string;
const dir = path.join(composeDir, stack);
fsSync.mkdirSync(dir, { recursive: true });
fsSync.writeFileSync(path.join(dir, fileName), contents, 'utf8');
}
function composeYaml(services: string[]): string {
return `services:\n${services.map((svc) => ` ${svc}:\n image: busybox:latest`).join('\n')}\n`;
}
function writeComposeFile(stack: string, services: string[], fileName = 'compose.yaml'): void {
writeRawComposeFile(
stack,
`services:\n${services.map((svc) => ` ${svc}:\n image: busybox:latest`).join('\n')}\n`,
fileName,
);
writeRawComposeFile(stack, composeYaml(services), fileName);
}
/** Model the visible prefix of an in-place save: valid, complete-looking, fresh. */
function writeUnsettledComposeFile(stack: string, services: string[], fileName = 'compose.yaml'): void {
writeRawComposeFileNow(stack, composeYaml(services), fileName);
}
/**
@@ -661,6 +684,16 @@ describe('declaration reconciliation trusts only readable compose content', () =
return parseLastKnownServices(row?.last_known_services);
}
/** Snapshot order follows the saved list, so compare the set, not the order. */
function expectBothClaims(nodeId: number, stack: string): void {
const saved = savedServices(nodeId, stack);
expect(saved).toEqual(expect.arrayContaining([
{ service: 'web', ports: [8080] },
{ service: 'db', ports: [5432] },
]));
expect(saved).toHaveLength(2);
}
/**
* A declaration saved as an array or a scalar is not Compose content, so
* it says nothing about which services the stack used to own. The saved
@@ -901,6 +934,181 @@ describe('declaration reconciliation trusts only readable compose content', () =
]);
expect((svc as unknown as Internals).reservedPorts.get(5432)?.host).toBe(host);
});
/**
* A save that has not finished still looks like a valid document on disk:
* an editor truncates the file and writes the new text, so a reader can
* catch a prefix that parses and simply omits the services not written
* yet. Treating that prefix as an authored removal would release the exact
* reservation this feature exists to keep, permanently.
*/
it('keeps both saved claims while a save is still in progress', async () => {
const stack = 'interrupted-save-stack';
const localNode = seedClaim(stack, [
{ service: 'db', ports: [5432] },
{ service: 'web', ports: [8080] },
]);
const host = `db.${stack}.${localNode.name}.sencho`;
writeComposeFile(stack, ['web', 'db']);
await withLocalDocker(async () => []);
const svc = MeshService.getInstance();
await svc.refreshAliasCache();
// The visible prefix of the save: valid YAML that does not mention db.
writeUnsettledComposeFile(stack, ['web']);
await svc.refreshAliasCache();
expectBothClaims(localNode.id, stack);
expect((svc as unknown as Internals).reservedPorts.get(5432)?.host).toBe(host);
await withLocalDocker(async (opts) => (
stackFromFilter(opts) === 'taker' ? [container('taker', 'running', [5432])] : []
));
vi.spyOn(
svc as unknown as { regenerateOverridesAcrossFleet: (n?: number, s?: string) => Promise<void> },
'regenerateOverridesAcrossFleet',
).mockResolvedValue(undefined);
await expect(svc.optInStack(localNode.id, 'taker', 'tester'))
.rejects.toThrow(`port 5432 is already claimed by ${host}`);
// The save completes. The claim never left, so it is still there once
// the file has been at rest and the declaration reads normally again.
writeUnsettledComposeFile(stack, ['web', 'db']);
settleComposeFile(stack);
await withLocalDocker(async () => []);
await svc.refreshAliasCache();
expectBothClaims(localNode.id, stack);
// A later tick that cannot reach the node replays both hostnames and
// both reservations, because the snapshot was never reduced.
await withLocalDocker(() => Promise.reject(new Error('docker socket unavailable')));
await svc.refreshAliasCache();
expect((await svc.listAliases()).map((a) => a.host)).toEqual(expect.arrayContaining([
`web.${stack}.${localNode.name}.sencho`,
host,
]));
expect((svc as unknown as Internals).reservedPorts.get(5432)?.host).toBe(host);
});
it('keeps the saved claim when a discovered override links outside the compose root', async () => {
// The canonical compose guard refuses a link that leaves the compose
// root. The declaration reader must apply the same rule instead of
// following the link and reading its service names as evidence.
const stack = 'outside-override-link-stack';
const localNode = seedClaim(stack, [
{ service: 'db', ports: [5432] },
{ service: 'web', ports: [8080] },
]);
const host = `db.${stack}.${localNode.name}.sencho`;
writeComposeFile(stack, ['web']);
const outside = path.join(tmpDir, 'outside');
fsSync.mkdirSync(outside, { recursive: true });
fsSync.writeFileSync(
path.join(outside, 'compose.override.yml'),
'services:\n other:\n image: busybox:latest\n',
'utf8',
);
const link = path.join(process.env.COMPOSE_DIR as string, stack, 'compose.override.yml');
fsSync.symlinkSync(path.join(outside, 'compose.override.yml'), link);
const { FileSystemService } = await import('../services/FileSystemService');
await expect(FileSystemService.getInstance(localNode.id).assertComposeFileReadable(stack))
.rejects.toMatchObject({ code: 'SYMLINK_ESCAPE' });
await withLocalDocker(async () => []);
const svc = MeshService.getInstance();
await svc.refreshAliasCache();
expectBothClaims(localNode.id, stack);
expect((svc as unknown as Internals).reservedPorts.get(5432)?.host).toBe(host);
});
it('keeps the saved claim when an explicit Git file links outside the compose root', async () => {
const stack = 'git-outside-link-stack';
const db = DatabaseService.getInstance();
const localNode = seedClaim(stack, [
{ service: 'db', ports: [5432] },
{ service: 'web', ports: [8080] },
]);
const host = `db.${stack}.${localNode.name}.sencho`;
writeComposeFile(stack, ['web']);
const outside = path.join(tmpDir, 'outside-git');
fsSync.mkdirSync(outside, { recursive: true });
fsSync.writeFileSync(
path.join(outside, 'extra.yaml'),
'services:\n other:\n image: busybox:latest\n',
'utf8',
);
const link = path.join(process.env.COMPOSE_DIR as string, stack, 'extra.yaml');
fsSync.symlinkSync(path.join(outside, 'extra.yaml'), link);
vi.spyOn(db, 'getGitSource').mockReturnValue({
applied_deploy_spec: { files: ['compose.yaml', 'extra.yaml'], contextDir: null },
} as unknown as ReturnType<typeof DatabaseService.prototype.getGitSource>);
await withLocalDocker(async () => []);
const svc = MeshService.getInstance();
await svc.refreshAliasCache();
expectBothClaims(localNode.id, stack);
expect((svc as unknown as Internals).reservedPorts.get(5432)?.host).toBe(host);
});
it.skipIf(process.platform === 'win32')(
'treats a special file as unknown without stalling the refresh',
async () => {
// A FIFO named like the discovered override would make a blocking
// readFile wait for a writer that never comes, holding up every
// other membership's publication.
const stack = 'fifo-declaration-stack';
const db = DatabaseService.getInstance();
const localNode = seedClaim(stack, [
{ service: 'db', ports: [5432] },
{ service: 'web', ports: [8080] },
]);
const host = `db.${stack}.${localNode.name}.sencho`;
const neighbourHost = `api.neighbour-stack.${localNode.name}.sencho`;
writeComposeFile(stack, ['web']);
const fifo = path.join(process.env.COMPOSE_DIR as string, stack, 'compose.override.yml');
execFileSync('mkfifo', [fifo]);
db.insertMeshStack(localNode.id, 'neighbour-stack', 'tester');
await withLocalDocker(async (opts) => (
stackFromFilter(opts) === 'neighbour-stack' ? [container('api', 'running', [3000])] : []
));
const svc = MeshService.getInstance();
const deadline = new Promise<'timeout'>((resolve) => {
setTimeout(() => resolve('timeout'), 3_000).unref();
});
await expect(Promise.race([svc.refreshAliasCache(), deadline])).resolves.not.toBe('timeout');
expectBothClaims(localNode.id, stack);
expect((svc as unknown as Internals).reservedPorts.get(5432)?.host).toBe(host);
// The healthy membership still published on the same tick.
expect((await svc.listAliases()).map((a) => a.host)).toContain(neighbourHost);
},
);
it('still follows a discovered override that links inside the compose root', async () => {
// In-root links are the supported sharing shape: the canonical read
// guard accepts them, so the declaration reader must keep doing so.
const stack = 'in-root-link-stack';
seedClaim(stack, [
{ service: 'db', ports: [5432] },
{ service: 'web', ports: [8080] },
]);
writeComposeFile(stack, ['web']);
const shared = path.join(process.env.COMPOSE_DIR as string, 'shared-overrides');
fsSync.mkdirSync(shared, { recursive: true });
fsSync.writeFileSync(path.join(shared, 'base.yml'), composeYaml(['db']), 'utf8');
const link = path.join(process.env.COMPOSE_DIR as string, stack, 'compose.override.yml');
fsSync.symlinkSync(path.join(shared, 'base.yml'), link);
settleComposeFile(stack, 'compose.override.yml');
const svc = MeshService.getInstance();
expect((await svc.getLocalDeclaredServiceNames(stack))?.sort()).toEqual(['db', 'web']);
});
});
/**
@@ -1058,6 +1266,57 @@ describe('mesh opt-out recovery', () => {
});
});
/**
* Opt-in already holds a successful observation when it inserts the
* membership. The refresh that follows re-inspects, but the membership must
* not depend on that second look: a Docker failure or a stop in between would
* otherwise leave the row with no saved ports and no reservation, so the stack
* would be opted in without the stability guarantee the opt-in promised.
*/
describe('opt-in keeps its first observation', () => {
type OptInInternals = {
inspectStackServices: (nodeId: number, stack: string, reach?: { reachable: boolean }) => Promise<Observed[]>;
syncForwarderListeners: () => Promise<void>;
regenerateOverridesAcrossFleet: (n?: number, s?: string) => Promise<void>;
cascadeRecomposeAcrossFleet: (n: number | undefined, s: string | undefined, actor: string) => void;
reservedPorts: Map<number, Alias>;
};
it('persists the ports the successful opt-in inspection returned', async () => {
const db = DatabaseService.getInstance();
const localNode = db.getDefaultNode();
if (!localNode) throw new Error('missing default node');
const stack = 'optin-first-observation';
writeComposeFile(stack, ['web']);
const svc = MeshService.getInstance();
const internals = svc as unknown as OptInInternals;
let inspections = 0;
vi.spyOn(internals, 'inspectStackServices').mockImplementation(async (_nodeId, _stack, reach) => {
inspections += 1;
if (inspections === 1) return [{ service: 'web', ports: [8080], running: true }];
if (reach) reach.reachable = false;
return [];
});
vi.spyOn(svc, 'pushOverrideToNode').mockResolvedValue(undefined);
vi.spyOn(internals, 'syncForwarderListeners').mockResolvedValue(undefined);
vi.spyOn(internals, 'regenerateOverridesAcrossFleet').mockResolvedValue(undefined);
vi.spyOn(internals, 'cascadeRecomposeAcrossFleet').mockImplementation(() => undefined);
await svc.optInStack(localNode.id, stack, 'tester');
expect(db.isMeshStackEnabled(localNode.id, stack)).toBe(true);
const row = db.listMeshStacks(localNode.id).find((r) => r.stack_name === stack);
expect(parseLastKnownServices(row?.last_known_services)).toEqual([{ service: 'web', ports: [8080] }]);
expect(internals.reservedPorts.get(8080)?.stackName).toBe(stack);
await svc.refreshAliasCache();
expect((await svc.listAliases()).map((a) => a.host))
.toContain(`web.${stack}.${localNode.name}.sencho`);
expect(internals.reservedPorts.get(8080)?.stackName).toBe(stack);
});
});
describe('parseLastKnownServices', () => {
// A corrupt or hand-edited column must not take the refresh down, and
// must not silently resurrect nonsense ports. Anything it cannot vouch
@@ -8,6 +8,7 @@
* answers apart at the route boundary.
*/
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { execFileSync } from 'child_process';
import fsSync from 'fs';
import path from 'path';
import request from 'supertest';
@@ -94,11 +95,16 @@ describe('GET /api/mesh/local-declared-services/:stackName', () => {
function writeComposeFile(stack: string): void {
const dir = path.join(process.env.COMPOSE_DIR as string, stack);
fsSync.mkdirSync(dir, { recursive: true });
const file = path.join(dir, 'compose.yaml');
fsSync.writeFileSync(
path.join(dir, 'compose.yaml'),
file,
'services:\n db:\n image: busybox:latest\n web:\n image: busybox:latest\n',
'utf8',
);
// The declaration reader only trusts a layer that has been at rest, so
// a completed save is modelled with an older mtime.
const past = new Date(Date.now() - 60_000);
fsSync.utimesSync(file, past, past);
}
it('answers 200 with the declared service names', async () => {
@@ -136,4 +142,46 @@ describe('GET /api/mesh/local-declared-services/:stackName', () => {
expect(res.status).toBe(503);
expect(res.body.error).toMatch(/declaration is unreadable/i);
});
it('answers 503 when a discovered override links outside the compose root', async () => {
// The same link is refused by the canonical compose read guard. Reading
// its service names as a declaration would release claims from a file
// the compose root does not own.
const stack = 'outside-link-declared-stack';
const dir = path.join(process.env.COMPOSE_DIR as string, stack);
fsSync.mkdirSync(dir, { recursive: true });
fsSync.writeFileSync(path.join(dir, 'compose.yaml'), 'services:\n web:\n image: busybox:latest\n', 'utf8');
const tmpDir = path.dirname(process.env.COMPOSE_DIR as string);
const outside = path.join(tmpDir, 'outside-declaration');
fsSync.mkdirSync(outside, { recursive: true });
const outsideOverride = path.join(outside, 'compose.override.yml');
fsSync.writeFileSync(outsideOverride, 'services:\n other:\n image: busybox:latest\n', 'utf8');
fsSync.symlinkSync(outsideOverride, path.join(dir, 'compose.override.yml'));
const res = await request(app)
.get(`/api/mesh/local-declared-services/${stack}`)
.set('Authorization', `Bearer ${adminToken()}`);
expect(res.status).toBe(503);
expect(res.body.error).toMatch(/declaration is unreadable/i);
});
it.skipIf(process.platform === 'win32')('answers 503 for a special file instead of blocking', async () => {
const stack = 'fifo-declared-stack';
const dir = path.join(process.env.COMPOSE_DIR as string, stack);
fsSync.mkdirSync(dir, { recursive: true });
fsSync.writeFileSync(path.join(dir, 'compose.yaml'), 'services:\n web:\n image: busybox:latest\n', 'utf8');
execFileSync('mkfifo', [path.join(dir, 'compose.override.yml')]);
const deadline = new Promise<'timeout'>((resolve) => {
setTimeout(() => resolve('timeout'), 3_000).unref();
});
const answered = request(app)
.get(`/api/mesh/local-declared-services/${stack}`)
.set('Authorization', `Bearer ${adminToken()}`)
.then((res) => res.status);
await expect(Promise.race([answered, deadline])).resolves.not.toBe('timeout');
expect(await answered).toBe(503);
});
});
+10 -3
View File
@@ -3594,14 +3594,21 @@ stmt.run('gitops_schema_version', '1');
return !!row;
}
public insertMeshStack(nodeId: number, stackName: string, createdBy: string | null): void {
/**
* Insert a membership and return the new row id. The id is the membership's
* identity for saved-service updates, so an opt-in can seed the observation
* it already made before another refresh runs. Returns null on a pilot,
* where memberships live on central.
*/
public insertMeshStack(nodeId: number, stackName: string, createdBy: string | null): number | null {
if (isPilotMode()) {
console.warn(`[DatabaseService] insertMeshStack ignored on pilot (node=${nodeId}, stack=${stackName})`);
return;
return null;
}
this.db.prepare(
const info = this.db.prepare(
'INSERT INTO mesh_stacks (node_id, stack_name, created_at, created_by) VALUES (?, ?, ?, ?)'
).run(nodeId, stackName, Date.now(), createdBy);
return Number(info.lastInsertRowid);
}
public deleteMeshStack(nodeId: number, stackName: string): void {
+91 -9
View File
@@ -7,7 +7,7 @@ import { Readable } from 'stream';
import { pipeline } from 'stream/promises';
import { NodeRegistry } from './NodeRegistry';
import { DatabaseService } from './DatabaseService';
import { isPathWithinBase, isValidStackName } from '../utils/validation';
import { isPathWithinBase, isValidRelativeStackPath, isValidStackName } from '../utils/validation';
import { COMPOSE_OVERRIDE_FILENAMES, COMPOSE_ROOT_FILENAMES } from '../utils/composeFilenames';
import { isBinaryBuffer } from '../utils/binaryDetect';
import { sanitizeForLog } from '../utils/safeLog';
@@ -499,6 +499,31 @@ export class FileSystemService {
}
}
/**
* Publish a managed compose file as a whole. The target may be a link that
* stays inside the compose root (a stack sharing a main file or an
* override), so the link is resolved first and its target is replaced;
* renaming onto the link path would replace the link itself. A reader
* therefore sees the previous document or the new one, never a prefix, and a
* failed publication leaves the previous document intact. The existing
* mode is kept so a restrictive compose file does not widen on save.
*/
private async publishManagedFile(safePath: string, content: string | Buffer): Promise<void> {
let target = safePath;
let mode: number | undefined;
try {
const stat = await fsPromises.stat(safePath);
mode = stat.mode & 0o777;
target = await fsPromises.realpath(safePath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
await this.writeStackFileAtomic(target, content, {
createParent: false,
...(mode === undefined ? {} : { mode }),
});
}
async saveStackContent(stackName: string, content: string | Buffer): Promise<void> {
const stackDir = this.resolveStackDir(stackName);
const filePath = path.join(stackDir, 'compose.yaml');
@@ -506,10 +531,11 @@ export class FileSystemService {
await this.assertRealWithinStack(stackName, stackDir);
await this.assertRealWithinStack(stackName, filePath);
try {
// Buffer input is written byte-exact (the encoding option is ignored for
// Buffers); string input keeps the utf-8 write. Byte-exactness matters to
// the Git materializer, whose content hashes are computed over raw bytes.
await fsPromises.writeFile(filePath, content, 'utf-8');
// Buffer input is published byte-exact (the encoding option is ignored
// for Buffers); string input keeps the utf-8 write. Byte-exactness
// matters to the Git materializer, whose content hashes are computed
// over raw bytes.
await this.publishManagedFile(filePath, content);
} catch (error) {
console.error('Error writing file:', error);
throw new Error(`Failed to save stack: ${stackName}`);
@@ -605,7 +631,7 @@ export class FileSystemService {
}
}
await fsPromises.writeFile(safePath, content, 'utf-8');
await this.publishManagedFile(safePath, content);
const newStat = await fsPromises.stat(safePath);
return { ok: true, mtimeMs: newStat.mtimeMs, filename };
}
@@ -627,6 +653,60 @@ export class FileSystemService {
}
}
/**
* Read one layer of a stack's compose declaration: the resolved main file,
* an applied Git file, or the hand-authored override. The mesh declaration
* reader uses the returned names to decide whether a saved port claim is
* still owned, so this read has to be at least as strict as the canonical
* compose read.
*
* Containment is checked on the real target, so a link that stays inside the
* compose root is followed (the supported sharing shape) and one that leaves
* it is refused. The descriptor is opened non-blocking and its own type is
* authoritative, so a directory, FIFO, or socket named like a compose layer
* is refused instead of parking the caller on a read that waits for a writer.
* A file that changes while it is being read is refused too: a half-written
* save is not an authored declaration.
*
* Throws for every refusal, including `SYMLINK_ESCAPE`, `NOT_A_FILE`, and
* `COMPOSE_LAYER_UNSTABLE`. Callers that release a saved claim must treat any
* throw as unknown and keep the claim.
*/
async readComposeLayer(stackName: string, relFile: string): Promise<{ content: string; mtimeMs: number }> {
if (relFile === '' || !isValidRelativeStackPath(relFile)) {
throw Object.assign(new Error('Invalid compose layer path'), { code: 'INVALID_PATH' });
}
const stackDir = this.resolveStackDir(stackName);
await this.assertRealWithinBase(stackDir);
await this.assertRealWithinStack(stackName, stackDir);
// Canonical js/path-injection barrier inline with the open sink, mirroring
// the other compose reads in this file.
const baseResolved = path.resolve(this.baseDir);
const layerPath = path.resolve(baseResolved, stackName, relFile);
if (!layerPath.startsWith(baseResolved + path.sep)) {
throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' });
}
await this.assertRealWithinBase(layerPath);
const fh = await fsPromises.open(layerPath, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK);
try {
const before = await fh.stat();
if (!before.isFile()) throw nonRegularComposeFileError(path.basename(layerPath));
const content = await fh.readFile('utf-8');
const after = await fh.stat();
if (after.ino !== before.ino || after.size !== before.size || after.mtimeMs !== before.mtimeMs) {
throw Object.assign(
new Error('Compose layer changed while it was being read'),
{ code: 'COMPOSE_LAYER_UNSTABLE' },
);
}
return { content, mtimeMs: after.mtimeMs };
} finally {
await fh.close();
}
}
/**
* Optimistic-concurrency write for an env file under a stack directory (the
* path was already validated by the caller).
@@ -948,7 +1028,7 @@ export class FileSystemService {
# - "8080:80"
`;
try {
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), boilerplate, 'utf-8');
await this.publishManagedFile(path.join(stackDir, 'compose.yaml'), boilerplate);
} catch (error) {
console.error('Error creating stack:', error);
throw new Error(`Failed to create stack: ${stackName}`);
@@ -2218,7 +2298,7 @@ export class FileSystemService {
private async writeStackFileAtomic(
safePath: string,
data: string | Buffer | Readable,
opts: { exclusive?: boolean; mode?: number } = {},
opts: { exclusive?: boolean; mode?: number; createParent?: boolean } = {},
): Promise<void> {
if (
opts.mode !== undefined
@@ -2226,7 +2306,9 @@ export class FileSystemService {
) {
throw Object.assign(new Error('Invalid permission bits'), { code: 'INVALID_PATH' });
}
await fsPromises.mkdir(path.dirname(safePath), { recursive: true });
if (opts.createParent !== false) {
await fsPromises.mkdir(path.dirname(safePath), { recursive: true });
}
// crypto.randomBytes gives a guaranteed-length high-entropy suffix; Math.random
// can drop leading zeros which narrows entropy unpredictably.
const suffix = `${process.pid}-${Date.now()}-${crypto.randomBytes(6).toString('hex')}`;
+51 -18
View File
@@ -34,6 +34,14 @@ import {
const ACTIVITY_BUFFER_SIZE = 1000;
const ALIAS_REFRESH_INTERVAL_MS = 60_000;
/**
* A compose layer written moments ago may be the visible prefix of a save that
* is still in progress. The declaration is the only evidence that releases a
* saved port claim, so a layer that has not been at rest for this long is
* reported as unknown and the claim stays. Supported compose saves publish by
* rename, so this window covers writers Sencho does not control.
*/
const DECLARATION_SETTLE_MS = 5_000;
const PROBE_TIMEOUT_MS = 5_000;
const SLOW_PROBE_THRESHOLD_MS = 500;
const DEFAULT_MESH_SUBNET = '172.30.0.0/24';
@@ -1727,7 +1735,19 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
}
}
db.insertMeshStack(nodeId, stackName, actor);
const rowId = db.insertMeshStack(nodeId, stackName, actor);
// Seed the new membership from the observation opt-in already made.
// The refresh below re-inspects, but a Docker failure or a stop in
// between must not leave the membership with no saved ports and no
// reservation: the stack was accepted on the strength of that first
// observation, so it is the snapshot the replay and collision check
// need from the start.
if (rowId !== null) {
db.setMeshStackLastKnownServices(
rowId,
JSON.stringify(services.map(({ service, ports }) => ({ service, ports }))),
);
}
try {
await this.refreshAliasCache();
await this.syncForwarderListeners();
@@ -2581,14 +2601,11 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
const shapes: Record<string, MeshServiceNetworkShape> = {};
try {
const fsSvc = FileSystemService.getInstance(targetNodeId);
const baseDir = fsSvc.getBaseDir();
for (const relFile of await this.resolveStackComposeFiles(fsSvc, stackName)) {
// The stack segment uses path.basename as defense-in-depth and
// the resolved path is re-checked against the base dir, so the
// form CodeQL's path-injection model recognizes stays at the
// read sink.
const composePath = path.join(baseDir, path.basename(stackName), relFile);
if (!isPathWithinBase(composePath, baseDir)) continue;
// Same containment, regular-file, and stability guard the
// declaration reader uses. One layer that cannot be read as a
// complete document is skipped rather than parsed as a partial
// service list.
let parsed: { services?: Record<string, unknown> } | null;
try {
// merge: true resolves YAML 1.1 merge keys (`<<: *anchor`).
@@ -2597,7 +2614,8 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
// `networks` through an anchor looks like it declares
// neither, so the override adds networks and extra_hosts
// to a service Compose refuses to start with them.
parsed = YAML.parse(await fs.readFile(composePath, 'utf8'), { merge: true }) as { services?: Record<string, unknown> } | null;
const layer = await fsSvc.readComposeLayer(stackName, relFile);
parsed = YAML.parse(layer.content, { merge: true }) as { services?: Record<string, unknown> } | null;
} catch {
continue;
}
@@ -2628,15 +2646,15 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
/**
* Read the service names the stack's Compose file(s) declare on a node.
* Returns null when the declaration cannot be established: a missing or
* unreadable file, or one that does not parse. The alias refresh uses this
* to tell an authored removal from a container that is merely absent for a
* tick, so "unknown" must not read as "declares nothing".
* Returns null when the declaration cannot be established: a missing,
* unreadable, unsafe, or still-changing file, or one that does not parse.
* The alias refresh uses this to tell an authored removal from a container
* that is merely absent for a tick, so "unknown" must not read as "declares
* nothing".
*/
private async readDeclaredServiceNames(stackName: string, nodeId: number): Promise<Set<string> | null> {
if (!isValidStackName(stackName)) return null;
const fsSvc = FileSystemService.getInstance(nodeId);
const baseDir = fsSvc.getBaseDir();
let relFiles: string[];
try {
const spec = DatabaseService.getInstance().getGitSource(stackName)?.applied_deploy_spec;
@@ -2667,14 +2685,29 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
if (relFiles.length === 0) return null;
const names = new Set<string>();
for (const relFile of relFiles) {
const composePath = path.join(baseDir, path.basename(stackName), relFile);
if (!isPathWithinBase(composePath, baseDir)) return null;
let parsed: unknown;
let content: string;
try {
content = await fs.readFile(composePath, 'utf8');
// Every layer goes through the canonical containment and
// regular-file guard. A link out of the compose root, a special
// file, or a layer that changed mid-read is refused, and a
// refused layer keeps every saved claim.
const layer = await fsSvc.readComposeLayer(stackName, relFile);
// A save that started moments ago can expose a valid prefix
// that omits services not written yet. It is not authored
// evidence of a removal until it has been at rest.
if (Date.now() - layer.mtimeMs < DECLARATION_SETTLE_MS) {
this.logDiag('compose declaration layer not settled', { stackName, relFile });
return null;
}
content = layer.content;
parsed = YAML.parse(content, { merge: true });
} catch {
} catch (err) {
this.logDiag('compose declaration layer unreadable', {
stackName,
relFile,
message: (err as Error).message,
});
return null;
}
// An empty or comment-only document is a valid no-op Compose
+1 -1
View File
@@ -70,7 +70,7 @@ Each opt-in and opt-out triggers a redeploy of the affected stack so its `/etc/h
**Stack stopped.** The opt-in record is sticky. A stack that opts in and then stops continues to show on the node card with a `suspended` pill and the caption `Stack stopped, alias resumes when services start.` Aliases republish on the next alias-refresh tick (within roughly one minute) when the stack starts again. No need to opt out and back in.
**Node offline.** When Sencho cannot determine a node's current state, it keeps the aliases and ports that node's meshed stacks last published. That covers an offline node and a node that answers with an error, such as after its API token rotates. Other nodes' overrides still list those hostnames (so redeploying a stack elsewhere does not drop them), and no other stack can claim those ports while the node is away. Connections to the node fail until it responds again. Stopped stacks release their aliases but keep their ports reserved, so a stopped stack never frees a port for a different stack to take, and a stack that is mid-teardown during a Take down or update keeps them too. A reservation is released only when Sencho can read the stack's full compose declaration and the service is no longer part of it: the service runs again without that port, it leaves the stack's compose file, or you opt the stack out. When the declaration is incomplete or cannot be read, for example a stack that selects its own compose files with `COMPOSE_FILE`, the reservation stays until you opt the stack out. For a port held by an offline node, the recovery is to wait for the node or remove it from the fleet.
**Node offline.** When Sencho cannot determine a node's current state, it keeps the aliases and ports that node's meshed stacks last published. That covers an offline node and a node that answers with an error, such as after its API token rotates. Other nodes' overrides still list those hostnames (so redeploying a stack elsewhere does not drop them), and no other stack can claim those ports while the node is away. Connections to the node fail until it responds again. Stopped stacks release their aliases but keep their ports reserved, so a stopped stack never frees a port for a different stack to take, and a stack that is mid-teardown during a Take down or update keeps them too. A reservation is released only when Sencho can read the stack's full compose declaration and the service is no longer part of it: the service runs again without that port, it leaves the stack's compose file, or you opt the stack out. A save that has not finished is not read as a removal either, so a compose file being rewritten cannot free a port by accident. When the declaration is incomplete or cannot be read, for example a stack that selects its own compose files with `COMPOSE_FILE`, the reservation stays until you opt the stack out. For a port held by an offline node, the recovery is to wait for the node or remove it from the fleet.
**Peer reconnect.** On every alias-refresh tick (60 s), central recomputes the global alias set and pushes refreshed overrides to any node whose set drifted. When a pilot tunnel comes up, central also re-pushes that node's overrides, so the boot race does not leave them stale. Stack redeploys are skipped if the override content is unchanged.