feat: Updater

This commit is contained in:
Sören Beye 2021-10-20 21:11:21 +02:00
parent c4c31bb5a6
commit 5db2c554dc
41 changed files with 3975 additions and 16 deletions

View File

@ -107,8 +107,8 @@
'uses': 'actions/upload-artifact@v2',
'with':
{
'name': 'manifest.json',
'path': './build/manifest.json',
'name': 'valetudo_release_manifest.json',
'path': './build/valetudo_release_manifest.json',
},
},
],

View File

@ -126,8 +126,8 @@
'with':
{
'upload_url': '${{ github.event.release.upload_url }}',
'asset_path': './build/manifest.json',
'asset_name': 'manifest.json',
'asset_path': './build/valetudo_release_manifest.json',
'asset_name': 'valetudo_release_manifest.json',
'asset_content_type': 'application/json',
},
},

View File

@ -1,6 +1,7 @@
const fs = require("fs");
const os = require("os");
const path = require("path");
const spawnSync = require("child_process").spawnSync;
const uuid = require("uuid");
const {generateId} = require("zoo-ids");
@ -163,6 +164,59 @@ class Tools {
static CLONE(obj) {
return JSON.parse(JSON.stringify(obj));
}
/**
* Returns the total and free size in bytes
*
* @param {string} path
*/
static GET_DISK_SPACE_INFO(path) {
try {
//Inspired by https://github.com/Alex-D/check-disk-space
const dfResult = spawnSync("df", ["-Pk", "--", path]);
const dfOutput = dfResult.stdout.toString().trim().split("\n").slice(1).map(l => {
return l.trim().split(/\s+(?=[\d/])/);
});
if (dfOutput.length !== 1 || dfOutput[0].length !== 6) {
return {
total: 0,
free: 0
};
}
return {
total: parseInt(dfOutput[0][1], 10) * 1024,
free: parseInt(dfOutput[0][3], 10) * 1024,
};
} catch (e) {
return {
total: 0,
free: 0,
};
}
}
static IS_UPX_COMPRESSED(path) {
let is_upx = false;
try {
const fd = fs.openSync(path, "r");
const buf = Buffer.alloc(256);
// eslint-disable-next-line no-unused-vars
const fstat = fs.fstatSync(fd);
fs.readSync(fd, buf, {length: 256});
fs.closeSync(fd);
is_upx = buf.toString().includes("UPX");
} catch (e) {
//intentional
}
return is_upx;
}
}
const VALETUDO_NAMESPACE = "be5f1ffc-c150-4785-9ebb-08fcfe90c933";

View File

