From ddc74c64e481811b3ce029665dd268028f66ad9d Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 16 Aug 2026 11:35:29 +0000 Subject: [PATCH] =?UTF-8?q?feat(cloud):=20netcup=20adapter=20=E2=80=94=20a?= =?UTF-8?q?dopt=20&=20install,=20since=20netcup=20has=20no=20order=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit netcup sells servers as monthly contracts through checkout, not as API resources: 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 provision adopts — it takes a server already on the account with no OS installed and installs one via POST /servers/{id}/image, which carries hostname, sshKeyIds and a first-boot customScript, so one call lands a fully configured box. destroy throws rather than powering off, because reporting success while the contract keeps billing is worse than failing. Adoption is deliberately timid, since installing an image wipes the target disk: never a server with a template, never a disabled one, and never a guess between multiple candidates without an adoptPrefix. 18 adapter tests, registry test updated. Co-Authored-By: Claude Opus 5 (1M context) --- packages/cli/src/adapter-registry.ts | 2 +- packages/cloud/netcup/README.md | 98 +++++ packages/cloud/netcup/package.json | 37 ++ packages/cloud/netcup/src/index.test.ts | 208 ++++++++++ packages/cloud/netcup/src/index.ts | 509 ++++++++++++++++++++++++ packages/cloud/netcup/tsconfig.json | 5 + pnpm-lock.yaml | 6 + 7 files changed, 864 insertions(+), 1 deletion(-) create mode 100644 packages/cloud/netcup/README.md create mode 100644 packages/cloud/netcup/package.json create mode 100644 packages/cloud/netcup/src/index.test.ts create mode 100644 packages/cloud/netcup/src/index.ts create mode 100644 packages/cloud/netcup/tsconfig.json 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':