@the-seeker/server-agent 0.1.1 → 0.2.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.
package/README.md CHANGED
@@ -73,7 +73,7 @@ nginx 상대 로그 경로는 필요할 때만 `nginx -V`의 `--prefix`를 조
73
73
  변환합니다(nginx 기본값 `/usr/local/nginx`). 요청별 변수가 포함된 로그 경로는
74
74
  정적인 파일로 확정할 수 없어 광고하지 않습니다.
75
75
 
76
- PM2 실행 위치는 `PM2_BIN`, 데이터 위치는 `pm2Home` 또는 `PM2_HOME`으로 설정합니다.
76
+ PM2 실행 파일은 `PM2_BIN`으로 설정하며, 이 값은 `jlist` 폴백 경로에서만 사용합니다. 데이터 위치는 `pm2Home` 또는 `PM2_HOME`으로 설정합니다.
77
77
  PM2 환경·실행 인수·실행 경로는 반환하지 않습니다. 로그 경로와 journal unit은
78
78
  `SourceRegistry` 내부에 보관하고 `tail(sourceId, lines, signal?)`로만 읽습니다.
79
79
  `lines`는 1~500이며 프로토콜 요청은 100/200/500만 허용합니다.
@@ -84,9 +84,44 @@ capability: `available`=조회 성공, `unsupported`=OS 미지원 또는 nginx
84
84
  `not_installed`=실행 파일 없음, `permission_denied`=권한 부족, `error`=기타 조회/파싱 실패.
85
85
  호스트 조회 실패는 호출자가 처리하고 host capability를 `error`로 지정합니다.
86
86
 
87
+ ## PM2 수집 방식
88
+
89
+ 0.2.0부터 heartbeat(기본 15초)마다 `pm2 jlist`를 자식 프로세스로 실행하지 않습니다. 대신 PM2 데몬의 RPC 유닉스 소켓 `$PM2_HOME/rpc.sock`에 연결해 `getMonitorData`를 한 번 호출한 뒤 연결을 닫습니다. PM2 CLI가 데몬과 통신할 때 사용하는 것과 같은 amp/axon-rpc 프로토콜을 사용하며, 추가 의존성은 없습니다.
90
+
91
+ 받은 데이터는 기존과 같은 허용 필드(zod allowlist)만 남깁니다. `pm2_env`의 환경 변수, 실행 인수, 실행 경로는 전송하지 않습니다. `pm2.describe` 온디맨드 요청도 같은 RPC 경로를 사용합니다.
92
+
93
+ 폴백 규칙은 다음과 같습니다.
94
+
95
+ - 소켓이 없거나 데몬이 실행 중이 아니어서 `ENOENT`가 발생하면, PM2 실행 파일이 있을 때 `available`과 빈 프로세스 목록을 반환합니다. 데몬을 띄우는 부작용을 막기 위해 `jlist`는 실행하지 않습니다. 실행 파일도 없으면 `not_installed`를 반환합니다.
96
+ - 소켓 권한 오류인 `EACCES`는 `permission_denied`로 반환합니다.
97
+ - 그 밖의 오류는 PM2 실행 파일이 있을 때 해당 heartbeat에 한해 `pm2 jlist`를 한 번 실행해 폴백합니다. 여기에는 8초 타임아웃, 프로토콜 불일치, 16MiB를 초과한 응답이 포함됩니다. 실행 파일이 없으면 `error`를 반환합니다.
98
+
99
+ 예전 방식을 강제로 사용하려면 `PM2_COLLECTOR=jlist`를 설정합니다.
100
+
101
+ systemd에서는 다음과 같이 설정할 수 있습니다.
102
+
103
+ ```bash
104
+ sudo systemctl edit theseeker-agent
105
+ ```
106
+
107
+ ```ini
108
+ [Service]
109
+ Environment=PM2_COLLECTOR=jlist
110
+ ```
111
+
112
+ 설정 후 `sudo systemctl restart theseeker-agent`를 실행합니다.
113
+
114
+ `rpc.sock`은 PM2 데몬 소유자만 접근할 수 있습니다. 따라서 `install --user`에는 PM2 데몬 소유자를 지정해야 합니다. 소유자는 다음 명령으로 확인할 수 있습니다.
115
+
116
+ ```bash
117
+ ps -eo user,cmd | grep "PM2 v"
118
+ ```
119
+
120
+ 소켓 위치는 `PM2_HOME` 또는 `--pm2-home`으로 결정합니다.
121
+
87
122
  ## 문제 해결
