optional stacks_dir flat layout for local stacks
This commit is contained in:
5 files changed
+92
-403
No files matched your search
+15
-54
@@ -26,7 +26,6 @@ import { resolveNanoCpusConflict, resolvePodmanUsernsMode } from './hostconfig-r
|
||||
import { parseImageReference } from './registry/image-ref';
|
||||
export { parseImageReference } from './registry/image-ref';
|
||||
import { rebaseEnvOntoImage, rebaseLabelsOntoImage, rebaseCommand, describeEnvRebase, describeLabelRebase, type ImageEnvLabels } from './container-env-merge';
|
||||
import { db, environments, eq } from './db/drizzle.js';
|
||||
import { encodeRegistryAuth, fetchRegistryToken, isSafeRegistryHost } from './registry-auth';
|
||||
import { classifyManifest, type ArtifactKind } from './semver/manifest-artifact';
|
||||
import { isSystemContainer, classifyEmptyDigestImage, localDigestIsIndexChild } from './scheduler/tasks/update-utils';
|
||||
@@ -4093,47 +4092,23 @@ export async function dockerPing(envId: number): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
export interface HawserAgentInfo {
|
||||
/**
|
||||
* Get Hawser agent info (for hawser-standard mode)
|
||||
* Returns agent info including uptime
|
||||
*/
|
||||
export async function getHawserInfo(envId: number): Promise<{
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
dockerVersion: string;
|
||||
hawserVersion: string;
|
||||
mode: string;
|
||||
uptime: number;
|
||||
/** Hawser agent STACKS_DIR — remote host path where compose stacks are written */
|
||||
stacksDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Hawser agent info (for hawser-standard mode)
|
||||
* Returns agent info including uptime
|
||||
*/
|
||||
export async function getHawserInfo(envId: number): Promise<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;
|
||||
} | null> {
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
const response = await dockerFetch('/_hawser/info', {}, envId);
|
||||
if (response.ok) {
|
||||
info = await response.json();
|
||||
break;
|
||||
return await response.json();
|
||||
}
|
||||
await drainResponse(response);
|
||||
console.warn(`[Hawser] Info endpoint returned ${response.status} for env ${envId}`);
|
||||
@@ -4142,27 +4117,7 @@ async function getHawserInfoCached(envId: number): Promise<HawserAgentInfo | nul
|
||||
console.warn(`[Hawser] Failed to fetch info for env ${envId} (attempt ${attempt + 1}): ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Volume operations
|
||||
@@ -5072,9 +5027,15 @@ export async function runContainerWithStreaming(options: {
|
||||
HostConfig: {
|
||||
Binds: options.binds || [],
|
||||
AutoRemove: false,
|
||||
// No log rotation: a helper's stdout IS its result, read via /logs after it
|
||||
// exits. An empty Config inherits the daemon's default rotation (Synology sets
|
||||
// max-size=10m,max-file=3), and a scanner writing a large JSON report line by
|
||||
// line blows past the window - the head rotates away and /logs returns a
|
||||
// truncated, mid-document buffer (#1496). One big file, no rotation; the log
|
||||
// dies with the ephemeral container so it never accumulates on disk.
|
||||
LogConfig: {
|
||||
Type: 'json-file',
|
||||
Config: {}
|
||||
Config: { 'max-file': '1', 'max-size': '1g' }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
+58
-295
@@ -9,6 +9,7 @@ import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync, unlinkSyn
|
||||
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 { redactSecretVars } from './secret-redact';
|
||||
import {
|
||||
applyFileDeletions,
|
||||
hashDirFiles,
|
||||
@@ -18,13 +19,7 @@ import {
|
||||
type DeletionApplyResult,
|
||||
type DeletionSkipReason
|
||||
} from './git-deletions';
|
||||
import {
|
||||
remapComposeContentsFromRemoteToStaging,
|
||||
remapComposeContentsFromStagingToRemote,
|
||||
remapPathsFromRemoteToStaging,
|
||||
remapPathsFromStagingToRemote
|
||||
} from './stacks-display-paths';
|
||||
import { isPathUnderRoot } from './path-utils';
|
||||
import { buildComposeOperationArgs, shouldRunSeparateBuildStep } from './compose-args';
|
||||
import { findStackNameCollision, moveStackFilePathCrossDevice, resolveStackDirForLayout } from './stack-path-utils';
|
||||
import { db, environments, eq } from './db/drizzle.js';
|
||||
import { isAllowedStackFilename } from './stack-filename';
|
||||
@@ -661,234 +656,6 @@ export function validateStacksDirAtStartup(): void {
|
||||
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
|
||||
// =============================================================================
|
||||
@@ -1393,7 +1160,7 @@ function findComposeOverrideFile(stackDir: string, composeFileName: string): str
|
||||
* @param customComposePath - Optional path to existing compose file (for imported stacks, skips writing)
|
||||
*/
|
||||
async function executeLocalCompose(
|
||||
operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull',
|
||||
operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull' | 'build',
|
||||
stackName: string,
|
||||
composeContent: string,
|
||||
dockerHost?: string,
|
||||
@@ -1652,39 +1419,7 @@ async function executeLocalCompose(
|
||||
console.log(`${logPrefix} [HostPath] Using stdin for compose content (paths translated)`);
|
||||
}
|
||||
|
||||
switch (operation) {
|
||||
case 'up':
|
||||
args.push('up', '-d', '--remove-orphans');
|
||||
if (forceRecreate) args.push('--force-recreate');
|
||||
if (build) args.push('--build');
|
||||
if (build && noBuildCache) args.push('--no-cache');
|
||||
if (pullPolicy) args.push('--pull', pullPolicy);
|
||||
// If targeting a specific service, only update that service
|
||||
if (serviceName) {
|
||||
args.push(serviceName);
|
||||
}
|
||||
break;
|
||||
case 'down':
|
||||
args.push('down', '--remove-orphans');
|
||||
if (removeVolumes) args.push('--volumes');
|
||||
break;
|
||||
case 'stop':
|
||||
args.push('stop');
|
||||
break;
|
||||
case 'start':
|
||||
args.push('start');
|
||||
break;
|
||||
case 'restart':
|
||||
args.push('restart');
|
||||
break;
|
||||
case 'pull':
|
||||
args.push('pull');
|
||||
// If targeting a specific service, pull only that service
|
||||
if (serviceName) {
|
||||
args.push(serviceName);
|
||||
}
|
||||
break;
|
||||
}
|
||||
args.push(...buildComposeOperationArgs(operation, { forceRecreate, removeVolumes, build, noBuildCache, pullPolicy, serviceName }));
|
||||
|
||||
const commandStr = args.join(' ');
|
||||
|
||||
@@ -1774,10 +1509,13 @@ async function executeLocalCompose(
|
||||
command: commandStr
|
||||
};
|
||||
} else {
|
||||
// stderr can echo an interpolated secret value (e.g. a failing
|
||||
// command containing ${DB_PASSWORD}); redact before it can reach a
|
||||
// notification channel, the DB errorMessage, or the client.
|
||||
return {
|
||||
success: false,
|
||||
output: stdout,
|
||||
error: stderr || `docker compose ${operation} exited with code ${code}`,
|
||||
output: redactSecretVars(stdout, secretVars),
|
||||
error: redactSecretVars(stderr, secretVars) || `docker compose ${operation} exited with code ${code}`,
|
||||
command: commandStr
|
||||
};
|
||||
}
|
||||
@@ -1789,7 +1527,7 @@ async function executeLocalCompose(
|
||||
return {
|
||||
success: false,
|
||||
output: '',
|
||||
error: `Failed to run docker compose ${operation}: ${err.message}`,
|
||||
error: redactSecretVars(`Failed to run docker compose ${operation}: ${err.message}`, secretVars),
|
||||
command: commandStr
|
||||
};
|
||||
} finally {
|
||||
@@ -1822,7 +1560,7 @@ async function executeLocalCompose(
|
||||
* @param secretVars - Secret environment variables (injected via shell env on Hawser, NEVER in .env)
|
||||
*/
|
||||
async function executeComposeViaHawser(
|
||||
operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull',
|
||||
operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull' | 'build',
|
||||
stackName: string,
|
||||
composeContent: string,
|
||||
envId: number,
|
||||
@@ -1993,10 +1731,12 @@ async function executeComposeViaHawser(
|
||||
deletion
|
||||
};
|
||||
} else {
|
||||
// The agent's stderr can echo an interpolated secret value; redact before
|
||||
// it reaches a notification channel, the DB errorMessage, or the client.
|
||||
return {
|
||||
success: false,
|
||||
output: result.output || '',
|
||||
error: result.error || `Compose ${operation} failed`,
|
||||
output: redactSecretVars(result.output || '', secretVars),
|
||||
error: redactSecretVars(result.error || `Compose ${operation} failed`, secretVars),
|
||||
deletion
|
||||
};
|
||||
}
|
||||
@@ -2008,7 +1748,7 @@ async function executeComposeViaHawser(
|
||||
output: '',
|
||||
error: isStringLength
|
||||
? `Stack files too large to send via Hawser. The repository may contain large binary files. Consider using a .dockerignore or moving large files out of the compose directory.`
|
||||
: `Failed to ${operation} via Hawser: ${err.message}`
|
||||
: redactSecretVars(`Failed to ${operation} via Hawser: ${err.message}`, secretVars)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2020,7 +1760,7 @@ async function executeComposeViaHawser(
|
||||
* @param secretVars - Secret environment variables (from DB, injected via shell env)
|
||||
*/
|
||||
async function executeComposeCommand(
|
||||
operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull',
|
||||
operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull' | 'build',
|
||||
options: ComposeCommandOptions,
|
||||
composeContent: string,
|
||||
envVars?: Record<string, string>,
|
||||
@@ -2901,6 +2641,12 @@ export async function removeStack(
|
||||
removeVolumes = false,
|
||||
deleteFiles = true
|
||||
): Promise<StackOperationResult> {
|
||||
// Reject a name that isn't a plain stack name BEFORE any path construction. A
|
||||
// traversal name (e.g. "..") would make getStackDir resolve to DATA_DIR's parent and
|
||||
// rmSync it; the create/deploy paths already enforce this same shape.
|
||||
if (!/^[a-z0-9][a-z0-9_-]*$/.test(stackName)) {
|
||||
return { success: false, error: 'Invalid stack name' };
|
||||
}
|
||||
return withStackLock(stackName, async () => {
|
||||
// Get compose file (may not exist for external stacks)
|
||||
const composeResult = await getStackComposeFile(stackName, envId);
|
||||
@@ -3053,7 +2799,10 @@ export async function removeStack(
|
||||
// (Don't delete default-path files when an adopted stack has custom path outside DATA_DIR)
|
||||
if (!stackDir && !stackSource?.composePath) {
|
||||
const defaultDir = await findStackDir(stackName, envId) || await getStackDir(stackName, envId);
|
||||
if (existsSync(defaultDir)) {
|
||||
// Same #675 guard as the composePath branch: only a strict subdir of a managed
|
||||
// stacks root whose basename is the stack name is deletable. Never DATA_DIR or a parent.
|
||||
const deletableRoots = [getDefaultStacksDir(), ...(isStacksDirEnvSet() ? [getLocalStacksDir()] : [])];
|
||||
if (deletableRoots.some((root) => isDeletableStackDir(defaultDir, root, stackName)) && existsSync(defaultDir)) {
|
||||
stackDir = defaultDir;
|
||||
}
|
||||
}
|
||||
@@ -3380,27 +3129,41 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
|
||||
// so no override file is needed - only pass secrets for shell injection.
|
||||
const isGitStack = !!sourceDir;
|
||||
|
||||
const cmdOptions: ComposeCommandOptions = {
|
||||
stackName: name,
|
||||
envId,
|
||||
forceRecreate,
|
||||
build,
|
||||
noBuildCache,
|
||||
pullPolicy,
|
||||
stackFiles,
|
||||
workingDir,
|
||||
composePath: actualComposePath,
|
||||
envPath: actualEnvPath,
|
||||
useOverrideFile: isGitStack,
|
||||
// Pass compose filename for Hawser (extracted from path or provided explicitly)
|
||||
composeFileName: composeFileName || (actualComposePath ? basename(actualComposePath) : undefined),
|
||||
filesToDelete
|
||||
};
|
||||
const composeEnvVars = isGitStack ? dbNonSecretVars : undefined;
|
||||
|
||||
// `--no-cache` is a `build` flag, not an `up` flag (#1479). When a no-cache
|
||||
// rebuild is requested, run a separate `docker compose build --no-cache` first,
|
||||
// then a plain `up`. Skipped on Hawser (its agent has no build op) - the up below
|
||||
// then omits --build for a no-cache request, so nothing crashes there.
|
||||
const deployEnv = envId ? await getEnvironment(envId) : null;
|
||||
if (shouldRunSeparateBuildStep(build, noBuildCache, deployEnv?.connectionType)) {
|
||||
console.log(`${logPrefix} Running separate 'build --no-cache' step before up...`);
|
||||
const buildResult = await executeComposeCommand('build', cmdOptions, compose, composeEnvVars, secretVars);
|
||||
if (!buildResult.success) return buildResult;
|
||||
}
|
||||
|
||||
console.log(`${logPrefix} Calling executeComposeCommand...`);
|
||||
const result = await executeComposeCommand(
|
||||
'up',
|
||||
{
|
||||
stackName: name,
|
||||
envId,
|
||||
forceRecreate,
|
||||
build,
|
||||
noBuildCache,
|
||||
pullPolicy,
|
||||
stackFiles,
|
||||
workingDir,
|
||||
composePath: actualComposePath,
|
||||
envPath: actualEnvPath,
|
||||
useOverrideFile: isGitStack,
|
||||
// Pass compose filename for Hawser (extracted from path or provided explicitly)
|
||||
composeFileName: composeFileName || (actualComposePath ? basename(actualComposePath) : undefined),
|
||||
filesToDelete
|
||||
},
|
||||
cmdOptions,
|
||||
compose,
|
||||
isGitStack ? dbNonSecretVars : undefined,
|
||||
composeEnvVars,
|
||||
secretVars
|
||||
);
|
||||
console.log(`${logPrefix} ========================================`);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { join } from 'path';
|
||||
import { getStackDir, isHawserConnection } from '$lib/server/stacks';
|
||||
import { getStackDir } from '$lib/server/stacks';
|
||||
import { getEnvironment } from '$lib/server/db';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
@@ -17,15 +17,6 @@ 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');
|
||||
@@ -38,7 +29,6 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
}
|
||||
|
||||
let stackDir: string;
|
||||
let source = 'default';
|
||||
|
||||
if (location) {
|
||||
// Custom location: {location}/{envName}/{stackName}/
|
||||
@@ -54,23 +44,13 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
}
|
||||
} else {
|
||||
// Dockhand default location
|
||||
if (envIdNum) {
|
||||
const env = await getEnvironment(envIdNum);
|
||||
if (env && isHawserConnection(env) && env.hawserStacksDir) {
|
||||
stackDir = join(env.hawserStacksDir, stackName);
|
||||
source = 'hawser';
|
||||
} else {
|
||||
stackDir = await getStackDir(stackName, envIdNum);
|
||||
}
|
||||
} else {
|
||||
stackDir = await getStackDir(stackName, envIdNum);
|
||||
}
|
||||
stackDir = await getStackDir(stackName, envIdNum);
|
||||
}
|
||||
|
||||
return json({
|
||||
stackDir,
|
||||
composePath: `${stackDir}/compose.yaml`,
|
||||
envPath: `${stackDir}/.env`,
|
||||
source
|
||||
source: 'default'
|
||||
});
|
||||
};
|
||||
@@ -1,9 +1,8 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getStackSources, getEnvironment } from '$lib/server/db';
|
||||
import { countStackEnvVars, resolveStackSourceDisplayPathsForEnv, buildStackPathHintsMap } from '$lib/server/stacks';
|
||||
import { getStackSources } from '$lib/server/db';
|
||||
import { countStackEnvVars } from '$lib/server/stacks';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { listContainers } from '$lib/server/docker';
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
@@ -26,19 +25,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
try {
|
||||
const sources = await getStackSources(envIdNum);
|
||||
|
||||
const envIds = [...new Set(sources.map((s) => s.environmentId).filter((id): id is number => id != null))];
|
||||
const envs = await Promise.all(envIds.map((id) => getEnvironment(id)));
|
||||
const envMap = new Map(envs.filter((e) => e !== undefined).map((e) => [e.id, e]));
|
||||
|
||||
const hintEnvIds = [...new Set(sources.map((s) => s.environmentId ?? null))];
|
||||
const hintMaps = await Promise.all(
|
||||
hintEnvIds.map(async (id) => {
|
||||
const containers = await listContainers(true, id).catch(() => []);
|
||||
return { id, map: buildStackPathHintsMap(containers) };
|
||||
})
|
||||
);
|
||||
const hintsByEnv = new Map(hintMaps.map((h) => [String(h.id ?? 'null'), h.map]));
|
||||
|
||||
// Convert to a map for easier lookup in the frontend
|
||||
const sourceMap: Record<
|
||||
string,
|
||||
{
|
||||
@@ -50,6 +37,11 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
envVarCount?: number;
|
||||
}
|
||||
> = {};
|
||||
// Count env vars server-side (one local read per stack) so the list badge does
|
||||
// not need a /env fetch per stack. GET /env resolves its env param as
|
||||
// `envId ? parseInt : null`, so pass the SAME null-when-absent value (not
|
||||
// undefined - getStackEnvVars scopes differently for null vs undefined) so the
|
||||
// count matches that endpoint's variable list exactly.
|
||||
const countEnvId = envIdNum ?? null;
|
||||
const counts = await Promise.all(
|
||||
sources.map((s) =>
|
||||
@@ -58,21 +50,16 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
: Promise.resolve(0)
|
||||
)
|
||||
);
|
||||
for (const [i, source] of sources.entries()) {
|
||||
const resolved = await resolveStackSourceDisplayPathsForEnv(
|
||||
source,
|
||||
source.environmentId != null ? envMap.get(source.environmentId) ?? null : null,
|
||||
hintsByEnv.get(String(source.environmentId ?? 'null'))?.get(source.stackName) ?? null
|
||||
);
|
||||
sources.forEach((source, i) => {
|
||||
sourceMap[source.stackName] = {
|
||||
sourceType: source.sourceType,
|
||||
composePath: resolved.composePath,
|
||||
composePath: source.composePath,
|
||||
repository: source.repository,
|
||||
secretProviderId: source.secretProviderId,
|
||||
icon: source.icon ?? null,
|
||||
envVarCount: counts[i],
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return json(sourceMap);
|
||||
} catch (error) {
|
||||
|
||||
@@ -706,20 +706,18 @@
|
||||
<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
|
||||
(managed local stacks under <code class="text-xs">STACKS_DIR</code>, when configured, are removed per stack name):
|
||||
and the following directories will be permanently removed from the Dockhand host:
|
||||
</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>
|
||||
<span class="text-muted-foreground shrink-0">(staging)</span>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
Shared git repository clones under <code class="text-xs">$DATA_DIR/git-repos/<repoName>/</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" />
|
||||
|
||||
Reference in new issue
Block a user