This commit is contained in:
jarek committed 2026-01-22 16:23:26 +01:00
1 parent a3cc26d958
commit 86a06d9de0
32 files changed
+799 -522

No files matched your search

+3 -1
View File
@@ -9,7 +9,9 @@ import type { AuditLogCreateData } from './db';
export interface AuditEventData extends AuditLogCreateData {
id: number;
timestamp: string;
createdAt: string;
environmentName?: string | null;
environmentIcon?: string | null;
}
// Create a singleton event emitter for audit events
+1 -1
View File
@@ -10,6 +10,7 @@
import { existsSync, openSync, readSync, closeSync } from 'node:fs';
import os from 'node:os';
import { randomBytes } from 'node:crypto';
// Cache kernel version check result
let needsFallback: boolean | null = null;
@@ -140,7 +141,6 @@ export function secureRandomBytes(size: number): Buffer {
}
// Use native crypto on modern kernels
const { randomBytes } = require('node:crypto');
return randomBytes(size);
}
+55 -3
View File
@@ -3023,13 +3023,32 @@ export async function logAuditEvent(data: AuditLogCreateData): Promise<AuditLogD
return auditLog!;
}
export async function getAuditLog(id: number): Promise<AuditLogData | undefined> {
const results = await db.select().from(auditLogs).where(eq(auditLogs.id, id));
export async function getAuditLog(id: number): Promise<(AuditLogData & { environmentName?: string | null; environmentIcon?: string | null }) | undefined> {
const results = await db.select({
id: auditLogs.id,
userId: auditLogs.userId,
username: auditLogs.username,
action: auditLogs.action,
entityType: auditLogs.entityType,
entityId: auditLogs.entityId,
entityName: auditLogs.entityName,
environmentId: auditLogs.environmentId,
description: auditLogs.description,
details: auditLogs.details,
ipAddress: auditLogs.ipAddress,
userAgent: auditLogs.userAgent,
createdAt: auditLogs.createdAt,
environmentName: environments.name,
environmentIcon: environments.icon
})
.from(auditLogs)
.leftJoin(environments, eq(auditLogs.environmentId, environments.id))
.where(eq(auditLogs.id, id));
if (!results[0]) return undefined;
return {
...results[0],
details: results[0].details ? JSON.parse(results[0].details) : null
} as AuditLogData;
} as AuditLogData & { environmentName?: string | null; environmentIcon?: string | null };
}
export async function getAuditLogs(filters: AuditLogFilters = {}): Promise<AuditLogResult> {
@@ -4433,6 +4452,39 @@ export async function deleteStackEnvVars(
}
}
/**
* Update stack name in environment variables (for stack rename operations).
* @param oldStackName - Current stack name
* @param newStackName - New stack name
* @param environmentId - Optional environment ID (null = no environment, undefined = all environments)
*/
export async function updateStackEnvVarsName(
oldStackName: string,
newStackName: string,
environmentId?: number | null
): Promise<void> {
if (environmentId === undefined) {
// Update all env vars for this stack (all environments)
await db.update(stackEnvironmentVariables)
.set({ stackName: newStackName })
.where(eq(stackEnvironmentVariables.stackName, oldStackName));
} else if (environmentId === null) {
await db.update(stackEnvironmentVariables)
.set({ stackName: newStackName })
.where(and(
eq(stackEnvironmentVariables.stackName, oldStackName),
isNull(stackEnvironmentVariables.environmentId)
));
} else {
await db.update(stackEnvironmentVariables)
.set({ stackName: newStackName })
.where(and(
eq(stackEnvironmentVariables.stackName, oldStackName),
eq(stackEnvironmentVariables.environmentId, environmentId)
));
}
}
/**
* Get all stacks with their environment variable counts.
* Useful for displaying env var badges in the stacks list.
+9
View File
@@ -1877,6 +1877,15 @@ export async function checkImageUpdateAvailable(
envId?: number
): Promise<ImageUpdateCheckResult> {
try {
// Skip update check for digest-pinned images
// If the user explicitly pins to a digest (image@sha256:...), they don't want auto-updates
if (isDigestBasedImage(imageName)) {
return {
hasUpdate: false,
currentDigest: imageName.split('@')[1] // Extract the digest part
};
}
// Get current image info to get RepoDigests
let currentImageInfo: any;
try {
+6 -6
View File
@@ -384,11 +384,11 @@ export async function syncRepository(repoId: number): Promise<SyncResult> {
let currentCommit = '';
if (!existsSync(repoPath)) {
// Clone the repository (shallow clone)
// Clone the repository (blobless clone - fetches all commits but blobs on-demand)
const repoUrl = buildRepoUrl(repo.url, credential);
const result = await execGit(
['clone', '--depth=1', '--branch', repo.branch, repoUrl, repoPath],
['clone', '--filter=blob:none', '--branch', repo.branch, repoUrl, repoPath],
process.cwd(),
env
);
@@ -611,7 +611,7 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
let currentCommit = '';
// Always re-clone to ensure clean state (handles branch/URL/credential changes, force pushes, etc.)
// Shallow clones are fast so this is acceptable
// Blobless clones fetch all commits (for git diff) but download blobs on-demand
const previousCommit = await getPreviousCommit(repoPath, env);
if (existsSync(repoPath)) {
console.log(`${logPrefix} Removing existing clone for fresh sync...`);
@@ -622,7 +622,7 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
const repoUrl = buildRepoUrl(repo.url, credential);
const result = await execGit(
['clone', '--depth=1', '--branch', repo.branch, repoUrl, repoPath],
['clone', '--filter=blob:none', '--branch', repo.branch, repoUrl, repoPath],
process.cwd(),
env
);
@@ -993,10 +993,10 @@ export async function deployGitStackWithProgress(
const repoUrl = buildRepoUrl(repo.url, credential);
// Step 3: Fetching
// Step 3: Fetching (blobless clone - fetches all commits but blobs on-demand)
onProgress({ status: 'fetching', message: `Fetching branch ${repo.branch}...`, step: 3, totalSteps });
const cloneResult = await execGit(
['clone', '--depth=1', '--branch', repo.branch, repoUrl, repoPath],
['clone', '--filter=blob:none', '--branch', repo.branch, repoUrl, repoPath],
process.cwd(),
env
);
+52 -19
View File
@@ -2,19 +2,21 @@
* Host Path Resolution Module
*
* Dockhand runs inside a Docker container where paths differ from the host.
* This module detects the host path for the DATA_DIR mount, enabling proper
* volume path resolution for compose stacks.
* This module detects the host paths for ALL container mounts, enabling proper
* volume path resolution for compose stacks (both internal and adopted/external).
*
* Problem:
* - Dockhand container has /app/data mounted from host (e.g., -v dockhand_data:/app/data)
* - User may also mount external directories (e.g., -v /host/stacks:/external-stacks)
* - Compose file says: ./ca.pem:/ca.pem (relative path)
* - docker-compose resolves this to /app/data/stacks/.../ca.pem
* - Docker daemon on HOST receives this path, but /app/data doesn't exist on host!
* - docker-compose resolves this to container path (e.g., /external-stacks/.../ca.pem)
* - Docker daemon on HOST receives this path, but /external-stacks doesn't exist on host!
* - Docker creates a directory instead of mounting the file
*
* Solution:
* - Query Docker API to find the host source path for our /app/data mount
* - Rewrite relative paths in compose files to use the host path
* - Query Docker API to find ALL host source paths for our container mounts
* - Rewrite relative paths in compose files to use the correct host path
* - Works for both internal stacks (DATA_DIR) and adopted stacks (external mounts)
*/
import { readFileSync } from 'node:fs';
@@ -24,6 +26,9 @@ import { resolve } from 'node:path';
let cachedHostDataDir: string | null = null;
let detectionAttempted = false;
// Cache ALL mounts for path translation (not just DATA_DIR)
let cachedMounts: Array<{ source: string; destination: string }> | null = null;
/**
* Get our own container ID
*/
@@ -111,6 +116,13 @@ export async function detectHostDataDir(): Promise<string | null> {
}>;
};
// Cache ALL mounts for later path translation (used by rewriteComposeVolumePaths)
cachedMounts = (containerInfo.Mounts || []).map(m => ({
source: m.Source,
destination: m.Destination
}));
console.log(`[HostPath] Cached ${cachedMounts.length} mount(s)`);
// Find the mount for our DATA_DIR
const dataMount = containerInfo.Mounts?.find(m => m.Destination === dataDir);
@@ -168,6 +180,34 @@ export function translateToHostPath(containerPath: string): string {
return containerPath;
}
/**
* Translate any container path to host path using ALL cached mounts.
* This is more general than translateToHostPath() which only handles DATA_DIR.
*
* @param containerPath - Path inside the container (e.g., /external-stacks/mystack)
* @returns Host path if a matching mount is found, or null if no translation possible
*/
export function translateContainerPathViaMount(containerPath: string): string | null {
if (!cachedMounts || cachedMounts.length === 0) {
return null;
}
// Sort mounts by destination length (longest first) to match most specific mount
const sortedMounts = [...cachedMounts].sort(
(a, b) => b.destination.length - a.destination.length
);
for (const mount of sortedMounts) {
if (containerPath.startsWith(mount.destination + '/') ||
containerPath === mount.destination) {
const relativePath = containerPath.substring(mount.destination.length);
return mount.source + relativePath;
}
}
return null;
}
/**
* Rewrite relative volume paths in a compose file to use absolute host paths.
* This is necessary when Dockhand runs inside Docker with a mounted data volume.
@@ -180,24 +220,17 @@ export function translateToHostPath(containerPath: string): string {
* @returns Modified compose content with absolute host paths, or original if no translation needed
*/
export function rewriteComposeVolumePaths(composeContent: string, workingDir: string): { content: string; modified: boolean; changes: string[] } {
const hostDataDir = getHostDataDir();
const changes: string[] = [];
if (!hostDataDir) {
// Try to translate workingDir to host path using ANY cached mount
// This handles both DATA_DIR mounts and external mounts (e.g., /external-stacks)
const hostWorkingDir = translateContainerPathViaMount(workingDir);
if (!hostWorkingDir) {
// Can't translate - workingDir is not under any known mount
return { content: composeContent, modified: false, changes };
}
const dataDir = resolve(process.env.DATA_DIR || '/app/data');
// Check if workingDir is under DATA_DIR
if (!workingDir.startsWith(dataDir + '/') && workingDir !== dataDir) {
return { content: composeContent, modified: false, changes };
}
// Calculate the host working directory
const relativePath = workingDir.substring(dataDir.length);
const hostWorkingDir = hostDataDir + relativePath;
// Parse compose content line by line to find and rewrite volume mounts
// We look for patterns like:
// - ./something:/container/path
@@ -124,6 +124,18 @@ export async function runContainerUpdate(
return;
}
// Skip digest-pinned images - they are explicitly locked to a specific version
if (isDigestBasedImage(imageNameFromConfig)) {
log(`Skipping ${containerName} - image pinned to specific digest`);
await updateScheduleExecution(execution.id, {
status: 'skipped',
completedAt: new Date().toISOString(),
duration: Date.now() - startTime,
details: { reason: 'Image pinned to specific digest' }
});
return;
}
// Get the actual image ID from inspect data
const currentImageId = inspectData.Image;
+83 -13
View File
@@ -20,8 +20,12 @@ import {
getGitStackByName,
deleteGitStack,
getStackSources,
deleteStackEnvVars
deleteStackEnvVars,
removePendingContainerUpdate,
deleteAutoUpdateSchedule,
getAutoUpdateSetting
} from './db';
import { unregisterSchedule } from './scheduler';
import { deleteGitStackFiles } from './git';
import { cleanPem } from '$lib/utils/pem';
import { rewriteComposeVolumePaths, getHostDataDir } from './host-path';
@@ -1534,18 +1538,24 @@ async function requireComposeFile(
const dbNonSecretVars = await getNonSecretEnvVarsAsRecord(stackName, envId);
// Read non-secret vars from .env file
// For stacks with custom path, use the env path if set (and not empty string which means "no env file")
// Otherwise, use the .env file in the stack directory
// For stacks with custom composePath (adopted/external), derive envPath from same directory
// For internal stacks, use the default data directory
let envFilePath: string | null = null;
if (composeResult.composePath && composeResult.envPath) {
// Custom compose path with explicit env path
envFilePath = composeResult.envPath;
} else if (composeResult.composePath && composeResult.envPath === '') {
// Custom compose path with explicit "no env file" - don't read any file
envFilePath = null;
if (composeResult.composePath) {
// Adopted/external stack with custom compose path
if (composeResult.envPath) {
// Explicit env path stored in database
envFilePath = composeResult.envPath;
} else if (composeResult.envPath === '') {
// Explicitly no env file (user selected "no .env")
envFilePath = null;
} else {
// envPath is null - look for .env next to the compose file
envFilePath = join(dirname(composeResult.composePath), '.env');
}
} else {
// Default location - look for .env in stack directory
// Internal stack - use default data directory location
const stackDir = composeResult.stackDir || await findStackDir(stackName, envId) || await getStackDir(stackName, envId);
envFilePath = join(stackDir, '.env');
}
@@ -1699,6 +1709,9 @@ export async function removeStack(
// Get compose file (may not exist for external stacks)
const composeResult = await getStackComposeFile(stackName);
// Get stack containers BEFORE removing them (for cleanup later)
const stackContainers = await getStackContainers(stackName, envId);
// If compose file exists, run docker compose down first
if (composeResult.success) {
const envVars = await getNonSecretEnvVarsAsRecord(stackName, envId);
@@ -1722,7 +1735,6 @@ export async function removeStack(
} else {
// External stack - remove containers directly in parallel
const { removeContainer } = await import('./docker.js');
const stackContainers = await getStackContainers(stackName, envId);
const removalResults = await Promise.allSettled(
stackContainers.map((container) =>
@@ -1746,12 +1758,70 @@ export async function removeStack(
}
}
// Clean up auto-update schedules and pending updates for stack containers
const envIdNum = typeof envId === 'number' ? envId : undefined;
for (const container of stackContainers) {
const containerName = container.names?.[0]?.replace(/^\//, '') || container.name;
const containerId = container.id;
// Clean up auto-update schedule
try {
const setting = await getAutoUpdateSetting(containerName, envIdNum);
if (setting) {
unregisterSchedule(setting.id, 'container_update');
await deleteAutoUpdateSchedule(containerName, envIdNum);
}
} catch {
// Ignore cleanup errors
}
// Clean up pending container update
try {
if (envIdNum) {
await removePendingContainerUpdate(envIdNum, containerId);
}
} catch {
// Ignore cleanup errors
}
}
// Clean up database records - collect errors but don't stop
const cleanupErrors: string[] = [];
// Delete compose file and directory
const stackDir = await findStackDir(stackName, envId) || await getStackDir(stackName, envId);
if (existsSync(stackDir)) {
// Only delete files that are within Dockhand's data directory (stacks we created)
// Adopted/imported stacks have files outside DATA_DIR and should be preserved
const stackSource = await getStackSource(stackName, envId);
const stacksDir = getStacksDir();
// Determine what directory to delete (if any)
let stackDir: string | null = null;
if (stackSource?.composePath) {
// Check if the compose path is within Dockhand's stacks directory
const customDir = dirname(stackSource.composePath);
const resolvedCustomDir = resolve(customDir);
const resolvedStacksDir = resolve(stacksDir);
// Only delete if the directory is within DATA_DIR/stacks/ (files we created)
// AND it looks like a stack directory (contains stackName for safety)
if (resolvedCustomDir.startsWith(resolvedStacksDir) &&
customDir.includes(stackName) &&
existsSync(customDir)) {
stackDir = customDir;
}
}
// Fall back to default paths (always within DATA_DIR/stacks/)
if (!stackDir) {
const defaultDir = await findStackDir(stackName, envId) || await getStackDir(stackName, envId);
if (existsSync(defaultDir)) {
stackDir = defaultDir;
}
}
// Delete the directory if found
if (stackDir) {
try {
rmSync(stackDir, { recursive: true, force: true });
} catch (err: any) {
@@ -422,10 +422,15 @@ async function start(): Promise<void> {
// Schedule regular collection
collectInterval = setInterval(collectMetrics, COLLECT_INTERVAL);
// Start disk space checking (every 5 minutes)
console.log('[MetricsSubprocess] Starting disk space monitoring (every 5 minutes)');
checkDiskSpace(); // Initial check
diskCheckInterval = setInterval(checkDiskSpace, DISK_CHECK_INTERVAL);
// Start disk space checking (every 5 minutes) - can be disabled for Synology NAS
const skipDfCollection = process.env.SKIP_DF_COLLECTION === 'true' || process.env.SKIP_DF_COLLECTION === '1';
if (!skipDfCollection) {
console.log('[MetricsSubprocess] Starting disk space monitoring (every 5 minutes)');
checkDiskSpace(); // Initial check
diskCheckInterval = setInterval(checkDiskSpace, DISK_CHECK_INTERVAL);
} else {
console.log('[MetricsSubprocess] Disk space monitoring disabled (SKIP_DF_COLLECTION=true)');
}
// Listen for commands from main process
process.on('message', (message: MainProcessCommand) => {