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:
159
src/services/linode-provider.ts
Normal file
159
src/services/linode-provider.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Linode VPS Provider Implementation
|
||||
* API Docs: https://www.linode.com/docs/api/
|
||||
*/
|
||||
|
||||
import type { VPSProviderConfig, CreateServerRequest, CreateServerResponse } from '../types';
|
||||
import { VPSProviderBase, OS_IMAGE_MAP } from './vps-provider';
|
||||
|
||||
interface LinodeInstance {
|
||||
id: number;
|
||||
label: string;
|
||||
status: string;
|
||||
ipv4: string[];
|
||||
ipv6: string;
|
||||
region: string;
|
||||
type: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
interface LinodeError {
|
||||
errors: Array<{
|
||||
field?: string;
|
||||
reason: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export class LinodeProvider extends VPSProviderBase {
|
||||
constructor(apiKey: string, timeout: number = 30000) {
|
||||
super({
|
||||
apiKey,
|
||||
baseUrl: 'https://api.linode.com/v4',
|
||||
timeout,
|
||||
});
|
||||
}
|
||||
|
||||
async createServer(request: CreateServerRequest): Promise<CreateServerResponse> {
|
||||
const url = `${this.config.baseUrl}/linode/instances`;
|
||||
|
||||
const body = {
|
||||
type: request.plan,
|
||||
region: request.region,
|
||||
image: request.osImage,
|
||||
root_pass: request.rootPassword,
|
||||
label: request.label || `server-${Date.now()}`,
|
||||
tags: request.tags || [],
|
||||
authorized_keys: request.sshKeys || [],
|
||||
booted: true,
|
||||
};
|
||||
|
||||
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 LinodeError;
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: `LINODE_${response.status}`,
|
||||
message: error.errors?.[0]?.reason || 'Unknown error',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const data = (await response.json()) as LinodeInstance;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
instanceId: String(data.id),
|
||||
ipv4: data.ipv4?.[0],
|
||||
ipv6: data.ipv6?.split('/')[0], // Remove CIDR notation
|
||||
status: data.status,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'LINODE_NETWORK_ERROR',
|
||||
message: error instanceof Error ? error.message : 'Network error',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async deleteServer(instanceId: string): Promise<{ success: boolean; error?: string }> {
|
||||
const url = `${this.config.baseUrl}/linode/instances/${instanceId}`;
|
||||
|
||||
try {
|
||||
const response = await this.fetchWithRetry(url, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = (await response.json()) as LinodeError;
|
||||
return {
|
||||
success: false,
|
||||
error: error.errors?.[0]?.reason || 'Failed to delete instance',
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} 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}/linode/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 LinodeInstance;
|
||||
return {
|
||||
status: data.status,
|
||||
ipv4: data.ipv4?.[0],
|
||||
ipv6: data.ipv6?.split('/')[0],
|
||||
};
|
||||
} catch {
|
||||
return { status: 'unknown' };
|
||||
}
|
||||
}
|
||||
|
||||
getOsImageId(osImage: string): string {
|
||||
return OS_IMAGE_MAP.linode[osImage as keyof typeof OS_IMAGE_MAP.linode] || 'linode/ubuntu22.04';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate secure root password for Linode
|
||||
* Linode requires: 6-128 chars, uppercase, lowercase, numeric
|
||||
*/
|
||||
generateRootPassword(): string {
|
||||
return this.generateSecurePassword(32);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user