300 lines
7.9 KiB
JavaScript
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 }
|