@ -12,6 +12,7 @@ const Webserver = require("./webserver/WebServer");
const NetworkAdvertisementManager = require("./NetworkAdvertisementManager");
const Scheduler = require("./scheduler/Scheduler");
const Updater = require("./updater/Updater");
const ValetudoEventHandlerFactory = require("./valetudo_events/ValetudoEventHandlerFactory");
const ValetudoRobotFactory = require("./core/ValetudoRobotFactory");
@ -62,10 +63,16 @@ class Valetudo {
this.robot.startup();
this.updater = new Updater({
config: this.config,
robot: this.robot
});
this.webserver = new Webserver({
config: this.config,
robot: this.robot,
ntpClient: this.ntpClient,
updater: this.updater,
valetudoEventStore: this.valetudoEventStore
});

View File

@ -56,6 +56,9 @@
"networkAdvertisement": {
"$ref": "#/components/schemas/NetworkAdvertisementConfigDTO"
},
"updater": {
"$ref": "#/components/schemas/UpdaterConfigDTO"
},
"timers": {
"type": "object"
},
@ -306,6 +309,36 @@
}
}
},
"UpdaterConfigDTO": {
"type": "object",
"additionalProperties": false,
"required": [
"enabled"
],
"properties": {
"enabled": {
"type": "boolean"
},
"updateProvider": {
"type": "object",
"additionalProperties": false,
"required": [
"type"
],
"properties": {
"type": {
"type": "string",
"enum": [
"github"
]
},
"implementationSpecificConfig": {
"type": "object"
}
}
}
}
},
"ValetudoTimer": {
"type": "object",
"description": "Everything time-related is in UTC",

View File

@ -0,0 +1,24 @@
const ValetudoUpdaterState = require("./ValetudoUpdaterState");
class ValetudoUpdaterApplyPendingState extends ValetudoUpdaterState {
/**
*
* @param {object} options
* @param {object} [options.metaData]
*
* @param {string} options.version The version (e.g. 2021.10.0)
* @param {Date} options.releaseTimestamp The release date as found in the manifest
* @param {string} options.downloadPath The path the new binary was downloaded to
*
* @class
*/
constructor(options) {
super(options);
this.version = options.version;
this.releaseTimestamp = options.releaseTimestamp;
this.downloadPath = options.downloadPath;
}
}
module.exports = ValetudoUpdaterApplyPendingState;

View File

@ -0,0 +1,30 @@
const ValetudoUpdaterState = require("./ValetudoUpdaterState");
class ValetudoUpdaterApprovalPendingState extends ValetudoUpdaterState {
/**
*
* @param {object} options
* @param {object} [options.metaData]
*
* @param {string} options.version The version to be installed (e.g. 2021.10.0)
* @param {Date} options.releaseTimestamp The release date as found in the manifest
* @param {string} options.changelog The changelog as provided by the github api. Github flavoured Markdown
* @param {string} options.downloadUrl The url from which the binary will be downloaded from
* @param {string} options.expectedHash The expected sha256sum of the downloaded binary
* @param {string} options.downloadPath The path the new binary is downloaded to
*
* @class
*/
constructor(options) {
super(options);
this.version = options.version;
this.releaseTimestamp = options.releaseTimestamp;
this.changelog = options.changelog;
this.downloadUrl = options.downloadUrl;
this.expectedHash = options.expectedHash;
this.downloadPath = options.downloadPath;
}
}
module.exports = ValetudoUpdaterApprovalPendingState;

View File

@ -0,0 +1,15 @@
const ValetudoUpdaterState = require("./ValetudoUpdaterState");
class ValetudoUpdaterDisabledState extends ValetudoUpdaterState {
/**
*
* @param {object} options
* @param {object} [options.metaData]
* @class
*/
constructor(options) {
super(options);
}
}
module.exports = ValetudoUpdaterDisabledState;

View File

@ -0,0 +1,30 @@
const ValetudoUpdaterState = require("./ValetudoUpdaterState");
//TODO: Further iterations could provide a download percentage
class ValetudoUpdaterDownloadingState extends ValetudoUpdaterState {
/**
*
* @param {object} options
* @param {object} [options.metaData]
*
* @param {string} options.version The version (e.g. 2021.10.0)
* @param {Date} options.releaseTimestamp The release date as found in the manifest
* @param {string} options.downloadUrl The url from which the binary will be downloaded from
* @param {string} options.expectedHash The expected sha256sum of the downloaded binary
* @param {string} options.downloadPath The path the new binary is downloaded to
*
* @class
*/
constructor(options) {
super(options);
this.version = options.version;
this.releaseTimestamp = options.releaseTimestamp;
this.downloadUrl = options.downloadUrl;
this.expectedHash = options.expectedHash;
this.downloadPath = options.downloadPath;
}
}
module.exports = ValetudoUpdaterDownloadingState;

View File

@ -0,0 +1,38 @@
const ValetudoUpdaterState = require("./ValetudoUpdaterState");
class ValetudoUpdaterErrorState extends ValetudoUpdaterState {
/**
* The update process aborted with type, message at timestamp
*
* @param {object} options
* @param {ValetudoUpdaterErrorType} options.type
* @param {string} options.message
* @param {object} [options.metaData]
* @class
*/
constructor(options) {
super(options);
this.type = options.type;
this.message = options.message;
}
}
/**
* @typedef {string} ValetudoUpdaterErrorType
* @enum {string}
*
*/
ValetudoUpdaterErrorState.ERROR_TYPE = Object.freeze({
UNKNOWN: "unknown",
NOT_EMBEDDED: "not_embedded",
NOT_DOCKED: "not_docked",
NOT_WRITABLE: "not_writable",
NOT_ENOUGH_SPACE: "not_enough_space",
DOWNLOAD_FAILED: "download_failed",
NO_RELEASE: "no_release",
NO_MATCHING_BINARY: "no_matching_binary",
INVALID_CHECKSUM: "invalid_checksum",
});
module.exports = ValetudoUpdaterErrorState;

View File

@ -0,0 +1,19 @@
const ValetudoUpdaterState = require("./ValetudoUpdaterState");
class ValetudoUpdaterIdleState extends ValetudoUpdaterState {
/**
*
* @param {object} options
* @param {object} [options.metaData]
*
* @param {string} options.currentVersion The currently running valetudo version
* @class
*/
constructor(options) {
super(options);
this.currentVersion = options.currentVersion;
}
}
module.exports = ValetudoUpdaterIdleState;

View File

@ -0,0 +1,19 @@
const SerializableEntity = require("../../SerializableEntity");
class ValetudoUpdaterState extends SerializableEntity {
/**
*
* @param {object} options
* @param {object} [options.metaData]
* @class
* @abstract
*/
constructor(options) {
super(options);
this.timestamp = new Date();
}
}
module.exports = ValetudoUpdaterState;

View File

@ -0,0 +1,31 @@
{
"components": {
"schemas": {
"ValetudoUpdaterApplyPendingState": {
"allOf": [
{
"$ref": "#/components/schemas/ValetudoUpdaterState"
},
{
"type": "object",
"properties": {
"version": {
"type": "string",
"description": "The valetudo version to be installed"
},
"releaseTimestamp": {
"type": "string",
"format": "date-time",
"description": "The release date as found in the manifest"
},
"downloadPath": {
"type": "string",
"description": "The path the new binary was downloaded to"
}
}
}
]
}
}
}
}

View File

@ -0,0 +1,43 @@
{
"components": {
"schemas": {
"ValetudoUpdaterApprovalPendingState": {
"allOf": [
{
"$ref": "#/components/schemas/ValetudoUpdaterState"
},
{
"type": "object",
"properties": {
"version": {
"type": "string",
"description": "The valetudo version to be installed"
},
"releaseTimestamp": {
"type": "string",
"format": "date-time",
"description": "The release date as found in the manifest"
},
"changelog": {
"type": "string",
"description": "The changelog as provided by the github api. Github flavoured Markdown"
},
"downloadUrl": {
"type": "string",
"description": "The url from which the binary will be downloaded from"
},
"expectedHash": {
"type": "string",
"description": "The expected sha256sum of the downloaded binary"
},
"downloadPath": {
"type": "string",
"description": "The path the new binary is downloaded to"
}
}
}
]
}
}
}
}

View File

@ -0,0 +1,18 @@
{
"components": {
"schemas": {
"ValetudoUpdaterDisabledState": {
"allOf": [
{
"$ref": "#/components/schemas/ValetudoUpdaterState"
},
{
"type": "object",
"properties": {
}
}
]
}
}
}
}

View File

@ -0,0 +1,39 @@
{
"components": {
"schemas": {
"ValetudoUpdaterDownloadingState": {
"allOf": [
{
"$ref": "#/components/schemas/ValetudoUpdaterState"
},
{
"type": "object",
"properties": {
"version": {
"type": "string",
"description": "The valetudo version to be installed"
},
"releaseTimestamp": {
"type": "string",
"format": "date-time",
"description": "The release date as found in the manifest"
},
"downloadUrl": {
"type": "string",
"description": "The url from which the binary will be downloaded from"
},
"expectedHash": {
"type": "string",
"description": "The expected sha256sum of the downloaded binary"
},
"downloadPath": {
"type": "string",
"description": "The path the new binary is downloaded to"
}
}
}
]
}
}
}
}

View File

@ -0,0 +1,36 @@
{
"components": {
"schemas": {
"ValetudoUpdaterErrorState": {
"allOf": [
{
"$ref": "#/components/schemas/ValetudoUpdaterState"
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"unknown",
"not_embedded",
"not_docked",
"not_writable",
"not_enough_space",
"download_failed",
"no_matching_binary",
"missing_manifest",
"invalid_manifest",
"invalid_checksum"
]
},
"message": {
"type": "string"
}
}
}
]
}
}
}
}

