Skip to content

Commit 702c08f

Browse files
committed
feat(hydra): add update command to upgrade runner binaries
1 parent 8a2503e commit 702c08f

5 files changed

Lines changed: 112 additions & 1 deletion

File tree

packages/hydra/src/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
StartCommand,
99
StatusCommand,
1010
StopCommand,
11+
UpdateCommand,
1112
} from "./commands";
1213
import { CLI_BIN, DEFAULT_CONFIG, HYDRA_CONFIG_FILE } from "./constants";
1314
import type { HydraConfig } from "./types";
@@ -33,6 +34,7 @@ class HydraCLI extends AbstractCLI {
3334
new StopCommand(),
3435
new StatusCommand(),
3536
new RemoveCommand(),
37+
new UpdateCommand(),
3638
new ProfileCommand(),
3739
]);
3840
}

packages/hydra/src/commands/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ export { RemoveCommand } from "./remove";
55
export { StartCommand } from "./start";
66
export { StatusCommand } from "./status";
77
export { StopCommand } from "./stop";
8+
export { UpdateCommand } from "./update";
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { color, Exit, log, spinner } from "@r5n/cli-core";
2+
import { BaseCommand, type Ctx } from "../base-command";
3+
import { createProvider } from "../providers";
4+
import type { Profile } from "../types";
5+
6+
type UpdateCtx = Ctx;
7+
8+
export class UpdateCommand extends BaseCommand {
9+
name = "update";
10+
description = "Update runners to the latest version";
11+
12+
async execute(ctx: UpdateCtx) {
13+
const profiles = ctx.config.get("profiles");
14+
const entries = ctx.config.get("runners") ?? [];
15+
16+
if (entries.length === 0) {
17+
throw new Exit("No runners found", "Run hydra create to provision runners");
18+
}
19+
20+
const s = spinner();
21+
const byProfile = Map.groupBy(entries, (e) => e.profile);
22+
let currentVersion: string | null = null;
23+
let latestVersion = "";
24+
let versionChecked = false;
25+
26+
for (const [profileName, profileEntries] of byProfile) {
27+
const profile = profiles[profileName] as Profile | undefined;
28+
const firstRunner = profileEntries[0];
29+
if (!profile || !firstRunner) continue;
30+
31+
const provider = createProvider(profile);
32+
33+
if (!versionChecked) {
34+
versionChecked = true;
35+
s.start("Checking for updates...");
36+
const result = await provider.download();
37+
latestVersion = result.version;
38+
currentVersion = await provider.currentVersion(firstRunner.id);
39+
s.stop(`Current: v${currentVersion ?? "unknown"} | Latest: v${latestVersion}`);
40+
41+
if (currentVersion === latestVersion) {
42+
log.info(`Already on latest version ${color.dim(`(v${latestVersion})`)}`);
43+
return;
44+
}
45+
}
46+
47+
const statuses = await provider.list();
48+
const runningIds = new Set(statuses.filter((r) => r.status === "running").map((r) => r.id));
49+
50+
for (const entry of profileEntries) {
51+
const wasRunning = runningIds.has(entry.id);
52+
53+
s.start(`Updating ${entry.id}...`);
54+
55+
if (wasRunning) await provider.stop([entry.id]);
56+
await provider.update([entry.id]);
57+
if (wasRunning) await provider.start([entry.id]);
58+
59+
const suffix = wasRunning ? ` ${color.dim("(restarted)")}` : "";
60+
s.stop(`${color.green("✓")} ${entry.id}${suffix}`);
61+
}
62+
}
63+
64+
log.info(`${color.green("Updated")} ${entries.length} runner(s) from v${currentVersion} to v${latestVersion}`);
65+
}
66+
}

packages/hydra/src/providers/GitHubRunnerProvider.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { existsSync } from "node:fs";
2-
import { cp, link, mkdir, readdir, readFile, symlink, unlink, writeFile } from "node:fs/promises";
2+
import { cp, link, mkdir, readdir, readFile, readlink, rm, symlink, unlink, writeFile } from "node:fs/promises";
33
import { join, resolve } from "node:path";
44
import { SHARED_DIR } from "../constants";
55
import type { Profile } from "../types";
@@ -104,6 +104,21 @@ export class GitHubRunnerProvider implements RunnerProvider {
104104
return runners;
105105
}
106106

107+
async currentVersion(id: string): Promise<string | null> {
108+
const runnerDir = join(this.profile.directory, id);
109+
return this.detectVersion(runnerDir);
110+
}
111+
112+
async update(ids: string[]): Promise<void> {
113+
const sharedPath = await this.ensureDownloaded();
114+
115+
for (const id of ids) {
116+
const runnerDir = join(this.profile.directory, id);
117+
await this.removeRunnerBinaries(runnerDir);
118+
await this.setupRunnerDir(sharedPath, runnerDir);
119+
}
120+
}
121+
107122
private async getRunnerStatus(runnerDir: string, id: string): Promise<RunnerInfo> {
108123
const name = await this.readRunnerName(runnerDir);
109124
const pid = await this.readPidFile(runnerDir);
@@ -240,6 +255,31 @@ export class GitHubRunnerProvider implements RunnerProvider {
240255
}
241256
}
242257

258+
private async detectVersion(runnerDir: string): Promise<string | null> {
259+
try {
260+
const target = await readlink(join(runnerDir, "externals"));
261+
const match = target.match(/github\/([^/]+)/);
262+
return match?.[1] ?? null;
263+
} catch {
264+
return null;
265+
}
266+
}
267+
268+
private async removeRunnerBinaries(runnerDir: string) {
269+
for (const dir of HARDLINK_DIRS) {
270+
await rm(join(runnerDir, dir), { force: true, recursive: true });
271+
}
272+
for (const dir of SYMLINK_DIRS) {
273+
await rm(join(runnerDir, dir), { force: true });
274+
}
275+
for (const pattern of ["*.sh", "*.sh.template"]) {
276+
const files = await Array.fromAsync(new Bun.Glob(pattern).scan(runnerDir));
277+
for (const file of files) {
278+
await rm(join(runnerDir, file), { force: true });
279+
}
280+
}
281+
}
282+
243283
private stripBom(content: string) {
244284
return content.replace(/^\uFEFF/, "");
245285
}

packages/hydra/src/providers/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,6 @@ export interface RunnerProvider {
1111
start(ids: string[]): Promise<void>;
1212
stop(ids: string[]): Promise<void>;
1313
list(): Promise<RunnerInfo[]>;
14+
currentVersion(id: string): Promise<string | null>;
15+
update(ids: string[]): Promise<void>;
1416
}

0 commit comments

Comments
 (0)