Files
srv/src/config.js
Orion Kindel c28429de2f feat: initial commit
modular configuration, db service works
2023-06-14 21:20:45 -05:00

300 lines
7.9 KiB
JavaScript

const path = require('path')
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 Net = require('./net.js')
const parseSegmentLinuxUser = flow(
o =>
'linux_user' in o
? Either.right(o.linux_user)
: Either.left(
new Error(
['linux_user required', `in: ${yaml.stringify(o)}`].join('\n'),
),
),
Either.flatMap(lx =>
'username' in lx
? Either.right({
...lx,
username: lx.username.trim(),
homeDir: `/home/${lx.username.trim()}`,
persistDir: `/tmp`,
})
: Either.left(
new Error(
['linux_user.username required', `in: ${yaml.stringify(lx)}`].join(
'\n',
),
),
),
),
Either.map(lx => ({
...lx,
persist: (lx.persist || []).map(p =>
path.isAbsolute(p) ? p : path.join(lx.homeDir, p.trim()),
),
})),
Either.flatMap(lx =>
!'persist' in lx ||
('persist' in lx &&
lx.persist.every(p => path.dirname(p).startsWith(lx.homeDir)))
? Either.right(lx)
: Either.left(
new Error(
[
`all paths in linux_user.persist must be subpaths of ${lx.homeDir}`,
`in: ${yaml.stringify(lx)}`,
].join('\n'),
),
),
),
Either.map(lx => ({
username: lx.username,
homeDir: lx.homeDir,
persistDir: lx.persistDir,
persist: lx.persist,
allowedSshPublicKeys: (lx.allowed_ssh_public_keys || []).map(p => p.trim()),
})),
)
const parseSegmentPostgres = (cfg, linuxUser) => pipe(
'postgres' in cfg
? Either.right(cfg.postgres)
: Either.left(
new Error(
['postgres required', `in: ${yaml.stringify(cfg)}`].join('\n'),
),
),
Either.map(pg => ({
...pg,
data_dir: pg.data_dir || './data',
})),
Either.map(pg => ({
...pg,
data_dir: path.isAbsolute(pg.data_dir)
? pg.data_dir
: path.join(linuxUser.homeDir, pg.data_dir.trim()),
})),
Either.flatMap(pg =>
path.dirname(pg.data_dir).startsWith(linuxUser.homeDir)
? Either.right(pg)
: Either.left(
new Error(
[
`postgres.data_dir must be a subpath of ${linuxUser.homeDir}`,
`in: ${yaml.stringify(pg)}`,
].join('\n'),
),
),
),
Either.flatMap(pg =>
'username' in pg
? Either.right(pg)
: Either.left(
new Error(
['postgres.username required', `in: ${yaml.stringify(pg)}`].join(
'\n',
),
),
),
),
Either.flatMap(pg =>
'password' in pg
? Either.right(pg)
: Either.left(
new Error(
['postgres.password required', `in: ${yaml.stringify(pg)}`].join(
'\n',
),
),
),
),
Either.map(pg => ({
username: pg.username.trim(),
password: pg.password.trim(),
dataDir: pg.data_dir,
})),
)
const parseSegmentNetwork = flow(
o =>
'network' in o
? Either.right(o.network)
: Either.left(
new Error(
['network required', `in: ${yaml.stringify(o)}`].join('\n'),
),
),
Either.flatMap(n =>
('interface' in n && n.interface === 'public') || n.interface === 'local'
? Either.right(n)
: Either.left(
new Error(
[
"network.interface required, and must be 'public' or 'local'",
`in: ${yaml.stringify(n)}`,
].join('\n'),
),
),
),
Either.flatMap(n =>
'port' in n && typeof n.port === 'number' && Number.isInteger(n.port)
? Either.right(n)
: Either.left(
new Error(
[
'network.port required and must be an integer',
`in: ${yaml.stringify(n)}`,
].join('\n'),
),
),
),
Either.flatMap(n =>
n.interface === 'public' || (n.interface === 'local' && !n.domain)
? Either.right(n)
: Either.left(
new Error(
[
"network.interface is 'local', network.domain must not be present.",
`in: ${yaml.stringify(n)}`,
].join('\n'),
),
),
),
Either.flatMap(n =>
n.interface === 'local' ||
(n.interface === 'public' &&
'domain' in n &&
typeof n.domain === 'string' &&
n.domain.length > 0)
? Either.right(n)
: Either.left(
new Error(
[
"network.interface is 'public', network.domain be set to a value.",
`in: ${yaml.stringify(n)}`,
].join('\n'),
),
),
),
Either.flatMap(n =>
'ssl' in n && typeof n.ssl === 'boolean'
? Either.right(n)
: Either.left(
new Error(
[
'network.ssl required and must be a bool',
`in: ${yaml.stringify(n)}`,
].join('\n'),
),
),
),
Either.map(n => ({
ssl: n.ssl,
domain: (n.domain || '').trim(),
interface: n.interface,
interfaceIp:
n.interface === 'local' ? Net.localInterfaceIp : Net.publicInterfaceIp,
port: n.port,
})),
)
const parseServiceDb = svc =>
pipe(
Either.Do,
Either.let('serviceType', () => 'db'),
Either.bind('linuxUser', () => parseSegmentLinuxUser(svc)),
Either.bind('network', () => parseSegmentNetwork(svc)),
Either.bind('postgres', ({linuxUser}) => parseSegmentPostgres(svc, linuxUser)),
)
const parseServiceApi = svc =>
pipe(
Either.Do,
Either.let('serviceType', () => 'api'),
Either.bind('linuxUser', () => parseSegmentLinuxUser(svc)),
Either.bind('network', () => parseSegmentNetwork(svc)),
)
const parseServiceUi = svc =>
pipe(
Either.Do,
Either.let('serviceType', () => 'ui'),
Either.bind('linuxUser', () => parseSegmentLinuxUser(svc)),
Either.bind('network', () => parseSegmentNetwork(svc)),
)
const badService = svc =>
new Error(
[
`top-level array elements must be records with a key named "db", "ui", or "api".`,
`in: ${yaml.stringify(svc)}`,
].join('\n'),
)
const parseService = svc =>
typeof svc !== 'object'
? Either.left(badService(svc))
: 'db' in svc
? parseServiceDb(svc.db)
: 'api' in svc
? parseServiceApi(svc.api)
: 'ui' in svc
? parseServiceUi(svc.ui)
: Either.left(badService(svc))
const ensureUniq =
({ getWith, onConflict, isConflict }) =>
array =>
pipe(
array,
Array_.map(getWith),
Array_.reduce(Either.right([]), (res, cur) =>
Either.flatMap(seen =>
isConflict(seen, cur)
? Either.left(onConflict(cur))
: Either.right([...seen, cur]),
)(res),
),
Either.map(() => array),
)
const ensureServicesDoNotOverlap = flow(
ensureUniq({
getWith: s => s.linuxUser.username,
onConflict: usr => new Error(`linux_user.username must be unique: ${usr}`),
isConflict: (seen, cur) => seen.includes(cur),
}),
Either.flatMap(
ensureUniq({
getWith: s => s.network.port,
onConflict: port => new Error(`network.port must be unique: ${port}`),
isConflict: (seen, cur) => seen.includes(cur),
}),
),
Either.flatMap(
ensureUniq({
getWith: s => s.network.domain,
onConflict: domain =>
new Error(`network.domain must be unique: ${domain}`),
isConflict: (seen, cur) => cur && seen.includes(cur),
}),
),
)
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))),
Either.tap(ensureServicesDoNotOverlap),
)
module.exports = { parse }