mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-28 03:06:57 +00:00
fix(self-update): resolve /app/data through bind or named volume (#1124)
SelfUpdateService scanned the container's Mounts list for Type='bind' only when picking the host path to forward to the helper container. A pilot enrolled with the recommended Docker Compose snippet uses a named volume (sencho-agent-data:/app/data, Type='volume'), so the lookup returned null and the boot log read "/app/data mount not found - update error recovery will be unavailable". The helper still ran on update but could not persist UPDATE_ERROR_FILE on a failed pull, so the next gateway process had nothing to surface. Extract findDataDirHost(mounts) and accept Type='bind' or Type='volume'. Docker populates Source with the on-disk volume directory for either type, so the existing :rw bind in the helper spawn works unchanged. The independent hostBindMounts filter stays strict to Type='bind' (forwarded operator-declared compose paths only; named volumes are not in scope there). Tests: pure unit coverage for the helper across 6 cases (bind hit, volume hit, no /app/data, mixed list with siblings, empty Source, tmpfs ignored).
This commit is contained in:
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,6 +17,28 @@ interface HostMount {
|
|||||||
destination: string;
|
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<DockerMount>): 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 {
|
interface ComposeContext {
|
||||||
workingDir: string;
|
workingDir: string;
|
||||||
configFiles: string;
|
configFiles: string;
|
||||||
@@ -86,14 +108,12 @@ class SelfUpdateService {
|
|||||||
// Collect all host bind mounts so the helper container can forward them.
|
// Collect all host bind mounts so the helper container can forward them.
|
||||||
// This lets docker compose resolve env_file, configs, secrets, and any
|
// This lets docker compose resolve env_file, configs, secrets, and any
|
||||||
// other host-path references that live outside the compose working dir.
|
// other host-path references that live outside the compose working dir.
|
||||||
const rawMounts = (info.Mounts ?? []) as Array<{
|
const rawMounts = (info.Mounts ?? []) as DockerMount[];
|
||||||
Type: string; Source: string; Destination: string;
|
|
||||||
}>;
|
|
||||||
const hostBindMounts: HostMount[] = rawMounts
|
const hostBindMounts: HostMount[] = rawMounts
|
||||||
.filter(m => m.Type === 'bind' && m.Source && m.Destination)
|
.filter(m => m.Type === 'bind' && m.Source && m.Destination)
|
||||||
.map(m => ({ source: m.Source, destination: 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) {
|
if (!dataDirHost) {
|
||||||
console.log('[SelfUpdate] /app/data mount not found - update error recovery will be unavailable');
|
console.log('[SelfUpdate] /app/data mount not found - update error recovery will be unavailable');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user