fix(storage): derive child-app bind paths from the admin's actual storage mount

Child services (Kiwix, Ollama, Qdrant, Flatnotes, Kolibri) are created via the
Docker socket, so their bind mounts use a HOST path. That path was baked into
the services table at seed time from NOMAD_STORAGE_PATH (default
/opt/project-nomad/storage) — and NOMAD_STORAGE_PATH wasn't even present in
management_compose.yaml, so it always fell back to the default.

Result: relocating the admin storage volume in compose (e.g.
/mnt/big/storage:/app/storage) moved the admin's own data but left child apps
mounting the old, now-empty /opt/project-nomad/storage. Kiwix would come up
with no content. (#938)

- Add _resolveHostStorageRoot(): inspect the admin's own container, find the
  bind backing /app/storage, and use its host Source as the single source of
  truth (cached). Falls back to NOMAD_STORAGE_PATH/default if it can't be
  inspected.
- Add _applyHostStorageRoot(): rewrite the host-side prefix of each storage bind
  to that root. No-op when it already matches the seeded prefix, so default
  installs are unaffected.
- Apply it in _createContainer (covers installs + dependency recursion) and in
  the Kiwix library-mode recreate path.
- management_compose.yaml: add an explicit NOMAD_STORAGE_PATH knob and rewrite
  the comments so the admin volume, env var, and disk-collector volume that must
  agree are spelled out.

Closes #938

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Sherwood 2026-06-05 16:00:36 -07:00 committed by jakeaturner
parent b2bea191a8
commit fab15c68a9
No known key found for this signature in database
GPG Key ID: B1072EBDEECE328D
2 changed files with 88 additions and 1 deletions

View File

@ -5,6 +5,8 @@ import { inject } from '@adonisjs/core'
import transmit from '@adonisjs/transmit/services/main'
import { doResumableDownloadWithRetry } from '../utils/downloads.js'
import { join } from 'path'
import os from 'node:os'
import env from '#start/env'
import {
ZIM_STORAGE_PATH,
BOOKS_STORAGE_PATH,
@ -27,6 +29,12 @@ export class DockerService {
public docker: Docker
private activeInstallations: Set<string> = new Set()
public static NOMAD_NETWORK = 'project-nomad_default'
public static ADMIN_CONTAINER_NAME = 'nomad_admin'
// 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
// storage volume relocates every child app too (#938). null = not yet resolved.
private _hostStorageRoot: string | null = null
private _servicesStatusCache: { data: { service_name: string; status: string }[]; expiresAt: number } | null = null
private _servicesStatusInflight: Promise<{ service_name: string; status: string }[]> | null = null
@ -447,6 +455,77 @@ export class DockerService {
* @param serviceName
* @returns
*/
/**
* Resolve the host filesystem path that backs the admin container's storage
* directory (`/app/storage`). Child services are created via the Docker socket,
* so their bind mounts need the path on the *host*, not inside the admin
* container. Deriving it from the admin's own mount means whatever host path
* the admin storage volume is mapped to in compose, child apps follow it
* automatically (#938).
*
* Falls back to NOMAD_STORAGE_PATH / the production default if the admin
* container or its storage mount can't be inspected.
*/
private async _resolveHostStorageRoot(): Promise<string> {
if (this._hostStorageRoot) return this._hostStorageRoot
const fallback = env.get('NOMAD_STORAGE_PATH', '/opt/project-nomad/storage')
try {
const adminStorageDest = join(process.cwd(), '/storage') // e.g. /app/storage
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).
let adminInfo = containers.find((c) =>
c.Names.includes(`/${DockerService.ADMIN_CONTAINER_NAME}`)
)
if (!adminInfo) {
const hn = os.hostname()
adminInfo = containers.find((c) => c.Id.startsWith(hn))
}
if (!adminInfo) return (this._hostStorageRoot = fallback)
const inspected = await this.docker.getContainer(adminInfo.Id).inspect()
const mount = (inspected.Mounts ?? []).find(
(m: any) => m.Type === 'bind' && m.Destination === adminStorageDest
)
if (mount?.Source) {
logger.info(`[DockerService] Resolved host storage root from admin mount: ${mount.Source}`)
return (this._hostStorageRoot = mount.Source)
}
return (this._hostStorageRoot = fallback)
} catch (err: any) {
logger.warn(
`[DockerService] Could not resolve host storage root, using fallback ${fallback}: ${err.message}`
)
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).
*/
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
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 + '/')) {
return `${root}${hostSrc.slice(seededRoot.length)}${rest}`
}
return b
})
}
async _createContainer(
service: Service & { dependencies?: Service[] },
containerConfig: any
@ -454,6 +533,10 @@ export class DockerService {
try {
this._broadcast(service.service_name, 'initializing', '')
// Point storage binds at wherever the admin's storage volume actually lives
// on the host (covers dependency installs too — they recurse through here).
await this._applyHostStorageRoot(containerConfig)
let dependencies = []
if (service.depends_on) {
const dependency = await Service.query().where('service_name', service.depends_on).first()
@ -1087,6 +1170,7 @@ export class DockerService {
await service.save()
const containerConfig = this._parseContainerConfig(service.container_config)
await this._applyHostStorageRoot(containerConfig)
// Step 4: Recreate container directly (skipping _createContainer to avoid re-downloading
// the bootstrap ZIM — ZIM files already exist on disk)

View File

@ -18,11 +18,14 @@ services:
ports:
- "8080:8080"
volumes:
- /opt/project-nomad/storage:/app/storage # If you change the default storage path (/opt/project-nomad/storage), make sure to update the disk-collector config as well!
# To store NOMAD's data somewhere other than /opt/project-nomad/storage, change the HOST side (left of the colon) here AND set NOMAD_STORAGE_PATH below to the same host path, then update the disk-collector volume at the bottom to match. The admin auto-detects this mount so child apps (Kiwix, Ollama, etc.) follow it, but setting NOMAD_STORAGE_PATH keeps everything explicit.
- /opt/project-nomad/storage:/app/storage
- /var/run/docker.sock:/var/run/docker.sock # Allows the admin service to communicate with the Host's Docker daemon
- nomad-update-shared:/app/update-shared # Shared volume for update communication
environment:
- NODE_ENV=production
# NOMAD_STORAGE_PATH is the HOST path where NOMAD stores all of its data (ZIMs, models, notes, etc.). It MUST match the host side of the /app/storage volume above and the disk-collector volume below. The admin also auto-detects its storage mount, so child apps follow it even if this is left at the default, but keep all three in sync to avoid surprises.
- NOMAD_STORAGE_PATH=/opt/project-nomad/storage
# PORT is the port the admin server listens on *inside* the container and should not be changed. If you want to change which port the admin interface is accessible from on the host, you can change the port mapping in the "ports" section (e.g. "9090:8080" to access it on port 9090 from the host)
- PORT=8080
- LOG_LEVEL=info