diff --git a/src/api/index.js b/src/api/index.js new file mode 100644 index 0000000..63673e2 --- /dev/null +++ b/src/api/index.js @@ -0,0 +1,222 @@ +"use strict"; + +const Promise = require("bluebird"); +const graphql = require("graphql"); +const fs = require("fs"); +const path = require("path"); + +const matchOrError = require("../match-or-error"); +const upperSnakeCase = require("../upper-snake-case"); +const All = require("../graphql/symbols/all"); +const createGraphQLInterface = require("../graphql/index"); +const {ID, LocalProperties, createDataObject} = require("../graphql/data-object"); + +const createLoaders = require("./loaders"); + +/* 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); + }, +}); + +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"; + } + } + } + }); +} + +module.exports = function () { + return createGraphQLInterface(schema, { loaderFactory: createLoaders }, { + 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 }); + }); + }); + } + } + } + }); +}; diff --git a/src/api/loaders.js b/src/api/loaders.js new file mode 100644 index 0000000..8015e53 --- /dev/null +++ b/src/api/loaders.js @@ -0,0 +1,98 @@ +"use strict"; + +const Promise = require("bluebird"); +const memoizee = require("memoizee"); +const DataLoader = require("dataloader"); + +const lvm = require("../wrappers/lvm"); +const smartctl = require("../wrappers/smartctl"); +const lsblk = require("../wrappers/lsblk"); +const findmnt = require("../wrappers/findmnt"); +const All = require("../graphql/symbols/all"); + +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; +} + +module.exports = 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 smartctlScanOnce = memoizee(smartctl.scan); + let lvmGetPhysicalVolumesOnce = memoizee(lvm.getPhysicalVolumes); + + let lsblkOnce = memoizee(() => { + return Promise.try(() => { + return lsblk(); + }).then((devices) => { + return { + tree: devices, + list: linearizeDevices(devices) + }; + }); + }); + + return { + lsblk: new DataLoader((names) => { + return Promise.try(() => { + return lsblkOnce(); + }).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(() => { + return smartctlScanOnce(); + }).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(() => { + return lvmGetPhysicalVolumesOnce(); + }).then((volumes) => { + return paths.map((path) => { + if (path === All) { + return volumes; + } else { + return volumes.find((device) => device.path === path); + } + }); + }); + }), + }; +}; \ No newline at end of file diff --git a/src/graphql-test.js b/src/graphql-test.js index 668af6a..3769570 100644 --- a/src/graphql-test.js +++ b/src/graphql-test.js @@ -2,21 +2,11 @@ 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(""); -} +const gql = require("./graphql/tag"); +const api = require("./api/index"); function debugDisplay(results) { if (results.errors != null && results.errors.length > 0) { @@ -52,370 +42,9 @@ function debugDisplay(results) { 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); -} +let makeQuery = api(); // 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 diff --git a/src/graphql/data-object.js b/src/graphql/data-object.js new file mode 100644 index 0000000..d7483c7 --- /dev/null +++ b/src/graphql/data-object.js @@ -0,0 +1,60 @@ +"use strict"; + +const Promise = require("bluebird"); + +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 ID = Symbol("ID"); +let LocalProperties = Symbol("localProperties"); + +module.exports = { + ID: ID, + LocalProperties: LocalProperties, + createDataObject: 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; + } +}; \ No newline at end of file diff --git a/src/graphql/index.js b/src/graphql/index.js new file mode 100644 index 0000000..3f4d391 --- /dev/null +++ b/src/graphql/index.js @@ -0,0 +1,11 @@ +"use strict"; + +const graphql = require("graphql"); + +module.exports = function createGraphQLInterface(schema, options, root) { + return function makeQuery(query, args) { + return graphql.graphql(schema, query, root, { + data: (options.loaderFactory != null) ? options.loaderFactory() : {} + }, args); + } +}; \ No newline at end of file diff --git a/src/graphql/symbols/all.js b/src/graphql/symbols/all.js new file mode 100644 index 0000000..a0d7e3e --- /dev/null +++ b/src/graphql/symbols/all.js @@ -0,0 +1,3 @@ +"use strict"; + +module.exports = Symbol("All"); \ No newline at end of file diff --git a/src/graphql/tag.js b/src/graphql/tag.js new file mode 100644 index 0000000..626a38c --- /dev/null +++ b/src/graphql/tag.js @@ -0,0 +1,5 @@ +"use strict"; + +module.exports = function gql(strings) { + return strings.join(""); +}; \ No newline at end of file