feat: add Queue-based async server provisioning
- Add Cloudflare Queue for async server provisioning workflow - Implement VPS provider abstraction (Linode, Vultr) - Add provisioning API endpoints with API key authentication - Fix race condition in balance deduction (atomic query) - Remove root_password from Queue for security (fetch from DB) - Add IP assignment wait logic after server creation - Add rollback/refund on all failure cases Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
189
src/services/vultr-provider.ts
Normal file
189
src/services/vultr-provider.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
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 {
|
||||
constructor(apiKey: string, timeout: number = 30000) {
|
||||
super({
|
||||
apiKey,
|
||||
baseUrl: 'https://api.vultr.com/v2',
|
||||
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 getServerStatus(
|
||||
instanceId: string
|
||||
): Promise<{ 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,
|
||||
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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user