Browse Source

WIP

feature/node-rewrite
Sven Slootweg 3 months ago
parent
commit
b9fc50c0d2
96 changed files with 5709 additions and 6783 deletions
  1. +16
    -0
      .eslintrc
  2. +0
    -78
      .eslintrc.js
  3. +3
    -1
      .gitignore
  4. +31
    -0
      .vscode/launch.json
  5. +1
    -0
      babel.config.json
  6. +8
    -5
      bin/server.js
  7. +6
    -5
      knexfile.js
  8. +140
    -0
      notes.txt
  9. +8
    -1
      notes/notes.txt
  10. +41
    -35
      package.json
  11. +9
    -0
      public/css/style.css
  12. +52
    -0
      src/api/data-sources/findmnt.js
  13. +39
    -13
      src/api/data-sources/lsblk.js
  14. +3
    -3
      src/api/data-sources/lvm/physical-volumes.js
  15. +13
    -0
      src/api/data-sources/nvme/list-namespaces.js
  16. +2
    -2
      src/api/data-sources/smartctl/attributes.js
  17. +2
    -2
      src/api/data-sources/smartctl/info.js
  18. +3
    -3
      src/api/data-sources/smartctl/scan.js
  19. +4
    -3
      src/api/index.js
  20. +3
    -1
      src/api/loaders.js
  21. +68
    -17
      src/api/types/block-device.js
  22. +81
    -22
      src/api/types/drive.js
  23. +2
    -2
      src/api/types/lvm-physical-volume.js
  24. +2
    -2
      src/api/types/lvm-volume-group.js
  25. +87
    -0
      src/api/types/mount.js
  26. +29
    -10
      src/app.js
  27. +6
    -0
      src/concat.js
  28. +0
    -8
      src/device-name-from-path.js
  29. +9
    -18
      src/errors.js
  30. +0
    -375
      src/exec-binary.js
  31. +121
    -85
      src/graphql-test.js
  32. +0
    -62
      src/graphql/data-object.js
  33. +0
    -11
      src/graphql/index.js
  34. +0
    -19
      src/linearize-tree.js
  35. +0
    -11
      src/map-value.js
  36. +0
    -17
      src/match-or-error.js
  37. +11
    -0
      src/packages/exec-binary/errors.js
  38. +285
    -0
      src/packages/exec-binary/index.js
  39. +23
    -15
      src/packages/exec-findmnt/index.js
  40. +67
    -0
      src/packages/exec-lsblk/index.js
  41. +12
    -0
      src/packages/exec-lvm/errors.js
  42. +7
    -4
      src/packages/exec-lvm/index.js
  43. +42
    -0
      src/packages/exec-nvme-cli/index.js
  44. +57
    -0
      src/packages/exec-smartctl/index.js
  45. +16
    -0
      src/packages/exec-smartctl/map-attribute-flags.js
  46. +249
    -0
      src/packages/exec-smartctl/parser.pegjs
  47. +0
    -0
      src/packages/express-async-react/clear-require-cache.js
  48. +4
    -2
      src/packages/express-async-react/index.js
  49. +0
    -0
      src/packages/express-async-react/register-babel.js
  50. +22
    -0
      src/packages/find-in-tree/example.js
  51. +35
    -0
      src/packages/find-in-tree/index.js
  52. +126
    -0
      src/packages/graphql-interface/data-object.js
  53. +17
    -0
      src/packages/graphql-interface/index.js
  54. +0
    -0
      src/packages/graphql-interface/symbols/all.js
  55. +0
    -0
      src/packages/graphql-interface/tag.js
  56. +0
    -0
      src/packages/graphql-interface/type-loader.js
  57. +9
    -0
      src/packages/items-to-object/index.js
  58. +33
    -3
      src/packages/make-units/index.js
  59. +15
    -0
      src/packages/map-tree/index.js
  60. +25
    -0
      src/packages/match-or-error/index.js
  61. +9
    -0
      src/packages/maybe-prefix/index.js
  62. +3
    -3
      src/packages/parse-bytes-iec/index.js
  63. +2
    -2
      src/packages/parse-memparse-value/index.js
  64. +25
    -10
      src/packages/parse-mount-options/index.js
  65. +29
    -19
      src/packages/parse-octal-mode/index.js
  66. +5
    -0
      src/packages/shallow-merge/index.js
  67. +10
    -0
      src/packages/text-parser-json/index.js
  68. +73
    -0
      src/packages/text-parser-pegjs/index.js
  69. +11
    -0
      src/packages/text-parser/index.js
  70. +11
    -0
      src/packages/text-parser/test.js
  71. +13
    -0
      src/packages/text-parser/test.pegjs
  72. +6
    -0
      src/packages/treecutter/README.md
  73. +33
    -0
      src/packages/treecutter/example.js
  74. +111
    -0
      src/packages/treecutter/index.js
  75. +2
    -2
      src/packages/unit-bytes-iec/index.js
  76. +1
    -1
      src/packages/unit-time/index.js
  77. +6
    -0
      src/packages/unreachable/index.js
  78. +1
    -1
      src/packages/upper-snake-case/index.js
  79. +0
    -9
      src/prefix-title.js
  80. +43
    -43
      src/routes/storage-devices.js
  81. +33
    -7
      src/schemas/main.gql
  82. +14
    -1
      src/scss/style.scss
  83. +8
    -5
      src/test-wrapper.js
  84. +8
    -0
      src/util/device-name-from-path.js
  85. +1
    -1
      src/util/image-store.js
  86. +0
    -0
      src/util/is-under-prefix.js
  87. +0
    -0
      src/util/tag-message.js
  88. +17
    -28
      src/validators/disk-images/add.js
  89. +3
    -3
      src/views/components/menu-item.jsx
  90. +15
    -1
      src/views/error.jsx
  91. +2
    -2
      src/views/hardware/layout.jsx
  92. +54
    -12
      src/views/hardware/storage-devices/list.jsx
  93. +0
    -51
      src/wrappers/lsblk.js
  94. +0
    -159
      src/wrappers/smartctl.js
  95. +58
    -0
      src/wrappers/smartctl/index.js
  96. +3258
    -5588
      yarn.lock

+ 16
- 0
.eslintrc View File

@@ -0,0 +1,16 @@
{
"extends": "@joepie91/eslint-config/react",
"parserOptions": {
"ecmaVersion": 2020,
"sourceType": "script"
},
"parser": "babel-eslint",
"plugins": [
"babel",
"import"
],
"rules": {
"import/no-extraneous-dependencies": 2,
"import/no-unresolved": [2, { "commonjs": true }]
}
}

+ 0
- 78
.eslintrc.js View File

