- Add start/stop/reboot endpoints for server power management - Add D1-based logging system (logs table + db-logger utility) - Add idempotency_key validation for order deduplication - Extend VPS provider interface with lifecycle methods Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
281 lines
7.5 KiB
TypeScript
281 lines
7.5 KiB
TypeScript
/**
|
|
* Vultr VPS Provider Implementation
|
|
* API Docs: https://www.vultr.com/api/
|
|
*/
|
|
|
|
import type { VPSProviderConfig, CreateServerRequest, CreateServerResponse } from '../types';
|
|
import { VPSProviderBase, OS_IMAGE_MAP } from './vps-provider';
|
|
import { TIMEOUTS } from '../config';
|
|
|
|
interface VultrInstance {
|
|
id: string;
|
|
os: string;
|
|
ram: number;
|
|
disk: number;
|
|
main_ip: string;
|
|
vcpu_count: number;
|
|
region: string;
|
|
plan: string;
|
|
date_created: string;
|
|
status: string;
|
|
power_status: string;
|
|
server_status: string;
|
|
v6_main_ip: string;
|
|
v6_network: string;
|
|
hostname: string;
|
|
label: string;
|
|
tag: string;
|
|
}
|
|
|
|
interface VultrCreateResponse {
|
|
instance: VultrInstance;
|
|
}
|
|
|
|
interface VultrError {
|
|
error: string;
|
|
status: number;
|
|
}
|
|
|
|
export class VultrProvider extends VPSProviderBase {
|
|
static readonly DEFAULT_BASE_URL = 'https://api.vultr.com/v2';
|
|
|
|
constructor(apiKey: string, baseUrl?: string, timeout: number = TIMEOUTS.VPS_PROVIDER_API_MS) {
|
|
super({
|
|
apiKey,
|
|
baseUrl: baseUrl || VultrProvider.DEFAULT_BASE_URL,
|
|
timeout,
|
|
});
|
|
}
|
|
|
|
async createServer(request: CreateServerRequest): Promise<CreateServerResponse> {
|
|
const url = `${this.config.baseUrl}/instances`;
|
|
|
|
const body = {
|
|
plan: request.plan,
|
|
region: request.region,
|
|
os_id: parseInt(request.osImage, 10),
|
|
label: request.label || `server-${Date.now()}`,
|
|
hostname: request.label || `server-${Date.now()}`,
|
|
tag: request.tags?.[0] || '',
|
|
sshkey_id: request.sshKeys || [],
|
|
enable_ipv6: true,
|
|
backups: 'disabled',
|
|
ddos_protection: false,
|
|
};
|
|
|
|
try {
|
|
const response = await this.fetchWithRetry(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${this.config.apiKey}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = (await response.json()) as VultrError;
|
|
return {
|
|
success: false,
|
|
error: {
|
|
code: `VULTR_${response.status}`,
|
|
message: error.error || 'Unknown error',
|
|
},
|
|
};
|
|
}
|
|
|
|
const data = (await response.json()) as VultrCreateResponse;
|
|
const instance = data.instance;
|
|
|
|
return {
|
|
success: true,
|
|
instanceId: instance.id,
|
|
ipv4: instance.main_ip !== '0.0.0.0' ? instance.main_ip : undefined,
|
|
ipv6: instance.v6_main_ip || undefined,
|
|
status: instance.status,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: {
|
|
code: 'VULTR_NETWORK_ERROR',
|
|
message: error instanceof Error ? error.message : 'Network error',
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
async deleteServer(instanceId: string): Promise<{ success: boolean; error?: string }> {
|
|
const url = `${this.config.baseUrl}/instances/${instanceId}`;
|
|
|
|
try {
|
|
const response = await this.fetchWithRetry(url, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
Authorization: `Bearer ${this.config.apiKey}`,
|
|
},
|
|
});
|
|
|
|
// Vultr returns 204 No Content on success
|
|
if (response.status === 204 || response.ok) {
|
|
return { success: true };
|
|
}
|
|
|
|
const error = (await response.json()) as VultrError;
|
|
return {
|
|
success: false,
|
|
error: error.error || 'Failed to delete instance',
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Network error',
|
|
};
|
|
}
|
|
}
|
|
|
|
async startServer(instanceId: string): Promise<{ success: boolean; error?: string }> {
|
|
const url = `${this.config.baseUrl}/instances/${instanceId}/start`;
|
|
|
|
try {
|
|
const response = await this.fetchWithRetry(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${this.config.apiKey}`,
|
|
},
|
|
});
|
|
|
|
// Vultr returns 204 No Content on success
|
|
if (response.status === 204 || response.ok) {
|
|
return { success: true };
|
|
}
|
|
|
|
const error = (await response.json()) as VultrError;
|
|
return {
|
|
success: false,
|
|
error: error.error || 'Failed to start instance',
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Network error',
|
|
};
|
|
}
|
|
}
|
|
|
|
async stopServer(instanceId: string): Promise<{ success: boolean; error?: string }> {
|
|
const url = `${this.config.baseUrl}/instances/${instanceId}/halt`;
|
|
|
|
try {
|
|
const response = await this.fetchWithRetry(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${this.config.apiKey}`,
|
|
},
|
|
});
|
|
|
|
// Vultr returns 204 No Content on success
|
|
if (response.status === 204 || response.ok) {
|
|
return { success: true };
|
|
}
|
|
|
|
const error = (await response.json()) as VultrError;
|
|
return {
|
|
success: false,
|
|
error: error.error || 'Failed to stop instance',
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Network error',
|
|
};
|
|
}
|
|
}
|
|
|
|
async rebootServer(instanceId: string): Promise<{ success: boolean; error?: string }> {
|
|
const url = `${this.config.baseUrl}/instances/${instanceId}/reboot`;
|
|
|
|
try {
|
|
const response = await this.fetchWithRetry(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${this.config.apiKey}`,
|
|
},
|
|
});
|
|
|
|
// Vultr returns 204 No Content on success
|
|
if (response.status === 204 || response.ok) {
|
|
return { success: true };
|
|
}
|
|
|
|
const error = (await response.json()) as VultrError;
|
|
return {
|
|
success: false,
|
|
error: error.error || 'Failed to reboot instance',
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Network error',
|
|
};
|
|
}
|
|
}
|
|
|
|
async getServerStatus(
|
|
instanceId: string
|
|
): Promise<{ status: string; power_status?: string; ipv4?: string; ipv6?: string }> {
|
|
const url = `${this.config.baseUrl}/instances/${instanceId}`;
|
|
|
|
try {
|
|
const response = await this.fetchWithRetry(url, {
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: `Bearer ${this.config.apiKey}`,
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
return { status: 'unknown' };
|
|
}
|
|
|
|
const data = (await response.json()) as { instance: VultrInstance };
|
|
return {
|
|
status: data.instance.status,
|
|
power_status: data.instance.power_status,
|
|
ipv4: data.instance.main_ip !== '0.0.0.0' ? data.instance.main_ip : undefined,
|
|
ipv6: data.instance.v6_main_ip || undefined,
|
|
};
|
|
} catch {
|
|
return { status: 'unknown' };
|
|
}
|
|
}
|
|
|
|
getOsImageId(osImage: string): string {
|
|
return OS_IMAGE_MAP.vultr[osImage as keyof typeof OS_IMAGE_MAP.vultr] || '2284'; // Default Ubuntu 22.04
|
|
}
|
|
|
|
/**
|
|
* Wait for server to be ready (IP assigned)
|
|
* Vultr servers may take a minute to get IP addresses
|
|
*/
|
|
async waitForReady(
|
|
instanceId: string,
|
|
maxWaitMs: number = 120000
|
|
): Promise<{ ready: boolean; ipv4?: string; ipv6?: string }> {
|
|
const startTime = Date.now();
|
|
const pollInterval = 5000;
|
|
|
|
while (Date.now() - startTime < maxWaitMs) {
|
|
const status = await this.getServerStatus(instanceId);
|
|
|
|
if (status.status === 'active' && status.ipv4) {
|
|
return { ready: true, ipv4: status.ipv4, ipv6: status.ipv6 };
|
|
}
|
|
|
|
await this.sleep(pollInterval);
|
|
}
|
|
|
|
return { ready: false };
|
|
}
|
|
}
|