88
123
 
89
- systemd의 PATH에서 nvm으로 설치한 PM2가 보이지 않으면 capability가 `not_installed`가 될 수 있습니다. `@the-seeker/server-agent@0.1.1`부터는 실행 중인 node의 `dirname(process.execPath)/pm2`를 자동으로 찾고, `install`이 systemd unit에 `Environment=PATH=<node bin dir>:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`을 기록합니다. 업그레이드 후 호스트에서 `install`을 다시 실행하면 됩니다.
124
+ systemd의 PATH에서 nvm으로 설치한 PM2가 보이지 않으면 capability가 `not_installed`가 될 수 있습니다. `@the-seeker/server-agent@0.1.1`부터는 실행 중인 node의 `dirname(process.execPath)/pm2`를 자동으로 찾고, `install`이 systemd unit에 `Environment=PATH=<node bin dir>:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`을 기록합니다. 업그레이드 후 호스트에서 `install`을 다시 실행하면 됩니다(0.1.1은 이어서 `sudo systemctl restart theseeker-agent` 필요, 0.1.2부터 `install`이 자동 재시작).
90
125
 
91
126
  자동 설정을 쓰지 않을 때는 systemd drop-in으로 PM2 실행 파일을 지정합니다.
92
127
 
@@ -0,0 +1,20 @@
1
+ import type { Socket } from "node:net";
2
+ /** amp v1: header byte `(version << 4) | argc`, then per argument a UInt32BE length followed by its bytes. */
3
+ export declare function encodeAmpMessage(args: readonly unknown[]): Buffer;
4
+ export declare function decodeAmpArg(arg: Buffer): unknown;
5
+ /** amp carries no outer frame length, so TCP chunks are buffered until each frame's arguments are complete. */
6
+ export declare class AmpFrameParser {
7
+ private readonly maxBytes;
8
+ private buffered;
9
+ constructor(maxBytes?: number);
10
+ push(chunk: Buffer): Buffer[][];
11
+ private read;
12
+ }
13
+ export interface Pm2CallOptions {
14
+ readonly timeoutMs?: number;
15
+ readonly signal?: AbortSignal;
16
+ readonly maxBytes?: number;
17
+ readonly connect?: (path: string) => Socket;
18
+ }
19
+ /** Minimal pm2-axon-rpc client for the PM2 daemon's `$PM2_HOME/rpc.sock`, avoiding a `pm2 jlist` subprocess. */
20
+ export declare function pm2Call<T = unknown>(sockPath: string, method: string, params: unknown, opts?: Pm2CallOptions): Promise<T>;
@@ -0,0 +1,139 @@
1
+ import { createConnection } from "node:net";
2
+ import { CollectionError } from "./command.js";
3
+ const AMP_VERSION = 1;
4
+ const MAX_ARGS = 15;
5
+ const DEFAULT_MAX_BYTES = 16 * 1024 * 1024;
6
+ let counter = 0;
7
+ /** amp v1: header byte `(version << 4) | argc`, then per argument a UInt32BE length followed by its bytes. */
8
+ export function encodeAmpMessage(args) {
9
+ if (args.length > MAX_ARGS)
10
+ throw new CollectionError("internal");
11
+ const parts = args.map((arg) => (Buffer.isBuffer(arg) ? arg : Buffer.from(typeof arg === "string" ? `s:${arg}` : `j:${JSON.stringify(arg)}`)));
12
+ const frame = [Buffer.from([(AMP_VERSION << 4) | parts.length])];
13
+ for (const part of parts) {
14
+ const length = Buffer.alloc(4);
15
+ length.writeUInt32BE(part.length, 0);
16
+ frame.push(length, part);
17
+ }
18
+ return Buffer.concat(frame);
19
+ }
20
+ export function decodeAmpArg(arg) {
21
+ const prefix = arg.subarray(0, 2).toString("utf8");
22
+ if (prefix === "s:")
23
+ return arg.subarray(2).toString("utf8");
24
+ if (prefix === "j:")
25
+ return JSON.parse(arg.subarray(2).toString("utf8"));
26
+ return arg;
27
+ }
28
+ /** amp carries no outer frame length, so TCP chunks are buffered until each frame's arguments are complete. */
29
+ export class AmpFrameParser {
30
+ maxBytes;
31
+ buffered = Buffer.alloc(0);
32
+ constructor(maxBytes = DEFAULT_MAX_BYTES) {
33
+ this.maxBytes = maxBytes;
34
+ }
35
+ push(chunk) {
36
+ this.buffered = this.buffered.length === 0 ? chunk : Buffer.concat([this.buffered, chunk]);
37
+ if (this.buffered.length > this.maxBytes)
38
+ throw new CollectionError("too_large");
39
+ const frames = [];
40
+ for (;;) {
41
+ const frame = this.read();
42
+ if (!frame)
43
+ return frames;
44
+ frames.push(frame);
45
+ }
46
+ }
47
+ read() {
48
+ if (this.buffered.length < 1)
49
+ return null;
50
+ const header = this.buffered.readUInt8(0);
51
+ if (header >> 4 !== AMP_VERSION)
52
+ throw new CollectionError("internal");
53
+ const argc = header & 0x0f;
54
+ const args = [];
55
+ let offset = 1;
56
+ for (let index = 0; index < argc; index += 1) {
57
+ if (offset + 4 > this.buffered.length)
58
+ return null;
59
+ const length = this.buffered.readUInt32BE(offset);
60
+ offset += 4;
61
+ if (offset + length > this.buffered.length)
62
+ return null;
63
+ args.push(this.buffered.subarray(offset, offset + length));
64
+ offset += length;
65
+ }
66
+ this.buffered = this.buffered.subarray(offset);
67
+ return args;
68
+ }
69
+ }
70
+ function socketError(error) {
71
+ if (error instanceof CollectionError)
72
+ return error;
73
+ const code = error instanceof Error && "code" in error ? error.code : undefined;
74
+ if (code === "ENOENT" || code === "ENOTSOCK" || code === "ECONNREFUSED")
75
+ return new CollectionError("not_found");
76
+ if (code === "EACCES" || code === "EPERM")
77
+ return new CollectionError("permission_denied");
78
+ return new CollectionError("internal");
79
+ }
80
+ function unwrapReply(frame, id) {
81
+ const [head, tail] = frame;
82
+ if (frame.length !== 2 || !head || !tail || decodeAmpArg(tail) !== id)
83
+ throw new CollectionError("internal");
84
+ const reply = decodeAmpArg(head);
85
+ if (typeof reply !== "object" || reply === null || "error" in reply)
86
+ throw new CollectionError("internal");
87
+ if (!("args" in reply) || !Array.isArray(reply.args))
88
+ throw new CollectionError("internal");
89
+ const values = reply.args;
90
+ return values[0];
91
+ }
92
+ /** Minimal pm2-axon-rpc client for the PM2 daemon's `$PM2_HOME/rpc.sock`, avoiding a `pm2 jlist` subprocess. */
93
+ export function pm2Call(sockPath, method, params, opts = {}) {
94
+ const { timeoutMs = 8000, maxBytes = DEFAULT_MAX_BYTES, signal } = opts;
95
+ const connect = opts.connect ?? ((path) => createConnection(path));
96
+ counter += 1;
97
+ const id = `${process.pid}:${counter}`;
98
+ return new Promise((resolve, reject) => {
99
+ if (signal?.aborted === true) {
100
+ reject(new CollectionError("timeout"));
101
+ return;
102
+ }
103
+ const socket = connect(sockPath);
104
+ const parser = new AmpFrameParser(maxBytes);
105
+ let settled = false;
106
+ const onAbort = () => fail(new CollectionError("timeout"));
107
+ const timer = setTimeout(onAbort, timeoutMs);
108
+ const settle = () => {
109
+ if (settled)
110
+ return false;
111
+ settled = true;
112
+ clearTimeout(timer);
113
+ signal?.removeEventListener("abort", onAbort);
114
+ socket.destroy();
115
+ return true;
116
+ };
117
+ function fail(error) {
118
+ if (settle())
119
+ reject(socketError(error));
120
+ }
121
+ signal?.addEventListener("abort", onAbort, { once: true });
122
+ socket.on("error", fail);
123
+ socket.on("close", () => fail(new CollectionError("internal")));
124
+ socket.on("connect", () => socket.write(encodeAmpMessage([{ type: "call", method, args: [params] }, id])));
125
+ socket.on("data", (chunk) => {
126
+ try {
127
+ const [frame] = parser.push(chunk);
128
+ if (!frame)
129
+ return;
130
+ const value = unwrapReply(frame, id);
131
+ if (settle())
132
+ resolve(value);
133
+ }
134
+ catch (error) {
135
+ fail(error);
136
+ }
137
+ });
138
+ });
139
+ }
@@ -5,7 +5,8 @@ export type Pm2Collection = {
5
5
  readonly processes: Process[];
6
6
  readonly sources: Source[];
7
7
  };
