1.0.7
This commit is contained in:
1 parent
c15355e159
commit
851e56bc57
23 files changed
+8923
No files matched your search
@@ -0,0 +1,508 @@
|
||||
/**
|
||||
* Stack Scanner Service
|
||||
*
|
||||
* Scans external filesystem paths for Docker Compose files and adopts them as stacks.
|
||||
* Discovered stacks are editable - compose and .env files are modified in their original location.
|
||||
*/
|
||||
|
||||
import { readdirSync, existsSync, statSync } from 'node:fs';
|
||||
import { join, basename, dirname, resolve } from 'node:path';
|
||||
import { getExternalStackPaths, getStackSources, upsertStackSource, type StackSourceType } from './db';
|
||||
|
||||
// Compose file patterns to detect (in order of priority)
|
||||
const COMPOSE_PATTERNS = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'];
|
||||
|
||||
// Directories to skip during scanning
|
||||
const SKIP_DIRECTORIES = ['.git', 'node_modules', '.docker', '__pycache__', '.venv', 'venv'];
|
||||
|
||||
// Maximum recursion depth to prevent runaway scanning
|
||||
const MAX_DEPTH = 5;
|
||||
|
||||
export interface RunningStackInfo {
|
||||
envId: number;
|
||||
envName: string;
|
||||
containerCount: number;
|
||||
}
|
||||
|
||||
export interface DiscoveredStack {
|
||||
name: string;
|
||||
composePath: string;
|
||||
envPath: string | null;
|
||||
sourceDir: string;
|
||||
serviceCount?: number; // Number of services defined in compose file
|
||||
runningOn?: RunningStackInfo[];
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
discovered: DiscoveredStack[];
|
||||
adopted: string[];
|
||||
skipped: DiscoveredStack[];
|
||||
errors: { path: string; error: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a stack name to be valid (lowercase alphanumeric with hyphens/underscores)
|
||||
*/
|
||||
function normalizeStackName(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file looks like a compose file (contains 'services:' key)
|
||||
*/
|
||||
async function isComposeFile(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const file = Bun.file(filePath);
|
||||
const content = await file.text();
|
||||
// Basic check for services key - could be more sophisticated
|
||||
return /^services:/m.test(content) || /\nservices:/m.test(content);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the number of services defined in a compose file
|
||||
* Uses simple regex to count top-level keys under 'services:' section
|
||||
*/
|
||||
async function countServices(filePath: string): Promise<number> {
|
||||
try {
|
||||
const file = Bun.file(filePath);
|
||||
const content = await file.text();
|
||||
|
||||
// Find the services section and count top-level keys
|
||||
const servicesMatch = content.match(/^services:\s*\n((?:[ \t]+\S[^\n]*\n?)*)/m) ||
|
||||
content.match(/\nservices:\s*\n((?:[ \t]+\S[^\n]*\n?)*)/m);
|
||||
|
||||
if (!servicesMatch) return 0;
|
||||
|
||||
const servicesBlock = servicesMatch[1];
|
||||
// Count lines that start with exactly 2 spaces followed by a non-space (service names)
|
||||
const serviceLines = servicesBlock.match(/^ [a-zA-Z0-9_-]+:/gm);
|
||||
return serviceLines?.length || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a single directory path for compose files
|
||||
*/
|
||||
async function scanPath(basePath: string): Promise<{ stacks: DiscoveredStack[]; errors: { path: string; error: string }[] }> {
|
||||
const discovered: DiscoveredStack[] = [];
|
||||
const errors: { path: string; error: string }[] = [];
|
||||
|
||||
// Resolve to absolute path
|
||||
const absolutePath = resolve(basePath);
|
||||
|
||||
// Verify path exists and is a directory
|
||||
if (!existsSync(absolutePath)) {
|
||||
errors.push({ path: basePath, error: 'Path does not exist' });
|
||||
return { stacks: discovered, errors };
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = statSync(absolutePath);
|
||||
if (!stat.isDirectory()) {
|
||||
errors.push({ path: basePath, error: 'Path is not a directory' });
|
||||
return { stacks: discovered, errors };
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push({ path: basePath, error: 'Cannot access path' });
|
||||
return { stacks: discovered, errors };
|
||||
}
|
||||
|
||||
// Track which directories we've found compose files in (to avoid duplicate scanning)
|
||||
const foundStackDirs = new Set<string>();
|
||||
|
||||
async function scan(currentPath: string, depth: number = 0): Promise<void> {
|
||||
// Limit depth to prevent runaway scanning
|
||||
if (depth > MAX_DEPTH) return;
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(currentPath, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
// Skip inaccessible directories
|
||||
return;
|
||||
}
|
||||
|
||||
// First pass: check for compose files in this directory
|
||||
for (const pattern of COMPOSE_PATTERNS) {
|
||||
const composePath = join(currentPath, pattern);
|
||||
if (existsSync(composePath)) {
|
||||
// Found a stack! Stack name = directory name
|
||||
const stackName = normalizeStackName(basename(currentPath));
|
||||
if (stackName) {
|
||||
// Check for .env file
|
||||
const envPath = join(currentPath, '.env');
|
||||
// Count services in compose file
|
||||
const serviceCount = await countServices(composePath);
|
||||
discovered.push({
|
||||
name: stackName,
|
||||
composePath,
|
||||
envPath: existsSync(envPath) ? envPath : null,
|
||||
sourceDir: currentPath,
|
||||
serviceCount
|
||||
});
|
||||
foundStackDirs.add(currentPath);
|
||||
}
|
||||
// Don't continue scanning in this directory - it's a stack
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: check for standalone compose files (*.yml, *.yaml) and recurse into subdirectories
|
||||
for (const entry of entries) {
|
||||
const entryPath = join(currentPath, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
// Skip excluded directories
|
||||
if (SKIP_DIRECTORIES.includes(entry.name)) continue;
|
||||
|
||||
// Skip if we already found a compose file here
|
||||
if (foundStackDirs.has(entryPath)) continue;
|
||||
|
||||
// Recurse into subdirectory
|
||||
await scan(entryPath, depth + 1);
|
||||
} else if (entry.isFile()) {
|
||||
const lowerName = entry.name.toLowerCase();
|
||||
|
||||
// Skip standard compose patterns (already handled above)
|
||||
if (COMPOSE_PATTERNS.includes(entry.name)) continue;
|
||||
|
||||
// Check for standalone compose files (e.g., myapp.yml, myapp.yaml)
|
||||
if (lowerName.endsWith('.yml') || lowerName.endsWith('.yaml')) {
|
||||
// Validate it's actually a compose file
|
||||
if (await isComposeFile(entryPath)) {
|
||||
const stackName = normalizeStackName(
|
||||
entry.name.replace(/\.(yml|yaml)$/i, '')
|
||||
);
|
||||
if (stackName) {
|
||||
// Check for .env file in same directory
|
||||
const envPath = join(currentPath, '.env');
|
||||
// Count services in compose file
|
||||
const serviceCount = await countServices(entryPath);
|
||||
discovered.push({
|
||||
name: stackName,
|
||||
composePath: entryPath,
|
||||
envPath: existsSync(envPath) ? envPath : null,
|
||||
sourceDir: currentPath,
|
||||
serviceCount
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await scan(absolutePath);
|
||||
return { stacks: discovered, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt a single stack into the database
|
||||
* - Checks if stack already exists (by composePath)
|
||||
* - Creates stackSource record with sourceType: 'internal'
|
||||
* - Does NOT deploy - just registers the stack
|
||||
*/
|
||||
export async function adoptStack(
|
||||
stack: DiscoveredStack,
|
||||
environmentId: number
|
||||
): Promise<{ success: boolean; adoptedName?: string; error?: string }> {
|
||||
// Get all existing stack sources to check for duplicates
|
||||
const existingSources = await getStackSources();
|
||||
|
||||
// Check if already adopted (by composePath)
|
||||
const alreadyAdopted = existingSources.some(
|
||||
(s) => s.composePath === stack.composePath
|
||||
);
|
||||
|
||||
if (alreadyAdopted) {
|
||||
return { success: false, error: 'Already adopted' };
|
||||
}
|
||||
|
||||
// Check for name conflict within the same environment
|
||||
let finalName = stack.name;
|
||||
const existingNames = new Set(
|
||||
existingSources
|
||||
.filter((s) => s.environmentId === environmentId)
|
||||
.map((s) => s.stackName)
|
||||
);
|
||||
|
||||
if (existingNames.has(finalName)) {
|
||||
// Append suffix to make unique
|
||||
let suffix = 1;
|
||||
while (existingNames.has(`${stack.name}-${suffix}`)) {
|
||||
suffix++;
|
||||
}
|
||||
finalName = `${stack.name}-${suffix}`;
|
||||
}
|
||||
|
||||
// Create stack source record - use 'internal' since we know the file paths
|
||||
try {
|
||||
await upsertStackSource({
|
||||
stackName: finalName,
|
||||
environmentId,
|
||||
sourceType: 'internal' as StackSourceType,
|
||||
composePath: stack.composePath,
|
||||
envPath: stack.envPath
|
||||
});
|
||||
|
||||
return { success: true, adoptedName: finalName };
|
||||
} catch (err) {
|
||||
console.error(`[Stack Scanner] Failed to adopt ${stack.name}:`, err);
|
||||
return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt multiple selected stacks into the database
|
||||
*/
|
||||
export async function adoptSelectedStacks(
|
||||
stacks: DiscoveredStack[],
|
||||
environmentId: number
|
||||
): Promise<{ adopted: string[]; failed: { name: string; error: string }[] }> {
|
||||
const adopted: string[] = [];
|
||||
const failed: { name: string; error: string }[] = [];
|
||||
|
||||
for (const stack of stacks) {
|
||||
const result = await adoptStack(stack, environmentId);
|
||||
if (result.success && result.adoptedName) {
|
||||
adopted.push(result.adoptedName);
|
||||
} else {
|
||||
failed.push({ name: stack.name, error: result.error || 'Unknown error' });
|
||||
}
|
||||
}
|
||||
|
||||
return { adopted, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan specific paths and return discovered stacks (without adopting)
|
||||
*/
|
||||
export async function scanPaths(paths: string[]): Promise<ScanResult> {
|
||||
if (paths.length === 0) {
|
||||
return { discovered: [], adopted: [], skipped: [], errors: [] };
|
||||
}
|
||||
|
||||
console.log(`[Stack Scanner] Scanning ${paths.length} path(s)...`);
|
||||
|
||||
const allDiscovered: DiscoveredStack[] = [];
|
||||
const allErrors: { path: string; error: string }[] = [];
|
||||
|
||||
// Scan all paths
|
||||
for (const path of paths) {
|
||||
const { stacks, errors } = await scanPath(path);
|
||||
allDiscovered.push(...stacks);
|
||||
allErrors.push(...errors);
|
||||
}
|
||||
|
||||
console.log(`[Stack Scanner] Found ${allDiscovered.length} compose file(s)`);
|
||||
|
||||
// Check which stacks are already adopted
|
||||
const existingSources = await getStackSources();
|
||||
const alreadyAdopted: DiscoveredStack[] = [];
|
||||
const newStacks: DiscoveredStack[] = [];
|
||||
|
||||
for (const stack of allDiscovered) {
|
||||
const isAdopted = existingSources.some(s => s.composePath === stack.composePath);
|
||||
if (isAdopted) {
|
||||
alreadyAdopted.push(stack);
|
||||
} else {
|
||||
newStacks.push(stack);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
discovered: newStacks,
|
||||
adopted: [],
|
||||
skipped: alreadyAdopted,
|
||||
errors: allErrors
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan all configured external paths and return discovered stacks (without adopting)
|
||||
*/
|
||||
export async function scanExternalPaths(): Promise<ScanResult> {
|
||||
const paths = await getExternalStackPaths();
|
||||
|
||||
if (paths.length === 0) {
|
||||
return { discovered: [], adopted: [], skipped: [], errors: [] };
|
||||
}
|
||||
|
||||
console.log(`[Stack Scanner] Scanning ${paths.length} external path(s)...`);
|
||||
|
||||
const allDiscovered: DiscoveredStack[] = [];
|
||||
const allErrors: { path: string; error: string }[] = [];
|
||||
|
||||
// Scan all paths
|
||||
for (const path of paths) {
|
||||
const { stacks, errors } = await scanPath(path);
|
||||
allDiscovered.push(...stacks);
|
||||
allErrors.push(...errors);
|
||||
}
|
||||
|
||||
console.log(`[Stack Scanner] Found ${allDiscovered.length} compose file(s)`);
|
||||
|
||||
// Check which stacks are already adopted
|
||||
const existingSources = await getStackSources();
|
||||
const alreadyAdopted: DiscoveredStack[] = [];
|
||||
const newStacks: DiscoveredStack[] = [];
|
||||
|
||||
for (const stack of allDiscovered) {
|
||||
const isAdopted = existingSources.some(s => s.composePath === stack.composePath);
|
||||
if (isAdopted) {
|
||||
alreadyAdopted.push(stack);
|
||||
} else {
|
||||
newStacks.push(stack);
|
||||
}
|
||||
}
|
||||
|
||||
if (alreadyAdopted.length > 0) {
|
||||
console.log(`[Stack Scanner] ${alreadyAdopted.length} stack(s) already adopted`);
|
||||
}
|
||||
if (newStacks.length > 0) {
|
||||
console.log(`[Stack Scanner] ${newStacks.length} new stack(s) available for adoption`);
|
||||
}
|
||||
if (allErrors.length > 0) {
|
||||
console.warn(`[Stack Scanner] ${allErrors.length} error(s) during scanning`);
|
||||
}
|
||||
|
||||
return {
|
||||
discovered: newStacks, // Only return stacks not yet adopted
|
||||
adopted: [], // No auto-adopt anymore
|
||||
skipped: alreadyAdopted,
|
||||
errors: allErrors
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two paths overlap (one is parent/child of the other)
|
||||
*/
|
||||
function pathsOverlap(path1: string, path2: string): 'parent' | 'child' | 'same' | null {
|
||||
const resolved1 = resolve(path1);
|
||||
const resolved2 = resolve(path2);
|
||||
|
||||
if (resolved1 === resolved2) {
|
||||
return 'same';
|
||||
}
|
||||
|
||||
// Normalize paths with trailing slash for proper prefix matching
|
||||
const normalized1 = resolved1.endsWith('/') ? resolved1 : resolved1 + '/';
|
||||
const normalized2 = resolved2.endsWith('/') ? resolved2 : resolved2 + '/';
|
||||
|
||||
if (normalized2.startsWith(normalized1)) {
|
||||
// path1 is parent of path2
|
||||
return 'parent';
|
||||
}
|
||||
|
||||
if (normalized1.startsWith(normalized2)) {
|
||||
// path1 is child of path2
|
||||
return 'child';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a path exists, is a directory, and doesn't overlap with existing paths
|
||||
*/
|
||||
export function validatePath(
|
||||
path: string,
|
||||
existingPaths: string[] = []
|
||||
): { valid: boolean; error?: string; resolvedPath?: string } {
|
||||
if (!path || typeof path !== 'string') {
|
||||
return { valid: false, error: 'Path is required' };
|
||||
}
|
||||
|
||||
const resolvedPath = resolve(path.trim());
|
||||
|
||||
if (!existsSync(resolvedPath)) {
|
||||
return { valid: false, error: 'Path does not exist' };
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = statSync(resolvedPath);
|
||||
if (!stat.isDirectory()) {
|
||||
return { valid: false, error: 'Path is not a directory' };
|
||||
}
|
||||
} catch {
|
||||
return { valid: false, error: 'Cannot access path' };
|
||||
}
|
||||
|
||||
// Check for overlapping paths
|
||||
for (const existingPath of existingPaths) {
|
||||
const overlap = pathsOverlap(resolvedPath, existingPath);
|
||||
if (overlap === 'same') {
|
||||
return { valid: false, error: 'This location is already added' };
|
||||
}
|
||||
if (overlap === 'parent') {
|
||||
return { valid: false, error: `This path contains an existing location: ${existingPath}` };
|
||||
}
|
||||
if (overlap === 'child') {
|
||||
return { valid: false, error: `This path is inside an existing location: ${existingPath}` };
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true, resolvedPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which discovered stacks are already running on any environment.
|
||||
* Matches by stack name (com.docker.compose.project label) since paths may differ.
|
||||
*/
|
||||
export async function detectRunningStacks(
|
||||
discovered: DiscoveredStack[]
|
||||
): Promise<DiscoveredStack[]> {
|
||||
if (discovered.length === 0) {
|
||||
return discovered;
|
||||
}
|
||||
|
||||
// Dynamic imports to avoid circular dependencies
|
||||
const { listComposeStacks } = await import('./stacks.js');
|
||||
const { getEnvironments } = await import('./db.js');
|
||||
|
||||
// Get all environments
|
||||
const environments = await getEnvironments();
|
||||
|
||||
if (environments.length === 0) {
|
||||
return discovered;
|
||||
}
|
||||
|
||||
// Build map of stack name -> running info across all environments
|
||||
const runningStacksMap = new Map<string, RunningStackInfo[]>();
|
||||
|
||||
// Query each environment in parallel for running stacks
|
||||
await Promise.all(
|
||||
environments.map(async (env) => {
|
||||
try {
|
||||
const stacks = await listComposeStacks(env.id);
|
||||
for (const stack of stacks) {
|
||||
const existing = runningStacksMap.get(stack.name) || [];
|
||||
existing.push({
|
||||
envId: env.id,
|
||||
envName: env.name,
|
||||
containerCount: stack.containers?.length || 0
|
||||
});
|
||||
runningStacksMap.set(stack.name, existing);
|
||||
}
|
||||
} catch (error) {
|
||||
// Environment might be offline - skip silently
|
||||
console.warn(`[Stack Scanner] Failed to query environment ${env.name}:`, error);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Attach running info to discovered stacks by matching name
|
||||
return discovered.map((stack) => ({
|
||||
...stack,
|
||||
runningOn: runningStacksMap.get(stack.name)
|
||||
}));
|
||||
}
|
||||
Reference in new issue
Block a user