OverviewFilesCommitsIssuesDocsReleasesPeers

host-services / test / collect.test.js

149 lines · javascript ref:
// buildServices / stateOf: attribution and states, without a host.
import test from 'brittle'
import { buildServices, stateOf, readListeners, pghAppOf } from '../lib/collect.js'
import fs from 'bare-fs'
import os from 'bare-os'
import path from 'bare-path'

const unit = (Id, extra = {}) => ({
  Id, Description: Id, LoadState: 'loaded', ActiveState: 'active', SubState: 'running',
  UnitFileState: 'enabled', FragmentPath: '/usr/lib/systemd/system/' + Id, User: '', ...extra
})

function build (over = {}) {
  return buildServices({
    listeners: [],
    owners: new Map(),
    pidInfo: new Map(),
    users: new Map([[1000, 'fleeky'], [122, 'jellyfin']]),
    units: [],
    userUnits: [],
    pghApps: [],
    probes: new Map(),
    ...over
  })
}

test('a port owned by our own pgh app attaches to that app', (t) => {
  const { services } = build({
    listeners: [{ port: 4050, addr: '0.0.0.0', scope: 'all', uid: 1000, inodes: [11] }],
    owners: new Map([[11, 500]]),
    pidInfo: new Map([[500, { pid: 500, cmd: 'bare x', pghApp: 'cthuloid-web', unit: null, userUnit: false }]]),
    pghApps: [{ name: 'cthuloid-web', running: true, version: '0.2.1', autostart: true, lastExitCode: 0 }],
    probes: new Map([['4050/0.0.0.0', { open: true, http: { code: 200, title: 'Cthuloid Lights' }, banner: null }]])
  })
  t.is(services.length, 1)
  t.is(services[0].id, 'pgh:cthuloid-web')
  t.is(services[0].ports[0].http.title, 'Cthuloid Lights')
  t.is(services[0].state, 'active')
})

test('another user\'s socket attaches to the running unit with that User=', (t) => {
  const { services } = build({
    listeners: [{ port: 8096, addr: '0.0.0.0', scope: 'all', uid: 122, inodes: [9] }],
    units: [unit('jellyfin.service', { User: 'jellyfin' })],
    probes: new Map([['8096/0.0.0.0', { open: true, http: { code: 302 }, banner: null }]])
  })
  const j = services.find((s) => s.id === 'unit:jellyfin.service')
  t.ok(j)
  t.is(j.ports[0].port, 8096)
  t.is(j.state, 'active')
})

test('a socket nobody can be tied to is its own "port" service', (t) => {
  const { services } = build({
    listeners: [{ port: 80, addr: '::', scope: 'all', uid: 0, inodes: [1] }],
    probes: new Map([['80/::', { open: true, http: { code: 308 }, banner: null }]])
  })
  t.is(services[0].kind, 'port')
  t.is(services[0].ports[0].owner.user, '0')
})

test('vendor units without ports go to the system list; hand-made ones stay', (t) => {
  const { services, system } = build({
    units: [unit('cron.service'), unit('easynews-indexer.service', { FragmentPath: '/etc/systemd/system/easynews-indexer.service' })],
    userUnits: [unit('kodi-repo.service', { FragmentPath: '/home/u/.config/systemd/user/kodi-repo.service' })]
  })
  t.alike(services.map((s) => s.id).sort(), ['unit:easynews-indexer.service', 'user:kodi-repo.service'])
  t.alike(system.map((s) => s.name), ['cron.service'])
})

test('a failed vendor unit is a service, not hidden in the system list', (t) => {
  const { services } = build({ units: [unit('xdg-desktop-portal.service', { ActiveState: 'failed', SubState: 'failed' })] })
  t.is(services[0].state, 'failed')
})

test('pghAppOf: the app is the script, not pgh\'s runtime', (t) => {
  t.is(pghAppOf(['/h/.local/share/store/apps/pgh/runtime/bare', '/h/.local/share/store/apps/favela-web/index.js']), 'favela-web')
  t.is(pghAppOf(['/h/.local/share/store/apps/pgh/runtime/bare', '/h/.local/share/store/apps/pgh/lib/supervisor.js']), 'pgh')
  t.is(pghAppOf(['/usr/bin/python3', '-m', 'http.server']), null)
})

