- 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>
269 lines
7.2 KiB
TypeScript
269 lines
7.2 KiB
TypeScript
/**
|
|
* 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';
|
|
import { TIMEOUTS } from '../config';
|
|
|
|
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 {
|
|
static readonly DEFAULT_BASE_URL = 'https://api.linode.com/v4';
|
|
|
|
constructor(apiKey: string, baseUrl?: string, timeout: number = TIMEOUTS.VPS_PROVIDER_API_MS) {
|
|
super({
|
|
apiKey,
|
|
baseUrl: baseUrl || LinodeProvider.DEFAULT_BASE_URL,
|
|
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),
|
|
});
|
|
|
|
// Read response text first to handle both JSON and HTML errors
|
|
const responseText = await response.text();
|
|
|
|
if (!response.ok) {
|
|
// Try to parse as JSON, but handle HTML responses gracefully
|
|
try {
|
|
const error = JSON.parse(responseText) as LinodeError;
|
|
return {
|
|
success: false,
|
|
error: {
|
|
code: `LINODE_${response.status}`,
|
|
message: error.errors?.[0]?.reason || 'Unknown error',
|
|
},
|
|
};
|
|
} catch {
|
|
return {
|
|
success: false,
|
|
error: {
|
|
code: `LINODE_${response.status}`,
|
|
message: `Non-JSON response: ${responseText.substring(0, 200)}`,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
const data = JSON.parse(responseText) 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 startServer(instanceId: string): Promise<{ success: boolean; error?: string }> {
|
|
const url = `${this.config.baseUrl}/linode/instances/${instanceId}/boot`;
|
|
|
|
try {
|
|
const response = await this.fetchWithRetry(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${this.config.apiKey}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = (await response.json()) as LinodeError;
|
|
return {
|
|
success: false,
|
|
error: error.errors?.[0]?.reason || 'Failed to start instance',
|
|
};
|
|
}
|
|
|
|
return { success: true };
|
|
} 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}/linode/instances/${instanceId}/shutdown`;
|
|
|
|
try {
|
|
const response = await this.fetchWithRetry(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${this.config.apiKey}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = (await response.json()) as LinodeError;
|
|
return {
|
|
success: false,
|
|
error: error.errors?.[0]?.reason || 'Failed to stop instance',
|
|
};
|
|
}
|
|
|
|
return { success: true };
|
|
} 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}/linode/instances/${instanceId}/reboot`;
|
|
|
|
try {
|
|
const response = await this.fetchWithRetry(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${this.config.apiKey}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = (await response.json()) as LinodeError;
|
|
return {
|
|
success: false,
|
|
error: error.errors?.[0]?.reason || 'Failed to reboot instance',
|
|
};
|
|
}
|
|
|
|
return { success: true };
|
|
} 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}/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;
|
|
// Linode: status is the power state (running, offline, etc.)
|
|
return {
|
|
status: data.status,
|
|
power_status: data.status, // For compatibility with Vultr logic
|
|
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);
|
|
}
|
|
}
|