View File

@ -0,0 +1,22 @@
{
"components": {
"schemas": {
"ValetudoUpdaterIdleState": {
"allOf": [
{
"$ref": "#/components/schemas/ValetudoUpdaterState"
},
{
"type": "object",
"properties": {
"currentVersion": {
"type": "string",
"description": "The currently running valetudo version"
}
}
}
]
}
}
}
}

View File

@ -0,0 +1,23 @@
{
"components": {
"schemas": {
"ValetudoUpdaterState": {
"allOf": [
{
"$ref": "#/components/schemas/SerializableEntity"
},
{
"type": "object",
"properties": {
"timestamp": {
"type": "string",
"format": "date-time",
"description": "The time this state was entered"
}
}
}
]
}
}
}
}

View File

@ -0,0 +1,9 @@
module.exports = {
ValetudoUpdaterApplyPendingState: require("./ValetudoUpdaterApplyPendingState"),
ValetudoUpdaterApprovalPendingState: require("./ValetudoUpdaterApprovalPendingState"),
ValetudoUpdaterDisabledState: require("./ValetudoUpdaterDisabledState"),
ValetudoUpdaterDownloadingState: require("./ValetudoUpdaterDownloadingState"),
ValetudoUpdaterErrorState: require("./ValetudoUpdaterErrorState"),
ValetudoUpdaterIdleState: require("./ValetudoUpdaterIdleState"),
ValetudoUpdaterState: require("./ValetudoUpdaterState")
};

View File

@ -75,5 +75,12 @@
},
"networkAdvertisement": {
"enabled": true
},
"updater": {
"enabled": true,
"updateProvider": {
"type": "github",
"implementationSpecificConfig": {}
}
}
}

View File

