fix(storage): match default prefix too when relocating child-app binds

_applyHostStorageRoot only rewrote binds carrying the current
NOMAD_STORAGE_PATH and short-circuited when env == resolved root. That
left user-modified/custom apps — whose binds freeze the default prefix at
edit time — mounting an empty dir on the documented "set NOMAD_STORAGE_PATH
+ relocate the volume" path (#938).

- Match either the env value or the hardcoded default prefix; drop the
  root == seededRoot no-op, excluding root from the candidates instead so
  rewrites stay idempotent.
- Add ADMIN_STORAGE_DEST / DEFAULT_HOST_STORAGE_ROOT constants; replace the
  cwd-derived storage dest so admin-mount lookup can't silently break.
- Comment why a transient inspect failure is deliberately not cached.
This commit is contained in:
jakeaturner 2026-06-07 21:53:58 +00:00
parent fab15c68a9
commit 057fb693f6
No known key found for this signature in database
GPG Key ID: B1072EBDEECE328D
1 changed files with 35 additions and 9 deletions

View File

@ -30,6 +30,13 @@ export class DockerService {
private activeInstallations: Set<string> = new Set()
public static NOMAD_NETWORK = 'project-nomad_default'
public static ADMIN_CONTAINER_NAME = 'nomad_admin'
// The admin's own storage mount destination inside its container (compose maps
// <hostPath>:/app/storage). Used to locate the backing host path (#938).
public static ADMIN_STORAGE_DEST = '/app/storage'
// Hardcoded production default host storage root. A seeded/frozen child bind can
// still carry this prefix even after NOMAD_STORAGE_PATH is set elsewhere, so it's
// matched alongside the env value when relocating binds (#938).
public static DEFAULT_HOST_STORAGE_ROOT = '/opt/project-nomad/storage'
// Resolved once: the host filesystem path backing the admin's /app/storage mount.
// Child-service binds are rewritten to live under this so relocating the admin
@ -468,9 +475,9 @@ export class DockerService {
*/
private async _resolveHostStorageRoot(): Promise<string> {
if (this._hostStorageRoot) return this._hostStorageRoot
const fallback = env.get('NOMAD_STORAGE_PATH', '/opt/project-nomad/storage')
const fallback = env.get('NOMAD_STORAGE_PATH', DockerService.DEFAULT_HOST_STORAGE_ROOT)
try {
const adminStorageDest = join(process.cwd(), '/storage') // e.g. /app/storage
const adminStorageDest = DockerService.ADMIN_STORAGE_DEST
const containers = await this.docker.listContainers({ all: true })
// Prefer the well-known admin container name; fall back to matching this
// process's own container by hostname (Docker defaults it to the short id).
@ -496,30 +503,49 @@ export class DockerService {
logger.warn(
`[DockerService] Could not resolve host storage root, using fallback ${fallback}: ${err.message}`
)
// Deliberately NOT cached: a transient Docker error (e.g. socket not ready at
// early boot) shouldn't lock this process into the fallback for its lifetime —
// caching here could permanently re-hide #938 after Docker recovers. Retry next call.
return fallback
}
}
/**
* Rewrite the host side of a service's storage bind mounts so they point at the
* resolved host storage root. The seeded binds use the default/env prefix; if the
* admin storage actually lives elsewhere on the host, swap that prefix so the
* child container mounts the same physical location (#938). No-op when the
* resolved root matches the seeded prefix (the common case).
* resolved host storage root. If the admin storage actually lives elsewhere on the
* host, swap a known storage-root prefix so the child container mounts the same
* physical location (#938).
*
* A bind's prefix may be the current NOMAD_STORAGE_PATH *or* the hardcoded default:
* curated services re-bake their config from the env every boot, but user-modified
* curated services and custom apps freeze their binds at edit time and can still
* carry the default prefix even after NOMAD_STORAGE_PATH is changed. Matching both
* (rather than only the current env, and short-circuiting when env == root) means
* the documented "set NOMAD_STORAGE_PATH and relocate the volume" path also fixes
* those frozen-prefix apps instead of leaving them mounting an empty directory.
* Idempotent: binds already under `root` match no other prefix and are left alone.
*/
private async _applyHostStorageRoot(containerConfig: any): Promise<void> {
const binds: string[] | undefined = containerConfig?.HostConfig?.Binds
if (!binds?.length) return
const seededRoot = env.get('NOMAD_STORAGE_PATH', '/opt/project-nomad/storage')
const root = await this._resolveHostStorageRoot()
if (root === seededRoot) return
// Known host-side prefixes a seeded/frozen bind might carry. Exclude `root` itself
// so binds already pointing at the resolved location are never rewritten.
const seededRoots = [
env.get('NOMAD_STORAGE_PATH', DockerService.DEFAULT_HOST_STORAGE_ROOT),
DockerService.DEFAULT_HOST_STORAGE_ROOT,
].filter((r) => r !== root)
if (!seededRoots.length) return
containerConfig.HostConfig.Binds = binds.map((b) => {
// bind format: "<hostSrc>:<containerDest>[:opts]" — hostSrc is a Linux abs path.
const firstColon = b.indexOf(':')
if (firstColon < 0) return b
const hostSrc = b.slice(0, firstColon)
const rest = b.slice(firstColon) // includes leading ':'
if (hostSrc === seededRoot || hostSrc.startsWith(seededRoot + '/')) {
const seededRoot = seededRoots.find(
(r) => hostSrc === r || hostSrc.startsWith(r + '/')
)
if (seededRoot) {
return `${root}${hostSrc.slice(seededRoot.length)}${rest}`
}
return b