@sonde/packs 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/dist/docker/index.d.ts +3 -0
  2. package/dist/docker/index.d.ts.map +1 -0
  3. package/dist/docker/index.js +15 -0
  4. package/dist/docker/index.js.map +1 -0
  5. package/dist/docker/manifest.d.ts +3 -0
  6. package/dist/docker/manifest.d.ts.map +1 -0
  7. package/dist/docker/manifest.js +54 -0
  8. package/dist/docker/manifest.js.map +1 -0
  9. package/dist/docker/probes/containers-list.d.ts +19 -0
  10. package/dist/docker/probes/containers-list.d.ts.map +1 -0
  11. package/dist/docker/probes/containers-list.js +25 -0
  12. package/dist/docker/probes/containers-list.js.map +1 -0
  13. package/dist/docker/probes/containers-list.test.d.ts +2 -0
  14. package/dist/docker/probes/containers-list.test.d.ts.map +1 -0
  15. package/dist/docker/probes/containers-list.test.js +43 -0
  16. package/dist/docker/probes/containers-list.test.js.map +1 -0
  17. package/dist/docker/probes/daemon-info.d.ts +16 -0
  18. package/dist/docker/probes/daemon-info.d.ts.map +1 -0
  19. package/dist/docker/probes/daemon-info.js +20 -0
  20. package/dist/docker/probes/daemon-info.js.map +1 -0
  21. package/dist/docker/probes/daemon-info.test.d.ts +2 -0
  22. package/dist/docker/probes/daemon-info.test.d.ts.map +1 -0
  23. package/dist/docker/probes/daemon-info.test.js +42 -0
  24. package/dist/docker/probes/daemon-info.test.js.map +1 -0
  25. package/dist/docker/probes/images-list.d.ts +17 -0
  26. package/dist/docker/probes/images-list.d.ts.map +1 -0
  27. package/dist/docker/probes/images-list.js +23 -0
  28. package/dist/docker/probes/images-list.js.map +1 -0
  29. package/dist/docker/probes/images-list.test.d.ts +2 -0
  30. package/dist/docker/probes/images-list.test.d.ts.map +1 -0
  31. package/dist/docker/probes/images-list.test.js +41 -0
  32. package/dist/docker/probes/images-list.test.js.map +1 -0
  33. package/dist/docker/probes/logs-tail.d.ts +12 -0
  34. package/dist/docker/probes/logs-tail.d.ts.map +1 -0
  35. package/dist/docker/probes/logs-tail.js +22 -0
  36. package/dist/docker/probes/logs-tail.js.map +1 -0
  37. package/dist/docker/probes/logs-tail.test.d.ts +2 -0
  38. package/dist/docker/probes/logs-tail.test.d.ts.map +1 -0
  39. package/dist/docker/probes/logs-tail.test.js +42 -0
  40. package/dist/docker/probes/logs-tail.test.js.map +1 -0
  41. package/dist/index.d.ts +9 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +15 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/system/index.d.ts +3 -0
  46. package/dist/system/index.d.ts.map +1 -0
  47. package/dist/system/index.js +13 -0
  48. package/dist/system/index.js.map +1 -0
  49. package/dist/system/manifest.d.ts +3 -0
  50. package/dist/system/manifest.d.ts.map +1 -0
  51. package/dist/system/manifest.js +39 -0
  52. package/dist/system/manifest.js.map +1 -0
  53. package/dist/system/probes/cpu-usage.d.ts +14 -0
  54. package/dist/system/probes/cpu-usage.d.ts.map +1 -0
  55. package/dist/system/probes/cpu-usage.js +20 -0
  56. package/dist/system/probes/cpu-usage.js.map +1 -0
  57. package/dist/system/probes/cpu-usage.test.d.ts +2 -0
  58. package/dist/system/probes/cpu-usage.test.d.ts.map +1 -0
  59. package/dist/system/probes/cpu-usage.test.js +45 -0
  60. package/dist/system/probes/cpu-usage.test.js.map +1 -0
  61. package/dist/system/probes/disk-usage.d.ts +19 -0
  62. package/dist/system/probes/disk-usage.d.ts.map +1 -0
  63. package/dist/system/probes/disk-usage.js +34 -0
  64. package/dist/system/probes/disk-usage.js.map +1 -0
  65. package/dist/system/probes/disk-usage.test.d.ts +2 -0
  66. package/dist/system/probes/disk-usage.test.d.ts.map +1 -0
  67. package/dist/system/probes/disk-usage.test.js +62 -0
  68. package/dist/system/probes/disk-usage.test.js.map +1 -0
  69. package/dist/system/probes/memory-usage.d.ts +19 -0
  70. package/dist/system/probes/memory-usage.d.ts.map +1 -0
  71. package/dist/system/probes/memory-usage.js +46 -0
  72. package/dist/system/probes/memory-usage.js.map +1 -0
  73. package/dist/system/probes/memory-usage.test.d.ts +2 -0
  74. package/dist/system/probes/memory-usage.test.d.ts.map +1 -0
  75. package/dist/system/probes/memory-usage.test.js +50 -0
  76. package/dist/system/probes/memory-usage.test.js.map +1 -0
  77. package/dist/systemd/index.d.ts +3 -0
  78. package/dist/systemd/index.d.ts.map +1 -0
  79. package/dist/systemd/index.js +13 -0
  80. package/dist/systemd/index.js.map +1 -0
  81. package/dist/systemd/manifest.d.ts +3 -0
  82. package/dist/systemd/manifest.d.ts.map +1 -0
  83. package/dist/systemd/manifest.js +51 -0
  84. package/dist/systemd/manifest.js.map +1 -0
  85. package/dist/systemd/probes/journal-query.d.ts +19 -0
  86. package/dist/systemd/probes/journal-query.d.ts.map +1 -0
  87. package/dist/systemd/probes/journal-query.js +37 -0
  88. package/dist/systemd/probes/journal-query.js.map +1 -0
  89. package/dist/systemd/probes/journal-query.test.d.ts +2 -0
  90. package/dist/systemd/probes/journal-query.test.d.ts.map +1 -0
  91. package/dist/systemd/probes/journal-query.test.js +50 -0
  92. package/dist/systemd/probes/journal-query.test.js.map +1 -0
  93. package/dist/systemd/probes/service-status.d.ts +16 -0
  94. package/dist/systemd/probes/service-status.d.ts.map +1 -0
  95. package/dist/systemd/probes/service-status.js +29 -0
  96. package/dist/systemd/probes/service-status.js.map +1 -0
  97. package/dist/systemd/probes/service-status.test.d.ts +2 -0
  98. package/dist/systemd/probes/service-status.test.d.ts.map +1 -0
  99. package/dist/systemd/probes/service-status.test.js +48 -0
  100. package/dist/systemd/probes/service-status.test.js.map +1 -0
  101. package/dist/systemd/probes/services-list.d.ts +18 -0
  102. package/dist/systemd/probes/services-list.d.ts.map +1 -0
  103. package/dist/systemd/probes/services-list.js +29 -0
  104. package/dist/systemd/probes/services-list.js.map +1 -0
  105. package/dist/systemd/probes/services-list.test.d.ts +2 -0
  106. package/dist/systemd/probes/services-list.test.d.ts.map +1 -0
  107. package/dist/systemd/probes/services-list.test.js +61 -0
  108. package/dist/systemd/probes/services-list.test.js.map +1 -0
  109. package/dist/types.d.ts +11 -0
  110. package/dist/types.d.ts.map +1 -0
  111. package/dist/types.js +2 -0
  112. package/dist/types.js.map +1 -0
  113. package/dist/validation.d.ts +16 -0
  114. package/dist/validation.d.ts.map +1 -0
  115. package/dist/validation.js +44 -0
  116. package/dist/validation.js.map +1 -0
  117. package/dist/validation.test.d.ts +2 -0
  118. package/dist/validation.test.d.ts.map +1 -0
  119. package/dist/validation.test.js +76 -0
  120. package/dist/validation.test.js.map +1 -0
  121. package/package.json +26 -0
  122. package/src/docker/index.ts +16 -0
  123. package/src/docker/manifest.ts +55 -0
  124. package/src/docker/probes/containers-list.test.ts +50 -0
  125. package/src/docker/probes/containers-list.ts +42 -0
  126. package/src/docker/probes/daemon-info.test.ts +48 -0
  127. package/src/docker/probes/daemon-info.ts +33 -0
  128. package/src/docker/probes/images-list.test.ts +48 -0
  129. package/src/docker/probes/images-list.ts +39 -0
  130. package/src/docker/probes/logs-tail.test.ts +51 -0
  131. package/src/docker/probes/logs-tail.ts +32 -0
  132. package/src/index.ts +18 -0
  133. package/src/system/index.ts +14 -0
  134. package/src/system/manifest.ts +40 -0
  135. package/src/system/probes/cpu-usage.test.ts +55 -0
  136. package/src/system/probes/cpu-usage.ts +30 -0
  137. package/src/system/probes/disk-usage.test.ts +73 -0
  138. package/src/system/probes/disk-usage.ts +53 -0
  139. package/src/system/probes/memory-usage.test.ts +58 -0
  140. package/src/system/probes/memory-usage.ts +63 -0
  141. package/src/systemd/index.ts +14 -0
  142. package/src/systemd/manifest.ts +52 -0
  143. package/src/systemd/probes/journal-query.test.ts +64 -0
  144. package/src/systemd/probes/journal-query.ts +56 -0
  145. package/src/systemd/probes/service-status.test.ts +59 -0
  146. package/src/systemd/probes/service-status.ts +42 -0
  147. package/src/systemd/probes/services-list.test.ts +68 -0
  148. package/src/systemd/probes/services-list.ts +45 -0
  149. package/src/types.ts +16 -0
  150. package/src/validation.test.ts +86 -0
  151. package/src/validation.ts +52 -0
  152. package/tsconfig.json +11 -0
  153. package/vitest.config.ts +8 -0
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { ExecFn } from '../../types.js';
3
+ import type { MemoryUsageResult } from './memory-usage.js';
4
+ import { memoryUsage, parseFreeOutput } from './memory-usage.js';
5
+
6
+ const SAMPLE_FREE_OUTPUT = ` total used free shared buff/cache available
7
+ Mem: 16595034112 8234127360 1021071360 348321792 7339835392 10543906816
8
+ Swap: 8589930496 1073741824 7516188672
9
+ `;
10
+
11
+ describe('parseFreeOutput', () => {
12
+ it('parses free -b output into structured data', () => {
13
+ const result = parseFreeOutput(SAMPLE_FREE_OUTPUT);
14
+
15
+ expect(result.totalBytes).toBe(16595034112);
16
+ expect(result.usedBytes).toBe(8234127360);
17
+ expect(result.freeBytes).toBe(1021071360);
18
+ expect(result.availableBytes).toBe(10543906816);
19
+ expect(result.swap.totalBytes).toBe(8589930496);
20
+ expect(result.swap.usedBytes).toBe(1073741824);
21
+ expect(result.swap.freeBytes).toBe(7516188672);
22
+ });
23
+
24
+ it('handles no swap', () => {
25
+ const output = ` total used free shared buff/cache available
26
+ Mem: 4153344000 2076672000 512000000 100000000 1564672000 2576672000
27
+ Swap: 0 0 0
28
+ `;
29
+ const result = parseFreeOutput(output);
30
+ expect(result.totalBytes).toBe(4153344000);
31
+ expect(result.swap.totalBytes).toBe(0);
32
+ expect(result.swap.usedBytes).toBe(0);
33
+ expect(result.swap.freeBytes).toBe(0);
34
+ });
35
+
36
+ it('handles minimal output format', () => {
37
+ const output = ` total used free shared buff/cache available
38
+ Mem: 8000000000 4000000000 2000000000 500000000 1500000000 5000000000
39
+ `;
40
+ const result = parseFreeOutput(output);
41
+ expect(result.totalBytes).toBe(8000000000);
42
+ expect(result.swap.totalBytes).toBe(0);
43
+ });
44
+ });
45
+
46
+ describe('memoryUsage handler', () => {
47
+ it('calls free -b and returns parsed result', async () => {
48
+ const mockExec: ExecFn = async (cmd, args) => {
49
+ expect(cmd).toBe('free');
50
+ expect(args).toEqual(['-b']);
51
+ return SAMPLE_FREE_OUTPUT;
52
+ };
53
+
54
+ const result = (await memoryUsage(undefined, mockExec)) as MemoryUsageResult;
55
+ expect(result.totalBytes).toBe(16595034112);
56
+ expect(result.swap.freeBytes).toBe(7516188672);
57
+ });
58
+ });
@@ -0,0 +1,63 @@
1
+ import type { ProbeHandler } from '../../types.js';
2
+
3
+ export interface MemoryUsageResult {
4
+ totalBytes: number;
5
+ usedBytes: number;
6
+ freeBytes: number;
7
+ availableBytes: number;
8
+ swap: {
9
+ totalBytes: number;
10
+ usedBytes: number;
11
+ freeBytes: number;
12
+ };
13
+ }
14
+
15
+ /**
16
+ * Runs `free -b` and parses the output into structured JSON.
17
+ * `-b` = output in bytes.
18
+ */
19
+ export const memoryUsage: ProbeHandler = async (_params, exec) => {
20
+ const stdout = await exec('free', ['-b']);
21
+ return parseFreeOutput(stdout);
22
+ };
23
+
24
+ export function parseFreeOutput(stdout: string): MemoryUsageResult {
25
+ const lines = stdout.trim().split('\n');
26
+
27
+ let totalBytes = 0;
28
+ let usedBytes = 0;
29
+ let freeBytes = 0;
30
+ let availableBytes = 0;
31
+ let swapTotal = 0;
32
+ let swapUsed = 0;
33
+ let swapFree = 0;
34
+
35
+ for (const line of lines) {
36
+ const parts = line.trim().split(/\s+/);
37
+ const label = parts[0]?.toLowerCase();
38
+
39
+ if (label === 'mem:') {
40
+ totalBytes = Number(parts[1]);
41
+ usedBytes = Number(parts[2]);
42
+ freeBytes = Number(parts[3]);
43
+ // "available" is the last column in modern `free` output
44
+ availableBytes = Number(parts[parts.length - 1]);
45
+ } else if (label === 'swap:') {
46
+ swapTotal = Number(parts[1]);
47
+ swapUsed = Number(parts[2]);
48
+ swapFree = Number(parts[3]);
49
+ }
50
+ }
51
+
52
+ return {
53
+ totalBytes,
54
+ usedBytes,
55
+ freeBytes,
56
+ availableBytes,
57
+ swap: {
58
+ totalBytes: swapTotal,
59
+ usedBytes: swapUsed,
60
+ freeBytes: swapFree,
61
+ },
62
+ };
63
+ }
@@ -0,0 +1,14 @@
1
+ import type { Pack } from '../types.js';
2
+ import { systemdManifest } from './manifest.js';
3
+ import { journalQuery } from './probes/journal-query.js';
4
+ import { serviceStatus } from './probes/service-status.js';
5
+ import { servicesList } from './probes/services-list.js';
6
+
7
+ export const systemdPack: Pack = {
8
+ manifest: systemdManifest,
9
+ handlers: {
10
+ 'systemd.services.list': servicesList,
11
+ 'systemd.service.status': serviceStatus,
12
+ 'systemd.journal.query': journalQuery,
13
+ },
14
+ };
@@ -0,0 +1,52 @@
1
+ import type { PackManifest } from '@sonde/shared';
2
+
3
+ export const systemdManifest: PackManifest = {
4
+ name: 'systemd',
5
+ version: '0.1.0',
6
+ description: 'systemd service and journal probes',
7
+ requires: {
8
+ groups: [],
9
+ files: [],
10
+ commands: ['systemctl'],
11
+ },
12
+ probes: [
13
+ {
14
+ name: 'services.list',
15
+ description: 'List all systemd service units',
16
+ capability: 'observe',
17
+ timeout: 10_000,
18
+ },
19
+ {
20
+ name: 'service.status',
21
+ description: 'Detailed status of a specific service',
22
+ capability: 'observe',
23
+ params: {
24
+ service: { type: 'string', description: 'Service unit name', required: true },
25
+ },
26
+ timeout: 10_000,
27
+ },
28
+ {
29
+ name: 'journal.query',
30
+ description: 'Query journal logs for a unit',
31
+ capability: 'observe',
32
+ params: {
33
+ unit: { type: 'string', description: 'Systemd unit name', required: true },
34
+ lines: {
35
+ type: 'number',
36
+ description: 'Number of log entries',
37
+ required: false,
38
+ default: 50,
39
+ },
40
+ },
41
+ timeout: 15_000,
42
+ },
43
+ ],
44
+ runbook: {
45
+ category: 'systemd',
46
+ probes: ['services.list'],
47
+ parallel: true,
48
+ },
49
+ detect: {
50
+ files: ['/run/systemd/system'],
51
+ },
52
+ };
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { ExecFn } from '../../types.js';
3
+ import type { JournalQueryResult } from './journal-query.js';
4
+ import { journalQuery, parseJournalQuery } from './journal-query.js';
5
+
6
+ const SAMPLE_OUTPUT = `{"__REALTIME_TIMESTAMP":"1705312200000000","PRIORITY":"6","MESSAGE":"Starting nginx...","_PID":"1234","_UID":"0","_SYSTEMD_UNIT":"nginx.service"}
7
+ {"__REALTIME_TIMESTAMP":"1705312201000000","PRIORITY":"6","MESSAGE":"Started nginx.","_PID":"1234","_UID":"0","_SYSTEMD_UNIT":"nginx.service"}
8
+ {"__REALTIME_TIMESTAMP":"1705312205000000","PRIORITY":"4","MESSAGE":"worker process exited on signal 15","_PID":"1235","_UID":"33","_SYSTEMD_UNIT":"nginx.service"}`;
9
+
10
+ describe('parseJournalQuery', () => {
11
+ it('parses journalctl JSON output into structured data', () => {
12
+ const result = parseJournalQuery('nginx.service', SAMPLE_OUTPUT);
13
+
14
+ expect(result.unit).toBe('nginx.service');
15
+ expect(result.entries).toHaveLength(3);
16
+ expect(result.entryCount).toBe(3);
17
+
18
+ expect(result.entries[0]).toEqual({
19
+ timestamp: '1705312200000000',
20
+ priority: 6,
21
+ message: 'Starting nginx...',
22
+ pid: '1234',
23
+ uid: '0',
24
+ });
25
+
26
+ expect(result.entries[2]?.priority).toBe(4);
27
+ expect(result.entries[2]?.message).toBe('worker process exited on signal 15');
28
+ });
29
+
30
+ it('handles empty output', () => {
31
+ const result = parseJournalQuery('nginx.service', '');
32
+ expect(result.entries).toHaveLength(0);
33
+ expect(result.entryCount).toBe(0);
34
+ });
35
+ });
36
+
37
+ describe('journalQuery handler', () => {
38
+ it('calls journalctl with default lines and returns parsed result', async () => {
39
+ const mockExec: ExecFn = async (cmd, args) => {
40
+ expect(cmd).toBe('journalctl');
41
+ expect(args).toEqual(['-u', 'nginx.service', '-n', '50', '--no-pager', '-o', 'json']);
42
+ return SAMPLE_OUTPUT;
43
+ };
44
+
45
+ const result = (await journalQuery({ unit: 'nginx.service' }, mockExec)) as JournalQueryResult;
46
+ expect(result.unit).toBe('nginx.service');
47
+ expect(result.entryCount).toBe(3);
48
+ });
49
+
50
+ it('uses custom lines param', async () => {
51
+ const mockExec: ExecFn = async (cmd, args) => {
52
+ expect(cmd).toBe('journalctl');
53
+ expect(args).toEqual(['-u', 'sshd.service', '-n', '20', '--no-pager', '-o', 'json']);
54
+ return '{}';
55
+ };
56
+
57
+ // Single-line JSON object, not an array — parses as one entry
58
+ const result = (await journalQuery(
59
+ { unit: 'sshd.service', lines: 20 },
60
+ mockExec,
61
+ )) as JournalQueryResult;
62
+ expect(result.unit).toBe('sshd.service');
63
+ });
64
+ });
@@ -0,0 +1,56 @@
1
+ import type { ProbeHandler } from '../../types.js';
2
+
3
+ export interface JournalEntry {
4
+ timestamp: string;
5
+ priority: number;
6
+ message: string;
7
+ pid: string;
8
+ uid: string;
9
+ }
10
+
11
+ export interface JournalQueryResult {
12
+ unit: string;
13
+ entries: JournalEntry[];
14
+ entryCount: number;
15
+ }
16
+
17
+ /**
18
+ * Runs `journalctl -u <unit> -n <lines> --no-pager -o json` and parses each JSON line.
19
+ */
20
+ export const journalQuery: ProbeHandler = async (params, exec) => {
21
+ const unit = params?.unit as string;
22
+ const lines = (params?.lines as number) ?? 50;
23
+
24
+ const stdout = await exec('journalctl', [
25
+ '-u',
26
+ unit,
27
+ '-n',
28
+ String(lines),
29
+ '--no-pager',
30
+ '-o',
31
+ 'json',
32
+ ]);
33
+ return parseJournalQuery(unit, stdout);
34
+ };
35
+
36
+ export function parseJournalQuery(unit: string, stdout: string): JournalQueryResult {
37
+ const lines = stdout.trim().split('\n').filter(Boolean);
38
+ const entries: JournalEntry[] = [];
39
+
40
+ for (const line of lines) {
41
+ const raw = JSON.parse(line);
42
+ entries.push({
43
+ timestamp: raw.__REALTIME_TIMESTAMP ?? raw._SOURCE_REALTIME_TIMESTAMP ?? '',
44
+ priority: Number(raw.PRIORITY ?? 6),
45
+ message: raw.MESSAGE ?? '',
46
+ pid: raw._PID ?? '',
47
+ uid: raw._UID ?? '',
48
+ });
49
+ }
50
+
51
+ return {
52
+ unit,
53
+ entries,
54
+ entryCount: entries.length,
55
+ };
56
+ }
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { ExecFn } from '../../types.js';
3
+ import type { ServiceStatusResult } from './service-status.js';
4
+ import { parseServiceStatus, serviceStatus } from './service-status.js';
5
+
6
+ const SAMPLE_OUTPUT = `Type=notify
7
+ Restart=on-failure
8
+ Id=nginx.service
9
+ LoadState=loaded
10
+ ActiveState=active
11
+ SubState=running
12
+ MainPID=1234
13
+ MemoryCurrent=52428800
14
+ NRestarts=2
15
+ ExecMainStartTimestamp=Mon 2024-01-15 10:30:00 UTC`;
16
+
17
+ describe('parseServiceStatus', () => {
18
+ it('parses systemctl show output into structured data', () => {
19
+ const result = parseServiceStatus(SAMPLE_OUTPUT);
20
+
21
+ expect(result).toEqual({
22
+ name: 'nginx.service',
23
+ loadState: 'loaded',
24
+ activeState: 'active',
25
+ subState: 'running',
26
+ mainPid: 1234,
27
+ memoryBytes: 52428800,
28
+ restartCount: 2,
29
+ });
30
+ });
31
+
32
+ it('handles missing fields with defaults', () => {
33
+ const result = parseServiceStatus('Id=unknown.service\nLoadState=not-found');
34
+
35
+ expect(result.name).toBe('unknown.service');
36
+ expect(result.loadState).toBe('not-found');
37
+ expect(result.activeState).toBe('');
38
+ expect(result.mainPid).toBe(0);
39
+ expect(result.memoryBytes).toBe(0);
40
+ expect(result.restartCount).toBe(0);
41
+ });
42
+ });
43
+
44
+ describe('serviceStatus handler', () => {
45
+ it('calls systemctl show with correct args and returns parsed result', async () => {
46
+ const mockExec: ExecFn = async (cmd, args) => {
47
+ expect(cmd).toBe('systemctl');
48
+ expect(args).toEqual(['show', 'nginx.service', '--no-pager']);
49
+ return SAMPLE_OUTPUT;
50
+ };
51
+
52
+ const result = (await serviceStatus(
53
+ { service: 'nginx.service' },
54
+ mockExec,
55
+ )) as ServiceStatusResult;
56
+ expect(result.name).toBe('nginx.service');
57
+ expect(result.mainPid).toBe(1234);
58
+ });
59
+ });
@@ -0,0 +1,42 @@
1
+ import type { ProbeHandler } from '../../types.js';
2
+
3
+ export interface ServiceStatusResult {
4
+ name: string;
5
+ loadState: string;
6
+ activeState: string;
7
+ subState: string;
8
+ mainPid: number;
9
+ memoryBytes: number;
10
+ restartCount: number;
11
+ }
12
+
13
+ /**
14
+ * Runs `systemctl show <service> --no-pager` and parses key=value output.
15
+ */
16
+ export const serviceStatus: ProbeHandler = async (params, exec) => {
17
+ const service = params?.service as string;
18
+ const stdout = await exec('systemctl', ['show', service, '--no-pager']);
19
+ return parseServiceStatus(stdout);
20
+ };
21
+
22
+ export function parseServiceStatus(stdout: string): ServiceStatusResult {
23
+ const props = new Map<string, string>();
24
+
25
+ for (const line of stdout.trim().split('\n')) {
26
+ const idx = line.indexOf('=');
27
+ if (idx === -1) continue;
28
+ const key = line.slice(0, idx);
29
+ const value = line.slice(idx + 1);
30
+ props.set(key, value);
31
+ }
32
+
33
+ return {
34
+ name: props.get('Id') ?? '',
35
+ loadState: props.get('LoadState') ?? '',
36
+ activeState: props.get('ActiveState') ?? '',
37
+ subState: props.get('SubState') ?? '',
38
+ mainPid: Number(props.get('MainPID') ?? '0'),
39
+ memoryBytes: Number(props.get('MemoryCurrent') ?? '0'),
40
+ restartCount: Number(props.get('NRestarts') ?? '0'),
41
+ };
42
+ }
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { ExecFn } from '../../types.js';
3
+ import type { ServicesListResult } from './services-list.js';
4
+ import { parseServicesList, servicesList } from './services-list.js';
5
+
6
+ const SAMPLE_OUTPUT = JSON.stringify([
7
+ {
8
+ unit: 'nginx.service',
9
+ load: 'loaded',
10
+ active: 'active',
11
+ sub: 'running',
12
+ description: 'A high performance web server',
13
+ },
14
+ {
15
+ unit: 'postgresql.service',
16
+ load: 'loaded',
17
+ active: 'inactive',
18
+ sub: 'dead',
19
+ description: 'PostgreSQL RDBMS',
20
+ },
21
+ ]);
22
+
23
+ describe('parseServicesList', () => {
24
+ it('parses systemctl JSON output into structured data', () => {
25
+ const result = parseServicesList(SAMPLE_OUTPUT);
26
+
27
+ expect(result.services).toHaveLength(2);
28
+ expect(result.services[0]).toEqual({
29
+ unit: 'nginx.service',
30
+ load: 'loaded',
31
+ active: 'active',
32
+ sub: 'running',
33
+ description: 'A high performance web server',
34
+ });
35
+ expect(result.services[1]).toEqual({
36
+ unit: 'postgresql.service',
37
+ load: 'loaded',
38
+ active: 'inactive',
39
+ sub: 'dead',
40
+ description: 'PostgreSQL RDBMS',
41
+ });
42
+ });
43
+
44
+ it('handles empty array', () => {
45
+ const result = parseServicesList('[]');
46
+ expect(result.services).toHaveLength(0);
47
+ });
48
+ });
49
+
50
+ describe('servicesList handler', () => {
51
+ it('calls systemctl with correct args and returns parsed result', async () => {
52
+ const mockExec: ExecFn = async (cmd, args) => {
53
+ expect(cmd).toBe('systemctl');
54
+ expect(args).toEqual([
55
+ 'list-units',
56
+ '--type=service',
57
+ '--all',
58
+ '--no-pager',
59
+ '--output=json',
60
+ ]);
61
+ return SAMPLE_OUTPUT;
62
+ };
63
+
64
+ const result = (await servicesList(undefined, mockExec)) as ServicesListResult;
65
+ expect(result.services).toHaveLength(2);
66
+ expect(result.services[0]?.unit).toBe('nginx.service');
67
+ });
68
+ });
@@ -0,0 +1,45 @@
1
+ import type { ProbeHandler } from '../../types.js';
2
+
3
+ export interface ServiceInfo {
4
+ unit: string;
5
+ load: string;
6
+ active: string;
7
+ sub: string;
8
+ description: string;
9
+ }
10
+
11
+ export interface ServicesListResult {
12
+ services: ServiceInfo[];
13
+ }
14
+
15
+ /**
16
+ * Runs `systemctl list-units --type=service --all --no-pager --output=json`
17
+ * and parses the JSON output.
18
+ */
19
+ export const servicesList: ProbeHandler = async (_params, exec) => {
20
+ const stdout = await exec('systemctl', [
21
+ 'list-units',
22
+ '--type=service',
23
+ '--all',
24
+ '--no-pager',
25
+ '--output=json',
26
+ ]);
27
+ return parseServicesList(stdout);
28
+ };
29
+
30
+ export function parseServicesList(stdout: string): ServicesListResult {
31
+ const raw = JSON.parse(stdout);
32
+ const services: ServiceInfo[] = [];
33
+
34
+ for (const entry of raw) {
35
+ services.push({
36
+ unit: entry.unit ?? '',
37
+ load: entry.load ?? '',
38
+ active: entry.active ?? '',
39
+ sub: entry.sub ?? '',
40
+ description: entry.description ?? '',
41
+ });
42
+ }
43
+
44
+ return { services };
45
+ }
package/src/types.ts ADDED
@@ -0,0 +1,16 @@
1
+ import type { PackManifest } from '@sonde/shared';
2
+
3
+ /** Function that executes a command and returns stdout */
4
+ export type ExecFn = (command: string, args: string[]) => Promise<string>;
5
+
6
+ /** Probe handler: takes params + exec helper, returns structured data */
7
+ export type ProbeHandler = (
8
+ params: Record<string, unknown> | undefined,
9
+ exec: ExecFn,
10
+ ) => Promise<unknown>;
11
+
12
+ /** A loaded pack with its manifest and probe handlers */
13
+ export interface Pack {
14
+ manifest: PackManifest;
15
+ handlers: Record<string, ProbeHandler>;
16
+ }
@@ -0,0 +1,86 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { Pack } from './types.js';
3
+ import { PackValidationError, createPackRegistry, validatePack } from './validation.js';
4
+
5
+ function makePack(overrides?: Partial<Pack>): Pack {
6
+ return {
7
+ manifest: {
8
+ name: 'test',
9
+ version: '0.1.0',
10
+ description: 'Test pack',
11
+ requires: { groups: [], files: [], commands: [] },
12
+ probes: [
13
+ { name: 'foo', description: 'Foo probe', capability: 'observe', timeout: 10_000 },
14
+ { name: 'bar', description: 'Bar probe', capability: 'observe', timeout: 10_000 },
15
+ ],
16
+ },
17
+ handlers: {
18
+ 'test.foo': async () => ({}),
19
+ 'test.bar': async () => ({}),
20
+ },
21
+ ...overrides,
22
+ };
23
+ }
24
+
25
+ describe('validatePack', () => {
26
+ it('passes for a valid pack', () => {
27
+ expect(() => validatePack(makePack())).not.toThrow();
28
+ });
29
+
30
+ it('throws on missing handler', () => {
31
+ const pack = makePack({
32
+ handlers: {
33
+ 'test.foo': async () => ({}),
34
+ // missing test.bar
35
+ },
36
+ });
37
+ expect(() => validatePack(pack)).toThrow(PackValidationError);
38
+ expect(() => validatePack(pack)).toThrow('missing handler for probe "test.bar"');
39
+ });
40
+
41
+ it('throws on extra handler', () => {
42
+ const pack = makePack({
43
+ handlers: {
44
+ 'test.foo': async () => ({}),
45
+ 'test.bar': async () => ({}),
46
+ 'test.baz': async () => ({}),
47
+ },
48
+ });
49
+ expect(() => validatePack(pack)).toThrow(PackValidationError);
50
+ expect(() => validatePack(pack)).toThrow('extra handler "test.baz" not in manifest');
51
+ });
52
+ });
53
+
54
+ describe('createPackRegistry', () => {
55
+ it('returns a map of validated packs', () => {
56
+ const pack = makePack();
57
+ const registry = createPackRegistry([pack]);
58
+
59
+ expect(registry.size).toBe(1);
60
+ expect(registry.get('test')).toBe(pack);
61
+ });
62
+
63
+ it('throws on duplicate pack names', () => {
64
+ const pack1 = makePack();
65
+ const pack2 = makePack();
66
+
67
+ expect(() => createPackRegistry([pack1, pack2])).toThrow(PackValidationError);
68
+ expect(() => createPackRegistry([pack1, pack2])).toThrow('Duplicate pack name: "test"');
69
+ });
70
+
71
+ it('throws if any pack is invalid', () => {
72
+ const validPack = makePack();
73
+ const invalidPack = makePack({
74
+ manifest: {
75
+ name: 'broken',
76
+ version: '0.1.0',
77
+ description: 'Broken pack',
78
+ requires: { groups: [], files: [], commands: [] },
79
+ probes: [{ name: 'x', description: 'X', capability: 'observe', timeout: 10_000 }],
80
+ },
81
+ handlers: {}, // missing handler
82
+ });
83
+
84
+ expect(() => createPackRegistry([validPack, invalidPack])).toThrow(PackValidationError);
85
+ });
86
+ });
@@ -0,0 +1,52 @@
1
+ import type { Pack } from './types.js';
2
+
3
+ export class PackValidationError extends Error {
4
+ constructor(message: string) {
5
+ super(message);
6
+ this.name = 'PackValidationError';
7
+ }
8
+ }
9
+
10
+ /**
11
+ * Validates that a pack's handlers match its manifest probes exactly.
12
+ * - Every probe in manifest must have a handler keyed as `{packName}.{probeName}`
13
+ * - No extra handlers may exist beyond what the manifest declares
14
+ */
15
+ export function validatePack(pack: Pack): void {
16
+ const packName = pack.manifest.name;
17
+ const expectedKeys = new Set(pack.manifest.probes.map((p) => `${packName}.${p.name}`));
18
+ const actualKeys = new Set(Object.keys(pack.handlers));
19
+
20
+ // Check for missing handlers
21
+ for (const key of expectedKeys) {
22
+ if (!actualKeys.has(key)) {
23
+ throw new PackValidationError(`Pack "${packName}": missing handler for probe "${key}"`);
24
+ }
25
+ }
26
+
27
+ // Check for extra handlers
28
+ for (const key of actualKeys) {
29
+ if (!expectedKeys.has(key)) {
30
+ throw new PackValidationError(`Pack "${packName}": extra handler "${key}" not in manifest`);
31
+ }
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Validates all packs and builds a frozen registry map.
37
+ * Throws on duplicate pack names or invalid packs.
38
+ */
39
+ export function createPackRegistry(packs: Pack[]): ReadonlyMap<string, Pack> {
40
+ const registry = new Map<string, Pack>();
41
+
42
+ for (const pack of packs) {
43
+ validatePack(pack);
44
+
45
+ if (registry.has(pack.manifest.name)) {
46
+ throw new PackValidationError(`Duplicate pack name: "${pack.manifest.name}"`);
47
+ }
48
+ registry.set(pack.manifest.name, pack);
49
+ }
50
+
51
+ return registry;
52
+ }