@ -0,0 +1,374 @@
// @ts-nocheck Required due to too many annoyances
const crypto = require("crypto");
const fs = require("fs");
const os = require("os");
const path = require("path/posix");
const spawnSync = require("child_process").spawnSync;
const {pipeline} = require("stream/promises");
const axios = require("axios").default;
const uuid = require("uuid");
const GithubValetudoUpdateProvider = require("./update_provider/GithubValetudoUpdateProvider");
const Logger = require("../Logger");
const stateAttrs = require("../entities/state/attributes");
const States = require("../entities/core/updater");
const Tools = require("../Tools");
class Updater {
/**
* @param {object} options
* @param {import("../Configuration")} options.config
* @param {import("../core/ValetudoRobot")} options.robot
*/
constructor(options) {
this.config = options.config;
this.robot = options.robot;
this.updaterConfig = this.config.get("updater");
this.state = undefined;
this.updateProvider = undefined;
if (this.updaterConfig.enabled === true) {
this.state = new States.ValetudoUpdaterIdleState({
currentVersion: Tools.GET_VALETUDO_VERSION()
});
} else {
this.state = new States.ValetudoUpdaterDisabledState({});
}
switch (this.updaterConfig.updateProvider.type) {
case "github":
this.updateProvider = new GithubValetudoUpdateProvider();
break;
default:
throw new Error(`Invalid UpdateProvider ${this.updaterConfig.updateProvider.type}`);
}
}
/**
* As everything regarding networking might take a long time, we just accept this request
* and then asynchronously process it.
* Updates are reported via the updaters state
*
* @return {boolean}
*/
triggerStart() {
if (!(this.state instanceof States.ValetudoUpdaterIdleState || this.state instanceof States.ValetudoUpdaterErrorState)) {
throw new Error("Updates can only be started when the updaters state is idle or error");
}
this.start().catch(err => {
//This should never happen
Logger.error("Unexpected error during startUpdate", err);
});
return true;
}
triggerDownload() {
if (!(this.state instanceof States.ValetudoUpdaterApprovalPendingState)) {
throw new Error("Downloads can only be started when there's pending approval");
}
this.download().catch(err => {
//This should never happen
Logger.error("Unexpected error during download", err);
});
return true;
}
triggerApply() {
if (!(this.state instanceof States.ValetudoUpdaterApplyPendingState)) {
throw new Error("Can only apply if there's finalization pending");
}
this.apply().catch(err => {
//This should never happen
Logger.error("Unexpected error during apply", err);
});
return true;
}
async apply() {
const applyPendingState = this.state;
fs.unlinkSync(process.argv0);
fs.copyFileSync(applyPendingState.downloadPath, process.argv0);
fs.chmodSync(process.argv0, fs.constants.S_IXUSR | fs.constants.S_IXGRP | fs.constants.S_IXOTH);
fs.unlinkSync(applyPendingState.downloadPath);
spawnSync("sync");
spawnSync("reboot");
}
async download() {
const approvalState = this.state;
const downloadingState = new States.ValetudoUpdaterDownloadingState({
version: approvalState.version,
releaseTimestamp: approvalState.releaseTimestamp,
downloadUrl: approvalState.downloadUrl,
expectedHash: approvalState.expectedHash,
downloadPath: approvalState.downloadPath
});
this.state = downloadingState;
try {
const downloadResponse = await axios.get(downloadingState.downloadUrl, {responseType: "stream"});
await pipeline(
downloadResponse.data,
fs.createWriteStream(downloadingState.downloadPath)
);
} catch (e) {
Logger.error("Error while downloading release binary", e);
this.state = new States.ValetudoUpdaterErrorState({
type: States.ValetudoUpdaterErrorState.ERROR_TYPE.DOWNLOAD_FAILED,
message: "Error while downloading release binary"
});
return;
}
let checksum;
try {
checksum = await new Promise((resolve, reject) => {
const hash = crypto.createHash("sha256");
const readStream = fs.createReadStream(downloadingState.downloadPath);
readStream.on("error", err => {
reject(err);
});
readStream.on("data", data => {
hash.update(data);
});
readStream.on("end", () => {
resolve(hash.digest("hex"));
});
});
} catch (e) {
Logger.error("Error while calculating downloaded binary checksum", e);
this.state = new States.ValetudoUpdaterErrorState({
type: States.ValetudoUpdaterErrorState.ERROR_TYPE.UNKNOWN,
message: "Error while calculating downloaded binary checksum"
});
}
if (checksum !== downloadingState.expectedHash) {
this.state = new States.ValetudoUpdaterErrorState({
type: States.ValetudoUpdaterErrorState.ERROR_TYPE.INVALID_CHECKSUM,
message: `Expected Checksum: ${downloadingState.expectedHash}. Actual: ${checksum}`
});
} else {
this.state = new States.ValetudoUpdaterApplyPendingState({
version: downloadingState.version,
releaseTimestamp: downloadingState.releaseTimestamp,
downloadPath: downloadingState.downloadPath
});
setTimeout(() => {
Logger.warn("Updater: User confirmation timeout.");
fs.unlinkSync(downloadingState.downloadPath);
this.state = new States.ValetudoUpdaterIdleState({
currentVersion: Tools.GET_VALETUDO_VERSION()
});
}, 10 * 60 * 1000);
}
}
/**
*
* @private
*/
async start() {
const lowmemRequired = os.totalmem() < Updater.LOWMEM_THRESHOLD;
const archRequired = Updater.ARCHITECTURES[process.arch];
let binaryRequired = `valetudo-${archRequired}${lowmemRequired ? "-lowmem" : ""}${Tools.IS_UPX_COMPRESSED(process.argv0) ? ".upx" : ""}`;
if (this.config.get("embedded") !== true) {
this.state = new States.ValetudoUpdaterErrorState({
type: States.ValetudoUpdaterErrorState.ERROR_TYPE.NOT_EMBEDDED,
message: "Updating is only possible in embedded mode"
});
return null;
}
try {
await this.robot.pollState();
} catch (e) {
this.state = new States.ValetudoUpdaterErrorState({
type: States.ValetudoUpdaterErrorState.ERROR_TYPE.UNKNOWN,
message: "Error while polling the robots state"
});
}
const statusAttribute = this.robot.state.getFirstMatchingAttributeByConstructor(
stateAttrs.StatusStateAttribute
);
if (!(statusAttribute && statusAttribute.value === stateAttrs.StatusStateAttribute.VALUE.DOCKED)) {
this.state = new States.ValetudoUpdaterErrorState({
type: States.ValetudoUpdaterErrorState.ERROR_TYPE.NOT_DOCKED,
message: "Updating is only possible while the robot is docked"
});
return null;
}
const downloadPath = this.getDownloadPath();
if (!downloadPath) { //This works because getDownloadPath already set the correct current state
return;
}
let releases;
try {
releases = await this.updateProvider.fetchReleases();
} catch (e) {
Logger.error("Error while fetching releases", e);
this.state = new States.ValetudoUpdaterErrorState({
type: States.ValetudoUpdaterErrorState.ERROR_TYPE.DOWNLOAD_FAILED,
message: "Error while fetching releases"
});
return;
}
const releaseVersions = releases.map(r => {
return r.version;
});
const currentVersionIndex = releaseVersions.indexOf(Tools.GET_VALETUDO_VERSION());
let releaseToDownload;
/*
Always try to pick the next chronological release if possible
*/
if (currentVersionIndex > 0) {
releaseToDownload = releases[currentVersionIndex-1];
} else {
releaseToDownload = releases[0];
}
if (!releaseToDownload) {
this.state = new States.ValetudoUpdaterErrorState({
type: States.ValetudoUpdaterErrorState.ERROR_TYPE.NO_RELEASE,
message: "No release found"
});
return;
}
let releaseBinaries;
try {
releaseBinaries = await this.updateProvider.fetchBinariesForRelease(releaseToDownload);
} catch (e) {
Logger.error(`Error while fetching release binaries for ${releaseToDownload.version}`, e);
this.state = new States.ValetudoUpdaterErrorState({
type: States.ValetudoUpdaterErrorState.ERROR_TYPE.DOWNLOAD_FAILED,
message: `Error while fetching release binaries for ${releaseToDownload.version}`
});
}
const binaryToUse = releaseBinaries.find(b => {
return b.name === binaryRequired;
});
if (!binaryToUse) {
this.state = new States.ValetudoUpdaterErrorState({
type: States.ValetudoUpdaterErrorState.ERROR_TYPE.NO_MATCHING_BINARY,
message: `Release ${releaseToDownload.version} doesn't feature a ${binaryRequired} binary.`
});
return;
}
this.state = new States.ValetudoUpdaterApprovalPendingState({
version: releaseToDownload.version,
releaseTimestamp: releaseToDownload.releaseTimestamp,
changelog: releaseToDownload.changelog,
downloadUrl: binaryToUse.downloadUrl,
expectedHash: binaryToUse.sha256sum,
downloadPath: path.join(downloadPath, uuid.v4())
});
}
/**
* @private
* @return {string|null}
*/
getDownloadPath() {
let downloadLocation = os.tmpdir();
let space;
try {
fs.accessSync(process.argv0, fs.constants.W_OK);
} catch (e) {
this.state = new States.ValetudoUpdaterErrorState({
type: States.ValetudoUpdaterErrorState.ERROR_TYPE.NOT_WRITABLE,
message: `Updating is impossible because binary location "${process.argv0}" is not writable.`
});
return null;
}
space = Tools.GET_DISK_SPACE_INFO(downloadLocation);
if (space.free < Updater.SPACE_REQUIREMENTS) {
downloadLocation = "/dev/shm";
if (space.free < Updater.SPACE_REQUIREMENTS) {
this.state = new States.ValetudoUpdaterErrorState({
type: States.ValetudoUpdaterErrorState.ERROR_TYPE.NOT_ENOUGH_SPACE,
message: `Updating is impossible because ${downloadLocation} only has ${space.free} bytes of free space. Required: ${Updater.SPACE_REQUIREMENTS} bytes.`
});
return null;
}
}
try {
fs.accessSync(downloadLocation, fs.constants.W_OK);
} catch (e) {
this.state = new States.ValetudoUpdaterErrorState({
type: States.ValetudoUpdaterErrorState.ERROR_TYPE.NOT_WRITABLE,
message: `Updating is impossible because download location "${downloadLocation}" is not writable.`
});
return null;
}
return downloadLocation;
}
}
Updater.SPACE_REQUIREMENTS = 40 * 1024 * 1024;
Updater.LOWMEM_THRESHOLD = 300 * 1024 * 1024;
Updater.ARCHITECTURES = {
"arm": "armv7",
"arm64": "aarch64"
};
module.exports = Updater;