@@ -1,78 +0,0 @@
module.exports = {
"env": {
"browser": true,
"commonjs": true,
"es6": true,
"node": true
},
"parserOptions": {
"ecmaFeatures": {
"experimentalObjectRestSpread": true,
"jsx": true
}
},
"plugins": [
"react"
],
"rules": {
/* Things that should effectively be syntax errors. */
"indent": [ "error", "tab", {
SwitchCase: 1
}],
"linebreak-style": [ "error", "unix" ],
"semi": [ "error", "always" ],
/* Things that are always mistakes. */
"getter-return": [ "error" ],
"no-compare-neg-zero": [ "error" ],
"no-dupe-args": [ "error" ],
"no-dupe-keys": [ "error" ],
"no-duplicate-case": [ "error" ],
"no-empty": [ "error" ],
"no-empty-character-class": [ "error" ],
"no-ex-assign": [ "error" ],
"no-extra-semi": [ "error" ],
"no-func-assign": [ "error" ],
"no-invalid-regexp": [ "error" ],
"no-irregular-whitespace": [ "error" ],
"no-obj-calls": [ "error" ],
"no-sparse-arrays": [ "error" ],
"no-undef": [ "error" ],
"no-unreachable": [ "error" ],
"no-unsafe-finally": [ "error" ],
"use-isnan": [ "error" ],
"valid-typeof": [ "error" ],
"curly": [ "error" ],
"no-caller": [ "error" ],
"no-fallthrough": [ "error" ],
"no-extra-bind": [ "error" ],
"no-extra-label": [ "error" ],
"array-callback-return": [ "error" ],
"prefer-promise-reject-errors": [ "error" ],
"no-with": [ "error" ],
"no-useless-concat": [ "error" ],
"no-unused-labels": [ "error" ],
"no-unused-expressions": [ "error" ],
"no-unused-vars": [ "error" , { argsIgnorePattern: "^_" } ],
"no-return-assign": [ "error" ],
"no-self-assign": [ "error" ],
"no-new-wrappers": [ "error" ],
"no-redeclare": [ "error" ],
"no-loop-func": [ "error" ],
"no-implicit-globals": [ "error" ],
"strict": [ "error", "global" ],
/* Make JSX not cause 'unused variable' errors. */
"react/jsx-uses-react": ["error"],
"react/jsx-uses-vars": ["error"],
/* Development code that should be removed before deployment. */
"no-console": [ "warn" ],
"no-constant-condition": [ "warn" ],
"no-debugger": [ "warn" ],
"no-alert": [ "warn" ],
"no-warning-comments": ["warn", {
terms: ["fixme"]
}],
/* Common mistakes that can *occasionally* be intentional. */
"no-template-curly-in-string": ["warn"],
"no-unsafe-negation": [ "warn" ],
}
};

+ 3
- 1
.gitignore View File

@@ -1,4 +1,6 @@
config.json
node_modules
images
disks
disks
yarn-error.log
junk

+ 31
- 0
.vscode/launch.json View File

@@ -0,0 +1,31 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "attach",
"name": "Attach to Process",
"address": "localhost",
"port": 9229,
"localRoot": "${workspaceFolder}",
"remoteRoot": "${workspaceFolder}",
"restart": true,
"skipFiles": [
"<node_internals>/**",
"node_modules/**"
],
},
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"program": "${workspaceFolder}/bin/server.js",
"skipFiles": [
"<node_internals>/**"
],
}
]
}

+ 1
- 0
babel.config.json View File

@@ -0,0 +1 @@
{ "presets": ["@babel/preset-env"] }

+ 8
- 5
bin/server.js View File

@@ -1,17 +1,20 @@
"use strict";

// FIXME: Use this in dev only, have a prod compile/build step
require("@babel/register");

const budoExpress = require("budo-express");
const path = require("path");

budoExpress({
port: 8000,
debug: true,
sourceMaps: true,
expressApp: require("../src/app")(),
basePath: path.resolve(__dirname, ".."),
entryPath: "src/client/index.jsx",
publicPath: "public",
entryFiles: "src/client/index.jsx",
staticPath: "public",
bundlePath: "js/bundle.js",
livereload: "**/*.{css,html}",
livereloadPattern: "**/*.{css,html}",
browserify: {
extensions: [".jsx"],
plugin: [
@@ -24,4 +27,4 @@ budoExpress({
}]
]
},
});
});

+ 6
- 5
knexfile.js View File

@@ -1,17 +1,18 @@
'use strict';

const postgresqlSocketUrl = require("postgresql-socket-url");
const config = require("./config.json");

module.exports = {
client: "pg",
connection: {
database: "cvm",
charset: "utf8",
username: config.database.username,
password: config.database.password
connectionString: postgresqlSocketUrl({
socketPath: "/tmp",
database: config.database
})
},
pool: {
min: 2,
max: 10
}
}
};

+ 140
- 0
notes.txt View File

@@ -0,0 +1,140 @@
MARKER:
- Replace local `unreachable` with @joepie91/unreachable
- Update all Validatem usage to new validateArguments API
- LVM / mdraid support and tabs (+ complete refactoring LVM implementation)
- Switch hashing to argon2id
- Switch child_process to execa

IDEAS:
- contextual sidebar on add/edit form pages that shows/highlights all the relevant data for deciding what to fill into the form
- eg. all storage devices and pools when creating a new volume
- or highlighting the currently-editing volume in an edit screen

----------------------

API architecture

- Level 0: (src/wrappers) Data source implementations
- Eg. output-parsing wrappers using `execBinary`, but implementations might also be provided by a third-party module entirely
- The APIs for these are specific to the implementation
- Level 1: (src/api/data-sources) Data source connectors
- These provide a standardized interface over the data source implementations, exposing each individual semantically distinct operation as a function
- That function takes either of:
- An array of identifiers of 'items' to obtain information about
- The `All` symbol to obtain all items
- Level 2: (src/graphql/data-object) The 'data object' abstraction
- Takes in a definition of a GraphQL object's structure, and which properties should be obtained from what data source connectors
- Definition structured as (dataSource => (field => dataGetter))
- The `dataSource` may either be:
- The name of the data source connector to obtain the source data from
- The special `LocalProperties` symbol, which specifies:
- Data that is immediately known upon object instantiation, and doesn't require accessing a data source
- Eg. the identifier that the object was initialized with
- Functions that produce data objects of other types, the instantiation of which doesn't require accessing a data source
- Eg. because it is initialized with the same identifier
- The `field` may either be:
- A string name, in which case it defines how to resolve that specific property on the data object
- The special `ID` symbol, in which case it defines by which identifier to request the 'thing' from the data source connector.
- Usually this will be the identifier that the data object is initialized with.
- The `dataGetter` is either:
- A function, mapping from the source data to a value, called with (sourceData, queryArgs, context)
- sourceData: The result object originating from the data source lookup
- queryArgs: The arguments passed to the property access in the GraphQL query
- context: The full GraphQL context + 'properties' key if DependsOn is used
- A string, specifying the property to extract from the source data, equivalent to `(sourceData) => sourceData[property]`
- NOTE: The dataSources are not specified directly in the data object definition! They're provided via GraphQL context separately.
- Level 3: (src/api/types)
- The actual data object definitions
- Parametric modules, take the full set of types as their argument
- Specified as a *function that instantiates and returns* a newly created data object, when initialized with some sort of identifier value
- Eg. the 'path' for a block device, or the 'ID' for a user
- The instantiation function is free to choose the arguments it accepts for initialization (and how to use them), but a destructured object is recommended

------------

Dynamic data lookup

