Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ on:
push:
branches: [main]

permissions:
contents: read

jobs:
gates:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -56,3 +59,12 @@ jobs:
- name: Production quality gate
working-directory: ./frontend
run: npm run check:quality

release:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: [gates, controller, frontend]
permissions:
contents: write
issues: write
pull-requests: write
uses: ./.github/workflows/release.yml
32 changes: 20 additions & 12 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,39 +1,47 @@
name: Release

on:
workflow_run:
workflows: [CI]
types: [completed]
branches: [main]
workflow_call:

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
queue: max

permissions:
contents: write
issues: write
pull-requests: write
contents: read

jobs:
release:
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push'
runs-on: ubuntu-latest
permissions:
contents: write
issues: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event.workflow_run.head_sha }}

- name: Select tested main revision
run: git checkout -B main "${{ github.event.workflow_run.head_sha }}"
ref: ${{ github.sha }}

- uses: actions/setup-node@v4
with:
node-version: 22.19.0

- name: Verify tested main revision
id: revision
env:
TESTED_SHA: ${{ github.sha }}
run: node scripts/release-revision.mjs

- name: Configure git author
if: steps.revision.outputs.current == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"

- name: Release
if: steps.revision.outputs.current == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
Expand Down
2 changes: 1 addition & 1 deletion controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write \"src/**/*.ts\"",
"typecheck": "tsc --noEmit",
"typecheck": "tsc --noEmit && tsc --noEmit -p tests/tsconfig.json",
"standards": "bun scripts/controller-standards-audit.ts",
"check": "knip && jscpd src && depcheck --skip-missing && bun run standards",
"check:fix": "knip --fix"
Expand Down
90 changes: 89 additions & 1 deletion controller/src/core/command.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import { readFileSync, realpathSync } from "node:fs";
import { delimiter, join, resolve } from "node:path";
import type { Readable } from "node:stream";
import { Effect } from "effect";
Expand All @@ -11,34 +12,97 @@ export type CommandResult = {

export type RunSyncOptions = {
timeoutMs?: number | undefined;
env?: NodeJS.ProcessEnv | undefined;
};

export type SpawnDetachedOptions = {
env?: NodeJS.ProcessEnv | undefined;
stdio: "pipe" | "ignore";
};

export type ProcessEnvironmentValue =
| { readonly status: "found"; readonly value: string }
| { readonly status: "missing" }
| { readonly status: "unavailable" };

export interface SpawnedProcess {
readonly pid?: number | undefined;
readonly exitCode: number | null;
readonly stdout: Readable | null;
readonly stderr: Readable | null;
on(event: "error", listener: (error: Error) => void): void;
on(event: "exit", listener: () => void): void;
removeListener(event: "error", listener: (error: Error) => void): void;
removeListener(event: "exit", listener: () => void): void;
unref(): void;
}

export interface ProcessRunner {
readProcessEnvironmentVariable(pid: number, key: string): ProcessEnvironmentValue;
runSync(command: string, args: string[], options?: RunSyncOptions): CommandResult;
signalProcessGroup(processGroupId: number, signal: NodeJS.Signals): boolean;
spawnDetached(command: string, args: string[], options: SpawnDetachedOptions): SpawnedProcess;
}

const readLinuxProcessEnvironmentVariable = (pid: number, key: string): ProcessEnvironmentValue => {
try {
const prefix = `${key}=`;
const entry = readFileSync(`/proc/${pid}/environ`, "utf8")
.split("\0")
.find((candidate) => candidate.startsWith(prefix));
return entry ? { status: "found", value: entry.slice(prefix.length) } : { status: "missing" };
} catch {
return { status: "unavailable" };
}
};

const readDarwinProcessEnvironmentVariable = (
pid: number,
key: string,
): ProcessEnvironmentValue => {
try {
const command = spawnSync("ps", ["ww", "-p", String(pid), "-o", "command="], {
encoding: "utf8",
});
const extended = spawnSync("ps", ["eww", "-p", String(pid), "-o", "command="], {
encoding: "utf8",
});
if (command.status !== 0 || extended.status !== 0 || !command.stdout || !extended.stdout) {
return { status: "unavailable" };
}
const prefix = `${key}=`;
const normalizedTokens = (value: string): string[] =>
value.split(/\s+/).map((token) => token.replace(/^["']|["']$/g, ""));
if (normalizedTokens(command.stdout).some((token) => token.startsWith(prefix))) {
return { status: "unavailable" };
}
const values = [
...new Set(
normalizedTokens(extended.stdout)
.filter((token) => token.startsWith(prefix))
.map((token) => token.slice(prefix.length)),
),
];
if (values.length === 0) return { status: "missing" };
return values.length === 1 && values[0]
? { status: "found", value: values[0] }
: { status: "unavailable" };
} catch {
return { status: "unavailable" };
}
};

export const realProcessRunner: ProcessRunner = {
readProcessEnvironmentVariable: (pid, key) => {
if (process.platform === "linux") return readLinuxProcessEnvironmentVariable(pid, key);
if (process.platform === "darwin") return readDarwinProcessEnvironmentVariable(pid, key);
return { status: "unavailable" };
},
runSync: (command, args, options = {}) => {
try {
const result = spawnSync(command, args, {
...(options.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),
env: process.env,
env: options.env ?? process.env,
});
return {
status: result.status,
Expand All @@ -53,6 +117,14 @@ export const realProcessRunner: ProcessRunner = {
};
}
},
signalProcessGroup: (processGroupId, signal) => {
try {
process.kill(-processGroupId, signal);
return true;
} catch {
return false;
}
},
spawnDetached: (command, args, options) =>
spawn(command, args, {
stdio: options.stdio === "pipe" ? ["ignore", "pipe", "pipe"] : "ignore",
Expand Down Expand Up @@ -248,3 +320,19 @@ export const resolveBinary = (binaryName: string): string | null => {
if (isExplicitPath(binaryName)) return Bun.which(resolve(binaryName));
return Bun.which(binaryName, { PATH: binarySearchPath() });
};

export const resolveBinaryFromEnvironment = (
binaryName: string,
environment: NodeJS.ProcessEnv,
): string | null => {
if (!binaryName) return null;
const resolvedBinary = isExplicitPath(binaryName)
? Bun.which(resolve(binaryName))
: Bun.which(binaryName, { PATH: environment["PATH"] ?? "" });
if (!resolvedBinary) return null;
try {
return realpathSync(resolvedBinary);
} catch {
return null;
}
};
74 changes: 58 additions & 16 deletions controller/src/modules/engines/process/process-inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,44 +3,86 @@ import { realProcessRunner, type ProcessRunner } from "../../../core/command";
export type ProcessInventoryEntry = {
pid: number;
ppid: number;
pgid: number;
processGroupId: number;
startIdentity: string;
stat: string;
command: string;
args: string[];
};

export type ProcessInventoryResult =
| { status: "available"; entries: ProcessInventoryEntry[] }
| { status: "unavailable"; entries: ProcessInventoryEntry[] };

const processInventoryEnvironment = (): NodeJS.ProcessEnv => ({
...process.env,
LC_ALL: "C",
LANG: "C",
LANGUAGE: "C",
});

export const normalizeProcessStartIdentity = (value: string): string | null => {
const startedAtMs = Date.parse(value.replace(/\s+/g, " ").trim());
return Number.isSafeInteger(startedAtMs) && startedAtMs > 0 ? String(startedAtMs) : null;
};

export const splitCommand = (command: string): string[] => {
const matches = command.match(/(?:[^\s"]+|"[^"]*")+/g) ?? [];
return matches.map((token) => token.replace(/^"|"$/g, ""));
};

const parseInventoryLine = (line: string): ProcessInventoryEntry | null => {
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/);
export const parseInventoryLine = (line: string): ProcessInventoryEntry | null => {
const match = line
.trim()
.match(
/^(\d+)\s+(\d+)\s+(\d+)\s+(\S+\s+\S+\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(\S+)(?:\s+(.*))?$/,
);
if (!match) return null;
const command = match[5] ?? "";
const startIdentity = normalizeProcessStartIdentity(match[4] ?? "");
if (!startIdentity) return null;
const command = match[6] ?? "";
return {
pid: Number(match[1]),
ppid: Number(match[2]),
pgid: Number(match[3]),
stat: match[4] ?? "",
processGroupId: Number(match[3]),
startIdentity,
stat: match[5] ?? "",
command,
args: splitCommand(command),
};
};

export const listProcessInventory = (
export const readProcessInventory = (
runner: ProcessRunner = realProcessRunner,
): ProcessInventoryEntry[] => {
): ProcessInventoryResult => {
try {
const result = runner.runSync("ps", ["-eo", "pid=,ppid=,pgid=,stat=,args="]);
if (result.status !== 0) return [];
const result = runner.runSync(
"ps",
["-eo", "pid=,ppid=,pgid=,lstart=,stat=,args="],
{ env: processInventoryEnvironment() },
);
if (result.status !== 0) return { status: "unavailable", entries: [] };
const output = result.stdout.trim();
if (!output) return [];
return output
.split("\n")
.flatMap((line) => parseInventoryLine(line) ?? [])
.filter((entry) => entry.pid > 0);
if (!output) return { status: "available", entries: [] };
const parsed = output.split("\n").map(parseInventoryLine);
if (parsed.some((entry) => entry === null)) {
return {
status: "unavailable",
entries: parsed.flatMap((entry) => entry ?? []).filter((entry) => entry.pid > 0),
};
}
return {
status: "available",
entries: parsed.flatMap((entry) => entry ?? []).filter((entry) => entry.pid > 0),
};
} catch {
return [];
return { status: "unavailable", entries: [] };
}
};

export const listProcessInventory = (
runner: ProcessRunner = realProcessRunner,
): ProcessInventoryEntry[] => {
const result = readProcessInventory(runner);
return result.entries;
};
Loading