@puls-dev/hcloud 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/plugin.js ADDED
@@ -0,0 +1,43 @@
1
+ import { registerProvider, printSection, Config } from "@puls-dev/core";
2
+ import { listHCloudResources } from "./list.js";
3
+ export const hcloudPlugin = {
4
+ name: "hcloud",
5
+ isConfigured: (cfg) => !!cfg?.token,
6
+ list: listHCloudResources,
7
+ render: (inv) => {
8
+ const costStr = inv.totalMonthlyCost > 0 ? ` · $${inv.totalMonthlyCost.toFixed(2)}/mo` : "";
9
+ printSection(`Hetzner Cloud Servers · ${inv.servers.length}${costStr}`, inv.servers, [
10
+ { header: "Name", width: 24, render: (s) => s.name },
11
+ { header: "Location", width: 10, render: (s) => s.location },
12
+ { header: "Type", width: 10, render: (s) => s.serverType },
13
+ { header: "Status", width: 8, render: (s) => s.status },
14
+ { header: "IP", width: 15, render: (s) => s.ip ?? "-" },
15
+ {
16
+ header: "$/mo",
17
+ width: 8,
18
+ render: (s) => (s.monthlyCost > 0 ? `$${s.monthlyCost.toFixed(2)}` : "?"),
19
+ },
20
+ ]);
21
+ if (inv.firewalls.length > 0) {
22
+ printSection(`Hetzner Cloud Firewalls · ${inv.firewalls.length}`, inv.firewalls, [
23
+ { header: "Name", width: 32, render: (f) => f.name },
24
+ { header: "Servers", width: 8, render: (f) => String(f.serverCount) },
25
+ ]);
26
+ }
27
+ if (inv.networks.length > 0) {
28
+ printSection(`Hetzner Cloud Networks · ${inv.networks.length}`, inv.networks, [
29
+ { header: "Name", width: 24, render: (n) => n.name },
30
+ { header: "IP Range", width: 20, render: (n) => n.ipRange },
31
+ ]);
32
+ }
33
+ },
34
+ configure: (pOpts) => {
35
+ Config.set({
36
+ providers: {
37
+ ...Config.get().providers,
38
+ hcloud: pOpts,
39
+ },
40
+ });
41
+ }
42
+ };
43
+ registerProvider(hcloudPlugin);
@@ -0,0 +1,41 @@
1
+ import { BaseBuilder, Output } from '@puls-dev/core';
2
+ import { SSHKeyBuilder } from './ssh_key.js';
3
+ import { NetworkBuilder } from './network.js';
4
+ export declare class ServerBuilder extends BaseBuilder {
5
+ readonly out: {
6
+ ip: Output<string>;
7
+ id: Output<number>;
8
+ };
9
+ config: any;
10
+ private serverId?;
11
+ private resolvedIp?;
12
+ private sshKeyPath?;
13
+ private _sshUser?;
14
+ private _provision;
15
+ private _forceConfigCheck;
16
+ private _sshKeys;
17
+ private _networks;
18
+ private _userData?;
19
+ private log;
20
+ constructor(name: string);
21
+ private discoverServer;
22
+ getMonthlyCost(state?: any): number;
23
+ image(image: string): this;
24
+ location(loc: string): this;
25
+ serverType(type: string): this;
26
+ sshKey(keyPath: string): this;
27
+ sshUser(user: string): this;
28
+ private resolveUser;
29
+ sshKeys(keys: (SSHKeyBuilder | number | string)[]): this;
30
+ networks(nets: (NetworkBuilder | number)[]): this;
31
+ userData(data: string): this;
32
+ provision(...playbookPaths: (string | string[])[]): this;
33
+ forceConfigCheck(): this;
34
+ getDiff(existing: any): {
35
+ field: string;
36
+ declared: any;
37
+ live: any;
38
+ }[];
39
+ deploy(): Promise<any>;
40
+ destroy(): Promise<any>;
41
+ }
package/dist/server.js ADDED
@@ -0,0 +1,315 @@
1
+ import { homedir } from 'os';
2
+ import { OS_IMAGE, LOCATION, SERVER_TYPE } from './types/hcloud.js';
3
+ import { Config, BaseBuilder, Output, checkPort, runProvisioner, getFileHash, resourceContextStorage } from '@puls-dev/core';
4
+ import { SSHKeyBuilder } from './ssh_key.js';
5
+ import { NetworkBuilder } from './network.js';
6
+ import { getHCloudApi } from './api.js';
7
+ export class ServerBuilder extends BaseBuilder {
8
+ out = {
9
+ ip: new Output(),
10
+ id: new Output(),
11
+ };
12
+ config = {
13
+ image: OS_IMAGE.UBUNTU_24_04,
14
+ location: Config.get().providers.hcloud?.defaultLocation || LOCATION.NBG1,
15
+ server_type: SERVER_TYPE.CX22,
16
+ };
17
+ serverId;
18
+ resolvedIp;
19
+ sshKeyPath;
20
+ _sshUser;
21
+ _provision = [];
22
+ _forceConfigCheck = false;
23
+ _sshKeys = [];
24
+ _networks = [];
25
+ _userData;
26
+ log(msg) {
27
+ console.log(` 🖥️ [HCloud.Server] ${msg}`);
28
+ }
29
+ constructor(name) {
30
+ super(name);
31
+ this.discoveryPromise = this.discoverServer(name);
32
+ }
33
+ async discoverServer(name) {
34
+ const api = getHCloudApi();
35
+ const data = await api.get(`/servers?name=${encodeURIComponent(name)}`);
36
+ const match = data.servers.find(s => s.name === name) ?? null;
37
+ if (match) {
38
+ this.serverId = match.id;
39
+ this.resolvedIp = match.public_net?.ipv4?.ip;
40
+ if (this.serverId)
41
+ this.out.id.resolve(this.serverId);
42
+ if (this.resolvedIp)
43
+ this.out.ip.resolve(this.resolvedIp);
44
+ }
45
+ return match;
46
+ }
47
+ getMonthlyCost(state) {
48
+ const type = state ? (state.server_type?.name ?? state.server_type) : this.config.server_type;
49
+ const pricing = {
50
+ 'cx22': 3.60,
51
+ 'cpx11': 4.80,
52
+ 'cpx21': 7.90,
53
+ 'cpx31': 14.80,
54
+ 'cpx41': 29.60,
55
+ 'cpx51': 64.20,
56
+ 'cax11': 4.00,
57
+ 'cax21': 8.00,
58
+ 'cax31': 16.00,
59
+ 'cax41': 32.00,
60
+ };
61
+ return pricing[type] ?? 0;
62
+ }
63
+ image(image) {
64
+ this.config.image = image;
65
+ return this;
66
+ }
67
+ location(loc) {
68
+ this.config.location = loc;
69
+ return this;
70
+ }
71
+ serverType(type) {
72
+ this.config.server_type = type;
73
+ return this;
74
+ }
75
+ sshKey(keyPath) {
76
+ this.sshKeyPath = keyPath.replace('~', homedir());
77
+ return this;
78
+ }
79
+ sshUser(user) {
80
+ this._sshUser = user;
81
+ return this;
82
+ }
83
+ resolveUser() {
84
+ return (this._sshUser ??
85
+ process.env.HCLOUD_SSH_USER ??
86
+ Config.get().providers.hcloud?.sshUser ??
87
+ "root");
88
+ }
89
+ sshKeys(keys) {
90
+ this._sshKeys = keys;
91
+ return this;
92
+ }
93
+ networks(nets) {
94
+ this._networks = nets;
95
+ return this;
96
+ }
97
+ userData(data) {
98
+ this._userData = data;
99
+ return this;
100
+ }
101
+ provision(...playbookPaths) {
102
+ this._provision.push(...playbookPaths.flat());
103
+ return this;
104
+ }
105
+ forceConfigCheck() {
106
+ this._forceConfigCheck = true;
107
+ return this;
108
+ }
109
+ getDiff(existing) {
110
+ const diffs = [];
111
+ if (existing.server_type?.name !== this.config.server_type) {
112
+ diffs.push({ field: "serverType", declared: this.config.server_type, live: existing.server_type?.name });
113
+ }
114
+ if (existing.image?.name !== this.config.image && existing.image?.name !== null) {
115
+ // Note: Hetzner sometimes returns null image name for custom snapshots or older images
116
+ diffs.push({ field: "image", declared: this.config.image, live: existing.image?.name });
117
+ }
118
+ if (existing.datacenter?.location?.name !== this.config.location) {
119
+ diffs.push({ field: "location", declared: this.config.location, live: existing.datacenter?.location?.name });
120
+ }
121
+ return diffs;
122
+ }
123
+ async deploy() {
124
+ const dryRun = this.isDryRunActive();
125
+ const existing = await this.discoveryPromise;
126
+ const api = getHCloudApi();
127
+ const hasChanges = existing ? this.getDiff(existing).length > 0 : true;
128
+ if (await this.checkProtection(hasChanges))
129
+ return null;
130
+ // Provisioning Calculations
131
+ const labels = existing?.labels ?? {};
132
+ const appliedHashes = {};
133
+ for (const [k, v] of Object.entries(labels)) {
134
+ if (k.startsWith("puls-h-")) {
135
+ const playbookSlug = k.substring(7);
136
+ appliedHashes[playbookSlug] = v;
137
+ }
138
+ }
139
+ const declaredPlaybooksWithHashes = this._provision.map((p) => {
140
+ const baseName = p.split("/").pop() ?? p;
141
+ const slug = baseName.toLowerCase().replace(/[^a-z0-9_-]/g, '-');
142
+ return { path: p, slug, hash: getFileHash(p) };
143
+ });
144
+ const playbooksToRun = this._forceConfigCheck
145
+ ? declaredPlaybooksWithHashes
146
+ : declaredPlaybooksWithHashes.filter((p) => {
147
+ const appliedHash = appliedHashes[p.slug];
148
+ return !appliedHash || appliedHash !== p.hash;
149
+ });
150
+ const playbookRunRequired = playbooksToRun.length > 0;
151
+ if (dryRun) {
152
+ this.log(`Planning server "${this.name}"...`);
153
+ if (!existing) {
154
+ this.log(`[PLAN] Create server ${this.name} (${this.config.server_type} in ${this.config.location})`);
155
+ if (this._provision.length > 0) {
156
+ this.log(` └─ Provision: ${this._provision.join(", ")}`);
157
+ }
158
+ this.out.id.resolve(-1);
159
+ this.out.ip.resolve('0.0.0.0');
160
+ }
161
+ else if (hasChanges || playbookRunRequired) {
162
+ if (hasChanges) {
163
+ const diffs = this.getDiff(existing);
164
+ this.log(`[PLAN] Recreate server ${this.name} due to changes:`);
165
+ for (const d of diffs) {
166
+ this.log(` └─ ${d.field}: ${d.live} -> ${d.declared}`);
167
+ }
168
+ }
169
+ if (playbookRunRequired) {
170
+ this.log(`[PLAN] Run ${playbooksToRun.length} playbook changes on existing server:`);
171
+ for (const p of playbooksToRun) {
172
+ this.log(` └─ Playbook: ${p.path}`);
173
+ }
174
+ }
175
+ }
176
+ else {
177
+ this.log(`Server is up to date.`);
178
+ }
179
+ for (const sidecar of this.sidecars)
180
+ await sidecar.deploy();
181
+ return this.config;
182
+ }
183
+ this.log(`Finalizing server...`);
184
+ if (!existing || hasChanges) {
185
+ if (existing && hasChanges) {
186
+ this.log(`Server has drifted or changed critical fields. Re-creating...`);
187
+ await api.delete(`/servers/${existing.id}`);
188
+ await this.waitFor('server deletion to complete', async () => {
189
+ try {
190
+ const res = await api.get(`/servers?name=${encodeURIComponent(this.name)}`);
191
+ return !res.servers.some(s => s.name === this.name);
192
+ }
193
+ catch {
194
+ return true;
195
+ }
196
+ });
197
+ }
198
+ // Resolve SSH Keys
199
+ const sshKeys = [];
200
+ for (const k of this._sshKeys) {
201
+ if (k instanceof SSHKeyBuilder) {
202
+ sshKeys.push(await k.out.id.get());
203
+ }
204
+ else {
205
+ sshKeys.push(k);
206
+ }
207
+ }
208
+ // Resolve Networks
209
+ const networks = [];
210
+ for (const n of this._networks) {
211
+ if (n instanceof NetworkBuilder) {
212
+ networks.push(await n.out.id.get());
213
+ }
214
+ else {
215
+ networks.push(n);
216
+ }
217
+ }
218
+ // Create labels for initial playbooks
219
+ const initialLabels = {};
220
+ for (const p of declaredPlaybooksWithHashes) {
221
+ initialLabels[`puls-h-${p.slug}`] = p.hash;
222
+ }
223
+ const payload = {
224
+ name: this.name,
225
+ server_type: this.config.server_type,
226
+ image: this.config.image,
227
+ location: this.config.location,
228
+ labels: initialLabels,
229
+ };
230
+ if (sshKeys.length > 0)
231
+ payload.ssh_keys = sshKeys;
232
+ if (networks.length > 0)
233
+ payload.networks = networks;
234
+ if (this._userData)
235
+ payload.user_data = this._userData;
236
+ const res = await api.post('/servers', payload);
237
+ this.serverId = res.server.id;
238
+ this.out.id.resolve(res.server.id);
239
+ this.log(`Created server with ID ${this.serverId}. Waiting for action ${res.action.id}...`);
240
+ await api.waitForAction(res.action.id);
241
+ // Fetch server details to get IP
242
+ const s = await api.get(`/servers/${this.serverId}`);
243
+ this.resolvedIp = s.server.public_net?.ipv4?.ip;
244
+ if (this.resolvedIp) {
245
+ this.out.ip.resolve(this.resolvedIp);
246
+ this.log(`Server IP is ${this.resolvedIp}`);
247
+ }
248
+ if (this._provision.length > 0 && this.resolvedIp) {
249
+ await this.waitFor(`SSH on ${this.resolvedIp} to be ready`, () => checkPort(this.resolvedIp, 22), { intervalMs: 10_000, timeoutMs: 300_000 });
250
+ for (const playbook of this._provision) {
251
+ const keyPath = this.sshKeyPath ? this.sshKeyPath : undefined;
252
+ await runProvisioner(this.resolvedIp, this.resolveUser(), keyPath, playbook);
253
+ }
254
+ }
255
+ }
256
+ else {
257
+ // Existing server, no critical changes, check for playbook runs
258
+ if (playbookRunRequired) {
259
+ this.log(`Running ${playbooksToRun.length} playbook changes...`);
260
+ if (!this.resolvedIp) {
261
+ throw new Error(`IP not resolved for existing server "${this.name}"`);
262
+ }
263
+ await this.waitFor(`SSH on ${this.resolvedIp} to be ready`, () => checkPort(this.resolvedIp, 22), { intervalMs: 10_000, timeoutMs: 300_000 });
264
+ const currentLabels = { ...existing.labels };
265
+ for (const p of playbooksToRun) {
266
+ const keyPath = this.sshKeyPath ? this.sshKeyPath : undefined;
267
+ await runProvisioner(this.resolvedIp, this.resolveUser(), keyPath, p.path);
268
+ currentLabels[`puls-h-${p.slug}`] = p.hash;
269
+ // Update labels on Hetzner Cloud Server
270
+ await api.put(`/servers/${this.serverId}`, { labels: currentLabels });
271
+ }
272
+ this.log(`Playbooks applied successfully and metadata updated.`);
273
+ }
274
+ else {
275
+ this.log(`Server is up to date.`);
276
+ }
277
+ }
278
+ // Register host in context for downstream playbooks
279
+ const context = resourceContextStorage.getStore();
280
+ if (context && context.hosts) {
281
+ const activeIp = this.resolvedIp ?? "0.0.0.0";
282
+ if (!context.hosts.some(h => h.name === this.name)) {
283
+ context.hosts.push({
284
+ name: this.name,
285
+ ip: activeIp,
286
+ user: this.resolveUser(),
287
+ sshKey: this.sshKeyPath,
288
+ provider: "hcloud"
289
+ });
290
+ }
291
+ }
292
+ for (const sidecar of this.sidecars)
293
+ await sidecar.deploy();
294
+ return this.config;
295
+ }
296
+ async destroy() {
297
+ const dryRun = this.isDryRunActive();
298
+ await this.discoveryPromise;
299
+ if (!this.serverId) {
300
+ this.log(`Server not found, skipping destroy.`);
301
+ return { destroyed: null };
302
+ }
303
+ this.log(`Destroying server (id=${this.serverId})...`);
304
+ if (dryRun) {
305
+ this.log(`[PLAN] Would delete server id=${this.serverId}`);
306
+ }
307
+ else {
308
+ const api = getHCloudApi();
309
+ await api.delete(`/servers/${this.serverId}`);
310
+ this.log(`Deleted server.`);
311
+ }
312
+ await this.destroySidecars();
313
+ return { destroyed: this.name };
314
+ }
315
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,263 @@
1
+ import { test, describe, beforeEach, afterEach, mock } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import fs from 'node:fs';
4
+ import { ServerBuilder } from './server.js';
5
+ import { Config } from '@puls-dev/core';
6
+ import { OS_IMAGE, LOCATION, SERVER_TYPE } from './types/hcloud.js';
7
+ describe('ServerBuilder Unit Tests', () => {
8
+ let originalFetch;
9
+ let fetchCalls = [];
10
+ let mockResponses = {};
11
+ beforeEach(() => {
12
+ Config.set({
13
+ dryRun: false,
14
+ providers: {
15
+ hcloud: { token: 'fake-hcloud-token', defaultLocation: 'nbg1' }
16
+ }
17
+ });
18
+ originalFetch = globalThis.fetch;
19
+ fetchCalls = [];
20
+ mockResponses = {};
21
+ // Default mock response so discovery doesn't fail by default
22
+ mockResponses['GET /servers'] = {
23
+ status: 200,
24
+ body: { servers: [] }
25
+ };
26
+ globalThis.fetch = async (input, init) => {
27
+ const url = String(input);
28
+ const method = init?.method ?? 'GET';
29
+ const body = init?.body ? JSON.parse(init.body) : undefined;
30
+ const headers = init?.headers;
31
+ fetchCalls.push({ url, method, body, headers });
32
+ const matchKey = Object.keys(mockResponses)
33
+ .filter(key => {
34
+ const [mMethod, mPath] = key.split(' ');
35
+ return method === mMethod && url.includes(mPath);
36
+ })
37
+ .sort((a, b) => b.split(' ')[1].length - a.split(' ')[1].length)[0];
38
+ if (matchKey) {
39
+ const resp = mockResponses[matchKey];
40
+ return {
41
+ ok: resp.status >= 200 && resp.status < 300,
42
+ status: resp.status,
43
+ json: async () => resp.body,
44
+ text: async () => JSON.stringify(resp.body),
45
+ };
46
+ }
47
+ return {
48
+ ok: false,
49
+ status: 404,
50
+ json: async () => ({ error: { message: 'Not found' } }),
51
+ text: async () => 'Not found',
52
+ };
53
+ };
54
+ // Mock readFileSync so tests don't hit the real filesystem for SSH keys
55
+ mock.method(fs, 'readFileSync', () => {
56
+ return 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL... test@example.com';
57
+ });
58
+ });
59
+ afterEach(() => {
60
+ globalThis.fetch = originalFetch;
61
+ mock.restoreAll();
62
+ });
63
+ test('gracefully handles discovery when server does not exist', async () => {
64
+ const builder = new ServerBuilder('my-server');
65
+ const discoveryResult = await builder.discoveryPromise;
66
+ assert.strictEqual(discoveryResult, null);
67
+ assert.strictEqual(fetchCalls.length, 1);
68
+ assert.strictEqual(fetchCalls[0].method, 'GET');
69
+ assert.ok(fetchCalls[0].url.includes('/servers?name=my-server'));
70
+ });
71
+ test('discovers server successfully when it exists', async () => {
72
+ mockResponses['GET /servers'] = {
73
+ status: 200,
74
+ body: {
75
+ servers: [
76
+ {
77
+ id: 123,
78
+ name: 'my-server',
79
+ public_net: {
80
+ ipv4: { ip: '1.2.3.4' }
81
+ },
82
+ server_type: { name: 'cx22' },
83
+ image: { name: 'ubuntu-24.04' },
84
+ datacenter: { location: { name: 'nbg1' } }
85
+ }
86
+ ]
87
+ }
88
+ };
89
+ const builder = new ServerBuilder('my-server');
90
+ const discoveryResult = await builder.discoveryPromise;
91
+ assert.ok(discoveryResult);
92
+ assert.strictEqual(discoveryResult.id, 123);
93
+ assert.strictEqual(await builder.out.ip.get(), '1.2.3.4');
94
+ assert.strictEqual(await builder.out.id.get(), 123);
95
+ });
96
+ test('calculates correct monthly cost', async () => {
97
+ const builder = new ServerBuilder('my-server');
98
+ await builder.discoveryPromise;
99
+ builder.serverType(SERVER_TYPE.CX22);
100
+ assert.strictEqual(builder.getMonthlyCost(), 3.60);
101
+ builder.serverType(SERVER_TYPE.CPX31);
102
+ assert.strictEqual(builder.getMonthlyCost(), 14.80);
103
+ });
104
+ test('creates a server when it does not exist', async () => {
105
+ mockResponses['POST /servers'] = {
106
+ status: 201,
107
+ body: {
108
+ server: {
109
+ id: 456,
110
+ name: 'new-server',
111
+ public_net: { ipv4: { ip: '5.6.7.8' } }
112
+ },
113
+ action: { id: 7890 }
114
+ }
115
+ };
116
+ mockResponses['GET /actions/7890'] = {
117
+ status: 200,
118
+ body: { action: { status: 'success' } }
119
+ };
120
+ mockResponses['GET /servers/456'] = {
121
+ status: 200,
122
+ body: {
123
+ server: {
124
+ id: 456,
125
+ name: 'new-server',
126
+ public_net: { ipv4: { ip: '5.6.7.8' } }
127
+ }
128
+ }
129
+ };
130
+ const builder = new ServerBuilder('new-server')
131
+ .serverType(SERVER_TYPE.CX22)
132
+ .image(OS_IMAGE.UBUNTU_24_04)
133
+ .location(LOCATION.NBG1);
134
+ await builder.deploy();
135
+ assert.strictEqual(await builder.out.id.get(), 456);
136
+ assert.strictEqual(await builder.out.ip.get(), '5.6.7.8');
137
+ const postCall = fetchCalls.find(c => c.method === 'POST' && c.url.includes('/servers'));
138
+ assert.ok(postCall);
139
+ assert.strictEqual(postCall.body.name, 'new-server');
140
+ assert.strictEqual(postCall.body.server_type, 'cx22');
141
+ assert.strictEqual(postCall.body.image, 'ubuntu-24.04');
142
+ assert.strictEqual(postCall.body.location, 'nbg1');
143
+ });
144
+ test('detects drift and recreates server', async () => {
145
+ let serverExists = true;
146
+ globalThis.fetch = async (input, init) => {
147
+ const url = String(input);
148
+ const method = init?.method ?? 'GET';
149
+ const body = init?.body ? JSON.parse(init.body) : undefined;
150
+ fetchCalls.push({ url, method, body });
151
+ if (method === 'DELETE' && url.includes('/servers/123')) {
152
+ serverExists = false;
153
+ return {
154
+ ok: true,
155
+ status: 200,
156
+ json: async () => ({})
157
+ };
158
+ }
159
+ if (method === 'GET' && url.includes('/servers')) {
160
+ if (url.includes('/servers/456')) {
161
+ return {
162
+ ok: true,
163
+ status: 200,
164
+ json: async () => ({
165
+ server: {
166
+ id: 456,
167
+ name: 'my-server',
168
+ public_net: { ipv4: { ip: '5.6.7.8' } }
169
+ }
170
+ })
171
+ };
172
+ }
173
+ if (serverExists) {
174
+ return {
175
+ ok: true,
176
+ status: 200,
177
+ json: async () => ({
178
+ servers: [
179
+ {
180
+ id: 123,
181
+ name: 'my-server',
182
+ public_net: { ipv4: { ip: '1.2.3.4' } },
183
+ server_type: { name: 'cpx11' },
184
+ image: { name: 'ubuntu-22.04' },
185
+ datacenter: { location: { name: 'nbg1' } }
186
+ }
187
+ ]
188
+ })
189
+ };
190
+ }
191
+ else {
192
+ return {
193
+ ok: true,
194
+ status: 200,
195
+ json: async () => ({ servers: [] })
196
+ };
197
+ }
198
+ }
199
+ if (method === 'POST' && url.includes('/servers')) {
200
+ return {
201
+ ok: true,
202
+ status: 201,
203
+ json: async () => ({
204
+ server: {
205
+ id: 456,
206
+ name: 'my-server',
207
+ public_net: { ipv4: { ip: '5.6.7.8' } }
208
+ },
209
+ action: { id: 7890 }
210
+ })
211
+ };
212
+ }
213
+ if (method === 'GET' && url.includes('/actions/7890')) {
214
+ return {
215
+ ok: true,
216
+ status: 200,
217
+ json: async () => ({ action: { status: 'success' } })
218
+ };
219
+ }
220
+ return {
221
+ ok: false,
222
+ status: 404,
223
+ json: async () => ({ error: { message: 'Not found' } }),
224
+ };
225
+ };
226
+ const builder = new ServerBuilder('my-server')
227
+ .serverType(SERVER_TYPE.CX22)
228
+ .image(OS_IMAGE.UBUNTU_24_04)
229
+ .location(LOCATION.NBG1);
230
+ await builder.deploy();
231
+ const deleteCall = fetchCalls.find(c => c.method === 'DELETE' && c.url.includes('/servers/123'));
232
+ assert.ok(deleteCall);
233
+ const postCall = fetchCalls.find(c => c.method === 'POST' && c.url.includes('/servers'));
234
+ assert.ok(postCall);
235
+ assert.strictEqual(postCall.body.server_type, 'cx22');
236
+ assert.strictEqual(postCall.body.image, 'ubuntu-24.04');
237
+ });
238
+ test('destroys a server when it exists', async () => {
239
+ mockResponses['GET /servers'] = {
240
+ status: 200,
241
+ body: {
242
+ servers: [
243
+ {
244
+ id: 123,
245
+ name: 'my-server',
246
+ public_net: { ipv4: { ip: '1.2.3.4' } },
247
+ server_type: { name: 'cx22' },
248
+ image: { name: 'ubuntu-24.04' },
249
+ datacenter: { location: { name: 'nbg1' } }
250
+ }
251
+ ]
252
+ }
253
+ };
254
+ mockResponses['DELETE /servers/123'] = {
255
+ status: 200,
256
+ body: {}
257
+ };
258
+ const builder = new ServerBuilder('my-server');
259
+ await builder.destroy();
260
+ const deleteCall = fetchCalls.find(c => c.method === 'DELETE' && c.url.includes('/servers/123'));
261
+ assert.ok(deleteCall);
262
+ });
263
+ });
@@ -0,0 +1,21 @@
1
+ import { BaseBuilder } from '@puls-dev/core';
2
+ import { Output } from '@puls-dev/core';
3
+ export declare class SSHKeyBuilder extends BaseBuilder {
4
+ readonly out: {
5
+ id: Output<number>;
6
+ fingerprint: Output<string>;
7
+ };
8
+ private _publicKey?;
9
+ private keyId?;
10
+ private log;
11
+ constructor(name: string);
12
+ private discoverKey;
13
+ publicKey(key: string): this;
14
+ getDiff(existing: any): {
15
+ field: string;
16
+ declared: string;
17
+ live: any;
18
+ }[];
19
+ deploy(): Promise<void>;
20
+ destroy(): Promise<void>;
21
+ }