diff --git a/src/lib/server/remote-staging-plan.ts b/src/lib/server/remote-staging-plan.ts index 06b8d62..7ffd415 100644 --- a/src/lib/server/remote-staging-plan.ts +++ b/src/lib/server/remote-staging-plan.ts @@ -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 (/), when stage is true. */ - projectDir?: string; + /** Absolute stack dir on the remote host (/), 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 -> /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 (`/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 `- :` 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 }; } diff --git a/src/lib/server/stacks.ts b/src/lib/server/stacks.ts index 040ee49..f3adfe8 100644 --- a/src/lib/server/stacks.ts +++ b/src/lib/server/stacks.ts @@ -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/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 | 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 on the target + // host, rewrite the compose's same-dir relative binds (`./x`) to /x so the + // remote daemon binds the staged files. undefined = no staging, compose unchanged. + remoteStackHostDir?: string ): Promise { const logPrefix = `[Stack:${stackName}]`; @@ -1060,6 +1085,19 @@ async function executeLocalCompose( } } + // direct-remote: the stack folder was copied to on the target host, so + // rewrite same-dir relative binds (`./x`) to /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 / 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 + // /, 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 + // / 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; secretVars: Record; + // 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, logPrefix: string, secretProviderId?: number | null, - stackEnvFileContent?: string + stackEnvFileContent?: string, + persistTo?: { stackName: string; envId: number | null | undefined } ): Promise { 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; @@ -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; diff --git a/src/routes/settings/environments/EnvironmentModal.svelte b/src/routes/settings/environments/EnvironmentModal.svelte index 2103522..931c59c 100644 --- a/src/routes/settings/environments/EnvironmentModal.svelte +++ b/src/routes/settings/environments/EnvironmentModal.svelte @@ -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('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 @@
- - - - - + + + + +
@@ -1823,8 +1820,8 @@ Learn more about Hawser
- - + +
{ formConnectionType = v as ConnectionType; @@ -1947,12 +1944,12 @@
{/if} - + {#if usesStackPath(formConnectionType)}
@@ -1976,23 +1973,28 @@ (default /data/stacks). Leave empty for the default; set it only if the agent runs with a custom one.

+

+ This must be a real path on the agent's host + where the stack files actually live. +

{:else}
-

How this path is used

+

Where this stack's files live on the host

- A direct daemon shares no filesystem with Dockhand. When set, Dockhand - materialises each stack's whole folder - compose, .env, - and relative-bind files (./config, - ./data) - into + A direct daemon shares no filesystem with Dockhand. When set, Dockhand copies each + stack's folder to <this path>/<stack> - on the remote host, and runs compose there - via --project-directory. + on the remote host so the backup + helper can read the compose and config, and rewrites relative binds + (./data) to that host path so they resolve + on the remote daemon.

- 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 + absolute paths or + named volumes instead.

{/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 /data/stacks. {: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}