diff --git a/packages/cli/src/adapter-registry.ts b/packages/cli/src/adapter-registry.ts index eb821a1a..4af5a9db 100644 --- a/packages/cli/src/adapter-registry.ts +++ b/packages/cli/src/adapter-registry.ts @@ -78,7 +78,7 @@ export const CATEGORIES: readonly AdapterCategory[] = [ id: 'cloud', pkgPrefix: '@profullstack/sh1pt-cloud', description: 'Raw-compute cloud providers — VPS, GPU, rollouts', - adapters: ['atlantic', 'cloudflare', 'digitalocean', 'exe-dev', 'firebase', 'fly', 'hetzner', 'nvidia', 'railway', 'runpod', 'supabase', 'vultr'], + adapters: ['atlantic', 'cloudflare', 'digitalocean', 'exe-dev', 'firebase', 'fly', 'hetzner', 'linode', 'nvidia', 'railway', 'runpod', 'supabase', 'vultr'], }, { id: 'observability', diff --git a/packages/cli/src/commands/scale.ts b/packages/cli/src/commands/scale.ts index 5b433a10..33d9f9d0 100644 --- a/packages/cli/src/commands/scale.ts +++ b/packages/cli/src/commands/scale.ts @@ -138,6 +138,7 @@ const DEFAULT_PRICING: Record = { 'cloud-digitalocean': { label: 'DigitalOcean (VPS)', hourly: 0.007 }, 'cloud-vultr': { label: 'Vultr (VPS)', hourly: 0.007 }, 'cloud-hetzner': { label: 'Hetzner Cloud (VPS)', hourly: 0.005 }, + 'cloud-linode': { label: 'Linode (VPS)', hourly: 0.0075 }, 'cloud-atlantic': { label: 'Atlantic.Net (VPS)', hourly: 0.008 }, 'cloud-railway': { label: 'Railway (hosting)', hourly: 0.017 }, 'cloud-cloudflare': { label: 'Cloudflare (Workers)', hourly: 0.0 }, diff --git a/packages/cloud/linode/README.md b/packages/cloud/linode/README.md new file mode 100644 index 00000000..cdded0bb --- /dev/null +++ b/packages/cloud/linode/README.md @@ -0,0 +1,44 @@ +# Linode / Akamai Cloud (VPS, GPU, Dedicated CPU, Block Storage) + +Provides the Linode / Akamai Cloud cloud provider adapter for sh1pt scale and deploy workflows. + +## What it does + +- Connects cloud provider credentials and project settings. +- Quotes Linode instance types before provisioning. +- Supports CPU VPS, GPU, dedicated CPU, and Block Storage workflows where implemented. +- Includes a connection flow for account or credential setup. +- Includes setup guidance for required credentials or provider configuration. + +## Package + +- Name: `@profullstack/sh1pt-cloud-linode` +- Path: `packages/cloud/linode` +- Adapter ID: `cloud-linode` +- Homepage: https://sh1pt.com + +## Scripts + +- `build`: `tsc -p tsconfig.json` +- `prepublishOnly`: `pnpm build` +- `typecheck`: `tsc -p tsconfig.json --noEmit` + +## Usage + +```bash +pnpm add @profullstack/sh1pt-cloud-linode +``` + +## Development + +```bash +pnpm --filter @profullstack/sh1pt-cloud-linode typecheck +``` + +Run tests from the repository root: + +```bash +pnpm vitest run packages/cloud/linode/src/index.test.ts +``` + + diff --git a/packages/cloud/linode/package.json b/packages/cloud/linode/package.json new file mode 100644 index 00000000..009eaa3f --- /dev/null +++ b/packages/cloud/linode/package.json @@ -0,0 +1,37 @@ +{ + "name": "@profullstack/sh1pt-cloud-linode", + "version": "0.1.15", + "type": "module", + "main": "./src/index.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "prepublishOnly": "pnpm build" + }, + "dependencies": { + "@profullstack/sh1pt-core": "workspace:*" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/profullstack/sh1pt.git", + "directory": "packages/cloud/linode" + }, + "homepage": "https://sh1pt.com", + "bugs": "https://github.com/profullstack/sh1pt/issues", + "files": [ + "dist" + ], + "publishConfig": { + "access": "public", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + } + } +} diff --git a/packages/cloud/linode/src/index.test.ts b/packages/cloud/linode/src/index.test.ts new file mode 100644 index 00000000..5d2b1e86 --- /dev/null +++ b/packages/cloud/linode/src/index.test.ts @@ -0,0 +1,618 @@ +import { contractTestCloud } from '@profullstack/sh1pt-core/testing'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import adapter from './index.js'; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('Linode cloud adapter', () => { + it('connects from the direct account response shape', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ euuid: 'acct-123', email: 'ops@example.com' }), + })); + + await expect(adapter.connect({ + secret: (key: string) => key === 'LINODE_API_TOKEN' ? 'token' : undefined, + log: vi.fn(), + }, {})).resolves.toEqual({ accountId: 'acct-123' }); + }); + + it('creates instances from direct create response shape', async () => { + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => JSON.stringify({ + data: [ + { + id: 'g6-nanode-1', + label: 'Nanode 1 GB', + price: { hourly: 0.0075, monthly: 5 }, + vcpus: 1, + memory: 1024, + disk: 25600, + transfer: 1000, + class: 'nanode', + }, + ], + }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => JSON.stringify({ + id: 123, + label: 'sh1pt-cpu-vps-test', + status: 'running', + type: 'g6-nanode-1', + ipv4: ['203.0.113.10'], + region: 'us-east', + created: '2026-06-13T00:00:00', + tags: ['sh1pt'], + }), + })); + + await expect(adapter.provision({ + secret: (key: string) => key === 'LINODE_API_TOKEN' ? 'token' : key === 'LINODE_ROOT_PASS' ? 'test-root-pass' : undefined, + log: vi.fn(), + dryRun: false, + }, { + kind: 'cpu-vps', + cpu: 1, + memory: 1, + region: 'us-east', + tags: ['sh1pt'], + }, {})).resolves.toMatchObject({ + id: '123', + kind: 'cpu-vps', + status: 'running', + publicIp: '203.0.113.10', + sku: 'g6-nanode-1', + }); + }); + + it('uses a Linode-valid short label when creating block storage volumes', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ + id: 456, + label: 'sh1pt-bs-test', + status: 'active', + size: 20, + region: 'us-east', + linode_id: null, + created: '2026-06-13T00:00:00', + tags: ['sh1pt'], + }), + }); + vi.stubGlobal('fetch', fetchMock); + + await expect(adapter.provision({ + secret: (key: string) => key === 'LINODE_API_TOKEN' ? 'token' : undefined, + log: vi.fn(), + dryRun: false, + }, { + kind: 'block-storage', + storage: 20, + region: 'us-east', + tags: ['sh1pt'], + }, {})).resolves.toMatchObject({ + id: '456', + kind: 'block-storage', + status: 'running', + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, init] = fetchMock.mock.calls[0]!; + const body = JSON.parse(init!.body as string); + expect(body.label).toMatch(/^sh1pt-bs-[a-z0-9]+-[a-z0-9]{4}$/); + expect(body.label.length).toBeLessThanOrEqual(32); + }); + + it('varies generated labels for same-millisecond block storage creates', async () => { + vi.spyOn(Date, 'now').mockReturnValue(1_800_000_000_000); + vi.spyOn(Math, 'random') + .mockReturnValueOnce(0.111111) + .mockReturnValueOnce(0.222222); + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ + id: 456, + label: 'sh1pt-bs-test', + status: 'active', + size: 20, + region: 'us-east', + linode_id: null, + created: '2026-06-13T00:00:00', + }), + }); + vi.stubGlobal('fetch', fetchMock); + + const ctx = { + secret: (key: string) => key === 'LINODE_API_TOKEN' ? 'token' : undefined, + log: vi.fn(), + dryRun: false, + }; + + await adapter.provision(ctx, { kind: 'block-storage', region: 'us-east' }, {}); + await adapter.provision(ctx, { kind: 'block-storage', region: 'us-east' }, {}); + + const first = JSON.parse(fetchMock.mock.calls[0]![1]!.body as string).label; + const second = JSON.parse(fetchMock.mock.calls[1]![1]!.body as string).label; + expect(first).not.toEqual(second); + expect(first.length).toBeLessThanOrEqual(32); + expect(second.length).toBeLessThanOrEqual(32); + }); + + it('does not create block storage when maxHourlyPrice is below the volume rate', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + await expect(adapter.provision({ + secret: (key: string) => key === 'LINODE_API_TOKEN' ? 'token' : undefined, + log: vi.fn(), + dryRun: false, + }, { + kind: 'block-storage', + storage: 20, + region: 'us-east', + maxHourlyPrice: 0.001, + }, {})).rejects.toThrow('exceeds maxHourlyPrice'); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('requires a login mechanism before non-dry-run image provisioning', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ + data: [ + { + id: 'g6-nanode-1', + label: 'Nanode 1 GB', + price: { hourly: 0.0075, monthly: 5 }, + vcpus: 1, + memory: 1024, + disk: 25600, + transfer: 1000, + class: 'nanode', + }, + ], + }), + })); + + await expect(adapter.provision({ + secret: (key: string) => key === 'LINODE_API_TOKEN' ? 'token' : undefined, + log: vi.fn(), + dryRun: false, + }, { kind: 'cpu-vps', region: 'us-east' }, {})).rejects.toThrow('linode image deploy requires'); + }); + + it('does not fall back to a default billable type when maxHourlyPrice filters all matches', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ + data: [ + { + id: 'g6-nanode-1', + label: 'Nanode 1 GB', + price: { hourly: 0.0075, monthly: 5 }, + vcpus: 1, + memory: 1024, + disk: 25600, + transfer: 1000, + class: 'nanode', + }, + ], + }), + }); + vi.stubGlobal('fetch', fetchMock); + + await expect(adapter.provision({ + secret: (key: string) => key === 'LINODE_API_TOKEN' ? 'token' : key === 'LINODE_ROOT_PASS' ? 'test-root-pass' : undefined, + log: vi.fn(), + dryRun: false, + }, { + kind: 'cpu-vps', + region: 'us-east', + maxHourlyPrice: 0.001, + }, {})).rejects.toThrow('satisfies maxHourlyPrice'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.linode.com/v4/linode/types?page_size=500', + expect.objectContaining({ method: 'GET' }), + ); + }); + + it('does not fall back to a default billable type when hardware constraints filter all matches', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ + data: [ + { + id: 'g6-nanode-1', + label: 'Nanode 1 GB', + price: { hourly: 0.0075, monthly: 5 }, + vcpus: 1, + memory: 1024, + disk: 25600, + transfer: 1000, + class: 'nanode', + }, + ], + }), + }); + vi.stubGlobal('fetch', fetchMock); + + await expect(adapter.provision({ + secret: (key: string) => key === 'LINODE_API_TOKEN' ? 'token' : key === 'LINODE_ROOT_PASS' ? 'test-root-pass' : undefined, + log: vi.fn(), + dryRun: false, + }, { + kind: 'cpu-vps', + cpu: 32, + memory: 128, + region: 'us-east', + }, {})).rejects.toThrow('satisfies requested hardware constraints'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('does not fall back to a default billable type when the requested region has no match', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ + data: [ + { + id: 'g6-nanode-1', + label: 'Nanode 1 GB', + price: { hourly: 0.0075, monthly: 5 }, + vcpus: 1, + memory: 1024, + disk: 25600, + transfer: 1000, + class: 'nanode', + region_availability: { 'us-east': 'unavailable' }, + }, + ], + }), + }); + vi.stubGlobal('fetch', fetchMock); + + await expect(adapter.provision({ + secret: (key: string) => key === 'LINODE_API_TOKEN' ? 'token' : key === 'LINODE_ROOT_PASS' ? 'test-root-pass' : undefined, + log: vi.fn(), + dryRun: false, + }, { + kind: 'cpu-vps', + region: 'us-east', + }, {})).rejects.toThrow('linode: no matching type for kind=cpu-vps in us-east'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('fetches fresh type data for each quote', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => JSON.stringify({ + data: [ + { + id: 'g6-nanode-1', + label: 'Nanode 1 GB', + price: { hourly: 0.0075, monthly: 5 }, + vcpus: 1, + memory: 1024, + disk: 25600, + transfer: 1000, + class: 'nanode', + }, + ], + }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => JSON.stringify({ + data: [ + { + id: 'g6-standard-1', + label: 'Linode 2 GB', + price: { hourly: 0.015, monthly: 10 }, + vcpus: 1, + memory: 2048, + disk: 51200, + transfer: 2000, + class: 'standard', + }, + ], + }), + }); + vi.stubGlobal('fetch', fetchMock); + + const ctx = { + secret: (key: string) => key === 'LINODE_API_TOKEN' ? 'token' : undefined, + log: vi.fn(), + }; + + await expect(adapter.quote(ctx, { kind: 'cpu-vps', region: 'us-east' }, {})).resolves.toMatchObject({ sku: 'g6-nanode-1' }); + await expect(adapter.quote(ctx, { kind: 'cpu-vps', region: 'us-east' }, {})).resolves.toMatchObject({ sku: 'g6-standard-1' }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('does not quote Linode as free when type pricing cannot be fetched', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + text: async () => 'temporarily unavailable', + })); + + await expect(adapter.quote({ + secret: (key: string) => key === 'LINODE_API_TOKEN' ? 'token' : undefined, + log: vi.fn(), + }, { kind: 'cpu-vps', region: 'us-east' }, {})).rejects.toThrow('Linode GET /linode/types?page_size=500 failed: 503 temporarily unavailable'); + }); + + it('does not quote Linode as free when no type matches the requested region', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ + data: [ + { + id: 'g6-nanode-1', + label: 'Nanode 1 GB', + price: { hourly: 0.0075, monthly: 5 }, + vcpus: 1, + memory: 1024, + disk: 25600, + transfer: 1000, + class: 'nanode', + region_availability: { 'us-east': 'unavailable' }, + }, + ], + }), + })); + + await expect(adapter.quote({ + secret: (key: string) => key === 'LINODE_API_TOKEN' ? 'token' : undefined, + log: vi.fn(), + }, { kind: 'cpu-vps', region: 'us-east' }, {})).rejects.toThrow('linode: no matching type for kind=cpu-vps in us-east'); + }); + + it('requests every page when listing instances and volumes', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => JSON.stringify({ + data: [ + { + id: 123, + label: 'sh1pt-cpu-vps-test', + status: 'running', + type: 'g6-nanode-1', + ipv4: ['203.0.113.10'], + region: 'us-east', + created: '2026-06-13T00:00:00', + tags: ['sh1pt'], + }, + ], + page: 1, + pages: 2, + }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => JSON.stringify({ + data: [ + { + id: 124, + label: 'sh1pt-cpu-vps-next', + status: 'running', + type: 'g6-standard-1', + ipv4: ['203.0.113.11'], + region: 'us-east', + created: '2026-06-13T00:00:00', + tags: ['sh1pt'], + }, + ], + page: 2, + pages: 2, + }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => JSON.stringify({ + data: [ + { + id: 456, + label: 'sh1pt-volume', + status: 'active', + size: 20, + region: 'us-east', + created: '2026-06-13T00:00:00', + tags: ['sh1pt'], + }, + ], + page: 1, + pages: 1, + }), + }); + vi.stubGlobal('fetch', fetchMock); + + await expect(adapter.list({ + secret: (key: string) => key === 'LINODE_API_TOKEN' ? 'token' : undefined, + log: vi.fn(), + }, {})).resolves.toHaveLength(3); + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + 'https://api.linode.com/v4/linode/instances?page=1&page_size=500', + expect.objectContaining({ method: 'GET' }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'https://api.linode.com/v4/linode/instances?page=2&page_size=500', + expect.objectContaining({ method: 'GET' }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 3, + 'https://api.linode.com/v4/volumes?page=1&page_size=500', + expect.objectContaining({ method: 'GET' }), + ); + }); + + it('falls back to volume destroy only when the instance is not found', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + text: async () => JSON.stringify({ errors: [{ reason: 'not found' }] }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 204, + text: async () => '', + }); + vi.stubGlobal('fetch', fetchMock); + + await expect(adapter.destroy({ + secret: (key: string) => key === 'LINODE_API_TOKEN' ? 'token' : undefined, + log: vi.fn(), + dryRun: false, + }, '123', {})).resolves.toBeUndefined(); + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + 'https://api.linode.com/v4/linode/instances/123', + expect.objectContaining({ method: 'DELETE' }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'https://api.linode.com/v4/volumes/123', + expect.objectContaining({ method: 'DELETE' }), + ); + }); + + it('does not call the API when destroy is a dry run', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + await expect(adapter.destroy({ + secret: (key: string) => key === 'LINODE_API_TOKEN' ? 'token' : undefined, + log: vi.fn(), + dryRun: true, + }, '123', {})).resolves.toBeUndefined(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('does not fall back to volume destroy on instance lifecycle errors', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 409, + statusText: 'Conflict', + text: async () => JSON.stringify({ errors: [{ reason: 'instance is busy' }] }), + }); + vi.stubGlobal('fetch', fetchMock); + + await expect(adapter.destroy({ + secret: (key: string) => key === 'LINODE_API_TOKEN' ? 'token' : undefined, + log: vi.fn(), + dryRun: false, + }, '123', {})).rejects.toThrow('409 instance is busy'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('falls back to volume status only when the instance is not found', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + text: async () => JSON.stringify({ errors: [{ reason: 'not found' }] }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => JSON.stringify({ + id: 456, + label: 'sh1pt-volume', + status: 'active', + size: 20, + region: 'us-east', + linode_id: null, + created: '2026-06-13T00:00:00', + tags: ['sh1pt'], + }), + }); + vi.stubGlobal('fetch', fetchMock); + + await expect(adapter.status({ + secret: (key: string) => key === 'LINODE_API_TOKEN' ? 'token' : undefined, + log: vi.fn(), + }, '456', {})).resolves.toMatchObject({ + id: '456', + kind: 'block-storage', + status: 'running', + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('does not fall back to volume status on transient instance errors', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + text: async () => 'temporarily unavailable', + }); + vi.stubGlobal('fetch', fetchMock); + + await expect(adapter.status({ + secret: (key: string) => key === 'LINODE_API_TOKEN' ? 'token' : undefined, + log: vi.fn(), + }, '456', {})).rejects.toThrow('503 temporarily unavailable'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('reports non-JSON API errors without parser noise', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + text: async () => 'temporarily unavailable', + })); + + await expect(adapter.connect({ + secret: (key: string) => key === 'LINODE_API_TOKEN' ? 'token' : undefined, + log: vi.fn(), + }, {})).rejects.toThrow('Linode GET /account failed: 503 temporarily unavailable'); + }); +}); + +contractTestCloud(adapter, { + sampleConfig: {}, + sampleSpec: { kind: 'cpu-vps', cpu: 1, memory: 1, region: 'us-east' }, + requiredSecrets: ['LINODE_API_TOKEN'], +}); diff --git a/packages/cloud/linode/src/index.ts b/packages/cloud/linode/src/index.ts new file mode 100644 index 00000000..eb9ea1cf --- /dev/null +++ b/packages/cloud/linode/src/index.ts @@ -0,0 +1,477 @@ +import { defineCloud, tokenSetup, type Instance, type InstanceSpec, type Quote } from '@profullstack/sh1pt-core'; + +// Linode, now Akamai Cloud Computing. API v4 exposes public type +// pricing and authenticated instance/volume management endpoints. +interface Config { + defaultRegion?: string; + authorizedKeys?: string[]; + authorizedUsers?: string[]; + rootPassSecret?: string; +} + +const API = 'https://api.linode.com/v4'; +const DEFAULT_REGION = 'us-east'; +const DEFAULT_IMAGE = 'linode/ubuntu24.04'; +const DEFAULT_ROOT_PASS_SECRET = 'LINODE_ROOT_PASS'; +const VOLUME_MONTHLY_PER_GB = 0.10; + +type RegionAvailability = + | Record + | Array; + +interface LinodeAccount { + email?: string; + euuid?: string; + company?: string; + first_name?: string; + last_name?: string; + balance?: number; + balance_uninvoiced?: number; +} + +interface LinodeType { + id: string; + label: string; + price: { + hourly: number; + monthly: number; + }; + vcpus: number; + memory: number; + disk: number; + transfer: number; + gpus?: number; + class?: string; + region_availability?: RegionAvailability; +} + +interface LinodeTypesResponse { + data: LinodeType[]; +} + +interface LinodePage { + data: T[]; + page?: number; + pages?: number; +} + +interface LinodeInstance { + id: number; + label: string; + status: string; + type: string; + ipv4?: string[]; + ipv6?: string; + region: string; + image?: string; + created: string; + specs?: { + gpus?: number; + }; + tags?: string[]; +} + +interface LinodeVolume { + id: number; + label: string; + status: string; + size: number; + region: string; + linode_id: number | null; + created: string; + tags?: string[]; +} + +export default defineCloud({ + id: 'cloud-linode', + label: 'Linode / Akamai Cloud (VPS, GPU, Dedicated CPU, Block Storage)', + supports: ['cpu-vps', 'gpu', 'bare-metal', 'block-storage'], + + async connect(ctx) { + if (!ctx.secret('LINODE_API_TOKEN')) throw new Error('LINODE_API_TOKEN not in vault - `sh1pt secret set LINODE_API_TOKEN`'); + ctx.log('linode connect - verifying token'); + const account = await linodeRequest(ctx, 'GET', '/account'); + const accountId = account.euuid ?? account.email ?? account.company ?? 'linode-account'; + ctx.log(`linode connected - account=${accountId}`); + return { accountId }; + }, + + async quote(ctx, spec, config) { + const region = spec.region ?? config.defaultRegion ?? DEFAULT_REGION; + ctx.log(`linode quote - kind=${spec.kind} region=${region}`); + + if (spec.kind === 'block-storage') { + const monthly = (spec.storage ?? 10) * VOLUME_MONTHLY_PER_GB; + return { + hourly: volumeHourlyRate(spec.storage ?? 10), + monthly, + currency: 'USD', + provider: 'linode', + sku: 'block-storage', + spot: false, + } satisfies Quote; + } + + const types = await fetchTypes(ctx); + const match = pickType(types, spec, region); + if (!match) { + ctx.log(`linode quote - no matching type for kind=${spec.kind} in ${region}`, 'warn'); + throw new Error(`linode: no matching type for kind=${spec.kind} in ${region}`); + } + + return { + hourly: match.price.hourly, + monthly: match.price.monthly, + currency: 'USD', + provider: 'linode', + sku: match.id, + spot: false, + } satisfies Quote; + }, + + async provision(ctx, spec, config) { + const region = spec.region ?? config.defaultRegion ?? DEFAULT_REGION; + const label = resourceLabel(spec.kind); + + if (ctx.dryRun) return { ...stubInstance('dry-run', 'provisioning', spec.kind), region }; + + if (spec.kind === 'block-storage') { + const hourly = volumeHourlyRate(spec.storage ?? 10); + if (spec.maxHourlyPrice !== undefined && hourly > spec.maxHourlyPrice) { + throw new Error(`linode: block-storage hourly price $${hourly} exceeds maxHourlyPrice $${spec.maxHourlyPrice}`); + } + ctx.log(`linode provision - volume region=${region} size=${spec.storage ?? 10}GB`); + const volume = await linodeRequest(ctx, 'POST', '/volumes', { + label, + region, + size: spec.storage ?? 10, + tags: spec.tags, + }); + return volumeToInstance(volume); + } + + const types = await fetchTypes(ctx); + const match = pickType(types, spec, region); + + if (!match && spec.maxHourlyPrice !== undefined) { + throw new Error(`linode: no matching type for kind=${spec.kind} in ${region} satisfies maxHourlyPrice $${spec.maxHourlyPrice}`); + } + + if (!match && hasHardwareConstraints(spec)) { + throw new Error(`linode: no matching type for kind=${spec.kind} in ${region} satisfies requested hardware constraints`); + } + + if (!match) { + throw new Error(`linode: no matching type for kind=${spec.kind} in ${region}`); + } + + const typeId = match.id; + + const login = loginPayload(ctx, config); + ctx.log(`linode provision - type=${typeId} region=${region} image=${spec.image ?? DEFAULT_IMAGE}`); + + const instance = await linodeRequest(ctx, 'POST', '/linode/instances', { + label, + region, + type: typeId, + image: spec.image ?? DEFAULT_IMAGE, + booted: true, + tags: spec.tags, + ...login, + }); + return instanceToInstance(instance); + }, + + async list(ctx) { + ctx.log('linode list - fetching instances'); + const instances = await fetchPages(ctx, '/linode/instances'); + const result = instances.map(instanceToInstance); + + try { + const volumes = await fetchPages(ctx, '/volumes'); + result.push(...volumes.map(volumeToInstance)); + } catch { + ctx.log('linode list - volumes fetch failed, returning instances only', 'warn'); + } + + return result; + }, + + async destroy(ctx, instanceId) { + ctx.log(`linode destroy - ${instanceId}`); + if (ctx.dryRun) return; + try { + await linodeRequest(ctx, 'DELETE', `/linode/instances/${instanceId}`); + return; + } catch (e) { + if (!isNotFound(e)) throw e; + } + await linodeRequest(ctx, 'DELETE', `/volumes/${instanceId}`); + }, + + async status(ctx, instanceId) { + ctx.log(`linode status - ${instanceId}`); + try { + const instance = await linodeRequest(ctx, 'GET', `/linode/instances/${instanceId}`); + return instanceToInstance(instance); + } catch (e) { + if (!isNotFound(e)) throw e; + } + const volume = await linodeRequest(ctx, 'GET', `/volumes/${instanceId}`); + return volumeToInstance(volume); + }, + + setup: tokenSetup({ + secretKey: 'LINODE_API_TOKEN', + label: 'Linode / Akamai Cloud', + vendorDocUrl: 'https://techdocs.akamai.com/linode-api/reference/api', + steps: [ + 'Open cloud.linode.com -> Profile -> API Tokens', + 'Create a personal access token with read/write access for Linodes and Volumes', + 'Run: sh1pt secret set LINODE_API_TOKEN ', + `For image deploys, also set ${DEFAULT_ROOT_PASS_SECRET} or configure authorizedKeys/authorizedUsers`, + ], + fields: [ + { key: 'defaultRegion', message: 'Default region (us-east, us-central, us-west, eu-west, eu-central, ap-south, ap-northeast):' }, + { key: 'rootPassSecret', message: `Root password secret name (default ${DEFAULT_ROOT_PASS_SECRET}):` }, + ], + }), +}); + +function loginPayload(ctx: { secret(key: string): string | undefined }, config: Config): Record { + const rootPass = ctx.secret(config.rootPassSecret ?? DEFAULT_ROOT_PASS_SECRET); + const payload: Record = {}; + if (rootPass) payload.root_pass = rootPass; + if (config.authorizedKeys?.length) payload.authorized_keys = config.authorizedKeys; + if (config.authorizedUsers?.length) payload.authorized_users = config.authorizedUsers; + if (!rootPass && !config.authorizedKeys?.length && !config.authorizedUsers?.length) { + throw new Error(`linode image deploy requires ${DEFAULT_ROOT_PASS_SECRET}, authorizedKeys, or authorizedUsers`); + } + return payload; +} + +function stubInstance(id: string, status: Instance['status'], kind: InstanceSpec['kind']): Instance { + return { + id, + kind, + status, + createdAt: new Date().toISOString(), + hourlyRate: 0, + currency: 'USD', + }; +} + +function instanceToInstance(instance: LinodeInstance): Instance { + const statusMap: Record = { + running: 'running', + offline: 'stopped', + stopped: 'stopped', + booting: 'provisioning', + busy: 'provisioning', + rebooting: 'provisioning', + shutting_down: 'provisioning', + provisioning: 'provisioning', + deleting: 'destroyed', + migrating: 'provisioning', + rebuilding: 'provisioning', + cloning: 'provisioning', + restoring: 'provisioning', + billing_suspension: 'failed', + }; + const gpus = instance.specs?.gpus ?? 0; + const kind: Instance['kind'] = gpus > 0 || instance.type.includes('gpu') ? 'gpu' : instance.type.includes('dedicated') ? 'bare-metal' : 'cpu-vps'; + + return { + id: String(instance.id), + kind, + status: statusMap[instance.status] ?? 'provisioning', + publicIp: instance.ipv4?.[0], + privateIp: instance.ipv4?.find((ip) => ip.startsWith('192.168.')), + createdAt: instance.created, + hourlyRate: 0, + currency: 'USD', + sku: instance.type, + region: instance.region, + tags: instance.tags, + }; +} + +function volumeToInstance(volume: LinodeVolume): Instance { + const statusMap: Record = { + active: 'running', + creating: 'provisioning', + resizing: 'provisioning', + contact_support: 'failed', + }; + return { + id: String(volume.id), + kind: 'block-storage', + status: statusMap[volume.status] ?? 'provisioning', + createdAt: volume.created, + hourlyRate: volumeHourlyRate(volume.size), + currency: 'USD', + region: volume.region, + tags: volume.tags, + }; +} + +function volumeHourlyRate(sizeGb: number): number { + return (sizeGb * VOLUME_MONTHLY_PER_GB) / 730; +} + +function hasHardwareConstraints(spec: InstanceSpec): boolean { + return !!(spec.cpu || spec.memory || spec.storage || spec.gpu?.count); +} + +function labelKind(kind: InstanceSpec['kind']): string { + if (kind === 'block-storage') return 'bs'; + if (kind === 'bare-metal') return 'metal'; + if (kind === 'cpu-vps') return 'cpu'; + return kind; +} + +function resourceLabel(kind: InstanceSpec['kind']): string { + const suffix = Math.random().toString(36).slice(2, 6).padEnd(4, '0'); + return `sh1pt-${labelKind(kind)}-${Date.now().toString(36)}-${suffix}`; +} + +function pickType(types: LinodeType[], spec: InstanceSpec, region: string): LinodeType | null { + let candidates = types.filter((type) => regionAvailable(type.region_availability, region)); + + if (spec.kind === 'gpu') { + candidates = candidates.filter((type) => (type.gpus ?? 0) > 0 || type.class === 'gpu' || type.id.includes('gpu')); + } else if (spec.kind === 'bare-metal') { + candidates = candidates.filter((type) => type.class === 'dedicated' || type.id.includes('dedicated')); + } else { + candidates = candidates.filter((type) => (type.gpus ?? 0) === 0 && type.class !== 'gpu' && !type.id.includes('gpu')); + } + + if (spec.cpu) candidates = candidates.filter((type) => type.vcpus >= spec.cpu!); + if (spec.memory) candidates = candidates.filter((type) => type.memory >= spec.memory! * 1024); + if (spec.storage) candidates = candidates.filter((type) => type.disk >= spec.storage! * 1024); + if (spec.gpu?.count) candidates = candidates.filter((type) => (type.gpus ?? 0) >= spec.gpu!.count); + if (spec.maxHourlyPrice !== undefined) candidates = candidates.filter((type) => type.price.hourly <= spec.maxHourlyPrice!); + + candidates.sort((a, b) => a.price.hourly - b.price.hourly); + return candidates[0] ?? null; +} + +function regionAvailable(availability: RegionAvailability | undefined, region: string): boolean { + if (!availability) return true; + if (Array.isArray(availability)) { + return availability.some((item) => { + if (typeof item === 'string') return item === region; + if (item.region !== region) return false; + const status = item.availability ?? item.status ?? 'available'; + return status !== 'unavailable'; + }); + } + const status = availability[region]; + return status === undefined || status !== 'unavailable'; +} + +async function fetchTypes(ctx: RequestContext): Promise { + const response = await linodeRequest(ctx, 'GET', '/linode/types?page_size=500', undefined, false); + return response.data; +} + +async function fetchPages(ctx: RequestContext, path: string): Promise { + const items: T[] = []; + let page = 1; + + for (;;) { + const response = await linodeRequest>(ctx, 'GET', `${path}?page=${page}&page_size=500`); + items.push(...response.data); + if (page >= (response.pages ?? page)) return items; + page += 1; + } +} + +interface RequestContext { + secret(key: string): string | undefined; + log(msg: string, level?: 'info' | 'warn' | 'error'): void; +} + +async function linodeRequest( + ctx: RequestContext, + method: string, + path: string, + body?: unknown, + auth = true, +): Promise { + const token = ctx.secret('LINODE_API_TOKEN'); + if (auth && !token) throw new Error('LINODE_API_TOKEN not in vault'); + + const headers: Record = { + Accept: 'application/json', + 'Content-Type': 'application/json', + }; + if (token) headers.Authorization = `Bearer ${token}`; + + const response = await fetch(`${API}${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(stripUndefined(body)), + }); + + if (method === 'DELETE' && (response.status === 200 || response.status === 204)) { + return undefined as T; + } + + const text = await response.text(); + const data = parseJson(text); + + if (!response.ok) { + throw new LinodeApiError(method, path, response.status, extractErrorMessage(data, response.statusText || text)); + } + + return data as T; +} + +class LinodeApiError extends Error { + constructor( + readonly method: string, + readonly path: string, + readonly status: number, + message: string, + ) { + super(`Linode ${method} ${path} failed: ${status} ${message}`); + } +} + +function isNotFound(e: unknown): boolean { + return e instanceof LinodeApiError && e.status === 404; +} + +function parseJson(text: string): unknown { + if (!text) return undefined; + try { + return JSON.parse(text); + } catch { + return text; + } +} + +function extractErrorMessage(data: unknown, fallback: string): string { + if (typeof data === 'object' && data && 'errors' in data && Array.isArray((data as { errors?: unknown }).errors)) { + const first = (data as { errors: Array<{ reason?: unknown; field?: unknown }> }).errors[0]; + if (typeof first?.reason === 'string') { + return typeof first.field === 'string' ? `${first.field}: ${first.reason}` : first.reason; + } + } + if (typeof data === 'object' && data && 'message' in data && typeof (data as { message?: unknown }).message === 'string') { + return (data as { message: string }).message; + } + if (typeof data === 'string' && data) return data; + return fallback; +} + +function stripUndefined(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stripUndefined); + if (!value || typeof value !== 'object') return value; + return Object.fromEntries( + Object.entries(value) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => [k, stripUndefined(v)]), + ); +} diff --git a/packages/cloud/linode/tsconfig.json b/packages/cloud/linode/tsconfig.json new file mode 100644 index 00000000..cf441478 --- /dev/null +++ b/packages/cloud/linode/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b91aecd..7df2a4b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -921,6 +921,12 @@ importers: specifier: workspace:* version: link:../../core + packages/cloud/linode: + dependencies: + '@profullstack/sh1pt-core': + specifier: workspace:* + version: link:../../core + packages/cloud/nvidia: dependencies: '@profullstack/sh1pt-core': diff --git a/sites/sh1pt.com/app/docs/page.tsx b/sites/sh1pt.com/app/docs/page.tsx index 4d5672d0..ceb48654 100644 --- a/sites/sh1pt.com/app/docs/page.tsx +++ b/sites/sh1pt.com/app/docs/page.tsx @@ -634,7 +634,7 @@ export default function DocsPage() { ['bots', 'Chat bots', 'discord · slack · telegram · signal · matrix · whatsapp · twilio · telnyx · twitch · wechat · irc · phonenumbers · teams'], ['bridges', 'Cross-network chat bridges', 'discord · irc · mastodon · matrix · nostr · signal · slack · telegram'], ['captcha', 'CAPTCHA solvers (browser-mode fallback)', '2captcha · captchasolver'], - ['cloud', 'Raw-compute providers', 'atlantic · cloudflare · digitalocean · firebase · fly · hetzner · nvidia · railway · runpod · supabase · vultr'], + ['cloud', 'Raw-compute providers', 'atlantic · cloudflare · digitalocean · firebase · fly · hetzner · linode · nvidia · railway · runpod · supabase · vultr'], ['observability', 'Release tracking + telemetry', 'sentry'], ['dns', 'DNS providers', 'cloudflare · porkbun'], ['secrets', 'Secrets CLIs', 'doppler · dotenvx · 1password'],