mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
feat: first-boot compose discovery and adopt-first sidebar (#1600)
* feat: add compose discovery for setup preflight and sidebar empty state Expose read-only compose discovery via GET /api/stacks/discovery and setup diagnostics. Replace the blank sidebar with path-aware discovery and move adopt into a dedicated dialog with a three-tab Create Stack flow. * test: assert post-setup handoff via sessionStorage read-back The Setup preflight test spied on Storage.prototype.setItem to check the post-setup adopt handoff. When the jsdom storage probe fails and the test harness swaps in its in-memory storage stub (which does not extend Storage), that stub's setItem never touches Storage.prototype, so the spy records zero calls and the assertion fails even though the component wrote the value. Read the value back with sessionStorage.getItem instead, matching how every other storage test in the suite asserts. This is robust to both the native jsdom storage and the in-memory fallback. * fix(setup): surface compose discovery as a preflight check row Drop the Setup discovery banner and non-working Review button. Show counts as a pass row in EnvironmentChecks (Setup only) and keep Enter Sencho as the handoff that opens adopt when candidates exist. * test(setup): cover zero-count discovery row omission * fix(stacks): widen adopt scan to any yaml and rename into place Homelab layouts often use nginx.yml or plex.yml. Surface those for adopt (except overrides), rename to compose.yaml on move so stacks register, and reset the confirm UI when a move fails.
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Tests for ComposeDiscoveryService.probeComposeDiscovery and
|
||||
* FileSystemService.countImportCandidates.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { probeComposeDiscovery } from '../services/ComposeDiscoveryService';
|
||||
import { FileSystemService } from '../services/FileSystemService';
|
||||
|
||||
const { tmpRoot } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const nodeOs = require('os');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const nodePath = require('path');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const nodeFs = require('fs');
|
||||
const tmpRoot: string = nodeFs.mkdtempSync(nodePath.join(nodeOs.tmpdir(), 'sencho-discovery-'));
|
||||
return { tmpRoot };
|
||||
});
|
||||
|
||||
vi.mock('../services/NodeRegistry', () => ({
|
||||
NodeRegistry: {
|
||||
getInstance: () => ({
|
||||
getComposeDir: () => tmpRoot,
|
||||
getDefaultNodeId: () => 1,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const COMPOSE = 'services:\n app:\n image: nginx:1.27\n';
|
||||
|
||||
describe('probeComposeDiscovery', () => {
|
||||
beforeAll(() => {
|
||||
fs.mkdirSync(path.join(tmpRoot, 'existing'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpRoot, 'existing', 'compose.yaml'), COMPOSE);
|
||||
fs.writeFileSync(path.join(tmpRoot, 'docker-compose.yml'), COMPOSE);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns readable discovery with stack and adopt counts', async () => {
|
||||
const probe = await probeComposeDiscovery(1);
|
||||
expect(probe.readable).toBe(true);
|
||||
if (!probe.readable) return;
|
||||
expect(probe.composeDir).toBe(tmpRoot);
|
||||
expect(probe.discovery.stackCount).toBe(1);
|
||||
expect(probe.discovery.adoptCandidateCount).toBe(1);
|
||||
expect(probe.discovery.adoptCandidatesTruncated).toBe(false);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('FileSystemService.countImportCandidates', () => {
|
||||
it('matches findImportCandidates length when under the cap', async () => {
|
||||
const listed = await FileSystemService.getInstance().findImportCandidates();
|
||||
const counted = await FileSystemService.getInstance().countImportCandidates(100);
|
||||
expect(counted.count).toBe(listed.length);
|
||||
expect(counted.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it('sets truncated only when more than maxCandidates exist', async () => {
|
||||
const wrap = path.join(tmpRoot, 'trunc-wrap');
|
||||
fs.mkdirSync(wrap, { recursive: true });
|
||||
try {
|
||||
for (let i = 0; i < 101; i++) {
|
||||
const dir = path.join(wrap, `c${i}`);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'compose.yaml'), COMPOSE);
|
||||
}
|
||||
const at100 = await FileSystemService.getInstance().countImportCandidates(100);
|
||||
expect(at100.count).toBe(100);
|
||||
expect(at100.truncated).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(wrap, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -87,4 +87,17 @@ describe('GET /api/diagnostics/environment', () => {
|
||||
expect(typeof c.detail).toBe('string');
|
||||
}
|
||||
});
|
||||
|
||||
it('still returns 200 when discovery probe succeeds on a readable compose dir', async () => {
|
||||
const res = await request(app).get('/api/diagnostics/environment').set('Authorization', adminAuthHeader);
|
||||
expect(res.status).toBe(200);
|
||||
if (res.body.discovery) {
|
||||
expect(res.body.discovery).toMatchObject({
|
||||
composeDir: expect.any(String),
|
||||
stackCount: expect.any(Number),
|
||||
adoptCandidateCount: expect.any(Number),
|
||||
adoptCandidatesTruncated: expect.any(Boolean),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,6 +50,43 @@ describe('FileSystemService.importCandidateIntoStack', () => {
|
||||
expect(await FileSystemService.getInstance().getStacks()).toContain('webapp');
|
||||
});
|
||||
|
||||
it('renames a non-canonical loose-root yaml to compose.yaml so the stack registers', async () => {
|
||||
fs.writeFileSync(path.join(tmpRoot, 'nginx.yml'), COMPOSE);
|
||||
try {
|
||||
await FileSystemService.getInstance().importCandidateIntoStack(
|
||||
{ location: 'nginx.yml', composeFile: 'nginx.yml', status: 'loose-root' },
|
||||
'nginx',
|
||||
);
|
||||
expect(fs.existsSync(path.join(tmpRoot, 'nginx', 'compose.yaml'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpRoot, 'nginx', 'nginx.yml'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(tmpRoot, 'nginx.yml'))).toBe(false);
|
||||
expect(await FileSystemService.getInstance().getStacks()).toContain('nginx');
|
||||
} finally {
|
||||
fs.rmSync(path.join(tmpRoot, 'nginx'), { recursive: true, force: true });
|
||||
fs.rmSync(path.join(tmpRoot, 'nginx.yml'), { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('renames a nested non-canonical yaml to compose.yaml after promoting the directory', async () => {
|
||||
fs.mkdirSync(path.join(tmpRoot, 'apps', 'plex'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpRoot, 'apps', 'plex', 'plex.yml'), COMPOSE);
|
||||
fs.writeFileSync(path.join(tmpRoot, 'apps', 'plex', '.env'), 'TOKEN=1\n');
|
||||
try {
|
||||
await FileSystemService.getInstance().importCandidateIntoStack(
|
||||
{ location: 'apps/plex/plex.yml', composeFile: 'plex.yml', status: 'nested' },
|
||||
'plex',
|
||||
);
|
||||
expect(fs.existsSync(path.join(tmpRoot, 'plex', 'compose.yaml'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpRoot, 'plex', 'plex.yml'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(tmpRoot, 'plex', '.env'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpRoot, 'apps', 'plex'))).toBe(false);
|
||||
expect(await FileSystemService.getInstance().getStacks()).toContain('plex');
|
||||
} finally {
|
||||
fs.rmSync(path.join(tmpRoot, 'plex'), { recursive: true, force: true });
|
||||
fs.rmSync(path.join(tmpRoot, 'apps', 'plex'), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves sibling root files (a root .env) untouched when moving a loose-root file', async () => {
|
||||
fs.writeFileSync(path.join(tmpRoot, 'compose.yaml'), COMPOSE);
|
||||
fs.writeFileSync(path.join(tmpRoot, '.env'), 'TOKEN=keep-me\n');
|
||||
@@ -79,6 +116,49 @@ describe('FileSystemService.importCandidateIntoStack', () => {
|
||||
expect(await FileSystemService.getInstance().getStacks()).toContain('vault');
|
||||
});
|
||||
|
||||
it('refuses nested non-canonical adopt when compose.yaml already exists in the source dir', async () => {
|
||||
fs.mkdirSync(path.join(tmpRoot, 'apps', 'clash'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpRoot, 'apps', 'clash', 'plex.yml'), COMPOSE);
|
||||
fs.writeFileSync(path.join(tmpRoot, 'apps', 'clash', 'compose.yaml'), 'services:\n other: {}\n');
|
||||
await expect(
|
||||
FileSystemService.getInstance().importCandidateIntoStack(
|
||||
{ location: 'apps/clash/plex.yml', composeFile: 'plex.yml', status: 'nested' },
|
||||
'clash',
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'DEST_EXISTS' });
|
||||
expect(fs.existsSync(path.join(tmpRoot, 'apps', 'clash', 'plex.yml'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpRoot, 'clash'))).toBe(false);
|
||||
fs.rmSync(path.join(tmpRoot, 'apps', 'clash'), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('rolls nested directory back when post-promote rename to compose.yaml fails', async () => {
|
||||
fs.mkdirSync(path.join(tmpRoot, 'apps', 'plexrb'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpRoot, 'apps', 'plexrb', 'plex.yml'), COMPOSE);
|
||||
const realRename = fsPromises.rename.bind(fsPromises);
|
||||
let calls = 0;
|
||||
const spy = vi.spyOn(fsPromises, 'rename').mockImplementation(async (...args: Parameters<typeof fsPromises.rename>) => {
|
||||
calls += 1;
|
||||
if (calls === 2) {
|
||||
throw Object.assign(new Error('rename failed'), { code: 'EIO' });
|
||||
}
|
||||
return realRename(...args);
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
FileSystemService.getInstance().importCandidateIntoStack(
|
||||
{ location: 'apps/plexrb/plex.yml', composeFile: 'plex.yml', status: 'nested' },
|
||||
'plex-rb',
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'EIO' });
|
||||
expect(fs.existsSync(path.join(tmpRoot, 'apps', 'plexrb', 'plex.yml'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpRoot, 'plex-rb'))).toBe(false);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
fs.rmSync(path.join(tmpRoot, 'apps', 'plexrb'), { recursive: true, force: true });
|
||||
fs.rmSync(path.join(tmpRoot, 'plex-rb'), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('honors a destination name different from the nested folder name', async () => {
|
||||
fs.mkdirSync(path.join(tmpRoot, 'group', 'api'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpRoot, 'group', 'api', 'compose.yaml'), COMPOSE);
|
||||
|
||||
@@ -151,6 +151,104 @@ describe('FileSystemService.findImportCandidates', () => {
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
it('surfaces non-canonical loose-root yaml (e.g. nginx.yml)', async () => {
|
||||
fs.writeFileSync(path.join(tmpRoot, 'nginx.yml'), COMPOSE);
|
||||
try {
|
||||
const candidates = await FileSystemService.getInstance().findImportCandidates();
|
||||
const loose = candidates.find((c) => c.location === 'nginx.yml');
|
||||
expect(loose).toMatchObject({
|
||||
name: '',
|
||||
composeFile: 'nginx.yml',
|
||||
status: 'loose-root',
|
||||
});
|
||||
expect(loose?.content).toContain('services:');
|
||||
} finally {
|
||||
fs.rmSync(path.join(tmpRoot, 'nginx.yml'), { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('surfaces nested non-canonical yaml (e.g. apps/plex/plex.yml)', async () => {
|
||||
const dir = path.join(tmpRoot, 'apps', 'plex');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'plex.yml'), COMPOSE);
|
||||
try {
|
||||
const candidates = await FileSystemService.getInstance().findImportCandidates();
|
||||
const nested = candidates.find((c) => c.location === 'apps/plex/plex.yml');
|
||||
expect(nested).toMatchObject({
|
||||
name: 'plex',
|
||||
composeFile: 'plex.yml',
|
||||
status: 'nested',
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(path.join(tmpRoot, 'apps', 'plex'), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('does not surface compose override filenames as adopt candidates', async () => {
|
||||
fs.writeFileSync(path.join(tmpRoot, 'compose.override.yml'), COMPOSE);
|
||||
const wrap = path.join(tmpRoot, 'ovr-wrap', 'ovr');
|
||||
fs.mkdirSync(wrap, { recursive: true });
|
||||
fs.writeFileSync(path.join(wrap, 'docker-compose.override.yaml'), COMPOSE);
|
||||
try {
|
||||
const candidates = await FileSystemService.getInstance().findImportCandidates();
|
||||
expect(candidates.some((c) => c.composeFile.includes('override'))).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(path.join(tmpRoot, 'compose.override.yml'), { force: true });
|
||||
fs.rmSync(path.join(tmpRoot, 'ovr-wrap'), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('does not surface non-canonical yaml inside a top-level folder (not a stack, not adoptable)', async () => {
|
||||
// plex/plex.yml at the compose root is neither a stack (no canonical compose)
|
||||
// nor an adopt candidate (promoting with destName === folder would conflict).
|
||||
const dir = path.join(tmpRoot, 'plex-top');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'plex.yml'), COMPOSE);
|
||||
try {
|
||||
const candidates = await FileSystemService.getInstance().findImportCandidates();
|
||||
expect(candidates.some((c) => c.location.includes('plex-top'))).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('picks the localeCompare-first non-canonical yaml when several exist', async () => {
|
||||
const dir = path.join(tmpRoot, 'sort-wrap', 'svc');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'z.yml'), COMPOSE);
|
||||
fs.writeFileSync(path.join(dir, 'a.yml'), COMPOSE);
|
||||
try {
|
||||
const candidates = await FileSystemService.getInstance().findImportCandidates();
|
||||
const nested = candidates.find((c) => c.name === 'svc');
|
||||
expect(nested).toMatchObject({
|
||||
composeFile: 'a.yml',
|
||||
location: 'sort-wrap/svc/a.yml',
|
||||
status: 'nested',
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(path.join(tmpRoot, 'sort-wrap'), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('prefers a canonical compose filename over a sibling non-canonical yaml', async () => {
|
||||
const dir = path.join(tmpRoot, 'pref-wrap', 'svc');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'compose.yaml'), COMPOSE);
|
||||
fs.writeFileSync(path.join(dir, 'nginx.yml'), COMPOSE);
|
||||
try {
|
||||
const candidates = await FileSystemService.getInstance().findImportCandidates();
|
||||
const nested = candidates.find((c) => c.name === 'svc');
|
||||
expect(nested).toMatchObject({
|
||||
composeFile: 'compose.yaml',
|
||||
location: 'pref-wrap/svc/compose.yaml',
|
||||
status: 'nested',
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(path.join(tmpRoot, 'pref-wrap'), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('truncates at maxCandidates', async () => {
|
||||
// Base fixtures yield 2 candidates (loose-root + nested); add a third loose
|
||||
// file so a cap of 2 actually truncates rather than coincidentally matching.
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Route tests for GET /api/stacks/discovery: auth, contract, and route shadowing.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let authCookie: string;
|
||||
let viewerCookie: string;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
authCookie = await loginAsTestAdmin(app);
|
||||
|
||||
const bcrypt = (await import('bcrypt')).default;
|
||||
const viewerHash = await bcrypt.hash('viewerpass', 1);
|
||||
DatabaseService.getInstance().addUser({ username: 'disc-viewer', password_hash: viewerHash, role: 'viewer' });
|
||||
const loginRes = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'disc-viewer', password: 'viewerpass' });
|
||||
const cookies = loginRes.headers['set-cookie'] as string | string[];
|
||||
viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies;
|
||||
|
||||
const composeDir = process.env.COMPOSE_DIR!;
|
||||
fs.mkdirSync(path.join(composeDir, 'stack-a'), { recursive: true });
|
||||
fs.writeFileSync(path.join(composeDir, 'stack-a', 'compose.yaml'), 'services:\n web:\n image: nginx\n');
|
||||
fs.writeFileSync(path.join(composeDir, 'docker-compose.yml'), 'services:\n loose:\n image: nginx\n');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
describe('GET /api/stacks/discovery', () => {
|
||||
it('requires authentication', async () => {
|
||||
const res = await request(app).get('/api/stacks/discovery');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('allows stack:read for a viewer', async () => {
|
||||
const res = await request(app).get('/api/stacks/discovery').set('Cookie', viewerCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.composeDir).toBe('string');
|
||||
expect(res.body.readable).toBe(true);
|
||||
expect(res.body.discovery).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns the stable discovery contract for a readable compose dir', async () => {
|
||||
const res = await request(app).get('/api/stacks/discovery').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
readable: true,
|
||||
discovery: {
|
||||
composeDir: expect.any(String),
|
||||
stackCount: expect.any(Number),
|
||||
adoptCandidateCount: expect.any(Number),
|
||||
adoptCandidatesTruncated: expect.any(Boolean),
|
||||
},
|
||||
});
|
||||
expect(res.body.composeDir).toBe(res.body.discovery.composeDir);
|
||||
});
|
||||
|
||||
it('does not shadow GET /:stackName (discovery is not treated as a stack name)', async () => {
|
||||
const res = await request(app).get('/api/stacks/discovery').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toMatch(/json/);
|
||||
expect(res.body).toHaveProperty('readable');
|
||||
expect(res.text).not.toMatch(/^services:/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/stacks/import/move viewer denial', () => {
|
||||
it('rejects move without stack:create', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/import/move')
|
||||
.set('Cookie', viewerCookie)
|
||||
.send({ location: 'docker-compose.yml', name: 'moved' });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { Router, type Request, type Response } from 'express';
|
||||
import { requireAdmin, requireUserSession } from '../middleware/tierGates';
|
||||
import { collectDiagnostics } from '../services/DiagnosticsService';
|
||||
import { collectEnvironmentReport, buildRealProbes } from '../services/EnvironmentCheckService';
|
||||
import { probeComposeDiscovery } from '../services/ComposeDiscoveryService';
|
||||
import DockerController from '../services/DockerController';
|
||||
import { withTimeout } from '../utils/withTimeout';
|
||||
|
||||
@@ -50,6 +51,14 @@ diagnosticsRouter.get('/environment', async (req: Request, res: Response): Promi
|
||||
const proto = (req.get('x-forwarded-proto')?.split(',')[0].trim()) || req.protocol;
|
||||
const host = req.get('host') || '';
|
||||
const report = await collectEnvironmentReport(buildRealProbes({ proto, host }));
|
||||
try {
|
||||
const probe = await probeComposeDiscovery(req.nodeId);
|
||||
if (probe.readable) {
|
||||
(report as typeof report & { discovery?: typeof probe.discovery }).discovery = probe.discovery;
|
||||
}
|
||||
} catch (discoveryErr) {
|
||||
console.error('[diagnostics] compose discovery probe failed:', (discoveryErr as Error).message);
|
||||
}
|
||||
res.json(report);
|
||||
} catch (err) {
|
||||
console.error('[diagnostics] failed to collect environment report:', (err as Error).message);
|
||||
|
||||
@@ -28,6 +28,7 @@ import { parseServiceImages, isPreflightAckActive } from '../utils/preflight-ack
|
||||
import type { PreflightAckExpiryMode } from '../services/DatabaseService';
|
||||
import { buildStackNetworkFacts } from '../services/network/composeNetworkInspector';
|
||||
import { buildStorageInventory } from '../services/storage/inventory';
|
||||
import { probeComposeDiscovery } from '../services/ComposeDiscoveryService';
|
||||
import { buildEffectiveAnatomy } from '../services/effectiveAnatomy';
|
||||
import { buildEnvInventory } from '../services/EnvInventoryService';
|
||||
import { buildStackLabelInventory } from '../services/LabelInventoryService';
|
||||
@@ -530,6 +531,32 @@ stacksRouter.post('/bulk', async (req: Request, res: Response) => {
|
||||
res.json({ action: typedAction, results });
|
||||
});
|
||||
|
||||
// Read-only compose discovery for the sidebar empty state. stack:read only;
|
||||
// never runs full environment diagnostics. Must register before /:stackName.
|
||||
stacksRouter.get('/discovery', async (req: Request, res: Response) => {
|
||||
if (!requirePermission(req, res, 'stack:read')) return;
|
||||
try {
|
||||
const probe = await probeComposeDiscovery(req.nodeId);
|
||||
if (probe.readable) {
|
||||
res.json({
|
||||
composeDir: probe.composeDir,
|
||||
readable: true,
|
||||
discovery: probe.discovery,
|
||||
});
|
||||
} else {
|
||||
res.json({
|
||||
composeDir: probe.composeDir,
|
||||
readable: false,
|
||||
discovery: null,
|
||||
error: probe.error,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to probe compose discovery:', error);
|
||||
res.status(500).json({ error: 'Failed to probe compose discovery' });
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.get('/:stackName', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import fs from 'fs/promises';
|
||||
import { constants as fsConstants } from 'fs';
|
||||
|
||||
export interface ComposeDiscovery {
|
||||
composeDir: string;
|
||||
stackCount: number;
|
||||
adoptCandidateCount: number;
|
||||
adoptCandidatesTruncated: boolean;
|
||||
}
|
||||
|
||||
export type ComposeDiscoveryProbe =
|
||||
| {
|
||||
composeDir: string;
|
||||
readable: true;
|
||||
discovery: ComposeDiscovery;
|
||||
}
|
||||
| {
|
||||
composeDir: string;
|
||||
readable: false;
|
||||
discovery: null;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export async function probeComposeDiscovery(nodeId: number): Promise<ComposeDiscoveryProbe> {
|
||||
const { FileSystemService } = await import('./FileSystemService');
|
||||
const fsSvc = FileSystemService.getInstance(nodeId);
|
||||
const composeDir = fsSvc.getBaseDir();
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(composeDir);
|
||||
if (!stat.isDirectory()) {
|
||||
return {
|
||||
composeDir,
|
||||
readable: false,
|
||||
discovery: null,
|
||||
error: 'Compose path is not a directory.',
|
||||
};
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const code = (err as NodeJS.ErrnoException)?.code;
|
||||
if (code === 'ENOENT') {
|
||||
return {
|
||||
composeDir,
|
||||
readable: false,
|
||||
discovery: null,
|
||||
error: 'Compose directory does not exist.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
composeDir,
|
||||
readable: false,
|
||||
discovery: null,
|
||||
error: 'Compose directory is not readable.',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(composeDir, fsConstants.R_OK);
|
||||
await fs.readdir(composeDir);
|
||||
} catch {
|
||||
return {
|
||||
composeDir,
|
||||
readable: false,
|
||||
discovery: null,
|
||||
error: 'Compose directory is not readable.',
|
||||
};
|
||||
}
|
||||
|
||||
const stackNames = await fsSvc.getStacks();
|
||||
const { count, truncated } = await fsSvc.countImportCandidates(100);
|
||||
|
||||
return {
|
||||
composeDir,
|
||||
readable: true,
|
||||
discovery: {
|
||||
composeDir,
|
||||
stackCount: stackNames.length,
|
||||
adoptCandidateCount: count,
|
||||
adoptCandidatesTruncated: truncated,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -63,8 +63,8 @@ const PROTECTED_STACK_FILES = new Set([
|
||||
// was taken; `.checksums` is the integrity manifest verified before a restore.
|
||||
const BACKUP_MARKER_FILES = new Set(['.timestamp', '.checksums']);
|
||||
|
||||
// Compose filenames Sencho recognizes, in resolution-priority order. Mirrors the
|
||||
// list FileSystemService uses elsewhere; named here for the import scan.
|
||||
// Compose filenames Sencho recognizes as a managed stack, in resolution-priority
|
||||
// order. Used by getStacks / hasComposeFile / firstComposeFilename.
|
||||
const IMPORT_COMPOSE_FILENAMES = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'] as const;
|
||||
const IMPORT_COMPOSE_FILENAME_SET = new Set<string>(IMPORT_COMPOSE_FILENAMES);
|
||||
// Override filenames docker compose can auto-discover, listed in priority order (first
|
||||
@@ -77,6 +77,25 @@ const COMPOSE_OVERRIDE_FILENAMES = [
|
||||
'docker-compose.override.yaml',
|
||||
'docker-compose.override.yml',
|
||||
] as const;
|
||||
const COMPOSE_OVERRIDE_FILENAME_SET = new Set<string>(COMPOSE_OVERRIDE_FILENAMES);
|
||||
|
||||
/** True for adopt-scan candidates: any .yml/.yaml that is not a compose override. */
|
||||
function isAdoptYamlFilename(name: string): boolean {
|
||||
return !COMPOSE_OVERRIDE_FILENAME_SET.has(name) && (name.endsWith('.yml') || name.endsWith('.yaml'));
|
||||
}
|
||||
|
||||
function assertSafeComposeBasename(name: string): void {
|
||||
// Basename only: reject path separators and the special entries . / .., but allow
|
||||
// names that merely contain ".." as a substring (e.g. foo..bar.yml).
|
||||
if (!name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
|
||||
throw Object.assign(new Error('Invalid path'), { code: 'INVALID_PATH' });
|
||||
}
|
||||
}
|
||||
|
||||
/** Canonical names keep their basename; everything else lands as compose.yaml. */
|
||||
function adoptDestComposeBasename(name: string): string {
|
||||
return IMPORT_COMPOSE_FILENAME_SET.has(name) ? name : 'compose.yaml';
|
||||
}
|
||||
// Skip reading compose files larger than this into the import preview.
|
||||
const IMPORT_MAX_PREVIEW_BYTES = 1_048_576; // 1 MiB
|
||||
|
||||
@@ -104,6 +123,14 @@ export interface ImportCandidateRaw {
|
||||
*/
|
||||
export type MovableImportCandidate = Pick<ImportCandidateRaw, 'location' | 'composeFile' | 'status'>;
|
||||
|
||||
/** Placement metadata for a single import candidate (no file read). */
|
||||
export interface ImportCandidateMeta {
|
||||
name: string;
|
||||
composeFile: string;
|
||||
location: string;
|
||||
status: 'loose-root' | 'nested';
|
||||
}
|
||||
|
||||
// Strips at most one trailing slash. The upstream validator
|
||||
// (isValidRelativeStackPath) rejects any '//' sequence, so a string reaching
|
||||
// this helper can carry at most one trailing slash, and a single slice is
|
||||
@@ -576,6 +603,33 @@ export class FileSystemService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose file to surface for adopt: prefer the four canonical names (same
|
||||
* access probe as firstComposeFilename, including weird/unreadable entries),
|
||||
* then any other regular .yml/.yaml file that is not a compose override.
|
||||
*/
|
||||
private async firstAdoptComposeFilename(dir: string): Promise<string | null> {
|
||||
const canonical = await this.firstComposeFilename(dir);
|
||||
if (canonical) return canonical;
|
||||
|
||||
this.assertWithinBase(dir);
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await fsPromises.readdir(dir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[FileSystemService] Failed to read directory during adopt scan:',
|
||||
sanitizeForLog((error as Error)?.message ?? String(error)),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const yamlFiles = entries
|
||||
.filter((e) => e.isFile() && typeof e.name === 'string' && isAdoptYamlFilename(e.name))
|
||||
.map((e) => e.name)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
return yamlFiles[0] ?? null;
|
||||
}
|
||||
|
||||
private async readComposeCandidate(filePath: string): Promise<{ content: string | null; oversized: boolean }> {
|
||||
this.assertWithinBase(filePath);
|
||||
let fh: import('fs/promises').FileHandle | null = null;
|
||||
@@ -612,48 +666,43 @@ export class FileSystemService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the compose directory for compose files that are not yet stacks: loose
|
||||
* files at the root and compose files one directory too deep. A top-level
|
||||
* subdirectory with a compose file is already a stack, so it is skipped, not
|
||||
* surfaced. Read-only. Bounded by `maxCandidates` and by a single level of
|
||||
* nesting so a deep tree cannot make this walk unbounded.
|
||||
* Shared placement walk for import discovery and counting. Yields the same
|
||||
* candidates findImportCandidates surfaces (including weird/unreadable files
|
||||
* that readComposeCandidate returns content:null for). Loose-root and nested
|
||||
* candidates accept any .yml/.yaml except compose override filenames; a
|
||||
* top-level subdirectory is still a stack only when it has a canonical
|
||||
* compose filename. Stops after `limit` yields.
|
||||
*/
|
||||
async findImportCandidates(maxCandidates = 100): Promise<ImportCandidateRaw[]> {
|
||||
const candidates: ImportCandidateRaw[] = [];
|
||||
private async *enumerateImportCandidates(limit: number): AsyncGenerator<ImportCandidateMeta> {
|
||||
let yielded = 0;
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await fsPromises.readdir(this.baseDir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
// The compose dir itself is unreadable (missing, permissions). The scan
|
||||
// degrades to an empty list, so log it rather than report "no files found"
|
||||
// for what is really an access failure.
|
||||
console.warn('[FileSystemService] Failed to scan compose directory for import:', sanitizeForLog((error as Error)?.message ?? String(error)));
|
||||
return candidates;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (candidates.length >= maxCandidates) break;
|
||||
if (yielded >= limit) return;
|
||||
if (!entry.name || typeof entry.name !== 'string') continue;
|
||||
|
||||
if (entry.isFile()) {
|
||||
if (IMPORT_COMPOSE_FILENAME_SET.has(entry.name)) {
|
||||
const loaded = await this.readComposeCandidate(path.join(this.baseDir, entry.name));
|
||||
candidates.push({ name: '', composeFile: entry.name, location: entry.name, status: 'loose-root', ...loaded });
|
||||
if (isAdoptYamlFilename(entry.name)) {
|
||||
yielded++;
|
||||
yield { name: '', composeFile: entry.name, location: entry.name, status: 'loose-root' };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const dir = path.join(this.baseDir, entry.name);
|
||||
// Canonical compose here means already a stack. Non-canonical yaml in this
|
||||
// folder (e.g. plex/plex.yml) is neither a stack nor an adopt candidate:
|
||||
// promoting with destName equal to the folder would conflict on disk.
|
||||
const topCompose = await this.firstComposeFilename(dir);
|
||||
if (topCompose) {
|
||||
// A top-level subdirectory with a compose file is already a stack (it
|
||||
// shows in the sidebar), so it is not an import candidate. Skip it and do
|
||||
// not descend: any compose files deeper inside belong to this stack.
|
||||
continue;
|
||||
}
|
||||
if (topCompose) continue;
|
||||
|
||||
// No compose at the top level: peek exactly one level deeper.
|
||||
let children: Dirent[];
|
||||
try {
|
||||
children = await fsPromises.readdir(dir, { withFileTypes: true });
|
||||
@@ -662,23 +711,54 @@ export class FileSystemService {
|
||||
continue;
|
||||
}
|
||||
for (const child of children) {
|
||||
if (candidates.length >= maxCandidates) break;
|
||||
if (yielded >= limit) return;
|
||||
if (!child.isDirectory() || !child.name || typeof child.name !== 'string') continue;
|
||||
const childDir = path.join(dir, child.name);
|
||||
const childCompose = await this.firstComposeFilename(childDir);
|
||||
const childCompose = await this.firstAdoptComposeFilename(childDir);
|
||||
if (childCompose) {
|
||||
const loaded = await this.readComposeCandidate(path.join(childDir, childCompose));
|
||||
candidates.push({
|
||||
yielded++;
|
||||
yield {
|
||||
name: child.name,
|
||||
composeFile: childCompose,
|
||||
location: `${entry.name}/${child.name}/${childCompose}`,
|
||||
status: 'nested',
|
||||
...loaded,
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count adopt candidates with 101st lookahead: truncated is true only when
|
||||
* more than maxCandidates exist.
|
||||
*/
|
||||
async countImportCandidates(maxCandidates = 100): Promise<{ count: number; truncated: boolean }> {
|
||||
let count = 0;
|
||||
for await (const _ of this.enumerateImportCandidates(maxCandidates + 1)) {
|
||||
count++;
|
||||
}
|
||||
if (count > maxCandidates) {
|
||||
return { count: maxCandidates, truncated: true };
|
||||
}
|
||||
return { count, truncated: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the compose directory for compose files that are not yet stacks: loose
|
||||
* files at the root and compose files one directory too deep. A top-level
|
||||
* subdirectory with a compose file is already a stack, so it is skipped, not
|
||||
* surfaced. Read-only. Bounded by `maxCandidates` and by a single level of
|
||||
* nesting so a deep tree cannot make this walk unbounded.
|
||||
*/
|
||||
async findImportCandidates(maxCandidates = 100): Promise<ImportCandidateRaw[]> {
|
||||
const candidates: ImportCandidateRaw[] = [];
|
||||
for await (const meta of this.enumerateImportCandidates(maxCandidates)) {
|
||||
const filePath = meta.status === 'loose-root'
|
||||
? path.join(this.baseDir, meta.composeFile)
|
||||
: path.join(this.baseDir, ...meta.location.split('/'));
|
||||
const loaded = await this.readComposeCandidate(filePath);
|
||||
candidates.push({ ...meta, ...loaded });
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
@@ -687,11 +767,13 @@ export class FileSystemService {
|
||||
* auto-discovery (getStacks) picks it up. This is the single write path of the
|
||||
* guided import flow and only runs on an explicit, per-file user action.
|
||||
*
|
||||
* A `loose-root` file is moved into <base>/<destName>/<composeFile>: only the
|
||||
* chosen compose file moves, so sibling files referenced by a relative path
|
||||
* A `loose-root` file is moved into <base>/<destName>/: only the chosen
|
||||
* compose file moves, so sibling files referenced by a relative path
|
||||
* (e.g. a root .env) stay where they are. A `nested` stack directory
|
||||
* (<parent>/<child>) is promoted whole to <base>/<destName>, preserving its
|
||||
* .env and any other files.
|
||||
* .env and any other files. Non-canonical basenames (anything other than the
|
||||
* four managed compose names) are renamed to compose.yaml so getStacks sees
|
||||
* the new stack, matching migrateFlatToDirectory.
|
||||
*
|
||||
* Never overwrites: a pre-existing destination is a conflict. Source and
|
||||
* destination are both confirmed to resolve inside the compose directory
|
||||
@@ -732,9 +814,12 @@ export class FileSystemService {
|
||||
if (candidate.status === 'loose-root') {
|
||||
const realSource = await this.realPathWithinBase(source);
|
||||
// Build the relocated file path through the same inline containment barrier so
|
||||
// the rename target is a credited safe path (candidate.composeFile is an
|
||||
// allowlisted compose filename, but it is traced from the request).
|
||||
const destComposePath = path.resolve(baseResolved, destName, candidate.composeFile);
|
||||
// the rename target is a credited safe path. composeFile is a basename from
|
||||
// the scan (any .yml/.yaml except overrides); containment is re-checked here.
|
||||
assertSafeComposeBasename(candidate.composeFile);
|
||||
// Non-canonical names (e.g. nginx.yml) become compose.yaml so getStacks picks
|
||||
// them up, matching migrateFlatToDirectory. Canonical names keep their basename.
|
||||
const destComposePath = path.resolve(baseResolved, destName, adoptDestComposeBasename(candidate.composeFile));
|
||||
if (!destComposePath.startsWith(baseResolved + path.sep)) {
|
||||
throw Object.assign(new Error('Invalid path'), { code: 'INVALID_PATH' });
|
||||
}
|
||||
@@ -770,7 +855,42 @@ export class FileSystemService {
|
||||
if (!isPathWithinBase(realCompose, realSourceDir)) {
|
||||
throw Object.assign(new Error('Compose file escapes the import directory'), { code: 'INVALID_PATH' });
|
||||
}
|
||||
// Non-canonical basenames become compose.yaml after promotion. Preflight so a
|
||||
// sibling compose.yaml cannot strand the folder after the directory move.
|
||||
const destBasename = adoptDestComposeBasename(candidate.composeFile);
|
||||
const needsComposeRename = destBasename !== candidate.composeFile;
|
||||
if (needsComposeRename) {
|
||||
assertSafeComposeBasename(candidate.composeFile);
|
||||
try {
|
||||
await fsPromises.access(path.join(realSourceDir, destBasename));
|
||||
throw Object.assign(
|
||||
new Error(`Cannot rename ${candidate.composeFile} to ${destBasename}: destination already exists`),
|
||||
{ code: 'DEST_EXISTS' },
|
||||
);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException)?.code;
|
||||
if (code === 'DEST_EXISTS') throw error;
|
||||
if (code !== 'ENOENT') throw error;
|
||||
}
|
||||
}
|
||||
await fsPromises.rename(realSourceDir, destDir);
|
||||
if (needsComposeRename) {
|
||||
const fromPath = path.resolve(destDir, candidate.composeFile);
|
||||
const toPath = path.resolve(destDir, destBasename);
|
||||
if (!fromPath.startsWith(destDir + path.sep) || !toPath.startsWith(destDir + path.sep)) {
|
||||
// Directory already moved; roll it back before failing.
|
||||
await fsPromises.rename(destDir, realSourceDir).catch(() => undefined);
|
||||
throw Object.assign(new Error('Invalid path'), { code: 'INVALID_PATH' });
|
||||
}
|
||||
try {
|
||||
await fsPromises.rename(fromPath, toPath);
|
||||
} catch (error) {
|
||||
// Roll the directory back to its nested path so the candidate stays
|
||||
// adoptable and a retry is not blocked by DEST_EXISTS on destName.
|
||||
await fsPromises.rename(destDir, realSourceDir).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user