"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"); 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" } }); } 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(paths: $drivePaths) { # path # interface # model # modelFamily # smartAvailable # smartEnabled # serialNumber # wwn # firmwareVersion # size # rpm # logicalSectorSize # physicalSectorSize # formFactor # ataVersion # sataVersion # 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); });