mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
converge everything into one data folder
This commit is contained in:
@@ -11,8 +11,6 @@ CHROMEGTTS_WAV_BIN="/usr/local/bin/chromegtts-wav"
|
||||
ROVER_SNAPSHOT_WRITER_BIN="/usr/local/bin/rover-snapshot-writer.sh"
|
||||
MEDIAMTX_SERVICE="/etc/systemd/system/mediamtx.service"
|
||||
MULTIROVER_SERVICE="/etc/systemd/system/multirover.service"
|
||||
SNAPSHOT_DIR="/var/lib/rover-snapshots"
|
||||
REPLAY_SEGMENT_DIR="/var/lib/replay-segments"
|
||||
KINECT_UDEV_RULE="/etc/udev/rules.d/99-kinect-world.rules"
|
||||
BLUETOOTH_OVERRIDE_DIR="/etc/systemd/system/bluetooth.service.d"
|
||||
BLUETOOTH_OVERRIDE="$BLUETOOTH_OVERRIDE_DIR/20-multirover-balance-board.conf"
|
||||
@@ -30,6 +28,8 @@ fi
|
||||
TARGET_USER="$SUDO_USER"
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
SERVER_DIR="$SCRIPT_DIR"
|
||||
DATA_DIR="$SERVER_DIR/data"
|
||||
SNAPSHOT_DIR="$DATA_DIR/rover-snapshots"
|
||||
BALANCE_BOARD_NATIVE_DIR="$SCRIPT_DIR/src/services/balanceBoardService/native"
|
||||
BALANCE_BOARD_WORKER="$BALANCE_BOARD_NATIVE_DIR/balance_board_worker"
|
||||
CONFIG_PATH="$SERVER_DIR/config.yaml"
|
||||
@@ -281,10 +281,23 @@ rm -f "$MEDIAMTX_SERVICE"
|
||||
rm -f /etc/mediamtx/mediamtx.yml
|
||||
|
||||
echo "[4/6] Writing systemd units..."
|
||||
mkdir -p "$SNAPSHOT_DIR"
|
||||
chown "$TARGET_USER":"$TARGET_USER" "$SNAPSHOT_DIR"
|
||||
mkdir -p "$REPLAY_SEGMENT_DIR"
|
||||
chown "$TARGET_USER":"$TARGET_USER" "$REPLAY_SEGMENT_DIR"
|
||||
# The repository data directory is the legacy deployment's single persistence
|
||||
# root and becomes the one bind-mounted /data directory during containerization.
|
||||
# Create only the snapshot child eagerly because MediaMTX's hook writes there;
|
||||
# the other services already create their own children when those features run.
|
||||
mkdir -p "$DATA_DIR" "$SNAPSHOT_DIR"
|
||||
chown "$TARGET_USER":"$TARGET_USER" "$DATA_DIR" "$SNAPSHOT_DIR"
|
||||
|
||||
# Previous installers used these two /var/lib directories. Replay code already
|
||||
# stopped reading its old location, and snapshots regenerate immediately, so do
|
||||
# not merge possibly stale runtime media over the new canonical data tree. Keep
|
||||
# an existing directory untouched and report it for deliberate cleanup after the
|
||||
# operator verifies the upgraded server.
|
||||
for legacy_dir in /var/lib/rover-snapshots /var/lib/replay-segments; do
|
||||
if [[ -d "$legacy_dir" ]]; then
|
||||
echo " Legacy runtime directory is no longer used: $legacy_dir"
|
||||
fi
|
||||
done
|
||||
cat > "$MULTIROVER_SERVICE" <<EOF
|
||||
[Unit]
|
||||
Description=Multi-Roomba Rover control server
|
||||
@@ -297,8 +310,7 @@ Group=$TARGET_USER
|
||||
WorkingDirectory=$SERVER_DIR
|
||||
Environment=NODE_ENV=production
|
||||
Environment=SERVER_CONFIG=$CONFIG_PATH
|
||||
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR
|
||||
Environment=REPLAY_SEGMENT_DIR=$REPLAY_SEGMENT_DIR
|
||||
Environment=SERVER_DATA_DIR=$DATA_DIR
|
||||
Environment=ROVER_SNAPSHOT_WRITER_BIN=$ROVER_SNAPSHOT_WRITER_BIN
|
||||
ExecStart=$NODE_BIN $SERVER_DIR/index.js
|
||||
Restart=on-failure
|
||||
|
||||
@@ -5,7 +5,16 @@
|
||||
set -euo pipefail
|
||||
|
||||
PATH_NAME="${MTX_PATH:-}"
|
||||
SNAP_DIR="${ROVER_SNAPSHOT_DIR:-/var/lib/rover-snapshots}"
|
||||
|
||||
# Node resolves and supplies SERVER_DATA_DIR when it starts MediaMTX, and
|
||||
# MediaMTX carries that environment into this runOnReady hook. Requiring that
|
||||
# single root prevents the writer from silently recreating the former /var/lib
|
||||
# snapshot store while the readers are looking inside the mounted data folder.
|
||||
if [[ -z "${SERVER_DATA_DIR:-}" ]]; then
|
||||
echo "SERVER_DATA_DIR is required for rover snapshot output" >&2
|
||||
exit 1
|
||||
fi
|
||||
SNAP_DIR="${SERVER_DATA_DIR}/rover-snapshots"
|
||||
|
||||
# Ignore non-rover-video paths.
|
||||
case "$PATH_NAME" in
|
||||
|
||||
@@ -1,41 +1,49 @@
|
||||
// data Paths helper
|
||||
// Purpose: Resolves persistent data paths across refactors so services keep loading prior state files.
|
||||
// Scope: Preserves runtime behavior by preferring configured/canonical paths while supporting legacy locations.
|
||||
const fs = require('fs');
|
||||
// Purpose: Defines the single filesystem boundary for all mutable, persistent server data.
|
||||
// Scope: Resolves the configured data root and every application-owned mutable path beneath it.
|
||||
const path = require('path');
|
||||
|
||||
const CANONICAL_DATA_DIR = path.resolve(__dirname, '..', '..', 'data');
|
||||
const LEGACY_DATA_DIR = path.resolve(__dirname, '..', 'data');
|
||||
|
||||
function pathExists(target) {
|
||||
try {
|
||||
fs.accessSync(target, fs.constants.F_OK);
|
||||
return true;
|
||||
} catch (_err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const ROVER_SNAPSHOT_DIR_NAME = 'rover-snapshots';
|
||||
const RUNTIME_DIR_NAME = 'runtime';
|
||||
|
||||
function resolveDataDir() {
|
||||
const configured = String(process.env.SERVER_DATA_DIR || '').trim();
|
||||
if (configured) return path.resolve(configured);
|
||||
if (pathExists(CANONICAL_DATA_DIR)) return CANONICAL_DATA_DIR;
|
||||
if (pathExists(LEGACY_DATA_DIR)) return LEGACY_DATA_DIR;
|
||||
return CANONICAL_DATA_DIR;
|
||||
}
|
||||
|
||||
function resolveDataPath(fileName) {
|
||||
const configured = String(process.env.SERVER_DATA_DIR || '').trim();
|
||||
if (configured) return path.join(path.resolve(configured), fileName);
|
||||
/*
|
||||
Always join through resolveDataDir instead of repeating environment handling
|
||||
in individual services. This is what makes one SERVER_DATA_DIR mount contain
|
||||
every database, JSON store, generated file, and persistent media directory.
|
||||
*/
|
||||
return path.join(resolveDataDir(), fileName);
|
||||
}
|
||||
|
||||
const canonicalPath = path.join(CANONICAL_DATA_DIR, fileName);
|
||||
const legacyPath = path.join(LEGACY_DATA_DIR, fileName);
|
||||
if (pathExists(canonicalPath)) return canonicalPath;
|
||||
if (pathExists(legacyPath)) return legacyPath;
|
||||
return canonicalPath;
|
||||
function resolveRoverSnapshotDir() {
|
||||
/*
|
||||
Snapshot production, polling, PTZ reads, and health reporting must use the
|
||||
exact same directory. Giving this shared directory a named resolver prevents
|
||||
one of those consumers from drifting back to the former /var/lib location.
|
||||
*/
|
||||
return resolveDataPath(ROVER_SNAPSHOT_DIR_NAME);
|
||||
}
|
||||
|
||||
function resolveRuntimePath(...pathSegments) {
|
||||
/*
|
||||
Disposable files are still files intentionally managed by the Node server.
|
||||
Keeping them below a named runtime directory preserves the single-root
|
||||
filesystem contract without confusing scratch files with durable stores.
|
||||
Callers remain responsible for deleting their own completed work.
|
||||
*/
|
||||
return resolveDataPath(path.join(RUNTIME_DIR_NAME, ...pathSegments));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveDataDir,
|
||||
resolveDataPath,
|
||||
resolveRoverSnapshotDir,
|
||||
resolveRuntimePath,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// Data Paths Helper Tests
|
||||
// Purpose: Pins the one-root persistence contract used by local, systemd, and future container deployments.
|
||||
// Scope: Exercises path resolution only and never creates files in the real server data directory.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const {
|
||||
resolveDataDir,
|
||||
resolveDataPath,
|
||||
resolveRoverSnapshotDir,
|
||||
resolveRuntimePath,
|
||||
} = require('./dataPaths');
|
||||
|
||||
const originalDataDir = process.env.SERVER_DATA_DIR;
|
||||
|
||||
test.afterEach(() => {
|
||||
/*
|
||||
Environment state is process-global. Restore the caller's value after each
|
||||
assertion so this focused test remains safe when it is composed with other
|
||||
tests in the same Node process later.
|
||||
*/
|
||||
if (originalDataDir === undefined) delete process.env.SERVER_DATA_DIR;
|
||||
else process.env.SERVER_DATA_DIR = originalDataDir;
|
||||
});
|
||||
|
||||
test('defaults every persistent path to the canonical server data directory', () => {
|
||||
delete process.env.SERVER_DATA_DIR;
|
||||
const expectedRoot = path.resolve(__dirname, '..', '..', 'data');
|
||||
|
||||
assert.equal(resolveDataDir(), expectedRoot);
|
||||
assert.equal(resolveDataPath('identity.sqlite'), path.join(expectedRoot, 'identity.sqlite'));
|
||||
assert.equal(resolveRoverSnapshotDir(), path.join(expectedRoot, 'rover-snapshots'));
|
||||
assert.equal(resolveRuntimePath('replay-builds'), path.join(expectedRoot, 'runtime', 'replay-builds'));
|
||||
});
|
||||
|
||||
test('moves every persistent path beneath SERVER_DATA_DIR when it is configured', () => {
|
||||
const configuredRoot = path.join(os.tmpdir(), 'multirover-data-path-test');
|
||||
process.env.SERVER_DATA_DIR = configuredRoot;
|
||||
|
||||
assert.equal(resolveDataDir(), path.resolve(configuredRoot));
|
||||
assert.equal(resolveDataPath('fleet-reports.sqlite'), path.join(configuredRoot, 'fleet-reports.sqlite'));
|
||||
assert.equal(resolveDataPath(path.join('replays', 'example.mp4')), path.join(configuredRoot, 'replays', 'example.mp4'));
|
||||
assert.equal(resolveRoverSnapshotDir(), path.join(configuredRoot, 'rover-snapshots'));
|
||||
assert.equal(
|
||||
resolveRuntimePath('audio-forward', 'uploads'),
|
||||
path.join(configuredRoot, 'runtime', 'audio-forward', 'uploads'),
|
||||
);
|
||||
});
|
||||
@@ -6,6 +6,7 @@ const EventEmitter = require('events');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('audioForwardService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { resolveRuntimePath } = require('../../helpers/dataPaths');
|
||||
const roverManager = require('../roverManager');
|
||||
const turnService = require('../turnService');
|
||||
const { isMuted, isVerified, verificationEvents } = require('../verificationService');
|
||||
@@ -25,7 +26,13 @@ const streamSuffix =
|
||||
typeof audioForwardConfig.streamSuffix === 'string' && audioForwardConfig.streamSuffix.trim()
|
||||
? audioForwardConfig.streamSuffix.trim()
|
||||
: '-fwd';
|
||||
const runtimeDir = path.resolve(audioForwardConfig.runtimeDir || '/tmp/mrr-audio-forward');
|
||||
/*
|
||||
FIFOs and uploaded clips are disposable, but they are deliberately created
|
||||
and managed by this application. A fixed path below SERVER_DATA_DIR keeps the
|
||||
Node process from writing to an unrelated host temp directory and prevents a
|
||||
configuration value from escaping the server's filesystem boundary.
|
||||
*/
|
||||
const runtimeDir = resolveRuntimePath('audio-forward');
|
||||
const uploadsDir = path.join(runtimeDir, 'uploads');
|
||||
const maxUploadBytes = Number.isFinite(audioForwardConfig.maxUploadBytes)
|
||||
? Math.max(256 * 1024, Math.floor(audioForwardConfig.maxUploadBytes))
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
|
||||
const fsp = require('fs/promises');
|
||||
const path = require('path');
|
||||
const { resolveRoverSnapshotDir } = require('../../helpers/dataPaths');
|
||||
const roverManager = require('../roverManager');
|
||||
const { getRoomCameras } = require('../roomCameraService');
|
||||
const { getRoomCameraState } = require('../roomCameraService');
|
||||
const { getReplayHealthSnapshot } = require('../replayEngineV2');
|
||||
|
||||
const ROVER_SNAPSHOT_DIR = process.env.ROVER_SNAPSHOT_DIR || '/var/lib/rover-snapshots';
|
||||
const ROVER_SNAPSHOT_DIR = resolveRoverSnapshotDir();
|
||||
const HEALTH_INTERVAL_MS = 5000;
|
||||
const ROOM_CAMERA_STALE_MS = 5000;
|
||||
const ROVER_SNAPSHOT_STALE_MS = 5000;
|
||||
|
||||
@@ -5,7 +5,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
const yaml = require('js-yaml');
|
||||
const { resolveDataPath } = require('../../helpers/dataPaths');
|
||||
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
||||
const { buildMediaMtxConfig } = require('./config');
|
||||
|
||||
function createMediaMtxSupervisor(deps) {
|
||||
@@ -52,6 +52,16 @@ function createMediaMtxSupervisor(deps) {
|
||||
logger.info(`Starting MediaMTX with generated config ${configPath}`);
|
||||
child = spawnProcess(mediaMtxBin, [configPath], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
/*
|
||||
MediaMTX passes its environment to runOnReady hooks. Supplying the
|
||||
resolved value here also covers development starts where
|
||||
SERVER_DATA_DIR was omitted, so the installed snapshot writer and every
|
||||
Node snapshot reader still converge on the same canonical data root.
|
||||
*/
|
||||
env: {
|
||||
...process.env,
|
||||
SERVER_DATA_DIR: resolveDataDir(),
|
||||
},
|
||||
});
|
||||
|
||||
forwardLines(child.stdout, 'info');
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// MediaMTX Supervisor Tests
|
||||
// Purpose: Verifies that generated MediaMTX state and child hooks inherit the server's single data root.
|
||||
// Scope: Uses a child-process double and a temporary directory; no listener or background process is started.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const EventEmitter = require('events');
|
||||
const { PassThrough } = require('stream');
|
||||
const { createMediaMtxSupervisor } = require('./supervisor');
|
||||
|
||||
test('passes the resolved data root to MediaMTX runOnReady hooks', () => {
|
||||
const temporaryDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'multirover-mediamtx-supervisor-'));
|
||||
const generatedConfigPath = path.join(temporaryDataDir, 'mediamtx.yml');
|
||||
const previousDataDir = process.env.SERVER_DATA_DIR;
|
||||
let invocation = null;
|
||||
|
||||
process.env.SERVER_DATA_DIR = temporaryDataDir;
|
||||
|
||||
const spawnProcess = (command, args, options) => {
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new PassThrough();
|
||||
child.stderr = new PassThrough();
|
||||
child.kill = (signal) => {
|
||||
child.emit('exit', 0, signal);
|
||||
};
|
||||
invocation = { command, args, options };
|
||||
return child;
|
||||
};
|
||||
|
||||
const logger = {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
};
|
||||
|
||||
try {
|
||||
const supervisor = createMediaMtxSupervisor({
|
||||
config: { media: { additionalHosts: ['media.example.test'] } },
|
||||
serverPort: 8080,
|
||||
logger,
|
||||
mediaMtxBin: '/test/bin/mediamtx',
|
||||
snapshotWriterPath: '/test/bin/rover-snapshot-writer',
|
||||
configPath: generatedConfigPath,
|
||||
spawnProcess,
|
||||
});
|
||||
|
||||
supervisor.start();
|
||||
|
||||
assert.equal(invocation.command, '/test/bin/mediamtx');
|
||||
assert.deepEqual(invocation.args, [generatedConfigPath]);
|
||||
assert.equal(invocation.options.env.SERVER_DATA_DIR, temporaryDataDir);
|
||||
assert.equal(fs.existsSync(generatedConfigPath), true);
|
||||
|
||||
supervisor.stop();
|
||||
} finally {
|
||||
/*
|
||||
Restore process-global state and delete only the test-owned directory so a
|
||||
failed assertion cannot alter later tests or leave generated YAML behind.
|
||||
*/
|
||||
if (previousDataDir === undefined) delete process.env.SERVER_DATA_DIR;
|
||||
else process.env.SERVER_DATA_DIR = previousDataDir;
|
||||
fs.rmSync(temporaryDataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -10,6 +10,7 @@ const { Cam } = require('onvif');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('ptzCamera');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { resolveRoverSnapshotDir } = require('../../helpers/dataPaths');
|
||||
const { isFeatureEnabled } = require('../../helpers/features');
|
||||
const {
|
||||
shouldUseSnapshotsForNonTurnVideo,
|
||||
@@ -43,7 +44,7 @@ const STOP_MOTION = Object.freeze({ pan: 0, tilt: 0, zoom: 0 });
|
||||
// explicitly disables replay for the camera.
|
||||
const DEFAULT_REPLAY_ENABLED = true;
|
||||
const DEFAULT_PTZ_COLOR = '#387bf8';
|
||||
const SNAPSHOT_DIR = process.env.ROVER_SNAPSHOT_DIR || '/var/lib/rover-snapshots';
|
||||
const SNAPSHOT_DIR = resolveRoverSnapshotDir();
|
||||
const SNAPSHOT_POLL_MS = 300;
|
||||
const SNAPSHOT_STREAM_INTERVAL_MS = 2000;
|
||||
const SPOTLIGHT_VERIFY_DELAY_MS = 1200;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Replay Builder Pipeline
|
||||
// Purpose: Assembles selected buffered segments into final replay output video with optional sidebar.
|
||||
// Scope: Owns concat/probe/layout/transcode pipeline and returns replay buffer plus source usage metadata.
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { resolveRuntimePath } = require('../../helpers/dataPaths');
|
||||
const { getActiveDrivers } = require('../turnService');
|
||||
const { getNickname } = require('../nicknameService');
|
||||
const { getRecentMessages } = require('../chatService');
|
||||
@@ -124,7 +124,15 @@ function createReplayBuilder({ execFileAsync, fsp, ensureDir, renderSidebarVideo
|
||||
durationMs: BUILD_DURATION_MS,
|
||||
});
|
||||
const resolvedTitle = sanitizeReplayTitle(title, resolveDefaultReplayTitle(requester, sources));
|
||||
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'mrr-replay-v2-'));
|
||||
/*
|
||||
A replay build is disposable, but every intermediate concat list, pinned
|
||||
segment, and ffmpeg output is created on the server's behalf. Create a
|
||||
unique workspace below SERVER_DATA_DIR so the application never spills
|
||||
those writes into the host-wide temporary directory.
|
||||
*/
|
||||
const buildRoot = resolveRuntimePath('replay-builds');
|
||||
await ensureDir(buildRoot);
|
||||
const tmpDir = await fsp.mkdtemp(path.join(buildRoot, 'build-'));
|
||||
|
||||
try {
|
||||
const usedSources = [];
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
const { execFile } = require('child_process');
|
||||
const EventEmitter = require('events');
|
||||
const fsp = require('fs/promises');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { promisify } = require('util');
|
||||
|
||||
const logger = require('../../globals/logger').child('roomCameraReplay');
|
||||
const { resolveRuntimePath } = require('../../helpers/dataPaths');
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -118,7 +118,15 @@ async function buildRoomCameraReplayVideo({ cameraId = null } = {}, { getRoomCam
|
||||
});
|
||||
if (!cameraEntries.length) throw new Error('No camera frames available yet');
|
||||
|
||||
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'rover-replay-'));
|
||||
/*
|
||||
Hundreds of frame images can be produced for one room-camera replay. They
|
||||
are temporary, but the Node application owns them, so both the workspace
|
||||
and final intermediate video stay under the configured data root until the
|
||||
existing finally block removes them.
|
||||
*/
|
||||
const buildRoot = resolveRuntimePath('room-camera-replay-builds');
|
||||
await fsp.mkdir(buildRoot, { recursive: true });
|
||||
const tmpDir = await fsp.mkdtemp(path.join(buildRoot, 'build-'));
|
||||
try {
|
||||
const firstFramePaths = [];
|
||||
for (let i = 0; i < cameraEntries.length; i += 1) {
|
||||
|
||||
@@ -5,8 +5,9 @@ const EventEmitter = require('events');
|
||||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
const logger = require('../../globals/logger').child('roverSnapshot');
|
||||
const { resolveRoverSnapshotDir } = require('../../helpers/dataPaths');
|
||||
|
||||
const SNAPSHOT_DIR = process.env.ROVER_SNAPSHOT_DIR || '/var/lib/rover-snapshots';
|
||||
const SNAPSHOT_DIR = resolveRoverSnapshotDir();
|
||||
const POLL_INTERVAL_MS = 300;
|
||||
const roverState = new Map();
|
||||
const events = new EventEmitter();
|
||||
|
||||
Reference in New Issue
Block a user