feat(stacks): optional STACKS_DIR layout with Hawser display paths (#1482)

When STACKS_DIR is set, local managed stacks are stored in a flat
$STACKS_DIR/<stackName>/ layout instead of $DATA_DIR/stacks/<env>/<stack>/.
Existing stacks are not moved automatically and continue to resolve from
their existing locations. Invalid or unwritable STACKS_DIR values produce a
warning and fall back to $DATA_DIR/stacks instead of aborting startup. With
this layout, local stack names must be unique across environments.

Hawser agents report their remote stacks directory on hello. That path is
persisted on the environment as hawser_stacks_dir so compose APIs and the UI
can show host paths rather than Dockhand staging paths. Staging paths are
remapped to and from the remote stack dir for compose files, backups, and
environment settings. Backup restore may write under either the default
staging root or the configured STACKS_DIR.

Co-authored-by: xd003 <xd003@users.noreply.github.com>
This commit is contained in:
Badal Singhandxd003 authored and GitHub committed 2026-08-31 18:54:27 +02:00
1 parent 53161a6718
commit 77686da02a
27 files changed
+9145 -190

No files matched your search

+10 -4
View File
@@ -698,7 +698,7 @@ async function materialiseStackFiles(destination: any, snapId: string, stackName
const { tmpdir } = await import('os');
const { join, dirname, resolve, sep } = await import('path');
const { randomUUID } = await import('crypto');
const { getStacksDir } = await import('../stacks');
const { getDefaultStacksDir, getLocalStacksDir, isStacksDirEnvSet } = await import('../stacks');
const { stackDirSource } = await import('./stackdir-plan');
// The stack dir is ALWAYS at /volumes/__dockhand_stackdir__ in the snapshot (local bind
// and remote tar both write there), so restore reads ONE deterministic path via
@@ -730,9 +730,15 @@ async function materialiseStackFiles(destination: any, snapId: string, stackName
// Path-traversal guard: the resolved target MUST stay under the stacks root.
const resolvedTarget = resolve(targetPath);
const stacksRoot = resolve(getStacksDir());
if (resolvedTarget !== stacksRoot && !resolvedTarget.startsWith(stacksRoot + sep)) {
console.log(`[Backup] materialiseStackFiles: refusing target "${resolvedTarget}" outside the stacks root "${stacksRoot}"`);
const allowedRoots = [resolve(getDefaultStacksDir())];
if (isStacksDirEnvSet()) {
allowedRoots.push(resolve(getLocalStacksDir()));
}
const underAllowedRoot = allowedRoots.some(
(root) => resolvedTarget === root || resolvedTarget.startsWith(root + sep)
);
if (!underAllowedRoot) {
console.log(`[Backup] materialiseStackFiles: refusing target "${resolvedTarget}" outside allowed stacks roots (${allowedRoots.join(', ')})`);
return false;
}
+1
View File
@@ -44,6 +44,7 @@ export const environments = sqliteTable('environments', {
hawserAgentName: text('hawser_agent_name'),
hawserVersion: text('hawser_version'),
hawserCapabilities: text('hawser_capabilities'), // JSON array: ["compose", "exec", "metrics"]
hawserStacksDir: text('hawser_stacks_dir'), // Remote STACKS_DIR reported by agent
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`)
});
+1
View File
@@ -47,6 +47,7 @@ export const environments = pgTable('environments', {
hawserAgentName: text('hawser_agent_name'),
hawserVersion: text('hawser_version'),
hawserCapabilities: text('hawser_capabilities'), // JSON array: ["compose", "exec", "metrics"]
hawserStacksDir: text('hawser_stacks_dir'), // Remote STACKS_DIR reported by agent
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow()
});
+53 -8
View File
@@ -26,6 +26,7 @@ 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';
@@ -4092,23 +4093,47 @@ export async function dockerPing(envId: number): Promise<boolean> {
}
}
/**
* Get Hawser agent info (for hawser-standard mode)
* Returns agent info including uptime
*/
export async function getHawserInfo(envId: number): Promise<{
export interface HawserAgentInfo {
agentId: string;
agentName: string;
dockerVersion: string;
hawserVersion: string;
mode: string;
uptime: number;
} | null> {
/** 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<HawserAgentInfo | null> {
return getHawserInfoCached(envId);
}
const HAWSER_INFO_TTL_MS = 60_000;
const HAWSER_INFO_NEGATIVE_TTL_MS = 3_000;
const hawserInfoCache = new Map<number, { info: HawserAgentInfo | null; expiresAt: number }>();
/**
* 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<HawserAgentInfo | null> {
const cached = hawserInfoCache.get(envId);
if (cached && cached.expiresAt > Date.now()) {
return cached.info;
}
let info: HawserAgentInfo | null = null;
for (let attempt = 0; attempt < 2; attempt++) {
try {
const response = await dockerFetch('/_hawser/info', {}, envId);
if (response.ok) {
return await response.json();
info = await response.json();
break;
}
await drainResponse(response);
console.warn(`[Hawser] Info endpoint returned ${response.status} for env ${envId}`);
@@ -4117,7 +4142,27 @@ export async function getHawserInfo(envId: number): Promise<{
console.warn(`[Hawser] Failed to fetch info for env ${envId} (attempt ${attempt + 1}): ${msg}`);
}
}
return null;
// Persist the remote stacks dir so display-path resolution survives agent
// restarts and doesn't depend on container runtime state (labels) or a
// live info round-trip per request.
if (info?.stacksDir) {
try {
await db
.update(environments)
.set({ hawserStacksDir: info.stacksDir })
.where(eq(environments.id, envId));
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
console.warn(`[Hawser] Failed to persist stacksDir for env ${envId}: ${msg}`);
}
}
hawserInfoCache.set(envId, {
info,
expiresAt: Date.now() + (info ? HAWSER_INFO_TTL_MS : HAWSER_INFO_NEGATIVE_TTL_MS)
});
return info;
}
// Volume operations
+34 -21
View File
@@ -42,6 +42,8 @@ export interface EdgeConnection {
dockerVersion: string;
hostname: string;
capabilities: string[];
/** Remote agent STACKS_DIR (from hello message) */
stacksDir?: string;
connectedAt: Date;
lastHeartbeat: number;
pendingRequests: Map<string, PendingRequest>;
@@ -496,6 +498,7 @@ export function handleEdgeConnection(
dockerVersion: hello.dockerVersion,
hostname: hello.hostname,
capabilities: hello.capabilities,
stacksDir: hello.stacksDir,
connectedAt: new Date(),
lastHeartbeat: Date.now(),
pendingRequests: new Map(),
@@ -522,32 +525,40 @@ export function handleEdgeConnection(
}
/**
* Update environment status in database
* Update environment status in database. Best-effort: called from connection
* setup, teardown and heartbeat-timeout paths where a DB failure must not
* take down the connection handling.
*/
async function updateEnvironmentStatus(
environmentId: number,
connection: EdgeConnection | null
): Promise<void> {
if (connection) {
await db
.update(environments)
.set({
hawserLastSeen: new Date().toISOString(),
hawserAgentId: connection.agentId,
hawserAgentName: connection.agentName,
hawserVersion: connection.agentVersion,
hawserCapabilities: JSON.stringify(connection.capabilities),
updatedAt: new Date().toISOString()
})
.where(eq(environments.id, environmentId));
} else {
await db
.update(environments)
.set({
hawserLastSeen: new Date().toISOString(),
updatedAt: new Date().toISOString()
})
.where(eq(environments.id, environmentId));
try {
if (connection) {
await db
.update(environments)
.set({
hawserLastSeen: new Date().toISOString(),
hawserAgentId: connection.agentId,
hawserAgentName: connection.agentName,
hawserVersion: connection.agentVersion,
hawserCapabilities: JSON.stringify(connection.capabilities),
hawserStacksDir: connection.stacksDir ?? null,
updatedAt: new Date().toISOString()
})
.where(eq(environments.id, environmentId));
} else {
await db
.update(environments)
.set({
hawserLastSeen: new Date().toISOString(),
updatedAt: new Date().toISOString()
})
.where(eq(environments.id, environmentId));
}
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
console.warn(`[Hawser] Failed to update environment status for env ${environmentId}: ${msg}`);
}
}
@@ -930,6 +941,8 @@ export interface HelloMessage {
dockerVersion: string;
hostname: string;
capabilities: string[];
/** Remote agent STACKS_DIR — host path where compose stacks are written */
stacksDir?: string;
}
export interface WelcomeMessage {
+12
View File
@@ -0,0 +1,12 @@
import { resolve, sep as pathSep } from 'node:path';
/**
* True when `child` is `root` or a descendant. Uses `root + sep` so a sibling
* path that merely shares a string prefix (e.g. `/repos/myrepo2` vs `/repos/myrepo`)
* is rejected.
*/
export function isPathUnderRoot(childPath: string, rootPath: string): boolean {
const resolvedChild = resolve(childPath);
const resolvedRoot = resolve(rootPath);
return resolvedChild === resolvedRoot || resolvedChild.startsWith(resolvedRoot + pathSep);
}
+48
View File
@@ -0,0 +1,48 @@
import { readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
export function resolveStackDirForLayout(
defaultRoot: string,
localRoot: string,
stackName: string,
environmentName: string | undefined,
flatLocal: boolean
): string {
return join(flatLocal ? localRoot : defaultRoot, ...(!flatLocal && environmentName ? [environmentName] : []), stackName);
}
export function findStackNameCollision<T extends { stackName: string; environmentId: number | null }>(
sources: T[],
stackName: string,
environmentId?: number | null
): T | undefined {
return sources.find(
(source) => source.stackName === stackName && source.environmentId !== environmentId && source.environmentId != null
);
}
/** Move a file atomically when possible, with a copy+delete fallback across filesystems. */
export function moveStackFilePathCrossDevice(
sourcePath: string,
destPath: string,
label: string,
rename: typeof renameSync = renameSync
): void {
try {
rename(sourcePath, destPath);
console.log(`[Stack] Moved ${label}: ${sourcePath} -> ${destPath}`);
} catch (renameError: any) {
if (renameError.code !== 'EXDEV') {
console.warn(`[Stack] Failed to move ${label}: ${renameError.message}`);
return;
}
try {
writeFileSync(destPath, readFileSync(sourcePath));
unlinkSync(sourcePath);
console.log(`[Stack] Copied ${label} (cross-fs): ${sourcePath} -> ${destPath}`);
} catch (error: any) {
console.warn(`[Stack] Failed to copy ${label}: ${error.message}`);
}
}
}
+72
View File
@@ -0,0 +1,72 @@
import { join, relative, resolve } from 'node:path';
import { isPathUnderRoot } from './path-utils';
function remapPathBetweenStackDirs(fromDir: string, toDir: string, path: string): string {
const fromResolved = resolve(fromDir);
const toResolved = resolve(toDir);
const resolved = resolve(path);
if (isPathUnderRoot(resolved, fromResolved)) {
return join(toResolved, relative(fromResolved, resolved));
}
return path;
}
function remapPathsBetweenStackDirs(fromDir: string, toDir: string, paths: string[]): string[] {
return paths.map((p) => remapPathBetweenStackDirs(fromDir, toDir, p));
}
function remapComposeContentsBetweenStackDirs(
fromDir: string,
toDir: string,
contents: Record<string, string>
): Record<string, string> {
const remapped: Record<string, string> = {};
for (const [path, content] of Object.entries(contents)) {
remapped[remapPathBetweenStackDirs(fromDir, toDir, path)] = content;
}
return remapped;
}
/**
* Remap absolute paths from Dockhand's Hawser staging dir to the remote host stack dir.
*/
export function remapPathsFromStagingToRemote(
stagingStackDir: string,
remoteStackDir: string,
paths: string[]
): string[] {
return remapPathsBetweenStackDirs(stagingStackDir, remoteStackDir, paths);
}
/**
* Remap absolute paths from the Hawser remote stack dir back to Dockhand staging.
*/
export function remapPathsFromRemoteToStaging(
stagingStackDir: string,
remoteStackDir: string,
paths: string[]
): string[] {
return remapPathsBetweenStackDirs(remoteStackDir, stagingStackDir, paths);
}
/**
* Remap composeContents keys from staging paths to remote display paths.
*/
export function remapComposeContentsFromStagingToRemote(
stagingStackDir: string,
remoteStackDir: string,
contents: Record<string, string>
): Record<string, string> {
return remapComposeContentsBetweenStackDirs(stagingStackDir, remoteStackDir, contents);
}
/**
* Remap composeContents keys from remote display paths back to staging paths.
*/
export function remapComposeContentsFromRemoteToStaging(
stagingStackDir: string,
remoteStackDir: string,
contents: Record<string, string>
): Record<string, string> {
return remapComposeContentsBetweenStackDirs(remoteStackDir, stagingStackDir, contents);
}
+423 -72
View File
@@ -5,8 +5,8 @@
* All lifecycle operations use docker compose commands.
*/
import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync, unlinkSync, renameSync, readFileSync, writeFileSync, realpathSync } from 'node:fs';
import { join, resolve, dirname, basename, normalize as pathNormalize, sep as pathSep } from 'node:path';
import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync, unlinkSync, renameSync, readFileSync, writeFileSync, realpathSync, accessSync, constants as fsConstants } from 'node:fs';
import { join, resolve, dirname, basename, isAbsolute, normalize as pathNormalize, sep as pathSep } from 'node:path';
import { spawn as nodeSpawn } from 'node:child_process';
import type { ChildProcess } from 'node:child_process';
import {
@@ -18,7 +18,17 @@ import {
type DeletionApplyResult,
type DeletionSkipReason
} from './git-deletions';
import {
remapComposeContentsFromRemoteToStaging,
remapComposeContentsFromStagingToRemote,
remapPathsFromRemoteToStaging,
remapPathsFromStagingToRemote
} from './stacks-display-paths';
import { isPathUnderRoot } from './path-utils';
import { findStackNameCollision, moveStackFilePathCrossDevice, resolveStackDirForLayout } from './stack-path-utils';
import { db, environments, eq } from './db/drizzle.js';
import { isAllowedStackFilename } from './stack-filename';
import { deriveStackStatus } from './stack-status';
import {
getEnvironment,
@@ -27,11 +37,11 @@ import {
getStackEnvVars,
setStackEnvVars,
getStackSource,
getStackSources,
upsertStackSource,
deleteStackSource,
getGitStackByName,
deleteGitStack,
getStackSources,
deleteStackEnvVars,
removePendingContainerUpdate,
getPendingContainerUpdates,
@@ -168,7 +178,8 @@ export class ComposeFileNotFoundError extends Error {
// =============================================================================
// Cache stacks directory
let _stacksDir: string | null = null;
let _defaultStacksDir: string | null = null;
let _localStacksDir: string | null = null;
// Per-stack locking mechanism to prevent race conditions during concurrent operations
const stackLocks = new Map<string, Promise<void>>();
@@ -352,37 +363,101 @@ function redactEnvVarsForLog(vars: Record<string, string>): Record<string, strin
// UTILITIES
// =============================================================================
function getDataDir(): string {
return process.env.DATA_DIR || './data';
}
/** True when the Dockhand-managed flat local root env var is set (non-empty). */
export function isStacksDirEnvSet(): boolean {
return !!process.env.STACKS_DIR?.trim();
}
/**
* Get the compose stacks directory (always returns absolute path)
* Hawser staging root: always $DATA_DIR/stacks (env-scoped leaves).
* Creates the directory if missing.
*/
export function getDefaultStacksDir(): string {
if (_defaultStacksDir) return _defaultStacksDir;
_defaultStacksDir = resolve(join(getDataDir(), 'stacks'));
if (!existsSync(_defaultStacksDir)) {
mkdirSync(_defaultStacksDir, { recursive: true });
}
return _defaultStacksDir;
}
/**
* Local managed stacks root: STACKS_DIR when set, otherwise the default staging root.
* Does not mkdir when STACKS_DIR is set — startup validation requires an existing writable dir.
*/
export function getLocalStacksDir(): string {
if (!isStacksDirEnvSet()) {
return getDefaultStacksDir();
}
if (_localStacksDir) return _localStacksDir;
_localStacksDir = resolve(process.env.STACKS_DIR!);
return _localStacksDir;
}
/**
* @deprecated Prefer getDefaultStacksDir() for Hawser staging or getLocalStacksDir() for local managed paths.
*/
export function getStacksDir(): string {
if (_stacksDir) return _stacksDir;
const dataDir = process.env.DATA_DIR || './data';
// Resolve to absolute path to avoid issues with relative paths in docker compose
_stacksDir = resolve(join(dataDir, 'stacks'));
if (!existsSync(_stacksDir)) {
mkdirSync(_stacksDir, { recursive: true });
return getDefaultStacksDir();
}
export function isHawserConnection(env: { connectionType?: string | null } | null | undefined): boolean {
return env?.connectionType === 'hawser-standard' || env?.connectionType === 'hawser-edge';
}
export function isLocalConnection(env: { connectionType?: string | null } | null | undefined): boolean {
if (!env) return true;
const ct = env.connectionType;
return ct === 'socket' || ct === 'direct' || !ct;
}
/** Flat STACKS_DIR/<stackName>/ layout applies to socket/direct (and no-env) when STACKS_DIR is set. */
export async function usesFlatLocalStacksDir(envId?: number | null): Promise<boolean> {
if (!isStacksDirEnvSet()) return false;
if (envId === undefined || envId === null) return true;
const env = await getEnvironment(envId);
return isLocalConnection(env);
}
/** Base path shown to the UI for stack file placement for the given environment. */
export async function getStacksBasePathForEnv(envId?: number | null): Promise<string> {
if (await usesFlatLocalStacksDir(envId)) {
return getLocalStacksDir();
}
return _stacksDir;
return getDefaultStacksDir();
}
/** True when dirPath is under the Hawser staging root ($DATA_DIR/stacks). */
export function isManagedStagingDir(dirPath: string): boolean {
const resolved = resolve(dirPath);
const stagingRoot = resolve(getDefaultStacksDir());
return resolved === stagingRoot || resolved.startsWith(stagingRoot + pathSep);
}
/** True when dirPath is under either managed root (staging or flat local STACKS_DIR). */
export function isManagedStackDir(dirPath: string): boolean {
const resolved = resolve(dirPath);
if (isManagedStagingDir(resolved)) return true;
if (isStacksDirEnvSet()) {
const localRoot = resolve(getLocalStacksDir());
return resolved === localRoot || resolved.startsWith(localRoot + pathSep);
}
return false;
}
/**
* Get stack directory path for a specific environment.
* New stacks use: $DATA_DIR/stacks/<envName>/<stackName>/
* Legacy stacks (no env): $DATA_DIR/stacks/<stackName>/
*
* Automatically looks up environment name from database.
* When STACKS_DIR is set for local envs: STACKS_DIR/<stackName>/ (flat).
* Otherwise: $DATA_DIR/stacks/<envName>/<stackName>/ (or legacy flat).
*/
export async function getStackDir(stackName: string, envId?: number | null): Promise<string> {
const stacksDir = getStacksDir();
if (envId) {
const env = await getEnvironment(envId);
if (env) {
return join(stacksDir, env.name, stackName);
}
}
// Legacy path for stacks without environment
return join(stacksDir, stackName);
const flatLocal = await usesFlatLocalStacksDir(envId);
const env = !flatLocal && envId ? await getEnvironment(envId) : undefined;
return resolveStackDirForLayout(getDefaultStacksDir(), getLocalStacksDir(), stackName, env?.name, flatLocal);
}
/**
@@ -460,7 +535,36 @@ export async function findStackDir(stackName: string, envId?: number | null): Pr
}
}
const stacksDir = getStacksDir();
const flatLocal = await usesFlatLocalStacksDir(envId);
if (flatLocal) {
const flatPath = join(getLocalStacksDir(), stackName);
if (existsSync(flatPath)) {
return flatPath;
}
// Safety net: pre-migration paths under $DATA_DIR/stacks
const defaultStacksDir = getDefaultStacksDir();
if (envId) {
const env = await getEnvironment(envId);
if (env) {
const namePath = join(defaultStacksDir, env.name, stackName);
if (existsSync(namePath)) {
return namePath;
}
}
const idPath = join(defaultStacksDir, String(envId), stackName);
if (existsSync(idPath)) {
return idPath;
}
}
const legacyPath = join(defaultStacksDir, stackName);
if (existsSync(legacyPath)) {
return legacyPath;
}
return null;
}
const stacksDir = getDefaultStacksDir();
// Look up environment name if we have an ID
if (envId) {
@@ -539,6 +643,252 @@ export async function countStackEnvVars(stackName: string, envId?: number | null
}
}
/** Fall back to the default layout when STACKS_DIR cannot safely be used. */
export function validateStacksDirAtStartup(): void {
const raw = process.env.STACKS_DIR?.trim();
if (!raw) return;
const resolved = resolve(raw);
try {
if (!statSync(resolved).isDirectory()) throw new Error('not a directory');
accessSync(resolved, fsConstants.W_OK);
} catch {
console.warn(`[StacksDir] STACKS_DIR="${raw}" (resolved: ${resolved}) is missing, not a directory, or not writable; falling back to $DATA_DIR/stacks.`);
delete process.env.STACKS_DIR;
return;
}
console.log(`[StacksDir] Using STACKS_DIR=${resolved}`);
}
type HawserEnvLike = { connectionType?: string | null; hawserStacksDir?: string | null };
async function resolveHawserStackDirPair(
stackName: string,
environmentId: number,
env?: HawserEnvLike | null,
hints?: { workingDir: string | null; configFiles: string[] | null } | null
): Promise<{ stagingStackDir: string; remoteStackDir: string } | null> {
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/<env>/<stack>/). Deployed files live on the remote host under the
* Hawser agent's STACKS_DIR/<stackName>/.
*/
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<string, string> | null | undefined,
env?: HawserEnvLike | null,
hints?: { workingDir: string | null; configFiles: string[] | null } | null
): Promise<Record<string, string> | 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<string, string>;
envPath?: string;
moveFromDir?: string;
oldComposePath?: string;
oldEnvPath?: string;
}
): Promise<typeof options> {
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<string, { workingDir: string | null; configFiles: string[] | null }> {
const map = new Map<string, { workingDir: string | null; configFiles: string[] | null }>();
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
// =============================================================================
@@ -728,25 +1078,7 @@ export async function saveStackComposeFile(
}
}
// Move/rename the compose file to new location
try {
renameSync(options.oldComposePath, options.composePath);
console.log(`[Stack] Moved compose file: ${options.oldComposePath} -> ${options.composePath}`);
} catch (renameErr: any) {
// If rename fails (e.g., cross-filesystem), try copy+delete
if (renameErr.code === 'EXDEV') {
try {
const data = readFileSync(options.oldComposePath);
writeFileSync(options.composePath, data);
unlinkSync(options.oldComposePath);
console.log(`[Stack] Copied compose file (cross-fs): ${options.oldComposePath} -> ${options.composePath}`);
} catch (err: any) {
console.warn(`[Stack] Failed to copy compose file: ${err.message}`);
}
} else {
console.warn(`[Stack] Failed to move compose file: ${renameErr.message}`);
}
}
moveStackFilePathCrossDevice(options.oldComposePath, options.composePath, 'compose file');
}
// Handle env file move/rename when path changes
@@ -764,25 +1096,7 @@ export async function saveStackComposeFile(
}
}
// Move/rename the env file to new location
try {
renameSync(options.oldEnvPath, options.envPath);
console.log(`[Stack] Moved env file: ${options.oldEnvPath} -> ${options.envPath}`);
} catch (renameErr: any) {
// If rename fails (e.g., cross-filesystem), try copy+delete
if (renameErr.code === 'EXDEV') {
try {
const data = readFileSync(options.oldEnvPath);
writeFileSync(options.envPath, data);
unlinkSync(options.oldEnvPath);
console.log(`[Stack] Copied env file (cross-fs): ${options.oldEnvPath} -> ${options.envPath}`);
} catch (err: any) {
console.warn(`[Stack] Failed to copy env file: ${err.message}`);
}
} else {
console.warn(`[Stack] Failed to move env file: ${renameErr.message}`);
}
}
moveStackFilePathCrossDevice(options.oldEnvPath, options.envPath, 'env file');
}
// Move all files from old directory to new directory when path changes
@@ -884,6 +1198,12 @@ export async function saveStackComposeFile(
// For creates, use new path; for updates, find existing path first
let stackDir: string;
if (create) {
if (await usesFlatLocalStacksDir(envId)) {
const collisionError = await checkFlatLocalStackNameCollision(name, envId);
if (collisionError) {
return { success: false, error: collisionError };
}
}
stackDir = await getStackDir(name, envId);
} else {
const existingDir = await findStackDir(name, envId);
@@ -921,6 +1241,23 @@ export async function saveStackComposeFile(
}
}
async function checkFlatLocalStackNameCollision(stackName: string, envId?: number | null): Promise<string | null> {
const allSources = await getStackSources();
const conflict = findStackNameCollision(allSources, stackName, envId);
if (conflict) {
const conflictEnv = conflict.environmentId ? await getEnvironment(conflict.environmentId) : undefined;
return `Stack name "${stackName}" is already used by environment "${conflictEnv?.name ?? conflict.environmentId}". With STACKS_DIR set, local stack names must be unique across environments.`;
}
const flatDir = join(getLocalStacksDir(), stackName);
if (existsSync(flatDir)) {
const existing = await getStackSource(stackName, envId);
if (!existing) {
return `Stack directory "${flatDir}" already exists. With STACKS_DIR set, local stack names must be unique across environments.`;
}
}
return null;
}
// =============================================================================
// REGISTRY AUTHENTICATION
// =============================================================================
@@ -2515,7 +2852,6 @@ export async function computeStackDeletionPaths(
envId?: number | null
): Promise<{ stackDir: string | null; gitDir: string | null; sourceType: string | null; namedVolumes: string[] }> {
const stackSource = await getStackSource(stackName, envId);
const stacksDir = getStacksDir();
// Named volumes compose created for this stack — exactly what `down --volumes` would
// remove (compose-managed, labeled with the project). Best-effort: a docker/API hiccup
@@ -2534,7 +2870,8 @@ export async function computeStackDeletionPaths(
if (stackSource?.composePath) {
const customDir = dirname(stackSource.composePath);
// SAME strict guard as removeStack (#675): strict subdir + basename match.
if (isDeletableStackDir(customDir, stacksDir, stackName) && existsSync(customDir)) {
const deletableRoots = [getDefaultStacksDir(), ...(isStacksDirEnvSet() ? [getLocalStacksDir()] : [])];
if (deletableRoots.some((root) => isDeletableStackDir(customDir, root, stackName)) && existsSync(customDir)) {
stackDir = customDir;
}
}
@@ -2591,7 +2928,7 @@ export async function removeStack(
let removalFiles: FileToDelete[] | undefined;
if (composeResult.stackDir) {
const resolvedStaging = resolve(composeResult.stackDir);
if (resolvedStaging.startsWith(resolve(getStacksDir()) + '/')) {
if (isManagedStackDir(resolvedStaging)) {
removalFiles = Object.entries(hashDirFiles(resolvedStaging)).map(
([path, hash]) => ({ path, hash })
);
@@ -2700,16 +3037,14 @@ export async function removeStack(
// 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) {
// Only delete if the compose dir is a STRICT SUBDIR of DATA_DIR/stacks/ whose
// basename matches the stack name (#675 guard; see stack-delete-guard.ts).
const customDir = dirname(stackSource.composePath);
if (isDeletableStackDir(customDir, stacksDir, stackName) && existsSync(customDir)) {
const deletableRoots = [getDefaultStacksDir(), ...(isStacksDirEnvSet() ? [getLocalStacksDir()] : [])];
if (deletableRoots.some((root) => isDeletableStackDir(customDir, root, stackName)) && existsSync(customDir)) {
stackDir = customDir;
}
}
@@ -2900,7 +3235,14 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
actualComposePath = composePath;
console.log(`${logPrefix} Using custom compose path, workingDir:`, workingDir);
} else if (sourceDir && existsSync(sourceDir)) {
// Git stack: copy entire source directory to internal stack directory
// Git stack: copy entire source directory to internal stack directory.
const existingGitSource = await getStackSource(name, envId);
if (!existingGitSource && await usesFlatLocalStacksDir(envId)) {
const collisionError = await checkFlatLocalStackNameCollision(name, envId);
if (collisionError) {
return { success: false, output: '', error: collisionError };
}
}
workingDir = await getStackDir(name, envId);
// Set actualComposePath using the provided compose filename from git stack config
@@ -2970,6 +3312,15 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
console.log(`${logPrefix} Using custom path from DB:`, workingDir);
} else {
// Default: compose file should already exist (written by saveStackComposeFile)
if (await usesFlatLocalStacksDir(envId)) {
const existing = await getStackSource(name, envId);
if (!existing) {
const collisionError = await checkFlatLocalStackNameCollision(name, envId);
if (collisionError) {
return { success: false, output: '', error: collisionError };
}
}
}
workingDir = await getStackDir(name, envId);
// Point at the default .env in the stack dir so its content (e.g. a
// bulk secret selector) reaches resolveProviderEnvVars below.