test('a plain process\'s ports are one service named after it', (t) => {
  const { services } = build({
    listeners: [17001, 18080].map((port, i) => ({ port, addr: '::', scope: 'all', uid: 1000, inodes: [i + 1] })),
    owners: new Map([[1, 7684], [2, 7684]]),
    pidInfo: new Map([[7684, { pid: 7684, cmd: 'cogserver', exe: 'cogserver', pghApp: null, unit: null, userUnit: false }]])
  })
  t.is(services.length, 1)
  t.is(services[0].kind, 'process')
  t.is(services[0].name, 'cogserver')
  t.is(services[0].ports.length, 2)
})

test('a root socket goes to the unit labels.json names for its port', (t) => {
  const { services } = build({
    listeners: [{ port: 22, addr: '0.0.0.0', scope: 'all', uid: 0, inodes: [1] }],
    units: [unit('ssh.service')],
    labels: { ports: { 22: { name: 'SSH', unit: 'ssh.service' } } }
  })
  t.is(services.length, 1)
  t.is(services[0].id, 'unit:ssh.service')
  t.is(services[0].ports[0].port, 22)
})

test('root sockets never go to a unit just because it says User=root', (t) => {
  const { services } = build({
    listeners: [{ port: 631, addr: '127.0.0.1', scope: 'local', uid: 0, inodes: [1] }],
    users: new Map([[0, 'root']]),
    units: [unit('ModemManager.service', { User: 'root' })]
  })
  t.is(services.length, 1)
  t.is(services[0].kind, 'port')
})

test('the label hint wins when several units run as the same user', (t) => {
  const { services } = build({
    listeners: [{ port: 8989, addr: '::', scope: 'all', uid: 1000, inodes: [1] }],
    units: ['sonarr.service', 'radarr.service'].map((id) => unit(id, { User: 'fleeky', FragmentPath: '/etc/systemd/system/' + id })),
    labels: { ports: { 8989: { name: 'Sonarr', unit: 'sonarr.service' } } }
  })
  const s = services.find((x) => x.id === 'unit:sonarr.service')
  t.is(s.ports.length, 1)
  t.absent(services.some((x) => x.kind === 'port'))
})

test('states', (t) => {
  t.is(stateOf({ kind: 'pgh', running: false, lastExitCode: 0, ports: [] }), 'dormant')
  t.is(stateOf({ kind: 'pgh', running: false, lastExitCode: 75, ports: [] }), 'dormant', '75 = OTA restart')
  t.is(stateOf({ kind: 'pgh', running: false, lastExitCode: 1, ports: [] }), 'failed')
  t.is(stateOf({ kind: 'pgh', running: true, ports: [{ open: false }] }), 'degraded')
  t.is(stateOf({ kind: 'unit', running: false, activeState: 'inactive', ports: [] }), 'dormant')
  t.is(stateOf({ kind: 'unit', running: true, activeState: 'active', ports: [{ open: true }, { open: false }] }), 'active')
})

test('readListeners: v4+v6 on the same port and scope are one entry; loopback twin folds into "all"', (t) => {
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hs-'))
  fs.mkdirSync(path.join(dir, 'net'))
  const head = '  sl  local_address rem_address   st tx_queue rx_queue tr tm->when retrnsmt   uid  timeout inode\n'
  fs.writeFileSync(path.join(dir, 'net/tcp'), head +
    '   0: 00000000:1FA0 00000000:0000 0A 00000000:00000000 00:00000000 00000000   122        0 101 1\n' +
    '   1: 0100007F:1FA0 00000000:0000 0A 00000000:00000000 00:00000000 00000000   122        0 102 1\n')
  fs.writeFileSync(path.join(dir, 'net/tcp6'), head +
    '   0: 00000000000000000000000000000000:1FA0 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000   122        0 103 1\n')
  const ls = readListeners(dir)
  t.is(ls.length, 1, '0.0.0.0, :: and 127.0.0.1 on 8096 are one service')
  t.is(ls[0].scope, 'all')
  t.alike(ls[0].inodes.sort(), [101, 102, 103])
})