mirror-cockpit-project-cockpit/pkg/kdump/config-client.js
2026-10-16 17:49:20 +02:00

345 lines
14 KiB
JavaScript

/*
* Copyright (C) 2016 Red Hat, Inc.
* SPDX-License-Identifier: LGPL-2.1-or-later
*/
import cockpit from 'cockpit';
const deprecatedKeys = ["net", "options", "link_delay", "disk_timeout", "debug_mem_level", "blacklist"];
const knownKeys = [
"raw", "nfs", "ssh", "sshkey", "path", "core_collector", "kdump_post", "kdump_pre", "extra_bins", "extra_modules",
"default", "force_rebuild", "override_resettable", "dracut_args", "fence_kdump_args", "fence_kdump_nodes"
];
// man kdump.conf suggests this as default configuration
const defaultCoreCollector = "makedumpfile -l --message-level 7 -d 31";
/* Parse an ini-style config file
* and monitor it for changes
*/
export class ConfigFile {
constructor(filename, superuser = false) {
this.filename = filename;
this._rawContent = undefined;
this._lines = [];
this._originalSettings = { };
this._dataAvailable = new Promise(resolve => { this._dataAvailableResolve = resolve });
this.settings = { };
cockpit.event_target(this);
this._fileHandle = cockpit.file(filename, { superuser });
this._fileHandle.watch((rawContent) => {
this._parseText(rawContent);
});
}
close() {
if (this._fileHandle) {
this._fileHandle.close();
this._fileHandle = undefined;
}
}
// wait for data to have been read at least once
wait() {
return this._dataAvailable;
}
/* parse lines of the config file
* if a line has a valid config key, use that as key
* and also store original line, line index, value and whether the line contains a comment
* we care about the comment since we don't want to overwrite a user comment when changing a value
* e.g. for line "someKey foo # comment"
* outputObject["someKey"] = { index: 0, value: "foo", origLine: "someKey foo # comment", hasComment: true }
* skipNotify: Don't notify about changes, e.g.to avoid multiple updates when writing a file
*/
_parseText(rawContent, skipNotify = false) {
this._dataAvailableResolve();
// if nothing changed, don't bother parsing the content
// do proceed if the content is empty, it might be our initial read
if (!rawContent) {
this._originalSettings = null;
this.settings = null;
if (!skipNotify)
this.dispatchEvent("kdumpConfigChanged", this.settings);
return;
}
if (rawContent === this._rawContent)
return;
// if (skipNotify === undefined)
// skipNotify = false;
this._rawContent = rawContent;
// parse the config file
this._lines = rawContent.split(/\r?\n/);
// this is the format expected by the UI
this.settings = {
_internal: {},
targets: {},
compression: { enabled: false, allowed: false, },
nfs_supports_directory: true,
};
this._lines.forEach((line, index) => {
const trimmed = line.trim();
// if the line is empty or only a comment, skip
if (trimmed.indexOf("#") === 0 || trimmed.length === 0)
return;
// we need to have a space between key and value
const separatorIndex = trimmed.indexOf(" ");
if (separatorIndex === -1)
return;
const key = trimmed.substring(0, separatorIndex);
let value = trimmed.substring(separatorIndex + 1).trim();
// value might have a comment at the end
const commentIndex = value.indexOf("#");
let comment;
if (commentIndex !== -1) {
comment = value.substring(commentIndex).trim();
value = value.substring(0, commentIndex).trim();
}
this.settings._internal[key] = {
index,
value,
origLine: line,
comment
};
});
// make sure we copy the original keys so we overwrite the correct lines when saving
this._originalSettings = { };
Object.keys(this.settings._internal).forEach((key) => {
this._originalSettings[key] = { ...this.settings._internal[key] };
});
this._extractSettings();
if (!skipNotify)
this.dispatchEvent("kdumpConfigChanged", this.settings);
}
/* extract settings managed by cockpit from _internal into platform independent model
*/
_extractSettings() {
// "path" applies to all targets
const path = this.settings._internal.path || { value: "" };
Object.keys(this.settings._internal).forEach((key) => {
if (key === "nfs") {
// split nfs line into server and export parts
const parts = this.settings._internal.nfs.value.match(/^([^[][^:]+|\[[^\]]+\]):(.*)$/);
if (!parts)
return;
this.settings.targets.nfs = {
type: key,
path: path.value,
server: parts[1],
export: parts[2],
};
} else if (key === "ssh") {
this.settings.targets.ssh = {
type: key,
path: path.value,
server: this.settings._internal.ssh.value,
};
if ("sshkey" in this.settings._internal)
this.settings.targets.ssh.sshkey = this.settings._internal.sshkey.value;
} else if (key === "raw") {
this.settings.targets.raw = {
type: key,
partition: this.settings._internal.raw.value
};
} else {
// probably local, but we might also have a mount
// check against known keys, the ones left over may be a mount target
// if the key is empty or known, we don't care about it here
if (!key || key in knownKeys || key in deprecatedKeys)
return;
// if we have a UUID, LABEL or /dev in the value, we can be pretty sure it's a mount option
const value = JSON.stringify(this.settings._internal[key]).toLowerCase();
if (value.indexOf("uuid") > -1 || value.indexOf("label") > -1 || value.indexOf("/dev") > -1) {
this.settings.targets.mount = {
type: "mount",
path: path.value,
fsType: key,
partition: this.settings._internal[key].value,
};
} else {
// TODO: check for know filesystem types here
}
}
});
// default to local if no target configured
if (Object.keys(this.settings.targets).length === 0)
this.settings.targets.local = { type: "local", path: path.value };
// only allow compression if there is no core collector set or it's set to makedumpfile
this.settings.compression.allowed = (
!("core_collector" in this.settings._internal) ||
(this.settings._internal.core_collector.value.trim().indexOf("makedumpfile") === 0)
);
// compression is enabled if we have a core_collector command with the "-c" parameter
this.settings.compression.enabled = (
("core_collector" in this.settings._internal) &&
this.settings._internal.core_collector.value &&
(this.settings._internal.core_collector.value.split(" ").indexOf("-c") !== -1)
);
this.settings.core_collector = this.settings._internal?.core_collector?.value ?? defaultCoreCollector;
}
/* update single _internal setting to given value
* make sure setting exists if value is not empty
*/
_updateSetting(settings, key, value) {
if (key in settings._internal) {
if (value)
settings._internal[key].value = value;
else
delete settings._internal[key];
} else {
if (value)
settings._internal[key] = { value };
}
}
/* transform settings from model back to _internal format
* this.settings = current state from file
* settings = in-memory state from UI
*/
_persistSettings(settings) {
// target
if (Object.keys(settings.targets).length > 0) {
const target = Object.values(settings.targets)[0];
this._updateSetting(settings, "path", target.path);
// wipe old target settings
for (const key in this.settings.targets) {
const oldTarget = this.settings.targets[key];
if (oldTarget.type === "mount") {
delete settings._internal[oldTarget.fsType];
} else if (oldTarget.type === "ssh") {
delete settings._internal.ssh;
delete settings._internal.sshkey;
} else {
delete settings._internal[key];
}
}
if (target.type === "nfs") {
this._updateSetting(settings, "nfs", [target.server, target.export].join(":"));
} else if (target.type === "ssh") {
this._updateSetting(settings, "ssh", target.server);
if ("sshkey" in target)
this._updateSetting(settings, "sshkey", target.sshkey);
} else if (target.type === "raw") {
this._updateSetting(settings, "raw", target.partition);
} else if (target.type === "mount") {
this._updateSetting(settings, target.fsType, target.partition);
}
/* ssh target needs a flattened vmcore for transport */
if ("core_collector" in settings._internal &&
settings._internal.core_collector.value.includes("makedumpfile")) {
if (target.type === "ssh" && !settings._internal.core_collector.value.includes("-F"))
settings._internal.core_collector.value += " -F";
else if (settings._internal.core_collector.value.includes("-F"))
settings._internal.core_collector.value =
settings._internal.core_collector.value
.split(" ")
.filter(e => e !== "-F")
.join(" ");
} else {
settings._internal.core_collector = { value: defaultCoreCollector };
if (target.type === "ssh") {
settings._internal.core_collector.value += " -F";
}
}
}
// compression
if (this.settings.compression.enabled !== settings.compression.enabled) {
if (settings.compression.enabled) {
// enable compression
if ("core_collector" in settings._internal)
settings._internal.core_collector.value = settings._internal.core_collector.value + " -c";
else
settings._internal.core_collector = { value: defaultCoreCollector };
} else {
// disable compression
if ("core_collector" in this.settings._internal) {
// just remove all "-c" parameters
settings._internal.core_collector.value =
settings._internal.core_collector.value
.split(" ")
.filter((e) => { return (e !== "-c") })
.join(" ");
} else {
// if we don't have anything on this in the original settings,
// we can get rid of the entry altogether
delete settings._internal.core_collector;
}
}
}
return settings;
}
/* generate the config file from raw text and settings
*/
generateConfig(settings) {
settings = this._persistSettings(settings);
const lines = this._lines.slice(0);
const linesToDelete = [];
// first find the settings lines that have been disabled/deleted
Object.keys(this._originalSettings).forEach((key) => {
if (!(key in settings._internal) || !(key in settings._internal && settings._internal[key].value)) {
const origEntry = this._originalSettings[key];
// if the line had a comment, keep it, otherwise delete
if (origEntry.comment !== undefined)
lines[origEntry.index] = "#" + origEntry.origLine;
else
linesToDelete.push(origEntry.index);
}
});
// we take the lines from our last read operation and modify them with the new settings
Object.keys(settings._internal).forEach((key) => {
const entry = settings._internal[key];
let line = key + " " + entry.value;
if (entry.comment)
line = line + " " + entry.comment;
// this might be a new entry
if (!(key in this._originalSettings)) {
lines.push(line);
return;
}
// otherwise edit the old line
const origEntry = this._originalSettings[key];
lines[origEntry.index] = line;
});
// now delete the rows we want to delete
linesToDelete.sort().reverse()
.forEach((lineIndex) => {
lines.splice(lineIndex, 1);
});
return lines.join("\n") + "\n";
}
/* write settings back to file
* new settings that don't have a corresponding entry already have an undefined or null index
* returns a promise for the file operation (cockpit File)
*/
write(settings) {
return this._fileHandle.modify((oldContent) => {
this._parseText(oldContent, true);
return this.generateConfig(settings);
});
}
}