diff --git a/src/lib/server/docker.ts b/src/lib/server/docker.ts index 341fdac..822b0d1 100644 --- a/src/lib/server/docker.ts +++ b/src/lib/server/docker.ts @@ -26,7 +26,6 @@ import { resolveNanoCpusConflict, resolvePodmanUsernsMode } from './hostconfig-r import { parseImageReference } from './registry/image-ref'; export { parseImageReference } from './registry/image-ref'; import { rebaseEnvOntoImage, rebaseLabelsOntoImage, rebaseCommand, describeEnvRebase, describeLabelRebase, type ImageEnvLabels } from './container-env-merge'; -import { db, environments, eq } from './db/drizzle.js'; import { encodeRegistryAuth, fetchRegistryToken, isSafeRegistryHost } from './registry-auth'; import { classifyManifest, type ArtifactKind } from './semver/manifest-artifact'; import { isSystemContainer, classifyEmptyDigestImage, localDigestIsIndexChild } from './scheduler/tasks/update-utils'; @@ -4093,47 +4092,23 @@ export async function dockerPing(envId: number): Promise { } } -export interface HawserAgentInfo { +/** + * Get Hawser agent info (for hawser-standard mode) + * Returns agent info including uptime + */ +export async function getHawserInfo(envId: number): Promise<{ agentId: string; agentName: string; dockerVersion: string; hawserVersion: string; mode: string; uptime: number; - /** Hawser agent STACKS_DIR — remote host path where compose stacks are written */ - stacksDir?: string; -} - -/** - * Get Hawser agent info (for hawser-standard mode) - * Returns agent info including uptime - */ -export async function getHawserInfo(envId: number): Promise { - return getHawserInfoCached(envId); -} - -const HAWSER_INFO_TTL_MS = 60_000; -const HAWSER_INFO_NEGATIVE_TTL_MS = 3_000; -const hawserInfoCache = new Map(); - -/** - * Memoized wrapper around the /_hawser/info agent round-trip. Stacks listing - * resolves display paths per stack, so a single list request would otherwise - * hit the agent once per stack (N+1). - */ -async function getHawserInfoCached(envId: number): Promise { - const cached = hawserInfoCache.get(envId); - if (cached && cached.expiresAt > Date.now()) { - return cached.info; - } - - let info: HawserAgentInfo | null = null; +} | null> { for (let attempt = 0; attempt < 2; attempt++) { try { const response = await dockerFetch('/_hawser/info', {}, envId); if (response.ok) { - info = await response.json(); - break; + return await response.json(); } await drainResponse(response); console.warn(`[Hawser] Info endpoint returned ${response.status} for env ${envId}`); @@ -4142,27 +4117,7 @@ async function getHawserInfoCached(envId: number): Promise { - const resolvedEnv = env ?? (await getEnvironment(environmentId)); - if (!isHawserConnection(resolvedEnv)) return null; - - const stagingStackDir = resolve( - (await findStackDir(stackName, environmentId)) ?? (await getStackDir(stackName, environmentId)) - ); - - let remoteStackDir: string | null = null; - const pathHints = hints ?? (await getStackPathHints(stackName, environmentId)); - if (pathHints.workingDir) { - remoteStackDir = pathHints.workingDir; - } else if (pathHints.configFiles?.length) { - remoteStackDir = dirname(pathHints.configFiles[0]); - } - - // DB-persisted remote stacksDir (survives agent restarts) - if (!remoteStackDir && resolvedEnv?.hawserStacksDir) { - remoteStackDir = join(resolvedEnv.hawserStacksDir, stackName); - } - - if (!remoteStackDir) { - const { getHawserInfo } = await import('./docker.js'); - const info = await getHawserInfo(environmentId); - if (info?.stacksDir) { - remoteStackDir = join(info.stacksDir, stackName); - try { - await db - .update(environments) - .set({ hawserStacksDir: info.stacksDir }) - .where(eq(environments.id, environmentId)); - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - console.warn(`[StacksDir] Failed to persist stacksDir for env ${environmentId}: ${msg}`); - } - } - } - - if (!remoteStackDir) return null; - return { stagingStackDir, remoteStackDir: resolve(remoteStackDir) }; -} - -/** - * For Hawser environments, compose paths in the DB point at Dockhand's staging copy - * ($DATA_DIR/stacks///). Deployed files live on the remote host under the - * Hawser agent's STACKS_DIR//. - */ -export async function remapHawserStagingDisplayPaths( - stackName: string, - environmentId: number | null | undefined, - paths: { composePath: string | null; composePaths: string[] }, - env?: HawserEnvLike | null, - hints?: { workingDir: string | null; configFiles: string[] | null } | null -): Promise<{ composePath: string | null; composePaths: string[] }> { - if (!paths.composePath && paths.composePaths.length === 0) return paths; - if (environmentId == null) return paths; - - const pathList = - paths.composePaths.length > 0 - ? paths.composePaths - : paths.composePath - ? [paths.composePath] - : []; - - const pair = await resolveHawserStackDirPair(stackName, environmentId, env, hints); - if (!pair) return paths; - - const { stagingStackDir, remoteStackDir } = pair; - const usesStaging = pathList.some((p) => { - const resolved = resolve(p); - return isPathUnderRoot(resolved, stagingStackDir) || isManagedStagingDir(dirname(resolved)); - }); - if (!usesStaging) return paths; - - const remapped = remapPathsFromStagingToRemote(stagingStackDir, remoteStackDir, pathList); - return { - composePath: remapped[0] ?? null, - composePaths: remapped - }; -} - -/** - * Remap composeContents keys from staging paths to Hawser remote display paths. - */ -export async function remapHawserStagingDisplayComposeContents( - stackName: string, - environmentId: number | null | undefined, - composeContents: Record | null | undefined, - env?: HawserEnvLike | null, - hints?: { workingDir: string | null; configFiles: string[] | null } | null -): Promise | null | undefined> { - if (!composeContents || environmentId == null) return composeContents; - - const pair = await resolveHawserStackDirPair(stackName, environmentId, env, hints); - if (!pair) return composeContents; - - return remapComposeContentsFromStagingToRemote( - pair.stagingStackDir, - pair.remoteStackDir, - composeContents - ); -} - -/** - * Convert Hawser display paths from the UI back to Dockhand staging paths for disk writes. - */ -export async function unmapHawserDisplayComposeOptionsToStaging( - stackName: string, - environmentId: number | null | undefined, - options: { - composePath?: string; - composePaths?: string[] | null; - composeContents?: Record; - envPath?: string; - moveFromDir?: string; - oldComposePath?: string; - oldEnvPath?: string; - } -): Promise { - if (!options || environmentId == null) return options; - - const pair = await resolveHawserStackDirPair(stackName, environmentId); - if (!pair) return options; - - const { stagingStackDir, remoteStackDir } = pair; - const result = { ...options }; - - if (options.composePath) { - result.composePath = remapPathsFromRemoteToStaging(stagingStackDir, remoteStackDir, [ - options.composePath - ])[0]; - } - if (options.composePaths) { - result.composePaths = remapPathsFromRemoteToStaging( - stagingStackDir, - remoteStackDir, - options.composePaths - ); - } - if (options.composeContents) { - result.composeContents = remapComposeContentsFromRemoteToStaging( - stagingStackDir, - remoteStackDir, - options.composeContents - ); - } - if (options.envPath) { - result.envPath = remapPathsFromRemoteToStaging(stagingStackDir, remoteStackDir, [ - options.envPath - ])[0]; - } - if (options.oldComposePath) { - result.oldComposePath = remapPathsFromRemoteToStaging(stagingStackDir, remoteStackDir, [ - options.oldComposePath - ])[0]; - } - if (options.oldEnvPath) { - result.oldEnvPath = remapPathsFromRemoteToStaging(stagingStackDir, remoteStackDir, [ - options.oldEnvPath - ])[0]; - } - - return result; -} - -/** - * Resolve stack source compose paths for UI display, including Hawser remote remapping. - */ -export async function resolveStackSourceDisplayPathsForEnv( - source: { - stackName: string; - environmentId?: number | null; - sourceType: string; - composePath?: string | null; - gitStack?: { contextDir?: string | null; composePath?: string } | null; - }, - env?: HawserEnvLike | null, - hints?: { workingDir: string | null; configFiles: string[] | null } | null -): Promise<{ composePath: string | null; composePaths: string[] }> { - const base = resolveStackSourceDisplayPaths(source); - return remapHawserStagingDisplayPaths(source.stackName, source.environmentId, base, env, hints); -} - -/** - * Build a map of compose project name -> path hints from a pre-fetched container - * list, so a stacks listing doesn't trigger one container listing per stack. - */ -export function buildStackPathHintsMap( - containers: any[] -): Map { - const map = new Map(); - for (const container of containers) { - const labels = container.labels || {}; - const project = labels['com.docker.compose.project']; - if (!project || map.has(project)) continue; - const workingDir = labels['com.docker.compose.project.working_dir'] || null; - const configFilesRaw = labels['com.docker.compose.project.config_files'] || null; - const configFiles = configFilesRaw ? configFilesRaw.split(',').map((f: string) => f.trim()) : null; - map.set(project, { workingDir, configFiles }); - } - return map; -} - -/** - * Resolve stack source compose paths to absolute on-disk paths for UI display. - * Git stacks store repo-relative paths in the DB; external/adopted stacks use absolute paths. - */ -export function resolveStackSourceDisplayPaths( - source: { - sourceType: string; - composePath?: string | null; - gitStack?: { contextDir?: string | null; composePath?: string } | null; - } -): { composePath: string | null; composePaths: string[] } { - if (!source.composePath) { - return { composePath: null, composePaths: [] }; - } - const path = isAbsolute(source.composePath) ? source.composePath : resolve(source.composePath); - return { composePath: path, composePaths: [path] }; -} - // ============================================================================= // COMPOSE FILE MANAGEMENT // ============================================================================= @@ -1393,7 +1160,7 @@ function findComposeOverrideFile(stackDir: string, composeFileName: string): str * @param customComposePath - Optional path to existing compose file (for imported stacks, skips writing) */ async function executeLocalCompose( - operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull', + operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull' | 'build', stackName: string, composeContent: string, dockerHost?: string, @@ -1652,39 +1419,7 @@ async function executeLocalCompose( console.log(`${logPrefix} [HostPath] Using stdin for compose content (paths translated)`); } - switch (operation) { - case 'up': - args.push('up', '-d', '--remove-orphans'); - if (forceRecreate) args.push('--force-recreate'); - if (build) args.push('--build'); - if (build && noBuildCache) args.push('--no-cache'); - if (pullPolicy) args.push('--pull', pullPolicy); - // If targeting a specific service, only update that service - if (serviceName) { - args.push(serviceName); - } - break; - case 'down': - args.push('down', '--remove-orphans'); - if (removeVolumes) args.push('--volumes'); - break; - case 'stop': - args.push('stop'); - break; - case 'start': - args.push('start'); - break; - case 'restart': - args.push('restart'); - break; - case 'pull': - args.push('pull'); - // If targeting a specific service, pull only that service - if (serviceName) { - args.push(serviceName); - } - break; - } + args.push(...buildComposeOperationArgs(operation, { forceRecreate, removeVolumes, build, noBuildCache, pullPolicy, serviceName })); const commandStr = args.join(' '); @@ -1774,10 +1509,13 @@ async function executeLocalCompose( command: commandStr }; } else { + // stderr can echo an interpolated secret value (e.g. a failing + // command containing ${DB_PASSWORD}); redact before it can reach a + // notification channel, the DB errorMessage, or the client. return { success: false, - output: stdout, - error: stderr || `docker compose ${operation} exited with code ${code}`, + output: redactSecretVars(stdout, secretVars), + error: redactSecretVars(stderr, secretVars) || `docker compose ${operation} exited with code ${code}`, command: commandStr }; } @@ -1789,7 +1527,7 @@ async function executeLocalCompose( return { success: false, output: '', - error: `Failed to run docker compose ${operation}: ${err.message}`, + error: redactSecretVars(`Failed to run docker compose ${operation}: ${err.message}`, secretVars), command: commandStr }; } finally { @@ -1822,7 +1560,7 @@ async function executeLocalCompose( * @param secretVars - Secret environment variables (injected via shell env on Hawser, NEVER in .env) */ async function executeComposeViaHawser( - operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull', + operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull' | 'build', stackName: string, composeContent: string, envId: number, @@ -1993,10 +1731,12 @@ async function executeComposeViaHawser( deletion }; } else { + // The agent's stderr can echo an interpolated secret value; redact before + // it reaches a notification channel, the DB errorMessage, or the client. return { success: false, - output: result.output || '', - error: result.error || `Compose ${operation} failed`, + output: redactSecretVars(result.output || '', secretVars), + error: redactSecretVars(result.error || `Compose ${operation} failed`, secretVars), deletion }; } @@ -2008,7 +1748,7 @@ async function executeComposeViaHawser( output: '', error: isStringLength ? `Stack files too large to send via Hawser. The repository may contain large binary files. Consider using a .dockerignore or moving large files out of the compose directory.` - : `Failed to ${operation} via Hawser: ${err.message}` + : redactSecretVars(`Failed to ${operation} via Hawser: ${err.message}`, secretVars) }; } } @@ -2020,7 +1760,7 @@ async function executeComposeViaHawser( * @param secretVars - Secret environment variables (from DB, injected via shell env) */ async function executeComposeCommand( - operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull', + operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull' | 'build', options: ComposeCommandOptions, composeContent: string, envVars?: Record, @@ -2901,6 +2641,12 @@ export async function removeStack( removeVolumes = false, deleteFiles = true ): Promise { + // Reject a name that isn't a plain stack name BEFORE any path construction. A + // traversal name (e.g. "..") would make getStackDir resolve to DATA_DIR's parent and + // rmSync it; the create/deploy paths already enforce this same shape. + if (!/^[a-z0-9][a-z0-9_-]*$/.test(stackName)) { + return { success: false, error: 'Invalid stack name' }; + } return withStackLock(stackName, async () => { // Get compose file (may not exist for external stacks) const composeResult = await getStackComposeFile(stackName, envId); @@ -3053,7 +2799,10 @@ export async function removeStack( // (Don't delete default-path files when an adopted stack has custom path outside DATA_DIR) if (!stackDir && !stackSource?.composePath) { const defaultDir = await findStackDir(stackName, envId) || await getStackDir(stackName, envId); - if (existsSync(defaultDir)) { + // Same #675 guard as the composePath branch: only a strict subdir of a managed + // stacks root whose basename is the stack name is deletable. Never DATA_DIR or a parent. + const deletableRoots = [getDefaultStacksDir(), ...(isStacksDirEnvSet() ? [getLocalStacksDir()] : [])]; + if (deletableRoots.some((root) => isDeletableStackDir(defaultDir, root, stackName)) && existsSync(defaultDir)) { stackDir = defaultDir; } } @@ -3380,27 +3129,41 @@ export async function deployStack(options: DeployStackOptions): Promise/ - * for local envs when STACKS_DIR is set, else $DATA_DIR/stacks///). */ export const GET: RequestHandler = async ({ url }) => { const stackName = url.searchParams.get('name'); @@ -38,7 +29,6 @@ export const GET: RequestHandler = async ({ url }) => { } let stackDir: string; - let source = 'default'; if (location) { // Custom location: {location}/{envName}/{stackName}/ @@ -54,23 +44,13 @@ export const GET: RequestHandler = async ({ url }) => { } } else { // Dockhand default location - if (envIdNum) { - const env = await getEnvironment(envIdNum); - if (env && isHawserConnection(env) && env.hawserStacksDir) { - stackDir = join(env.hawserStacksDir, stackName); - source = 'hawser'; - } else { - stackDir = await getStackDir(stackName, envIdNum); - } - } else { - stackDir = await getStackDir(stackName, envIdNum); - } + stackDir = await getStackDir(stackName, envIdNum); } return json({ stackDir, composePath: `${stackDir}/compose.yaml`, envPath: `${stackDir}/.env`, - source + source: 'default' }); }; diff --git a/src/routes/api/stacks/sources/+server.ts b/src/routes/api/stacks/sources/+server.ts index 17ae94a..7f5bebc 100644 --- a/src/routes/api/stacks/sources/+server.ts +++ b/src/routes/api/stacks/sources/+server.ts @@ -1,9 +1,8 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getStackSources, getEnvironment } from '$lib/server/db'; -import { countStackEnvVars, resolveStackSourceDisplayPathsForEnv, buildStackPathHintsMap } from '$lib/server/stacks'; +import { getStackSources } from '$lib/server/db'; +import { countStackEnvVars } from '$lib/server/stacks'; import { authorize } from '$lib/server/authorize'; -import { listContainers } from '$lib/server/docker'; /** * @openapi @@ -26,19 +25,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => { try { const sources = await getStackSources(envIdNum); - const envIds = [...new Set(sources.map((s) => s.environmentId).filter((id): id is number => id != null))]; - const envs = await Promise.all(envIds.map((id) => getEnvironment(id))); - const envMap = new Map(envs.filter((e) => e !== undefined).map((e) => [e.id, e])); - - const hintEnvIds = [...new Set(sources.map((s) => s.environmentId ?? null))]; - const hintMaps = await Promise.all( - hintEnvIds.map(async (id) => { - const containers = await listContainers(true, id).catch(() => []); - return { id, map: buildStackPathHintsMap(containers) }; - }) - ); - const hintsByEnv = new Map(hintMaps.map((h) => [String(h.id ?? 'null'), h.map])); - + // Convert to a map for easier lookup in the frontend const sourceMap: Record< string, { @@ -50,6 +37,11 @@ export const GET: RequestHandler = async ({ url, cookies }) => { envVarCount?: number; } > = {}; + // Count env vars server-side (one local read per stack) so the list badge does + // not need a /env fetch per stack. GET /env resolves its env param as + // `envId ? parseInt : null`, so pass the SAME null-when-absent value (not + // undefined - getStackEnvVars scopes differently for null vs undefined) so the + // count matches that endpoint's variable list exactly. const countEnvId = envIdNum ?? null; const counts = await Promise.all( sources.map((s) => @@ -58,21 +50,16 @@ export const GET: RequestHandler = async ({ url, cookies }) => { : Promise.resolve(0) ) ); - for (const [i, source] of sources.entries()) { - const resolved = await resolveStackSourceDisplayPathsForEnv( - source, - source.environmentId != null ? envMap.get(source.environmentId) ?? null : null, - hintsByEnv.get(String(source.environmentId ?? 'null'))?.get(source.stackName) ?? null - ); + sources.forEach((source, i) => { sourceMap[source.stackName] = { sourceType: source.sourceType, - composePath: resolved.composePath, + composePath: source.composePath, repository: source.repository, secretProviderId: source.secretProviderId, icon: source.icon ?? null, envVarCount: counts[i], }; - } + }); return json(sourceMap); } catch (error) { diff --git a/src/routes/settings/environments/EnvironmentsTab.svelte b/src/routes/settings/environments/EnvironmentsTab.svelte index 5694351..20d72a0 100644 --- a/src/routes/settings/environments/EnvironmentsTab.svelte +++ b/src/routes/settings/environments/EnvironmentsTab.svelte @@ -706,20 +706,18 @@

The environment {deleteEnvTarget.name} - and the following directories will be permanently removed from the Dockhand host - (managed local stacks under STACKS_DIR, when configured, are removed per stack name): + and the following directories will be permanently removed from the Dockhand host:

$DATA_DIR/stacks/{deleteEnvTarget.name}/ - (staging) +
+
+ + $DATA_DIR/git-repos/{deleteEnvTarget.name}/
-

- Shared git repository clones under $DATA_DIR/git-repos/<repoName>/ - are not removed when an environment is deleted. -

{#if deleteCountsLoading}