1.0.33
This commit is contained in:
85 files changed
+10118
-1046
No files matched your search
+127
-11
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user