Sometimes there are special cases where we can't (reliably) obtain particular data from the same source, eg. the correct data source connector to invoke may be dependent on some other data in the object. Need to figure out an API that allows representing this ergonomically.

Maybe have an async "resolve these data sources" API that can be used from within a custom handler? This would sidestep the issue where particularly complex cases are hard or impossible to represent in a declarative format, by just making it custom logic entirely.

Maybe something similar for resolving properties defined elsewhere on the object? Otherwise any custom handler in the [Dynamic] block would invoke the handlers for *all* of these dependencies (which are specified on a block level), even when they are not needed for that particular handler.

-------------

execBinary redesign

- requireOnStdout
- expectOnStdout
- failOnStdout

- requireOnStderr
- expectOnStderr
- failOnStderr

Types of handling:
- requireOn*: a result must be produced by the parsing adapter
- expectOn*: a result *may* be produced by the parsing adapter
- failOn*: if a result is produced by the parsing adapter, that constitutes an error

Adapter:
A { create: Function, supportsStreams: Boolean } object that, upon initialization/calling `create`, returns a function that takes the string or stream of output, and returns a result or throws an error/NoResult. Example adapters:
- matchLiteral: match a literal string
- booleanResult: switches from "return undefined or throw NoResult" to "return true or false"
- matchRegex: match a regular expression and extract data
- matchPeg: run a PEG parser and use its output
- matchMultiple: run multiple adapters and combine the results into a single (keyed) object

matchMultiple example:
matchMultiple({
deviceName: matchRegex(/name: (.+)/, ([ name ]) => name),
isNVMe: matchLiteral("protocol: NVMe", { booleanResult: true })
})




- Different kinds of output handling:
- expect*: require that the handler produce a result
- if result: OK
- if no result: fail
- if parsing error: fail
- handle*: optionally produce a result
- if result: OK
- if no result: OK
- if parsing error: fail
- fail*: when output is detected, produce an error
- if result: fail
- if no result: OK
- if parsing error: fail
- expectStderr (convert stderr to success result) vs. detectStderr (convert stderr to thrown error)
- expectStdout
- expectEmptyOutput

Create various utility methods for parsing stdout/stderr, that can be used separately within the expect* and detect* methods

Some sort of matchAll([ .. ]) utility for returning the results of multiple handlers/extractors? Maybe follow the 'messages' model that PostCSS follows?
Interceptor model? That can also produce messages, and modify the flags and such of the invocation

TODO: Publish error-chain! Separating out the error chaining itself, from the display
Adapt from other cause-retaining error types
full-chain instanceof?


---------------

Glossary

Bind mount
"Mounts" a folder on one (mounted) filesystem, as a separate mount/filesystem, essentially mirroring it under another location
Loopback device
Virtual block device that can be mounted, and is backed by a *file* on another (mounted) filesystem.


----------------

Utilities

fuser
Show which processes use the named files, sockets, or filesystems.

+ 8
- 1
notes/notes.txt View File

@@ -9,6 +9,7 @@ Research questions:

Todo list:
- UI: Convert existing Pug templates to JSX/GraphQL
- Finalize the conversion of the physical drives page (totals etc.)
- GraphQL API: Add database support
- GraphQL API: Memory resources (usage, available, etc.)
- Wrappers: Add error handling to smartctl wrapper
@@ -643,4 +644,10 @@ Hardware -> Storage Devices
- Allocation:usage ratio
- IOPS
- Read/Write traffic
- Read/Write latency
- Read/Write latency

------

lsblk

- Use `bytes` flag to get sizes in JSON output in bytes, rather than as a unit string!

+ 41
- 35
package.json View File

@@ -4,7 +4,7 @@
"description": "A VPS management panel",
"main": "index.js",
"scripts": {
"dev": "NODE_ENV=development nodemon --ext js,pug,jsx,gql --ignore node_modules --ignore src/client bin/server.js"
"dev": "NODE_ENV=development nodemon --ext js,pug,jsx,gql,pegjs --ignore node_modules --ignore src/client --inspect=9229 bin/server.js"
},
"repository": {
"type": "git",
@@ -13,20 +13,31 @@
"author": "Sven Slootweg",
"license": "WTFPL",
"dependencies": {
"@babel/register": "^7.4.0",
"@babel/register": "^7.8.3",
"@joepie91/express-react-views": "^1.0.1",
"@joepie91/gulp-partial-patch-livereload-logger": "^1.0.1",
"@validatem/allow-extra-properties": "^0.1.0",
"@validatem/anything": "^0.1.0",
"@validatem/array-of": "^0.1.2",
"@validatem/core": "^0.3.15",
"@validatem/dynamic": "^0.1.2",
"@validatem/is-number": "^0.1.3",
"@validatem/is-regular-expression": "^0.1.0",
"@validatem/is-string": "^1.0.0",
"@validatem/required": "^0.1.1",
"@validatem/when": "^0.1.0",
"JSONStream": "^1.1.4",
"argon2": "^0.27.0",
"array.prototype.flat": "^1.2.1",
"as-expression": "^1.0.0",
"assure-array": "^1.0.0",
"bhttp": "^1.2.4",
"bignumber.js": "^8.1.1",
"bluebird": "^3.4.6",
"body-parser": "^1.15.2",
"budo-express": "^1.0.2",
"capitalize": "^2.0.0",
"checkit": "^0.7.0",
"chalk": "^4.1.0",
"classnames": "^2.2.6",
"create-error": "^0.3.1",
"create-event-emitter": "^1.0.0",
"dataloader": "^1.4.0",
"debounce": "^1.0.0",
@@ -34,61 +45,56 @@
"default-value": "^1.0.0",
"dotty": "^0.1.0",
"end-of-stream": "^1.1.0",
"entities": "^2.0.0",
"error-chain": "^0.1.2",
"escape-string-regexp": "^2.0.0",
"eval": "^0.1.4",
"execall": "^1.0.0",
"express": "^4.14.0",
"express-promise-router": "^1.1.0",
"express-ws": "^3.0.0",
"fs-extra": "^3.0.1",
"function-rate-limit": "^1.1.0",
"generate-lookup-table": "^1.0.0",
"graphql": "^14.2.1",
"joi": "^14.3.0",
"is-iterable": "^1.1.1",
"is-plain-obj": "^2.1.0",
"knex": "^0.13.0",
"map-obj": "^3.0.0",
"match-value": "^1.1.0",
"memoizee": "^0.4.14",
"nanoid": "^2.1.11",
"object.fromentries": "^2.0.2",
"pegjs": "^0.10.0",
"pg": "^6.1.0",
"pug": "^2.0.0-beta6",
"postgresql-socket-url": "^1.0.0",
"react-dom": "^16.8.6",
"rfr": "^1.2.3",
"scrypt-for-humans": "^2.0.5",
"snake-case": "^2.1.0",
"split": "^1.0.0",
"sse-channel": "^3.1.1",
"syncpipe": "^1.0.0",
"through2": "^2.0.1",
"uuid": "^2.0.2"
"uuid": "^2.0.2",
"validatem": "^0.2.0"
},
"devDependencies": {
"@babel/core": "^7.1.6",
"@babel/preset-env": "^7.1.6",
"@babel/core": "^7.8.4",
"@babel/node": "^7.8.4",
"@babel/preset-env": "^7.8.4",
"@babel/preset-react": "^7.0.0",
"@joepie91/gulp-preset-es2015": "^1.0.1",
"@joepie91/gulp-preset-scss": "^1.0.1",
"babel-core": "^6.14.0",
"babel-loader": "^6.4.1",
"babel-preset-es2015": "^6.14.0",
"babel-preset-es2015-riot": "^1.1.0",
"@joepie91/eslint-config": "^1.1.0",
"babel-eslint": "^10.0.3",
"babelify": "^10.0.0",
"browserify-hmr": "^0.3.7",
"budo": "^11.5.0",
"chokidar": "^1.6.0",
"eslint": "^5.16.0",
"eslint": "^6.8.0",
"eslint-plugin-babel": "^5.3.0",
"eslint-plugin-import": "^2.20.1",
"eslint-plugin-react": "^7.12.4",
"gulp": "^3.9.1",
"gulp-cached": "^1.1.0",
"gulp-livereload": "^3.8.1",
"gulp-named-log": "^1.0.1",
"gulp-nodemon": "^2.1.0",
"gulp-rename": "^1.2.2",
"jade": "^1.11.0",
"json-loader": "^0.5.4",
"listening": "^0.1.0",
"eslint-plugin-react-hooks": "^2.4.0",
"nodemon": "^1.18.11",
"npm-check-licenses": "^1.0.5",
"react": "^16.8.6",
"react-hot-loader": "^4.3.12",
"riot": "^3.6.1",
"riotjs-loader": "^4.0.0",
"tiny-lr": "^0.2.1",
"webpack": "^1.15.0",
"webpack-stream": "^3.2.0"
"react-hot-loader": "^4.3.12"
}
}

