mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
container manager update and restart stuffs
This commit is contained in:
+2
-2
@@ -3,8 +3,8 @@
|
|||||||
# MultiRover Production Application Image
|
# MultiRover Production Application Image
|
||||||
# Purpose: Builds the web application, Node dependencies, native hardware
|
# Purpose: Builds the web application, Node dependencies, native hardware
|
||||||
# workers, and pinned runtime tools into one amd64 server image.
|
# workers, and pinned runtime tools into one amd64 server image.
|
||||||
# Scope: Packages the main application only. Compose, host hardware access, and
|
# Scope: Packages the application and its private controller command in one
|
||||||
# lifecycle/update control remain separate deployment concerns.
|
# image. Compose still isolates their processes, mounts, and privileges.
|
||||||
|
|
||||||
ARG FEDORA_VERSION=43
|
ARG FEDORA_VERSION=43
|
||||||
|
|
||||||
|
|||||||
+47
-5
@@ -1,14 +1,17 @@
|
|||||||
# MultiRover production deployment
|
# MultiRover production deployment
|
||||||
#
|
#
|
||||||
# This intentionally contains only the application container. The later update
|
# The public application and private lifecycle controller reuse one published
|
||||||
# controller will be added as a second service when it exists; keeping it out
|
# image. Only the lifecycle service receives Docker control; the application
|
||||||
# now means this first deployment pass has no placeholder process or unused
|
# retains the narrower hardware access it needs at runtime.
|
||||||
# privilege.
|
|
||||||
name: multirover
|
name: multirover
|
||||||
|
|
||||||
|
# This is the deployment's single image selector. Production leaves it on
|
||||||
|
# latest; a development deployment can change this one line to its branch tag.
|
||||||
|
x-multirover-image: &multirover-image ghcr.io/legop3/multiroombarover:latest
|
||||||
|
|
||||||
services:
|
services:
|
||||||
server:
|
server:
|
||||||
image: ghcr.io/legop3/multiroombarover:latest
|
image: *multirover-image
|
||||||
container_name: multirover
|
container_name: multirover
|
||||||
|
|
||||||
# Host networking preserves the server's existing LAN behavior and avoids
|
# Host networking preserves the server's existing LAN behavior and avoids
|
||||||
@@ -31,6 +34,10 @@ services:
|
|||||||
# account's numeric UID while keeping every persistent file together.
|
# account's numeric UID while keeping every persistent file together.
|
||||||
- data:/data
|
- data:/data
|
||||||
|
|
||||||
|
# The application can ask the private controller for one of its fixed
|
||||||
|
# lifecycle operations, but it never receives the Docker socket itself.
|
||||||
|
- lifecycle-socket:/run/multirover
|
||||||
|
|
||||||
# bluetoothctl talks to the host Bluetooth daemon over this socket. The
|
# bluetoothctl talks to the host Bluetooth daemon over this socket. The
|
||||||
# socket is read-only as a filesystem mount; D-Bus method calls still flow
|
# socket is read-only as a filesystem mount; D-Bus method calls still flow
|
||||||
# through it normally without exposing the rest of the host D-Bus tree.
|
# through it normally without exposing the rest of the host D-Bus tree.
|
||||||
@@ -48,8 +55,43 @@ services:
|
|||||||
cap_add:
|
cap_add:
|
||||||
- NET_ADMIN
|
- NET_ADMIN
|
||||||
|
|
||||||
|
lifecycle:
|
||||||
|
# Reuse the application image and override only its command. This keeps
|
||||||
|
# GHCR publishing and host deployment limited to one image and one Compose
|
||||||
|
# file while the Docker-authorized process remains isolated from the app.
|
||||||
|
image: *multirover-image
|
||||||
|
container_name: multirover-lifecycle
|
||||||
|
command: ["node", "src/services/serverControlService/controller.js"]
|
||||||
|
user: root
|
||||||
|
network_mode: none
|
||||||
|
restart: unless-stopped
|
||||||
|
# The shared image's health check targets the application HTTP server. This
|
||||||
|
# controller intentionally has no TCP listener, so it must not inherit it.
|
||||||
|
healthcheck:
|
||||||
|
disable: true
|
||||||
|
security_opt:
|
||||||
|
- label=disable
|
||||||
|
environment:
|
||||||
|
# Requests cannot override this deployment-owned target. Reusing the
|
||||||
|
# anchor guarantees the controller pulls the image used by the server.
|
||||||
|
MULTIROVER_TARGET_IMAGE: *multirover-image
|
||||||
|
volumes:
|
||||||
|
# Status survives replacement of the application and is kept under its
|
||||||
|
# existing single data boundary. The transient socket has its own volume
|
||||||
|
# so special filesystem entries never enter full-data backups.
|
||||||
|
- data:/data
|
||||||
|
- lifecycle-socket:/run/multirover
|
||||||
|
|
||||||
|
# This is host-root-equivalent access and therefore exists only here,
|
||||||
|
# never in the public-facing application container.
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
# Use an explicit name so the same data volume survives Compose project moves
|
# Use an explicit name so the same data volume survives Compose project moves
|
||||||
# or directory renames. Ordinary `docker compose down` does not remove it.
|
# or directory renames. Ordinary `docker compose down` does not remove it.
|
||||||
data:
|
data:
|
||||||
name: multirover-data
|
name: multirover-data
|
||||||
|
# This volume carries only the private Unix socket and contains no durable
|
||||||
|
# application state. Docker creates it automatically with the deployment.
|
||||||
|
lifecycle-socket:
|
||||||
|
name: multirover-lifecycle-socket
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ This document is the live implementation tracker for the migration.
|
|||||||
- [x] Phase 2, step 10: Build and locally verify the production application image
|
- [x] Phase 2, step 10: Build and locally verify the production application image
|
||||||
- [x] Phase 2, steps 11-12: Add the single-container Compose deployment and locally verify its host-access contract
|
- [x] Phase 2, steps 11-12: Add the single-container Compose deployment and locally verify its host-access contract
|
||||||
- [x] Phase 2, step 13: Add application and container health checks
|
- [x] Phase 2, step 13: Add application and container health checks
|
||||||
|
- [x] Phase 2, step 14: Add the restricted lifecycle container and connect the System UI
|
||||||
- [x] Phase 2, step 15: Build pull requests and publish the main branch to the single GHCR `latest` channel
|
- [x] Phase 2, step 15: Build pull requests and publish the main branch to the single GHCR `latest` channel
|
||||||
- [ ] Phase 2: Containerization, GHCR publishing, and container lifecycle controls
|
- [ ] Phase 2: Containerization, GHCR publishing, and container lifecycle controls
|
||||||
|
|
||||||
@@ -685,6 +686,17 @@ These operations require a lockdown administrator and recent password confirmati
|
|||||||
|
|
||||||
The Compose contract should remain stable so ordinary main-branch image updates replace only the application image. Updating the lifecycle component or changing host mounts/capabilities is a separate, rarer deployment-format update and must not be disguised as an ordinary application update.
|
The Compose contract should remain stable so ordinary main-branch image updates replace only the application image. Updating the lifecycle component or changing host mounts/capabilities is a separate, rarer deployment-format update and must not be disguised as an ordinary application update.
|
||||||
|
|
||||||
|
### Lifecycle-controller implementation notes
|
||||||
|
|
||||||
|
Implemented on 2026-09-15:
|
||||||
|
|
||||||
|
- Reused the single published application image for the lifecycle service with a controller-only command. This avoids a second Dockerfile, image name, GHCR workflow, and release lifecycle while the two containers still run separate processes with separate privileges.
|
||||||
|
- Mounted `/var/run/docker.sock` only in the network-disabled lifecycle container. The application communicates through a dedicated Unix-socket volume and cannot submit an image name, container name, command, or Docker option; the controller operates only on the fixed `multirover` container and the deployment-selected MultiRover image.
|
||||||
|
- Added fixed status, update-check, restart, and update operations. Update checks pull the configured moving image and compare Docker image IDs. Updates retain the previous image ID, recreate the application with its existing Compose host contract, wait for the image health check, and restore the previous image when replacement health fails.
|
||||||
|
- Persisted the current operation and result under `data/runtime/container-lifecycle`. This survives ordinary application replacement and browser reconnection but remains excluded from full backups as disposable controller state.
|
||||||
|
- Connected the existing lockdown-administrator password confirmation and audit history to the lifecycle operations. The Administration overview polls persisted progress, reports update and rollback results, and keeps the legacy process-level restart only when no controller socket exists.
|
||||||
|
- Defined the deployment image once through a Compose YAML anchor. Both services and the controller target reuse that exact value, so production stays on `ghcr.io/legop3/multiroombarover:latest` and development requires changing only the single visible selector line to a branch tag.
|
||||||
|
|
||||||
## 15. GHCR publishing automation
|
## 15. GHCR publishing automation
|
||||||
|
|
||||||
Add repository automation that:
|
Add repository automation that:
|
||||||
@@ -763,7 +775,7 @@ Within the two hard phase boundaries, the safest order is:
|
|||||||
- [x] Build and verify the production application image.
|
- [x] Build and verify the production application image.
|
||||||
- [x] Add Compose, data mounting, networking, and hardware access.
|
- [x] Add Compose, data mounting, networking, and hardware access.
|
||||||
- [x] Add GHCR build and publication automation.
|
- [x] Add GHCR build and publication automation.
|
||||||
- [ ] Add the restricted lifecycle container and connect the System UI.
|
- [x] Add the restricted lifecycle container and connect the System UI.
|
||||||
- [ ] Test update, rollback, backup restore, and hardware on the actual server.
|
- [ ] Test update, rollback, backup restore, and hardware on the actual server.
|
||||||
- [ ] Perform the final systemd-to-Compose cutover.
|
- [ ] Perform the final systemd-to-Compose cutover.
|
||||||
|
|
||||||
|
|||||||
Generated
+363
@@ -13,6 +13,7 @@
|
|||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^12.11.1",
|
||||||
"discord.js": "^14.25.1",
|
"discord.js": "^14.25.1",
|
||||||
|
"dockerode": "^5.0.1",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"fuse.js": "^7.4.2",
|
"fuse.js": "^7.4.2",
|
||||||
"home-assistant-js-websocket": "^3.1.2",
|
"home-assistant-js-websocket": "^3.1.2",
|
||||||
@@ -35,6 +36,12 @@
|
|||||||
"nodemon": "^3.1.0"
|
"nodemon": "^3.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@balena/dockerignore": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/@discordjs/builders": {
|
"node_modules/@discordjs/builders": {
|
||||||
"version": "1.14.1",
|
"version": "1.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz",
|
||||||
@@ -185,6 +192,55 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@grpc/grpc-js": {
|
||||||
|
"version": "1.14.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz",
|
||||||
|
"integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@grpc/proto-loader": "^0.8.0",
|
||||||
|
"@js-sdsl/ordered-map": "^4.4.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": {
|
||||||
|
"version": "0.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz",
|
||||||
|
"integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash.camelcase": "^4.3.0",
|
||||||
|
"long": "^5.0.0",
|
||||||
|
"protobufjs": "^7.5.5",
|
||||||
|
"yargs": "^17.7.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"proto-loader-gen-types": "build/bin/proto-loader-gen-types.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@grpc/proto-loader": {
|
||||||
|
"version": "0.7.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz",
|
||||||
|
"integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash.camelcase": "^4.3.0",
|
||||||
|
"long": "^5.0.0",
|
||||||
|
"protobufjs": "^7.2.5",
|
||||||
|
"yargs": "^17.7.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"proto-loader-gen-types": "build/bin/proto-loader-gen-types.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@huggingface/jinja": {
|
"node_modules/@huggingface/jinja": {
|
||||||
"version": "0.5.9",
|
"version": "0.5.9",
|
||||||
"resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.9.tgz",
|
"resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.9.tgz",
|
||||||
@@ -1088,6 +1144,16 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@js-sdsl/ordered-map": {
|
||||||
|
"version": "4.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz",
|
||||||
|
"integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/js-sdsl"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@protobufjs/aspromise": {
|
"node_modules/@protobufjs/aspromise": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
||||||
@@ -1276,6 +1342,30 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ansi-styles": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/anymatch": {
|
"node_modules/anymatch": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||||
@@ -1302,6 +1392,15 @@
|
|||||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/asn1": {
|
||||||
|
"version": "0.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
|
||||||
|
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": "~2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||||
@@ -1373,6 +1472,15 @@
|
|||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bcrypt-pbkdf": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"tweetnacl": "^0.14.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/better-sqlite3": {
|
"node_modules/better-sqlite3": {
|
||||||
"version": "12.11.1",
|
"version": "12.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz",
|
||||||
@@ -1500,6 +1608,15 @@
|
|||||||
"ieee754": "^1.1.13"
|
"ieee754": "^1.1.13"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buildcheck": {
|
||||||
|
"version": "0.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
|
||||||
|
"integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bytes": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -1572,6 +1689,20 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cliui": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"strip-ansi": "^6.0.1",
|
||||||
|
"wrap-ansi": "^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color": {
|
"node_modules/color": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||||
@@ -1666,6 +1797,20 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cpu-features": {
|
||||||
|
"version": "0.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
|
||||||
|
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"buildcheck": "~0.0.6",
|
||||||
|
"nan": "^2.19.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "2.6.9",
|
"version": "2.6.9",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
@@ -1803,6 +1948,61 @@
|
|||||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/docker-modem": {
|
||||||
|
"version": "5.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.7.tgz",
|
||||||
|
"integrity": "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.1.1",
|
||||||
|
"readable-stream": "^3.5.0",
|
||||||
|
"split-ca": "^1.0.1",
|
||||||
|
"ssh2": "^1.15.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/docker-modem/node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/docker-modem/node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/dockerode": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dockerode/-/dockerode-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-avsq/xk4YPIrn0CgleX5bjT9Y8IT1p9PxrNQ++RBQ2WEyFfHCTDsT9kmyxz+H/axnjAwg8wJWEIuPGOUuNupiA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@balena/dockerignore": "^1.0.2",
|
||||||
|
"@grpc/grpc-js": "^1.11.1",
|
||||||
|
"@grpc/proto-loader": "^0.7.13",
|
||||||
|
"docker-modem": "^5.0.7",
|
||||||
|
"protobufjs": "^7.3.2",
|
||||||
|
"tar-fs": "^2.1.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -1823,6 +2023,12 @@
|
|||||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/encodeurl": {
|
"node_modules/encodeurl": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||||
@@ -1951,6 +2157,15 @@
|
|||||||
"integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
|
"integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/escalade": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/escape-html": {
|
"node_modules/escape-html": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
@@ -2184,6 +2399,15 @@
|
|||||||
"url": "https://github.com/sponsors/krisk"
|
"url": "https://github.com/sponsors/krisk"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-caller-file": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-intrinsic": {
|
"node_modules/get-intrinsic": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
@@ -2505,6 +2729,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-fullwidth-code-point": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-glob": {
|
"node_modules/is-glob": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||||
@@ -2585,6 +2818,12 @@
|
|||||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.camelcase": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.snakecase": {
|
"node_modules/lodash.snakecase": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz",
|
||||||
@@ -2796,6 +3035,13 @@
|
|||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/nan": {
|
||||||
|
"version": "2.29.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/nan/-/nan-2.29.0.tgz",
|
||||||
|
"integrity": "sha512-GlGk3HIvitbvs+LT3g6XUP1kpirKNvmDFwF/bmo6XNWSb/eYEs/O4bfgIEIXCZ+lIOTS5xNwDvSGMw6FJdAhtA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/napi-build-utils": {
|
"node_modules/napi-build-utils": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||||
@@ -3264,6 +3510,15 @@
|
|||||||
"node": ">=20.18.1"
|
"node": ">=20.18.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-directory": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/require-from-string": {
|
"node_modules/require-from-string": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
@@ -3724,12 +3979,35 @@
|
|||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/split-ca": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/sprintf-js": {
|
"node_modules/sprintf-js": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
|
||||||
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
|
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/ssh2": {
|
||||||
|
"version": "1.17.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
|
||||||
|
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"dependencies": {
|
||||||
|
"asn1": "^0.2.6",
|
||||||
|
"bcrypt-pbkdf": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.16.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"cpu-features": "~0.0.10",
|
||||||
|
"nan": "^2.23.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
@@ -3748,6 +4026,32 @@
|
|||||||
"safe-buffer": "~5.2.0"
|
"safe-buffer": "~5.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/strip-json-comments": {
|
"node_modules/strip-json-comments": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||||
@@ -3875,6 +4179,12 @@
|
|||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tweetnacl": {
|
||||||
|
"version": "0.14.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
|
||||||
|
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
|
||||||
|
"license": "Unlicense"
|
||||||
|
},
|
||||||
"node_modules/type-fest": {
|
"node_modules/type-fest": {
|
||||||
"version": "0.13.1",
|
"version": "0.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
|
||||||
@@ -3975,6 +4285,23 @@
|
|||||||
"integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==",
|
"integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/wrap-ansi": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/wrappy": {
|
"node_modules/wrappy": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
@@ -4024,6 +4351,15 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/y18n": {
|
||||||
|
"version": "5.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
|
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||||
@@ -4032,6 +4368,33 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs": {
|
||||||
|
"version": "17.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz",
|
||||||
|
"integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^8.0.1",
|
||||||
|
"escalade": "^3.1.1",
|
||||||
|
"get-caller-file": "^2.0.5",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"string-width": "^4.2.3",
|
||||||
|
"y18n": "^5.0.5",
|
||||||
|
"yargs-parser": "^21.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs-parser": {
|
||||||
|
"version": "21.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||||
|
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^12.11.1",
|
||||||
"discord.js": "^14.25.1",
|
"discord.js": "^14.25.1",
|
||||||
|
"dockerode": "^5.0.1",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"fuse.js": "^7.4.2",
|
"fuse.js": "^7.4.2",
|
||||||
"home-assistant-js-websocket": "^3.1.2",
|
"home-assistant-js-websocket": "^3.1.2",
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
// 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.
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const http = require('http');
|
||||||
|
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';
|
||||||
|
const SOCKET_PATH = process.env.MULTIROVER_LIFECYCLE_SOCKET || '/run/multirover/lifecycle.sock';
|
||||||
|
const STATUS_PATH = process.env.MULTIROVER_LIFECYCLE_STATUS || '/data/runtime/container-lifecycle/status.json';
|
||||||
|
const HEALTH_TIMEOUT_MS = 2 * 60 * 1000;
|
||||||
|
const HEALTH_POLL_MS = 1000;
|
||||||
|
|
||||||
|
let operationRunning = false;
|
||||||
|
let status = readStatus();
|
||||||
|
|
||||||
|
function readStatus() {
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(fs.readFileSync(STATUS_PATH, 'utf8'));
|
||||||
|
// A controller replacement cannot resume an in-flight Docker operation.
|
||||||
|
// Preserve its history but make the interrupted result explicit.
|
||||||
|
if (saved.state === 'running') {
|
||||||
|
return {
|
||||||
|
...saved,
|
||||||
|
state: 'failed',
|
||||||
|
message: 'The lifecycle controller restarted before the operation completed.',
|
||||||
|
completedAt: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== 'ENOENT') process.stderr.write(`Unable to read lifecycle status: ${error.message}\n`);
|
||||||
|
return {
|
||||||
|
state: 'idle',
|
||||||
|
operation: null,
|
||||||
|
message: 'No lifecycle operation has run yet.',
|
||||||
|
startedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
runningImage: null,
|
||||||
|
availableImage: null,
|
||||||
|
updateAvailable: null,
|
||||||
|
rollback: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStatus(patch) {
|
||||||
|
status = { ...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.renameSync(temporaryPath, STATUS_PATH);
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(milliseconds) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inspectTargetContainer() {
|
||||||
|
return docker.getContainer(TARGET_CONTAINER_NAME).inspect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inspectLocalTargetImage() {
|
||||||
|
try {
|
||||||
|
return await docker.getImage(TARGET_IMAGE).inspect();
|
||||||
|
} catch (error) {
|
||||||
|
if (error.statusCode === 404) return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pullTargetImage() {
|
||||||
|
const stream = await new Promise((resolve, reject) => {
|
||||||
|
docker.pull(TARGET_IMAGE, (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();
|
||||||
|
if (!image) throw new Error('Docker finished pulling but the target image is unavailable.');
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
function environmentMap(values = []) {
|
||||||
|
const result = new Map();
|
||||||
|
values.forEach((entry) => {
|
||||||
|
const separator = String(entry).indexOf('=');
|
||||||
|
const key = separator >= 0 ? entry.slice(0, separator) : entry;
|
||||||
|
result.set(key, String(entry));
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeEnvironment({ current = [], previousDefaults = [], nextDefaults = [] }) {
|
||||||
|
const previous = environmentMap(previousDefaults);
|
||||||
|
const overrides = current.filter((entry) => previous.get(String(entry).split('=', 1)[0]) !== entry);
|
||||||
|
const merged = environmentMap(nextDefaults);
|
||||||
|
overrides.forEach((entry) => merged.set(String(entry).split('=', 1)[0], entry));
|
||||||
|
return [...merged.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildReplacementOptions({ current, imageReference }) {
|
||||||
|
const [previousImage, nextImage] = await Promise.all([
|
||||||
|
docker.getImage(current.Image).inspect(),
|
||||||
|
docker.getImage(imageReference).inspect(),
|
||||||
|
]);
|
||||||
|
const imageConfig = nextImage.Config || {};
|
||||||
|
|
||||||
|
/*
|
||||||
|
Image-owned defaults come from the replacement image so updates can change
|
||||||
|
their command, health check, or runtime environment. Only environment
|
||||||
|
values that Compose overrode on the running container survive. HostConfig
|
||||||
|
is copied intact because it contains the named volume, host networking,
|
||||||
|
device access, capability, security, and restart-policy contract.
|
||||||
|
*/
|
||||||
|
return {
|
||||||
|
name: TARGET_CONTAINER_NAME,
|
||||||
|
Image: imageReference,
|
||||||
|
AttachStdin: false,
|
||||||
|
AttachStdout: false,
|
||||||
|
AttachStderr: false,
|
||||||
|
Tty: false,
|
||||||
|
OpenStdin: false,
|
||||||
|
StdinOnce: false,
|
||||||
|
Env: mergeEnvironment({
|
||||||
|
current: current.Config?.Env,
|
||||||
|
previousDefaults: previousImage.Config?.Env,
|
||||||
|
nextDefaults: imageConfig.Env,
|
||||||
|
}),
|
||||||
|
Cmd: imageConfig.Cmd,
|
||||||
|
Entrypoint: imageConfig.Entrypoint,
|
||||||
|
WorkingDir: imageConfig.WorkingDir,
|
||||||
|
User: imageConfig.User,
|
||||||
|
ExposedPorts: imageConfig.ExposedPorts,
|
||||||
|
Volumes: imageConfig.Volumes,
|
||||||
|
Healthcheck: imageConfig.Healthcheck,
|
||||||
|
StopSignal: imageConfig.StopSignal,
|
||||||
|
StopTimeout: imageConfig.StopTimeout,
|
||||||
|
Labels: { ...(imageConfig.Labels || {}), ...(current.Config?.Labels || {}) },
|
||||||
|
HostConfig: current.HostConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeContainerIfPresent() {
|
||||||
|
const container = docker.getContainer(TARGET_CONTAINER_NAME);
|
||||||
|
try {
|
||||||
|
const current = await container.inspect();
|
||||||
|
if (current.State?.Running) {
|
||||||
|
try {
|
||||||
|
await container.stop({ t: 20 });
|
||||||
|
} catch (error) {
|
||||||
|
if (error.statusCode !== 304) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await container.remove();
|
||||||
|
} catch (error) {
|
||||||
|
if (error.statusCode !== 404) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replaceContainer({ template, imageReference }) {
|
||||||
|
const options = await buildReplacementOptions({ current: template, imageReference });
|
||||||
|
await removeContainerIfPresent();
|
||||||
|
const replacement = await docker.createContainer(options);
|
||||||
|
await replacement.start();
|
||||||
|
return replacement;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForHealthyContainer() {
|
||||||
|
const deadline = Date.now() + HEALTH_TIMEOUT_MS;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const current = await inspectTargetContainer();
|
||||||
|
const health = current.State?.Health?.Status;
|
||||||
|
if (current.State?.Running && health === 'healthy') return current;
|
||||||
|
if (health === 'unhealthy') throw new Error('The replacement container failed its health check.');
|
||||||
|
await sleep(HEALTH_POLL_MS);
|
||||||
|
}
|
||||||
|
throw new Error('The replacement container did not become healthy in time.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshImageStatus() {
|
||||||
|
const [container, image] = await Promise.all([
|
||||||
|
inspectTargetContainer(),
|
||||||
|
inspectLocalTargetImage(),
|
||||||
|
]);
|
||||||
|
return writeStatus({
|
||||||
|
runningImage: container.Image,
|
||||||
|
availableImage: image?.Id || null,
|
||||||
|
updateAvailable: image ? container.Image !== image.Id : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCheck() {
|
||||||
|
writeStatus({ message: `Pulling ${TARGET_IMAGE}…` });
|
||||||
|
await pullTargetImage();
|
||||||
|
const current = await refreshImageStatus();
|
||||||
|
return current.updateAvailable ? 'An application update is available.' : 'The application is current.';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runRestart() {
|
||||||
|
writeStatus({ message: 'Restarting the application container…' });
|
||||||
|
await docker.getContainer(TARGET_CONTAINER_NAME).restart({ t: 20 });
|
||||||
|
await waitForHealthyContainer();
|
||||||
|
await refreshImageStatus();
|
||||||
|
return 'The application container restarted successfully.';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runUpdate() {
|
||||||
|
writeStatus({ message: `Pulling ${TARGET_IMAGE}…`, rollback: null });
|
||||||
|
const targetImage = await pullTargetImage();
|
||||||
|
const previousContainer = await inspectTargetContainer();
|
||||||
|
const previousImage = previousContainer.Image;
|
||||||
|
let replacementStarted = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeStatus({
|
||||||
|
message: 'Replacing the application container…',
|
||||||
|
runningImage: previousImage,
|
||||||
|
availableImage: targetImage.Id,
|
||||||
|
updateAvailable: previousImage !== targetImage.Id,
|
||||||
|
});
|
||||||
|
replacementStarted = true;
|
||||||
|
await replaceContainer({ template: previousContainer, imageReference: TARGET_IMAGE });
|
||||||
|
writeStatus({ message: 'Waiting for the updated application to become healthy…' });
|
||||||
|
const healthy = await waitForHealthyContainer();
|
||||||
|
writeStatus({
|
||||||
|
runningImage: healthy.Image,
|
||||||
|
availableImage: targetImage.Id,
|
||||||
|
updateAvailable: false,
|
||||||
|
});
|
||||||
|
return previousImage === targetImage.Id
|
||||||
|
? 'The current application image was restarted successfully.'
|
||||||
|
: 'The application was updated successfully.';
|
||||||
|
} catch (error) {
|
||||||
|
if (!replacementStarted) throw error;
|
||||||
|
writeStatus({ message: 'The update failed; restoring the previous application image…' });
|
||||||
|
try {
|
||||||
|
await replaceContainer({ template: previousContainer, imageReference: previousImage });
|
||||||
|
await waitForHealthyContainer();
|
||||||
|
writeStatus({
|
||||||
|
rollback: { status: 'succeeded', image: previousImage, completedAt: Date.now() },
|
||||||
|
runningImage: previousImage,
|
||||||
|
updateAvailable: true,
|
||||||
|
});
|
||||||
|
} catch (rollbackError) {
|
||||||
|
writeStatus({
|
||||||
|
rollback: { status: 'failed', image: previousImage, error: rollbackError.message, completedAt: Date.now() },
|
||||||
|
});
|
||||||
|
throw new Error(`${error.message} Rollback also failed: ${rollbackError.message}`);
|
||||||
|
}
|
||||||
|
throw new Error(`${error.message} The previous image was restored.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueOperation(operation, runner) {
|
||||||
|
if (operationRunning) throw new Error('Another lifecycle operation is already running.');
|
||||||
|
operationRunning = true;
|
||||||
|
writeStatus({
|
||||||
|
state: 'running',
|
||||||
|
operation,
|
||||||
|
message: `Starting ${operation}…`,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
completedAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Respond to the application before replacement disconnects its Socket.IO
|
||||||
|
// clients. The persisted status remains available when the new process asks
|
||||||
|
// for the result after reconnecting.
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const message = await runner();
|
||||||
|
writeStatus({ state: 'succeeded', message, completedAt: Date.now() });
|
||||||
|
} catch (error) {
|
||||||
|
writeStatus({ state: 'failed', message: error.message, completedAt: Date.now() });
|
||||||
|
} finally {
|
||||||
|
operationRunning = false;
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(response, statusCode, value) {
|
||||||
|
response.writeHead(statusCode, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||||
|
response.end(JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer((request, response) => {
|
||||||
|
try {
|
||||||
|
if (request.method === 'GET' && request.url === '/status') {
|
||||||
|
refreshImageStatus()
|
||||||
|
.catch(() => status)
|
||||||
|
.then((current) => sendJson(response, 200, { available: true, ...current }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.method === 'POST' && request.url === '/check') {
|
||||||
|
sendJson(response, 202, { available: true, ...queueOperation('check', runCheck) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.method === 'POST' && request.url === '/restart') {
|
||||||
|
sendJson(response, 202, { available: true, ...queueOperation('restart', runRestart) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.method === 'POST' && request.url === '/update') {
|
||||||
|
sendJson(response, 202, { available: true, ...queueOperation('update', runUpdate) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendJson(response, 404, { error: 'Lifecycle operation not found.' });
|
||||||
|
} catch (error) {
|
||||||
|
sendJson(response, 409, { error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(SOCKET_PATH), { recursive: true });
|
||||||
|
fs.rmSync(SOCKET_PATH, { force: true });
|
||||||
|
server.listen(SOCKET_PATH, () => {
|
||||||
|
// The application runs as uid 1000 while this Docker-authorized controller
|
||||||
|
// runs as root. The socket carries only the fixed API above, so permitting
|
||||||
|
// the application user to connect does not expose arbitrary Docker access.
|
||||||
|
fs.chmodSync(SOCKET_PATH, 0o666);
|
||||||
|
process.stdout.write(`Lifecycle controller listening on ${SOCKET_PATH}\n`);
|
||||||
|
});
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
server.close(() => {
|
||||||
|
fs.rmSync(SOCKET_PATH, { force: true });
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
process.once('SIGINT', stop);
|
||||||
|
process.once('SIGTERM', stop);
|
||||||
@@ -4,7 +4,8 @@
|
|||||||
const io = require('../../globals/io');
|
const io = require('../../globals/io');
|
||||||
const logger = require('../../globals/logger').child('serverControlService');
|
const logger = require('../../globals/logger').child('serverControlService');
|
||||||
const { getConfigurationDatabase } = require('../../configuration');
|
const { getConfigurationDatabase } = require('../../configuration');
|
||||||
const { requireRecentPassword } = require('../adminConfigurationService');
|
const { requireLockdownAdministrator, requireRecentPassword } = require('../adminConfigurationService');
|
||||||
|
const lifecycleClient = require('./lifecycleClient');
|
||||||
|
|
||||||
const database = getConfigurationDatabase();
|
const database = getConfigurationDatabase();
|
||||||
let restartPending = false;
|
let restartPending = false;
|
||||||
@@ -45,15 +46,60 @@ function requestApplicationRestart({ actor, reason = 'administrator-requested' }
|
|||||||
}
|
}
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
|
socket.on('server:lifecycleStatus', (_payload = {}, cb = () => {}) => {
|
||||||
|
Promise.resolve()
|
||||||
|
.then(() => requireLockdownAdministrator(socket))
|
||||||
|
.then(() => lifecycleClient.getLifecycleStatus())
|
||||||
|
.then((lifecycle) => cb({ success: true, lifecycle }))
|
||||||
|
.catch((error) => cb({ error: error.message, code: error.code || null }));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('server:checkForUpdate', (_payload = {}, cb = () => {}) => {
|
||||||
|
Promise.resolve()
|
||||||
|
.then(() => requireRecentPassword(socket))
|
||||||
|
.then(() => lifecycleClient.checkForUpdate())
|
||||||
|
.then((lifecycle) => {
|
||||||
|
database.recordAuditEvent(actorFor(socket), 'application.update-check-requested', {});
|
||||||
|
cb({ success: true, lifecycle });
|
||||||
|
})
|
||||||
|
.catch((error) => cb({ error: error.message, code: error.code || null }));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('server:updateApplication', (_payload = {}, cb = () => {}) => {
|
||||||
|
Promise.resolve()
|
||||||
|
.then(() => requireRecentPassword(socket))
|
||||||
|
.then(() => lifecycleClient.updateApplication())
|
||||||
|
.then((lifecycle) => {
|
||||||
|
database.recordAuditEvent(actorFor(socket), 'application.update-requested', {});
|
||||||
|
io.emit('server:restarting', { reason: 'application-update' });
|
||||||
|
cb({ success: true, lifecycle });
|
||||||
|
})
|
||||||
|
.catch((error) => cb({ error: error.message, code: error.code || null }));
|
||||||
|
});
|
||||||
|
|
||||||
socket.on('server:restartApplication', (_payload = {}, cb = () => {}) => {
|
socket.on('server:restartApplication', (_payload = {}, cb = () => {}) => {
|
||||||
try {
|
Promise.resolve().then(async () => {
|
||||||
requireRecentPassword(socket);
|
requireRecentPassword(socket);
|
||||||
|
if (restartPending) throw new Error('Application restart already pending.');
|
||||||
const actor = actorFor(socket);
|
const actor = actorFor(socket);
|
||||||
requestApplicationRestart({ actor });
|
try {
|
||||||
cb({ success: true });
|
const lifecycle = await lifecycleClient.restartApplication();
|
||||||
} catch (error) {
|
restartPending = true;
|
||||||
cb({ error: error.message, code: error.code || null });
|
database.recordAuditEvent(actor, 'application.restart-requested', { reason: 'administrator-requested' });
|
||||||
}
|
logger.warn('Application container restart requested', { actor });
|
||||||
|
io.emit('server:restarting', { reason: 'administrator-requested' });
|
||||||
|
cb({ success: true, lifecycle });
|
||||||
|
} catch (error) {
|
||||||
|
/*
|
||||||
|
Legacy installations intentionally have no controller socket. Keep
|
||||||
|
their existing process-signal restart during migration, but never
|
||||||
|
bypass a real controller rejection such as an operation conflict.
|
||||||
|
*/
|
||||||
|
if (!['ENOENT', 'ECONNREFUSED'].includes(error.code)) throw error;
|
||||||
|
requestApplicationRestart({ actor });
|
||||||
|
cb({ success: true, legacy: true });
|
||||||
|
}
|
||||||
|
}).catch((error) => cb({ error: error.message, code: error.code || null }));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// 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.
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
const LIFECYCLE_SOCKET_PATH = process.env.MULTIROVER_LIFECYCLE_SOCKET || '/run/multirover/lifecycle.sock';
|
||||||
|
const REQUEST_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
function requestLifecycleController({ method = 'GET', path }) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = http.request({
|
||||||
|
socketPath: LIFECYCLE_SOCKET_PATH,
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
}, (response) => {
|
||||||
|
let body = '';
|
||||||
|
response.setEncoding('utf8');
|
||||||
|
response.on('data', (chunk) => {
|
||||||
|
// Controller responses contain only a small status document. Keeping a
|
||||||
|
// hard bound prevents a broken peer from growing application memory.
|
||||||
|
body += chunk;
|
||||||
|
if (body.length > 64 * 1024) request.destroy(new Error('Lifecycle controller response is too large.'));
|
||||||
|
});
|
||||||
|
response.on('end', () => {
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = body ? JSON.parse(body) : {};
|
||||||
|
} catch {
|
||||||
|
reject(new Error('Lifecycle controller returned an invalid response.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
reject(new Error(parsed.error || 'Lifecycle controller request failed.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(parsed);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
request.setTimeout(REQUEST_TIMEOUT_MS, () => request.destroy(new Error('Lifecycle controller did not respond.')));
|
||||||
|
request.on('error', reject);
|
||||||
|
request.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLifecycleStatus() {
|
||||||
|
try {
|
||||||
|
return await requestLifecycleController({ path: '/status' });
|
||||||
|
} catch (error) {
|
||||||
|
/*
|
||||||
|
The legacy development/server installation deliberately has no lifecycle
|
||||||
|
controller. Report that as an unavailable capability so the existing
|
||||||
|
in-process restart remains usable instead of turning absence into a noisy
|
||||||
|
server error.
|
||||||
|
*/
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
state: 'unavailable',
|
||||||
|
message: error.message,
|
||||||
|
operation: null,
|
||||||
|
updateAvailable: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
checkForUpdate: () => requestLifecycleController({ method: 'POST', path: '/check' }),
|
||||||
|
getLifecycleStatus,
|
||||||
|
restartApplication: () => requestLifecycleController({ method: 'POST', path: '/restart' }),
|
||||||
|
updateApplication: () => requestLifecycleController({ method: 'POST', path: '/update' }),
|
||||||
|
};
|
||||||
@@ -26,6 +26,9 @@ export const createAdministrator = (socket, payload) => emitAdminRequest(socket,
|
|||||||
export const updateAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateAdministrator', payload);
|
export const updateAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateAdministrator', payload);
|
||||||
export const deleteAdministrator = (socket, id) => emitAdminRequest(socket, 'adminConfig:deleteAdministrator', { id });
|
export const deleteAdministrator = (socket, id) => emitAdminRequest(socket, 'adminConfig:deleteAdministrator', { id });
|
||||||
export const restartApplication = (socket) => emitAdminRequest(socket, 'server:restartApplication');
|
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 getBackupRestoreStatus = (socket) => emitAdminRequest(socket, 'backupRestore:status');
|
export const getBackupRestoreStatus = (socket) => emitAdminRequest(socket, 'backupRestore:status');
|
||||||
export const createFullBackup = (socket) => emitAdminRequest(socket, 'backupRestore:createBackup');
|
export const createFullBackup = (socket) => emitAdminRequest(socket, 'backupRestore:createBackup');
|
||||||
export const createRestoreUpload = (socket) => emitAdminRequest(socket, 'backupRestore:createRestoreUpload');
|
export const createRestoreUpload = (socket) => emitAdminRequest(socket, 'backupRestore:createRestoreUpload');
|
||||||
|
|||||||
@@ -4,7 +4,13 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import CardFrame from '../../components/CardFrame/index.jsx';
|
import CardFrame from '../../components/CardFrame/index.jsx';
|
||||||
import { restartApplication, restoreConfigurationRevision } from '../api.js';
|
import {
|
||||||
|
checkForApplicationUpdate,
|
||||||
|
getApplicationLifecycleStatus,
|
||||||
|
restartApplication,
|
||||||
|
restoreConfigurationRevision,
|
||||||
|
updateApplication,
|
||||||
|
} from '../api.js';
|
||||||
|
|
||||||
function formatDate(value) {
|
function formatDate(value) {
|
||||||
return Number.isFinite(Number(value)) ? new Date(Number(value)).toLocaleString() : 'unknown';
|
return Number.isFinite(Number(value)) ? new Date(Number(value)).toLocaleString() : 'unknown';
|
||||||
@@ -13,6 +19,33 @@ function formatDate(value) {
|
|||||||
export default function AdminOverview({ snapshot, socket, runSensitive, onSnapshot }) {
|
export default function AdminOverview({ snapshot, socket, runSensitive, onSnapshot }) {
|
||||||
const config = snapshot.configuration;
|
const config = snapshot.configuration;
|
||||||
const [restartRequested, setRestartRequested] = useState(false);
|
const [restartRequested, setRestartRequested] = useState(false);
|
||||||
|
const [lifecycle, setLifecycle] = useState(null);
|
||||||
|
const [lifecycleError, setLifecycleError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
async function refreshLifecycle() {
|
||||||
|
if (!socket.connected) return;
|
||||||
|
try {
|
||||||
|
const response = await getApplicationLifecycleStatus(socket);
|
||||||
|
if (!mounted) return;
|
||||||
|
setLifecycle(response.lifecycle);
|
||||||
|
setLifecycleError('');
|
||||||
|
} catch (error) {
|
||||||
|
if (mounted) setLifecycleError(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshLifecycle();
|
||||||
|
// Update work continues while this application is being replaced. Polling
|
||||||
|
// the controller gives the page current progress before disconnect and the
|
||||||
|
// persisted final result immediately after Socket.IO reconnects.
|
||||||
|
const timer = window.setInterval(refreshLifecycle, 2500);
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [socket]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!restartRequested) return undefined;
|
if (!restartRequested) return undefined;
|
||||||
@@ -47,6 +80,33 @@ export default function AdminOverview({ snapshot, socket, runSensitive, onSnapsh
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function checkForUpdate() {
|
||||||
|
try {
|
||||||
|
const response = await runSensitive(() => checkForApplicationUpdate(socket));
|
||||||
|
setLifecycle(response.lifecycle);
|
||||||
|
} catch (error) {
|
||||||
|
setLifecycleError(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update() {
|
||||||
|
if (!window.confirm('Pull the configured MultiRover image and replace the application container now? The page will reconnect automatically.')) return;
|
||||||
|
try {
|
||||||
|
const response = await runSensitive(() => updateApplication(socket));
|
||||||
|
setLifecycle(response.lifecycle);
|
||||||
|
} catch (error) {
|
||||||
|
setLifecycleError(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lifecycleBusy = lifecycle?.state === 'running';
|
||||||
|
const lifecycleAvailable = Boolean(lifecycle?.available);
|
||||||
|
const updateSummary = lifecycle?.updateAvailable === true
|
||||||
|
? 'An update is available.'
|
||||||
|
: lifecycle?.updateAvailable === false
|
||||||
|
? 'The running application matches the latest checked image.'
|
||||||
|
: 'Check for updates to compare the running container with the configured image.';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<CardFrame title="Administration overview" meta={`revision ${config.revision}`} bodyClassName="grid gap-0.5 p-0.5 md:grid-cols-3">
|
<CardFrame title="Administration overview" meta={`revision ${config.revision}`} bodyClassName="grid gap-0.5 p-0.5 md:grid-cols-3">
|
||||||
@@ -58,11 +118,24 @@ export default function AdminOverview({ snapshot, socket, runSensitive, onSnapsh
|
|||||||
<Link className="button-dark" to="/reports">Open fleet reports</Link>
|
<Link className="button-dark" to="/reports">Open fleet reports</Link>
|
||||||
<Link className="button-dark" to="/">Open driver application</Link>
|
<Link className="button-dark" to="/">Open driver application</Link>
|
||||||
</CardFrame>
|
</CardFrame>
|
||||||
<CardFrame title="Application" bodyClassName="space-y-0.5 p-1 text-sm">
|
<CardFrame title="Application container" meta={lifecycleAvailable ? lifecycle?.state : 'controller unavailable'} bodyClassName="space-y-1 p-1 text-sm">
|
||||||
<p className="text-slate-300">Restart only the MultiRover application. The process supervisor starts it again automatically without rebooting the host.</p>
|
<div className="surface max-w-4xl space-y-0.5 p-1 text-slate-300">
|
||||||
<button type="button" className="button-danger" disabled={restartRequested} onClick={restart}>
|
<p>{lifecycleAvailable ? updateSummary : 'Container updates are unavailable until the lifecycle service is running.'}</p>
|
||||||
{restartRequested ? 'Waiting for application…' : 'Restart application'}
|
{lifecycle?.message ? <p className="text-slate-400">{lifecycle.message}</p> : null}
|
||||||
</button>
|
{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>
|
||||||
|
<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}>
|
||||||
|
{lifecycle?.operation === 'update' && lifecycleBusy ? 'Updating application…' : 'Update and restart'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="button-danger" disabled={restartRequested || lifecycleBusy} onClick={restart}>
|
||||||
|
{restartRequested ? 'Waiting for application…' : 'Restart application'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</CardFrame>
|
</CardFrame>
|
||||||
<CardFrame title="Configuration revisions" meta={snapshot.revisions.length} bodyClassName="max-h-64 overflow-y-auto p-0.5 text-xs">
|
<CardFrame title="Configuration revisions" meta={snapshot.revisions.length} bodyClassName="max-h-64 overflow-y-auto p-0.5 text-xs">
|
||||||
{snapshot.revisions.map((revision) => (
|
{snapshot.revisions.map((revision) => (
|
||||||
|
|||||||
Reference in New Issue
Block a user