1.0.12
This commit is contained in:
1 parent
a3cc26d958
commit
86a06d9de0
32 files changed
+799
-522
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user