8
- export declare function mapPm2(text: string, registry: SourceRegistry, now?: number): Pm2Collection;
8
+ export declare function mapPm2(list: unknown, registry: SourceRegistry, now?: number): Pm2Collection;
9
+ export declare function resolvePm2Home(pm2Home?: string): string;
9
10
  export declare function collectPm2(registry: SourceRegistry, options?: {
10
11
  readonly pm2Home?: string;
11
12
  readonly signal?: AbortSignal;
@@ -1,7 +1,8 @@
1
1
  import { homedir } from "node:os";
2
2
  import { isAbsolute, join } from "node:path";
3
3
  import { z } from "zod";
4
- import { capabilityError, locate, run } from "./command.js";
4
+ import { CollectionError, capabilityError, locate, run } from "./command.js";
5
+ import { pm2Call } from "./pm2-rpc.js";
5
6
  const jlist = z.array(z.object({
6
7
  name: z.string(), pm_id: z.number(),
7
8
  monit: z.object({ cpu: z.number().default(0), memory: z.number().default(0) }),
@@ -11,8 +12,8 @@ const jlist = z.array(z.object({
11
12
  pm_out_log_path: z.string().optional(), pm_err_log_path: z.string().optional(),
12
13
  }),
13
14
  }));
14
- export function mapPm2(text, registry, now = Date.now()) {
15
- const entries = jlist.parse(JSON.parse(text)).slice(0, 100);
15
+ export function mapPm2(list, registry, now = Date.now()) {
16
+ const entries = jlist.parse(list).slice(0, 100);
16
17
  const sources = [];
17
18
  const processes = entries.map((entry) => {
18
19
  const environment = entry.pm2_env;
@@ -31,20 +32,45 @@ export function mapPm2(text, registry, now = Date.now()) {
31
32
  });
32
33
  return { capability: "available", processes, sources: [...new Map(sources.map((source) => [source.id, source])).values()] };
33
34
  }
34
- export async function collectPm2(registry, options = {}) {
35
- registry.clearKinds(["pm2_out", "pm2_err"]);
35
+ export function resolvePm2Home(pm2Home) {
36
+ return pm2Home ?? process.env["PM2_HOME"] ?? join(homedir(), ".pm2");
37
+ }
38
+ function empty(capability) {
39
+ return { capability, processes: [], sources: [] };
40
+ }
41
+ /** Spawning `pm2 jlist` boots a daemon when none is running, so it stays a fallback for a reachable-but-unreadable socket. */
42
+ async function collectViaJlist(registry, home, signal) {
36
43
  try {
37
44
  const binary = process.env["PM2_BIN"] || await locate("pm2");
38
45
  if (!binary)
39
- return { capability: "not_installed", processes: [], sources: [] };
46
+ return empty("not_installed");
40
47
  const { stdout } = await run(binary, ["jlist"], {
41
48
  timeout: 8000, maxBuffer: 8 * 1024 * 1024,
42
- env: { ...process.env, PM2_HOME: options.pm2Home ?? process.env["PM2_HOME"] ?? join(homedir(), ".pm2") },
43
- ...(options.signal ? { signal: options.signal } : {}),
49
+ env: { ...process.env, PM2_HOME: home },
50
+ ...(signal ? { signal } : {}),
44
51
  });
45
- return mapPm2(stdout, registry);
52
+ return mapPm2(JSON.parse(stdout), registry);
53
+ }
54
+ catch (error) {
55
+ return empty(capabilityError(error));
56
+ }
57
+ }
58
+ export async function collectPm2(registry, options = {}) {
59
+ registry.clearKinds(["pm2_out", "pm2_err"]);
60
+ const home = resolvePm2Home(options.pm2Home);
61
+ if (process.env["PM2_COLLECTOR"] === "jlist")
62
+ return await collectViaJlist(registry, home, options.signal);
63
+ try {
64
+ const list = await pm2Call(join(home, "rpc.sock"), "getMonitorData", {}, { timeoutMs: 8000, ...(options.signal ? { signal: options.signal } : {}) });
65
+ return mapPm2(list, registry);
46
66
  }
47
67
  catch (error) {
48
- return { capability: capabilityError(error), processes: [], sources: [] };
68
+ const code = error instanceof CollectionError ? error.code : "internal";
69
+ if (code === "permission_denied")
70
+ return empty("permission_denied");
71
+ const binary = process.env["PM2_BIN"] || await locate("pm2");
72
+ if (code === "not_found")
73
+ return empty(binary ? "available" : "not_installed");
74
+ return binary ? await collectViaJlist(registry, home, options.signal) : empty("error");
49
75
  }
50
76
  }
package/dist/handlers.js CHANGED
@@ -1,8 +1,7 @@
1
- import { homedir, platform as currentPlatform } from "node:os";
2
- import { join } from "node:path";
1
+ import { platform as currentPlatform } from "node:os";
3
2
  import { CollectionError, locate, responseError, run } from "./collectors/command.js";
4
3
  import { collectNginx } from "./collectors/nginx.js";
5
- import { mapPm2 } from "./collectors/pm2.js";
4
+ import { collectPm2 } from "./collectors/pm2.js";
6
5
  export const AGENT_REQUEST_TIMEOUT_MS = 15000;
7
6
  export const MAX_CONCURRENT_REQUESTS = 4;
8
7
  export const SUPPORTED_ACTIONS = ["logs.tail", "service.status", "pm2.describe", "nginx.sources"];
@@ -34,14 +33,10 @@ async function serviceStatus(unit, platform, signal) {
34
33
  return { unit, active_state: properties.get("ActiveState") ?? "unknown", sub_state: properties.get("SubState") ?? "unknown" };
35
34
  }
36
35
  async function describeProcess(name, registry, state, signal) {
37
- const binary = process.env["PM2_BIN"] || await locate("pm2");
38
- if (!binary)
39
- throw new CollectionError("unsupported");
40
- const { stdout } = await run(binary, ["jlist"], {
41
- timeout: 8000, maxBuffer: 8 * 1024 * 1024, signal,
42
- env: { ...process.env, PM2_HOME: state.pm2Home ?? process.env["PM2_HOME"] ?? join(homedir(), ".pm2") },
43
- });
44
- const found = mapPm2(stdout, registry).processes.find((entry) => entry.name === name);
36
+ const collection = await collectPm2(registry, { ...(state.pm2Home ? { pm2Home: state.pm2Home } : {}), signal });
37
+ if (collection.capability !== "available")
38
+ throw new CollectionError(capabilityCode(collection.capability));
39
+ const found = collection.processes.find((entry) => entry.name === name);
45
40
  if (!found)
46
41
  throw new CollectionError("not_found");
47
42
  return found;
@@ -54,6 +54,7 @@ export function manualInstallCommands(endpoint, user, addGroups) {
54
54
  "and then runs:",
55
55
  " sudo systemctl daemon-reload",
56
56
  ` sudo systemctl enable --now ${SERVICE_NAME}`,
57
+ ` sudo systemctl restart ${SERVICE_NAME}`,
57
58
  ];
58
59
  }
59
60
  export function manualGroupCommand(user) {
@@ -89,6 +90,11 @@ export async function installSystemd(environment, options) {
89
90
  if (!(await runSystemctl(environment, ["enable", "--now", SERVICE_NAME])))
90
91
  return 1;
91
92
  environment.print(`service enabled: ${SERVICE_NAME}`);
93
+ // `enable --now` no-ops on an already-running service, so an upgraded package or a rewritten unit/config
94
+ // would keep running the old process until someone restarted it by hand.
95
+ if (!(await runSystemctl(environment, ["restart", SERVICE_NAME])))
96
+ return 1;
97
+ environment.print(`service restarted: ${SERVICE_NAME}`);
92
98
  if (!options.addGroups)
93
99
  for (const line of manualGroupCommand(options.detection.user))
94
100
  environment.print(line);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@the-seeker/server-agent",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "bin": { "theseeker-agent": "dist/cli.js" },
6
6
  "engines": { "node": ">=20" },