@puls-dev/hcloud 0.5.3 → 0.5.4

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/README.md CHANGED
@@ -10,20 +10,20 @@ This package is the official Hetzner Cloud provider plug-in for Puls. It manages
10
10
 
11
11
  ## Available Builders
12
12
 
13
- * **\`HCloud.Server\`**: Provision cloud servers (analogous to Droplets) with support for SSH keys, server types, locations, and our signature Ansible playbook provisioning.
14
- * **\`HCloud.SSHKey\`**: Register SSH public keys on your Hetzner Cloud account.
15
- * **\`HCloud.Network\`**: Create private networks (VPCs) with custom IP ranges.
16
- * **\`HCloud.Firewall\`**: Define inbound/outbound firewall rules.
13
+ * **`HCloud.Server`**: Provision cloud servers (analogous to Droplets) with support for SSH keys, server types, locations, and our signature Ansible playbook provisioning.
14
+ * **`HCloud.SSHKey`**: Register SSH public keys on your Hetzner Cloud account.
15
+ * **`HCloud.Network`**: Create private networks (VPCs) with custom IP ranges.
16
+ * **`HCloud.Firewall`**: Define inbound/outbound firewall rules.
17
17
 
18
18
  ## Installation
19
19
 
20
- \`\`\`bash
20
+ ```bash
21
21
  npm install @puls-dev/core @puls-dev/hcloud
22
- \`\`\`
22
+ ```
23
23
 
24
24
  ## Quick Example
25
25
 
26
- \`\`\`typescript
26
+ ```typescript
27
27
  import { Stack, Deploy } from "@puls-dev/core";
28
28
  import { HCloud, SERVER_TYPE, LOCATION, OS_IMAGE } from "@puls-dev/hcloud";
29
29
 
@@ -43,13 +43,13 @@ class MyStack extends Stack {
43
43
  .networks([this.net])
44
44
  .provision(["playbooks/web.yml"]);
45
45
  }
46
- \`\`\`
46
+ ```
47
47
 
48
48
  ## Authentication
49
49
 
50
50
  Declare your Hetzner Cloud API token in your \`.env\` file:
51
- \`\`\`bash
51
+ ```bash
52
52
  HCLOUD_TOKEN=your-hetzner-cloud-api-token
53
- \`\`\`
53
+ ```
54
54
 
