CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
server-targets.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 783dd46 | 1 | /** |
| 2 | * Server-target driver (Block ST). | |
| 3 | * | |
| 4 | * Drives the SSH/scp subprocess pipeline that takes a `server_targets` row | |
| 5 | * and either tests the connection or runs a deploy on the box. All process | |
| 6 | * spawning is funnelled through a single injectable seam (`__setSpawnForTests`) | |
| 7 | * so the test suite can drive the lib without actually shelling out. | |
| 8 | * | |
| 9 | * Design notes: | |
| 10 | * - We use the host's `ssh`/`scp` CLI rather than a Node SSH library. It | |
| 11 | * keeps the surface tiny (no new deps) and lets the operator manage | |
| 12 | * ciphers/algorithms via /etc/ssh/ssh_config like any other server tool. | |
| 13 | * - The private key is materialised into a Bun temp file with mode 0600, | |
| 14 | * used for the call, then unlinked in a `finally`. Never touches /tmp | |
| 15 | * persistently and never sits on disk longer than the call. | |
| 16 | * - Host-key verification uses TOFU: on first connect (status='unverified' | |
| 17 | * and no host_fingerprint) we accept-new and record the fingerprint. | |
| 18 | * Subsequent connects pin against that fingerprint. A mismatch aborts. | |
| 19 | * | |
| 20 | * Public surface: | |
| 21 | * - `testConnection(target)` → returns ok/fail + pinned fingerprint | |
| 22 | * - `deployToTarget(target, ctx)`→ uploads env, runs deploy_script | |
| 23 | * - `materializeEnv(env)` → render env-vars map to a .env body | |
| 24 | */ | |
| 25 | ||
| 26 | import { mkdtemp, writeFile, rm } from "fs/promises"; | |
| 27 | import { tmpdir } from "os"; | |
| 28 | import path from "path"; | |
| 29 | import { decryptValue, renderDotenv } from "./server-targets-crypto"; | |
| 30 | import type { ServerTarget } from "../db/schema"; | |
| 31 | ||
| 32 | export interface SpawnResult { | |
| 33 | exitCode: number; | |
| 34 | stdout: string; | |
| 35 | stderr: string; | |
| 36 | } | |
| 37 | ||
| 38 | /** | |
| 39 | * Subprocess seam. Production calls `Bun.spawn`; tests override. | |
| 40 | * | |
| 41 | * Returns the captured stdout/stderr + exit code. `stdin` is optional; when | |
| 42 | * provided, it's written and closed before the process is awaited so we can | |
| 43 | * pipe a .env body in without a temp file. | |
| 44 | */ | |
| 45 | export type SpawnFn = ( | |
| 46 | cmd: string[], | |
| 47 | opts: { cwd?: string; stdin?: string; env?: Record<string, string> } | |
| 48 | ) => Promise<SpawnResult>; | |
| 49 | ||
| 50 | const defaultSpawn: SpawnFn = async (cmd, opts) => { | |
| 51 | const proc = Bun.spawn(cmd, { | |
| 52 | cwd: opts.cwd, | |
| 53 | env: { ...process.env, ...(opts.env || {}) }, | |
| 54 | stdin: opts.stdin ? "pipe" : "ignore", | |
| 55 | stdout: "pipe", | |
| 56 | stderr: "pipe", | |
| 57 | }); | |
| 58 | if (opts.stdin) { | |
| 59 | proc.stdin?.write(opts.stdin); | |
| 60 | proc.stdin?.end(); | |
| 61 | } | |
| 62 | const [stdout, stderr] = await Promise.all([ | |
| 63 | new Response(proc.stdout).text(), | |
| 64 | new Response(proc.stderr).text(), | |
| 65 | ]); | |
| 66 | const exitCode = await proc.exited; | |
| 67 | return { exitCode, stdout, stderr }; | |
| 68 | }; | |
| 69 | ||
| 70 | let _spawn: SpawnFn = defaultSpawn; | |
| 71 | export function __setSpawnForTests(fn: SpawnFn | null): void { | |
| 72 | _spawn = fn ?? defaultSpawn; | |
| 73 | } | |
| 74 | ||
| 75 | /** | |
| 76 | * Run a fn with the decrypted SSH private key written to a 0600 file in a | |
| 77 | * private temp dir. The dir is rm -rf'd in `finally`, no matter what. | |
| 78 | */ | |
| 79 | async function withKeyFile<T>( | |
| 80 | encryptedKey: string, | |
| 81 | fn: (keyPath: string) => Promise<T> | |
| 82 | ): Promise<T> { | |
| 83 | const dec = decryptValue(encryptedKey); | |
| 84 | if (!dec.ok) { | |
| 85 | throw new Error(`decrypt private key: ${dec.error}`); | |
| 86 | } | |
| 87 | const dir = await mkdtemp(path.join(tmpdir(), "gluecron-st-")); | |
| 88 | const keyPath = path.join(dir, "id"); | |
| 89 | await writeFile(keyPath, dec.plaintext, { mode: 0o600 }); | |
| 90 | try { | |
| 91 | return await fn(keyPath); | |
| 92 | } finally { | |
| 93 | await rm(dir, { recursive: true, force: true }).catch(() => {}); | |
| 94 | } | |
| 95 | } | |
| 96 | ||
| 97 | /** | |
| 98 | * Build the common `ssh` arg list. On a target with no pinned fingerprint we | |
| 99 | * pass `accept-new`; once pinned we require strict host-key checking via the | |
| 100 | * known-hosts file we materialise alongside the key. | |
| 101 | * | |
| 102 | * Returns the args up to (but not including) the remote command — the | |
| 103 | * caller appends `[user@host, ...cmd]` or scp paths. | |
| 104 | */ | |
| 105 | function sshBaseArgs(target: ServerTarget, keyPath: string): string[] { | |
| 106 | const knownHostsArg = target.hostFingerprint | |
| 107 | ? // Pinned: hand ssh a known_hosts line via -o KnownHostsCommand-ish | |
| 108 | // bypass — easier route is a temp file passed via UserKnownHostsFile. | |
| 109 | // We can't easily inline a fingerprint without the full key, so we | |
| 110 | // fall back to strict checking against a per-target known_hosts file | |
| 111 | // written next to the key. With no host_fingerprint we accept-new. | |
| 112 | ["-o", "StrictHostKeyChecking=yes"] | |
| 113 | : ["-o", "StrictHostKeyChecking=accept-new"]; | |
| 114 | return [ | |
| 115 | "-i", | |
| 116 | keyPath, | |
| 117 | "-o", | |
| 118 | "BatchMode=yes", | |
| 119 | "-o", | |
| 120 | "ConnectTimeout=10", | |
| 121 | "-o", | |
| 122 | `UserKnownHostsFile=${path.join(path.dirname(keyPath), "known_hosts")}`, | |
| 123 | ...knownHostsArg, | |
| 124 | "-p", | |
| 125 | String(target.port), | |
| 126 | ]; | |
| 127 | } | |
| 128 | ||
| 129 | /** | |
| 130 | * Test connectivity. Returns the host fingerprint (SHA256 of the host key) | |
| 131 | * captured via `ssh-keyscan` so the caller can pin it on the row. | |
| 132 | * | |
| 133 | * Flow: | |
| 134 | * 1. ssh-keyscan -p <port> <host> → host key text | |
| 135 | * 2. ssh-keygen -lf <keyfile> → SHA256:<fp> fingerprint | |
| 136 | * 3. ssh -i <key> user@host 'echo gluecron' → confirms key works | |
| 137 | * | |
| 138 | * Any step's non-zero exit collapses to {ok:false}. Never throws. | |
| 139 | */ | |
| 140 | export async function testConnection( | |
| 141 | target: ServerTarget | |
| 142 | ): Promise< | |
| 143 | | { ok: true; fingerprint: string } | |
| 144 | | { ok: false; error: string; stage: "scan" | "fingerprint" | "auth" } | |
| 145 | > { | |
| 146 | try { | |
| 147 | return await withKeyFile(target.encryptedPrivateKey, async (keyPath) => { | |
| 148 | const dir = path.dirname(keyPath); | |
| 149 | const scan = await _spawn( | |
| 150 | ["ssh-keyscan", "-T", "5", "-p", String(target.port), target.host], | |
| 151 | {} | |
| 152 | ); | |
| 153 | if (scan.exitCode !== 0 || !scan.stdout.trim()) { | |
| 154 | return { | |
| 155 | ok: false as const, | |
| 156 | error: scan.stderr.trim() || "ssh-keyscan returned nothing", | |
| 157 | stage: "scan" as const, | |
| 158 | }; | |
| 159 | } | |
| 160 | const knownHostsPath = path.join(dir, "known_hosts"); | |
| 161 | await writeFile(knownHostsPath, scan.stdout); | |
| 162 | ||
| 163 | const fp = await _spawn(["ssh-keygen", "-lf", knownHostsPath], {}); | |
| 164 | if (fp.exitCode !== 0) { | |
| 165 | return { | |
| 166 | ok: false as const, | |
| 167 | error: fp.stderr.trim() || "ssh-keygen failed", | |
| 168 | stage: "fingerprint" as const, | |
| 169 | }; | |
| 170 | } | |
| 171 | // Output format: "<bits> SHA256:<base64> comment (TYPE)" | |
| 172 | const match = fp.stdout.match(/SHA256:[A-Za-z0-9+/=]+/); | |
| 173 | const fingerprint = match ? match[0] : fp.stdout.trim().split(/\s+/)[1] || ""; | |
| 174 | ||
| 175 | const probe = await _spawn( | |
| 176 | [ | |
| 177 | "ssh", | |
| 178 | ...sshBaseArgs(target, keyPath), | |
| 179 | `${target.sshUser}@${target.host}`, | |
| 180 | "echo gluecron-ok", | |
| 181 | ], | |
| 182 | {} | |
| 183 | ); | |
| 184 | if (probe.exitCode !== 0 || !probe.stdout.includes("gluecron-ok")) { | |
| 185 | return { | |
| 186 | ok: false as const, | |
| 187 | error: probe.stderr.trim() || "ssh probe failed", | |
| 188 | stage: "auth" as const, | |
| 189 | }; | |
| 190 | } | |
| 191 | return { ok: true as const, fingerprint }; | |
| 192 | }); | |
| 193 | } catch (err) { | |
| 194 | return { | |
| 195 | ok: false, | |
| 196 | error: err instanceof Error ? err.message : String(err), | |
| 197 | stage: "auth", | |
| 198 | }; | |
| 199 | } | |
| 200 | } | |
| 201 | ||
| 202 | export interface DeployContext { | |
| 203 | commitSha?: string; | |
| 204 | ref?: string; | |
| 205 | /** Decrypted env-var map (KEY → value). Render to .env before upload. */ | |
| 206 | env: Record<string, string>; | |
| 207 | } | |
| 208 | ||
| 209 | export interface DeployResult { | |
| 210 | ok: boolean; | |
| 211 | exitCode: number; | |
| 212 | stdout: string; | |
| 213 | stderr: string; | |
| 214 | } | |
| 215 | ||
| 216 | /** | |
| 217 | * Run the deploy on the target box. | |
| 218 | * | |
| 219 | * Sequence: | |
| 220 | * 1. Write env map → /tmp/gluecron-<rand>.env (locally), scp to | |
| 221 | * `<deploy_path>/.env.gluecron` on the box (0600 via umask). | |
| 222 | * 2. ssh user@host 'cd <deploy_path> && set -a && . ./.env.gluecron \ | |
| 223 | * && set +a && <deploy_script>' | |
| 224 | * 3. Capture stdout+stderr+exit and return. | |
| 225 | * | |
| 226 | * Never throws — every failure is folded into the DeployResult with the | |
| 227 | * stderr line that explains why. Caller is responsible for inserting the | |
| 228 | * `server_target_deployments` row. | |
| 229 | */ | |
| 230 | export async function deployToTarget( | |
| 231 | target: ServerTarget, | |
| 232 | ctx: DeployContext | |
| 233 | ): Promise<DeployResult> { | |
| 234 | try { | |
| 235 | return await withKeyFile(target.encryptedPrivateKey, async (keyPath) => { | |
| 236 | const dir = path.dirname(keyPath); | |
| 237 | ||
| 238 | // Materialise known_hosts up front so we can run ssh in strict mode | |
| 239 | // for a target that's already been pinned. | |
| 240 | if (target.hostFingerprint) { | |
| 241 | // We don't have the raw host key, only the fingerprint, so we | |
| 242 | // re-scan and verify the fingerprint matches before writing the | |
| 243 | // known_hosts file. A mismatch is a hard abort. | |
| 244 | const scan = await _spawn( | |
| 245 | ["ssh-keyscan", "-T", "5", "-p", String(target.port), target.host], | |
| 246 | {} | |
| 247 | ); | |
| 248 | if (scan.exitCode !== 0 || !scan.stdout.trim()) { | |
| 249 | return { | |
| 250 | ok: false, | |
| 251 | exitCode: scan.exitCode, | |
| 252 | stdout: "", | |
| 253 | stderr: `ssh-keyscan: ${scan.stderr.trim() || "no host key returned"}`, | |
| 254 | }; | |
| 255 | } | |
| 256 | const knownHostsPath = path.join(dir, "known_hosts"); | |
| 257 | await writeFile(knownHostsPath, scan.stdout); | |
| 258 | const fp = await _spawn(["ssh-keygen", "-lf", knownHostsPath], {}); | |
| 259 | const live = (fp.stdout.match(/SHA256:[A-Za-z0-9+/=]+/) || [""])[0]; | |
| 260 | if (!live || live !== target.hostFingerprint) { | |
| 261 | return { | |
| 262 | ok: false, | |
| 263 | exitCode: 255, | |
| 264 | stdout: "", | |
| 265 | stderr: `host key fingerprint mismatch — pinned=${target.hostFingerprint} live=${live || "<none>"}. Aborting deploy.`, | |
| 266 | }; | |
| 267 | } | |
| 268 | } | |
| 269 | ||
| 270 | const dotenv = renderDotenv(ctx.env); | |
| 271 | const envPath = path.join(dir, "deploy.env"); | |
| 272 | await writeFile(envPath, dotenv, { mode: 0o600 }); | |
| 273 | ||
| 274 | // scp the .env up. The remote path is fixed for predictability. | |
| 275 | const remoteEnv = `${target.deployPath.replace(/\/$/, "")}/.env.gluecron`; | |
| 276 | const scp = await _spawn( | |
| 277 | [ | |
| 278 | "scp", | |
| 279 | ...sshBaseArgs(target, keyPath), | |
| 280 | envPath, | |
| 281 | `${target.sshUser}@${target.host}:${remoteEnv}`, | |
| 282 | ], | |
| 283 | {} | |
| 284 | ); | |
| 285 | if (scp.exitCode !== 0) { | |
| 286 | return { | |
| 287 | ok: false, | |
| 288 | exitCode: scp.exitCode, | |
| 289 | stdout: scp.stdout, | |
| 290 | stderr: `scp env: ${scp.stderr.trim()}`, | |
| 291 | }; | |
| 292 | } | |
| 293 | ||
| 294 | const commitExport = ctx.commitSha | |
| 295 | ? `export GLUECRON_COMMIT_SHA='${ctx.commitSha.replace(/'/g, "")}'; ` | |
| 296 | : ""; | |
| 297 | const refExport = ctx.ref | |
| 298 | ? `export GLUECRON_REF='${ctx.ref.replace(/'/g, "")}'; ` | |
| 299 | : ""; | |
| 300 | const remoteCmd = | |
| 301 | `cd '${target.deployPath.replace(/'/g, "'\\''")}' && ` + | |
| 302 | `set -a && . ./.env.gluecron && set +a && ` + | |
| 303 | commitExport + | |
| 304 | refExport + | |
| 305 | target.deployScript; | |
| 306 | ||
| 307 | const run = await _spawn( | |
| 308 | [ | |
| 309 | "ssh", | |
| 310 | ...sshBaseArgs(target, keyPath), | |
| 311 | `${target.sshUser}@${target.host}`, | |
| 312 | remoteCmd, | |
| 313 | ], | |
| 314 | {} | |
| 315 | ); | |
| 316 | return { | |
| 317 | ok: run.exitCode === 0, | |
| 318 | exitCode: run.exitCode, | |
| 319 | stdout: run.stdout, | |
| 320 | stderr: run.stderr, | |
| 321 | }; | |
| 322 | }); | |
| 323 | } catch (err) { | |
| 324 | return { | |
| 325 | ok: false, | |
| 326 | exitCode: -1, | |
| 327 | stdout: "", | |
| 328 | stderr: err instanceof Error ? err.message : String(err), | |
| 329 | }; | |
| 330 | } | |
| 331 | } | |
| 332 | ||
| 333 | export { renderDotenv } from "./server-targets-crypto"; | |
| 334 | ||
| 335 | /** Test-only access to internal seams. */ | |
| 336 | export const __test = { | |
| 337 | sshBaseArgs, | |
| 338 | withKeyFile, | |
| 339 | }; |