View File

@ -0,0 +1,96 @@
const ValetudoRelease = require("./ValetudoRelease");
const ValetudoReleaseBinary = require("./ValetudoReleaseBinary");
const ValetudoUpdateProvider = require("./ValetudoUpdateProvider");
const {default: axios} = require("axios");
class GithubValetudoUpdateProvider extends ValetudoUpdateProvider {
/**
* @return {Promise<Array<import("./ValetudoRelease")>>}
*/
async fetchReleases() {
let rawReleasesResponse = await axios.get(GithubValetudoUpdateProvider.RELEASES_URL);
let releases = [];
if (!Array.isArray(rawReleasesResponse?.data)) {
throw new Error("GithubValetudoUpdateProvider: Received invalid releases response");
}
releases = rawReleasesResponse.data.map(rR => {
return new ValetudoRelease({
version: rR.tag_name,
releaseTimestamp: new Date(rR.published_at),
changelog: rR.body,
metaData: {
githubReleaseUrl: rR.url
}
});
});
releases.sort((a, b) => {
return b.releaseTimestamp.getTime() - a.releaseTimestamp.getTime();
});
return releases;
}
/**
* @param {import("./ValetudoRelease")} release
* @return {Promise<Array<import("./ValetudoReleaseBinary")>>}
*/
async fetchBinariesForRelease(release) {
if (!release.metaData.githubReleaseUrl) {
throw new Error("Missing Github Release URL in Release Metadata");
}
let rawReleaseResponse = await axios.get(release.metaData.githubReleaseUrl);
let releaseBinaries = [];
// @ts-ignore
if (!Array.isArray(rawReleaseResponse?.data?.assets)) {
throw new Error("GithubValetudoUpdateProvider: Received invalid release response");
}
// @ts-ignore
let manifestAsset = rawReleaseResponse.data.assets.find(a => {
return a.name === GithubValetudoUpdateProvider.MANIFEST_NAME;
});
if (!manifestAsset) {
throw new Error(`GithubValetudoUpdateProvider: Missing ${GithubValetudoUpdateProvider.MANIFEST_NAME}`);
}
let rawManifestResponse = await axios.get(manifestAsset.browser_download_url);
// @ts-ignore
if (!rawManifestResponse.data || rawManifestResponse.data.version !== release.version) {
throw new Error(`GithubValetudoUpdateProvider: Invalid ${GithubValetudoUpdateProvider.MANIFEST_NAME}`);
}
const manifest = rawManifestResponse.data;
// @ts-ignore
releaseBinaries = rawReleaseResponse.data.assets.filter(a => {
return a.name !== GithubValetudoUpdateProvider.MANIFEST_NAME;
}).map(a => {
return new ValetudoReleaseBinary({
name: a.name,
// @ts-ignore
sha256sum: manifest.sha256sums[a.name] ?? "", //This will cause any install to fail but at least it's somewhat valid
downloadUrl: a.browser_download_url
});
});
return releaseBinaries;
}
}
GithubValetudoUpdateProvider.RELEASES_URL = "https://api.github.com/repos/Hypfer/Valetudo/releases";
GithubValetudoUpdateProvider.MANIFEST_NAME = "valetudo_release_manifest.json";
module.exports = GithubValetudoUpdateProvider;

View File

@ -0,0 +1,21 @@
class ValetudoRelease {
/**
*
* @param {object} options
* @param {string} options.version
* @param {Date} options.releaseTimestamp
* @param {string} options.changelog Github Flavoured Markdown
*
* @param {object} [options.metaData]
*/
constructor(options) {
this.version = options.version;
this.releaseTimestamp = options.releaseTimestamp;
this.changelog = options.changelog;
this.metaData = options.metaData ?? {};
}
}
module.exports = ValetudoRelease;

View File

@ -0,0 +1,17 @@
class ValetudoReleaseBinary {
/**
*
* @param {object} options
* @param {string} options.name
* @param {string} options.sha256sum
* @param {string} options.downloadUrl
*/
constructor(options) {
this.name = options.name;
this.sha256sum = options.sha256sum;
this.downloadUrl = options.downloadUrl;
}
}
module.exports = ValetudoReleaseBinary;

View File

@ -0,0 +1,26 @@
const NotImplementedError = require("../../core/NotImplementedError");
class ValetudoUpdateProvider {
constructor() {
}
/**
* @abstract
* @return {Promise<Array<import("./ValetudoRelease")>>} These have to be sorted by release date. Element 0 should be the most recent one
*/
async fetchReleases() {
throw new NotImplementedError();
}
/**
* @abstract
* @param {import("./ValetudoRelease")} release
* @return {Promise<Array<import("./ValetudoReleaseBinary")>>}
*/
async fetchBinariesForRelease(release) {
throw new NotImplementedError();
}
}
module.exports = ValetudoUpdateProvider;

View File

@ -0,0 +1,65 @@
const express = require("express");
const Logger = require("../Logger");
class UpdaterRouter {
/**
*
* @param {object} options
* @param {import("../updater/Updater")} options.updater
* @param {import("../Configuration")} options.config
* @param {*} options.validator
*/
constructor(options) {
this.router = express.Router({mergeParams: true});
this.config = options.config;
this.updater = options.updater;
this.validator = options.validator;
this.initRoutes();
}
initRoutes() {
this.router.get("/state", (req, res) => {
res.json(this.updater.state);
});
this.router.put("/", async (req, res) => {
if (req.body && req.body.action) {
try {
switch (req.body.action) {
case "start":
this.updater.triggerStart();
break;
case "download":
this.updater.triggerDownload();
break;
case "apply":
this.updater.triggerApply();
break;
default:
// noinspection ExceptionCaughtLocallyJS
throw new Error("Invalid action");
}
res.sendStatus(200);
} catch (e) {
Logger.warn("Error while executing action \"" + req.body.action + "\" for Updater", e);
res.status(400).json(e.message);
}
} else {
res.status(400).send("Missing action in request body");
}
});
}
getRouter() {
return this.router;
}
}
module.exports = UpdaterRouter;

View File

@ -23,6 +23,7 @@ const NTPClientRouter = require("./NTPClientRouter");
const SSDPRouter = require("./SSDPRouter");
const SystemRouter = require("./SystemRouter");
const TimerRouter = require("./TimerRouter");
const UpdaterRouter = require("./UpdaterRouter");
const ValetudoEventRouter = require("./ValetudoEventRouter");
class WebServer {
@ -30,6 +31,7 @@ class WebServer {
* @param {object} options
* @param {import("../core/ValetudoRobot")} options.robot
* @param {import("../NTPClient")} options.ntpClient
* @param {import("../updater/Updater")} options.updater
* @param {import("../ValetudoEventStore")} options.valetudoEventStore
* @param {import("../Configuration")} options.config
*/
@ -119,6 +121,8 @@ class WebServer {
this.app.use("/api/v2/events/", new ValetudoEventRouter({valetudoEventStore: this.valetudoEventStore, validator: this.validator}).getRouter());
this.app.use("/api/v2/updater/", new UpdaterRouter({config: this.config, updater: options.updater, validator: this.validator}).getRouter());
this.app.use("/_ssdp/", new SSDPRouter({config: this.config, robot: this.robot}).getRouter());
this.app.use(express.static(path.join(__dirname, "../../..", "frontend/build")));

View File

@ -0,0 +1,77 @@
{
"/api/v2/updater/state": {
"get": {
"tags": [
"Updater"
],
"summary": "Get Updater state",
"responses": {
"200": {
"description": "The Updaters current state.",
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/ValetudoUpdaterIdleState"
},
{
"$ref": "#/components/schemas/ValetudoUpdaterErrorState"
},
{
"$ref": "#/components/schemas/ValetudoUpdaterApprovalPendingState"
},
{
"$ref": "#/components/schemas/ValetudoUpdaterDownloadingState"
},
{
"$ref": "#/components/schemas/ValetudoUpdaterApplyPendingState"
},
{
"$ref": "#/components/schemas/ValetudoUpdaterDisabledState"
}
]
}
}
}
}
}
}
},
"/api/v2/updater": {
"put": {
"tags": [
"Updater"
],
"summary": "Request and execute an update of Valetudo",
"description": "Please do keep in mind that this is a potentially dangerous operation.",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": [
"start",
"download",
"apply"
]
}
}
}
}
}
},
"responses": {
"200": {
"$ref": "#/components/responses/200"
},
"400": {
"$ref": "#/components/responses/400"
}
}
}
}
}

View File

@ -35,6 +35,7 @@
"dependencies": {
"@destinationstransfers/ntp": "^2.0.0",
"ajv": "8.6.0",
"axios": "0.21.1",
"async-mqtt": "^2.6.1",
"body-parser": "^1.18.3",
"compression": "^1.7.2",
@ -65,7 +66,7 @@
"@types/express-list-endpoints": "^4.0.1",
"@types/mocha": "^7.0.2",
"@types/multer": "^1.4.2",
"@types/node": "^13.9.1",
"@types/node": "^16.11.1",
"@types/on-headers": "^1.0.0",
"@types/semaphore": "^1.1.1",
"@types/jstoxml": "^2.0.1",

View File

@ -43,6 +43,9 @@
"react": "17.0.2",
"react-div-100vh": "0.6.0",
"react-dom": "17.0.2",
"rehype-raw": "6.1.0",
"react-markdown": "7.0.1",
"remark-gfm": "3.0.0",
"react-query": "3.24.4",
"react-router-dom": "5.3.0",
"react-scripts": "4.0.3",

View File

@ -34,6 +34,7 @@ import {
Timer,
TimerInformation,
TimerProperties,
UpdaterState,
ValetudoEvent,
ValetudoEventInteractionContext,
ValetudoVersion,
@ -777,3 +778,23 @@ export const sendCombinedVirtualRestrictionsUpdate = async (
parameters
);
};
export const fetchUpdaterState = async (): Promise<UpdaterState> => {
return valetudoAPI
.get<UpdaterState>("/updater/state")
.then(({data}) => {
return data;
});
};
export const sendUpdaterCommand = async (
command: "start" | "download" | "apply"
): Promise<void> => {
await valetudoAPI.put(
"/updater",
{
"action": command
}
);
};

View File

@ -39,6 +39,7 @@ import {
fetchSystemRuntimeInfo,
fetchTimerInformation,
fetchTimerProperties,
fetchUpdaterState,
fetchValetudoEvents,
fetchValetudoInformation,
fetchValetudoLog,
@ -72,6 +73,7 @@ import {
sendStartMappingPass,
sendTimerCreation,
sendTimerUpdate,
sendUpdaterCommand,
sendValetudoEventInteraction,
sendValetudoLogLevel,
sendVoicePackManagementCommand,
@ -149,10 +151,11 @@ enum CacheKey {
Wifi = "wifi",
ManualControl = "manual_control",
ManualControlProperties = "manual_control_properties",
CombinedVirtualRestrictionsProperties = "combined_virtual_restrictions_properties"
CombinedVirtualRestrictionsProperties = "combined_virtual_restrictions_properties",
UpdaterState = "updater_state"
}
const useOnCommandError = (capability: Capability): ((error: unknown) => void) => {
const useOnCommandError = (capability: Capability | string): ((error: unknown) => void) => {
const {enqueueSnackbar} = useSnackbar();
return React.useCallback((error: any) => {
@ -993,3 +996,14 @@ export const useCombinedVirtualRestrictionsMutation = (
}
);
};
export const useUpdaterStateQuery = () => {
return useQuery(CacheKey.UpdaterState, fetchUpdaterState);
};
export const useUpdaterCommandMutation = () => {
const onError = useOnCommandError("Updater");
return useMutation(sendUpdaterCommand, {onError});
};

View File

@ -389,3 +389,17 @@ export interface CombinedVirtualRestrictionsUpdateRequestParameters {
export interface CombinedVirtualRestrictionsProperties {
supportedRestrictedZoneTypes: Array<ValetudoRestrictedZoneType>
}
export interface UpdaterState {
__class: "ValetudoUpdaterIdleState" | "ValetudoUpdaterErrorState" | "ValetudoUpdaterApprovalPendingState" | "ValetudoUpdaterDownloadingState" | "ValetudoUpdaterApplyPendingState" | "ValetudoUpdaterDisabledState";
timestamp: string;
type?: "unknown" | "not_embedded" | "not_docked" | "not_writable" | "not_enough_space" | "download_failed" | "no_matching_binary" | "missing_manifest" | "invalid_manifest" | "invalid_checksum";
message?: string;
currentVersion?: string;
version?: string;
releaseTimestamp?: string;
changelog?: string;
downloadUrl?: string;
expectedHash?: string;
downloadPath?: string;
}

View File

@ -27,6 +27,7 @@ import {
Menu as MenuIcon,
PendingActions as PendingActionsIcon,
Power as PowerIcon,
SystemUpdateAlt as UpdaterIcon,
SettingsRemote as SettingsRemoteIcon,
Elderly as OldFrontendIcon,
GitHub as GithubIcon,
@ -150,6 +151,13 @@ const menuTree: Array<MenuEntry | MenuSubheader> = [
title: "Interfaces",
menuIcon: PowerIcon,
menuText: "Interfaces"
},
{
kind: "MenuEntry",
routeMatch: "/settings/updater",
title: "Updater",
menuIcon: UpdaterIcon,
menuText: "Updater"
}
];

View File

@ -4,6 +4,7 @@ import About from "./About";
import Timers from "./timers";
import Log from "./Log";
import Interfaces from "./interfaces";
import Updater from "./Updater";
const SettingsRouter = (): JSX.Element => {
const {path} = useRouteMatch();
@ -22,6 +23,9 @@ const SettingsRouter = (): JSX.Element => {
<Route exact path={path + "/interfaces"}>
<Interfaces/>
</Route>
<Route exact path={path + "/updater"}>
<Updater/>
</Route>
<Route path="*">
<h3>Unknown route</h3>
</Route>

View File

@ -0,0 +1,5 @@
.reactMarkDown {}
.reactMarkDown img {
max-width: 100%;
}

View File

@ -0,0 +1,299 @@
import {
UpdaterState,
useUpdaterCommandMutation,
useUpdaterStateQuery
} from "../api";
import {
Refresh as RefreshIcon,
SystemUpdateAlt as UpdaterIcon,
Warning as ErrorIcon,
Download as DownloadIcon,
PendingActions as ApprovalPendingIcon,
Info as IdleIcon,
RestartAlt as ApplyPendingIcon,
ExpandMore as ExpandMoreIcon,
UpdateDisabled as UpdaterDisabledIcon
} from "@mui/icons-material";
import {
Accordion, AccordionDetails,
AccordionSummary,
Box,
Container,
Divider,
Grid,
IconButton,
Paper,
Typography
} from "@mui/material";
import React, {FunctionComponent} from "react";
import LoadingFade from "../components/LoadingFade";
import {LoadingButton} from "@mui/lab";
import ConfirmationDialog from "../components/ConfirmationDialog";
import style from "./Updater.module.css";
import ReactMarkdown from "react-markdown";
import gfm from "remark-gfm";
import rehypeRaw from "rehype-raw";
const Updater = (): JSX.Element => {
const {
data: updaterState,
isLoading: updaterStateLoading,
isError: updaterStateError,
refetch: refetchUpdaterState,
} = useUpdaterStateQuery();
return (
<Container>
<Paper style={{marginBottom: "16px"}}>
<Grid container direction="row">
<Box px={2} pt={1} style={{width: "100%"}}>
<Grid item container alignItems="center" spacing={1} justifyContent="space-between">
<Grid item style={{display:"flex"}}>
<Grid item style={{paddingRight: "8px"}}><UpdaterIcon/></Grid>
<Grid item>
<Typography>Updater</Typography>
</Grid>
</Grid>
<Grid item>
<IconButton onClick={() => {
refetchUpdaterState();
}}>
<RefreshIcon/>
</IconButton>
</Grid>
</Grid>
<Divider sx={{mt: 1}}/>
<UpdaterStateComponent
state={updaterState}
stateLoading={updaterStateLoading}
stateError={updaterStateError}
/>
</Box>
</Grid>
</Paper>
</Container>
);
};
const UpdaterStateComponent : React.FunctionComponent<{ state: UpdaterState | undefined, stateLoading: boolean, stateError: boolean }> = ({
state,
stateLoading,
stateError
}) => {
if (stateLoading || !state) {
return (
<LoadingFade/>
);
}
if (stateError) {
return <Typography color="error">Error loading Updater state</Typography>;
}
const getIconForState = () : JSX.Element => {
switch (state.__class) {
case "ValetudoUpdaterErrorState":
return <ErrorIcon sx={{ fontSize: "3rem" }}/>;
case "ValetudoUpdaterDownloadingState":
return <DownloadIcon sx={{ fontSize: "3rem" }}/>;
case "ValetudoUpdaterApprovalPendingState":
return <ApprovalPendingIcon sx={{ fontSize: "3rem" }}/>;
case "ValetudoUpdaterIdleState":
return <IdleIcon sx={{ fontSize: "3rem" }}/>;
case "ValetudoUpdaterApplyPendingState":
return <ApplyPendingIcon sx={{ fontSize: "3rem" }}/>;
case "ValetudoUpdaterDisabledState":
return <UpdaterDisabledIcon sx={{ fontSize: "3rem" }}/>;
}
};
const getContentForState = () : JSX.Element | undefined => {
switch (state.__class) {
case "ValetudoUpdaterErrorState":
return (
<Typography color="red"> {state.message}</Typography>
);
case "ValetudoUpdaterDownloadingState":
return (
<>
<Typography>Valetudo is currently downloading release {state.version}</Typography>
<br/>
<Typography>Please be patient...</Typography>
</>
);
case "ValetudoUpdaterApprovalPendingState":
return (
<Accordion
defaultExpanded={true}
>
<AccordionSummary expandIcon={<ExpandMoreIcon/>}>
<Typography>Changelog for Valetudo {state.version}</Typography>
</AccordionSummary>
<AccordionDetails>
<Box style={{width:"100%", paddingLeft: "16px", paddingRight:"16px"}}>
<ReactMarkdown
remarkPlugins={[gfm]}
rehypePlugins={[rehypeRaw]}
className={style.reactMarkDown}
>
{state.changelog ? state.changelog: ""}
</ReactMarkdown>
</Box>
</AccordionDetails>
</Accordion>
);
case "ValetudoUpdaterIdleState":
return (
<Typography>You&apos;re current running {state.currentVersion}</Typography>
);
case "ValetudoUpdaterApplyPendingState":
return (
<Typography>Successfully downloaded {state.version}</Typography>
);
case "ValetudoUpdaterDisabledState":
return (
<Typography>The Updater was disabled in the Valetudo config.</Typography>
);
}
};
return (
<>
<Grid container alignItems="center" direction="column" style={{paddingBottom:"16px"}}>
<Grid item style={{marginTop:"8px"}}>
{getIconForState()}
</Grid>
<Grid item>
{getContentForState()}
</Grid>
{
state.__class === "ValetudoUpdaterApplyPendingState" &&
<Typography color="red" style={{marginTop:"1rem", width: "80%"}}>
Please keep in mind that updating can be a dangerous operation.<br/>
Make sure that you&apos;ve thoroughly read the changelog to be aware of possible breaking changes.<br/><br/>
Also, during updates, you should always be prepared for some troubleshooting so please do not click apply if you currently don&apos;t have time for that.
</Typography>
}
</Grid>
<Divider sx={{mt: 1}}/>
<UpdaterControls
state={state}
/>
</>
);
};
const UpdaterControls : React.FunctionComponent<{ state: UpdaterState}> = ({
state,
}) => {
return (
<Grid container justifyContent="flex-end" direction="row" style={{paddingTop: "16px", paddingBottom:"16px"}}>
<Grid item>
{
(
state.__class === "ValetudoUpdaterIdleState" ||
state.__class === "ValetudoUpdaterErrorState"
) &&
<StartUpdateControls/>
}
{
(
state.__class === "ValetudoUpdaterApprovalPendingState"
) &&
<DownloadUpdateControls/>
}
{
(
state.__class === "ValetudoUpdaterApplyPendingState"
) &&
<ApplyUpdateControls/>
}
</Grid>
</Grid>
);
};
const StartUpdateControls: FunctionComponent = () => {
const [dialogOpen, setDialogOpen] = React.useState(false);
const {mutate: sendCommand, isLoading: commandExecuting} = useUpdaterCommandMutation();
const {
refetch: refetchUpdaterState,
} = useUpdaterStateQuery();
return (
<>
<LoadingButton loading={commandExecuting} variant="outlined" onClick={() => {
setDialogOpen(true);
}} sx={{mt: 1, mb: 1}}>Start Update</LoadingButton>
<ConfirmationDialog title="Start Update?"
text="Do you want to look for a new version of Valetudo?"
open={dialogOpen} onClose={() => {
setDialogOpen(false);
}} onAccept={() => {
sendCommand("start");
setTimeout(() => {
refetchUpdaterState().then();
}, 2000); //TODO: this could be better
}}/>
</>
);
};
const DownloadUpdateControls: FunctionComponent = () => {
const [dialogOpen, setDialogOpen] = React.useState(false);
const {mutate: sendCommand, isLoading: commandExecuting} = useUpdaterCommandMutation();
const {
refetch: refetchUpdaterState,
} = useUpdaterStateQuery();
return (
<>
<LoadingButton loading={commandExecuting} variant="outlined" onClick={() => {
setDialogOpen(true);
}} sx={{mt: 1, mb: 1}}>Download Update</LoadingButton>
<ConfirmationDialog title="Download Update?"
text="Do you want to download the displayed Valetudo update? Please make sure to fully read the provided changelog as it may contain breaking changes as well as other relevant information."
open={dialogOpen} onClose={() => {
setDialogOpen(false);
}} onAccept={() => {
sendCommand("download");
setTimeout(() => {
refetchUpdaterState().then();
}, 100); //TODO: this could be better
}}/>
</>
);
};
const ApplyUpdateControls: FunctionComponent = () => {
const [dialogOpen, setDialogOpen] = React.useState(false);
const {mutate: sendCommand, isLoading: commandExecuting} = useUpdaterCommandMutation();
return (
<>
<LoadingButton loading={commandExecuting} variant="outlined" onClick={() => {
setDialogOpen(true);
}} sx={{mt: 1, mb: 1}}>Apply Update</LoadingButton>
<ConfirmationDialog title="Apply Update?"
text="Do you want to apply the downloaded update? The robot will reboot during this procedure."
open={dialogOpen} onClose={() => {
setDialogOpen(false);
}} onAccept={() => {
sendCommand("apply");
}}/>
</>
);
};
export default Updater;

2427
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -26,6 +26,7 @@ const options = {
{name: "System", description: "System API"},
{name: "NTP", description: "NTP Client API"},
{name: "Timers", description: "Timers API"},
{name: "Updater", description: "Update Valetudo using Valetudo"},
{name: "BasicControlCapability", description: "Basic control capability"},
{name: "FanSpeedControlCapability", description: "Fan speed control capability"},
@ -85,6 +86,7 @@ const options = {
path.join(__dirname, "./backend/lib/entities/map/doc/*.openapi.json"),
path.join(__dirname, "./backend/lib/entities/core/doc/*.openapi.json"),
path.join(__dirname, "./backend/lib/entities/core/ntpClient/doc/*.openapi.json"),
path.join(__dirname, "./backend/lib/entities/core/updater/doc/*.openapi.json"),
path.join(__dirname, "./backend/lib/entities/state/doc/*.openapi.json"),
path.join(__dirname, "./backend/lib/entities/state/attributes/doc/*.openapi.json"),
path.join(__dirname, "./backend/lib/core/capabilities/doc/*.openapi.json")

View File

@ -38,4 +38,4 @@ Object.values(binaries).forEach((path, i) => {
}
})
fs.writeFileSync("./build/manifest.json", JSON.stringify(manifest, null, 2))
fs.writeFileSync("./build/valetudo_release_manifest.json", JSON.stringify(manifest, null, 2))