55
- Learn more at **[pulsdev.io/providers/hcloud](https://pulsdev.io/providers/hcloud.md)**.
55
+ Learn more at **[pulsdev.io/providers/hcloud](https://pulsdev.io/providers/hcloud)**.
package/dist/api.js CHANGED
@@ -78,6 +78,31 @@ export class HCloudApiClient {
78
78
  firewalls: []
79
79
  };
80
80
  }
81
+ if (path.includes("/volumes")) {
82
+ return {
83
+ volume: {
84
+ id: 8080,
85
+ name: body?.name ?? "mock-volume",
86
+ size: body?.size ?? 10,
87
+ linux_device: "/dev/disk/by-id/scsi-0HC_Volume_8080"
88
+ },
89
+ volumes: [],
90
+ action: { id: 7890, status: "success" }
91
+ };
92
+ }
93
+ if (path.includes("/load_balancers")) {
94
+ return {
95
+ load_balancer: {
96
+ id: 9090,
97
+ name: body?.name ?? "mock-lb",
98
+ public_net: {
99
+ ipv4: { ip: "1.2.3.4" }
100
+ }
101
+ },
102
+ load_balancers: [],
103
+ action: { id: 7890, status: "success" }
104
+ };
105
+ }
81
106
  if (path.includes("/actions/")) {
82
107
  return { action: { id: 7890, status: "success" } };
83
108
  }
package/dist/index.d.ts CHANGED
@@ -3,6 +3,8 @@ import { ServerBuilder } from "./server.js";
3
3
  import { SSHKeyBuilder } from "./ssh_key.js";
4
4
  import { NetworkBuilder } from "./network.js";
5
5
  import { FirewallBuilder } from "./firewall.js";
6
+ import { VolumeBuilder } from "./volume.js";
7
+ import { LoadBalancerBuilder } from "./load_balancer.js";
6
8
  export declare const HCloud: {
7
9
  init: (opts: {
8
10
  token: string;
@@ -13,11 +15,15 @@ export declare const HCloud: {
13
15
  SSHKey: (name: string) => SSHKeyBuilder;
14
16
  Network: (name: string) => NetworkBuilder;
15
17
  Firewall: (name: string) => FirewallBuilder;
18
+ Volume: (name: string) => VolumeBuilder;
19
+ LoadBalancer: (name: string) => LoadBalancerBuilder;
16
20
  };
17
21
  export * from "./types/hcloud.js";
18
22
  export { ServerBuilder } from "./server.js";
19
23
  export { SSHKeyBuilder } from "./ssh_key.js";
20
24
  export { NetworkBuilder } from "./network.js";
21
25
  export { FirewallBuilder } from "./firewall.js";
26
+ export { VolumeBuilder } from "./volume.js";
27
+ export { LoadBalancerBuilder } from "./load_balancer.js";
22
28
  export { HCloudApiClient, getHCloudApi } from "./api.js";
23
29
  export { listHCloudResources } from "./list.js";
package/dist/index.js CHANGED
@@ -4,6 +4,8 @@ import { ServerBuilder } from "./server.js";
4
4
  import { SSHKeyBuilder } from "./ssh_key.js";
5
5
  import { NetworkBuilder } from "./network.js";
6
6
  import { FirewallBuilder } from "./firewall.js";
7
+ import { VolumeBuilder } from "./volume.js";
8
+ import { LoadBalancerBuilder } from "./load_balancer.js";
7
9
  export const HCloud = {
8
10
  init: (opts) => {
9
11
  Config.set({
@@ -17,11 +19,15 @@ export const HCloud = {
17
19
  SSHKey: (name) => new SSHKeyBuilder(name),
18
20
  Network: (name) => new NetworkBuilder(name),
19
21
  Firewall: (name) => new FirewallBuilder(name),
22
+ Volume: (name) => new VolumeBuilder(name),
23
+ LoadBalancer: (name) => new LoadBalancerBuilder(name),
20
24
  };
21
25
  export * from "./types/hcloud.js";
22
26
  export { ServerBuilder } from "./server.js";
23
27
  export { SSHKeyBuilder } from "./ssh_key.js";
24
28
  export { NetworkBuilder } from "./network.js";
25
29
  export { FirewallBuilder } from "./firewall.js";
30
+ export { VolumeBuilder } from "./volume.js";
31
+ export { LoadBalancerBuilder } from "./load_balancer.js";
26
32
  export { HCloudApiClient, getHCloudApi } from "./api.js";
27
33
  export { listHCloudResources } from "./list.js";
package/dist/list.js CHANGED
@@ -16,10 +16,12 @@ function priceForType(type) {
16
16
  }
17
17
  export async function listHCloudResources() {
18
18
  const api = getHCloudApi();
19
- const [serversData, firewallsData, networksData] = await Promise.all([
19
+ const [serversData, firewallsData, networksData, volumesData, lbData] = await Promise.all([
20
20
  api.get('/servers'),
21
21
  api.get('/firewalls'),
22
22
  api.get('/networks'),
23
+ api.get('/volumes'),
24
+ api.get('/load_balancers'),
23
25
  ]);
24
26
  const servers = serversData.servers.map((s) => ({
25
27
  id: s.id,
@@ -40,6 +42,19 @@ export async function listHCloudResources() {
40
42
  name: n.name,
41
43
  ipRange: n.ip_range ?? '',
42
44
  }));
45
+ const volumes = volumesData.volumes.map((v) => ({
46
+ id: v.id,
47
+ name: v.name,
48
+ size: v.size,
49
+ serverId: v.server ?? undefined,
50
+ }));
51
+ const loadBalancers = lbData.load_balancers.map((lb) => ({
52
+ id: lb.id,
53
+ name: lb.name,
54
+ ip: lb.public_net?.ipv4?.ip ?? undefined,
55
+ location: lb.location?.name ?? '',
56
+ type: lb.load_balancer_type?.name ?? '',
57
+ }));
43
58
  const totalMonthlyCost = servers.reduce((sum, s) => sum + s.monthlyCost, 0);
44
- return { servers, firewalls, networks, totalMonthlyCost };
59
+ return { servers, firewalls, networks, volumes, loadBalancers, totalMonthlyCost };
45
60
  }
@@ -0,0 +1,35 @@
1
+ import { BaseBuilder, Output } from '@puls-dev/core';
2
+ import { ServerBuilder } from './server.js';
3
+ export interface HCloudForwardingRule {
4
+ listenPort: number;
5
+ targetPort: number;
6
+ protocol: 'http' | 'https' | 'tcp';
7
+ }
8
+ export declare class LoadBalancerBuilder extends BaseBuilder {
9
+ readonly out: {
10
+ id: Output<number>;
11
+ ip: Output<string>;
12
+ };
13
+ private _type;
14
+ private _location;
15
+ private _algorithm;
16
+ private _services;
17
+ private _targets;
18
+ private lbId?;
19
+ private resolvedIp?;
20
+ constructor(name: string);
21
+ private discoverLb;
22
+ type(lbType: string): this;
23
+ location(loc: string): this;
24
+ algorithm(algo: 'round_robin' | 'least_connections'): this;
25
+ forward(listenPort: number, targetPort: number, protocol?: 'http' | 'https' | 'tcp'): this;
26
+ target(...servers: (ServerBuilder | number)[]): this;
27
+ private log;
28
+ getDiff(existing: any): {
29
+ field: string;
30
+ declared: string;
31
+ live: any;
32
+ }[];
33
+ deploy(): Promise<void>;
34
+ destroy(): Promise<void>;
35
+ }
@@ -0,0 +1,226 @@
1
+ import { BaseBuilder, Output, Config } from '@puls-dev/core';
2
+ import { getHCloudApi } from './api.js';
3
+ import { ServerBuilder } from './server.js';
4
+ export class LoadBalancerBuilder extends BaseBuilder {
5
+ out = {
6
+ id: new Output(),
7
+ ip: new Output(),
8
+ };
9
+ _type = 'lb11';
10
+ _location = Config.get().providers.hcloud?.defaultLocation || 'nbg1';
11
+ _algorithm = 'round_robin';
12
+ _services = [];
13
+ _targets = [];
14
+ lbId;
15
+ resolvedIp;
16
+ constructor(name) {
17
+ super(name);
18
+ this.discoveryPromise = this.discoverLb(name);
19
+ }
20
+ async discoverLb(name) {
21
+ const api = getHCloudApi();
22
+ const data = await api.get('/load_balancers');
23
+ const match = data.load_balancers.find(lb => lb.name === name) ?? null;
24
+ if (match) {
25
+ this.lbId = match.id;
26
+ this.resolvedIp = match.public_net?.ipv4?.ip;
27
+ this.out.id.resolve(match.id);
28
+ if (this.resolvedIp)
29
+ this.out.ip.resolve(this.resolvedIp);
30
+ }
31
+ return match;
32
+ }
33
+ type(lbType) {
34
+ this._type = lbType;
35
+ return this;
36
+ }
37
+ location(loc) {
38
+ this._location = loc;
39
+ return this;
40
+ }
41
+ algorithm(algo) {
42
+ this._algorithm = algo;
43
+ return this;
44
+ }
45
+ forward(listenPort, targetPort, protocol = 'http') {
46
+ this._services.push({ listenPort, targetPort, protocol });
47
+ return this;
48
+ }
49
+ target(...servers) {
50
+ this._targets.push(...servers);
51
+ return this;
52
+ }
53
+ log(msg) {
54
+ console.log(` ⚖️ [HCloud.LoadBalancer] ${msg}`);
55
+ }
56
+ getDiff(existing) {
57
+ const diffs = [];
58
+ if (existing && existing.load_balancer_type?.name !== this._type) {
59
+ diffs.push({ field: "type", declared: this._type, live: existing.load_balancer_type?.name });
60
+ }
61
+ if (existing && existing.location?.name !== this._location) {
62
+ diffs.push({ field: "location", declared: this._location, live: existing.location?.name });
63
+ }
64
+ if (existing && existing.algorithm?.type !== this._algorithm) {
65
+ diffs.push({ field: "algorithm", declared: this._algorithm, live: existing.algorithm?.type });
66
+ }
67
+ return diffs;
68
+ }
69
+ async deploy() {
70
+ const dryRun = this.isDryRunActive();
71
+ const existing = await this.discoveryPromise;
72
+ const api = getHCloudApi();
73
+ const hasChanges = existing ? this.getDiff(existing).length > 0 : true;
74
+ // Resolve Targets
75
+ const targetServerIds = [];
76
+ for (const t of this._targets) {
77
+ if (t instanceof ServerBuilder) {
78
+ targetServerIds.push(await t.out.id.get());
79
+ }
80
+ else {
81
+ targetServerIds.push(t);
82
+ }
83
+ }
84
+ if (dryRun) {
85
+ this.log(`Planning load balancer "${this.name}"...`);
86
+ if (!existing) {
87
+ this.log(`[PLAN] Create load balancer ${this.name} (${this._type} in ${this._location})`);
88
+ this._services.forEach(s => {
89
+ this.log(` └─ Forward: ${s.protocol.toUpperCase()} ${s.listenPort} -> ${s.targetPort}`);
90
+ });
91
+ if (targetServerIds.length > 0) {
92
+ this.log(` └─ Targets: [${targetServerIds.join(', ')}]`);
93
+ }
94
+ this.out.id.resolve(-1);
95
+ this.out.ip.resolve('0.0.0.0');
96
+ }
97
+ else if (hasChanges) {
98
+ this.log(`[PLAN] Recreate load balancer ${this.name} due to type/location change`);
99
+ }
100
+ else {
101
+ this.log(`Load balancer is up to date.`);
102
+ }
103
+ return;
104
+ }
105
+ this.log(`Finalizing load balancer...`);
106
+ const servicesPayload = this._services.map(s => ({
107
+ protocol: s.protocol,
108
+ listen_port: s.listenPort,
109
+ destination_port: s.targetPort,
110
+ proxyprotocol: false,
111
+ health_check: {
112
+ protocol: s.protocol === 'tcp' ? 'tcp' : 'http',
113
+ port: s.targetPort,
114
+ interval: 15,
115
+ timeout: 10,
116
+ retries: 3,
117
+ ...(s.protocol !== 'tcp' && {
118
+ http: {
119
+ path: '/',
120
+ status_codes: ['2??', '3??']
121
+ }
122
+ })
123
+ }
124
+ }));
125
+ const targetsPayload = targetServerIds.map(id => ({
126
+ type: 'server',
127
+ server: { id }
128
+ }));
129
+ if (!existing || hasChanges) {
130
+ if (existing && hasChanges) {
131
+ this.log(`Load balancer type/location changed. Re-creating...`);
132
+ await api.delete(`/load_balancers/${existing.id}`);
133
+ await this.waitFor('load balancer deletion to complete', async () => {
134
+ try {
135
+ const res = await api.get('/load_balancers');
136
+ return !res.load_balancers.some(lb => lb.name === this.name);
137
+ }
138
+ catch {
139
+ return true;
140
+ }
141
+ });
142
+ }
143
+ const res = await api.post('/load_balancers', {
144
+ name: this.name,
145
+ load_balancer_type: this._type,
146
+ location: this._location,
147
+ algorithm: { type: this._algorithm },
148
+ services: servicesPayload,
149
+ targets: targetsPayload,
150
+ });
151
+ this.lbId = res.load_balancer.id;
152
+ this.resolvedIp = res.load_balancer.public_net?.ipv4?.ip;
153
+ this.out.id.resolve(this.lbId);
154
+ if (this.resolvedIp)
155
+ this.out.ip.resolve(this.resolvedIp);
156
+ await api.waitForAction(res.action.id);
157
+ this.log(`Created load balancer (id=${this.lbId}, ip=${this.resolvedIp})`);
158
+ }
159
+ else {
160
+ // Update existing load balancer services/targets if they differ
161
+ const currentServices = existing.services || [];
162
+ const currentTargets = existing.targets || [];
163
+ // Check if services match
164
+ const servicesChanged = servicesPayload.length !== currentServices.length ||
165
+ servicesPayload.some((s, idx) => {
166
+ const curr = currentServices[idx];
167
+ return !curr || curr.listen_port !== s.listen_port || curr.destination_port !== s.destination_port || curr.protocol !== s.protocol;
168
+ });
169
+ if (servicesChanged) {
170
+ this.log(`Updating services...`);
171
+ // Delete old services
172
+ for (const s of currentServices) {
173
+ const res = await api.post(`/load_balancers/${this.lbId}/actions/delete_service`, {
174
+ listen_port: s.listen_port
175
+ });
176
+ await api.waitForAction(res.action.id);
177
+ }
178
+ // Add new services
179
+ for (const s of servicesPayload) {
180
+ const res = await api.post(`/load_balancers/${this.lbId}/actions/add_service`, s);
181
+ await api.waitForAction(res.action.id);
182
+ }
183
+ }
184
+ // Check if targets match
185
+ const currentTargetServerIds = currentTargets
186
+ .filter((t) => t.type === 'server')
187
+ .map((t) => t.server.id);
188
+ const targetsChanged = targetServerIds.length !== currentTargetServerIds.length ||
189
+ targetServerIds.some(id => !currentTargetServerIds.includes(id));
190
+ if (targetsChanged) {
191
+ this.log(`Updating targets...`);
192
+ // Remove old targets
193
+ for (const t of currentTargets) {
194
+ if (t.type === 'server') {
195
+ const res = await api.post(`/load_balancers/${this.lbId}/actions/remove_target`, {
196
+ type: 'server',
197
+ server: { id: t.server.id }
198
+ });
199
+ await api.waitForAction(res.action.id);
200
+ }
201
+ }
202
+ // Add new targets
203
+ for (const id of targetServerIds) {
204
+ const res = await api.post(`/load_balancers/${this.lbId}/actions/add_target`, {
205
+ type: 'server',
206
+ server: { id }
207
+ });
208
+ await api.waitForAction(res.action.id);
209
+ }
210
+ }
211
+ this.log(`Load balancer is up to date.`);
212
+ }
213
+ }
214
+ async destroy() {
215
+ const existing = await this.discoveryPromise;
216
+ if (existing) {
217
+ this.log(`Deleting load balancer...`);
218
+ const api = getHCloudApi();
219
+ await api.delete(`/load_balancers/${existing.id}`);
220
+ this.log(`Deleted load balancer.`);
221
+ }
222
+ else {
223
+ this.log(`Load balancer does not exist. Skipping deletion.`);
224
+ }
225
+ }
226
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,86 @@
1
+ import { test, describe, beforeEach, afterEach } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { LoadBalancerBuilder } from './load_balancer.js';
4
+ import { Config } from '@puls-dev/core';
5
+ describe('LoadBalancerBuilder Unit Tests', () => {
6
+ let originalFetch;
7
+ let fetchCalls = [];
8
+ let mockResponses = {};
9
+ beforeEach(() => {
10
+ Config.set({
11
+ dryRun: false,
12
+ providers: {
13
+ hcloud: { token: 'fake-hcloud-token', defaultLocation: 'nbg1' }
14
+ }
15
+ });
16
+ originalFetch = globalThis.fetch;
17
+ fetchCalls = [];
18
+ mockResponses = {};
19
+ mockResponses['GET /load_balancers'] = {
20
+ status: 200,
21
+ body: { load_balancers: [] }
22
+ };
23
+ globalThis.fetch = async (input, init) => {
24
+ const url = String(input);
25
+ const method = init?.method ?? 'GET';
26
+ const body = init?.body ? JSON.parse(init.body) : undefined;
27
+ fetchCalls.push({ url, method, body });
28
+ const matchKey = Object.keys(mockResponses)
29
+ .filter(key => {
30
+ const [mMethod, mPath] = key.split(' ');
31
+ return method === mMethod && url.includes(mPath);
32
+ })
33
+ .sort((a, b) => b.split(' ')[1].length - a.split(' ')[1].length)[0];
34
+ if (matchKey) {
35
+ const resp = mockResponses[matchKey];
36
+ return {
37
+ ok: resp.status >= 200 && resp.status < 300,
38
+ status: resp.status,
39
+ json: async () => resp.body,
40
+ text: async () => JSON.stringify(resp.body),
41
+ };
42
+ }
43
+ return {
44
+ ok: false,
45
+ status: 404,
46
+ json: async () => ({ error: { message: 'Not found' } }),
47
+ };
48
+ };
49
+ });
50
+ afterEach(() => {
51
+ globalThis.fetch = originalFetch;
52
+ });
53
+ test('creates a load balancer when it does not exist', async () => {
54
+ mockResponses['POST /load_balancers'] = {
55
+ status: 201,
56
+ body: {
57
+ load_balancer: {
58
+ id: 9090,
59
+ name: 'web-lb',
60
+ public_net: { ipv4: { ip: '1.2.3.4' } }
61
+ },
62
+ action: { id: 7890 }
63
+ }
64
+ };
65
+ mockResponses['GET /actions/7890'] = {
66
+ status: 200,
67
+ body: { action: { status: 'success' } }
68
+ };
69
+ const lb = new LoadBalancerBuilder('web-lb')
70
+ .type('lb11')
71
+ .location('nbg1')
72
+ .forward(80, 8080, 'http')
73
+ .target(123);
74
+ await lb.deploy();
75
+ assert.strictEqual(await lb.out.id.get(), 9090);
76
+ assert.strictEqual(await lb.out.ip.get(), '1.2.3.4');
77
+ const postCall = fetchCalls.find(c => c.method === 'POST' && c.url.includes('/load_balancers'));
78
+ assert.ok(postCall);
79
+ assert.strictEqual(postCall.body.name, 'web-lb');
80
+ assert.strictEqual(postCall.body.load_balancer_type, 'lb11');
81
+ assert.strictEqual(postCall.body.location, 'nbg1');
82
+ assert.strictEqual(postCall.body.services[0].listen_port, 80);
83
+ assert.strictEqual(postCall.body.services[0].destination_port, 8080);
84
+ assert.strictEqual(postCall.body.targets[0].server.id, 123);
85
+ });
86
+ });
package/dist/plugin.js CHANGED
@@ -30,11 +30,25 @@ export const hcloudPlugin = {
30
30
  { header: "IP Range", width: 20, render: (n) => n.ipRange },
31
31
  ]);
32
32
  }
33
+ if (inv.volumes && inv.volumes.length > 0) {
34
+ printSection(`Hetzner Cloud Volumes · ${inv.volumes.length}`, inv.volumes, [
35
+ { header: "Name", width: 24, render: (v) => v.name },
36
+ { header: "Size (GB)", width: 10, render: (v) => `${v.size} GB` },
37
+ { header: "Attached Server", width: 18, render: (v) => v.serverId ? String(v.serverId) : "None" },
38
+ ]);
39
+ }
40
+ if (inv.loadBalancers && inv.loadBalancers.length > 0) {
41
+ printSection(`Hetzner Cloud Load Balancers · ${inv.loadBalancers.length}`, inv.loadBalancers, [
42
+ { header: "Name", width: 24, render: (lb) => lb.name },
43
+ { header: "Type", width: 10, render: (lb) => lb.type },
44
+ { header: "Location", width: 10, render: (lb) => lb.location },
45
+ { header: "IP", width: 15, render: (lb) => lb.ip ?? "-" },
46
+ ]);
47
+ }
33
48
  },
34
49
  configure: (pOpts) => {
35
50
  Config.set({
36
51
  providers: {
37
- ...Config.get().providers,
38
52
  hcloud: pOpts,
39
53
  },
40
54
  });
@@ -0,0 +1,29 @@
1
+ import { BaseBuilder, Output } from '@puls-dev/core';
2
+ import { ServerBuilder } from './server.js';
3
+ export declare class VolumeBuilder extends BaseBuilder {
4
+ readonly out: {
5
+ id: Output<number>;
6
+ linuxDevice: Output<string>;
7
+ };
8
+ private _size;
9
+ private _location;
10
+ private _server?;
11
+ private _automount;
12
+ private _format?;
13
+ private volumeId?;
14
+ constructor(name: string);
15
+ private discoverVolume;
16
+ size(gb: number): this;
17
+ location(loc: string): this;
18
+ server(server: ServerBuilder | number): this;
19
+ automount(auto: boolean): this;
20
+ format(fsType: 'ext4' | 'xfs'): this;
21
+ private log;
22
+ getDiff(existing: any): {
23
+ field: string;
24
+ declared: number;
25
+ live: any;
26
+ }[];
27
+ deploy(): Promise<void>;
28
+ destroy(): Promise<void>;
29
+ }
package/dist/volume.js ADDED
@@ -0,0 +1,164 @@
1
+ import { BaseBuilder, Output, Config } from '@puls-dev/core';
2
+ import { getHCloudApi } from './api.js';
3
+ import { ServerBuilder } from './server.js';
4
+ export class VolumeBuilder extends BaseBuilder {
5
+ out = {
6
+ id: new Output(),
7
+ linuxDevice: new Output(),
8
+ };
9
+ _size = 10; // GB
10
+ _location = Config.get().providers.hcloud?.defaultLocation || 'nbg1';
11
+ _server;
12
+ _automount = false;
13
+ _format;
14
+ volumeId;
15
+ constructor(name) {
16
+ super(name);
17
+ this.discoveryPromise = this.discoverVolume(name);
18
+ }
19
+ async discoverVolume(name) {
20
+ const api = getHCloudApi();
21
+ const data = await api.get('/volumes');
22
+ const match = data.volumes.find(v => v.name === name) ?? null;
23
+ if (match) {
24
+ this.volumeId = match.id;
25
+ this.out.id.resolve(match.id);
26
+ this.out.linuxDevice.resolve(match.linux_device);
27
+ }
28
+ return match;
29
+ }
30
+ size(gb) {
31
+ this._size = gb;
32
+ return this;
33
+ }
34
+ location(loc) {
35
+ this._location = loc;
36
+ return this;
37
+ }
38
+ server(server) {
39
+ this._server = server;
40
+ return this;
41
+ }
42
+ automount(auto) {
43
+ this._automount = auto;
44
+ return this;
45
+ }
46
+ format(fsType) {
47
+ this._format = fsType;
48
+ return this;
49
+ }
50
+ log(msg) {
51
+ console.log(` 💾 [HCloud.Volume] ${msg}`);
52
+ }
53
+ getDiff(existing) {
54
+ const diffs = [];
55
+ if (existing && existing.size !== this._size) {
56
+ diffs.push({ field: "size", declared: this._size, live: existing.size });
57
+ }
58
+ return diffs;
59
+ }
60
+ async deploy() {
61
+ const dryRun = this.isDryRunActive();
62
+ const existing = await this.discoveryPromise;
63
+ const api = getHCloudApi();
64
+ const targetServerId = this._server instanceof ServerBuilder
65
+ ? await this._server.out.id.get()
66
+ : this._server;
67
+ if (dryRun) {
68
+ this.log(`Planning volume "${this.name}"...`);
69
+ if (!existing) {
70
+ this.log(`[PLAN] Create volume ${this.name} (${this._size} GB in ${this._location})`);
71
+ if (targetServerId) {
72
+ this.log(` └─ Attach to server: ${targetServerId}`);
73
+ }
74
+ this.out.id.resolve(-1);
75
+ this.out.linuxDevice.resolve('/dev/disk/by-id/scsi-0HC_Volume_mock');
76
+ }
77
+ else {
78
+ const diffs = this.getDiff(existing);
79
+ if (diffs.length > 0) {
80
+ this.log(`[PLAN] Resize volume ${this.name} ${existing.size} GB -> ${this._size} GB`);
81
+ }
82
+ const currentServerId = existing.server;
83
+ if (targetServerId !== currentServerId) {
84
+ if (currentServerId && !targetServerId) {
85
+ this.log(`[PLAN] Detach volume from server ${currentServerId}`);
86
+ }
87
+ else if (!currentServerId && targetServerId) {
88
+ this.log(`[PLAN] Attach volume to server ${targetServerId}`);
89
+ }
90
+ else if (currentServerId && targetServerId) {
91
+ this.log(`[PLAN] Detach from server ${currentServerId} and attach to ${targetServerId}`);
92
+ }
93
+ }
94
+ }
95
+ return;
96
+ }
97
+ this.log(`Finalizing volume...`);
98
+ if (!existing) {
99
+ const payload = {
100
+ name: this.name,
101
+ size: this._size,
102
+ automount: this._automount,
103
+ };
104
+ if (this._format)
105
+ payload.format = this._format;
106
+ if (targetServerId) {
107
+ payload.server = targetServerId;
108
+ }
109
+ else {
110
+ payload.location = this._location;
111
+ }
112
+ const res = await api.post('/volumes', payload);
113
+ this.volumeId = res.volume.id;
114
+ this.out.id.resolve(res.volume.id);
115
+ this.out.linuxDevice.resolve(res.volume.linux_device);
116
+ if (res.action) {
117
+ await api.waitForAction(res.action.id);
118
+ }
119
+ this.log(`Created volume (id=${this.volumeId})`);
120
+ }
121
+ else {
122
+ const diffs = this.getDiff(existing);
123
+ if (diffs.length > 0) {
124
+ this.log(`Resizing volume to ${this._size} GB...`);
125
+ const res = await api.post(`/volumes/${this.volumeId}/actions/resize`, { size: this._size });
126
+ await api.waitForAction(res.action.id);
127
+ }
128
+ const currentServerId = existing.server;
129
+ if (targetServerId !== currentServerId) {
130
+ if (currentServerId) {
131
+ this.log(`Detaching volume from server ${currentServerId}...`);
132
+ const res = await api.post(`/volumes/${this.volumeId}/actions/detach`, {});
133
+ await api.waitForAction(res.action.id);
134
+ }
135
+ if (targetServerId) {
136
+ this.log(`Attaching volume to server ${targetServerId}...`);
137
+ const res = await api.post(`/volumes/${this.volumeId}/actions/attach`, {
138
+ server: targetServerId,
139
+ automount: this._automount,
140
+ });
141
+ await api.waitForAction(res.action.id);
142
+ }
143
+ }
144
+ this.log(`Volume is up to date.`);
145
+ }
146
+ }
147
+ async destroy() {
148
+ const existing = await this.discoveryPromise;
149
+ if (existing) {
150
+ this.log(`Deleting volume...`);
151
+ const api = getHCloudApi();
152
+ if (existing.server) {
153
+ this.log(`Detaching from server first...`);
154
+ const res = await api.post(`/volumes/${existing.id}/actions/detach`, {});
155
+ await api.waitForAction(res.action.id);
156
+ }
157
+ await api.delete(`/volumes/${existing.id}`);
158
+ this.log(`Deleted volume.`);
159
+ }
160
+ else {
161
+ this.log(`Volume does not exist. Skipping deletion.`);
162
+ }
163
+ }
164
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,111 @@
1
+ import { test, describe, beforeEach, afterEach } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { VolumeBuilder } from './volume.js';
4
+ import { Config } from '@puls-dev/core';
5
+ describe('VolumeBuilder Unit Tests', () => {
6
+ let originalFetch;
7
+ let fetchCalls = [];
8
+ let mockResponses = {};
9
+ beforeEach(() => {
10
+ Config.set({
11
+ dryRun: false,
12
+ providers: {
13
+ hcloud: { token: 'fake-hcloud-token', defaultLocation: 'nbg1' }
14
+ }
15
+ });
16
+ originalFetch = globalThis.fetch;
17
+ fetchCalls = [];
18
+ mockResponses = {};
19
+ mockResponses['GET /volumes'] = {
20
+ status: 200,
21
+ body: { volumes: [] }
22
+ };
23
+ mockResponses['GET /servers'] = {
24
+ status: 200,
25
+ body: { servers: [] }
26
+ };
27
+ globalThis.fetch = async (input, init) => {
28
+ const url = String(input);
29
+ const method = init?.method ?? 'GET';
30
+ const body = init?.body ? JSON.parse(init.body) : undefined;
31
+ fetchCalls.push({ url, method, body });
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
+ };
52
+ };
53
+ });
54
+ afterEach(() => {
55
+ globalThis.fetch = originalFetch;
56
+ });
57
+ test('creates a volume when it does not exist', async () => {
58
+ mockResponses['POST /volumes'] = {
59
+ status: 201,
60
+ body: {
61
+ volume: {
62
+ id: 8080,
63
+ name: 'db-data',
64
+ size: 20,
65
+ linux_device: '/dev/disk/by-id/scsi-0HC_Volume_8080'
66
+ }
67
+ }
68
+ };
69
+ const volume = new VolumeBuilder('db-data')
70
+ .size(20)
71
+ .location('nbg1');
72
+ await volume.deploy();
73
+ assert.strictEqual(await volume.out.id.get(), 8080);
74
+ assert.strictEqual(await volume.out.linuxDevice.get(), '/dev/disk/by-id/scsi-0HC_Volume_8080');
75
+ const postCall = fetchCalls.find(c => c.method === 'POST' && c.url.includes('/volumes'));
76
+ assert.ok(postCall);
77
+ assert.strictEqual(postCall.body.name, 'db-data');
78
+ assert.strictEqual(postCall.body.size, 20);
79
+ assert.strictEqual(postCall.body.location, 'nbg1');
80
+ });
81
+ test('resizes a volume when size differs', async () => {
82
+ mockResponses['GET /volumes'] = {
83
+ status: 200,
84
+ body: {
85
+ volumes: [
86
+ {
87
+ id: 8080,
88
+ name: 'db-data',
89
+ size: 10,
90
+ linux_device: '/dev/disk/by-id/scsi-0HC_Volume_8080',
91
+ server: null
92
+ }
93
+ ]
94
+ }
95
+ };
96
+ mockResponses['POST /volumes/8080/actions/resize'] = {
97
+ status: 201,
98
+ body: { action: { id: 7890 } }
99
+ };
100
+ mockResponses['GET /actions/7890'] = {
101
+ status: 200,
102
+ body: { action: { status: 'success' } }
103
+ };
104
+ const volume = new VolumeBuilder('db-data')
105
+ .size(30);
106
+ await volume.deploy();
107
+ const resizeCall = fetchCalls.find(c => c.method === 'POST' && c.url.includes('/volumes/8080/actions/resize'));
108
+ assert.ok(resizeCall);
109
+ assert.strictEqual(resizeCall.body.size, 30);
110
+ });
111
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@puls-dev/hcloud",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
4
4
  "description": "Hetzner Cloud (HCloud) Provider for Puls IaC",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",