feat(stacks): guided first stack import flow (#1285)

* feat(stacks): add guided first stack import flow

Add an Import mode to the Create Stack dialog and a zero-stacks empty
state so a new user who already has compose files on disk can land their
first stack without reading the docs first.

A read-only scan of the compose directory (GET /api/stacks/import/scan)
lists the compose files it finds with a dry preview of each file's
services, ports, volumes, and env files. Each result is labelled by
placement: already a stack, loose at the root of the compose directory,
or one folder too deep, with the exact path to move misplaced files to.
The scan never writes, moves, or changes any files.

Manual stack creation (Empty, From Git, From Docker Run) is unchanged.

* fix(stacks): read import-scan candidates via a single file handle

Open the compose file once and stat plus read on the same descriptor so
the size check and the read observe the same inode, instead of resolving
the path twice (stat then readFile), which is a time-of-check/time-of-use
race. Mirrors the existing handle-based readers in FileSystemService.

* fix(stacks): confine import scan to the compose dir and refine the empty state

Harden the read-only import scan:
- Resolve symlinks and confirm the real target stays inside the compose
  directory before reading a candidate, and reject non-regular files, so a
  symlinked compose file or parent cannot expose a file outside the compose
  directory through the preview (matches resolveSafeStackPath).
- Read at most the stat-reported size (bounded by the 1 MiB cap) from the open
  handle, so a file that grows after the size check cannot exceed the cap.
- Log when the compose directory or a subdirectory cannot be read, so an access
  failure is not silently reported as "no compose files found".

Only show the first-run "No stacks yet" prompt when no filter chip is active, so
a filter that matches nothing is not mistaken for an empty fleet.
This commit is contained in:
Anso
2026-06-02 16:10:05 -04:00
committed by GitHub
parent c82a39c65a
commit 06b25262cc
11 changed files with 963 additions and 12 deletions
@@ -0,0 +1,86 @@
/**
* Unit tests for parseComposePreview: the pure compose-to-preview parser behind
* the guided import scan. Covers port/volume/env normalization, the relative-
* volume warning (1:1 path rule), and the non-throwing error paths.
*/
import { describe, it, expect } from 'vitest';
import { parseComposePreview } from '../helpers/composePreview';
describe('parseComposePreview', () => {
it('extracts services, short-syntax ports, volumes, and env_file', () => {
const yaml = `
services:
web:
image: nginx:1.27
ports:
- "8080:80"
- "53:53/udp"
volumes:
- ./data:/usr/share/nginx/html
env_file: web.env
`;
const result = parseComposePreview(yaml);
expect(result.parseError).toBeUndefined();
expect(result.services).toHaveLength(1);
const web = result.services[0];
expect(web.name).toBe('web');
expect(web.image).toBe('nginx:1.27');
expect(web.ports).toEqual(['8080->80', '53->53']);
expect(web.volumes).toEqual(['./data:/usr/share/nginx/html']);
expect(web.envFiles).toEqual(['web.env']);
// Relative bind source triggers the 1:1 path-rule warning.
expect(result.warnings.some((w) => w.includes('1:1 path rule'))).toBe(true);
});
it('normalizes long-syntax ports and ip-prefixed short ports', () => {
const yaml = `
services:
api:
ports:
- target: 3001
published: 2283
- "127.0.0.1:9000:9000"
- "5000"
`;
const result = parseComposePreview(yaml);
expect(result.services[0].ports).toEqual(['2283->3001', '9000->9000', '5000']);
});
it('handles env_file as an array of strings and {path} objects', () => {
const yaml = `
services:
app:
env_file:
- common.env
- path: secrets.env
`;
const result = parseComposePreview(yaml);
expect(result.services[0].envFiles).toEqual(['common.env', 'secrets.env']);
});
it('does not warn for named or absolute volumes', () => {
const yaml = `
services:
db:
volumes:
- pgdata:/var/lib/postgresql/data
- /opt/host/conf:/etc/conf
volumes:
pgdata:
`;
const result = parseComposePreview(yaml);
expect(result.services[0].volumes).toEqual(['pgdata:/var/lib/postgresql/data', '/opt/host/conf:/etc/conf']);
expect(result.warnings).toHaveLength(0);
});
it('reports a parseError for invalid YAML without throwing', () => {
const result = parseComposePreview('services: [unclosed');
expect(result.parseError).toBeDefined();
expect(result.services).toHaveLength(0);
});
it('reports a parseError when there are no services', () => {
const result = parseComposePreview("name: just-a-file\nversion: '3'");
expect(result.parseError).toBe('No services found in this file.');
});
});
+151
View File
@@ -0,0 +1,151 @@
/**
* Tests for FileSystemService.findImportCandidates: the read-only compose-dir
* walk behind the guided import scan. Uses a real temp directory so the nesting
* and placement-status logic is exercised against the actual filesystem.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import fs from 'fs';
import path from 'path';
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-import-'));
return { tmpRoot };
});
vi.mock('../services/NodeRegistry', () => ({
NodeRegistry: {
getInstance: () => ({
getComposeDir: () => tmpRoot,
getDefaultNodeId: () => 1,
}),
},
}));
import { FileSystemService } from '../services/FileSystemService';
const COMPOSE = 'services:\n app:\n image: nginx:1.27\n';
describe('FileSystemService.findImportCandidates', () => {
beforeAll(() => {
// Already a stack: top-level subdir with a compose file.
fs.mkdirSync(path.join(tmpRoot, 'immich'), { recursive: true });
fs.writeFileSync(path.join(tmpRoot, 'immich', 'compose.yaml'), COMPOSE);
// Loose at the root: will not auto-register.
fs.writeFileSync(path.join(tmpRoot, 'docker-compose.yml'), COMPOSE);
// One directory too deep: apps/ has no compose, apps/vault/ does.
fs.mkdirSync(path.join(tmpRoot, 'apps', 'vault'), { recursive: true });
fs.writeFileSync(path.join(tmpRoot, 'apps', 'vault', 'compose.yaml'), COMPOSE);
// A directory with no compose file at all: ignored.
fs.mkdirSync(path.join(tmpRoot, 'notes'), { recursive: true });
fs.writeFileSync(path.join(tmpRoot, 'notes', 'README.md'), '# notes');
});
afterAll(() => {
fs.rmSync(tmpRoot, { recursive: true, force: true });
});
it('classifies listed, loose-root, and nested compose files and ignores the rest', async () => {
const candidates = await FileSystemService.getInstance().findImportCandidates();
const listed = candidates.find((c) => c.status === 'listed');
expect(listed).toMatchObject({ name: 'immich', composeFile: 'compose.yaml', location: 'immich/compose.yaml' });
expect(listed?.content).toContain('services:');
const loose = candidates.find((c) => c.status === 'loose-root');
expect(loose).toMatchObject({ name: '', composeFile: 'docker-compose.yml', location: 'docker-compose.yml' });
const nested = candidates.find((c) => c.status === 'nested');
expect(nested).toMatchObject({ name: 'vault', composeFile: 'compose.yaml', location: 'apps/vault/compose.yaml' });
// The directory with only a README produced no candidate.
expect(candidates.some((c) => c.name === 'notes')).toBe(false);
expect(candidates).toHaveLength(3);
});
it('flags oversized compose files instead of reading them', async () => {
const bigDir = path.join(tmpRoot, 'big');
fs.mkdirSync(bigDir, { recursive: true });
fs.writeFileSync(path.join(bigDir, 'compose.yaml'), 'x'.repeat(1_048_577));
try {
const candidates = await FileSystemService.getInstance().findImportCandidates();
const big = candidates.find((c) => c.name === 'big');
expect(big?.oversized).toBe(true);
expect(big?.content).toBeNull();
} finally {
fs.rmSync(bigDir, { recursive: true, force: true });
}
});
it('skips a non-regular file (a directory named compose.yaml) without reading it', async () => {
// A directory named compose.yaml passes the access() probe; the isFile()
// guard means it is reported as unreadable rather than read as content.
const weirdDir = path.join(tmpRoot, 'weird');
fs.mkdirSync(path.join(weirdDir, 'compose.yaml'), { recursive: true });
try {
const candidates = await FileSystemService.getInstance().findImportCandidates();
const weird = candidates.find((c) => c.name === 'weird');
expect(weird).toBeDefined();
expect(weird?.content).toBeNull();
expect(weird?.oversized).toBe(false);
} finally {
fs.rmSync(weirdDir, { recursive: true, force: true });
}
});
it('does not read a compose file that symlinks outside the compose directory', async () => {
// Sibling of the temp compose root, i.e. outside the base dir.
const outside = path.join(path.dirname(tmpRoot), `sencho-outside-${Date.now()}.yaml`);
fs.writeFileSync(outside, COMPOSE);
const escDir = path.join(tmpRoot, 'escape');
fs.mkdirSync(escDir, { recursive: true });
let linked = true;
try {
fs.symlinkSync(outside, path.join(escDir, 'compose.yaml'));
} catch {
// Creating symlinks needs privilege on some platforms; the assertion below
// runs for real on the Linux CI runners.
linked = false;
}
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
try {
if (!linked) return;
const candidates = await FileSystemService.getInstance().findImportCandidates();
const esc = candidates.find((c) => c.name === 'escape');
expect(esc).toBeDefined();
expect(esc?.content).toBeNull();
expect(warnSpy).toHaveBeenCalled();
} finally {
warnSpy.mockRestore();
fs.rmSync(escDir, { recursive: true, force: true });
fs.rmSync(outside, { force: true });
}
});
it('keeps the top-level listing and does not also descend into it', async () => {
// A directory that is already a stack and also has a compose file one level
// deeper yields only the listed entry, never the nested child.
const dir = path.join(tmpRoot, 'both');
fs.mkdirSync(path.join(dir, 'sub'), { recursive: true });
fs.writeFileSync(path.join(dir, 'compose.yaml'), COMPOSE);
fs.writeFileSync(path.join(dir, 'sub', 'compose.yaml'), COMPOSE);
try {
const candidates = await FileSystemService.getInstance().findImportCandidates();
const fromBoth = candidates.filter((c) => c.location.startsWith('both/'));
expect(fromBoth).toHaveLength(1);
expect(fromBoth[0]).toMatchObject({ name: 'both', status: 'listed', location: 'both/compose.yaml' });
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it('truncates at maxCandidates', async () => {
const candidates = await FileSystemService.getInstance().findImportCandidates(2);
expect(candidates).toHaveLength(2);
});
});