+ 9
- 0
public/css/style.css View File

@@ -60,6 +60,9 @@ table {
table td.hidden {
border: none; }

table.drives td {
vertical-align: top; }

table.drives td.smart.HEALTHY {
background-color: #00a500; }

@@ -93,3 +96,9 @@ table.drives th.atRisk {

table.drives th.failing {
color: #c20000; }

.stacktrace {
white-space: pre-wrap;
font-family: monospace; }
.stacktrace .irrelevant {
color: gray; }

+ 52
- 0
src/api/data-sources/findmnt.js View File

@@ -0,0 +1,52 @@
"use strict";

const Promise = require("bluebird");
const memoizee = require("memoizee");
const fs = Promise.promisifyAll(require("fs"));
const treecutter = require("../../packages/treecutter");
const findmnt = require("../../packages/exec-findmnt");
const shallowMerge = require("../../packages/shallow-merge");
const All = require("../../packages/graphql-interface/symbols/all");

module.exports = function () {
let findmntOnce = memoizee(() => {
return Promise.try(() => {
return findmnt();
}).then((mounts) => {
return treecutter.flatten(mounts);
}).map((mount) => {
if (mount.sourceDevice?.startsWith("/")) {
return Promise.try(() => {
return fs.realpathAsync(mount.sourceDevice);
}).then((actualSourcePath) => {
return shallowMerge(mount, {
sourceDevice: actualSourcePath
});
});
} else {
return mount;
}
}).then((list) => {
let tree = treecutter.rebuild(list);

return {
tree: tree,
list: list
};
});
});

return function (mountpoints) {
return Promise.try(() => {
return findmntOnce();
}).then(({tree, list}) => {
return mountpoints.map((mountpoint) => {
if (mountpoint === All) {
return tree;
} else {
return list.find((mount) => mount.mountpoint === mountpoint);
}
});
});
};
};

+ 39
- 13
src/api/data-sources/lsblk.js View File

@@ -2,35 +2,61 @@

const Promise = require("bluebird");
const memoizee = require("memoizee");

const linearizeTree = require("../../linearize-tree");
const lsblk = require("../../wrappers/lsblk");
const All = require("../../graphql/symbols/all");
const asExpression = require("as-expression");
const fs = Promise.promisifyAll(require("fs"));
const lsblk = require("../../packages/exec-lsblk");
const All = require("../../packages/graphql-interface/symbols/all");
const treecutter = require("../../packages/treecutter");
const findInTree = require("../../packages/find-in-tree");
const shallowMerge = require("../../packages/shallow-merge");
const unreachable = require("../../packages/unreachable");

module.exports = function () {
let lsblkOnce = memoizee(() => {
return Promise.try(() => {
return lsblk();
}).then((tree) => {
return treecutter.flatten(tree);
}).map((device) => {
return Promise.try(() => {
return fs.realpathAsync(device.path);
}).then((actualPath) => {
return shallowMerge(device, {
path: actualPath
});
});
}).then((devices) => {
return {
tree: devices,
list: linearizeTree(devices)
tree: treecutter.rebuild(devices),
list: devices
};
});
});

return function (names) {
return function (selectors) {
return Promise.try(() => {
return lsblkOnce();
}).then(({tree, list}) => {
return names.map((name) => {
if (name === All) {
return tree;
return selectors.map((selector) => {
if (selector === All) {
// return tree;
return list;
} else {
return list.find((device) => device.name === name);
let { path, name } = selector;

let predicate = asExpression(() => {
if (path != null) {
return (device) => device.path === path;
} else if (name != null) {
return (device) => device.name === name;
} else {
unreachable("No selector specified for lsblk");
}
});

return findInTree({ tree, predicate });
}
});
});
};
};
};

+ 3
- 3
src/api/data-sources/lvm/physical-volumes.js View File

@@ -3,8 +3,8 @@
const Promise = require("bluebird");
const memoizee = require("memoizee");

const lvm = require("../../../wrappers/lvm");
const All = require("../../../graphql/symbols/all");
const lvm = require("../../../packages/exec-lvm");
const All = require("../../../packages/graphql-interface/symbols/all");

module.exports = function () {
let getPhysicalVolumesOnce = memoizee(lvm.getPhysicalVolumes);
@@ -22,4 +22,4 @@ module.exports = function () {
});
});
};
};
};

+ 13
- 0
src/api/data-sources/nvme/list-namespaces.js View File

@@ -0,0 +1,13 @@
"use strict";

const Promise = require("bluebird");

const nvmeCli = require("../../../packages/exec-nvme-cli");

module.exports = function () {
return function (controllerPaths) {
return Promise.map(controllerPaths, (path) => {
return nvmeCli.listNamespaces({ devicePath: path });
});
};
};

+ 2
- 2
src/api/data-sources/smartctl/attributes.js View File

@@ -1,7 +1,7 @@
"use strict";

const Promise = require("bluebird");
const smartctl = require("../../../wrappers/smartctl");
const smartctl = require("../../../packages/exec-smartctl");

module.exports = function () {
return function (paths) {
@@ -9,4 +9,4 @@ module.exports = function () {
return smartctl.attributes({ devicePath: path });
});
};
};
};

+ 2
- 2
src/api/data-sources/smartctl/info.js View File

