diff --git a/backend/src/__tests__/self-update-data-mount.test.ts b/backend/src/__tests__/self-update-data-mount.test.ts new file mode 100644 index 00000000..cc9ed849 --- /dev/null +++ b/backend/src/__tests__/self-update-data-mount.test.ts @@ -0,0 +1,56 @@ +/** + * findDataDirHost detects the host-side path for /app/data across bind AND + * named-volume mounts. Pre-fix the helper bound the resolver to Type='bind' + * only, so a pilot-agent deployed with the recommended `sencho-agent-data:/ + * app/data` named volume logged "/app/data mount not found - update error + * recovery will be unavailable" at boot. + */ +import { describe, expect, it } from 'vitest'; +import { findDataDirHost } from '../services/SelfUpdateService'; + +describe('findDataDirHost', () => { + it('returns the host path for a bind mount at /app/data', () => { + const source = findDataDirHost([ + { Type: 'bind', Source: '/opt/sencho/data', Destination: '/app/data' }, + ]); + expect(source).toBe('/opt/sencho/data'); + }); + + it('returns the host path for a named volume at /app/data', () => { + const source = findDataDirHost([ + { Type: 'volume', Source: '/var/lib/docker/volumes/sencho-agent-data/_data', Destination: '/app/data' }, + ]); + expect(source).toBe('/var/lib/docker/volumes/sencho-agent-data/_data'); + }); + + it('returns null when no mount targets /app/data', () => { + const source = findDataDirHost([ + { Type: 'bind', Source: '/var/run/docker.sock', Destination: '/var/run/docker.sock' }, + { Type: 'bind', Source: '/opt/compose', Destination: '/app/compose' }, + ]); + expect(source).toBeNull(); + }); + + it('picks the /app/data mount out of a mixed list and ignores siblings', () => { + const source = findDataDirHost([ + { Type: 'bind', Source: '/var/run/docker.sock', Destination: '/var/run/docker.sock' }, + { Type: 'volume', Source: '/var/lib/docker/volumes/sencho-agent-data/_data', Destination: '/app/data' }, + { Type: 'bind', Source: '/opt/compose', Destination: '/app/compose' }, + ]); + expect(source).toBe('/var/lib/docker/volumes/sencho-agent-data/_data'); + }); + + it('returns null when a /app/data entry carries no Source', () => { + const source = findDataDirHost([ + { Type: 'bind', Source: '', Destination: '/app/data' }, + ]); + expect(source).toBeNull(); + }); + + it('ignores tmpfs and other non-bind/volume types', () => { + const source = findDataDirHost([ + { Type: 'tmpfs', Source: '', Destination: '/app/data' }, + ]); + expect(source).toBeNull(); + }); +}); diff --git a/backend/src/services/SelfUpdateService.ts b/backend/src/services/SelfUpdateService.ts index fd34cd89..f1e354bf 100644 --- a/backend/src/services/SelfUpdateService.ts +++ b/backend/src/services/SelfUpdateService.ts @@ -17,6 +17,28 @@ interface HostMount { destination: string; } +/** Narrow projection of the Dockerode mount entry we actually consume. */ +export type DockerMount = { + Type: 'bind' | 'volume' | 'tmpfs' | 'npipe' | 'cluster' | 'image'; + Source: string; + Destination: string; +}; + +/** + * Find the host-side path Docker resolved for /app/data, regardless of whether + * the operator declared a bind or a named volume. The helper container uses + * this path to mount /app/data:rw so it can write UPDATE_ERROR_FILE when a + * compose recreate fails before the gateway can persist the error itself. + */ +export function findDataDirHost(mounts: ReadonlyArray): string | null { + const match = mounts.find(m => + m.Destination === '/app/data' && + (m.Type === 'bind' || m.Type === 'volume') && + !!m.Source, + ); + return match?.Source ?? null; +} + interface ComposeContext { workingDir: string; configFiles: string; @@ -86,14 +108,12 @@ class SelfUpdateService { // Collect all host bind mounts so the helper container can forward them. // This lets docker compose resolve env_file, configs, secrets, and any // other host-path references that live outside the compose working dir. - const rawMounts = (info.Mounts ?? []) as Array<{ - Type: string; Source: string; Destination: string; - }>; + const rawMounts = (info.Mounts ?? []) as DockerMount[]; const hostBindMounts: HostMount[] = rawMounts .filter(m => m.Type === 'bind' && m.Source && m.Destination) .map(m => ({ source: m.Source, destination: m.Destination })); - const dataDirHost = hostBindMounts.find(m => m.destination === '/app/data')?.source ?? null; + const dataDirHost = findDataDirHost(rawMounts); if (!dataDirHost) { console.log('[SelfUpdate] /app/data mount not found - update error recovery will be unavailable'); }