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 <[email protected]>
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

+1
View File
@@ -0,0 +1 @@
ALTER TABLE "environments" ADD COLUMN "hawser_stacks_dir" text;
File diff suppressed because it is too large. Load diff
+8 -1
View File
@@ -106,6 +106,13 @@
"when": 1787468505425,
"tag": "0014_backup_tls_and_container_icons",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1787600000000,
"tag": "0015_hawser_stacks_dir",
"breakpoints": true
}
]
}
}
+1
View File
@@ -0,0 +1 @@
ALTER TABLE `environments` ADD `hawser_stacks_dir` text;
File diff suppressed because it is too large. Load diff
+8 -1
View File
@@ -106,6 +106,13 @@
"when": 1787468495122,
"tag": "0014_backup_tls_and_container_icons",
"breakpoints": true
},
{
"idx": 15,
"version": "6",
"when": 1787600000000,
"tag": "0015_hawser_stacks_dir",
"breakpoints": true
}
]
}
}
+2
View File
@@ -12,6 +12,7 @@ import { initCryptoFallback } from '$lib/server/crypto-fallback';
import { detectHostDataDir } from '$lib/server/host-path';
import { listContainers, removeContainer } from '$lib/server/docker';
import { migrateCredentials } from '$lib/server/encryption';
import { validateStacksDirAtStartup } from '$lib/server/stacks';
import { gzipSync } from 'node:zlib';
import { rmSync, readdirSync, existsSync } from 'fs';
import { join } from 'path';
@@ -144,6 +145,7 @@ if (!initialized) {
setServerStartTime(); // Track when server started
initDatabase();
validateStacksDirAtStartup();
// Migrate plain text credentials to encrypted storage.
// This also handles key rotation if ENCRYPTION_KEY env var differs from the
+11 -1
View File
@@ -26306,7 +26306,17 @@
"stacks"
],
"summary": "Return the default Dockhand stacks directory ($DATA_DIR/stacks/) where new stacks are stored by default",
"parameters": [],
"parameters": [
{
"name": "env",
"in": "query",
"required": false,
"schema": {
"type": "integer"
},
"description": "Environment ID used to select the local or environment-scoped stacks root"
}
],
"responses": {
"200": {
"description": "Successful response",
+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.
+27 -5
View File
@@ -1,11 +1,11 @@
import { json } from '@sveltejs/kit';
import { join } from 'path';
import { join, resolve, basename } from 'path';
import { existsSync, rmSync, renameSync } from 'fs';
import type { RequestHandler } from './$types';
import { getEnvironment, updateEnvironment, deleteEnvironment, getEnvironmentPublicIps, setEnvironmentPublicIp, deleteEnvironmentPublicIp, deleteEnvUpdateCheckSettings, deleteImagePruneSettings, getGitStacksForEnvironmentOnly, deleteGitStack, getBackupConfigs } from '$lib/server/db';
import { getEnvironment, updateEnvironment, deleteEnvironment, getEnvironmentPublicIps, setEnvironmentPublicIp, deleteEnvironmentPublicIp, deleteEnvUpdateCheckSettings, deleteImagePruneSettings, getGitStacksForEnvironmentOnly, deleteGitStack, getBackupConfigs, getStackSources } from '$lib/server/db';
import { clearDockerClientCache } from '$lib/server/docker';
import { deleteGitStackFiles, getGitReposDir } from '$lib/server/git';
import { getStacksDir } from '$lib/server/stacks';
import { getDefaultStacksDir, getLocalStacksDir, isLocalConnection, isStacksDirEnvSet } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import { auditEnvironment } from '$lib/server/audit';
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
@@ -116,7 +116,7 @@ export const PUT: RequestHandler = async (event) => {
// regardless of where they ultimately deploy — so the rename matters
// locally for every env type.
if (isRename) {
const stacksDir = getStacksDir();
const stacksDir = getDefaultStacksDir();
const gitReposDir = getGitReposDir();
const oldStacks = join(stacksDir, oldEnv.name);
const newStacks = join(stacksDir, data.name);
@@ -317,7 +317,7 @@ export const DELETE: RequestHandler = async (event) => {
// Clean up stack directory for this environment
// Safety: only delete subdirectory named after the env, never the parent
try {
const stacksDir = getStacksDir();
const stacksDir = getDefaultStacksDir();
const envStackDir = join(stacksDir, env.name);
if (envStackDir !== stacksDir && envStackDir.startsWith(stacksDir) && existsSync(envStackDir)) {
rmSync(envStackDir, { recursive: true, force: true });
@@ -326,6 +326,28 @@ export const DELETE: RequestHandler = async (event) => {
console.error(`Failed to clean up stack directory for environment "${env.name}":`, err);
}
// Flat STACKS_DIR layout: remove per-stack managed dirs for this local environment
if (isStacksDirEnvSet() && isLocalConnection(env)) {
try {
const localStacksDir = getLocalStacksDir();
const resolvedLocalRoot = resolve(localStacksDir);
const stackSources = await getStackSources(id);
for (const source of stackSources) {
const stackDir = join(localStacksDir, source.stackName);
const resolvedStackDir = resolve(stackDir);
if (
resolvedStackDir.startsWith(resolvedLocalRoot + '/') &&
basename(resolvedStackDir) === source.stackName &&
existsSync(stackDir)
) {
rmSync(stackDir, { recursive: true, force: true });
}
}
} catch (err) {
console.error(`Failed to clean up flat STACKS_DIR stacks for environment "${env.name}":`, err);
}
}
// Clean up git-repos directory for this environment
try {
const gitReposDir = getGitReposDir();
+43 -12
View File
@@ -1,9 +1,23 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getStackComposeFile, deployStack, saveStackComposeFile } from '$lib/server/stacks';
import { dirname } from 'node:path';
import { getStackComposeFile, deployStack, saveStackComposeFile, remapHawserStagingDisplayPaths, unmapHawserDisplayComposeOptionsToStaging } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import { createJobResponse } from '$lib/server/sse';
async function remapDisplayPath(
name: string,
envId: number | undefined,
path: string | null | undefined
): Promise<string | null | undefined> {
if (!path) return path;
const remapped = await remapHawserStagingDisplayPaths(name, envId, {
composePath: path,
composePaths: []
});
return remapped.composePath ?? path;
}
// GET /api/stacks/[name]/compose - Get compose file content
/**
* @openapi
@@ -32,17 +46,26 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
return json({
error: result.error,
needsFileLocation: result.needsFileLocation || false,
composePath: result.composePath,
envPath: result.envPath
composePath: await remapDisplayPath(name, envIdNum, result.composePath),
envPath: await remapDisplayPath(name, envIdNum, result.envPath)
}, { status: 404 });
}
const displayPaths = await remapHawserStagingDisplayPaths(name, envIdNum, {
composePath: result.composePath ?? null,
composePaths: []
});
let displayStackDir = result.stackDir;
if (displayPaths.composePath) {
displayStackDir = dirname(displayPaths.composePath);
}
return json({
content: result.content,
stackDir: result.stackDir,
composePath: result.composePath,
envPath: result.envPath,
suggestedEnvPath: result.suggestedEnvPath
stackDir: displayStackDir,
composePath: displayPaths.composePath,
envPath: await remapDisplayPath(name, envIdNum, result.envPath),
suggestedEnvPath: await remapDisplayPath(name, envIdNum, result.suggestedEnvPath)
});
} catch (error: any) {
console.error(`Error getting compose file for stack ${name}:`, error);
@@ -101,17 +124,25 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) =>
return json({ error: 'Permission denied: binding a secret provider requires the secrets permission' }, { status: 403 });
}
// Build options object for custom paths, move operation, file renames, and secret provider binding
const pathOptions = (composePath || envPath !== undefined || moveFromDir || oldComposePath || oldEnvPath || secretProviderId !== undefined)
? { composePath, envPath, moveFromDir, oldComposePath, oldEnvPath, secretProviderId }
: undefined;
// Convert Hawser display paths from the UI back to Dockhand staging paths
// before writing. secretProviderId is not a path and is merged after unmap.
let pathOptions: Parameters<typeof unmapHawserDisplayComposeOptionsToStaging>[2] | undefined =
(composePath || envPath !== undefined || moveFromDir || oldComposePath || oldEnvPath)
? { composePath, envPath, moveFromDir, oldComposePath, oldEnvPath }
: undefined;
if (pathOptions) {
pathOptions = await unmapHawserDisplayComposeOptionsToStaging(name, envIdNum, pathOptions);
}
// Persist the submitted content on EVERY accepted PUT, whether or not path fields came
// along. Gating this on pathOptions left Dockhand's stored copy stale while restart:true
// deployed the new content - a later GET then served the old copy, silently reverting the
// change on the next read/edit/deploy round-trip (#1383). saveStackComposeFile handles a
// possibly-undefined pathOptions fine (the non-restart branch already relied on that).
const saveResult = await saveStackComposeFile(name, content, false, envIdNum, pathOptions);
const saveResult = await saveStackComposeFile(name, content, false, envIdNum, {
...pathOptions,
...(secretProviderId !== undefined ? { secretProviderId } : {})
});
if (!saveResult.success) {
return json({ error: saveResult.error }, { status: 500 });
}
+13 -3
View File
@@ -1,5 +1,5 @@
import { json } from '@sveltejs/kit';
import { getStacksDir } from '$lib/server/stacks';
import { getStacksBasePathForEnv } from '$lib/server/stacks';
import type { RequestHandler } from './$types';
/**
@@ -7,10 +7,20 @@ import type { RequestHandler } from './$types';
*
* @openapi
* summary: Return the default Dockhand stacks directory ($DATA_DIR/stacks/) where new stacks are stored by default
* query: env:integer Environment ID used to select the local or environment-scoped stacks root
* resp-200: {basePath:string!}
* resp-200-example: {"basePath":"/data/stacks"}
*
* Returns the Dockhand stacks root for the requested environment context.
* Query params:
* - env: Environment ID (optional) — when set, returns STACKS_DIR for local envs
* with STACKS_DIR configured, otherwise $DATA_DIR/stacks (staging / legacy).
*/
export const GET: RequestHandler = async () => {
const basePath = getStacksDir();
export const GET: RequestHandler = async ({ url }) => {
const envParam = url.searchParams.get('env');
const envIdNum = envParam ? parseInt(envParam) : undefined;
const basePath = await getStacksBasePathForEnv(
envIdNum !== undefined && !Number.isNaN(envIdNum) ? envIdNum : undefined
);
return json({ basePath });
};
+23 -3
View File
@@ -1,6 +1,6 @@
import { json } from '@sveltejs/kit';
import { join } from 'path';
import { getStackDir } from '$lib/server/stacks';
import { getStackDir, isHawserConnection } from '$lib/server/stacks';
import { getEnvironment } from '$lib/server/db';
import type { RequestHandler } from './$types';
@@ -17,6 +17,15 @@ import type { RequestHandler } from './$types';
* resp-200: {stackDir:string!, composePath:string!, envPath:string!, source:string!}
* resp-200-example: {"stackDir":"/data/stacks/prod/web","composePath":"/data/stacks/prod/web/compose.yaml","envPath":"/data/stacks/prod/web/.env","source":"default"}
* resp-400: Stack name is required
*
* Query params:
* - name: Stack name (required)
* - env: Environment ID (optional)
* - location: Custom base location path (optional)
*
* If location is provided, path will be: {location}/{envName}/{stackName}/
* Otherwise uses Dockhand's default via getStackDir (flat STACKS_DIR/<stackName>/
* for local envs when STACKS_DIR is set, else $DATA_DIR/stacks/<envName>/<stackName>/).
*/
export const GET: RequestHandler = async ({ url }) => {
const stackName = url.searchParams.get('name');
@@ -29,6 +38,7 @@ export const GET: RequestHandler = async ({ url }) => {
}
let stackDir: string;
let source = 'default';
if (location) {
// Custom location: {location}/{envName}/{stackName}/
@@ -44,13 +54,23 @@ export const GET: RequestHandler = async ({ url }) => {
}
} else {
// Dockhand default location
stackDir = await getStackDir(stackName, envIdNum);
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);
}
}
return json({
stackDir,
composePath: `${stackDir}/compose.yaml`,
envPath: `${stackDir}/.env`,
source: 'default'
source
});
};
+24 -11
View File
@@ -1,8 +1,9 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getStackSources } from '$lib/server/db';
import { countStackEnvVars } from '$lib/server/stacks';
import { getStackSources, getEnvironment } from '$lib/server/db';
import { countStackEnvVars, resolveStackSourceDisplayPathsForEnv, buildStackPathHintsMap } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import { listContainers } from '$lib/server/docker';
/**
* @openapi
@@ -25,7 +26,19 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
try {
const sources = await getStackSources(envIdNum);
// Convert to a map for easier lookup in the frontend
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]));
const sourceMap: Record<
string,
{
@@ -37,11 +50,6 @@ 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) =>
@@ -50,16 +58,21 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
: Promise.resolve(0)
)
);
sources.forEach((source, i) => {
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
);
sourceMap[source.stackName] = {
sourceType: source.sourceType,
composePath: source.composePath,
composePath: resolved.composePath,
repository: source.repository,
secretProviderId: source.secretProviderId,
icon: source.icon ?? null,
envVarCount: counts[i],
};
});
}
return json(sourceMap);
} catch (error) {
@@ -961,9 +961,9 @@
// - socket/direct: the deploy staging dir AND the in-app editor source
// (containers' compose project labels point here, so redeploy is
// required after rename or next restart fails)
// - hawser-standard/edge: the in-app editor source for ALL stacks
// PLUS the git-repos clones for git stacks (the agent's deployed
// dir lives on the remote host and is not affected)
// - hawser-standard/edge: the in-app editor staging source for stacks
// (the agent's deployed dir lives on the remote host and is not affected)
// Git repository clones are shared per-repo under git-repos/<repoName>/.
// → fire the warning whenever the name changes.
const newName = formName.trim();
const willRenameOnDisk = newName !== environment.name;
@@ -3143,8 +3143,11 @@
</Dialog.Content>
</Dialog.Root>
<!-- Env-rename confirmation: only fires for socket/direct envs where the
server actually moves $DATA_DIR/stacks/<oldName> → <newName>. -->
<!-- Env-rename confirmation: fires when the name changes. The server renames
env-scoped staging under $DATA_DIR/stacks/<oldName>. When STACKS_DIR is set
for local envs, deployed stack files stay at STACKS_DIR/<stackName>/ and are
not moved by an env rename. Shared git clones under git-repos/<repoName>/
are unaffected. -->
<Dialog.Root bind:open={showRenameConfirm}>
<Dialog.Content class="max-w-2xl">
<Dialog.Header>
@@ -3153,17 +3156,13 @@
Rename environment?
</Dialog.Title>
<Dialog.Description class="pt-2 space-y-3 text-sm">
<p>The following directories will be moved on the Dockhand host:</p>
<p>The following staging directories will be moved on the Dockhand host (local deployed stacks under <code class="text-xs">STACKS_DIR</code>, when configured, are not renamed):</p>
<div class="space-y-1 text-xs font-mono bg-muted/40 rounded-md p-3 border overflow-x-auto">
<div class="flex items-center gap-2 whitespace-nowrap">
<code class="whitespace-nowrap">$DATA_DIR/stacks/{renameConfirmFrom}/</code>
<ArrowRight class="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
<code class="whitespace-nowrap">$DATA_DIR/stacks/{renameConfirmTo}/</code>
</div>
<div class="flex items-center gap-2 whitespace-nowrap">
<code class="whitespace-nowrap">$DATA_DIR/git-repos/{renameConfirmFrom}/</code>
<ArrowRight class="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
<code class="whitespace-nowrap">$DATA_DIR/git-repos/{renameConfirmTo}/</code>
<span class="text-muted-foreground shrink-0">(staging)</span>
</div>
</div>
{#if renameCountsUnknown}
@@ -3213,7 +3212,7 @@
<p>
Their containers run on the Hawser agent host, where the deploy
directory doesn't include the env name — those keep working without
a redeploy. Only the local editor source and git clone caches move.
a redeploy. Only the local editor staging directory is renamed.
</p>
{/if}
</Dialog.Description>
@@ -706,18 +706,20 @@
<p>
The environment
<code class="text-xs bg-muted px-1 py-0.5 rounded">{deleteEnvTarget.name}</code>
and the following directories will be permanently removed from the Dockhand host:
and the following directories will be permanently removed from the Dockhand host
(managed local stacks under <code class="text-xs">STACKS_DIR</code>, when configured, are removed per stack name):
</p>
<div class="space-y-1 text-xs font-mono bg-muted/40 rounded-md p-3 border overflow-x-auto">
<div class="flex items-center gap-2 whitespace-nowrap">
<Trash2 class="w-3.5 h-3.5 shrink-0 text-destructive" />
<code class="whitespace-nowrap">$DATA_DIR/stacks/{deleteEnvTarget.name}/</code>
</div>
<div class="flex items-center gap-2 whitespace-nowrap">
<Trash2 class="w-3.5 h-3.5 shrink-0 text-destructive" />
<code class="whitespace-nowrap">$DATA_DIR/git-repos/{deleteEnvTarget.name}/</code>
<span class="text-muted-foreground shrink-0">(staging)</span>
</div>
</div>
<p class="text-muted-foreground text-xs">
Shared git repository clones under <code class="text-xs">$DATA_DIR/git-repos/&lt;repoName&gt;/</code>
are not removed when an environment is deleted.
</p>
{#if deleteCountsLoading}
<p class="flex items-center gap-2">
<RefreshCw class="w-3.5 h-3.5 animate-spin" />
+825 -31
View File
File diff suppressed because it is too large. Load diff
+95
View File
@@ -0,0 +1,95 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { join } from 'node:path';
import {
remapComposeContentsFromRemoteToStaging,
remapComposeContentsFromStagingToRemote,
remapPathsFromRemoteToStaging,
remapPathsFromStagingToRemote
} from '../src/lib/server/stacks-display-paths';
describe('remapPathsFromStagingToRemote', () => {
it('remaps compose paths from Dockhand staging to Hawser STACKS_DIR', () => {
const staging = '/opt/docker/data/dockhand/stacks/nexz447/linkleaner';
const remote = '/opt/docker/stacks/linkleaner';
const paths = [
join(staging, 'compose.yaml'),
join(staging, 'compose.override.yaml')
];
const remapped = remapPathsFromStagingToRemote(staging, remote, paths);
assert.deepEqual(remapped, [
join(remote, 'compose.yaml'),
join(remote, 'compose.override.yaml')
]);
});
it('leaves paths outside the staging dir unchanged', () => {
const staging = '/data/stacks/prod/myapp';
const remote = '/opt/stacks/myapp';
const external = '/srv/custom/myapp/compose.yaml';
const remapped = remapPathsFromStagingToRemote(staging, remote, [external]);
assert.deepEqual(remapped, [external]);
});
});
describe('remapPathsFromRemoteToStaging', () => {
it('remaps compose paths from Hawser STACKS_DIR back to Dockhand staging', () => {
const staging = '/opt/docker/data/dockhand/stacks/nexz447/linkleaner';
const remote = '/opt/docker/stacks/linkleaner';
const paths = [
join(remote, 'compose.yaml'),
join(remote, 'compose.override.yaml')
];
const remapped = remapPathsFromRemoteToStaging(staging, remote, paths);
assert.deepEqual(remapped, [
join(staging, 'compose.yaml'),
join(staging, 'compose.override.yaml')
]);
});
});
describe('remapComposeContentsFromStagingToRemote', () => {
it('remaps composeContents keys to remote display paths', () => {
const staging = '/opt/docker/data/dockhand/stacks/prod/linkleaner';
const remote = '/opt/docker/stacks/linkleaner';
const contents = {
[join(staging, 'compose.yaml')]: 'services:\n app:\n image: app',
[join(staging, 'compose.override.yaml')]: 'services:\n app:\n ports:\n - 8080:80'
};
const remapped = remapComposeContentsFromStagingToRemote(staging, remote, contents);
assert.equal(remapped[join(remote, 'compose.yaml')], contents[join(staging, 'compose.yaml')]);
assert.equal(
remapped[join(remote, 'compose.override.yaml')],
contents[join(staging, 'compose.override.yaml')]
);
assert.equal(Object.keys(remapped).length, 2);
});
});
describe('remapComposeContentsFromRemoteToStaging', () => {
it('remaps composeContents keys back to staging paths', () => {
const staging = '/opt/docker/data/dockhand/stacks/prod/linkleaner';
const remote = '/opt/docker/stacks/linkleaner';
const contents = {
[join(remote, 'compose.yaml')]: 'services:\n app:\n image: app',
[join(remote, 'compose.override.yaml')]: 'services:\n app:\n ports:\n - 8080:80'
};
const remapped = remapComposeContentsFromRemoteToStaging(staging, remote, contents);
assert.equal(remapped[join(staging, 'compose.yaml')], contents[join(remote, 'compose.yaml')]);
assert.equal(
remapped[join(staging, 'compose.override.yaml')],
contents[join(remote, 'compose.override.yaml')]
);
assert.equal(Object.keys(remapped).length, 2);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { afterEach, describe, expect, it } from 'bun:test';
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
findStackNameCollision,
moveStackFilePathCrossDevice,
resolveStackDirForLayout
} from '../src/lib/server/stack-path-utils';
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
describe('moveStackFilePathCrossDevice', () => {
it('copies and deletes the source when rename fails with EXDEV', () => {
const dir = mkdtempSync(join(tmpdir(), 'dockhand-stack-move-'));
tempDirs.push(dir);
const source = join(dir, 'old.env');
const destination = join(dir, 'new.env');
writeFileSync(source, 'TOKEN=secret\n');
moveStackFilePathCrossDevice(source, destination, 'env file', () => {
throw Object.assign(new Error('cross-device link'), { code: 'EXDEV' });
});
expect(readFileSync(destination, 'utf8')).toBe('TOKEN=secret\n');
expect(existsSync(source)).toBe(false);
});
});
describe('resolveStackDirForLayout', () => {
it('uses flat STACKS_DIR for local stacks and environment scope otherwise', () => {
expect(resolveStackDirForLayout('/data/stacks', '/srv/stacks', 'app', 'local', true)).toBe('/srv/stacks/app');
expect(resolveStackDirForLayout('/data/stacks', '/srv/stacks', 'app', 'production', false)).toBe('/data/stacks/production/app');
});
});
describe('findStackNameCollision', () => {
it('finds the same stack name in another local environment', () => {
const sources = [
{ stackName: 'app', environmentId: 1 },
{ stackName: 'worker', environmentId: 2 }
];
expect(findStackNameCollision(sources, 'app', 2)).toEqual(sources[0]);
expect(findStackNameCollision(sources, 'app', 1)).toBeUndefined();
});
});