@@ -1,7 +1,7 @@
"use strict";

const Promise = require("bluebird");
const smartctl = require("../../../wrappers/smartctl");
const smartctl = require("../../../packages/exec-smartctl");

module.exports = function () {
return function (paths) {
@@ -9,4 +9,4 @@ module.exports = function () {
return smartctl.info({ devicePath: path });
});
};
};
};

+ 3
- 3
src/api/data-sources/smartctl/scan.js View File

@@ -3,8 +3,8 @@
const Promise = require("bluebird");
const memoizee = require("memoizee");

const smartctl = require("../../../wrappers/smartctl");
const All = require("../../../graphql/symbols/all");
const smartctl = require("../../../packages/exec-smartctl");
const All = require("../../../packages/graphql-interface/symbols/all");

module.exports = function () {
let scanOnce = memoizee(smartctl.scan);
@@ -22,4 +22,4 @@ module.exports = function () {
});
});
};
};
};

+ 4
- 3
src/api/index.js View File

@@ -5,9 +5,9 @@ const graphql = require("graphql");
const fs = require("fs");
const path = require("path");

const createGraphQLInterface = require("../graphql/index");
const All = require("../graphql/symbols/all");
const loadTypes = require("../graphql/type-loader");
const createGraphQLInterface = require("../packages/graphql-interface/index");
const All = require("../packages/graphql-interface/symbols/all");
const loadTypes = require("../packages/graphql-interface/type-loader");

const createLoaders = require("./loaders");

@@ -45,6 +45,7 @@ let schema = graphql.buildSchema(fs.readFileSync(path.resolve(__dirname, "../sch
let types = loadTypes({
Drive: require("./types/drive"),
BlockDevice: require("./types/block-device"),
Mount: require("./types/mount"),
LVMPhysicalVolume: require("./types/lvm-physical-volume"),
LVMVolumeGroup: require("./types/lvm-volume-group"),
});


+ 3
- 1
src/api/loaders.js View File

@@ -5,10 +5,12 @@ const mapObj = require("map-obj");

let dataSourceFactories = {
lsblk: require("./data-sources/lsblk"),
findmnt: require("./data-sources/findmnt"),
smartctlInfo: require("./data-sources/smartctl/info"),
smartctlScan: require("./data-sources/smartctl/scan"),
smartctlAttributes: require("./data-sources/smartctl/attributes"),
lvmPhysicalVolumes: require("./data-sources/lvm/physical-volumes"),
nvmeListNamespaces: require("./data-sources/nvme/list-namespaces"),
};

module.exports = function createLoaders() {
@@ -18,4 +20,4 @@ module.exports = function createLoaders() {
new DataLoader(factory())
];
});
};
};

+ 68
- 17
src/api/types/block-device.js View File

@@ -1,35 +1,85 @@
"use strict";

const {createDataObject, LocalProperties, ID} = require("../../graphql/data-object");
const deviceNameFromPath = require("../../device-name-from-path");
const mapValue = require("../../map-value");
const Promise = require("bluebird");
const fs = Promise.promisifyAll(require("fs"));
const matchValue = require("match-value");

