/** * 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 { 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); } }