start, stop or restart on Hawser updates its files on the remote host, not only on redeploy

This commit is contained in:
jarek committed 2026-08-13 10:20:29 +02:00
1 parent cc0d50913a
commit f1a3311c90
3 files changed
+207 -96

No files matched your search

+56 -16
View File
@@ -1,29 +1,28 @@
/**
* Pure decision logic for staging a stack's files onto a `direct` remote host (no server
* deps - safe to unit test). The IO (helper container + put-archive) lives in
* Pure decision logic for copying a stack's files onto a `direct` remote host FOR BACKUP (no
* server deps - safe to unit test). The IO (helper container + put-archive) lives in
* stage-remote-stackfiles.ts; this module only decides IF and WHERE.
*/
import { normalizeBaseDir, stackDirIn } from './stack-paths';
export interface RemoteStagingPlan {
stage: boolean;
/** Absolute project dir on the remote host (<remoteBase>/<stack>), when stage is true. */
projectDir?: string;
/** Absolute stack dir on the remote host (<remoteBase>/<stack>), when stage is true. */
hostDir?: string;
reason: string;
}
/**
* Decide whether a `direct` deploy should stage files onto the remote host, and where.
* Decide whether a `direct` deploy should copy the stack folder onto the remote host, and where.
* This is BACKUP-only: the backup helper bind-mounts the host dir to capture the compose/config.
* It does NOT steer deploy - compose runs locally with the user's own bind paths (absolute or
* matched 1:1), the same as a hawser agent whose STACKS_DIR is a backup-only declaration.
*
* Staging happens when ALL hold:
* - operation is 'up' (down/stop/start/restart don't re-stage),
* Copying happens when ALL hold:
* - operation is 'up' (down/stop/start/restart don't re-copy),
* - the env has a non-empty remote_stacks_dir configured,
* - there are stack files to stage.
* Setting remote_stacks_dir is the user's explicit "keep my stacks here on the host", so we
* stage the WHOLE stack dir (compose + .env + sibling config) - not only when the compose has
* relative binds. Relative binds still need it to resolve, but a bind-less stack needs its
* compose on the host just as much: to be manageable and BACKUPABLE (the helper bind-mounts the
* host dir; without the compose there, a backup can't capture it).
* Any miss => stage:false => the deploy takes the exact current code path (no --project-directory).
* - there are stack files to copy.
* Any miss => stage:false => nothing is copied (stack-file backup is skipped for the env).
*/
export function planRemoteStaging(input: {
operation: string;
@@ -34,7 +33,48 @@ export function planRemoteStaging(input: {
}): RemoteStagingPlan {
if (input.operation !== 'up') return { stage: false, reason: `operation ${input.operation} does not stage` };
if (!input.hasStackFiles) return { stage: false, reason: 'no stack files to stage' };
const base = typeof input.remoteStacksDir === 'string' ? input.remoteStacksDir.trim().replace(/\/+$/, '') : '';
const base = typeof input.remoteStacksDir === 'string' ? normalizeBaseDir(input.remoteStacksDir) : '';
if (!base) return { stage: false, reason: 'no remote_stacks_dir configured on the env' };
return { stage: true, projectDir: `${base}/${input.stackName}`, reason: 'direct env with remote_stacks_dir' };
// SAME formula the backup resolver reads from (stackDirIn) - deploy COPIES here, backup READS here.
return { stage: true, hostDir: stackDirIn(base, input.stackName), reason: 'direct env with remote_stacks_dir' };
}
export interface BindRewriteResult {
content: string;
modified: boolean;
/** Human-readable `./x -> <hostDir>/x` mappings, for logging. */
changes: string[];
}
/**
* Rewrite a compose file's SAME-DIR relative bind sources (`./x`) to absolute paths under the
* stack's host dir (`<hostDir>/x`), so a `direct` remote daemon binds the files the deploy
* copied there instead of a path only Dockhand can see.
*
* Only `./x` is rewritten - that's exactly the subtree the staging copy delivers to `hostDir`.
* A `../x` escapes the stack dir (never staged), so it is left untouched to fail loudly rather
* than point at a bogus host path. Named volumes, absolute binds, and non-bind lines pass through.
* The scheme is inferred from a compose line, so no YAML parser is needed (mirrors
* rewriteComposeVolumePaths); each match is anchored to a `- <src>:<dest>` volume entry.
*
* Pure and dependency-free (safe to unit test). `hostDir` must be an absolute POSIX path.
*/
export function rewriteBindsToHostDir(composeContent: string, hostDir: string): BindRewriteResult {
const base = hostDir.replace(/\/+$/, '');
const changes: string[] = [];
// - ./x:/dest where the WHOLE "src:dest" may be wrapped in matching quotes:
// - ./data:/data | - "./data:/data" | - './data:/data'
// `./` only - a `../` (or bare `.`) is intentionally NOT matched.
const RE = /^(\s*-\s*)(['"]?)\.\/([^'":\s]+)(:[^'"]+)(\2)\s*$/;
const out = composeContent.split('\n').map((line) => {
const m = line.match(RE);
if (!m) return line;
const [, prefix, quote, rel, dest] = m;
const abs = `${base}/${rel}`;
changes.push(` ./${rel} -> ${abs}`);
return `${prefix}${quote}${abs}${dest}${quote}`;
});
return { content: out.join('\n'), modified: changes.length > 0, changes };
}
+121 -53
View File
@@ -37,9 +37,11 @@ import {
deleteAutoUpdateSchedule,
getAutoUpdateSetting,
getStackSourceByComposePath,
getSecretProviderById
getSecretProviderById,
setStackInjectedSecretKeys
} from './db';
import { getProvider } from './secretproviders';
import { resolveComposeDockerHost, buildComposeBaseArgs } from './compose-docker-args';
import { unregisterSchedule } from './scheduler';
import { sendEventNotification } from './notifications';
import { deleteGitStackFiles, parseEnvFileContent } from './git';
@@ -309,6 +311,26 @@ async function readDirFilesAsMap(dirPath: string): Promise<Record<string, string
return files;
}
/**
* Stack-dir files for a LIFECYCLE op (start/stop/restart/down) on Hawser.
*
* Deploy ships the stack dir as stackFiles so the agent materializes the tree and runs
* `-f <dir>/compose.yaml`; the lifecycle ops didn't, so the agent fell back to `-f -`
* (stdin) and any include:/sibling file the compose references was ABSENT on the agent,
* breaking down/stop (#1240). Give them the same map. Ignored by local/socket/direct
* (executeLocalCompose has no stackFiles param); only the Hawser branch consumes it.
* Best-effort: a missing/unreadable dir returns undefined -> exact prior behavior.
*/
async function lifecycleStackFiles(stackDir?: string): Promise<Record<string, string> | undefined> {
if (!stackDir || !existsSync(stackDir)) return undefined;
try {
const files = await readDirFilesAsMap(stackDir);
return Object.keys(files).length > 0 ? files : undefined;
} catch {
return undefined;
}
}
// =============================================================================
// DEBUG UTILITIES
// =============================================================================
@@ -1001,7 +1023,10 @@ async function executeLocalCompose(
build?: boolean,
noBuildCache?: boolean,
pullPolicy?: string,
remoteProjectDir?: string
// direct-remote only: when the stack folder was staged to <remoteStackHostDir> on the target
// host, rewrite the compose's same-dir relative binds (`./x`) to <remoteStackHostDir>/x so the
// remote daemon binds the staged files. undefined = no staging, compose unchanged.
remoteStackHostDir?: string
): Promise<StackOperationResult> {
const logPrefix = `[Stack:${stackName}]`;
@@ -1060,6 +1085,19 @@ async function executeLocalCompose(
}
}
// direct-remote: the stack folder was copied to <remoteStackHostDir> on the target host, so
// rewrite same-dir relative binds (`./x`) to <remoteStackHostDir>/x. This resolves them on the
// remote daemon without --project-directory (which would break `include:`). include stays local.
if (remoteStackHostDir) {
const { rewriteBindsToHostDir } = await import('./remote-staging-plan');
const rw = rewriteBindsToHostDir(finalComposeContent, remoteStackHostDir);
if (rw.modified) {
finalComposeContent = rw.content;
console.log(`${logPrefix} direct env: rewrote ${rw.changes.length} relative bind(s) to the staged host dir:`);
for (const change of rw.changes) console.log(`${logPrefix}${change}`);
}
}
// Build spawn environment with ONLY essential system variables.
// CRITICAL: Do NOT spread process.env! Docker Compose shell env has higher
// priority than --env-file, so Dockhand's vars would override user's .env values.
@@ -1068,12 +1106,12 @@ async function executeLocalCompose(
HOME: process.env.HOME || '/root',
};
// Docker connection config
if (dockerHost) {
spawnEnv.DOCKER_HOST = dockerHost;
} else if (process.env.DOCKER_HOST) {
spawnEnv.DOCKER_HOST = process.env.DOCKER_HOST;
}
// Docker connection config. Pass the daemon via the `docker -H` CLI FLAG (built
// into `args` below), NOT via DOCKER_HOST in the shell env: shell env leaks into
// services that pass through or interpolate DOCKER_HOST (e.g. a socket-proxy
// sidecar), overriding the value the stack set for itself (#1393). `-H` connects
// compose to the right daemon without polluting the compose interpolation env.
const composeDockerHost = resolveComposeDockerHost(dockerHost, process.env.DOCKER_HOST);
// Honor explicit DOCKER_API_VERSION override from environment (user-controlled).
// Otherwise let compose negotiate natively — 5.0.2 handles old daemons correctly.
@@ -1081,6 +1119,16 @@ async function executeLocalCompose(
spawnEnv.DOCKER_API_VERSION = process.env.DOCKER_API_VERSION;
}
// DOCKER_CONFIG points the Docker CLI at its config dir (where `docker login` writes
// credentials). loginToRegistries() runs with the full process env and writes to
// $DOCKER_CONFIG/config.json; without passing it through here, compose would read from
// $HOME/.docker instead and every private-image pull falls back to anonymous -> 401
// (#1376). It's a CLI-config var, not a Compose interpolation var, so it can't collide
// with user .env values - safe to allow-list next to the other DOCKER_* vars.
if (process.env.DOCKER_CONFIG) {
spawnEnv.DOCKER_CONFIG = process.env.DOCKER_CONFIG;
}
// Check if .env file exists on disk (for legacy support decision)
const defaultEnvPath = join(composeFileDir, '.env');
const hasEnvFile = existsSync(defaultEnvPath) || (customEnvPath && existsSync(customEnvPath));
@@ -1153,16 +1201,9 @@ async function executeLocalCompose(
// Build command based on operation
// If we have modified compose content (host path translation), use stdin instead of file
const useStdin = finalComposeContent !== composeContent;
const args = ['docker', 'compose', '-p', stackName];
// For a `direct` remote daemon we can't share Dockhand's filesystem, so relative binds
// (./config, ./data) are staged onto the remote host under `remoteProjectDir` and the
// compose is pointed there with --project-directory: compose resolves `./x` against that
// remote path (which need not exist locally) instead of the client cwd, so the daemon
// binds the staged files. undefined = current behavior (no staging, no flag).
if (remoteProjectDir) {
args.push('--project-directory', remoteProjectDir);
}
// `-H` is a GLOBAL docker flag, so it goes before `compose`. This connects to the
// daemon without putting DOCKER_HOST in the shell env (#1393 - see above).
const args = buildComposeBaseArgs(stackName, composeDockerHost);
// Temp file for path-translated override content (cleaned up in finally block)
let tempOverridePath: string | undefined;
@@ -1191,12 +1232,6 @@ async function executeLocalCompose(
args.push('-f', overrideFile);
console.log(`${logPrefix} Including override file: ${basename(overrideFile)}`);
}
} else if (remoteProjectDir) {
// Internal stack + remote staging: --project-directory repoints compose's base to the
// remote path, which breaks the usual cwd auto-discovery of compose.yaml (that file is
// on Dockhand's host, not under the remote project dir). Point -f at the local file
// explicitly so compose reads it locally while resolving ./binds against the remote dir.
args.push('-f', composeFile);
}
// else: internal stack without path translation - no -f needed.
// Docker Compose auto-discovers compose.yaml + compose.override.yaml from cwd.
@@ -1711,31 +1746,31 @@ async function executeComposeCommand(
skipVerify: env.tlsSkipVerify ?? false
} : undefined;
// A direct daemon shares no filesystem with Dockhand, so relative binds
// (./config, ./data) would resolve to a path the remote daemon can't see and
// Docker would auto-create empty dirs. When the env has a `remote_stacks_dir`
// set AND the compose has relative binds, stage the stack files onto the remote
// host under <remoteDir>/<stack> and point compose there with --project-directory.
// The plan() decides IF/WHERE (pure, unit-tested); anything it declines takes the
// exact current path (remoteProjectDir stays undefined = no flag, no staging).
let remoteProjectDir: string | undefined;
// A `direct` env with a `remote_stacks_dir` set gets its WHOLE stack folder
// (compose + includes + .env + sibling config) copied onto the target host under
// <remoteDir>/<stack>, so the backup helper can bind-mount it there. This is
// When the env has a `remote_stacks_dir`, Dockhand copies the whole stack folder to
// <remoteDir>/<stack> on the target host so the backup helper can read it AND so the
// compose's same-dir relative binds (`./data`) can be rewritten to that absolute host
// path (done inside executeLocalCompose). This resolves relative binds on the remote
// daemon WITHOUT --project-directory (which would break `include:`). No remote_stacks_dir
// -> nothing staged, compose unchanged: relative binds resolve against the local cwd
// (1.0.37 behavior), absolute/named binds work.
let remoteStackHostDir: string | undefined;
{
const { getEnvSetting } = await import('./db');
const { planRemoteStaging } = await import('./remote-staging-plan');
const remoteStacksDir = await getEnvSetting('remote_stacks_dir', envId ?? undefined);
// Stage the WHOLE local stack dir (compose + relative-bind files like nginx.conf,
// ./data). The local stack dir is config-only - runtime data written by the remote
// container never comes back here - so this can't clobber live data. The tar is
// STREAMED from disk (O(1) RAM), so a large ./data doesn't buffer in memory.
// The tar is STREAMED from disk (O(1) RAM), so a large stack dir doesn't buffer.
const hasLocalDir = !!(operation === 'up' && workingDir && existsSync(workingDir));
const plan = planRemoteStaging({
operation, remoteStacksDir, stackName, composeContent, hasStackFiles: hasLocalDir,
});
if (plan.stage && plan.projectDir && workingDir) {
if (plan.stage && plan.hostDir && workingDir) {
const { stageStackDirOnRemote } = await import('./stage-remote-stackfiles');
const { staged } = await stageStackDirOnRemote(envId!, plan.projectDir, workingDir);
console.log(`[Stack:${stackName}] direct env: staged ${staged} file(s) to ${plan.projectDir} on the remote host (${plan.reason})`);
remoteProjectDir = plan.projectDir;
const { staged } = await stageStackDirOnRemote(envId!, plan.hostDir, workingDir);
console.log(`[Stack:${stackName}] direct env: staged ${staged} file(s) to ${plan.hostDir} on the remote host (${plan.reason})`);
remoteStackHostDir = plan.hostDir;
}
}
@@ -1758,7 +1793,7 @@ async function executeComposeCommand(
build,
noBuildCache,
pullPolicy,
remoteProjectDir
remoteStackHostDir
);
}
@@ -2247,7 +2282,7 @@ export async function startStack(
// via getStackComposeFile/getStackSource) to avoid a redundant DB lookup.
const isGitStack = result.sourceType === 'git';
const opts: ComposeCommandOptions = { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath, useOverrideFile: isGitStack };
const opts: ComposeCommandOptions = { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath, useOverrideFile: isGitStack, stackFiles: await lifecycleStackFiles(result.stackDir) };
// Check if containers exist for this stack. If they do, use 'start' to resume
// them (preserves container IDs, avoids Traefik race conditions from recreation).
@@ -2295,7 +2330,7 @@ export async function stopStack(
const composeResult = await executeComposeCommand(
'stop',
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath, useOverrideFile: isGitStack },
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath, useOverrideFile: isGitStack, stackFiles: await lifecycleStackFiles(result.stackDir) },
result.content!,
result.nonSecretVars,
result.secretVars
@@ -2336,7 +2371,7 @@ export async function restartStack(
// sourceType is plumbed through from requireComposeFile to avoid a redundant DB lookup.
const isGitStack = result.sourceType === 'git';
const opts: ComposeCommandOptions = { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath, useOverrideFile: isGitStack };
const opts: ComposeCommandOptions = { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath, useOverrideFile: isGitStack, stackFiles: await lifecycleStackFiles(result.stackDir) };
let composeResult: StackOperationResult;
@@ -2377,7 +2412,7 @@ export async function downStack(
const composeResult = await executeComposeCommand(
'down',
{ stackName, envId, removeVolumes, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath, useOverrideFile: isGitStack },
{ stackName, envId, removeVolumes, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath, useOverrideFile: isGitStack, stackFiles: await lifecycleStackFiles(result.stackDir) },
result.content!,
result.nonSecretVars,
result.secretVars
@@ -2521,11 +2556,12 @@ export async function removeStack(
if (deleteFiles && envId != null) {
try {
const { getEnvironment, getEnvSetting } = await import('./db');
const { normalizeBaseDir, stackDirIn } = await import('./stack-paths');
const env = await getEnvironment(envId);
if (env?.connectionType === 'direct') {
const remoteStacksDir = await getEnvSetting('remote_stacks_dir', envId);
const base = typeof remoteStacksDir === 'string' ? remoteStacksDir.trim().replace(/\/+$/, '') : '';
if (base) console.log(`[Stack:${stackName}] leaving staged files at ${base}/${stackName} on the remote host (not deleting - may hold user data)`);
const base = typeof remoteStacksDir === 'string' && remoteStacksDir.trim() ? normalizeBaseDir(remoteStacksDir) : '';
if (base) console.log(`[Stack:${stackName}] leaving staged files at ${stackDirIn(base, stackName)} on the remote host (not deleting - may hold user data)`);
}
} catch { /* log-only, never blocks removal */ }
}
@@ -2860,6 +2896,9 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
} else {
// Default: compose file should already exist (written by saveStackComposeFile)
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.
actualEnvPath = join(workingDir, '.env');
console.log(`${logPrefix} Using internal stack directory:`, workingDir);
}
@@ -2902,7 +2941,8 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
initialSecretVars,
logPrefix,
source?.secretProviderId,
envFileContent
envFileContent,
{ stackName: name, envId }
);
console.log(`${logPrefix} DB non-secret override vars:`, Object.keys(dbNonSecretVars).length);
console.log(`${logPrefix} DB secret vars:`, Object.keys(secretVars).length);
@@ -3232,6 +3272,10 @@ export async function saveStackEnvVars(
interface EnrichedEnvVars {
dbNonSecretVars: Record<string, string>;
secretVars: Record<string, string>;
// Names (no values) of secret keys pulled/resolved from the bound provider this
// deploy - bulk keys and inline refs promoted to secrets. Container inspect masks
// these; they are not in stack_env_vars, so getSecretKeysToMask can't see them.
injectedProviderKeys: string[];
}
// Variable names that trigger a bulk pull. OP_ENVIRONMENT_ID is retained for
@@ -3243,10 +3287,30 @@ async function resolveProviderEnvVars(
secretVars: Record<string, string>,
logPrefix: string,
secretProviderId?: number | null,
stackEnvFileContent?: string
stackEnvFileContent?: string,
persistTo?: { stackName: string; envId: number | null | undefined }
): Promise<EnrichedEnvVars> {
const envFileVars = stackEnvFileContent ? parseEnvFileContent(stackEnvFileContent) : {};
// Keys already present as DB secrets on entry; anything in secretVars beyond
// these at the end is provider-injected (bulk pull or promoted inline ref).
const dbSecretKeysOnEntry = new Set(Object.keys(secretVars));
const injectedProviderKeys = (): string[] =>
Object.keys(secretVars).filter((k) => !dbSecretKeysOnEntry.has(k));
// Persist the provider-injected key NAMES (no values) so container inspect can
// mask them. Done HERE, the single resolution choke point, so no caller can
// forget it (the primary deployStack path used to). Best-effort: a persist
// failure must never block a deploy.
const persistInjectedKeys = async () => {
if (!persistTo) return;
try {
await setStackInjectedSecretKeys(persistTo.stackName, persistTo.envId, injectedProviderKeys());
} catch (err) {
console.warn(`${logPrefix} Failed to persist injected secret key names:`, err);
}
};
const providerRow = secretProviderId ? await getSecretProviderById(secretProviderId) : undefined;
const provider = providerRow ? getProvider(providerRow.type) : undefined;
@@ -3310,12 +3374,14 @@ async function resolveProviderEnvVars(
for (const ref of envFileRefs.values()) refs.add(ref);
if (refs.size === 0) {
return { dbNonSecretVars, secretVars };
await persistInjectedKeys();
return { dbNonSecretVars, secretVars, injectedProviderKeys: injectedProviderKeys() };
}
if (!providerRow || !provider) {
console.warn(`${logPrefix} Found ${refs.size} reference(s) but no usable secret provider is bound to this stack; leaving them as literals`);
return { dbNonSecretVars, secretVars };
await persistInjectedKeys();
return { dbNonSecretVars, secretVars, injectedProviderKeys: injectedProviderKeys() };
}
let refMap: Map<string, string>;
@@ -3358,7 +3424,8 @@ async function resolveProviderEnvVars(
console.log(`${logPrefix} ${provider.label} resolved ${refMap.size}/${refs.size} reference(s) (promoted from DB: ${promotedFromDb}, from .env: ${promotedFromEnvFile})`);
return { dbNonSecretVars, secretVars };
await persistInjectedKeys();
return { dbNonSecretVars, secretVars, injectedProviderKeys: injectedProviderKeys() };
}
/**
@@ -3390,7 +3457,8 @@ async function applyProviderSecretsToComposeResult(
{ ...result.secretVars },
logPrefix,
source?.secretProviderId,
envFileContent
envFileContent,
{ stackName, envId: envId ?? undefined }
);
result.nonSecretVars = dbNonSecretVars;
result.secretVars = secretVars;
@@ -68,7 +68,6 @@
} from 'lucide-svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import * as Alert from '$lib/components/ui/alert';
import * as Popover from '$lib/components/ui/popover';
import IconPicker from '$lib/components/icon-picker.svelte';
import AvatarCropper from '$lib/components/AvatarCropper.svelte';
import { isCustomIcon } from '$lib/utils/icons';
@@ -297,9 +296,9 @@
let formDiskWarningThreshold = $state(80);
let formDiskWarningThresholdGb = $state(50);
let formConnectionType = $state<ConnectionType>('socket');
// Envs that keep stack files on a REMOTE host, so backup needs a declared stack path:
// direct (Dockhand stages there, drives deploy + backup) and hawser (the agent keeps files
// at its STACKS_DIR; the path is backup-only, does not steer deploy).
// Envs that keep stack files on a REMOTE host, so backup needs a declared stack path.
// For hawser it is backup-only (the agent owns its STACKS_DIR). For direct it also drives
// deploy: Dockhand copies the folder there and rewrites relative binds to that host path.
const usesStackPath = (ct: ConnectionType) => ct === 'direct' || ct === 'hawser-standard' || ct === 'hawser-edge';
const isHawserConn = (ct: ConnectionType) => ct === 'hawser-standard' || ct === 'hawser-edge';
let formHawserToken = $state('');
@@ -1782,13 +1781,11 @@
<div class="space-y-2">
<div class="flex items-center gap-1.5">
<Label for="edit-env-connection-type">Connection type</Label>
<Popover.Root>
<Popover.Trigger>
<button type="button" class="text-muted-foreground hover:text-foreground">
<HelpCircle class="w-3.5 h-3.5" />
</button>
</Popover.Trigger>
<Popover.Content class="w-80 text-sm z-[200]" side="right">
<Tooltip.Root>
<Tooltip.Trigger type="button" class="text-muted-foreground hover:text-foreground">
<HelpCircle class="w-3.5 h-3.5" />
</Tooltip.Trigger>
<Tooltip.Content class="w-80 text-sm z-[200]" side="right">
<div class="space-y-3">
<div class="flex items-start gap-2">
<Unplug class="w-4 h-4 mt-0.5 text-cyan-500 shrink-0" />
@@ -1823,8 +1820,8 @@
Learn more about Hawser
</a>
</div>
</Popover.Content>
</Popover.Root>
</Tooltip.Content>
</Tooltip.Root>
</div>
<Select.Root type="single" value={formConnectionType} onValueChange={(v) => {
formConnectionType = v as ConnectionType;
@@ -1947,12 +1944,12 @@
</div>
{/if}
<!-- Stack path for backup (direct = also drives deploy; hawser = backup-only) -->
<!-- Stack path: hawser = backup-only; direct = backup + relative-bind rewrite on deploy -->
{#if usesStackPath(formConnectionType)}
<div class="space-y-2">
<div class="flex items-center gap-1.5">
<Label for="edit-env-remote-stacks-dir">
{isHawserConn(formConnectionType) ? 'Remote stack path (for backup)' : 'Remote stacks directory'}
Remote stack path (for backup)
<span class="text-muted-foreground font-normal">(optional)</span>
</Label>
<Tooltip.Root>
@@ -1976,23 +1973,28 @@
(default <code class="bg-muted px-1 rounded">/data/stacks</code>). Leave empty for
the default; set it only if the agent runs with a custom one.
</p>
<p class="text-muted-foreground">
This must be a <span class="font-medium text-foreground">real path on the agent's host</span>
where the stack files actually live.
</p>
</div>
{:else}
<div class="space-y-2">
<p class="font-medium">How this path is used</p>
<p class="font-medium">Where this stack's files live on the host</p>
<p class="text-muted-foreground">
A direct daemon shares no filesystem with Dockhand. When set, Dockhand
materialises each stack's whole folder - compose, <code class="bg-muted px-1 rounded">.env</code>,
and relative-bind files (<code class="bg-muted px-1 rounded">./config</code>,
<code class="bg-muted px-1 rounded">./data</code>) - into
A direct daemon shares no filesystem with Dockhand. When set, Dockhand copies each
stack's folder to
<code class="bg-muted px-1 rounded">&lt;this path&gt;/&lt;stack&gt;</code>
<span class="font-medium text-foreground">on the remote host</span>, and runs compose there
via <code class="bg-muted px-1 rounded">--project-directory</code>.
<span class="font-medium text-foreground">on the remote host</span> so the backup
helper can read the compose and config, and rewrites relative binds
(<code class="bg-muted px-1 rounded">./data</code>) to that host path so they resolve
on the remote daemon.
</p>
<p class="text-muted-foreground">
This applies to normal stacks and git sync alike, so relative binds resolve
and every stack is backupable. Leave empty to use only named volumes or
absolute paths.
Leave empty to skip this: the stack won't be backupable and a relative bind resolves
to a path only Dockhand can see. Use
<span class="font-medium text-foreground">absolute paths</span> or
<span class="font-medium text-foreground">named volumes</span> instead.
</p>
</div>
{/if}
@@ -2009,8 +2011,9 @@
Absolute path on the agent's host where it keeps stack folders. Used only to back up
each stack's compose and config. Leave empty for the default <code class="bg-muted px-1 rounded">/data/stacks</code>.
{:else}
Absolute path on the remote host where Dockhand stages each stack's files before deploying.
Set it for stacks with relative binds, or to keep stacks backupable on a remote direct daemon.
Absolute path on the remote host where Dockhand keeps this stack's files, so its compose
and config are backupable and relative binds resolve on the remote daemon. Leave empty to
use only absolute paths or named volumes.
{/if}
</p>
</div>