mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +00:00
feat: make each plugin an npm workspace built by a termix-plugin CLI
This commit is contained in:
1 parent
4758016f41
commit
5796186a1c
448 files changed
+3087
-2003
No files matched your search
@@ -0,0 +1,90 @@
|
||||
import type { HostSelector } from "../../../../../src/types/automations.js";
|
||||
import { resolveHostById } from "../../../../../src/backend/hosts/host-resolver.js";
|
||||
import { createCurrentFleetRepository } from "../../../../../src/backend/database/repositories/factory.js";
|
||||
import { PermissionManager } from "../../../../../src/backend/utils/permission-manager.js";
|
||||
import type { StepExecutionContext } from "./types.js";
|
||||
|
||||
/** A host the caller is allowed to act on. `host` is always resolved. */
|
||||
export interface ResolvedTarget {
|
||||
id: number;
|
||||
name: string;
|
||||
host: NonNullable<Awaited<ReturnType<typeof resolveHostById>>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a selector into the hosts a step may actually act on.
|
||||
*
|
||||
* Access is checked here, at execution time rather than when the automation
|
||||
* was saved, so a permission revoked after the fact takes effect on the next
|
||||
* run. resolveHostById performs its own connect-level check and returns null
|
||||
* when the owner can no longer reach the host.
|
||||
*/
|
||||
export async function resolveTargets(
|
||||
selector: HostSelector,
|
||||
context: StepExecutionContext,
|
||||
): Promise<{ targets: ResolvedTarget[]; skipped: number[] }> {
|
||||
const ids = await selectorHostIds(selector, context);
|
||||
const targets: ResolvedTarget[] = [];
|
||||
const skipped: number[] = [];
|
||||
|
||||
for (const id of ids) {
|
||||
const host = await resolveHostById(id, context.userId);
|
||||
if (!host) {
|
||||
skipped.push(id);
|
||||
continue;
|
||||
}
|
||||
targets.push({ id, name: host.name || host.ip, host });
|
||||
}
|
||||
|
||||
return { targets, skipped };
|
||||
}
|
||||
|
||||
async function selectorHostIds(
|
||||
selector: HostSelector,
|
||||
context: StepExecutionContext,
|
||||
): Promise<number[]> {
|
||||
switch (selector.kind) {
|
||||
case "host":
|
||||
return [selector.hostId];
|
||||
case "hosts":
|
||||
return selector.hostIds;
|
||||
case "trigger":
|
||||
return context.triggerHostId ? [context.triggerHostId] : [];
|
||||
case "fleet":
|
||||
return fleetHostIds(selector.fleetId, context.userId);
|
||||
case "all":
|
||||
return allAccessibleHostIds(context.userId);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fleetHostIds(
|
||||
fleetId: number,
|
||||
userId: string,
|
||||
): Promise<number[]> {
|
||||
try {
|
||||
const repository = createCurrentFleetRepository();
|
||||
const members = await repository.listEffectiveMembers(userId, fleetId);
|
||||
return members.map((member) => member.id);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function allAccessibleHostIds(userId: string): Promise<number[]> {
|
||||
try {
|
||||
const { createCurrentHostRepository } =
|
||||
await import("../../../../../src/backend/database/repositories/factory.js");
|
||||
const hosts = await createCurrentHostRepository().listByUserId(userId);
|
||||
const ids = hosts.map((host) => host.id);
|
||||
const allowed =
|
||||
await PermissionManager.getInstance().filterAccessibleHostIds(
|
||||
userId,
|
||||
ids,
|
||||
);
|
||||
return ids.filter((id) => allowed.has(id));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import {
|
||||
DEFAULT_STEP_TIMEOUT_MS,
|
||||
type Step,
|
||||
} from "../../../../../src/types/automations.js";
|
||||
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import {
|
||||
execElevated,
|
||||
shellSingleQuote,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||
import {
|
||||
createFleetSshFactory,
|
||||
getFleetPoolKey,
|
||||
} from "../../../../../src/backend/hosts/ssh-client-factory.js";
|
||||
import { withConnection } from "../../../../../src/backend/hosts/ssh-connection-pool.js";
|
||||
import { resolveSnippetCommand } from "../../../../../src/backend/database/routes/snippets-execution.js";
|
||||
import {
|
||||
createCurrentNotificationChannelRepository,
|
||||
createCurrentSnippetRepository,
|
||||
} from "../../../../../src/backend/database/repositories/factory.js";
|
||||
import { sendAutomationNotification } from "../notify.js";
|
||||
import { automationFetch } from "../http.js";
|
||||
import { renderRecord, renderTemplate } from "../template.js";
|
||||
import { resolveTargets, type ResolvedTarget } from "./host-targets.js";
|
||||
import {
|
||||
fail,
|
||||
ok,
|
||||
stepTimeout,
|
||||
type StepExecutionContext,
|
||||
type StepResult,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* One executor per step type.
|
||||
*
|
||||
* Anything that leaves Termix checks context.dryRun first and reports what it
|
||||
* would have done instead of doing it, so an automation can be exercised
|
||||
* safely while it is being built.
|
||||
*/
|
||||
export async function executeStep(
|
||||
step: Step,
|
||||
context: StepExecutionContext,
|
||||
): Promise<StepResult> {
|
||||
switch (step.type) {
|
||||
case "notify":
|
||||
return runNotify(step, context);
|
||||
case "http":
|
||||
return runHttp(step, context);
|
||||
case "run_command":
|
||||
return runCommand(step, context);
|
||||
case "run_snippet":
|
||||
return runSnippet(step, context);
|
||||
case "docker":
|
||||
return runDocker(step, context);
|
||||
case "tunnel":
|
||||
return runTunnel(step, context);
|
||||
case "wol":
|
||||
return runWol(step, context);
|
||||
case "wait":
|
||||
return runWait(step, context);
|
||||
case "set_var":
|
||||
return runSetVar(step, context);
|
||||
case "stop":
|
||||
return {
|
||||
success: true,
|
||||
halt: { status: step.status ?? "success" },
|
||||
output: `Stopped with status ${step.status ?? "success"}`,
|
||||
};
|
||||
default:
|
||||
return fail(`Unsupported step type: ${(step as Step).type}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runNotify(
|
||||
step: Extract<Step, { type: "notify" }>,
|
||||
context: StepExecutionContext,
|
||||
): Promise<StepResult> {
|
||||
const title = renderTemplate(step.title ?? "", context.template);
|
||||
const body = renderTemplate(step.body ?? "", context.template);
|
||||
|
||||
if (step.channelIds.length === 0) {
|
||||
return fail("No notification channels selected");
|
||||
}
|
||||
if (context.dryRun) {
|
||||
return ok(
|
||||
`Would notify ${step.channelIds.length} channel(s): ${title || body}`,
|
||||
);
|
||||
}
|
||||
|
||||
const repository = createCurrentNotificationChannelRepository();
|
||||
const channels = await repository.listNotificationChannels(context.userId);
|
||||
const selected = channels.filter((channel) =>
|
||||
step.channelIds.includes(channel.id),
|
||||
);
|
||||
|
||||
if (selected.length === 0) {
|
||||
return fail("Selected notification channels no longer exist");
|
||||
}
|
||||
|
||||
let delivered = 0;
|
||||
const errors: string[] = [];
|
||||
for (const channel of selected) {
|
||||
if (!channel.enabled) continue;
|
||||
try {
|
||||
await sendAutomationNotification(
|
||||
{ id: channel.id, type: channel.type, config: channel.config },
|
||||
{
|
||||
title,
|
||||
body,
|
||||
severity: step.severity ?? "warning",
|
||||
context: context.template,
|
||||
},
|
||||
);
|
||||
delivered++;
|
||||
} catch (error) {
|
||||
errors.push(
|
||||
`${channel.name}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (delivered === 0) {
|
||||
return fail(errors.join("; ") || "No enabled channels to notify");
|
||||
}
|
||||
return ok(
|
||||
`Notified ${delivered} channel(s)${errors.length ? `; ${errors.join("; ")}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function runHttp(
|
||||
step: Extract<Step, { type: "http" }>,
|
||||
context: StepExecutionContext,
|
||||
): Promise<StepResult> {
|
||||
const url = renderTemplate(step.url, context.template);
|
||||
const headers = renderRecord(step.headers, context.template);
|
||||
const body = step.body
|
||||
? renderTemplate(step.body, context.template)
|
||||
: undefined;
|
||||
|
||||
if (context.dryRun) {
|
||||
return ok(`Would ${step.method} ${url}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await automationFetch(url, {
|
||||
method: step.method,
|
||||
headers,
|
||||
body,
|
||||
allowPrivateNetwork: step.allowPrivateNetwork,
|
||||
timeoutMs: stepTimeout(context, step.timeoutMs, DEFAULT_STEP_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
const summary = `HTTP ${response.status} ${response.statusText}\n${text}`;
|
||||
return response.ok ? ok(summary) : fail(`HTTP ${response.status}`, summary);
|
||||
} catch (error) {
|
||||
return fail(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function runCommand(
|
||||
step: Extract<Step, { type: "run_command" }>,
|
||||
context: StepExecutionContext,
|
||||
): Promise<StepResult> {
|
||||
const command = renderTemplate(step.command, context.template);
|
||||
return runOnTargets(step.hostSelector, context, async (target) => {
|
||||
if (context.dryRun) {
|
||||
return { output: `Would run on ${target.name}: ${command}` };
|
||||
}
|
||||
return execOnHost(
|
||||
target.host,
|
||||
command,
|
||||
step.elevated,
|
||||
context,
|
||||
step.timeoutMs,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function runSnippet(
|
||||
step: Extract<Step, { type: "run_snippet" }>,
|
||||
context: StepExecutionContext,
|
||||
): Promise<StepResult> {
|
||||
const snippet = await createCurrentSnippetRepository()
|
||||
.findOwnedById(context.userId, step.snippetId)
|
||||
.catch(() => null);
|
||||
|
||||
if (!snippet) return fail("Snippet not found");
|
||||
if (snippet.isNote) return fail("Notes cannot be executed on a host");
|
||||
|
||||
// Template values only ever reach the snippet through inputValues, never by
|
||||
// rewriting the snippet body, so automation variables cannot inject snippet
|
||||
// syntax of their own.
|
||||
const inputValues = renderRecord(step.inputValues, context.template) ?? {};
|
||||
|
||||
return runOnTargets(step.hostSelector, context, async (target) => {
|
||||
const command = resolveSnippetCommand(
|
||||
snippet.content,
|
||||
{
|
||||
ip: target.host.ip,
|
||||
username: target.host.username,
|
||||
port: target.host.port,
|
||||
name: target.host.name,
|
||||
},
|
||||
inputValues,
|
||||
);
|
||||
|
||||
if (context.dryRun) {
|
||||
return { output: `Would run snippet on ${target.name}: ${command}` };
|
||||
}
|
||||
return execOnHost(
|
||||
target.host,
|
||||
command,
|
||||
step.elevated,
|
||||
context,
|
||||
step.timeoutMs,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function runDocker(
|
||||
step: Extract<Step, { type: "docker" }>,
|
||||
context: StepExecutionContext,
|
||||
): Promise<StepResult> {
|
||||
const container = renderTemplate(step.container, context.template);
|
||||
if (!container) return fail("Container name is required");
|
||||
|
||||
return runOnTargets(step.hostSelector, context, async (target) => {
|
||||
if (context.dryRun) {
|
||||
return {
|
||||
output: `Would ${step.action} container ${container} on ${target.name}`,
|
||||
};
|
||||
}
|
||||
|
||||
// The Docker HTTP routes are tied to an interactive session, so run the
|
||||
// equivalent command over the same pooled SSH connection everything else
|
||||
// uses. The container name is quoted because it comes from a template.
|
||||
const command = `docker ${step.action} ${shellSingleQuote(container)}`;
|
||||
return execOnHost(target.host, command, false, context, step.timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
async function runTunnel(
|
||||
step: Extract<Step, { type: "tunnel" }>,
|
||||
context: StepExecutionContext,
|
||||
): Promise<StepResult> {
|
||||
const name = renderTemplate(step.tunnelName, context.template);
|
||||
if (context.dryRun) return ok(`Would ${step.action} tunnel ${name}`);
|
||||
|
||||
try {
|
||||
const manager =
|
||||
await import("../../../../../src/backend/hosts/tunnel/manager.js");
|
||||
const config = manager.tunnelConfigs?.get(name);
|
||||
if (!config) return fail(`Tunnel "${name}" is not configured`);
|
||||
|
||||
if (step.action === "connect") {
|
||||
await manager.connectSSHTunnel(config);
|
||||
return ok(`Tunnel ${name} connected`);
|
||||
}
|
||||
|
||||
// shouldRetry false, otherwise the manager immediately reconnects the
|
||||
// tunnel the automation just asked it to drop.
|
||||
manager.manualDisconnects.add(name);
|
||||
await manager.handleDisconnect(name, config, false);
|
||||
return ok(`Tunnel ${name} disconnected`);
|
||||
} catch (error) {
|
||||
return fail(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function runWol(
|
||||
step: Extract<Step, { type: "wol" }>,
|
||||
context: StepExecutionContext,
|
||||
): Promise<StepResult> {
|
||||
const host = await resolveTargets(
|
||||
{ kind: "host", hostId: step.hostId },
|
||||
context,
|
||||
);
|
||||
const target = host.targets[0];
|
||||
if (!target?.host) return fail("Host not found or not accessible");
|
||||
|
||||
const mac = (target.host as { macAddress?: string }).macAddress;
|
||||
if (!mac) return fail("Host has no MAC address configured");
|
||||
if (context.dryRun) return ok(`Would wake ${target.name} (${mac})`);
|
||||
|
||||
try {
|
||||
const { sendWakeOnLan, isValidMac } =
|
||||
await import("../../../../../src/backend/utils/wake-on-lan.js");
|
||||
if (!isValidMac(mac)) return fail(`Invalid MAC address: ${mac}`);
|
||||
await sendWakeOnLan(mac);
|
||||
return ok(`Sent magic packet to ${target.name}`);
|
||||
} catch (error) {
|
||||
return fail(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function runWait(
|
||||
step: Extract<Step, { type: "wait" }>,
|
||||
context: StepExecutionContext,
|
||||
): Promise<StepResult> {
|
||||
const requested = Math.max(step.seconds, 0) * 1000;
|
||||
const waitMs = Math.min(
|
||||
requested,
|
||||
stepTimeout(context, undefined, requested),
|
||||
);
|
||||
if (context.dryRun) return ok(`Would wait ${step.seconds}s`);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||
return ok(`Waited ${Math.round(waitMs / 1000)}s`);
|
||||
}
|
||||
|
||||
async function runSetVar(
|
||||
step: Extract<Step, { type: "set_var" }>,
|
||||
context: StepExecutionContext,
|
||||
): Promise<StepResult> {
|
||||
const value = renderTemplate(step.value, context.template);
|
||||
return ok(`${step.name} = ${value}`, { [step.name]: value });
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a per-host action across a selector's targets. One host failing does
|
||||
* not stop the others, matching how fleet execution behaves.
|
||||
*/
|
||||
async function runOnTargets(
|
||||
selector: Extract<Step, { type: "run_command" }>["hostSelector"],
|
||||
context: StepExecutionContext,
|
||||
run: (target: ResolvedTarget) => Promise<{ output?: string; error?: string }>,
|
||||
): Promise<StepResult> {
|
||||
const { targets, skipped } = await resolveTargets(selector, context);
|
||||
|
||||
if (targets.length === 0) {
|
||||
return fail(
|
||||
skipped.length > 0
|
||||
? `No accessible hosts (${skipped.length} skipped)`
|
||||
: "No hosts matched the selector",
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
targets.map(async (target) => ({
|
||||
target,
|
||||
result: await run(target),
|
||||
})),
|
||||
);
|
||||
|
||||
const lines: string[] = [];
|
||||
let failures = 0;
|
||||
|
||||
for (const [index, settled] of results.entries()) {
|
||||
const name = targets[index].name;
|
||||
if (settled.status === "rejected") {
|
||||
failures++;
|
||||
lines.push(`${name}: ${String(settled.reason)}`);
|
||||
continue;
|
||||
}
|
||||
const { result } = settled.value;
|
||||
if (result.error) {
|
||||
failures++;
|
||||
lines.push(`${name}: ${result.error}`);
|
||||
} else {
|
||||
lines.push(`${name}: ${result.output ?? "ok"}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (skipped.length > 0) {
|
||||
lines.push(`${skipped.length} host(s) skipped: no access`);
|
||||
}
|
||||
|
||||
const output = lines.join("\n");
|
||||
return failures > 0 && failures === targets.length
|
||||
? fail(`All ${failures} host(s) failed`, output)
|
||||
: ok(output);
|
||||
}
|
||||
|
||||
async function execOnHost(
|
||||
host: ResolvedTarget["host"],
|
||||
command: string,
|
||||
elevated: boolean | undefined,
|
||||
context: StepExecutionContext,
|
||||
timeoutMs: number | undefined,
|
||||
): Promise<{ output?: string; error?: string }> {
|
||||
const sshHost = host as ResolvedTarget["host"] & { sudoPassword?: string };
|
||||
const timeout = stepTimeout(context, timeoutMs, DEFAULT_STEP_TIMEOUT_MS);
|
||||
if (timeout <= 0) return { error: "Run deadline exceeded" };
|
||||
|
||||
try {
|
||||
const result = await withConnection(
|
||||
getFleetPoolKey(sshHost),
|
||||
createFleetSshFactory(sshHost),
|
||||
async (client) => {
|
||||
if (elevated) {
|
||||
return execElevated(client, command, sshHost.sudoPassword, {
|
||||
timeoutMs: timeout,
|
||||
});
|
||||
}
|
||||
return execCommand(client, command, timeout);
|
||||
},
|
||||
);
|
||||
|
||||
const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
|
||||
if (result.code === 0 || result.code === null) {
|
||||
return { output: output || "(no output)" };
|
||||
}
|
||||
return { error: `Exited with code ${result.code}`, output };
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Step } from "../../../../../src/types/automations.js";
|
||||
import type { TemplateContext } from "../template.js";
|
||||
|
||||
/** What every executor returns. `output` is what later steps can read. */
|
||||
export interface StepResult {
|
||||
success: boolean;
|
||||
output?: string;
|
||||
error?: string;
|
||||
/** Merged into the run's variables, for set_var and friends. */
|
||||
vars?: Record<string, string>;
|
||||
/** Set by the stop step to end the run early. */
|
||||
halt?: { status: "success" | "failed" };
|
||||
}
|
||||
|
||||
export interface StepExecutionContext {
|
||||
userId: string;
|
||||
automationId: number;
|
||||
runId: number;
|
||||
/** Nothing that leaves Termix may actually happen when this is set. */
|
||||
dryRun: boolean;
|
||||
template: TemplateContext;
|
||||
/** Host the trigger fired for, when there was one. */
|
||||
triggerHostId?: number;
|
||||
/** Automations already on the stack, to refuse recursion. */
|
||||
ancestry: number[];
|
||||
depth: number;
|
||||
/** Wall-clock deadline for the whole run. */
|
||||
deadlineAt: number;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export type StepExecutor<T extends Step = Step> = (
|
||||
step: T,
|
||||
context: StepExecutionContext,
|
||||
) => Promise<StepResult>;
|
||||
|
||||
export function ok(output?: string, vars?: Record<string, string>): StepResult {
|
||||
return { success: true, output, vars };
|
||||
}
|
||||
|
||||
export function fail(error: string, output?: string): StepResult {
|
||||
return { success: false, error, output };
|
||||
}
|
||||
|
||||
/** Milliseconds left before the run's overall deadline. */
|
||||
export function remainingMs(context: StepExecutionContext): number {
|
||||
return Math.max(context.deadlineAt - Date.now(), 0);
|
||||
}
|
||||
|
||||
/** A step's timeout, clamped so it can never outlive the run. */
|
||||
export function stepTimeout(
|
||||
context: StepExecutionContext,
|
||||
requested: number | undefined,
|
||||
fallback: number,
|
||||
): number {
|
||||
const wanted = requested && requested > 0 ? requested : fallback;
|
||||
return Math.max(Math.min(wanted, remainingMs(context)), 0);
|
||||
}
|
||||
Reference in new issue
Block a user