@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 +11 -11
- package/dist/api.js +25 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/list.js +17 -2
- package/dist/load_balancer.d.ts +35 -0
- package/dist/load_balancer.js +226 -0
- package/dist/load_balancer.test.d.ts +1 -0
- package/dist/load_balancer.test.js +86 -0
- package/dist/plugin.js +15 -1
- package/dist/volume.d.ts +29 -0
- package/dist/volume.js +164 -0
- package/dist/volume.test.d.ts +1 -0
- package/dist/volume.test.js +111 -0
- package/package.json +1 -1
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
|
|
20
|
+
```bash
|
|
21
21
|
npm install @puls-dev/core @puls-dev/hcloud
|
|
22
|
-
|
|
22
|
+
```
|
|
23
23
|
|
|
24
24
|
## Quick Example
|
|
25
25
|
|
|
26
|
-
|
|
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
|
-
|
|
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
|
|
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
|
});
|
package/dist/volume.d.ts
ADDED
|
@@ -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
|
+
});
|