diff --git a/packages/cli/src/adapter-registry.ts b/packages/cli/src/adapter-registry.ts index f099a2d0..25502737 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', 'lambda-labs', 'linode', 'nvidia', 'railway', 'runpod', 'supabase', 'vultr'], + adapters: ['atlantic', 'cloudflare', 'digitalocean', 'exe-dev', 'firebase', 'fly', 'hetzner', 'lambda-labs', 'linode', 'netcup', 'nvidia', 'railway', 'runpod', 'supabase', 'vultr'], }, { id: 'observability', diff --git a/packages/cloud/netcup/README.md b/packages/cloud/netcup/README.md new file mode 100644 index 00000000..272cb505 --- /dev/null +++ b/packages/cloud/netcup/README.md @@ -0,0 +1,98 @@ +# netcup (VPS, Root Server) + +Provides the netcup cloud provider adapter for sh1pt `deploy` and `scale` +workflows, driving the Server Control Panel (SCP) REST API. + +## Read this first: netcup has no order API + +Every other cloud adapter in sh1pt can call an endpoint and get a new machine. +**netcup cannot.** Servers are bought through checkout in the Customer Control +Panel — they are monthly contracts, not API resources. The SCP API only ever +sees servers that already exist on the account. + +This is not an oversight in the adapter. The retired SOAP webservice had no +order method, and the REST API that replaced it on 2026-04-30 has 63 endpoints, +none of which create or delete a server. + +So the two lifecycle verbs mean something specific here: + +| sh1pt verb | netcup behaviour | +|---|---| +| `provision` | **Adopts** a server already on the account that has no OS installed, then installs one on it (`POST /servers/{id}/image`) | +| `destroy` | **Throws.** There is no cancel endpoint; a server is terminated from the Customer Control Panel. Powering it off would leave the contract billing while reporting success | + +If nothing is adoptable, `provision` fails with the plan that matches your spec +and a link to buy it, then adopts it on the next run. + +### Why provision is still worth having + +`POST /servers/{id}/image` accepts `hostname`, `sshKeyIds` and a `customScript` +that runs on first boot. So one adopt call can land a fully configured box — +image installed, key authorized, bootstrap script executed — which is the +expensive part of standing up a server anyway. + +```ts +provision(ctx, { kind: 'cpu-vps', sshKeyIds: ['5'], tags: ['dev.moshcode.sh'] }, { + defaultImage: 'Ubuntu 24.04', + customScript: '#!/bin/bash\ncurl -fsSL https://example.com/root-ubuntu.sh | bash', +}); +``` + +### Guardrails + +Installing an image **wipes the target disk**, so adoption is deliberately +timid: + +- A server with a `template` already has an OS and is never adopted. +- A `disabled` server is never adopted. +- If more than one server is adoptable and no `adoptPrefix` is configured, + `provision` refuses rather than guessing. + +## Credentials + +Two grants are supported, checked in this order: + +1. `NETCUP_SCP_CLIENT_ID` + `NETCUP_SCP_CLIENT_SECRET` — client credentials, + created in SCP under **Options → REST API**. Preferred. +2. `NETCUP_SCP_USERNAME` + `NETCUP_SCP_PASSWORD` — password grant. The username + is your CCP customer number. + +`NETCUP_SCP_USER_ID` is optional and only needed to resolve SSH keys by name +rather than by numeric id. Note that the SCP `userId` is **not** the CCP +customer number — it is a separate internal identifier. + +```bash +sh1pt secret set NETCUP_SCP_CLIENT_ID +sh1pt secret set NETCUP_SCP_CLIENT_SECRET +``` + +SCP supports an IP allowlist for API access (Options → REST API). A `403` with +`ip not allowed` means the calling host is not on it. + +## Pricing + +netcup publishes no pricing endpoint, so `quote` reads from the price list +compiled into the adapter (EUR, incl. 19% VAT, verified 2026-08-16). netcup +bills monthly contracts; the `hourly` field is derived as `monthly / 730` purely +to satisfy sh1pt's `Quote` shape and does not correspond to anything netcup +charges. **Update `PRICES` in `src/index.ts` when the price list moves.** + +## Package + +- Name: `@profullstack/sh1pt-cloud-netcup` +- Path: `packages/cloud/netcup` +- Adapter ID: `cloud-netcup` +- Homepage: https://sh1pt.com + +## API reference + +- Docs: https://www.netcup.com/en/helpcenter/documentation/server/rest-api +- OpenAPI: https://www.servercontrolpanel.de/scp-core/api/v1/openapi +- Base URL: `https://www.servercontrolpanel.de/scp-core/api/v1` + +## Development + +```bash +pnpm --filter @profullstack/sh1pt-cloud-netcup typecheck +pnpm vitest run packages/cloud/netcup/src/index.test.ts +``` diff --git a/packages/cloud/netcup/package.json b/packages/cloud/netcup/package.json new file mode 100644 index 00000000..054ba371 --- /dev/null +++ b/packages/cloud/netcup/package.json @@ -0,0 +1,37 @@ +{ + "name": "@profullstack/sh1pt-cloud-netcup", + "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/netcup" + }, + "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/netcup/src/index.test.ts b/packages/cloud/netcup/src/index.test.ts new file mode 100644 index 00000000..671ae51b --- /dev/null +++ b/packages/cloud/netcup/src/index.test.ts @@ -0,0 +1,208 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import adapter, { adoptable, pickImage, pickPlan, sanitizeHostname, serverToInstance, resetTokenCache } from './index.js'; + +const CLIENT_SECRETS = (key: string): string | undefined => ({ + NETCUP_SCP_CLIENT_ID: 'client-abc', + NETCUP_SCP_CLIENT_SECRET: 'shhh', +}[key]); + +const ctx = (overrides: Partial<{ secret: (k: string) => string | undefined; dryRun: boolean }> = {}) => ({ + secret: overrides.secret ?? CLIENT_SECRETS, + log: vi.fn(), + dryRun: overrides.dryRun ?? false, +}); + +function jsonResponse(body: unknown, status = 200) { + return { ok: status >= 200 && status < 300, status, text: async () => JSON.stringify(body) }; +} + +const TOKEN = jsonResponse({ access_token: 'tok', expires_in: 300 }); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + resetTokenCache(); +}); + +describe('netcup cloud adapter', () => { + it('connects with client credentials and reports the account', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(TOKEN) + .mockResolvedValueOnce(jsonResponse([{ id: 1, name: 'v220', disabled: false }])); + vi.stubGlobal('fetch', fetchMock); + + await expect(adapter.connect(ctx(), {})).resolves.toEqual({ accountId: 'client-abc' }); + + const [tokenUrl, tokenInit] = fetchMock.mock.calls[0]!; + expect(tokenUrl).toContain('/protocol/openid-connect/token'); + expect(String(tokenInit.body)).toContain('grant_type=client_credentials'); + }); + + it('refuses to act without credentials', async () => { + await expect(adapter.connect(ctx({ secret: () => undefined }), {})) + .rejects.toThrow(/NETCUP_SCP_CLIENT_ID/); + }); + + it('quotes from the published price list in EUR', async () => { + const quote = await adapter.quote(ctx(), { kind: 'cpu-vps', cpu: 8, memory: 16 }, {}); + expect(quote).toMatchObject({ sku: 'VPS 2000 G12', monthly: 19.25, currency: 'EUR', spot: false }); + expect(quote.hourly).toBeCloseTo(19.25 / 730, 4); + }); + + it('picks the cheapest plan that satisfies the spec', () => { + expect(pickPlan({ kind: 'cpu-vps', memory: 8 })?.sku).toBe('VPS 1000 G12'); + expect(pickPlan({ kind: 'cpu-vps', memory: 9 })?.sku).toBe('VPS 2000 G12'); + expect(pickPlan({ kind: 'cpu-vps', memory: 512 })).toBeNull(); + }); + + it('never adopts a server that already has an OS installed', () => { + const servers = [ + { id: 1, name: 'v1', disabled: false, template: { id: 7, name: 'Ubuntu 24.04' } }, + { id: 2, name: 'v2', disabled: false, template: null }, + { id: 3, name: 'v3', disabled: true, template: null }, + ]; + expect(adoptable(servers).map(s => s.id)).toEqual([2]); + }); + + it('filters adoption candidates by prefix', () => { + const servers = [ + { id: 1, name: 'pit-box', disabled: false, template: null }, + { id: 2, name: 'other', disabled: false, template: null }, + ]; + expect(adoptable(servers, 'pit').map(s => s.id)).toEqual([1]); + }); + + it('tells you to go buy one when nothing is adoptable', async () => { + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce(TOKEN) + .mockResolvedValueOnce(jsonResponse([{ id: 1, name: 'v1', disabled: false, template: { id: 7, name: 'Ubuntu' } }]))); + + await expect(adapter.provision(ctx(), { kind: 'cpu-vps', memory: 16 }, {})) + .rejects.toThrow(/no order API[\s\S]*VPS 2000 G12/); + }); + + it('refuses to guess between multiple uninstalled servers', async () => { + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce(TOKEN) + .mockResolvedValueOnce(jsonResponse([ + { id: 1, name: 'v1', disabled: false, template: null }, + { id: 2, name: 'v2', disabled: false, template: null }, + ]))); + + await expect(adapter.provision(ctx(), { kind: 'cpu-vps' }, {})) + .rejects.toThrow(/refusing to guess/); + }); + + it('installs the image with ssh keys and the custom script', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(TOKEN) + .mockResolvedValueOnce(jsonResponse([{ id: 42, name: 'v42', nickname: 'pit', disabled: false, template: null }])) + .mockResolvedValueOnce(jsonResponse([{ id: 9, name: 'Ubuntu 24.04 LTS', alias: 'Ubuntu 24.04' }])) + .mockResolvedValueOnce(jsonResponse({})) + .mockResolvedValueOnce(jsonResponse({ + id: 42, name: 'v42', disabled: false, + serverLiveInfo: { state: 'ON', cpuCount: 8, maxServerMemoryInMiB: 16384 }, + ipv4Addresses: [{ id: 1, ip: '203.0.113.9' }], + site: { id: 2, city: 'Nuremberg' }, + maxCpuCount: 8, + })); + vi.stubGlobal('fetch', fetchMock); + + const instance = await adapter.provision( + ctx(), + { kind: 'cpu-vps', sshKeyIds: ['5'], tags: ['dev.moshcode.sh'] }, + { customScript: '#!/bin/bash\nroot-ubuntu.sh' }, + ); + + const installBody = JSON.parse(String(fetchMock.mock.calls[3]![1].body)); + expect(installBody).toMatchObject({ + imageFlavourId: 9, + hostname: 'dev.moshcode.sh', + sshKeyIds: [5], + sshPasswordAuthentication: false, + customScript: '#!/bin/bash\nroot-ubuntu.sh', + }); + expect(instance).toMatchObject({ id: '42', status: 'provisioning', publicIp: '203.0.113.9', currency: 'EUR' }); + }); + + it('leaves password auth on when no ssh key is supplied', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(TOKEN) + .mockResolvedValueOnce(jsonResponse([{ id: 42, name: 'v42', disabled: false, template: null }])) + .mockResolvedValueOnce(jsonResponse([{ id: 9, name: 'Ubuntu 24.04', alias: 'Ubuntu 24.04' }])) + .mockResolvedValueOnce(jsonResponse({})) + .mockResolvedValueOnce(jsonResponse({ id: 42, name: 'v42', disabled: false })); + vi.stubGlobal('fetch', fetchMock); + + await adapter.provision(ctx(), { kind: 'cpu-vps' }, {}); + const body = JSON.parse(String(fetchMock.mock.calls[3]![1].body)); + expect(body.sshPasswordAuthentication).toBe(true); + expect(body.sshKeyIds).toBeUndefined(); + }); + + it('does not install anything on a dry run', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(TOKEN) + .mockResolvedValueOnce(jsonResponse([{ id: 42, name: 'v42', disabled: false, template: null }])) + .mockResolvedValueOnce(jsonResponse([{ id: 9, name: 'Ubuntu 24.04', alias: 'Ubuntu 24.04' }])); + vi.stubGlobal('fetch', fetchMock); + + const instance = await adapter.provision(ctx({ dryRun: true }), { kind: 'cpu-vps' }, {}); + expect(instance.status).toBe('provisioning'); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'POST' && String(init.body).includes('imageFlavourId'))).toBe(false); + }); + + it('fails loudly on destroy instead of pretending to cancel', async () => { + await expect(adapter.destroy(ctx(), '42', {})) + .rejects.toThrow(/no cancel endpoint exists[\s\S]*keeps billing/); + }); + + it('maps live state and location into the instance shape', () => { + const instance = serverToInstance({ + id: 42, name: 'v42', nickname: 'pit', disabled: false, template: { id: 7, name: 'Ubuntu 24.04' }, + serverLiveInfo: { state: 'OFF', cpuCount: 8, maxServerMemoryInMiB: 16384 }, + ipv4Addresses: [{ id: 1, ip: '203.0.113.9' }], + site: { id: 2, city: 'Nuremberg' }, + maxCpuCount: 8, + }); + expect(instance).toMatchObject({ + id: '42', kind: 'cpu-vps', status: 'stopped', publicIp: '203.0.113.9', + region: 'Nuremberg', sku: 'VPS 2000 G12', currency: 'EUR', + }); + expect(instance.metadata).toMatchObject({ nickname: 'pit', installed: true }); + }); + + it('reports a disabled server as stopped', () => { + expect(serverToInstance({ id: 1, name: 'v1', disabled: true }).status).toBe('stopped'); + }); + + it('prefers an Ubuntu LTS image when none is requested', () => { + const flavours = [ + { id: 1, name: 'Debian 12', alias: 'Debian 12' }, + { id: 2, name: 'Ubuntu 24.04 LTS', alias: 'Ubuntu 24.04 LTS' }, + ]; + expect(pickImage(flavours)?.id).toBe(2); + expect(pickImage(flavours, 'debian')?.id).toBe(1); + expect(pickImage(flavours, 'plan9')).toBeNull(); + }); + + it('normalizes hostnames netcup would reject', () => { + expect(sanitizeHostname('Scrambled Eggs!')).toBe('scrambled-eggs'); + expect(sanitizeHostname('dev.moshcode.sh')).toBe('dev.moshcode.sh'); + expect(sanitizeHostname('!!!')).toBe('sh1pt-host'); + }); + + it('surfaces the API error message on failure', async () => { + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce(TOKEN) + .mockResolvedValueOnce({ ok: false, status: 403, text: async () => JSON.stringify({ message: 'ip not allowed' }) })); + + await expect(adapter.status(ctx(), '42', {})).rejects.toThrow(/403 ip not allowed/); + }); + + it('reports an auth failure distinctly from an API failure', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ ok: false, status: 401, text: async () => 'invalid_client' })); + await expect(adapter.list(ctx(), {})).rejects.toThrow(/netcup auth failed: 401/); + }); +}); diff --git a/packages/cloud/netcup/src/index.ts b/packages/cloud/netcup/src/index.ts new file mode 100644 index 00000000..fa694610 --- /dev/null +++ b/packages/cloud/netcup/src/index.ts @@ -0,0 +1,509 @@ +import { defineCloud, tokenSetup, type Instance, type Quote, type InstanceSpec } from '@profullstack/sh1pt-core'; + +// netcup — Server Control Panel (SCP) REST API. +// +// netcup's model differs from every other adapter here: servers are BOUGHT in +// the Customer Control Panel (a checkout with a contract), and the SCP API only +// ever sees servers that already exist on the account. There is no order +// endpoint and no cancel endpoint — the SOAP webservice never had one, and the +// REST API that replaced it on 2026-04-30 does not either (63 paths, none of +// them create or delete a server). +// +// So `provision` here means ADOPT: take a server already on the account that +// has no OS installed, install one on it, and hand it back as an Instance. That +// is the real netcup workflow, and it is genuinely useful — `POST /servers/{id}/image` +// takes an ssh key list and a `customScript`, so a provision can land a fully +// configured box in one call. +// +// API docs: https://www.netcup.com/en/helpcenter/documentation/server/rest-api +// OpenAPI: https://www.servercontrolpanel.de/scp-core/api/v1/openapi +interface Config { + defaultImage?: string; // image flavour alias/name, e.g. 'Ubuntu 24.04' + // Adopt only servers whose name/nickname matches this prefix. Without it, + // provision refuses to touch anything when the account holds more than one + // uninstalled server — wiping the wrong box is not a recoverable mistake. + adoptPrefix?: string; + // Shell script handed to the installer, run on first boot. This is where + // root-ubuntu.sh goes. + customScript?: string; +} + +const API = 'https://www.servercontrolpanel.de/scp-core/api/v1'; +const TOKEN_URL = 'https://www.servercontrolpanel.de/realms/scp/protocol/openid-connect/token'; + +// netcup publishes no pricing endpoint, so quotes come from the published +// price list. Monthly is the real number — netcup bills monthly contracts, not +// by the hour — and hourly is derived only to satisfy the Quote shape. +// Prices in EUR incl. 19% VAT, verified 2026-08-16. +const PRICES: Array<{ sku: string; cpu: number; memory: number; storage: number; monthly: number }> = [ + { sku: 'VPS 500 G12', cpu: 2, memory: 2, storage: 64, monthly: 3.97 }, + { sku: 'VPS 1000 G12', cpu: 4, memory: 8, storage: 256, monthly: 10.37 }, + { sku: 'VPS 2000 G12', cpu: 8, memory: 16, storage: 512, monthly: 19.25 }, + { sku: 'VPS 4000 G12', cpu: 12, memory: 32, storage: 1024, monthly: 32.41 }, + { sku: 'VPS 8000 G12', cpu: 16, memory: 64, storage: 2048, monthly: 47.95 }, +]; + +const HOURS_PER_MONTH = 730; + +// ── Response shapes ────────────────────────────────────────────── + +type ServerState = 'ON' | 'OFF' | 'SUSPENDED'; + +interface NetcupServerTemplate { + id: number; + name: string; +} + +interface NetcupServerListItem { + id: number; + name: string; + hostname?: string | null; + nickname?: string | null; + disabled: boolean; + template?: NetcupServerTemplate | null; +} + +interface NetcupServerInfo { + state: ServerState; + uptimeInSeconds?: number; + currentServerMemoryInMiB?: number; + maxServerMemoryInMiB?: number; + cpuCount?: number; + template?: string | null; +} + +interface NetcupIPv4 { + id: number; + ip: string; + netmask?: string; + gateway?: string | null; +} + +interface NetcupSite { + id: number; + city: string; +} + +interface NetcupServer extends NetcupServerListItem { + serverLiveInfo?: NetcupServerInfo | null; + ipv4Addresses?: NetcupIPv4[]; + site?: NetcupSite; + maxCpuCount?: number; + disksAvailableSpaceInMiB?: number; + architecture?: 'AMD64' | 'ARM64'; +} + +interface NetcupImageFlavour { + id: number; + name: string; + alias: string; +} + +interface NetcupSshKey { + id: number; + name?: string; +} + +interface NetcupTokenResponse { + access_token: string; + expires_in: number; +} + +// ── Adapter ────────────────────────────────────────────────────── + +export default defineCloud({ + id: 'cloud-netcup', + label: 'netcup (VPS, Root Server — adopt & install; ordering is manual)', + supports: ['cpu-vps'], + + async connect(ctx, _config) { + requireCredentials(ctx); + ctx.log('netcup connect · requesting token...'); + const token = await accessToken(ctx); + const servers = await netcupRequest(ctx, 'GET', '/servers', undefined, token); + const accountId = ctx.secret('NETCUP_SCP_CLIENT_ID') ?? ctx.secret('NETCUP_SCP_USERNAME') ?? 'netcup-account'; + ctx.log(`netcup connected · account=${accountId} · servers=${servers.length}`); + return { accountId }; + }, + + async quote(ctx, spec, _config) { + const match = pickPlan(spec); + if (!match) { + ctx.log(`netcup quote · nothing in the published price list satisfies cpu=${spec.cpu ?? '-'} memory=${spec.memory ?? '-'}GB storage=${spec.storage ?? '-'}GB`, 'warn'); + return { hourly: 0, monthly: 0, currency: 'EUR', provider: 'netcup', sku: 'none', spot: false }; + } + ctx.log(`netcup quote · ${match.sku} · €${match.monthly.toFixed(2)}/mo (list price — netcup bills monthly, not hourly)`); + return { + hourly: round4(match.monthly / HOURS_PER_MONTH), + monthly: match.monthly, + currency: 'EUR', + provider: 'netcup', + sku: match.sku, + spot: false, + } satisfies Quote; + }, + + // Adopt an already-purchased, not-yet-installed server and install an OS on + // it. Never touches a server that already has a template — that is somebody's + // running box, and installing over it destroys the disk. + async provision(ctx, spec, config) { + requireCredentials(ctx); + const token = await accessToken(ctx); + const servers = await netcupRequest(ctx, 'GET', '/servers', undefined, token); + + const candidates = adoptable(servers, config.adoptPrefix); + if (candidates.length === 0) { + throw new Error(orderInstructions(spec, config.adoptPrefix, servers.length)); + } + if (candidates.length > 1 && !config.adoptPrefix) { + throw new Error( + `netcup provision: ${candidates.length} servers on the account have no OS installed ` + + `(${candidates.map(s => s.name).join(', ')}). Installing wipes the target disk, so refusing to guess. ` + + `Set adoptPrefix in the provider config to name which one to take.`, + ); + } + + const target = candidates[0]!; + const flavours = await netcupRequest(ctx, 'GET', `/servers/${target.id}/imageflavours`, undefined, token); + const image = pickImage(flavours, spec.image ?? config.defaultImage); + if (!image) { + throw new Error( + `netcup provision: no image flavour matches "${spec.image ?? config.defaultImage ?? '(unset)'}". ` + + `Available: ${flavours.map(f => f.alias || f.name).join(', ')}`, + ); + } + + const hostname = sanitizeHostname(spec.tags?.[0] ?? target.nickname ?? target.name); + ctx.log(`netcup provision · adopting server ${target.name} (id=${target.id}) · image=${image.alias || image.name} · hostname=${hostname}`); + + if (ctx.dryRun) { + ctx.log('netcup provision · dry run — no image installed'); + return stubInstance(String(target.id), 'provisioning'); + } + + const sshKeyIds = await resolveSshKeyIds(ctx, spec.sshKeyIds, token); + await netcupRequest(ctx, 'POST', `/servers/${target.id}/image`, { + imageFlavourId: image.id, + hostname, + rootPartitionFullDiskSize: true, + ...(sshKeyIds.length ? { sshKeyIds } : {}), + // No key means password auth has to stay on, or the box is unreachable. + sshPasswordAuthentication: sshKeyIds.length === 0, + ...(config.customScript ? { customScript: config.customScript } : {}), + }, token); + + ctx.log(`netcup provision · image install started on ${target.name} — poll status for readiness`); + const detail = await netcupRequest(ctx, 'GET', `/servers/${target.id}`, undefined, token); + return serverToInstance(detail, 'provisioning'); + }, + + async list(ctx, _config) { + requireCredentials(ctx); + const token = await accessToken(ctx); + ctx.log('netcup list · fetching servers'); + const servers = await netcupRequest(ctx, 'GET', '/servers', undefined, token); + + const detailed = await Promise.all(servers.map(async (s) => { + try { + return serverToInstance(await netcupRequest(ctx, 'GET', `/servers/${s.id}`, undefined, token)); + } catch { + // A server that errors on detail still exists and still bills. Report + // it from the list shape rather than dropping it from the inventory. + ctx.log(`netcup list · detail fetch failed for ${s.name}, reporting from list`, 'warn'); + return serverToInstance(s); + } + })); + return detailed; + }, + + // netcup has no cancel endpoint — a server is a monthly contract, terminated + // from the Customer Control Panel. Powering it off would leave the bill + // running while reporting success, so this fails loudly instead. + async destroy(ctx, instanceId, _config) { + ctx.log(`netcup destroy · refusing · ${instanceId}`, 'error'); + throw new Error( + `netcup cannot cancel server ${instanceId} over the API — no cancel endpoint exists. ` + + `A netcup server is a monthly contract: terminate it at https://www.customercontrolpanel.de ` + + `(Products → your server → Cancel). Until you do, it keeps billing. ` + + `To wipe it without cancelling, reinstall via provision.`, + ); + }, + + async status(ctx, instanceId, _config) { + requireCredentials(ctx); + const token = await accessToken(ctx); + ctx.log(`netcup status · ${instanceId}`); + const server = await netcupRequest(ctx, 'GET', `/servers/${instanceId}`, undefined, token); + return serverToInstance(server); + }, + + setup: tokenSetup({ + secretKey: 'NETCUP_SCP_CLIENT_SECRET', + label: 'netcup SCP', + vendorDocUrl: 'https://www.netcup.com/en/helpcenter/documentation/server/rest-api', + steps: [ + 'Log in to https://www.servercontrolpanel.de', + 'Open Options → REST API and enable API access', + 'Create API credentials — note the client id and client secret', + 'Optionally restrict access by IP (Options → REST API → IP filter)', + 'Run: sh1pt secret set NETCUP_SCP_CLIENT_ID ', + 'Run: sh1pt secret set NETCUP_SCP_CLIENT_SECRET ', + 'Servers are ORDERED at netcup.com — there is no order API. Buy the VPS first, then provision adopts it.', + ], + fields: [ + { key: 'defaultImage', message: 'Default image flavour (e.g. Ubuntu 24.04):' }, + { key: 'adoptPrefix', message: 'Only adopt servers whose name starts with (blank = require exactly one uninstalled server):' }, + ], + }), +}); + +// ── Helpers ────────────────────────────────────────────────────── + +function requireCredentials(ctx: { secret(k: string): string | undefined }): void { + const hasClient = ctx.secret('NETCUP_SCP_CLIENT_ID') && ctx.secret('NETCUP_SCP_CLIENT_SECRET'); + const hasPassword = ctx.secret('NETCUP_SCP_USERNAME') && ctx.secret('NETCUP_SCP_PASSWORD'); + if (!hasClient && !hasPassword) { + throw new Error( + 'netcup credentials not in vault — set NETCUP_SCP_CLIENT_ID + NETCUP_SCP_CLIENT_SECRET ' + + '(SCP → Options → REST API), or NETCUP_SCP_USERNAME (your CCP customer number) + NETCUP_SCP_PASSWORD', + ); + } +} + +export function orderInstructions(spec: InstanceSpec, adoptPrefix: string | undefined, serverCount: number): string { + const plan = pickPlan(spec); + const which = plan ? `${plan.sku} (€${plan.monthly.toFixed(2)}/mo)` : 'a VPS matching your spec'; + const scoped = adoptPrefix ? ` matching prefix "${adoptPrefix}"` : ''; + return ( + `netcup provision: no server without an OS${scoped} on this account (${serverCount} server(s) seen). ` + + `netcup has no order API — servers are bought through checkout, not provisioned. ` + + `Order ${which} at https://www.netcup.com/en/server/vps, wait for the SCP welcome mail, ` + + `then re-run this command and it will adopt the new server and install the image.` + ); +} + +export function adoptable(servers: NetcupServerListItem[], adoptPrefix?: string): NetcupServerListItem[] { + return servers.filter((s) => { + if (s.disabled) return false; + // A template means an OS is already installed. Adopting it would wipe it. + if (s.template) return false; + if (!adoptPrefix) return true; + const p = adoptPrefix.toLowerCase(); + return (s.name?.toLowerCase().startsWith(p) ?? false) || (s.nickname?.toLowerCase().startsWith(p) ?? false); + }); +} + +export function pickPlan(spec: InstanceSpec): (typeof PRICES)[number] | null { + let candidates = PRICES.slice(); + if (spec.cpu) candidates = candidates.filter(p => p.cpu >= spec.cpu!); + if (spec.memory) candidates = candidates.filter(p => p.memory >= spec.memory!); + if (spec.storage) candidates = candidates.filter(p => p.storage >= spec.storage!); + if (spec.maxHourlyPrice) { + candidates = candidates.filter(p => p.monthly / HOURS_PER_MONTH <= spec.maxHourlyPrice!); + } + candidates.sort((a, b) => a.monthly - b.monthly); + return candidates[0] ?? null; +} + +export function pickImage(flavours: NetcupImageFlavour[], wanted?: string): NetcupImageFlavour | null { + if (!flavours.length) return null; + if (!wanted) { + return flavours.find(f => /ubuntu/i.test(f.alias || f.name) && /24\.04|lts/i.test(f.alias || f.name)) + ?? flavours.find(f => /ubuntu/i.test(f.alias || f.name)) + ?? flavours[0]!; + } + const w = wanted.toLowerCase(); + return flavours.find(f => (f.alias || '').toLowerCase() === w || (f.name || '').toLowerCase() === w) + ?? flavours.find(f => (f.alias || '').toLowerCase().includes(w) || (f.name || '').toLowerCase().includes(w)) + ?? null; +} + +// netcup validates hostnames against a strict pattern; a rejected hostname +// fails the whole install, so normalize rather than pass through. +export function sanitizeHostname(raw: string): string { + const cleaned = raw + .toLowerCase() + .replace(/[^a-z0-9.-]/g, '-') + .replace(/-+/g, '-') + .replace(/^[-.]+|[-.]+$/g, '') + .slice(0, 63); + return cleaned || 'sh1pt-host'; +} + +export function serverToInstance(s: NetcupServer | NetcupServerListItem, override?: Instance['status']): Instance { + const detail = s as NetcupServer; + const live = detail.serverLiveInfo; + const stateMap: Record = { + ON: 'running', + OFF: 'stopped', + SUSPENDED: 'stopped', + }; + const status: Instance['status'] = override + ?? (s.disabled ? 'stopped' : live?.state ? stateMap[live.state] : 'provisioning'); + + const ip = detail.ipv4Addresses?.find(a => a.ip)?.ip; + const memory = live?.maxServerMemoryInMiB ? Math.round(live.maxServerMemoryInMiB / 1024) : undefined; + const plan = PRICES.find(p => p.cpu === (detail.maxCpuCount ?? live?.cpuCount) && p.memory === memory); + + return { + id: String(s.id), + kind: 'cpu-vps', + status, + publicIp: ip, + // netcup exposes no creation timestamp on a server, and inventing one here + // would be worse than reporting the epoch honestly. + createdAt: new Date(0).toISOString(), + hourlyRate: plan ? round4(plan.monthly / HOURS_PER_MONTH) : 0, + currency: 'EUR', + sku: plan?.sku ?? detail.template?.name ?? s.template?.name ?? undefined, + region: detail.site?.city, + metadata: { + name: s.name, + ...(s.nickname ? { nickname: s.nickname } : {}), + ...(s.hostname ? { hostname: s.hostname } : {}), + ...(detail.architecture ? { architecture: detail.architecture } : {}), + ...(live?.uptimeInSeconds !== undefined ? { uptimeInSeconds: live.uptimeInSeconds } : {}), + installed: Boolean(s.template), + }, + }; +} + +function stubInstance(id: string, status: Instance['status']): Instance { + return { + id, + kind: 'cpu-vps', + status, + createdAt: new Date(0).toISOString(), + hourlyRate: 0, + currency: 'EUR', + }; +} + +async function resolveSshKeyIds( + ctx: { secret(k: string): string | undefined; log(msg: string, level?: 'info' | 'warn' | 'error'): void }, + requested: string[] | undefined, + token: string, +): Promise { + if (!requested?.length) return []; + const userId = ctx.secret('NETCUP_SCP_USER_ID'); + // Numeric ids can be used as-is; names need the account's key list to resolve. + const numeric = requested.filter(k => /^\d+$/.test(k)).map(Number); + const named = requested.filter(k => !/^\d+$/.test(k)); + if (!named.length) return numeric; + if (!userId) { + ctx.log(`netcup provision · cannot resolve ssh key name(s) ${named.join(', ')} without NETCUP_SCP_USER_ID — using numeric ids only`, 'warn'); + return numeric; + } + const keys = await netcupRequest(ctx, 'GET', `/users/${userId}/ssh-keys`, undefined, token); + for (const name of named) { + const hit = keys.find(k => k.name === name); + if (hit) numeric.push(hit.id); + else ctx.log(`netcup provision · ssh key "${name}" not found on the account`, 'warn'); + } + return numeric; +} + +// ── Transport ──────────────────────────────────────────────────── + +let tokenCache: { token: string; expiresAt: number } | null = null; + +export function resetTokenCache(): void { + tokenCache = null; +} + +async function accessToken(ctx: { secret(k: string): string | undefined; log(msg: string, level?: 'info' | 'warn' | 'error'): void }): Promise { + if (tokenCache && Date.now() < tokenCache.expiresAt) return tokenCache.token; + + const clientId = ctx.secret('NETCUP_SCP_CLIENT_ID'); + const clientSecret = ctx.secret('NETCUP_SCP_CLIENT_SECRET'); + const username = ctx.secret('NETCUP_SCP_USERNAME'); + const password = ctx.secret('NETCUP_SCP_PASSWORD'); + + const form = new URLSearchParams(); + if (clientId && clientSecret) { + form.set('grant_type', 'client_credentials'); + form.set('client_id', clientId); + form.set('client_secret', clientSecret); + } else if (username && password) { + form.set('grant_type', 'password'); + form.set('client_id', ctx.secret('NETCUP_SCP_CLIENT_ID') ?? 'scp-rest-api'); + form.set('username', username); + form.set('password', password); + } else { + throw new Error('netcup credentials not in vault — see `sh1pt setup cloud-netcup`'); + } + + const response = await fetch(TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: form.toString(), + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`netcup auth failed: ${response.status} ${text.slice(0, 300)}`); + } + const data = JSON.parse(text) as NetcupTokenResponse; + // Expire a minute early so a token never dies mid-request. + tokenCache = { token: data.access_token, expiresAt: Date.now() + Math.max(0, (data.expires_in - 60)) * 1000 }; + return data.access_token; +} + +async function netcupRequest( + ctx: { secret(k: string): string | undefined; log(msg: string, level?: 'info' | 'warn' | 'error'): void }, + method: string, + path: string, + body?: unknown, + token?: string, +): Promise { + const bearer = token ?? await accessToken(ctx); + const opts: RequestInit = { + method, + headers: { + Authorization: `Bearer ${bearer}`, + Accept: 'application/json', + ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), + }, + }; + if (body !== undefined) opts.body = JSON.stringify(stripUndefined(body)); + + const response = await fetch(`${API}${path}`, opts); + if (response.status === 204) return undefined as T; + + const text = await response.text(); + let data: unknown; + try { + data = text ? JSON.parse(text) : undefined; + } catch (error) { + if (!response.ok) data = { message: text || response.statusText }; + else throw error; + } + + if (!response.ok) { + throw new Error(`netcup ${method} ${path} failed: ${response.status} ${extractErrorMessage(data, response.statusText)}`); + } + return data as T; +} + +function extractErrorMessage(data: unknown, fallback: string): string { + if (typeof data === 'object' && data) { + const d = data as Record; + for (const key of ['message', 'error_description', 'error', 'detail', 'title']) { + if (typeof d[key] === 'string') return d[key] as string; + } + } + return fallback; +} + +function round4(n: number): number { + return Math.round(n * 10000) / 10000; +} + +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/netcup/tsconfig.json b/packages/cloud/netcup/tsconfig.json new file mode 100644 index 00000000..cf441478 --- /dev/null +++ b/packages/cloud/netcup/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 1802e9a8..9f214f25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -889,6 +889,12 @@ importers: specifier: workspace:* version: link:../../core + packages/cloud/netcup: + dependencies: + '@profullstack/sh1pt-core': + specifier: workspace:* + version: link:../../core + packages/cloud/nvidia: dependencies: '@profullstack/sh1pt-core':