Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/cli/src/adapter-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
98 changes: 98 additions & 0 deletions packages/cloud/netcup/README.md
Original file line number Diff line number Diff line change
@@ -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 <client id>
sh1pt secret set NETCUP_SCP_CLIENT_SECRET <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
```
37 changes: 37 additions & 0 deletions packages/cloud/netcup/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
208 changes: 208 additions & 0 deletions packages/cloud/netcup/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
Loading
Loading