diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..05b3370 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +config.yml +config.*.yml +!config.example.yml +node_modules diff --git a/README.md b/README.md index 40a0f9c..b6d026e 100644 --- a/README.md +++ b/README.md @@ -4,41 +4,10 @@ scaffold one or all of the `dnim` api, database, ui on a bare debian image re-runnable and idempotent; changes to configuration does the same work as initial setup without losing state. ## inputs -script input is read from `./config.yml`: -```yaml -db: - linux_user: - username: "foo_db" - allowed_ssh_public_keys: ['ssh-ed25519 my special ssh key'] - persist: ["data"] # files not listed will be deleted when script is re-run. relative paths are resolved from /home/ - port: - local: 5432 - public: 0 # `0` means not publicly accessible; only local traffic (e.g. ssh sessions) may connect - domain: "db.dnim.org" - pg_admin: - username: "postgres" - password: "password" -api: - linux_user: - username: "foo_api" - allowed_ssh_public_keys: ['ssh-ed25519 my special ssh key'] - persist: ["data"] # files not listed will be deleted when script is re-run. relative paths are resolved from /home/ - port: - local: 1234 - public: 1234 - domain: "api.dnim.org" -ui: - linux_user: - username: "foo_ui" - allowed_ssh_public_keys: ['ssh-ed25519 my special ssh key'] - persist: ["data"] # files not listed will be deleted when script is re-run. relative paths are resolved from /home/ - port: - local: 1234 - public: 1234 - domain: "dnim.org" -``` +script input is read from `./config.yml`, an example of which can be found +at [`./config.example.yml`]. -top-level keys `db`, `api`, or `ui` may be omitted to separately deploy instances of each service. +top-level keys `db`, `api`, or `ui` may be omitted or repeated. ## observable outputs * linux user `db.linux_user.username` is created diff --git a/config.example.yml b/config.example.yml new file mode 100644 index 0000000..4c18c54 --- /dev/null +++ b/config.example.yml @@ -0,0 +1,35 @@ +- db: + linux_user: + username: 'foo_db' + allowed_ssh_public_keys: ['ssh-ed25519 my special ssh key'] + # files not listed will be deleted when script is re-run. + # relative paths are resolved from /home/ + persist: ['data'] + network: + interface: '' # valid values: ['public', 'local'] + port: 5432 + domain: 'db.foo.org' + ssl: false + pg_admin: + username: 'postgres' + password: 'password' +- api: + linux_user: + username: 'foo_api' + allowed_ssh_public_keys: ['ssh-ed25519 my special ssh key'] + persist: [] + network: + interface: '' # valid values: ['public', 'local'] + port: 5432 + domain: 'api.foo.org' + ssl: true +- ui: + linux_user: + username: 'foo_ui' + allowed_ssh_public_keys: ['ssh-ed25519 my special ssh key'] + persist: [] + network: + interface: '' # valid values: ['public', 'local'] + port: 5432 + domain: 'foo.org' + ssl: true diff --git a/package.json b/package.json new file mode 100644 index 0000000..b300500 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "srv", + "version": "1.0.0", + "main": "index.js", + "private": "true", + "repository": "git@git.orionkindel.com:dnim/srv.git", + "author": "Orion Kindel ", + "license": "MIT", + "dependencies": { + "@matchbook/ts": "^1.0.0", + "fp-ts": "^2.16.0", + "yaml": "^2.3.1" + } +} diff --git a/src/cmd.js b/src/cmd.js new file mode 100644 index 0000000..bd89c03 --- /dev/null +++ b/src/cmd.js @@ -0,0 +1,34 @@ +const Either = require("fp-ts/Either"); +const { pipe, identity: id } = require("fp-ts/function"); +const cp = require("child_process"); + +const run = (script) => + pipe( + Either.tryCatch( + () => cp.spawnSync(script, { encoding: "utf8", shell: true }), + id + ), + Either.flatMap((o) => (o.error ? Either.left(o.error) : Either.right(o))), + Either.flatMap((o) => + o.status > 0 + ? Either.left( + new Error(JSON.stringify({ reason: "status_gt_0", ...o })) + ) + : Either.right(o) + ), + Either.map((o) => o.stdout.trim()) + ); + +const ignoringStatus = Either.orElse((e) => + pipe( + Either.tryCatch(() => JSON.parse(e.message), id), + Either.flatMap((o) => + o.reason && o.reason === "status_gt_0" + ? Either.right(o.stdout.trim()) + : Either.left(new Error()) + ), + Either.mapLeft(() => e) + ) +); + +module.exports = { run, ignoringStatus }; diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..564aee5 --- /dev/null +++ b/src/config.js @@ -0,0 +1,164 @@ +const yaml = require("yaml"); +const { strike, match, otherwise } = require("@matchbook/ts"); +const Array_ = require("fp-ts/Array"); +const Either = require("fp-ts/Either"); +const { identity: id, flow, pipe } = require("fp-ts/function"); + +const parseSegmentLinuxUser = (o) => + "linux_user" in o + ? "username" in o.linux_user + ? Either.right({ + username: o.linux_user.username, + allowedSshPublicKeys: o.allowed_ssh_public_keys || [], + }) + : Either.left(new Error("linux_user.username required")) + : Either.left(new Error("linux_user required")); + +const parseSegmentPgAdmin = flow( + (o) => + "pg_admin" in o + ? Either.right(o.pg_admin) + : Either.left( + new Error( + ["pg_admin required", `in: ${JSON.stringify(o)}`].join("\n") + ) + ), + Either.flatMap((pg) => + "username" in pg + ? Either.right(pg) + : Either.left( + new Error( + ["pg_admin.username required", `in: ${JSON.stringify(pg)}`].join( + "\n" + ) + ) + ) + ), + Either.flatMap((pg) => + "password" in pg + ? Either.right(pg) + : Either.left( + new Error( + ["pg_admin.password required", `in: ${JSON.stringify(pg)}`].join( + "\n" + ) + ) + ) + ), + Either.map((pg) => ({ username: pg.username, password: pg.password })) +); + +const parseSegmentNetwork = flow( + (o) => + "network" in o + ? Either.right(o.network) + : Either.left( + new Error(["network required", `in: ${JSON.stringify(o)}`].join("\n")) + ), + Either.flatMap((n) => + "interface" in n + ? Either.right(n) + : Either.left( + new Error( + ["network.interface required", `in: ${JSON.stringify(n)}`].join( + "\n" + ) + ) + ) + ), + Either.flatMap((n) => + "port" in n + ? Either.right(n) + : Either.left( + new Error( + ["network.port required", `in: ${JSON.stringify(n)}`].join("\n") + ) + ) + ), + Either.flatMap((n) => + "domain" in n + ? Either.right(n) + : Either.left( + new Error( + [ + "network.domain required", + " (to not listen on a public domain, set this property to empty string '')", + `in: ${JSON.stringify(n)}`, + ].join("\n") + ) + ) + ), + Either.flatMap((n) => + "ssl" in n + ? Either.right(n) + : Either.left( + new Error( + ["network.ssl required", `in: ${JSON.stringify(n)}`].join("\n") + ) + ) + ), + Either.map((n) => ({ + ssl: n.ssl, + domain: n.domain || undefined, + interface: n.interface, + port: n.port, + })) +); + +const parseServiceDb = (svc) => + pipe( + Either.Do, + Either.bind("linuxUser", () => parseSegmentLinuxUser(svc)), + Either.let("persist", () => + (svc.persist || []).filter((s) => typeof s === "string" && s.length > 0) + ), + Either.bind("network", () => parseSegmentNetwork(svc)), + Either.bind("pgAdmin", () => parseSegmentPgAdmin(svc)) + ); + +const parseServiceApi = (svc) => + pipe( + Either.Do, + Either.bind("linuxUser", () => parseSegmentLinuxUser(svc)), + Either.let("persist", () => + (svc.persist || []).filter((s) => typeof s === "string" && s.length > 0) + ), + Either.bind("network", () => parseSegmentNetwork(svc)) + ); + +const parseServiceUi = (svc) => + pipe( + Either.Do, + Either.bind("linuxUser", () => parseSegmentLinuxUser(svc)), + Either.let("persist", () => + (svc.persist || []).filter((s) => typeof s === "string" && s.length > 0) + ), + Either.bind("network", () => parseSegmentNetwork(svc)) + ); + +const parseService = (svc) => + "db" in svc + ? parseServiceDb(svc.db) + : "api" in svc + ? parseServiceApi(svc.api) + : "ui" in svc + ? parseServiceUi(svc.ui) + : Either.left( + new Error( + [ + `top-level array elements must be records with a key named "db", "ui", or "api".`, + `in: ${JSON.stringify(svc)}`, + ].join("\n") + ) + ); + +const parse = flow( + (cfg) => Either.tryCatch(() => yaml.parse(cfg), id), + Either.filterOrElse( + (p) => p instanceof Array, + () => new Error("config must have top-level array elements") + ), + Either.flatMap((svcs) => Either.sequenceArray(svcs.map(parseService))) +); + +module.exports = { parse }; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..dfed2b6 --- /dev/null +++ b/src/index.js @@ -0,0 +1,10 @@ +const Config = require("./config.js"); +const Cmd = require("./cmd.js"); +const fs = require("fs"); +const {pipe} = require("fp-ts/function"); +const Either = require("fp-ts/Either"); + +pipe( + Cmd.run('cat ./config.example.yml'), + Either.flatMap(Config.parse), +); diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..5f35f6d --- /dev/null +++ b/yarn.lock @@ -0,0 +1,18 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@matchbook/ts@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@matchbook/ts/-/ts-1.0.0.tgz#7338b3fc3fd15f46c5849bf898cde200c6865b48" + integrity sha512-0eOE/mJSNa3Z8fxP3fWumBQjVjd+N/oZIqctZ+CVl/l8bZc6rgW/YfsjiHzjZzHJkYdrFOfD2S4u9kqGAcs7iA== + +fp-ts@^2.16.0: + version "2.16.0" + resolved "https://registry.yarnpkg.com/fp-ts/-/fp-ts-2.16.0.tgz#64e03314dfc1c7ce5e975d3496ac14bc3eb7f92e" + integrity sha512-bLq+KgbiXdTEoT1zcARrWEpa5z6A/8b7PcDW7Gef3NSisQ+VS7ll2Xbf1E+xsgik0rWub/8u0qP/iTTjj+PhxQ== + +yaml@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.3.1.tgz#02fe0975d23cd441242aa7204e09fc28ac2ac33b" + integrity sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==