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 { createCurrentAlertRepository, 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 { 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, context: StepExecutionContext, ): Promise { 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 = createCurrentAlertRepository(); 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, context: StepExecutionContext, ): Promise { 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, context: StepExecutionContext, ): Promise { 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, context: StepExecutionContext, ): Promise { 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, context: StepExecutionContext, ): Promise { 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, context: StepExecutionContext, ): Promise { 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, context: StepExecutionContext, ): Promise { 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, context: StepExecutionContext, ): Promise { 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, context: StepExecutionContext, ): Promise { 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["hostSelector"], context: StepExecutionContext, run: (target: ResolvedTarget) => Promise<{ output?: string; error?: string }>, ): Promise { 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) }; } }