This commit is contained in:
jarek committed 2026-06-15 14:56:51 +02:00
1 parent d465ecfe96
commit 83c3a5ea09
85 files changed
+10118 -1046

No files matched your search

+127 -11
View File
@@ -9,6 +9,15 @@ import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync, unlinkSyn
import { join, resolve, dirname, basename } from 'node:path';
import { spawn as nodeSpawn } from 'node:child_process';
import type { ChildProcess } from 'node:child_process';
import {
applyFileDeletions,
hashDirFiles,
skipReasonMessage,
normalizeSkipReason,
type FileToDelete,
type DeletionApplyResult,
type DeletionSkipReason
} from './git-deletions';
import {
getEnvironment,
getSecretEnvVarsAsRecord,
@@ -61,6 +70,8 @@ export interface StackOperationResult {
error?: string;
/** The docker compose command that was executed (for debugging/testing) */
command?: string;
/** Result of applying git deletion sync (files removed / kept, with reasons) */
deletion?: DeletionApplyResult;
}
/**
@@ -111,6 +122,8 @@ export interface DeployStackOptions {
envPath?: string; // Custom env file path (for adopted/imported stacks)
composeFileName?: string; // Compose filename to use (e.g., "docker-compose.yaml") for git stacks
envFileName?: string; // Env filename relative to compose dir (e.g., ".env") for git stacks
/** Git deletion sync (#966): files confirmed safe to delete from the stack dir */
filesToDelete?: FileToDelete[];
}
// =============================================================================
@@ -830,6 +843,10 @@ interface ComposeCommandOptions {
serviceName?: string;
/** Compose filename for Hawser (e.g., "docker-compose.prod.yml") - extracted from composePath */
composeFileName?: string;
/** Git deletion sync (#966): files to delete on the Hawser agent's stack dir */
filesToDelete?: FileToDelete[];
/** On down: ask the Hawser agent to remove the stack directory entirely (#1162, stack deletion only) */
removeFiles?: boolean;
}
/**
@@ -1285,7 +1302,9 @@ async function executeComposeViaHawser(
composeFileName?: string,
build?: boolean,
noBuildCache?: boolean,
pullPolicy?: string
pullPolicy?: string,
filesToDelete?: FileToDelete[],
removeFiles?: boolean
): Promise<StackOperationResult> {
const logPrefix = `[Stack:${stackName}]`;
// Import dockerFetch dynamically to avoid circular dependency
@@ -1362,7 +1381,14 @@ async function executeComposeViaHawser(
noBuildCache: (build && noBuildCache) || false,
pullPolicy: pullPolicy || '',
registries, // Registry credentials for docker login
serviceName // Target specific service only (with --no-deps)
serviceName, // Target specific service only (with --no-deps)
// Git deletion sync (#966): agent re-verifies containment + content
// hash per file before deleting. Old agents ignore this field.
filesToDelete: filesToDelete && filesToDelete.length > 0
? filesToDelete.map(f => ({ path: f.path, sha256: f.hash }))
: undefined,
// Stack deletion (#1162): remove the agent-side stack dir on down
removeFiles: removeFiles || false
});
console.log(`${logPrefix} Sending request to Hawser agent...`);
@@ -1380,6 +1406,8 @@ async function executeComposeViaHawser(
success: boolean;
output?: string;
error?: string;
deletedFiles?: string[];
skippedFiles?: { path: string; reason: string }[];
};
console.log(`${logPrefix} ----------------------------------------`);
@@ -1393,16 +1421,50 @@ async function executeComposeViaHawser(
console.log(`${logPrefix} Error:`, result.error);
}
// Git deletion sync: interpret the agent's report. An agent that supports
// the feature always returns deletedFiles/skippedFiles (possibly empty
// arrays) when filesToDelete was sent. An old agent ignores the field and
// returns neither — every requested deletion is marked agent-no-support.
// Skips are FINAL (no carry-forward, no retry): the files stay on the
// remote host as unmanaged residue, identical to pre-feature behavior.
let deletion: DeletionApplyResult | undefined;
if (filesToDelete && filesToDelete.length > 0) {
if (result.deletedFiles !== undefined || result.skippedFiles !== undefined) {
deletion = {
deleted: result.deletedFiles ?? [],
skipped: (result.skippedFiles ?? []).map(s => ({
path: s.path,
reason: normalizeSkipReason(s.reason || 'apply-failed')
}))
};
for (const path of deletion.deleted) {
console.log(`${logPrefix} Agent removed "${path}" — deleted from the repository`);
}
for (const skip of deletion.skipped) {
if (skip.reason === 'already-absent') continue;
console.warn(`${logPrefix} Agent kept "${skip.path}" — ${skipReasonMessage(skip.reason)}`);
}
} else {
deletion = {
deleted: [],
skipped: filesToDelete.map(f => ({ path: f.path, reason: 'agent-no-support' as DeletionSkipReason }))
};
console.warn(`${logPrefix} ${skipReasonMessage('agent-no-support')} (${filesToDelete.length} file(s) affected)`);
}
}
if (result.success) {
return {
success: true,
output: result.output || `Stack "${stackName}" ${operation} completed via Hawser`
output: result.output || `Stack "${stackName}" ${operation} completed via Hawser`,
deletion
};
} else {
return {
success: false,
output: result.output || '',
error: result.error || `Compose ${operation} failed`
error: result.error || `Compose ${operation} failed`,
deletion
};
}
} catch (err: any) {
@@ -1431,7 +1493,7 @@ async function executeComposeCommand(
envVars?: Record<string, string>,
secretVars?: Record<string, string>
): Promise<StackOperationResult> {
const { stackName, envId, forceRecreate, build, noBuildCache, pullPolicy, removeVolumes, stackFiles, workingDir, composePath, envPath, useOverrideFile, serviceName, composeFileName } = options;
const { stackName, envId, forceRecreate, build, noBuildCache, pullPolicy, removeVolumes, stackFiles, workingDir, composePath, envPath, useOverrideFile, serviceName, composeFileName, filesToDelete, removeFiles } = options;
// Get environment configuration
const env = envId ? await getEnvironment(envId) : null;
@@ -1521,7 +1583,9 @@ async function executeComposeCommand(
composeFileName,
build,
noBuildCache,
pullPolicy
pullPolicy,
filesToDelete,
removeFiles
);
}
@@ -1560,12 +1624,20 @@ async function executeComposeCommand(
}
case 'socket':
default:
default: {
// Honor the environment's configured socket path. Without this,
// docker compose falls back to /var/run/docker.sock regardless of
// the env's setting — wrong daemon for rootless/multi-socket hosts
// (#1172). Default '/var/run/docker.sock' is left as undefined so
// the CLI's own default applies (preserves existing behavior).
const sock = env.socketPath && env.socketPath !== '/var/run/docker.sock'
? `unix://${env.socketPath}`
: undefined;
return executeLocalCompose(
operation,
stackName,
composeContent,
undefined, // dockerHost
sock,
undefined, // tlsConfig
envVars,
secretVars,
@@ -1581,6 +1653,7 @@ async function executeComposeCommand(
noBuildCache,
pullPolicy
);
}
}
}
@@ -2088,6 +2161,24 @@ export async function removeStack(
if (composeResult.success) {
const envVars = await getNonSecretEnvVarsAsRecord(stackName, envId);
const secretVars = await getSecretEnvVarsAsRecord(stackName, envId);
// Stack removal cleanup (#1162): the agent deletes ONLY what Dockhand
// explicitly lists. The list is the local staging dir contents — exactly
// the files Dockhand ever wrote for this stack (compose, .env,
// .env.dockhand, git files), never user volume data (that exists only on
// the agent host). Each entry is hash-verified agent-side; the agent's
// stack dir is removed only if nothing else remains in it.
// Only built for Dockhand-managed staging dirs (inside DATA_DIR/stacks).
let removalFiles: FileToDelete[] | undefined;
if (composeResult.stackDir) {
const resolvedStaging = resolve(composeResult.stackDir);
if (resolvedStaging.startsWith(resolve(getStacksDir()) + '/')) {
removalFiles = Object.entries(hashDirFiles(resolvedStaging)).map(
([path, hash]) => ({ path, hash })
);
}
}
const downResult = await executeComposeCommand(
'down',
{
@@ -2096,7 +2187,10 @@ export async function removeStack(
removeVolumes,
workingDir: composeResult.stackDir,
composePath: composeResult.composePath ?? undefined,
envPath: composeResult.envPath ?? undefined
envPath: composeResult.envPath ?? undefined,
// Full stack removal: the Hawser agent cleans its stack dir (#1162)
removeFiles: true,
filesToDelete: removalFiles
},
composeResult.content!,
envVars,
@@ -2267,7 +2361,7 @@ export async function removeStack(
* Uses stack locking to prevent concurrent deployments.
*/
export async function deployStack(options: DeployStackOptions): Promise<StackOperationResult> {
const { name, compose, envId, sourceDir, forceRecreate, build, noBuildCache, pullPolicy, composePath, envPath, composeFileName, envFileName } = options;
const { name, compose, envId, sourceDir, forceRecreate, build, noBuildCache, pullPolicy, composePath, envPath, composeFileName, envFileName, filesToDelete } = options;
const logPrefix = `[Stack:${name}]`;
console.log(`${logPrefix} ========================================`);
@@ -2299,6 +2393,7 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
let actualComposePath: string | undefined;
let actualEnvPath: string | undefined = envPath; // Start with provided envPath (for adopted stacks)
let stackFiles: Record<string, string> | undefined;
let localDeletionResult: DeletionApplyResult | undefined;
if (composePath) {
// Adopted/imported stack: use the original compose file location
@@ -2351,6 +2446,21 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
filter: (src) => !src.includes('/.git/') && !src.endsWith('/.git')
});
console.log(`${logPrefix} Copied ${sourceDir} -> ${workingDir}`);
// Git deletion sync (#966): remove files that were deleted from the
// repository. The list is manifest entries absent from the new clone;
// the applier re-verifies containment + content hash per file, so
// volume data and locally modified files are never touched.
if (filesToDelete && filesToDelete.length > 0) {
localDeletionResult = applyFileDeletions(workingDir, filesToDelete);
for (const path of localDeletionResult.deleted) {
console.log(`${logPrefix} Removed "${path}" — deleted from the repository`);
}
for (const skip of localDeletionResult.skipped) {
if (skip.reason === 'already-absent') continue;
console.warn(`${logPrefix} Kept "${skip.path}" — ${skipReasonMessage(skip.reason)}`);
}
}
} else {
// Internal stack: check if a custom path exists in DB (adopted/imported stacks)
const source = await getStackSource(name, envId);
@@ -2422,7 +2532,8 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
envPath: actualEnvPath,
useOverrideFile: isGitStack,
// Pass compose filename for Hawser (extracted from path or provided explicitly)
composeFileName: composeFileName || (actualComposePath ? basename(actualComposePath) : undefined)
composeFileName: composeFileName || (actualComposePath ? basename(actualComposePath) : undefined),
filesToDelete
},
compose,
isGitStack ? dbNonSecretVars : undefined,
@@ -2438,6 +2549,11 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
if (result.error) {
console.log(`${logPrefix} Error:`, result.error);
}
// Deletion result: the remote (Hawser) result is authoritative when present;
// for local deployments the local applier's result is the truth.
if (!result.deletion && localDeletionResult) {
result.deletion = localDeletionResult;
}
return result;
});
}