module.exports = function (_types) {
return function BlockDevice({ name, path }) {
if (name != null) {
path = `/dev/${name}`;
} else if (path != null) {
name = deviceNameFromPath(path);
}
const { createDataObject, LocalProperties, ID, Dynamic } = require("../../packages/graphql-interface/data-object");
const All = require("../../packages/graphql-interface/symbols/all");
const treecutter = require("../../packages/treecutter");

module.exports = function (types) {
return function BlockDevice({ name, path, _treecutterDepth, _treecutterSequenceNumber }) {
// if (name != null) {
// path = `/dev/${name}`;
// } else if (path != null) {
// name = deviceNameFromPath(path);
// }

// return Promise.try(() => {
// return fs.realpathAsync(path);
// }).then((realPath) => {
/* FIXME: parent */
return createDataObject({
[LocalProperties]: {
path: path
_treecutterDepth,
_treecutterSequenceNumber
},
[Dynamic]: {
mounts: function ({ type }, { resolveProperty, resolvePropertyPath, resolveDataSource }) {
return Promise.try(() => {
return resolveDataSource("findmnt", All);
}).then((allMounts) => {
return treecutter.flatten(allMounts);
}).map((mount) => {
return types.Mount({ mountpoint: mount.mountpoint });
}).filter((mount) => {
return Promise.try(() => {
return resolvePropertyPath([ "sourceDevice", "path" ], mount);
}).then((sourceDevicePath) => {
// FIXME: Get own path dynamically
return (sourceDevicePath === path);
});
}).then((mounts) => {
if (type != null) {
return Promise.filter(mounts, (mount) => {
return Promise.try(() => {
return resolveProperty("type", mount);
}).then((mountType) => {
return (mountType === type);
});
});
} else {
return mounts;
}
});
}
},
// findmnt: {
// [ID]: All,
// mounts: function (allMounts, { type }, context) {
// let { resolveProperty } = context;
// console.log("CONTEXT", context);
// // FIXME: Why is this called so often?
// }
// },
lsblk: {
[ID]: name,
[ID]: { name, path },
name: "name",
path: (device) => {
return fs.realpathAsync(device.path);
},
type: (device) => {
return mapValue(device.type, {
return matchValue(device.type, {
partition: "PARTITION",
disk: "DISK",
loopDevice: "LOOP_DEVICE"
});
},
size: "size",
mountpoint: "mountpoint",
mountpoint: "mountpoint", // FIXME: Isn't this obsoleted by `mounts`?
deviceNumber: "deviceNumber",
removable: "removable",
readOnly: "readOnly",
@@ -40,5 +90,6 @@ module.exports = function (_types) {
}
}
});
// });
};
};
};

+ 81
- 22
src/api/types/drive.js View File

@@ -2,40 +2,99 @@

const Promise = require("bluebird");

const {createDataObject, LocalProperties, ID} = require("../../graphql/data-object");
const upperSnakeCase = require("../../upper-snake-case");
const linearizeTree = require("../../linearize-tree");
const deviceNameFromPath = require("../../device-name-from-path");
const {createDataObject, LocalProperties, ID, Dynamic} = require("../../packages/graphql-interface/data-object");
const upperSnakeCase = require("../../packages/upper-snake-case");
const treecutter = require("../../packages/treecutter");
const deviceNameFromPath = require("../../util/device-name-from-path");

/* TO IMPLEMENT:
- resolveProperty
- resolveProperties
- resolveDataSource
- Dynamic
*/

module.exports = function (types) {
return function Drive({ path }) {
return createDataObject({
[LocalProperties]: {
path: path,
blockDevice: () => {
return types.BlockDevice({ 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. */
},
lsblk: {
[ID]: deviceNameFromPath(path),
allBlockDevices: function (rootDevice, { type }, context) {
let devices = linearizeTree([rootDevice])
.map((device) => types.BlockDevice({ name: device.name }));
[Dynamic]: {
// FIXME: namespaces
blockDevice: (_, { resolveProperty }) => {
return Promise.try(() => {
return resolveProperty("interface");
}).then((interface_) => {
if (interface_ === "nvme") {
/* NVMe drives do not have a single block device, they have zero or more namespaces */
return null;
} else {
return types.BlockDevice({ path: path });
}
});
},
allBlockDevices: ({ type }, { resolveProperty, resolveDataSource }) => {
// FIXME: Figure out how to semantically represent that data cannot be stored directly onto an NVMe device (only onto a namespace), but *can* be directly stored on a *non-NVMe* device... usually, anyway.

if (type != null) {
return Promise.filter(devices, (device) => {
return Promise.try(() => {
return resolveProperty("interface");
}).then((interface_) => {
if (interface_ === "nvme") {
// Dynamic data source lookup: nvme list-ns -> Drive
return Promise.try(() => {
return device.type({}, context);
}).then((deviceType) => {
return (deviceType === type);
return resolveDataSource("nvmeListNamespaces", path);
}).map((namespaceId) => {
return `${path}n${namespaceId}`;
});
});
} else {
return devices;
}
} else {
return [ path ];
}
}).map((rootPath) => {
return resolveDataSource("lsblk", { path: rootPath });
}).then((blockDeviceTrees) => {
let blockDevices = treecutter.flatten(blockDeviceTrees)
.map((device) => types.BlockDevice(device));
// MARKER: Find a way to reassemble this tree on the client side, for display
// MARKER: Why are most of the mounts (erroneously) empty?

if (type != null) {
return Promise.filter(blockDevices, (device) => {
return Promise.try(() => {
return resolveProperty("type", device.item);
}).then((deviceType) => {
return (deviceType === type);
});
});
} else {
return blockDevices;
}
});
}
},
lsblk: {
[ID]: { path },
// TODO: Implement [DependsOn], for cases where a source data mapper depends on data from more than one source, so it can reference properties defined elsewhere?
// FIXME: Figure out a nice way to make a source lookup conditional upon something else (like only do a `lsblk` if not an NVMe drive, and for NVMe drives return a hardcoded thing)
// allBlockDevices: function (rootDevice, { type }, context) {
// let devices = treecutter.flatten([rootDevice])
// .map((device) => types.BlockDevice({ name: device.name }));

// if (type != null) {
// return Promise.filter(devices, (device) => {
// return Promise.try(() => {
// return device.type({}, context);
// }).then((deviceType) => {
// return (deviceType === type);
// });
// });
// } else {
// return devices;
// }
// }
},
smartctlScan: {
[ID]: path,
interface: "interface"
@@ -87,4 +146,4 @@ module.exports = function (types) {
}
});
};
};
};

+ 2
- 2
src/api/types/lvm-physical-volume.js View File

@@ -1,6 +1,6 @@
"use strict";

const {createDataObject, LocalProperties, ID} = require("../../graphql/data-object");
const {createDataObject, LocalProperties, ID} = require("../../packages/graphql-interface/data-object");

module.exports = function (types) {
return function LVMPhysicalVolume({ path }) {
@@ -29,4 +29,4 @@ module.exports = function (types) {
}
});
};
};
};

+ 2
- 2
src/api/types/lvm-volume-group.js View File

@@ -1,6 +1,6 @@
"use strict";

const {createDataObject, LocalProperties} = require("../../graphql/data-object");
const {createDataObject, LocalProperties} = require("../../packages/graphql-interface/data-object");

module.exports = function (_types) {
return function createVolumeGroup({ name }) {
@@ -10,4 +10,4 @@ module.exports = function (_types) {
}
});
};
};
};

+ 87
- 0
src/api/types/mount.js View File

@@ -0,0 +1,87 @@
"use strict";

const Promise = require("bluebird");
const fs = Promise.promisifyAll(require("fs"));

const {createDataObject, LocalProperties, ID, Dynamic} = require("../../packages/graphql-interface/data-object");

module.exports = function (types) {
return function Mount({ mountpoint }) {
return createDataObject({
[LocalProperties]: {
mountpoint: mountpoint
},
[Dynamic]: {
sourceDevice: (_, { resolveDataSource }) => {
// FIXME: This code is rather bulky, maybe there should be a first-class way to express "try to create a data object that may fail"
return Promise.try(() => {
return resolveDataSource("findmnt", mountpoint);
}).then((mount) => {
if (mount.sourceDevice != null) {
return Promise.try(() => {
return fs.realpathAsync(mount.sourceDevice);
}).then((sourcePath) => {
return Promise.try(() => {
return resolveDataSource("lsblk", { path: sourcePath });
}).then((lsblkResult) => {
if (lsblkResult != null) {
return types.BlockDevice({ path: sourcePath });
} else {
// This occurs when the `sourceDevice` is a valid device, but it is not a *block* device, eg. like with `/dev/fuse`
return null;
}
});
});
} else {
return null;
}
});
}
},
findmnt: {
[ID]: mountpoint,
id: "id",
// FIXME: Aren't we inferring the below somewhere else in the code, using the square brackets?
type: (mount) => {
if (mount.rootPath === "/") {
return "ROOT_MOUNT";
} else {
return "SUBMOUNT";
}
},
// sourceDevice: (mount) => {
// return Promise.try(() => {
// if (mount.sourceDevice != null) {
// return Promise.try(() => {
// return fs.realpathAsync(mount.sourceDevice);
// }).then((sourcePath) => {
// return types.BlockDevice({ path: sourcePath });
// });
// } else {
// return null;
// }
// });
// },
filesystem: "filesystem",
options: "options",
label: "label",
uuid: "uuid",
partitionLabel: "partitionLabel",
partitionUUID: "partitionUUID",
deviceNumber: "deviceNumber",
totalSpace: "totalSpace",
freeSpace: "freeSpace",
usedSpace: "usedSpace",
rootPath: "rootPath",
taskID: "taskID",
optionalFields: "optionalFields",
propagationFlags: "propagationFlags",
children: (mount) => {
return mount.children.map((child) => {
return Mount({ mountpoint: child.mountpoint });
});
}
}
});
};
};

+ 29
- 10
src/app.js View File

@@ -6,8 +6,12 @@ const express = require("express");
const knex = require("knex");
const path = require("path");
const bodyParser = require("body-parser");
const graphql = require("graphql");
const chalk = require("chalk");
const util = require("util");
const errorChain = require("error-chain");

const expressAsyncReact = require("./express-async-react");
const expressAsyncReact = require("./packages/express-async-react");

function projectPath(targetPath) {
return path.join(__dirname, "..", targetPath);
@@ -15,7 +19,7 @@ function projectPath(targetPath) {

module.exports = function () {
let db = knex(require("../knexfile"));
let imageStore = require("./image-store")(projectPath("./images"));
let imageStore = require("./util/image-store")(projectPath("./images"));
let taskTracker = require("../lib/tasks/tracker")();
let apiQuery = require("./api")();

@@ -75,19 +79,34 @@ module.exports = function () {
app.use("/hardware/storage-devices", require("./routes/storage-devices")(state));

app.use((err, req, res, next) => {
if (err.showChain != null) {
console.log(err.showChain());
console.log("#####################");
console.log(err.getAllContext());
} else {
console.log(err.stack);
/* GraphQL will wrap any data-resolving errors in its own error type, and that'll break our `showChain` logic below. Note that some GraphQL errors may not *have* an originalError (eg. schema violations), so we account for that as well. */
let sourceError = (err instanceof graphql.GraphQLError && err.originalError != null)
? err.originalError
: err;
console.error(errorChain.render(sourceError));

// FIXME: Render full context instead, according to error-chain?
for (let key of Object.keys(err)) {
console.error(chalk.yellow.bold(`${key}: `) + util.inspect(err[key], { colors: true }));
}
// if (sourceError.showChain != null) {
// console.log(sourceError.showChain());
// console.log("#####################");
// console.log(sourceError.getAllContext());
// } else {
// console.log(sourceError.stack);

// }

res.render("error", {
error: err
});

debugger;
});

return app;
};
};

+ 6
- 0
src/concat.js View File

@@ -0,0 +1,6 @@
"use strict";

module.exports = function concat(characters) {
// NOTE: This function doesn't really *do* much, it mostly exists to have a more conceptually useful name for this operation (since `.join("")` is non-obvious as to its purpose). This operation is often needed when writing PEG.js parsers, since those will parse byte-by-byte, and so any repeating modifier will result in an *array of characters* when what you usually want is a string. This makes it a string.
return characters.join("");
};

+ 0
- 8
src/device-name-from-path.js View File

@@ -1,8 +0,0 @@
"use strict";

const matchOrError = require("./match-or-error");

module.exports = function deviceNameFromPath(path) {
let [name] = matchOrError(/^\/dev\/(.+)$/, path);
return name;
};

+ 9
- 18
src/errors.js View File

@@ -2,26 +2,17 @@

const errorChain = require("error-chain");

let HttpError = errorChain("HttpError", {
exposeToUser: true
let HttpError = errorChain.create("HttpError", {
context: { exposeToUser: true }
});

module.exports = {
UnauthorizedError: errorChain("UnauthorizedError", {
statusCode: 401
UnauthorizedError: errorChain.create("UnauthorizedError", {
inheritsFrom: HttpError,
context: { statusCode: 401 }
}),
ForbiddenError: errorChain.create("ForbiddenError", {
inheritsFrom: HttpError,
context: { statusCode: 403 }
}, HttpError),
ForbiddenError: errorChain("ForbiddenError", {
statusCode: 403
}, HttpError),

UnexpectedOutput: errorChain("UnexpectedOutput"),
ExpectedOutputMissing: errorChain("ExpectedOutputMissing"),
NonZeroExitCode: errorChain("NonZeroExitCode"),
CommandExecutionFailed: errorChain("CommandExecutionFailed"),
InvalidPath: errorChain("InvalidPath"),
InvalidName: errorChain("InvalidName"),
PartitionExists: errorChain("PartitionExists"),
VolumeGroupExists: errorChain("VolumeGroupExists"),
InvalidVolumeGroup: errorChain("InvalidVolumeGroup"),
PhysicalVolumeInUse: errorChain("PhysicalVolumeInUse"),
};

+ 0
- 375
src/exec-binary.js View File

@@ -1,375 +0,0 @@
"use strict";

require("array.prototype.flat").shim();

const Promise = require("bluebird");
const util = require("util");
const execFileAsync = util.promisify(require("child_process").execFile);
const execAll = require("execall");
const debug = require("debug")("cvm:execBinary");

const errors = require("./errors");

let None = Symbol("None");

/* FIXME: How to handle partial result parsing when an error is encountered in the parsing code? */
/* FIXME: "terminal" flag for individual matches in exec-binary */
/* FIXME: Test that flag-dash prevention in arguments works */

function keyToFlagName(key) {
if (key.startsWith("!")) {
return key.slice(1);
} else if (key.length === 1) {
return `-${key}`;
} else {
return `--${key}`;
}
}

function flagValueToArgs(key, value) {
if (value === true) {
return [key];
} else if (Array.isArray(value)) {
return value.map((item) => {
return flagValueToArgs(key, item);
}).flat();
} else {
return [key, value];
}
}

function flagsToArgs(flags) {
return Object.keys(flags).map((key) => {
let value = flags[key];
let flagName = keyToFlagName(key);

return flagValueToArgs(flagName, value);
}).flat();
}

function regexExpectationsForChannel(object, channel) {
return object._settings.expectations.filter((expectation) => {
return expectation.channel === channel && expectation.type === "regex";
});
}

function executeExpectation(expectation, stdout, stderr) {
let output = (expectation.channel === "stdout") ? stdout : stderr;

if (expectation.type === "regex") {
if (expectation.regex.test(output)) {
return executeRegexExpectation(expectation, output);
} else {
return None;
}
} else if (expectation.type === "json") {
let parsedOutput = JSON.parse(output);

if (expectation.callback != null) {
return expectation.callback(parsedOutput);
} else {
return parsedOutput;
}
} else {
throw new Error(`Unexpected expectation type: ${expectation.type}`);
}
}

function executeRegexExpectation(expectation, input) {
function processResult(fullMatch, groups) {
if (expectation.callback != null) {
return expectation.callback(groups, fullMatch, input);
} else {
return groups;
}
}

if (expectation.matchAll) {
let matches = execAll(expectation.regex, input);

if (matches.length > 0) { /* FILEBUG: File issue on execall repo to document the no-match output */
let results = matches.map((match) => {
return processResult(match.match, match.sub);
}).filter((result) => {
return (result !== None);
});

if (results.length > 0) {
return results;
} else {
return None;
}
} else {
return None;
}
} else {
let match = expectation.regex.exec(input);

if (match != null) {
return processResult(match[0], match.slice(1));
} else {
return None;
}
}
}

function verifyRegex(regex, {matchAll}) {
if (matchAll === true && !regex.flags.includes("g")) {
throw new Error("You enabled the 'matchAll' option, but the specified regular expression is not a global one; you probably forgot to specify the 'g' flag");
}
}

function validateArguments(args) {
if (args.some((arg) => arg == null)) {
throw new Error("One or more arguments were undefined or null; this is probably a mistake in how you're calling the command");
} else if (args.some((arg) => arg[0] === "-")) {
throw new Error("For security reasons, command arguments cannot start with a dash; use the 'withFlags' method if you want to specify flags");
}
}

module.exports = function createBinaryInvocation(command, args = []) {
/* FIXME: The below disallows dashes in the args, but not in the command. Is that what we want? */
validateArguments(args);

return {
_settings: {
asRoot: false,
singleResult: false,
atLeastOneResult: false,
jsonStdout: false,
jsonStderr: false,
expectations: [],
flags: {},
environment: {}
},
_withSettings: function (newSettings) {
let newObject = Object.assign({}, this, {
_settings: Object.assign({}, this._settings, newSettings)
});

/* FIXME: Make this ignore json expectations */
let hasStdoutExpectations = (regexExpectationsForChannel(newObject, "stdout").length > 0);
let hasStderrExpectations = (regexExpectationsForChannel(newObject, "stderr").length > 0);

if (newObject._settings.jsonStdout && hasStdoutExpectations) {
throw new Error("The 'expectJsonStdout' and 'expectStdout' options cannot be combined");
} else if (newObject._settings.jsonStderr && hasStderrExpectations) {
throw new Error("The 'expectJsonStderr' and 'expectStderr' options cannot be combined");
} else {
return newObject;
}
},
asRoot: function () {
return this._withSettings({ asRoot: true });
},
singleResult: function () {
return this._withSettings({ singleResult: true });
},
atLeastOneResult: function () {
return this._withSettings({ atLeastOneResult: true });
},
/* NOTE: Subsequent withFlags calls involving the same flag key will *override* the earlier value, not add to it! */
withFlags: function (flags) {
if (flags != null) {
return this._withSettings({
flags: Object.assign({}, this._settings.flags, flags)
});
} else {
return this;
}
},
withEnvironment: function (environment) {
if (environment != null) {
return this._withSettings({
environment: Object.assign({}, this._settings.environment, environment)
});
} else {
return this;
}
},
withModifier: function (modifierFunction) {
if (modifierFunction != null) {
return modifierFunction(this);
} else {
return this;
}
},
expectJsonStdout: function (callback) {
if (!this._settings.jsonStdout) {
return this._withSettings({
jsonStdout: true,
expectations: this._settings.expectations.concat([{
type: "json",
channel: "stdout",
key: "stdout",
callback: callback
}])
});
}
},
expectJsonStderr: function (callback) {
if (!this._settings.jsonStderr) {
return this._withSettings({
jsonStderr: true,
expectations: this._settings.expectations.concat([{
type: "json",
channel: "stderr",
key: "stderr",
callback: callback
}])
});
}
},
expectStdout: function (key, regex, {required, result, matchAll} = {}) {
verifyRegex(regex, {matchAll});

return this._withSettings({
expectations: this._settings.expectations.concat([{
type: "regex",
channel: "stdout",
required: (required === true),
key: key,
regex: regex,
callback: result,
matchAll: matchAll
}])
});
},
expectStderr: function (key, regex, {required, result, matchAll} = {}) {
verifyRegex(regex, {matchAll});

return this._withSettings({
expectations: this._settings.expectations.concat([{
type: "regex",
channel: "stderr",
required: (required === true),
key: key,
regex: regex,
callback: result,
matchAll: matchAll
}])
});
},
then: function () {
throw new Error("Attempted to use a command builder as a Promise; you probably forgot to call .execute");
},
execute: function () {
return Promise.try(() => {
let effectiveCommand = command;
let effectiveArgs = flagsToArgs(this._settings.flags).concat(args);

if (this._settings.asRoot) {
effectiveCommand = "sudo";
effectiveArgs = [command].concat(effectiveArgs);
}

let effectiveCompleteCommand = [effectiveCommand].concat(effectiveArgs);

return Promise.try(() => {
debug(`Running: ${effectiveCommand} ${effectiveArgs.map((arg) => `"${arg}"`).join(" ")}`);

return execFileAsync(effectiveCommand, effectiveArgs, {
env: Object.assign({}, process.env, this._settings.environment)
});
}).then(({stdout, stderr}) => {
return { stdout, stderr, exitCode: 0 };
}).catch((error) => {
let {stdout, stderr} = error;
let exitCode = (typeof error.code === "number") ? error.code : null;
return { stdout, stderr, error, exitCode };
}).then(({stdout, stderr, error, exitCode}) => {
let finalResult, resultFound;
try {
if (this._settings.singleResult) {
let result = None;
let i = 0;
while (result === None && i < this._settings.expectations.length) {
let expectation = this._settings.expectations[i];
result = executeExpectation(expectation, stdout, stderr);
if (expectation.required === true && result === None) {
throw new errors.ExpectedOutputMissing(`Expected output not found for key '${expectation.key}'`, {
exitCode: exitCode,
stdout: stdout,
stderr: stderr
});
}
i += 1;
}
finalResult = result;
resultFound = (finalResult !== None);
} else {
let results = this._settings.expectations.map((expectation) => {
let result = executeExpectation(expectation, stdout, stderr);
if (result === None) {
if (expectation.required === true) {
throw new errors.ExpectedOutputMissing(`Expected output not found for key '${expectation.key}'`, {
exitCode: exitCode,
stdout: stdout,
stderr: stderr
});
} else {
return result;
}
} else {
return { key: expectation.key, value: result };
}
}).filter((result) => {
return (result !== None);
});
resultFound = (results.length > 0);
finalResult = results.reduce((object, {key, value}) => {
return Object.assign(object, {
[key]: value
});
}, {});
}
} catch (processingError) {
throw errors.UnexpectedOutput.chain(processingError, "An error occurred while processing command output", {
command: effectiveCompleteCommand,
exitCode: exitCode,
stdout: stdout,
stderr: stderr
});
}
if (resultFound || this._settings.atLeastOneResult === false) {
if (error != null) {
throw new errors.NonZeroExitCode.chain(error, `Process '${command}' exited with code ${exitCode}`, {
exitCode: exitCode,
stdout: stdout,
stderr: stderr,
result: finalResult
});
} else {
return {
exitCode: exitCode,
stdout: stdout,
stderr: stderr,
result: finalResult
};
}
} else {
throw new errors.ExpectedOutputMissing("None of the expected outputs for the command were encountered, but at least one result is required", {
exitCode: exitCode,
stdout: stdout,
stderr: stderr
});
}
}).catch(errors.CommandExecutionFailed.rethrowChained(`An error occurred while executing '${command}'`, {
command: effectiveCompleteCommand
}));
});
}
};
};

+ 121
- 85
src/graphql-test.js View File

@@ -58,104 +58,140 @@ let makeQuery = api();

return Promise.try(() => {
let query = gql`
# query SomeDrives($drivePaths: [String]) {
query SomeDrives {
query {
hardware {
drives {
path
interface
smartHealth
size
rpm
serialNumber
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
# }
# }
blockDevice {
name
}

partitions: allBlockDevices(type: PARTITION) {
name
size

mounts {
mountpoint
}
}
}
}

# 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
# }
# }
# }
}
`;

// let query = gql`
// # query SomeDrives($drivePaths: [String]) {
// query SomeDrives {
// hardware {
// drives {
// path
// interface
// model
// modelFamily

// blockDevice {
// submounts: mounts(type: SUBMOUNT) {
// mountpoint
// filesystem
// }
// }
// # 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);
});
});

+ 0
- 62
src/graphql/data-object.js View File

@@ -1,62 +0,0 @@
"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 (args, context) {
let {data} = context;

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, args, context);
} 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
- 11
src/graphql/index.js View File

@@ -1,11 +0,0 @@
"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
- 19
src/linearize-tree.js View File

@@ -1,19 +0,0 @@
"use strict";

module.exports = function linearizeTree(rootList, childrenProperty = "children") {
let linearizedItems = [];

function add(list) {
for (let item of list) {
linearizedItems.push(item);

if (item[childrenProperty] != null) {
add(item[childrenProperty]);
}
}
}

add(rootList);
return linearizedItems;
};

+ 0
- 11
src/map-value.js View File