Break out GraphQL abstractions and API implementation

feature/node-rewrite
Sven Slootweg 5 years ago
parent 729c069048
commit db056bbe2f

@ -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 });
});
});
}
}
}
});
};

@ -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);
}
});
});
}),
};
};

@ -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

@ -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;
}
};

@ -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);
}
};

@ -0,0 +1,3 @@
"use strict";
module.exports = Symbol("All");

@ -0,0 +1,5 @@
"use strict";
module.exports = function gql(strings) {
return strings.join("");
};
Loading…
Cancel
Save