Private
Public Access
feat: initial Go/JS/Rust Origin check-in client SDKs
CI / rust (push) Successful in 3m6s
CI / go (push) Successful in 33s
CI / js (push) Successful in 34s
CI / rust (push) Successful in 3m6s
CI / go (push) Successful in 33s
CI / js (push) Successful in 34s
Standalone clients that resolve a machine unique ID (OS machine ID first, falling back to a generated UUID persisted to disk), detect hostname/IP/ container/orchestrator, and register+re-ping origin.warky.dev's public service-checkin endpoint on a daily interval. Ported from icy2's internal origincheckin package but extended to the full check-in contract and made dependency-light for external reuse. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
# @warkanum/origin-client (JS/TS)
|
||||
|
||||
```
|
||||
npm install @warkanum/origin-client
|
||||
```
|
||||
|
||||
Node >=18, ESM only.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { OriginClient } from "@warkanum/origin-client";
|
||||
|
||||
const client = new OriginClient({
|
||||
serviceKey: "...",
|
||||
type: "myservice",
|
||||
version: "1.2.3",
|
||||
name: "myservice-nova",
|
||||
});
|
||||
|
||||
client.start(); // immediate check-in, then every 24h until stop()
|
||||
```
|
||||
|
||||
Call `client.checkinOnce()` directly instead of `start()` to send a single
|
||||
check-in and observe the result/error.
|
||||
|
||||
## Config
|
||||
|
||||
| Field | Required | Default |
|
||||
|---|---|---|
|
||||
| `serviceKey` | yes | — |
|
||||
| `type`, `version`, `name` | yes | — |
|
||||
| `url` | no | `DEFAULT_URL` (`https://origin.warky.dev/api/public/service-checkin`) |
|
||||
| `appType` | no | `"javascript"` |
|
||||
| `hostname` | no | OS hostname |
|
||||
| `containerId` | no | best-effort Docker/cgroup detection |
|
||||
| `orchestrator` | no | best-effort Kubernetes/Docker detection |
|
||||
| `installId` | no | OS machine ID, else a generated UUID persisted to `installIdPath` |
|
||||
| `intervalHours` | no | `DEFAULT_INTERVAL_HOURS` (24) |
|
||||
| `logger` | no | discards warnings |
|
||||
|
||||
`site`, `environment`, `databaseType`, `databaseName`, `port`, `baseUrl`,
|
||||
`internalUrl`, `description` are optional metadata with no default.
|
||||
|
||||
`hostname()`, `outboundIp()`, `containerId()`, `orchestrator()`, and
|
||||
`resolveMachineId()` are exported standalone for callers that want the
|
||||
detection logic without the HTTP client.
|
||||
Generated
+1331
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@warkanum/origin-client",
|
||||
"version": "0.1.0",
|
||||
"description": "Origin service check-in client: registers this process with origin.warky.dev and re-pings it on an interval.",
|
||||
"type": "module",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { containerId as detectContainerId, hostname as detectHostname, orchestrator as detectOrchestrator } from "./hostInfo.js";
|
||||
import type { Logger } from "./machineId.js";
|
||||
import { resolveMachineId } from "./machineId.js";
|
||||
import type { CheckinResponse, Payload } from "./payload.js";
|
||||
|
||||
export const DEFAULT_URL = "https://origin.warky.dev/api/public/service-checkin";
|
||||
export const DEFAULT_INTERVAL_HOURS = 24;
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
export interface Config {
|
||||
/** Check-in endpoint. Defaults to DEFAULT_URL. */
|
||||
url?: string;
|
||||
/** Sent as the X-Origin-Service-Key header. Required. */
|
||||
serviceKey: string;
|
||||
|
||||
/** Origin service type, which must already be registered in Origin (e.g. "icy2"). Required. */
|
||||
type: string;
|
||||
/** Caller's own version string. Required. */
|
||||
version: string;
|
||||
/** Identifies this deployment/instance to Origin, e.g. "icy2-nova". Required. */
|
||||
name: string;
|
||||
|
||||
site?: string;
|
||||
environment?: string;
|
||||
databaseType?: string;
|
||||
databaseName?: string;
|
||||
/** Defaults to "javascript" when unset. */
|
||||
appType?: string;
|
||||
/** Defaults to the OS hostname when unset. */
|
||||
hostname?: string;
|
||||
port?: number;
|
||||
baseUrl?: string;
|
||||
internalUrl?: string;
|
||||
/** Defaults to best-effort Docker/cgroup detection when unset. */
|
||||
containerId?: string;
|
||||
/** Defaults to best-effort Kubernetes/Docker detection when unset. */
|
||||
orchestrator?: string;
|
||||
description?: string;
|
||||
|
||||
/** Overrides machine-ID resolution entirely when set. */
|
||||
installId?: string;
|
||||
/** Where a generated fallback install ID is persisted when no real OS machine ID is available. */
|
||||
installIdPath?: string;
|
||||
|
||||
/** How often start() repeats. Defaults to DEFAULT_INTERVAL_HOURS. */
|
||||
intervalHours?: number;
|
||||
|
||||
/** Receives non-fatal warnings. Defaults to discarding them. */
|
||||
logger?: Logger;
|
||||
}
|
||||
|
||||
type ResolvedConfig = Config & { url: string; appType: string; intervalHours: number; logger: Logger };
|
||||
|
||||
/** OriginClient checks a service in with Origin. */
|
||||
export class OriginClient {
|
||||
private readonly cfg: ResolvedConfig;
|
||||
private readonly installId: string;
|
||||
private timer?: ReturnType<typeof setInterval>;
|
||||
|
||||
/**
|
||||
* Validates config, resolves the machine/install ID, and returns a ready
|
||||
* client. Resolution happens once, at construction, so every check-in
|
||||
* made by this client reports the same unique_install_id.
|
||||
*/
|
||||
constructor(config: Config) {
|
||||
if (!config.serviceKey || !config.type || !config.version || !config.name) {
|
||||
throw new Error("originclient: serviceKey, type, version, and name are required");
|
||||
}
|
||||
const logger = config.logger ?? { warn: () => {} };
|
||||
this.cfg = {
|
||||
...config,
|
||||
url: config.url || DEFAULT_URL,
|
||||
appType: config.appType || "javascript",
|
||||
intervalHours: config.intervalHours && config.intervalHours > 0 ? config.intervalHours : DEFAULT_INTERVAL_HOURS,
|
||||
logger,
|
||||
};
|
||||
this.installId = resolveMachineId(config.installId, config.installIdPath, logger);
|
||||
}
|
||||
|
||||
private buildPayload(): Payload {
|
||||
const hostname = this.cfg.hostname || detectHostname();
|
||||
const containerId = this.cfg.containerId || detectContainerId();
|
||||
const orchestrator = this.cfg.orchestrator || detectOrchestrator();
|
||||
|
||||
return {
|
||||
type: this.cfg.type,
|
||||
version: this.cfg.version,
|
||||
name: this.cfg.name,
|
||||
unique_install_id: this.installId,
|
||||
site: this.cfg.site,
|
||||
environment: this.cfg.environment,
|
||||
database_type: this.cfg.databaseType,
|
||||
database_name: this.cfg.databaseName,
|
||||
app_type: this.cfg.appType,
|
||||
hostname: hostname || undefined,
|
||||
port: this.cfg.port,
|
||||
base_url: this.cfg.baseUrl,
|
||||
internal_url: this.cfg.internalUrl,
|
||||
container_id: containerId || undefined,
|
||||
orchestrator: orchestrator || undefined,
|
||||
description: this.cfg.description,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a single check-in request and returns Origin's response. Throws
|
||||
* on any encode/network/non-2xx failure, after logging it via
|
||||
* config.logger.
|
||||
*/
|
||||
async checkinOnce(): Promise<CheckinResponse> {
|
||||
const payload = this.buildPayload();
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const res = await fetch(this.cfg.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Origin-Service-Key": this.cfg.serviceKey,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`originclient: non-2xx response: ${res.status}`);
|
||||
}
|
||||
|
||||
return (await res.json()) as CheckinResponse;
|
||||
} catch (err) {
|
||||
this.cfg.logger.warn("origin checkin: request failed", err);
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs an immediate check-in, then repeats every intervalHours until
|
||||
* stop() is called. Check-in failures are logged via config.logger and
|
||||
* otherwise ignored; call checkinOnce() directly to observe them.
|
||||
*/
|
||||
start(): void {
|
||||
void this.checkinOnce().catch(() => {});
|
||||
const intervalMs = this.cfg.intervalHours * 60 * 60 * 1000;
|
||||
this.timer = setInterval(() => {
|
||||
void this.checkinOnce().catch(() => {});
|
||||
}, intervalMs);
|
||||
this.timer.unref?.();
|
||||
}
|
||||
|
||||
/** Stops the interval started by start(). Safe to call even if start() was never called. */
|
||||
stop(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { hostname as osHostname, networkInterfaces } from "node:os";
|
||||
|
||||
/** hostname returns the OS hostname, or "" if it cannot be determined. */
|
||||
export function hostname(): string {
|
||||
try {
|
||||
return osHostname();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* outboundIp returns the first non-loopback IPv4 address bound to this
|
||||
* host. It is a LAN-facing address, not the host's public internet
|
||||
* address, and is not part of Origin's wire contract - it exists so
|
||||
* callers can populate base_url/internal_url/description themselves.
|
||||
*/
|
||||
export function outboundIp(): string {
|
||||
const interfaces = networkInterfaces();
|
||||
for (const name of Object.keys(interfaces)) {
|
||||
for (const info of interfaces[name] ?? []) {
|
||||
if (info.family === "IPv4" && !info.internal) {
|
||||
return info.address;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* containerId best-effort detects the Docker/Kubernetes container ID this
|
||||
* process runs in. It returns "" outside a container or when detection
|
||||
* fails; an empty container ID is treated as "not containerized" rather
|
||||
* than an error.
|
||||
*/
|
||||
export function containerId(): string {
|
||||
try {
|
||||
if (existsSync("/proc/self/cgroup")) {
|
||||
const id = parseCgroupContainerId(readFileSync("/proc/self/cgroup", "utf8"));
|
||||
if (id) return id;
|
||||
}
|
||||
} catch {
|
||||
// fall through to dockerenv check
|
||||
}
|
||||
if (existsSync("/.dockerenv")) {
|
||||
const h = hostname();
|
||||
if (isContainerIdLike(h)) return h;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* orchestrator best-effort detects the orchestration environment:
|
||||
* "kubernetes" under Kubernetes, "docker" in a plain Docker container, ""
|
||||
* otherwise.
|
||||
*/
|
||||
export function orchestrator(): string {
|
||||
if (process.env.KUBERNETES_SERVICE_HOST) return "kubernetes";
|
||||
if (existsSync("/.dockerenv")) return "docker";
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* parseCgroupContainerId extracts a container ID from /proc/self/cgroup
|
||||
* content, handling both cgroup v1 (".../docker/<id>") and cgroup v2
|
||||
* systemd-scope (".../docker-<id>.scope") layouts.
|
||||
*/
|
||||
export function parseCgroupContainerId(data: string): string {
|
||||
for (const rawLine of data.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
const idx = line.lastIndexOf("/");
|
||||
if (idx === -1) continue;
|
||||
let segment = line.slice(idx + 1);
|
||||
segment = segment.replace(/\.scope$/, "");
|
||||
segment = segment.replace(/^docker-/, "");
|
||||
if (isContainerIdLike(segment)) return segment;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function isContainerIdLike(s: string): boolean {
|
||||
if (s.length < 12 || s.length > 64) return false;
|
||||
return /^[0-9a-f]+$/.test(s);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { DEFAULT_INTERVAL_HOURS, DEFAULT_URL, OriginClient } from "./client.js";
|
||||
export type { Config } from "./client.js";
|
||||
export { containerId, hostname, orchestrator, outboundIp } from "./hostInfo.js";
|
||||
export { defaultInstallIdPath, detectOsMachineId, resolveMachineId } from "./machineId.js";
|
||||
export type { Logger } from "./machineId.js";
|
||||
export type { CheckinResponse, Payload } from "./payload.js";
|
||||
@@ -0,0 +1,124 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir, platform, tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
export interface Logger {
|
||||
warn(msg: string, err?: unknown): void;
|
||||
}
|
||||
|
||||
const noopLogger: Logger = { warn: () => {} };
|
||||
|
||||
function readLinuxMachineId(): string | undefined {
|
||||
for (const path of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
|
||||
try {
|
||||
const id = readFileSync(path, "utf8").trim();
|
||||
if (id) return id;
|
||||
} catch {
|
||||
// try the next candidate path
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readDarwinMachineId(): string | undefined {
|
||||
try {
|
||||
const out = execFileSync("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], {
|
||||
encoding: "utf8",
|
||||
timeout: 2000,
|
||||
});
|
||||
return out.match(/"IOPlatformUUID"\s*=\s*"([0-9A-Fa-f-]+)"/)?.[1];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function readWindowsMachineId(): string | undefined {
|
||||
try {
|
||||
const out = execFileSync(
|
||||
"reg",
|
||||
["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"],
|
||||
{ encoding: "utf8", timeout: 2000 },
|
||||
);
|
||||
return out.match(/MachineGuid\s+REG_SZ\s+([0-9A-Fa-f-]+)/)?.[1];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** detectOsMachineId reads the real OS machine ID, or undefined if unavailable/unsupported. */
|
||||
export function detectOsMachineId(): string | undefined {
|
||||
switch (platform()) {
|
||||
case "linux":
|
||||
return readLinuxMachineId();
|
||||
case "darwin":
|
||||
return readDarwinMachineId();
|
||||
case "win32":
|
||||
return readWindowsMachineId();
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* machineIdDetector is a mutable indirection over detectOsMachineId so
|
||||
* tests can stub OS machine-id lookup without depending on the actual
|
||||
* host's state. It's a settable-property holder rather than a plain
|
||||
* exported `let` because ES module namespace bindings can't be reassigned
|
||||
* from outside the module.
|
||||
*/
|
||||
export const machineIdDetector: { current: () => string | undefined } = {
|
||||
current: detectOsMachineId,
|
||||
};
|
||||
|
||||
/** defaultInstallIdPath returns the fallback-UUID persistence path used when no explicit path is given. */
|
||||
export function defaultInstallIdPath(): string {
|
||||
const base = process.env.XDG_CONFIG_HOME || join(homedir() || tmpdir(), ".config");
|
||||
return join(base, "origin-client", "install-id");
|
||||
}
|
||||
|
||||
/**
|
||||
* resolveMachineId returns the unique install ID to report to Origin, in
|
||||
* priority order:
|
||||
*
|
||||
* 1. explicit, if non-empty - a caller-supplied override.
|
||||
* 2. the real OS machine ID (/etc/machine-id on Linux, IOPlatformUUID on
|
||||
* macOS, MachineGuid on Windows), if readable.
|
||||
* 3. the ID already persisted at path, if present.
|
||||
* 4. a newly generated UUID, which resolveMachineId attempts to persist
|
||||
* to path for future runs.
|
||||
*
|
||||
* OS-ID and persistence failures are logged as warnings, not thrown:
|
||||
* resolveMachineId always returns a usable ID.
|
||||
*/
|
||||
export function resolveMachineId(explicit?: string, path?: string, logger: Logger = noopLogger): string {
|
||||
const trimmedExplicit = explicit?.trim();
|
||||
if (trimmedExplicit) return trimmedExplicit;
|
||||
|
||||
const osId = machineIdDetector.current();
|
||||
if (osId) {
|
||||
const trimmed = osId.trim();
|
||||
if (trimmed) return trimmed;
|
||||
} else {
|
||||
logger.warn("machine id: OS machine id unavailable, falling back to a generated id");
|
||||
}
|
||||
|
||||
const resolvedPath = path && path.length > 0 ? path : defaultInstallIdPath();
|
||||
|
||||
try {
|
||||
const existing = readFileSync(resolvedPath, "utf8").trim();
|
||||
if (existing) return existing;
|
||||
} catch {
|
||||
// no persisted id yet
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
try {
|
||||
mkdirSync(dirname(resolvedPath), { recursive: true, mode: 0o750 });
|
||||
writeFileSync(resolvedPath, id + "\n", { mode: 0o600 });
|
||||
} catch (err) {
|
||||
logger.warn("machine id: failed to persist generated id, using an ephemeral id for this run", err);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Payload is the JSON body posted to Origin's public service-checkin
|
||||
* endpoint. Field names and requiredness mirror
|
||||
* POST /api/public/service-checkin as documented in
|
||||
* origin/doc/service-checkin.md: type, version, name, and
|
||||
* unique_install_id are required; everything else is optional and, when
|
||||
* omitted, does not overwrite existing instance data on Origin's side.
|
||||
*/
|
||||
export interface Payload {
|
||||
type: string;
|
||||
version: string;
|
||||
name: string;
|
||||
unique_install_id: string;
|
||||
site?: string;
|
||||
environment?: string;
|
||||
database_type?: string;
|
||||
database_name?: string;
|
||||
app_type?: string;
|
||||
hostname?: string;
|
||||
port?: number;
|
||||
base_url?: string;
|
||||
internal_url?: string;
|
||||
container_id?: string;
|
||||
orchestrator?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** CheckinResponse is returned by Origin after a successful check-in. */
|
||||
export interface CheckinResponse {
|
||||
success: boolean;
|
||||
service_instance_id: number;
|
||||
unique_install_id: string;
|
||||
status: string;
|
||||
pinged_at: string;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { OriginClient } from "../src/client.js";
|
||||
|
||||
function withServer(
|
||||
handler: (req: IncomingMessage, res: ServerResponse) => void,
|
||||
): Promise<{ server: Server; url: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const server = createServer(handler);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
const port = typeof address === "object" && address ? address.port : 0;
|
||||
resolve({ server, url: `http://127.0.0.1:${port}` });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("OriginClient", () => {
|
||||
it("requires serviceKey/type/version/name", () => {
|
||||
expect(() => new OriginClient({ serviceKey: "", type: "", version: "", name: "" })).toThrow();
|
||||
});
|
||||
|
||||
it("sends the expected payload and headers on checkinOnce", async () => {
|
||||
let gotKey = "";
|
||||
let gotBody: Record<string, unknown> = {};
|
||||
const { server, url } = await withServer((req, res) => {
|
||||
gotKey = req.headers["x-origin-service-key"] as string;
|
||||
let raw = "";
|
||||
req.on("data", (chunk) => {
|
||||
raw += chunk;
|
||||
});
|
||||
req.on("end", () => {
|
||||
gotBody = JSON.parse(raw);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
service_instance_id: 42,
|
||||
unique_install_id: gotBody.unique_install_id,
|
||||
status: "provisioning",
|
||||
pinged_at: "2026-08-01T00:00:00Z",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
const client = new OriginClient({
|
||||
url,
|
||||
serviceKey: "test-key",
|
||||
type: "icy2",
|
||||
version: "v1.0.0",
|
||||
name: "icy2-test",
|
||||
installId: "test-install-id",
|
||||
});
|
||||
|
||||
const resp = await client.checkinOnce();
|
||||
|
||||
expect(gotKey).toBe("test-key");
|
||||
expect(gotBody.type).toBe("icy2");
|
||||
expect(gotBody.name).toBe("icy2-test");
|
||||
expect(gotBody.unique_install_id).toBe("test-install-id");
|
||||
expect(resp.success).toBe(true);
|
||||
expect(resp.service_instance_id).toBe(42);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("throws on a non-2xx response", async () => {
|
||||
const { server, url } = await withServer((_req, res) => {
|
||||
res.writeHead(500);
|
||||
res.end();
|
||||
});
|
||||
|
||||
try {
|
||||
const client = new OriginClient({
|
||||
url,
|
||||
serviceKey: "test-key",
|
||||
type: "icy2",
|
||||
version: "v1.0.0",
|
||||
name: "icy2-test",
|
||||
installId: "test-install-id",
|
||||
});
|
||||
|
||||
await expect(client.checkinOnce()).rejects.toThrow();
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not throw for an unreachable server", async () => {
|
||||
const client = new OriginClient({
|
||||
url: "http://127.0.0.1:1",
|
||||
serviceKey: "test-key",
|
||||
type: "icy2",
|
||||
version: "v1.0.0",
|
||||
name: "icy2-test",
|
||||
installId: "test-install-id",
|
||||
});
|
||||
|
||||
await expect(client.checkinOnce()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("start() checks in immediately and stop() prevents further calls", async () => {
|
||||
let calls = 0;
|
||||
const { server, url } = await withServer((_req, res) => {
|
||||
calls += 1;
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
service_instance_id: 1,
|
||||
unique_install_id: "x",
|
||||
status: "provisioning",
|
||||
pinged_at: "2026-08-01T00:00:00Z",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
const client = new OriginClient({
|
||||
url,
|
||||
serviceKey: "test-key",
|
||||
type: "icy2",
|
||||
version: "v1.0.0",
|
||||
name: "icy2-test",
|
||||
installId: "test-install-id",
|
||||
intervalHours: 24,
|
||||
});
|
||||
|
||||
client.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
client.stop();
|
||||
|
||||
expect(calls).toBe(1);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hostname, isContainerIdLike, parseCgroupContainerId } from "../src/hostInfo.js";
|
||||
|
||||
describe("parseCgroupContainerId", () => {
|
||||
it("parses a cgroup v1 docker path", () => {
|
||||
const data = "12:memory:/docker/9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b\n";
|
||||
expect(parseCgroupContainerId(data)).toBe("9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b");
|
||||
});
|
||||
|
||||
it("parses a cgroup v2 systemd scope", () => {
|
||||
const data = "0::/system.slice/docker-9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b.scope\n";
|
||||
expect(parseCgroupContainerId(data)).toBe("9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b");
|
||||
});
|
||||
|
||||
it("returns empty on a non-container host", () => {
|
||||
expect(parseCgroupContainerId("0::/user.slice/user-1000.slice/session-2.scope\n")).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty for empty input", () => {
|
||||
expect(parseCgroupContainerId("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isContainerIdLike", () => {
|
||||
it("accepts a hex id of valid length", () => {
|
||||
expect(isContainerIdLike("9f8b7c6d5e4f")).toBe(true);
|
||||
});
|
||||
it("rejects short strings", () => {
|
||||
expect(isContainerIdLike("short")).toBe(false);
|
||||
});
|
||||
it("rejects non-hex strings", () => {
|
||||
expect(isContainerIdLike("session-2")).toBe(false);
|
||||
});
|
||||
it("rejects empty strings", () => {
|
||||
expect(isContainerIdLike("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hostname", () => {
|
||||
it("returns a non-empty value", () => {
|
||||
expect(hostname().length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import * as machineId from "../src/machineId.js";
|
||||
|
||||
function tempInstallIdPath(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "origin-client-test-"));
|
||||
return join(dir, "install-id");
|
||||
}
|
||||
|
||||
describe("resolveMachineId", () => {
|
||||
afterEach(() => {
|
||||
machineId.machineIdDetector.current = machineId.detectOsMachineId;
|
||||
});
|
||||
|
||||
it("prefers an explicit override", () => {
|
||||
machineId.machineIdDetector.current = () => "os-machine-id";
|
||||
expect(machineId.resolveMachineId("explicit-id", tempInstallIdPath())).toBe("explicit-id");
|
||||
});
|
||||
|
||||
it("uses the OS machine id when available", () => {
|
||||
machineId.machineIdDetector.current = () => "os-machine-id";
|
||||
expect(machineId.resolveMachineId(undefined, tempInstallIdPath())).toBe("os-machine-id");
|
||||
});
|
||||
|
||||
it("falls back to a generated id and persists it across calls", () => {
|
||||
machineId.machineIdDetector.current = () => undefined;
|
||||
const path = tempInstallIdPath();
|
||||
|
||||
const first = machineId.resolveMachineId(undefined, path);
|
||||
expect(first.length).toBeGreaterThan(0);
|
||||
|
||||
const second = machineId.resolveMachineId(undefined, path);
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it("logs a warning when the OS id is unavailable", () => {
|
||||
machineId.machineIdDetector.current = () => undefined;
|
||||
const warn = vi.fn();
|
||||
machineId.resolveMachineId(undefined, tempInstallIdPath(), { warn });
|
||||
expect(warn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user