mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-04-28 03:49:31 +00:00
fix: refresh server IP from cloud API before reconnect SSH (#2625)
Fixes #2624 When reconnecting to an existing server via `spawn ls` or `spawn last`, the CLI now queries the cloud provider API for the server's current IP before attempting SSH. This prevents silent SSH timeouts when a server's IP changes (e.g., after a restart or elastic IP reallocation). Changes: - Add `getServerIp()` to DigitalOcean, Hetzner, AWS, and GCP modules - Add `updateRecordIp()` to history.ts to persist IP changes - Add `refreshConnectionIp()` in list.ts that authenticates with the cloud provider and refreshes the IP before enter/reconnect/fix actions - If the server no longer exists, mark it deleted and inform the user - If refresh fails (e.g., no credentials), fall back to cached IP Agent: code-health Co-authored-by: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a738e658a3
commit
f3a9db4b91
6 changed files with 203 additions and 1 deletions
|
|
@ -1171,6 +1171,25 @@ export async function promptSpawnName(): Promise<void> {
|
|||
|
||||
// ─── Lifecycle ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Fetch the current public IP of an existing Lightsail instance. Returns null if it no longer exists. */
|
||||
export async function getServerIp(instanceName: string): Promise<string | null> {
|
||||
const r = await asyncTryCatch(() => lightsailGetInstance(instanceName));
|
||||
if (!r.ok) {
|
||||
const msg = getErrorMessage(r.error);
|
||||
if (
|
||||
msg.includes("404") ||
|
||||
msg.includes("not found") ||
|
||||
msg.includes("Not Found") ||
|
||||
msg.includes("NotFoundException")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw r.error;
|
||||
}
|
||||
const ip = r.data.ip;
|
||||
return ip || null;
|
||||
}
|
||||
|
||||
export async function destroyServer(name?: string): Promise<void> {
|
||||
const target = name || _state.instanceName;
|
||||
if (!target) {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,14 @@ import type { Manifest } from "../manifest.js";
|
|||
|
||||
import * as p from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
import { clearHistory, filterHistory, getActiveServers, removeRecord } from "../history.js";
|
||||
import {
|
||||
clearHistory,
|
||||
filterHistory,
|
||||
getActiveServers,
|
||||
markRecordDeleted,
|
||||
removeRecord,
|
||||
updateRecordIp,
|
||||
} from "../history.js";
|
||||
import { agentKeys, cloudKeys, loadManifest } from "../manifest.js";
|
||||
import { asyncTryCatch, tryCatch, unwrapOr } from "../shared/result.js";
|
||||
import { cmdConnect, cmdEnterAgent, cmdOpenDashboard } from "./connect.js";
|
||||
|
|
@ -242,6 +249,96 @@ export async function resolveListFilters(
|
|||
};
|
||||
}
|
||||
|
||||
// ── IP refresh ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Refresh the IP address for a connection by querying the cloud provider API.
|
||||
* Updates the in-memory connection object and persists the change to history.
|
||||
* Returns "ok" if the IP was refreshed (or unchanged), "gone" if the server
|
||||
* no longer exists, or "skip" if refresh is not applicable (local, sprite, etc.).
|
||||
*/
|
||||
async function refreshConnectionIp(record: SpawnRecord): Promise<"ok" | "gone" | "skip"> {
|
||||
const conn = record.connection;
|
||||
if (!conn?.cloud || conn.cloud === "local" || conn.cloud === "sprite" || conn.deleted) {
|
||||
return "skip";
|
||||
}
|
||||
|
||||
const serverId = conn.server_id || conn.server_name || "";
|
||||
if (!serverId) {
|
||||
return "skip";
|
||||
}
|
||||
|
||||
let currentIp: string | null = null;
|
||||
|
||||
switch (conn.cloud) {
|
||||
case "digitalocean": {
|
||||
const { ensureDoToken, getServerIp } = await import("../digitalocean/digitalocean.js");
|
||||
await ensureDoToken();
|
||||
currentIp = await getServerIp(serverId);
|
||||
break;
|
||||
}
|
||||
case "hetzner": {
|
||||
const { ensureHcloudToken, getServerIp } = await import("../hetzner/hetzner.js");
|
||||
await ensureHcloudToken();
|
||||
currentIp = await getServerIp(serverId);
|
||||
break;
|
||||
}
|
||||
case "aws": {
|
||||
const { ensureAwsCli, authenticate, getServerIp } = await import("../aws/aws.js");
|
||||
await ensureAwsCli();
|
||||
await authenticate();
|
||||
currentIp = await getServerIp(serverId);
|
||||
break;
|
||||
}
|
||||
case "gcp": {
|
||||
const { ensureGcloudCli, authenticate, resolveProject, getServerIp } = await import("../gcp/gcp.js");
|
||||
const zone = conn.metadata?.zone || "us-central1-a";
|
||||
const project = conn.metadata?.project || "";
|
||||
if (!project) {
|
||||
return "skip";
|
||||
}
|
||||
process.env.GCP_ZONE = zone;
|
||||
process.env.GCP_PROJECT = project;
|
||||
await ensureGcloudCli();
|
||||
await authenticate();
|
||||
// Set SPAWN_NON_INTERACTIVE to suppress project prompt during refresh
|
||||
const prevNonInteractive = process.env.SPAWN_NON_INTERACTIVE;
|
||||
process.env.SPAWN_NON_INTERACTIVE = "1";
|
||||
const resolveResult = await asyncTryCatch(() => resolveProject());
|
||||
if (prevNonInteractive === undefined) {
|
||||
delete process.env.SPAWN_NON_INTERACTIVE;
|
||||
} else {
|
||||
process.env.SPAWN_NON_INTERACTIVE = prevNonInteractive;
|
||||
}
|
||||
if (!resolveResult.ok) {
|
||||
return "skip";
|
||||
}
|
||||
currentIp = await getServerIp(serverId, zone, project);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return "skip";
|
||||
}
|
||||
|
||||
if (currentIp === null) {
|
||||
// Server no longer exists
|
||||
p.log.warn("Server no longer exists on the cloud provider.");
|
||||
markRecordDeleted(record);
|
||||
if (conn) {
|
||||
conn.deleted = true;
|
||||
}
|
||||
return "gone";
|
||||
}
|
||||
|
||||
if (currentIp !== conn.ip) {
|
||||
p.log.info(`Server IP changed: ${conn.ip} -> ${currentIp}`);
|
||||
conn.ip = currentIp;
|
||||
updateRecordIp(record, currentIp);
|
||||
}
|
||||
|
||||
return "ok";
|
||||
}
|
||||
|
||||
// ── Record actions ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Outcome of handleRecordAction — determines whether the picker loops or exits. */
|
||||
|
|
@ -349,6 +446,19 @@ export async function handleRecordAction(
|
|||
return RecordActionOutcome.Back;
|
||||
}
|
||||
|
||||
// Refresh IP from cloud API before connecting (enter/reconnect/fix)
|
||||
if (action === "enter" || action === "reconnect" || action === "fix") {
|
||||
const refreshResult = await asyncTryCatch(() => refreshConnectionIp(selected));
|
||||
if (refreshResult.ok && refreshResult.data === "gone") {
|
||||
p.log.info(`Use ${pc.cyan(`spawn ${selected.agent} ${selected.cloud}`)} to start a new one.`);
|
||||
return RecordActionOutcome.Back;
|
||||
}
|
||||
if (!refreshResult.ok) {
|
||||
// Non-fatal: proceed with cached IP if refresh fails
|
||||
p.log.warn(`Could not refresh server IP: ${getErrorMessage(refreshResult.error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "enter") {
|
||||
const enterResult = await asyncTryCatch(() => cmdEnterAgent(selected.connection, selected.agent, manifest));
|
||||
if (!enterResult.ok) {
|
||||
|
|
|
|||
|
|
@ -1314,6 +1314,22 @@ export async function promptSpawnName(): Promise<void> {
|
|||
|
||||
// ─── Lifecycle ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Fetch the current public IP of an existing droplet. Returns null if the droplet no longer exists. */
|
||||
export async function getServerIp(dropletId: string): Promise<string | null> {
|
||||
const r = await asyncTryCatch(() => doApi("GET", `/droplets/${dropletId}`, undefined, 1));
|
||||
if (!r.ok) {
|
||||
const msg = getErrorMessage(r.error);
|
||||
if (msg.includes("404") || msg.includes("not found") || msg.includes("Not Found")) {
|
||||
return null;
|
||||
}
|
||||
throw r.error;
|
||||
}
|
||||
const data = parseJsonObj(r.data);
|
||||
const v4Networks = toObjectArray(data?.droplet?.networks?.v4);
|
||||
const publicNet = v4Networks.find((n) => n.type === "public");
|
||||
return publicNet?.ip_address && isString(publicNet.ip_address) ? publicNet.ip_address : null;
|
||||
}
|
||||
|
||||
export async function destroyServer(dropletId?: string): Promise<void> {
|
||||
const id = dropletId || _state.dropletId;
|
||||
if (!id) {
|
||||
|
|
|
|||
|
|
@ -1053,6 +1053,27 @@ export async function interactiveSession(cmd: string): Promise<number> {
|
|||
|
||||
// ─── Lifecycle ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Fetch the current public IP of an existing GCP instance. Returns null if it no longer exists. */
|
||||
export async function getServerIp(instanceName: string, zone: string, project: string): Promise<string | null> {
|
||||
const result = gcloudSync([
|
||||
"compute",
|
||||
"instances",
|
||||
"describe",
|
||||
instanceName,
|
||||
`--zone=${zone}`,
|
||||
`--project=${project}`,
|
||||
"--format=get(networkInterfaces[0].accessConfigs[0].natIP)",
|
||||
]);
|
||||
if (result.exitCode !== 0) {
|
||||
if (/not found|404|was not found/i.test(result.stderr)) {
|
||||
return null;
|
||||
}
|
||||
throw new Error(`GCP API error: ${result.stderr}`);
|
||||
}
|
||||
const ip = result.stdout.trim();
|
||||
return ip || null;
|
||||
}
|
||||
|
||||
export async function destroyInstance(name?: string): Promise<void> {
|
||||
const instanceName = name || _state.instanceName;
|
||||
const zone = _state.zone || process.env.GCP_ZONE || DEFAULT_ZONE;
|
||||
|
|
|
|||
|
|
@ -699,6 +699,26 @@ export async function promptSpawnName(): Promise<void> {
|
|||
|
||||
// ─── Lifecycle ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Fetch the current public IP of an existing Hetzner server. Returns null if the server no longer exists. */
|
||||
export async function getServerIp(serverId: string): Promise<string | null> {
|
||||
const r = await asyncTryCatch(() => hetznerApi("GET", `/servers/${serverId}`, undefined, 1));
|
||||
if (!r.ok) {
|
||||
const msg = getErrorMessage(r.error);
|
||||
if (msg.includes("404") || msg.includes("not found") || msg.includes("Not Found")) {
|
||||
return null;
|
||||
}
|
||||
throw r.error;
|
||||
}
|
||||
const data = parseJsonObj(r.data);
|
||||
const server = toRecord(data?.server);
|
||||
if (!server) {
|
||||
return null;
|
||||
}
|
||||
const publicNet = toRecord(server.public_net);
|
||||
const ipv4 = toRecord(publicNet?.ipv4);
|
||||
return isString(ipv4?.ip) ? ipv4.ip : null;
|
||||
}
|
||||
|
||||
export async function destroyServer(serverId?: string): Promise<void> {
|
||||
const id = serverId || _state.serverId;
|
||||
if (!id) {
|
||||
|
|
|
|||
|
|
@ -416,6 +416,22 @@ export function markRecordDeleted(record: SpawnRecord): boolean {
|
|||
return true;
|
||||
}
|
||||
|
||||
/** Update the IP address on a history record's connection. Returns true if the record was found and updated. */
|
||||
export function updateRecordIp(record: SpawnRecord, newIp: string): boolean {
|
||||
const history = loadHistory();
|
||||
const index = findRecordIndex(history, record);
|
||||
if (index < 0) {
|
||||
return false;
|
||||
}
|
||||
const found = history[index];
|
||||
if (!found.connection) {
|
||||
return false;
|
||||
}
|
||||
found.connection.ip = newIp;
|
||||
writeHistory(history);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getActiveServers(): SpawnRecord[] {
|
||||
const records = loadHistory();
|
||||
return records.filter((r) => r.connection?.cloud && r.connection.cloud !== "local" && !r.connection.deleted);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue