mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-14 10:42:45 -05:00
This commit is contained in:
@@ -26,6 +26,8 @@ Phase 1 must be complete and verified before Phase 2 begins. Containerization mu
|
||||
|
||||
## Decision log
|
||||
|
||||
- 2026-10-02: The admin container controls accept a tag within the repository supplied by Compose through `MULTIROVER_TARGET_IMAGE`. A successful healthy replacement saves the tag in the lifecycle status volume; failed replacements retain the previous selection. Checks and later updates follow that saved tag. Changing repositories in Compose resets the selection. Manual Compose deployments still use the Compose image and can replace a UI-selected image; the UI displays both references. The lifecycle helper itself is updated through Compose. Image rollback does not revert application data migrations. Deploy this feature to both the application and lifecycle services before using tag selection.
|
||||
|
||||
- 2026-09-14: Publish `ghcr.io/legop3/multiroombarover:latest` only from the repository's main branch. Every other repository branch publishes one moving development image named for that branch, with invalid tag separators normalized; these are development selectors rather than numbered releases. Pull requests only verify that the image builds. There are no release numbers, semantic-version tags, stable/edge channels, or operator-facing version selection. Docker image digests remain an internal mechanism for detecting an available update and retaining the previously running image for rollback.
|
||||
- 2026-09-15: Support only `linux/amd64` for the central server image. Rover computers remain independently ARM-capable, but publishing an untested ARM server image would multiply native-worker, media-binary, TTS-library, and hardware validation without serving the current deployment. ARM server support can be added later when a real ARM server exists to verify it.
|
||||
- 2026-09-15: Mount the application data directory from the Docker-managed `multirover-data` named volume instead of a host bind path. Docker initializes the empty volume with the image's non-root ownership, eliminating host UID matching, directory creation, ownership commands, and root application startup. The admin backup/restore system is the supported portable interface to the complete data tree.
|
||||
|
||||
@@ -64,6 +64,7 @@ socket.on('connect', async () => {
|
||||
// previously stored session with this object instead of merging snapshots.
|
||||
socket.on('session:sync', (session) => {
|
||||
console.log('Session:', session);
|
||||
console.log('Server mode:', session.mode);
|
||||
});
|
||||
|
||||
// chat:init contains the recent chat history available when the bot connects.
|
||||
@@ -105,6 +106,21 @@ Run it with:
|
||||
node bot.js
|
||||
```
|
||||
|
||||
## Reading server mode
|
||||
|
||||
Read `session.mode` from the `session:sync` snapshot to get the current server
|
||||
mode. Its possible values are `open`, `turns`, `admin`, and `lockdown`.
|
||||
|
||||
The server sends a fresh session snapshot whenever the mode changes, so the
|
||||
example's handler receives both the initial mode and subsequent updates. No
|
||||
polling or separate mode listener is needed. If your bot stores session data for
|
||||
its chat-handling logic, replace the stored session on every `session:sync` and
|
||||
read the mode from that latest snapshot.
|
||||
|
||||
`session.mode` is the server-wide mode; `session.role` is this connection's role
|
||||
(such as `spectator`), and `session.operatingMode` is a separate connection field.
|
||||
Use `session.mode` when answering chat questions about the server's current mode.
|
||||
|
||||
## Events used
|
||||
|
||||
- `nickname:set` sets the bot's visible nickname.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Restricted Container Lifecycle Controller
|
||||
// Purpose: Pulls and replaces only the fixed MultiRover application container through Docker's local API.
|
||||
// Scope: Runs as the private lifecycle Compose service; it exposes no TCP listener and accepts no caller-selected targets.
|
||||
// Scope: Runs as the private lifecycle Compose service; it exposes no TCP listener and accepts tags only within the deployment-configured repository.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
@@ -9,6 +9,12 @@ const Docker = require('dockerode');
|
||||
const docker = new Docker({ socketPath: '/var/run/docker.sock' });
|
||||
const TARGET_CONTAINER_NAME = 'multirover';
|
||||
const TARGET_IMAGE = process.env.MULTIROVER_TARGET_IMAGE || 'ghcr.io/legop3/multiroombarover:latest';
|
||||
// A registry port is not a tag separator. Strip a digest before splitting the tag.
|
||||
const configuredName = TARGET_IMAGE.split('@')[0];
|
||||
const tagSeparator = configuredName.lastIndexOf(':');
|
||||
const hasConfiguredTag = tagSeparator > configuredName.lastIndexOf('/');
|
||||
const IMAGE_REPOSITORY = hasConfiguredTag ? configuredName.slice(0, tagSeparator) : configuredName;
|
||||
const DEFAULT_TAG = TARGET_IMAGE.includes('@') ? null : (hasConfiguredTag ? configuredName.slice(tagSeparator + 1) : 'latest');
|
||||
const SOCKET_PATH = process.env.MULTIROVER_LIFECYCLE_SOCKET || '/run/multirover/lifecycle.sock';
|
||||
// Status belongs beside the controller's private socket instead of in the
|
||||
// application's data volume. The controller runs as root for Docker access;
|
||||
@@ -20,6 +26,32 @@ const HEALTH_POLL_MS = 1000;
|
||||
|
||||
let operationRunning = false;
|
||||
let status = readStatus();
|
||||
// A repository change in Compose invalidates the previous repository's selection.
|
||||
if (status.selectedRepository !== IMAGE_REPOSITORY) {
|
||||
status.selectedRepository = IMAGE_REPOSITORY;
|
||||
status.selectedTag = DEFAULT_TAG;
|
||||
}
|
||||
|
||||
function selectedImage() {
|
||||
return status.selectedTag ? `${IMAGE_REPOSITORY}:${status.selectedTag}` : TARGET_IMAGE;
|
||||
}
|
||||
|
||||
function publicStatus() {
|
||||
return {
|
||||
...status,
|
||||
configuredImage: TARGET_IMAGE,
|
||||
repository: IMAGE_REPOSITORY,
|
||||
targetImage: selectedImage(),
|
||||
};
|
||||
}
|
||||
|
||||
function requestedImage(tag) {
|
||||
if (tag === null) return { imageReference: selectedImage(), tag: status.selectedTag };
|
||||
if (!/^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}$/.test(tag)) {
|
||||
throw new Error('Enter a valid container tag (1–128 letters, numbers, underscores, periods, or hyphens).');
|
||||
}
|
||||
return { imageReference: `${IMAGE_REPOSITORY}:${tag}`, tag };
|
||||
}
|
||||
|
||||
function readStatus() {
|
||||
try {
|
||||
@@ -52,11 +84,12 @@ function readStatus() {
|
||||
}
|
||||
|
||||
function writeStatus(patch) {
|
||||
status = { ...status, ...patch };
|
||||
const nextStatus = { ...status, ...patch };
|
||||
fs.mkdirSync(path.dirname(STATUS_PATH), { recursive: true });
|
||||
const temporaryPath = `${STATUS_PATH}.tmp`;
|
||||
fs.writeFileSync(temporaryPath, `${JSON.stringify(status, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
||||
fs.writeFileSync(temporaryPath, `${JSON.stringify(nextStatus, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
||||
fs.renameSync(temporaryPath, STATUS_PATH);
|
||||
status = nextStatus;
|
||||
return status;
|
||||
}
|
||||
|
||||
@@ -68,23 +101,23 @@ async function inspectTargetContainer() {
|
||||
return docker.getContainer(TARGET_CONTAINER_NAME).inspect();
|
||||
}
|
||||
|
||||
async function inspectLocalTargetImage() {
|
||||
async function inspectLocalTargetImage(imageReference = selectedImage()) {
|
||||
try {
|
||||
return await docker.getImage(TARGET_IMAGE).inspect();
|
||||
return await docker.getImage(imageReference).inspect();
|
||||
} catch (error) {
|
||||
if (error.statusCode === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function pullTargetImage() {
|
||||
async function pullTargetImage(imageReference = selectedImage()) {
|
||||
const stream = await new Promise((resolve, reject) => {
|
||||
docker.pull(TARGET_IMAGE, (error, pullStream) => (error ? reject(error) : resolve(pullStream)));
|
||||
docker.pull(imageReference, (error, pullStream) => (error ? reject(error) : resolve(pullStream)));
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
docker.modem.followProgress(stream, (error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
const image = await inspectLocalTargetImage();
|
||||
const image = await inspectLocalTargetImage(imageReference);
|
||||
if (!image) throw new Error('Docker finished pulling but the target image is unavailable.');
|
||||
return image;
|
||||
}
|
||||
@@ -193,13 +226,14 @@ async function refreshImageStatus() {
|
||||
]);
|
||||
return writeStatus({
|
||||
runningImage: container.Image,
|
||||
runningImageReference: container.Config?.Image || null,
|
||||
availableImage: image?.Id || null,
|
||||
updateAvailable: image ? container.Image !== image.Id : null,
|
||||
});
|
||||
}
|
||||
|
||||
async function runCheck() {
|
||||
writeStatus({ message: `Pulling ${TARGET_IMAGE}…` });
|
||||
writeStatus({ message: `Pulling ${selectedImage()}…` });
|
||||
await pullTargetImage();
|
||||
const current = await refreshImageStatus();
|
||||
return current.updateAvailable ? 'An application update is available.' : 'The application is current.';
|
||||
@@ -213,9 +247,9 @@ async function runRestart() {
|
||||
return 'The application container restarted successfully.';
|
||||
}
|
||||
|
||||
async function runUpdate() {
|
||||
writeStatus({ message: `Pulling ${TARGET_IMAGE}…`, rollback: null });
|
||||
const targetImage = await pullTargetImage();
|
||||
async function runUpdate({ imageReference, tag }) {
|
||||
writeStatus({ message: `Pulling ${imageReference}…`, rollback: null });
|
||||
const targetImage = await pullTargetImage(imageReference);
|
||||
const previousContainer = await inspectTargetContainer();
|
||||
const previousImage = previousContainer.Image;
|
||||
let replacementStarted = false;
|
||||
@@ -228,11 +262,15 @@ async function runUpdate() {
|
||||
updateAvailable: previousImage !== targetImage.Id,
|
||||
});
|
||||
replacementStarted = true;
|
||||
await replaceContainer({ template: previousContainer, imageReference: TARGET_IMAGE });
|
||||
await replaceContainer({ template: previousContainer, imageReference: targetImage.Id });
|
||||
writeStatus({ message: 'Waiting for the updated application to become healthy…' });
|
||||
const healthy = await waitForHealthyContainer();
|
||||
writeStatus({
|
||||
// Commit the tag only once the replacement passes its health check.
|
||||
selectedRepository: IMAGE_REPOSITORY,
|
||||
selectedTag: tag,
|
||||
runningImage: healthy.Image,
|
||||
runningImageReference: healthy.Config?.Image || null,
|
||||
availableImage: targetImage.Id,
|
||||
updateAvailable: false,
|
||||
});
|
||||
@@ -284,7 +322,7 @@ function queueOperation(operation, runner) {
|
||||
operationRunning = false;
|
||||
}
|
||||
}, 500);
|
||||
return status;
|
||||
return publicStatus();
|
||||
}
|
||||
|
||||
function sendJson(response, statusCode, value) {
|
||||
@@ -294,10 +332,11 @@ function sendJson(response, statusCode, value) {
|
||||
|
||||
const server = http.createServer((request, response) => {
|
||||
try {
|
||||
const url = new URL(request.url, 'http://localhost');
|
||||
if (request.method === 'GET' && request.url === '/status') {
|
||||
refreshImageStatus()
|
||||
.catch(() => status)
|
||||
.then((current) => sendJson(response, 200, { available: true, ...current }));
|
||||
.then(() => sendJson(response, 200, { available: true, ...publicStatus() }));
|
||||
return;
|
||||
}
|
||||
if (request.method === 'POST' && request.url === '/check') {
|
||||
@@ -308,8 +347,10 @@ const server = http.createServer((request, response) => {
|
||||
sendJson(response, 202, { available: true, ...queueOperation('restart', runRestart) });
|
||||
return;
|
||||
}
|
||||
if (request.method === 'POST' && request.url === '/update') {
|
||||
sendJson(response, 202, { available: true, ...queueOperation('update', runUpdate) });
|
||||
if (request.method === 'POST' && url.pathname === '/update') {
|
||||
const target = requestedImage(url.searchParams.get('tag'));
|
||||
queueOperation('update', () => runUpdate(target));
|
||||
sendJson(response, 202, { available: true, ...publicStatus() });
|
||||
return;
|
||||
}
|
||||
sendJson(response, 404, { error: 'Lifecycle operation not found.' });
|
||||
|
||||
@@ -88,14 +88,14 @@ io.on('connection', (socket) => {
|
||||
.catch((error) => cb({ error: error.message, code: error.code || null }));
|
||||
});
|
||||
|
||||
socket.on('server:updateApplication', (_payload = {}, cb = () => {}) => {
|
||||
socket.on('server:updateApplication', (payload = {}, cb = () => {}) => {
|
||||
Promise.resolve()
|
||||
.then(() => requireRecentPassword(socket))
|
||||
.then(() => lifecycleClient.updateApplication())
|
||||
.then(() => lifecycleClient.updateApplication(payload?.tag))
|
||||
.then((lifecycle) => {
|
||||
// Wait for controller acceptance so rejected updates do not kick users.
|
||||
notifyDriversOfRestart(actorFor(socket));
|
||||
database.recordAuditEvent(actorFor(socket), 'application.update-requested', {});
|
||||
database.recordAuditEvent(actorFor(socket), 'application.update-requested', { tag: payload?.tag ?? lifecycle.selectedTag });
|
||||
io.emit('server:restarting', { reason: 'application-update' });
|
||||
cb({ success: true, lifecycle });
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Container Lifecycle Client
|
||||
// Purpose: Sends fixed lifecycle requests from the application to the private controller socket.
|
||||
// Scope: This client cannot select images, containers, or Docker arguments; the controller owns those constants.
|
||||
// Scope: This client can request a tag; the controller owns the repository, container, and Docker arguments.
|
||||
const http = require('http');
|
||||
|
||||
const LIFECYCLE_SOCKET_PATH = process.env.MULTIROVER_LIFECYCLE_SOCKET || '/run/multirover/lifecycle.sock';
|
||||
@@ -68,5 +68,11 @@ module.exports = {
|
||||
checkForUpdate: () => requestLifecycleController({ method: 'POST', path: '/check' }),
|
||||
getLifecycleStatus,
|
||||
restartApplication: () => requestLifecycleController({ method: 'POST', path: '/restart' }),
|
||||
updateApplication: () => requestLifecycleController({ method: 'POST', path: '/update' }),
|
||||
updateApplication: (tag) => {
|
||||
if (tag !== undefined && typeof tag !== 'string') throw new Error('Container tag must be a string.');
|
||||
return requestLifecycleController({
|
||||
method: 'POST',
|
||||
path: tag === undefined ? '/update' : `/update?tag=${encodeURIComponent(tag)}`,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -28,7 +28,7 @@ export const deleteAdministrator = (socket, id) => emitAdminRequest(socket, 'adm
|
||||
export const restartApplication = (socket) => emitAdminRequest(socket, 'server:restartApplication');
|
||||
export const getApplicationLifecycleStatus = (socket) => emitAdminRequest(socket, 'server:lifecycleStatus');
|
||||
export const checkForApplicationUpdate = (socket) => emitAdminRequest(socket, 'server:checkForUpdate');
|
||||
export const updateApplication = (socket) => emitAdminRequest(socket, 'server:updateApplication');
|
||||
export const updateApplication = (socket, tag) => emitAdminRequest(socket, 'server:updateApplication', { tag });
|
||||
export const getBackupRestoreStatus = (socket) => emitAdminRequest(socket, 'backupRestore:status');
|
||||
export const createFullBackup = (socket) => emitAdminRequest(socket, 'backupRestore:createBackup');
|
||||
export const createRestoreUpload = (socket) => emitAdminRequest(socket, 'backupRestore:createRestoreUpload');
|
||||
|
||||
@@ -21,6 +21,7 @@ export default function AdminOverview({ snapshot, socket, runSensitive, onSnapsh
|
||||
const [restartRequested, setRestartRequested] = useState(false);
|
||||
const [lifecycle, setLifecycle] = useState(null);
|
||||
const [lifecycleError, setLifecycleError] = useState('');
|
||||
const [containerTag, setContainerTag] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
@@ -90,10 +91,13 @@ export default function AdminOverview({ snapshot, socket, runSensitive, onSnapsh
|
||||
}
|
||||
|
||||
async function update() {
|
||||
if (!window.confirm('Pull the configured MultiRover image and replace the application container now? The page will reconnect automatically.')) return;
|
||||
const tag = containerTag === null ? undefined : containerTag.trim();
|
||||
const target = tag === undefined ? lifecycle.targetImage : `${lifecycle.repository}:${tag}`;
|
||||
if (!window.confirm(`Pull ${target} and replace the application container now? The page will reconnect automatically.`)) return;
|
||||
try {
|
||||
const response = await runSensitive(() => updateApplication(socket));
|
||||
const response = await runSensitive(() => updateApplication(socket, tag));
|
||||
setLifecycle(response.lifecycle);
|
||||
setContainerTag(null);
|
||||
} catch (error) {
|
||||
setLifecycleError(error.message);
|
||||
}
|
||||
@@ -121,15 +125,36 @@ export default function AdminOverview({ snapshot, socket, runSensitive, onSnapsh
|
||||
<CardFrame title="Application container" meta={lifecycleAvailable ? lifecycle?.state : 'controller unavailable'} bodyClassName="space-y-1 p-1 text-sm">
|
||||
<div className="surface max-w-4xl space-y-0.5 p-1 text-slate-300">
|
||||
<p>{lifecycleAvailable ? updateSummary : 'Container updates are unavailable until the lifecycle service is running.'}</p>
|
||||
{lifecycleAvailable ? (
|
||||
<div className="space-y-0.5 break-all">
|
||||
<p>Repository: {lifecycle.repository}</p>
|
||||
<p>Selected image: {lifecycle.targetImage}</p>
|
||||
<p>Running image: {lifecycle.runningImage}</p>
|
||||
<p>Compose image: {lifecycle.configuredImage}</p>
|
||||
{lifecycle.targetImage !== lifecycle.configuredImage ? <p className="text-amber-300">A manual Compose deployment may restore the Compose image.</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{lifecycle?.message ? <p className="text-slate-400">{lifecycle.message}</p> : null}
|
||||
{lifecycle?.rollback ? <p>Rollback: {lifecycle.rollback.status}{lifecycle.rollback.error ? ` — ${lifecycle.rollback.error}` : ''}</p> : null}
|
||||
{lifecycleError ? <p className="text-red-300">{lifecycleError}</p> : null}
|
||||
</div>
|
||||
<label className="flex max-w-md flex-col gap-0.5">
|
||||
<span>Container tag</span>
|
||||
<input
|
||||
className="rounded border border-slate-600 bg-slate-900 px-2 py-1 text-slate-100"
|
||||
value={containerTag ?? lifecycle?.selectedTag ?? ''}
|
||||
placeholder="Enter a tag"
|
||||
maxLength={128}
|
||||
disabled={!lifecycleAvailable || lifecycleBusy}
|
||||
onChange={(event) => setContainerTag(event.target.value)}
|
||||
/>
|
||||
<span className="text-xs text-slate-400">Update and restart applies this tag. Update checks use the saved selected image.</span>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-0.5">
|
||||
<button type="button" className="button-dark" disabled={!lifecycleAvailable || lifecycleBusy} onClick={checkForUpdate}>
|
||||
{lifecycle?.operation === 'check' && lifecycleBusy ? 'Checking for update…' : 'Check for update'}
|
||||
</button>
|
||||
<button type="button" className="button-danger" disabled={!lifecycleAvailable || lifecycleBusy} onClick={update}>
|
||||
<button type="button" className="button-danger" disabled={!lifecycleAvailable || lifecycleBusy || (containerTag !== null && !/^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}$/.test(containerTag.trim()))} onClick={update}>
|
||||
{lifecycle?.operation === 'update' && lifecycleBusy ? 'Updating application…' : 'Update and restart'}
|
||||
</button>
|
||||
<button type="button" className="button-danger" disabled={restartRequested || lifecycleBusy} onClick={restart}>
|
||||
|
||||
Reference in New Issue
Block a user