You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
532 lines
12 KiB
JavaScript
532 lines
12 KiB
JavaScript
"use strict";
|
|
|
|
const Promise = require("bluebird");
|
|
const graphql = require("graphql");
|
|
const DataLoader = require("dataloader");
|
|
const util = require("util");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const chalk = require("chalk");
|
|
|
|
const matchOrError = require("./match-or-error");
|
|
const lsblk = require("./wrappers/lsblk");
|
|
const smartctl = require("./wrappers/smartctl");
|
|
const lvm = require("./wrappers/lvm");
|
|
const upperSnakeCase = require("./upper-snake-case");
|
|
|
|
function gql(strings) {
|
|
return strings.join("");
|
|
}
|
|
|
|
function debugDisplay(results) {
|
|
if (results.errors != null && results.errors.length > 0) {
|
|
results.errors.forEach((graphqlError) => {
|
|
let errorHeader;
|
|
|
|
if (graphqlError.path != null) {
|
|
errorHeader = `Error occurred for path: ${graphqlError.path.join(" -> ")}`;
|
|
} else if (graphqlError.locations != null && graphqlError.locations.length > 0) {
|
|
errorHeader = `Error occurred at line ${graphqlError.locations[0].line}, column ${graphqlError.locations[0].column}`;
|
|
} else {
|
|
errorHeader = "Error occurred in GraphQL";
|
|
}
|
|
|
|
console.log(chalk.bgBlue.white(errorHeader));
|
|
|
|
let error = graphqlError.originalError;
|
|
|
|
if (error != null) {
|
|
if (error.showChain != null) {
|
|
console.log(error.showChain());
|
|
} else {
|
|
console.log(error.stack);
|
|
}
|
|
} else {
|
|
console.log(graphqlError.stack);
|
|
}
|
|
|
|
console.log("-----------------------------");
|
|
});
|
|
}
|
|
|
|
console.log(util.inspect(results.data, {colors: true, depth: null}));
|
|
}
|
|
|
|
/* FIXME: This seems to be added into a global registry somehow? How to specify this explicitly on a query without relying on globals? */
|
|
new graphql.GraphQLScalarType({
|
|
name: "ByteSize",
|
|
description: "A value that represents a value on a byte scale",
|
|
serialize: (value) => {
|
|
return JSON.stringify(value);
|
|
},
|
|
parseValue: (value) => {
|
|
return JSON.parse(value);
|
|
},
|
|
parseLiteral: (value) => {
|
|
return JSON.parse(value);
|
|
},
|
|
});
|
|
|
|
new graphql.GraphQLScalarType({
|
|
name: "TimeSize",
|
|
description: "A value that represents a value on a time scale",
|
|
serialize: (value) => {
|
|
return JSON.stringify(value);
|
|
},
|
|
parseValue: (value) => {
|
|
return JSON.parse(value);
|
|
},
|
|
parseLiteral: (value) => {
|
|
return JSON.parse(value);
|
|
},
|
|
});
|
|
|
|
function withProperty(dataSource, id, property) {
|
|
return withData(dataSource, id, (value) => {
|
|
return value[property];
|
|
});
|
|
}
|
|
|
|
function withData(dataSource, id, callback) {
|
|
return function (_, {data}) {
|
|
return Promise.try(() => {
|
|
if (data[dataSource] != null) {
|
|
return data[dataSource].load(id);
|
|
} else {
|
|
throw new Error(`Specified data source '${dataSource}' does not exist`);
|
|
}
|
|
}).then((value) => {
|
|
if (value != null) {
|
|
return callback(value);
|
|
} else {
|
|
throw new Error(`Got a null value from data source '${dataSource}' for ID '${id}'`);
|
|
}
|
|
});
|
|
};
|
|
}
|
|
|
|
let All = Symbol("All");
|
|
|
|
function createLoaders() {
|
|
/* The below is to ensure that commands that produce a full list of all possible items, only ever get called and processed *once* per query, no matter what data is requested. */
|
|
let lsblkPromise;
|
|
let smartctlPromise;
|
|
let lvmPhysicalVolumesPromise;
|
|
|
|
return {
|
|
lsblk: new DataLoader((names) => {
|
|
return Promise.try(() => {
|
|
if (lsblkPromise == null) {
|
|
lsblkPromise = Promise.try(() => {
|
|
return lsblk();
|
|
}).then((devices) => {
|
|
return {
|
|
tree: devices,
|
|
list: linearizeDevices(devices)
|
|
};
|
|
});
|
|
}
|
|
|
|
return lsblkPromise;
|
|
}).then(({tree, list}) => {
|
|
return names.map((name) => {
|
|
if (name === All) {
|
|
return tree;
|
|
} else {
|
|
return list.find((device) => device.name === name);
|
|
}
|
|
});
|
|
});
|
|
}),
|
|
smartctlScan: new DataLoader((paths) => {
|
|
return Promise.try(() => {
|
|
if (smartctlPromise == null) {
|
|
smartctlPromise = smartctl.scan();
|
|
}
|
|
|
|
return smartctlPromise;
|
|
}).then((devices) => {
|
|
return paths.map((path) => {
|
|
if (path === All) {
|
|
return devices;
|
|
} else {
|
|
return devices.find((device) => device.path === path);
|
|
}
|
|
});
|
|
});
|
|
}),
|
|
smartctlInfo: new DataLoader((paths) => {
|
|
return Promise.map(paths, (path) => {
|
|
return smartctl.info({ devicePath: path });
|
|
});
|
|
}),
|
|
smartctlAttributes: new DataLoader((paths) => {
|
|
return Promise.map(paths, (path) => {
|
|
return smartctl.attributes({ devicePath: path });
|
|
});
|
|
}),
|
|
lvmPhysicalVolumes: new DataLoader((paths) => {
|
|
return Promise.try(() => {
|
|
if (lvmPhysicalVolumesPromise == null) {
|
|
lvmPhysicalVolumesPromise = lvm.getPhysicalVolumes();
|
|
}
|
|
|
|
return lvmPhysicalVolumesPromise;
|
|
}).then((volumes) => {
|
|
return paths.map((path) => {
|
|
if (path === All) {
|
|
return volumes;
|
|
} else {
|
|
return volumes.find((device) => device.path === path);
|
|
}
|
|
});
|
|
});
|
|
}),
|
|
};
|
|
}
|
|
|
|
let ID = Symbol("ID");
|
|
let LocalProperties = Symbol("localProperties");
|
|
|
|
function createDataObject(mappings) {
|
|
let object = {};
|
|
|
|
if (mappings[LocalProperties] != null) {
|
|
Object.assign(object, mappings[LocalProperties]);
|
|
}
|
|
|
|
for (let [dataSource, items] of Object.entries(mappings)) {
|
|
if (items[ID] != null) {
|
|
let id = items[ID];
|
|
|
|
for (let [property, source] of Object.entries(items)) {
|
|
if (typeof source === "string") {
|
|
object[property] = withProperty(dataSource, id, source);
|
|
} else if (typeof source === "function") {
|
|
object[property] = withData(dataSource, id, source);
|
|
}
|
|
}
|
|
} else {
|
|
throw new Error(`No object ID was provided for the '${dataSource}' data source`);
|
|
}
|
|
}
|
|
|
|
return object;
|
|
}
|
|
|
|
// ###############################################
|
|
|
|
let schema = graphql.buildSchema(fs.readFileSync(path.resolve(__dirname, "./schemas/main.gql"), "utf8"));
|
|
|
|
function createBlockDevice({ name, path }) {
|
|
if (name != null) {
|
|
path = `/dev/${name}`;
|
|
} else if (path != null) {
|
|
let match = matchOrError(/^\/dev\/(.+)$/, path);
|
|
name = match[0];
|
|
}
|
|
|
|
/* FIXME: parent */
|
|
|
|
return createDataObject({
|
|
[LocalProperties]: {
|
|
path: path
|
|
},
|
|
lsblk: {
|
|
[ID]: name,
|
|
name: "name",
|
|
size: "size",
|
|
mountpoint: "mountpoint",
|
|
deviceNumber: "deviceNumber",
|
|
removable: "removable",
|
|
readOnly: "readOnly",
|
|
children: (device) => {
|
|
return device.children.map((child) => {
|
|
return createBlockDevice({ name: child.name });
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function createPhysicalVolume({ path }) {
|
|
return createDataObject({
|
|
[LocalProperties]: {
|
|
path: path,
|
|
blockDevice: () => {
|
|
return createBlockDevice({ path: path });
|
|
}
|
|
},
|
|
lvmPhysicalVolumes: {
|
|
[ID]: path,
|
|
volumeGroup: (volume) => {
|
|
if (volume.volumeGroup != null) {
|
|
return createVolumeGroup({ name: volume.volumeGroup });
|
|
}
|
|
},
|
|
format: "format",
|
|
size: "totalSpace",
|
|
freeSpace: "freeSpace",
|
|
duplicate: "isDuplicate",
|
|
allocatable: "isAllocatable",
|
|
used: "isUsed",
|
|
exported: "isExported",
|
|
missing: "isMissing"
|
|
}
|
|
});
|
|
}
|
|
|
|
function createVolumeGroup({ name }) {
|
|
return createDataObject({
|
|
[LocalProperties]: {
|
|
name: name
|
|
}
|
|
});
|
|
}
|
|
|
|
function createDrive({ path }) {
|
|
return createDataObject({
|
|
[LocalProperties]: {
|
|
path: path,
|
|
blockDevice: () => {
|
|
return createBlockDevice({ path: path });
|
|
},
|
|
/* FIXME: allBlockDevices, for representing every single block device that's hosted on this physical drive, linearly. Need to figure out how that works with representation of mdraid arrays, LVM volumes, etc. */
|
|
},
|
|
smartctlScan: {
|
|
[ID]: path,
|
|
interface: "interface"
|
|
},
|
|
smartctlInfo: {
|
|
[ID]: path,
|
|
model: "model",
|
|
modelFamily: "modelFamily",
|
|
smartAvailable: "smartAvailable",
|
|
smartEnabled: "smartEnabled",
|
|
serialNumber: "serialNumber",
|
|
wwn: "wwn",
|
|
firmwareVersion: "firmwareVersion",
|
|
size: "size",
|
|
rpm: "rpm",
|
|
logicalSectorSize: (device) => device.sectorSizes.logical,
|
|
physicalSectorSize: (device) => device.sectorSizes.physical,
|
|
formFactor: "formFactor",
|
|
ataVersion: "ataVersion",
|
|
sataVersion: "sataVersion"
|
|
},
|
|
smartctlAttributes: {
|
|
[ID]: path,
|
|
smartAttributes: (attributes) => {
|
|
return attributes.map((attribute) => {
|
|
return Object.assign({}, attribute, {
|
|
type: upperSnakeCase(attribute.type),
|
|
updatedWhen: upperSnakeCase(attribute.updatedWhen)
|
|
});
|
|
});
|
|
},
|
|
smartHealth: (attributes) => {
|
|
let failed = attributes.filter((item) => {
|
|
return (item.failingNow === true || item.failedBefore === true);
|
|
});
|
|
|
|
let deteriorating = attributes.filter((item) => {
|
|
return (item.type === "preFail" && item.worstValueSeen < 100);
|
|
});
|
|
|
|
if (failed.length > 0) {
|
|
return "FAILING";
|
|
} else if (deteriorating.length > 0) {
|
|
return "DETERIORATING";
|
|
} else {
|
|
return "HEALTHY";
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function linearizeDevices(devices) {
|
|
let linearizedDevices = [];
|
|
|
|
function add(list) {
|
|
for (let device of list) {
|
|
linearizedDevices.push(device);
|
|
|
|
if (device.children != null) {
|
|
add(device.children);
|
|
}
|
|
}
|
|
}
|
|
|
|
add(devices);
|
|
|
|
return linearizedDevices;
|
|
}
|
|
|
|
let root = {
|
|
hardware: {
|
|
drives: function ({ paths }, { data }) {
|
|
return Promise.try(() => {
|
|
if (paths != null) {
|
|
return data.smartctlScan.loadMany(paths);
|
|
} else {
|
|
return data.smartctlScan.load(All);
|
|
}
|
|
}).then((devices) => {
|
|
return devices.map((device) => {
|
|
return createDrive({ path: device.path });
|
|
});
|
|
});
|
|
}
|
|
},
|
|
resources: {
|
|
blockDevices: function ({ names }, { data }) {
|
|
return Promise.try(() => {
|
|
if (names != null) {
|
|
return data.lsblk.loadMany(names);
|
|
} else {
|
|
return data.lsblk.load(All);
|
|
}
|
|
}).then((devices) => {
|
|
return devices.map((device) => {
|
|
return createBlockDevice({ name: device.name });
|
|
});
|
|
});
|
|
},
|
|
lvm: {
|
|
physicalVolumes: function ({ paths }, { data }) {
|
|
return Promise.try(() => {
|
|
if (paths != null) {
|
|
return data.lvmPhysicalVolumes.loadMany(paths);
|
|
} else {
|
|
return data.lvmPhysicalVolumes.load(All);
|
|
}
|
|
}).then((volumes) => {
|
|
return volumes.map((volume) => {
|
|
return createPhysicalVolume({ path: volume.path });
|
|
});
|
|
});
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
function makeQuery(query, args) {
|
|
return graphql.graphql(schema, query, root, {
|
|
data: createLoaders()
|
|
}, args);
|
|
}
|
|
|
|
// FIXME: If we intend to target macOS, a lot of whitespace-based output splitting won't work: https://www.mail-archive.com/austin-group-l@opengroup.org/msg01678.html
|
|
|
|
// findmnt --json -o +SIZE,AVAIL
|
|
// -> map back to mountPoint stuff?
|
|
// blkid
|
|
// to discover the filesystem that a given path exists on: stat -c %m
|
|
// partx
|
|
// (rest of util-linux)
|
|
// memory usage: /proc/meminfo
|
|
|
|
return Promise.try(() => {
|
|
let query = gql`
|
|
# query SomeDrives($drivePaths: [String]) {
|
|
query SomeDrives {
|
|
hardware {
|
|
drives {
|
|
path
|
|
interface
|
|
|
|
model
|
|
modelFamily
|
|
smartAvailable
|
|
smartEnabled
|
|
serialNumber
|
|
wwn
|
|
firmwareVersion
|
|
size
|
|
rpm
|
|
logicalSectorSize
|
|
physicalSectorSize
|
|
formFactor
|
|
ataVersion
|
|
sataVersion
|
|
|
|
smartHealth
|
|
# smartAttributes {
|
|
# name
|
|
# type
|
|
# value
|
|
# failingNow
|
|
|
|
# flags {
|
|
# affectsPerformance
|
|
# indicatesFailure
|
|
# }
|
|
# }
|
|
|
|
# blockDevice {
|
|
# removable
|
|
|
|
# children {
|
|
# name
|
|
# mountpoint
|
|
# size
|
|
# }
|
|
# }
|
|
}
|
|
}
|
|
|
|
# resources {
|
|
# blockDevices {
|
|
# name
|
|
# mountpoint
|
|
# size
|
|
# deviceNumber
|
|
# removable
|
|
# readOnly
|
|
# parent { name }
|
|
|
|
# children {
|
|
# name
|
|
# mountpoint
|
|
# size
|
|
# deviceNumber
|
|
# removable
|
|
# readOnly
|
|
# parent { name }
|
|
# }
|
|
# }
|
|
|
|
# lvm {
|
|
# physicalVolumes {
|
|
# path
|
|
|
|
# blockDevice {
|
|
# name
|
|
# deviceNumber
|
|
# }
|
|
|
|
# volumeGroup {
|
|
# name
|
|
# }
|
|
|
|
# format
|
|
# size
|
|
# freeSpace
|
|
# duplicate
|
|
# allocatable
|
|
# used
|
|
# exported
|
|
# missing
|
|
# }
|
|
# }
|
|
# }
|
|
}
|
|
`;
|
|
|
|
return makeQuery(query, {
|
|
// drivePaths: ["/dev/sda", "/dev/sdb"]
|
|
});
|
|
}).then((results) => {
|
|
debugDisplay(results);
|
|
}); |