puls-dev 0.1.8 → 0.2.0
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 +8 -8
- package/dist/index.d.ts +0 -7
- package/dist/index.js +0 -7
- package/dist/providers/aws/index.d.ts +1 -0
- package/dist/providers/aws/index.js +1 -0
- package/dist/providers/aws/lambda.js +6 -6
- package/dist/providers/aws/lambda.test.d.ts +1 -0
- package/dist/providers/aws/lambda.test.js +189 -0
- package/dist/providers/aws/route53.d.ts +1 -1
- package/dist/providers/aws/route53.js +20 -12
- package/dist/providers/aws/route53.test.d.ts +1 -0
- package/dist/providers/aws/route53.test.js +229 -0
- package/dist/providers/aws/s3.d.ts +3 -0
- package/dist/providers/aws/s3.js +65 -3
- package/dist/providers/aws/s3.test.d.ts +1 -0
- package/dist/providers/aws/s3.test.js +172 -0
- package/dist/providers/do/api.js +5 -1
- package/dist/providers/do/certificate.test.d.ts +1 -0
- package/dist/providers/do/certificate.test.js +133 -0
- package/dist/providers/do/domain.d.ts +12 -1
- package/dist/providers/do/domain.js +129 -13
- package/dist/providers/do/domain.test.d.ts +1 -0
- package/dist/providers/do/domain.test.js +200 -0
- package/dist/providers/do/droplet.js +2 -2
- package/dist/providers/do/droplet.test.d.ts +1 -0
- package/dist/providers/do/droplet.test.js +265 -0
- package/dist/providers/do/firewall.test.d.ts +1 -0
- package/dist/providers/do/firewall.test.js +176 -0
- package/dist/providers/do/index.d.ts +1 -0
- package/dist/providers/do/index.js +1 -0
- package/dist/providers/do/load_balancer.d.ts +39 -5
- package/dist/providers/do/load_balancer.js +272 -30
- package/dist/providers/do/load_balancer.test.d.ts +1 -0
- package/dist/providers/do/load_balancer.test.js +269 -0
- package/dist/providers/firebase/api.js +2 -2
- package/dist/providers/firebase/functions.d.ts +1 -0
- package/dist/providers/firebase/functions.js +24 -10
- package/dist/providers/firebase/functions.test.d.ts +1 -0
- package/dist/providers/firebase/functions.test.js +297 -0
- package/dist/providers/firebase/hosting.d.ts +2 -0
- package/dist/providers/firebase/hosting.js +15 -9
- package/dist/providers/firebase/hosting.test.d.ts +1 -0
- package/dist/providers/firebase/hosting.test.js +181 -0
- package/dist/providers/proxmox/index.d.ts +1 -0
- package/dist/providers/proxmox/index.js +1 -0
- package/dist/providers/proxmox/vm.d.ts +0 -1
- package/dist/providers/proxmox/vm.js +4 -50
- package/package.json +78 -5
|
@@ -1,12 +1,46 @@
|
|
|
1
|
+
import { BaseBuilder } from '../../core/resource.js';
|
|
1
2
|
import { DropletBuilder } from './droplet.js';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
import { CertificateBuilder } from './certificate.js';
|
|
4
|
+
export interface HealthCheckOpts {
|
|
5
|
+
protocol: 'http' | 'https' | 'tcp';
|
|
6
|
+
port: number;
|
|
7
|
+
path?: string;
|
|
8
|
+
checkIntervalSeconds?: number;
|
|
9
|
+
responseTimeoutSeconds?: number;
|
|
10
|
+
unhealthyThreshold?: number;
|
|
11
|
+
healthyThreshold?: number;
|
|
12
|
+
}
|
|
13
|
+
export interface StickySessionOpts {
|
|
14
|
+
type: 'cookies' | 'none';
|
|
15
|
+
cookieName?: string;
|
|
16
|
+
cookieTtlSeconds?: number;
|
|
17
|
+
}
|
|
18
|
+
export interface ForwardingRule {
|
|
19
|
+
entryProtocol: 'http' | 'https' | 'tcp' | string;
|
|
20
|
+
entryPort: number;
|
|
21
|
+
targetProtocol: 'http' | 'https' | 'tcp' | string;
|
|
22
|
+
targetPort: number;
|
|
23
|
+
certificate?: string | CertificateBuilder;
|
|
24
|
+
tlsPassthrough?: boolean;
|
|
25
|
+
}
|
|
26
|
+
export declare class LoadBalancerBuilder extends BaseBuilder {
|
|
27
|
+
name: string;
|
|
28
|
+
private _region;
|
|
5
29
|
private targetNames;
|
|
6
|
-
private
|
|
30
|
+
private forwardingRules;
|
|
31
|
+
private healthCheckConfig?;
|
|
32
|
+
private stickySessionConfig?;
|
|
33
|
+
private lbId?;
|
|
7
34
|
constructor(name: string);
|
|
8
35
|
private discoverLb;
|
|
9
|
-
|
|
36
|
+
region(region: string): this;
|
|
10
37
|
targets(droplets: (DropletBuilder | string)[]): this;
|
|
38
|
+
target(...droplets: (DropletBuilder | string)[]): this;
|
|
39
|
+
forward(entryProtocol: 'http' | 'https' | 'tcp' | string, entryPort: number, targetProtocol: 'http' | 'https' | 'tcp' | string, targetPort: number, certificate?: string | CertificateBuilder, tlsPassthrough?: boolean): this;
|
|
40
|
+
healthCheck(opts: HealthCheckOpts): this;
|
|
41
|
+
stickySession(type: 'cookies' | 'none', cookieName?: string, cookieTtlSeconds?: number): this;
|
|
42
|
+
private resolveDropletIds;
|
|
43
|
+
private resolveCertificateId;
|
|
11
44
|
deploy(): Promise<any>;
|
|
45
|
+
destroy(): Promise<any>;
|
|
12
46
|
}
|
|
@@ -1,20 +1,75 @@
|
|
|
1
|
+
import { BaseBuilder } from '../../core/resource.js';
|
|
1
2
|
import { Config } from '../../core/config.js';
|
|
3
|
+
import { CertificateBuilder } from './certificate.js';
|
|
2
4
|
import { getDoApi } from './api.js';
|
|
3
|
-
export class LoadBalancerBuilder {
|
|
5
|
+
export class LoadBalancerBuilder extends BaseBuilder {
|
|
4
6
|
name;
|
|
5
|
-
|
|
6
|
-
region: Config.get().providers.do?.defaultRegion ?? 'fra1',
|
|
7
|
-
};
|
|
7
|
+
_region = Config.get().providers.do?.defaultRegion ?? 'fra1';
|
|
8
8
|
targetNames = [];
|
|
9
|
-
|
|
9
|
+
forwardingRules = [];
|
|
10
|
+
healthCheckConfig;
|
|
11
|
+
stickySessionConfig;
|
|
12
|
+
lbId;
|
|
10
13
|
constructor(name) {
|
|
14
|
+
super(name);
|
|
11
15
|
this.name = name;
|
|
12
16
|
this.discoveryPromise = this.discoverLb(name);
|
|
13
17
|
}
|
|
14
18
|
async discoverLb(name) {
|
|
15
19
|
const api = getDoApi();
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
try {
|
|
21
|
+
const data = await api.get('/load_balancers?per_page=200');
|
|
22
|
+
const match = data.load_balancers.find(lb => lb.name === name) ?? null;
|
|
23
|
+
if (match) {
|
|
24
|
+
this.lbId = match.id;
|
|
25
|
+
}
|
|
26
|
+
return match;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
region(region) {
|
|
33
|
+
this._region = region;
|
|
34
|
+
return this;
|
|
35
|
+
}
|
|
36
|
+
targets(droplets) {
|
|
37
|
+
this.targetNames = droplets.map(d => (typeof d === 'string' ? d : d.name));
|
|
38
|
+
return this;
|
|
39
|
+
}
|
|
40
|
+
target(...droplets) {
|
|
41
|
+
return this.targets(droplets);
|
|
42
|
+
}
|
|
43
|
+
forward(entryProtocol, entryPort, targetProtocol, targetPort, certificate, tlsPassthrough) {
|
|
44
|
+
this.forwardingRules.push({
|
|
45
|
+
entryProtocol,
|
|
46
|
+
entryPort,
|
|
47
|
+
targetProtocol,
|
|
48
|
+
targetPort,
|
|
49
|
+
certificate,
|
|
50
|
+
tlsPassthrough,
|
|
51
|
+
});
|
|
52
|
+
return this;
|
|
53
|
+
}
|
|
54
|
+
healthCheck(opts) {
|
|
55
|
+
this.healthCheckConfig = {
|
|
56
|
+
protocol: opts.protocol,
|
|
57
|
+
port: opts.port,
|
|
58
|
+
path: opts.path ?? (opts.protocol === 'tcp' ? undefined : '/'),
|
|
59
|
+
checkIntervalSeconds: opts.checkIntervalSeconds ?? 10,
|
|
60
|
+
responseTimeoutSeconds: opts.responseTimeoutSeconds ?? 5,
|
|
61
|
+
unhealthyThreshold: opts.unhealthyThreshold ?? 3,
|
|
62
|
+
healthyThreshold: opts.healthyThreshold ?? 5,
|
|
63
|
+
};
|
|
64
|
+
return this;
|
|
65
|
+
}
|
|
66
|
+
stickySession(type, cookieName, cookieTtlSeconds) {
|
|
67
|
+
this.stickySessionConfig = {
|
|
68
|
+
type,
|
|
69
|
+
...(cookieName && { cookieName }),
|
|
70
|
+
...(cookieTtlSeconds && { cookieTtlSeconds }),
|
|
71
|
+
};
|
|
72
|
+
return this;
|
|
18
73
|
}
|
|
19
74
|
async resolveDropletIds() {
|
|
20
75
|
const api = getDoApi();
|
|
@@ -25,38 +80,225 @@ export class LoadBalancerBuilder {
|
|
|
25
80
|
if (match)
|
|
26
81
|
ids.push(match.id);
|
|
27
82
|
}
|
|
28
|
-
return ids;
|
|
83
|
+
return ids.sort((a, b) => a - b);
|
|
29
84
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
85
|
+
async resolveCertificateId(cert) {
|
|
86
|
+
const api = getDoApi();
|
|
87
|
+
const name = cert instanceof CertificateBuilder ? cert.name : cert;
|
|
88
|
+
// Check if it's already a UUID
|
|
89
|
+
if (typeof cert === 'string' && cert.length === 36 && cert.includes('-')) {
|
|
90
|
+
return cert;
|
|
91
|
+
}
|
|
92
|
+
const data = await api.get('/certificates?per_page=200');
|
|
93
|
+
const match = data.certificates.find(c => c.name === name || c.id === name);
|
|
94
|
+
if (!match) {
|
|
95
|
+
throw new Error(`[LoadBalancer:${this.name}] Certificate "${name}" not found in DO account.`);
|
|
96
|
+
}
|
|
97
|
+
return match.id;
|
|
33
98
|
}
|
|
34
99
|
async deploy() {
|
|
35
|
-
const dryRun =
|
|
100
|
+
const dryRun = this.isDryRunActive();
|
|
36
101
|
const existing = await this.discoveryPromise;
|
|
102
|
+
const api = getDoApi();
|
|
37
103
|
console.log(`\n⚖️ Finalizing load balancer "${this.name}"...`);
|
|
104
|
+
// 1. Resolve Target Droplet IDs
|
|
105
|
+
const dropletIds = dryRun ? [12345] : await this.resolveDropletIds();
|
|
106
|
+
// 2. Build Forwarding Rules
|
|
107
|
+
const finalRules = [];
|
|
108
|
+
const rulesToResolve = this.forwardingRules.length > 0
|
|
109
|
+
? this.forwardingRules
|
|
110
|
+
: [
|
|
111
|
+
{
|
|
112
|
+
entryProtocol: 'http',
|
|
113
|
+
entryPort: 80,
|
|
114
|
+
targetProtocol: 'http',
|
|
115
|
+
targetPort: 80,
|
|
116
|
+
}
|
|
117
|
+
];
|
|
118
|
+
for (const rule of rulesToResolve) {
|
|
119
|
+
let certId;
|
|
120
|
+
if (rule.certificate) {
|
|
121
|
+
certId = dryRun ? 'mock-cert-uuid' : await this.resolveCertificateId(rule.certificate);
|
|
122
|
+
}
|
|
123
|
+
finalRules.push({
|
|
124
|
+
entry_protocol: rule.entryProtocol.toLowerCase(),
|
|
125
|
+
entry_port: rule.entryPort,
|
|
126
|
+
target_protocol: rule.targetProtocol.toLowerCase(),
|
|
127
|
+
target_port: rule.targetPort,
|
|
128
|
+
...(certId && { certificate_id: certId }),
|
|
129
|
+
...(rule.tlsPassthrough !== undefined && { tls_passthrough: rule.tlsPassthrough }),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
// 3. Build Health Check Config
|
|
133
|
+
const finalHealthCheck = this.healthCheckConfig
|
|
134
|
+
? {
|
|
135
|
+
protocol: this.healthCheckConfig.protocol.toLowerCase(),
|
|
136
|
+
port: this.healthCheckConfig.port,
|
|
137
|
+
...(this.healthCheckConfig.path && { path: this.healthCheckConfig.path }),
|
|
138
|
+
check_interval_seconds: this.healthCheckConfig.checkIntervalSeconds,
|
|
139
|
+
response_timeout_seconds: this.healthCheckConfig.responseTimeoutSeconds,
|
|
140
|
+
unhealthy_threshold: this.healthCheckConfig.unhealthyThreshold,
|
|
141
|
+
healthy_threshold: this.healthCheckConfig.healthyThreshold,
|
|
142
|
+
}
|
|
143
|
+
: {
|
|
144
|
+
protocol: 'http',
|
|
145
|
+
port: 80,
|
|
146
|
+
path: '/',
|
|
147
|
+
check_interval_seconds: 10,
|
|
148
|
+
response_timeout_seconds: 5,
|
|
149
|
+
unhealthy_threshold: 3,
|
|
150
|
+
healthy_threshold: 5,
|
|
151
|
+
};
|
|
152
|
+
// 4. Build Sticky Sessions Config
|
|
153
|
+
const finalStickySessions = this.stickySessionConfig
|
|
154
|
+
? {
|
|
155
|
+
type: this.stickySessionConfig.type,
|
|
156
|
+
...(this.stickySessionConfig.cookieName && { cookie_name: this.stickySessionConfig.cookieName }),
|
|
157
|
+
...(this.stickySessionConfig.cookieTtlSeconds && { cookie_ttl_seconds: this.stickySessionConfig.cookieTtlSeconds }),
|
|
158
|
+
}
|
|
159
|
+
: {
|
|
160
|
+
type: 'none',
|
|
161
|
+
};
|
|
162
|
+
// 5. Compare configuration for idempotent update
|
|
163
|
+
const resolvedRegion = this._region;
|
|
164
|
+
let hasChanges = true;
|
|
38
165
|
if (existing) {
|
|
39
|
-
|
|
166
|
+
const regionMatch = existing.region?.slug === resolvedRegion;
|
|
167
|
+
const dropletsMatch = arraysEqual(existing.droplet_ids, dropletIds);
|
|
168
|
+
const rulesMatch = rulesEqual(existing.forwarding_rules, finalRules);
|
|
169
|
+
const healthMatch = healthCheckEqual(existing.health_check, finalHealthCheck);
|
|
170
|
+
const stickyMatch = stickySessionsEqual(existing.sticky_sessions, finalStickySessions);
|
|
171
|
+
hasChanges = !regionMatch || !dropletsMatch || !rulesMatch || !healthMatch || !stickyMatch;
|
|
172
|
+
}
|
|
173
|
+
if (await this.checkProtection(hasChanges))
|
|
174
|
+
return null;
|
|
175
|
+
if (dryRun) {
|
|
176
|
+
if (!existing) {
|
|
177
|
+
console.log(` 📝 [PLAN] Create load balancer ${this.name} in ${resolvedRegion}`);
|
|
178
|
+
console.log(` └─ Targets: [${this.targetNames.join(', ')}]`);
|
|
179
|
+
console.log(` └─ Rules: ${JSON.stringify(finalRules)}`);
|
|
180
|
+
console.log(` └─ Health Check: ${JSON.stringify(finalHealthCheck)}`);
|
|
181
|
+
console.log(` └─ Sticky Sessions: ${JSON.stringify(finalStickySessions)}`);
|
|
182
|
+
}
|
|
183
|
+
else if (hasChanges) {
|
|
184
|
+
console.log(` 📝 [PLAN] Update load balancer ${this.name} in ${resolvedRegion}`);
|
|
185
|
+
console.log(` └─ Target updates or config changes detected.`);
|
|
186
|
+
console.log(` └─ Targets: [${this.targetNames.join(', ')}]`);
|
|
187
|
+
console.log(` └─ Rules: ${JSON.stringify(finalRules)}`);
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
console.log(` ✅ Load balancer ${this.name} is up to date.`);
|
|
191
|
+
}
|
|
192
|
+
for (const sidecar of this.sidecars)
|
|
193
|
+
await sidecar.deploy();
|
|
194
|
+
return existing || { name: this.name, region: resolvedRegion, forwarding_rules: finalRules };
|
|
195
|
+
}
|
|
196
|
+
if (!existing) {
|
|
197
|
+
const result = await api.post('/load_balancers', {
|
|
198
|
+
name: this.name,
|
|
199
|
+
region: resolvedRegion,
|
|
200
|
+
forwarding_rules: finalRules,
|
|
201
|
+
health_check: finalHealthCheck,
|
|
202
|
+
sticky_sessions: finalStickySessions,
|
|
203
|
+
droplet_ids: dropletIds,
|
|
204
|
+
});
|
|
205
|
+
this.lbId = result.load_balancer.id;
|
|
206
|
+
console.log(`🚀 Created load balancer ${this.name} (id=${this.lbId})`);
|
|
207
|
+
if (this.targetNames.length)
|
|
208
|
+
console.log(` Targets: [${this.targetNames.join(', ')}]`);
|
|
209
|
+
for (const sidecar of this.sidecars)
|
|
210
|
+
await sidecar.deploy();
|
|
211
|
+
return result.load_balancer;
|
|
212
|
+
}
|
|
213
|
+
else if (hasChanges) {
|
|
214
|
+
console.log(`✨ Updating load balancer ${this.name} (id=${this.lbId})...`);
|
|
215
|
+
const result = await api.put(`/load_balancers/${this.lbId}`, {
|
|
216
|
+
name: this.name,
|
|
217
|
+
region: resolvedRegion,
|
|
218
|
+
forwarding_rules: finalRules,
|
|
219
|
+
health_check: finalHealthCheck,
|
|
220
|
+
sticky_sessions: finalStickySessions,
|
|
221
|
+
droplet_ids: dropletIds,
|
|
222
|
+
});
|
|
223
|
+
console.log(` ✅ Load balancer updated.`);
|
|
224
|
+
for (const sidecar of this.sidecars)
|
|
225
|
+
await sidecar.deploy();
|
|
226
|
+
return result.load_balancer;
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
console.log(`✅ Load balancer ${this.name} is up to date.`);
|
|
230
|
+
for (const sidecar of this.sidecars)
|
|
231
|
+
await sidecar.deploy();
|
|
40
232
|
return existing;
|
|
41
233
|
}
|
|
234
|
+
}
|
|
235
|
+
async destroy() {
|
|
236
|
+
const dryRun = this.isDryRunActive();
|
|
237
|
+
await this.discoveryPromise;
|
|
238
|
+
if (!this.lbId) {
|
|
239
|
+
console.log(`\n🗑️ "${this.name}" not found, nothing to destroy.`);
|
|
240
|
+
return { destroyed: null };
|
|
241
|
+
}
|
|
242
|
+
console.log(`\n🗑️ Destroying load balancer "${this.name}" (id=${this.lbId})...`);
|
|
42
243
|
if (dryRun) {
|
|
43
|
-
console.log(` 📝 [PLAN]
|
|
44
|
-
return this.config;
|
|
244
|
+
console.log(` 📝 [PLAN] Would delete load balancer id=${this.lbId}`);
|
|
45
245
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
{ entry_protocol: 'http', entry_port: 80, target_protocol: 'http', target_port: 80 },
|
|
53
|
-
{ entry_protocol: 'https', entry_port: 443, target_protocol: 'http', target_port: 80 },
|
|
54
|
-
],
|
|
55
|
-
droplet_ids: dropletIds,
|
|
56
|
-
});
|
|
57
|
-
console.log(`🚀 Created load balancer ${this.name} (id=${result.load_balancer.id})`);
|
|
58
|
-
if (this.targetNames.length)
|
|
59
|
-
console.log(` Targets: [${this.targetNames.join(', ')}]`);
|
|
60
|
-
return result.load_balancer;
|
|
246
|
+
else {
|
|
247
|
+
await getDoApi().delete(`/load_balancers/${this.lbId}`);
|
|
248
|
+
console.log(` ✅ Deleted.`);
|
|
249
|
+
}
|
|
250
|
+
await this.destroySidecars();
|
|
251
|
+
return { destroyed: this.name };
|
|
61
252
|
}
|
|
62
253
|
}
|
|
254
|
+
// Helpers for structural comparisons
|
|
255
|
+
function arraysEqual(a, b) {
|
|
256
|
+
if (!a || !b)
|
|
257
|
+
return a === b;
|
|
258
|
+
if (a.length !== b.length)
|
|
259
|
+
return false;
|
|
260
|
+
const sortedA = [...a].sort();
|
|
261
|
+
const sortedB = [...b].sort();
|
|
262
|
+
return sortedA.every((val, index) => val === sortedB[index]);
|
|
263
|
+
}
|
|
264
|
+
function rulesEqual(existingRules, targetRules) {
|
|
265
|
+
if (!existingRules || !targetRules)
|
|
266
|
+
return existingRules === targetRules;
|
|
267
|
+
if (existingRules.length !== targetRules.length)
|
|
268
|
+
return false;
|
|
269
|
+
const sortKey = (r) => `${r.entry_protocol}:${r.entry_port}:${r.target_protocol}:${r.target_port}`;
|
|
270
|
+
const sortedExisting = [...existingRules].sort((a, b) => sortKey(a).localeCompare(sortKey(b)));
|
|
271
|
+
const sortedTarget = [...targetRules].sort((a, b) => sortKey(a).localeCompare(sortKey(b)));
|
|
272
|
+
return sortedExisting.every((ext, i) => {
|
|
273
|
+
const tgt = sortedTarget[i];
|
|
274
|
+
return (ext.entry_protocol === tgt.entry_protocol &&
|
|
275
|
+
ext.entry_port === tgt.entry_port &&
|
|
276
|
+
ext.target_protocol === tgt.target_protocol &&
|
|
277
|
+
ext.target_port === tgt.target_port &&
|
|
278
|
+
(ext.certificate_id ?? '') === (tgt.certificate_id ?? '') &&
|
|
279
|
+
Boolean(ext.tls_passthrough) === Boolean(tgt.tls_passthrough));
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
function healthCheckEqual(ext, tgt) {
|
|
283
|
+
if (!ext || !tgt)
|
|
284
|
+
return ext === tgt;
|
|
285
|
+
return (ext.protocol === tgt.protocol &&
|
|
286
|
+
ext.port === tgt.port &&
|
|
287
|
+
(ext.path ?? '/') === (tgt.path ?? '/') &&
|
|
288
|
+
ext.check_interval_seconds === tgt.check_interval_seconds &&
|
|
289
|
+
ext.response_timeout_seconds === tgt.response_timeout_seconds &&
|
|
290
|
+
ext.unhealthy_threshold === tgt.unhealthy_threshold &&
|
|
291
|
+
ext.healthy_threshold === tgt.healthy_threshold);
|
|
292
|
+
}
|
|
293
|
+
function stickySessionsEqual(ext, tgt) {
|
|
294
|
+
if (!ext || !tgt)
|
|
295
|
+
return ext === tgt;
|
|
296
|
+
const extType = ext.type ?? 'none';
|
|
297
|
+
const tgtType = tgt.type ?? 'none';
|
|
298
|
+
if (extType !== tgtType)
|
|
299
|
+
return false;
|
|
300
|
+
if (extType === 'none')
|
|
301
|
+
return true;
|
|
302
|
+
return (ext.cookie_name === tgt.cookie_name &&
|
|
303
|
+
ext.cookie_ttl_seconds === tgt.cookie_ttl_seconds);
|
|
304
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,269 @@
|
|
|
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 '../../core/config.js';
|
|
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
|
+
do: { token: 'fake-do-token' }
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
originalFetch = globalThis.fetch;
|
|
17
|
+
fetchCalls = [];
|
|
18
|
+
mockResponses = {};
|
|
19
|
+
globalThis.fetch = async (input, init) => {
|
|
20
|
+
const url = String(input);
|
|
21
|
+
const method = init?.method ?? 'GET';
|
|
22
|
+
const body = init?.body ? JSON.parse(init.body) : undefined;
|
|
23
|
+
const headers = init?.headers;
|
|
24
|
+
fetchCalls.push({ url, method, body, headers });
|
|
25
|
+
const matchKey = Object.keys(mockResponses).find(key => {
|
|
26
|
+
const [mMethod, mPath] = key.split(' ');
|
|
27
|
+
// Support matching exact endpoint subpath
|
|
28
|
+
return method === mMethod && url.includes(mPath);
|
|
29
|
+
});
|
|
30
|
+
if (matchKey) {
|
|
31
|
+
const resp = mockResponses[matchKey];
|
|
32
|
+
return {
|
|
33
|
+
ok: resp.status >= 200 && resp.status < 300,
|
|
34
|
+
status: resp.status,
|
|
35
|
+
json: async () => resp.body,
|
|
36
|
+
text: async () => JSON.stringify(resp.body),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
ok: false,
|
|
41
|
+
status: 404,
|
|
42
|
+
json: async () => ({ message: 'Not found' }),
|
|
43
|
+
text: async () => 'Not found',
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
});
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
globalThis.fetch = originalFetch;
|
|
49
|
+
});
|
|
50
|
+
test('gracefully handles discovery when load balancer does not exist', async () => {
|
|
51
|
+
mockResponses['GET /load_balancers'] = {
|
|
52
|
+
status: 200,
|
|
53
|
+
body: { load_balancers: [] }
|
|
54
|
+
};
|
|
55
|
+
const builder = new LoadBalancerBuilder('my-lb');
|
|
56
|
+
const discoveryResult = await builder.discoveryPromise;
|
|
57
|
+
assert.strictEqual(discoveryResult, null);
|
|
58
|
+
assert.strictEqual(fetchCalls.length, 1);
|
|
59
|
+
assert.strictEqual(fetchCalls[0].method, 'GET');
|
|
60
|
+
assert.ok(fetchCalls[0].url.endsWith('/load_balancers?per_page=200'));
|
|
61
|
+
});
|
|
62
|
+
test('discovers load balancer successfully when it exists', async () => {
|
|
63
|
+
mockResponses['GET /load_balancers'] = {
|
|
64
|
+
status: 200,
|
|
65
|
+
body: {
|
|
66
|
+
load_balancers: [
|
|
67
|
+
{ id: 'lb-123', name: 'my-lb', region: { slug: 'nyc3' } }
|
|
68
|
+
]
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const builder = new LoadBalancerBuilder('my-lb');
|
|
72
|
+
const discoveryResult = await builder.discoveryPromise;
|
|
73
|
+
assert.ok(discoveryResult);
|
|
74
|
+
assert.strictEqual(discoveryResult.id, 'lb-123');
|
|
75
|
+
assert.strictEqual(discoveryResult.name, 'my-lb');
|
|
76
|
+
});
|
|
77
|
+
test('performs clean dry-run planning without making write requests', async () => {
|
|
78
|
+
Config.set({
|
|
79
|
+
dryRun: true,
|
|
80
|
+
providers: { do: { token: 'fake-token' } }
|
|
81
|
+
});
|
|
82
|
+
mockResponses['GET /load_balancers'] = {
|
|
83
|
+
status: 200,
|
|
84
|
+
body: { load_balancers: [] }
|
|
85
|
+
};
|
|
86
|
+
const builder = new LoadBalancerBuilder('my-lb');
|
|
87
|
+
builder
|
|
88
|
+
.region('nyc3')
|
|
89
|
+
.targets(['app-vm-1'])
|
|
90
|
+
.forward('http', 80, 'http', 80);
|
|
91
|
+
const result = await builder.deploy();
|
|
92
|
+
assert.ok(result);
|
|
93
|
+
// Discovery GET happened, but no writes (POST, PUT, DELETE)
|
|
94
|
+
const writeCalls = fetchCalls.filter(c => c.method !== 'GET');
|
|
95
|
+
assert.strictEqual(writeCalls.length, 0);
|
|
96
|
+
});
|
|
97
|
+
test('deploys new load balancer and resolves droplet/certificate details', async () => {
|
|
98
|
+
mockResponses['GET /load_balancers'] = {
|
|
99
|
+
status: 200,
|
|
100
|
+
body: { load_balancers: [] }
|
|
101
|
+
};
|
|
102
|
+
mockResponses['GET /droplets?name=app-vm-1'] = {
|
|
103
|
+
status: 200,
|
|
104
|
+
body: { droplets: [{ id: 111, name: 'app-vm-1' }] }
|
|
105
|
+
};
|
|
106
|
+
mockResponses['GET /droplets?name=app-vm-2'] = {
|
|
107
|
+
status: 200,
|
|
108
|
+
body: { droplets: [{ id: 222, name: 'app-vm-2' }] }
|
|
109
|
+
};
|
|
110
|
+
mockResponses['GET /certificates'] = {
|
|
111
|
+
status: 200,
|
|
112
|
+
body: { certificates: [{ id: 'cert-uuid', name: 'my-cert' }] }
|
|
113
|
+
};
|
|
114
|
+
mockResponses['POST /load_balancers'] = {
|
|
115
|
+
status: 201,
|
|
116
|
+
body: { load_balancer: { id: 'lb-789', name: 'my-lb' } }
|
|
117
|
+
};
|
|
118
|
+
const builder = new LoadBalancerBuilder('my-lb');
|
|
119
|
+
builder
|
|
120
|
+
.region('nyc3')
|
|
121
|
+
.targets(['app-vm-1', 'app-vm-2'])
|
|
122
|
+
.forward('http', 80, 'http', 80)
|
|
123
|
+
.forward('https', 443, 'http', 80, 'my-cert')
|
|
124
|
+
.healthCheck({ protocol: 'http', port: 80, path: '/health', checkIntervalSeconds: 15 })
|
|
125
|
+
.stickySession('cookies', 'lb-cookie', 3600);
|
|
126
|
+
const result = await builder.deploy();
|
|
127
|
+
assert.strictEqual(result.id, 'lb-789');
|
|
128
|
+
// Verify resolving calls
|
|
129
|
+
const postCall = fetchCalls.find(c => c.method === 'POST');
|
|
130
|
+
assert.ok(postCall);
|
|
131
|
+
assert.ok(postCall.url.endsWith('/load_balancers'));
|
|
132
|
+
assert.deepStrictEqual(postCall.body, {
|
|
133
|
+
name: 'my-lb',
|
|
134
|
+
region: 'nyc3',
|
|
135
|
+
forwarding_rules: [
|
|
136
|
+
{ entry_protocol: 'http', entry_port: 80, target_protocol: 'http', target_port: 80 },
|
|
137
|
+
{ entry_protocol: 'https', entry_port: 443, target_protocol: 'http', target_port: 80, certificate_id: 'cert-uuid' }
|
|
138
|
+
],
|
|
139
|
+
health_check: {
|
|
140
|
+
protocol: 'http',
|
|
141
|
+
port: 80,
|
|
142
|
+
path: '/health',
|
|
143
|
+
check_interval_seconds: 15,
|
|
144
|
+
response_timeout_seconds: 5,
|
|
145
|
+
unhealthy_threshold: 3,
|
|
146
|
+
healthy_threshold: 5
|
|
147
|
+
},
|
|
148
|
+
sticky_sessions: {
|
|
149
|
+
type: 'cookies',
|
|
150
|
+
cookie_name: 'lb-cookie',
|
|
151
|
+
cookie_ttl_seconds: 3600
|
|
152
|
+
},
|
|
153
|
+
droplet_ids: [111, 222]
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
test('skips update deployment if load balancer configuration is up-to-date', async () => {
|
|
157
|
+
mockResponses['GET /load_balancers'] = {
|
|
158
|
+
status: 200,
|
|
159
|
+
body: {
|
|
160
|
+
load_balancers: [
|
|
161
|
+
{
|
|
162
|
+
id: 'lb-123',
|
|
163
|
+
name: 'my-lb',
|
|
164
|
+
region: { slug: 'nyc3' },
|
|
165
|
+
droplet_ids: [111],
|
|
166
|
+
forwarding_rules: [
|
|
167
|
+
{ entry_protocol: 'http', entry_port: 80, target_protocol: 'http', target_port: 80 }
|
|
168
|
+
],
|
|
169
|
+
health_check: {
|
|
170
|
+
protocol: 'http',
|
|
171
|
+
port: 80,
|
|
172
|
+
path: '/health',
|
|
173
|
+
check_interval_seconds: 15,
|
|
174
|
+
response_timeout_seconds: 5,
|
|
175
|
+
unhealthy_threshold: 3,
|
|
176
|
+
healthy_threshold: 5
|
|
177
|
+
},
|
|
178
|
+
sticky_sessions: {
|
|
179
|
+
type: 'none'
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
]
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
mockResponses['GET /droplets?name=app-vm-1'] = {
|
|
186
|
+
status: 200,
|
|
187
|
+
body: { droplets: [{ id: 111, name: 'app-vm-1' }] }
|
|
188
|
+
};
|
|
189
|
+
const builder = new LoadBalancerBuilder('my-lb');
|
|
190
|
+
builder
|
|
191
|
+
.region('nyc3')
|
|
192
|
+
.targets(['app-vm-1'])
|
|
193
|
+
.forward('http', 80, 'http', 80)
|
|
194
|
+
.healthCheck({ protocol: 'http', port: 80, path: '/health', checkIntervalSeconds: 15 });
|
|
195
|
+
await builder.deploy();
|
|
196
|
+
// Verify no PUT or POST was executed
|
|
197
|
+
const writeCalls = fetchCalls.filter(c => c.method === 'POST' || c.method === 'PUT');
|
|
198
|
+
assert.strictEqual(writeCalls.length, 0);
|
|
199
|
+
});
|
|
200
|
+
test('performs in-place update when load balancer target or rule config changes', async () => {
|
|
201
|
+
mockResponses['GET /load_balancers'] = {
|
|
202
|
+
status: 200,
|
|
203
|
+
body: {
|
|
204
|
+
load_balancers: [
|
|
205
|
+
{
|
|
206
|
+
id: 'lb-123',
|
|
207
|
+
name: 'my-lb',
|
|
208
|
+
region: { slug: 'nyc3' },
|
|
209
|
+
droplet_ids: [111], // Desired is [111, 222]
|
|
210
|
+
forwarding_rules: [
|
|
211
|
+
{ entry_protocol: 'http', entry_port: 80, target_protocol: 'http', target_port: 80 }
|
|
212
|
+
],
|
|
213
|
+
health_check: {
|
|
214
|
+
protocol: 'http',
|
|
215
|
+
port: 80,
|
|
216
|
+
path: '/'
|
|
217
|
+
},
|
|
218
|
+
sticky_sessions: {
|
|
219
|
+
type: 'none'
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
]
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
mockResponses['GET /droplets?name=app-vm-1'] = {
|
|
226
|
+
status: 200,
|
|
227
|
+
body: { droplets: [{ id: 111, name: 'app-vm-1' }] }
|
|
228
|
+
};
|
|
229
|
+
mockResponses['GET /droplets?name=app-vm-2'] = {
|
|
230
|
+
status: 200,
|
|
231
|
+
body: { droplets: [{ id: 222, name: 'app-vm-2' }] }
|
|
232
|
+
};
|
|
233
|
+
mockResponses['PUT /load_balancers/lb-123'] = {
|
|
234
|
+
status: 200,
|
|
235
|
+
body: { load_balancer: { id: 'lb-123', name: 'my-lb' } }
|
|
236
|
+
};
|
|
237
|
+
const builder = new LoadBalancerBuilder('my-lb');
|
|
238
|
+
builder
|
|
239
|
+
.region('nyc3')
|
|
240
|
+
.targets(['app-vm-1', 'app-vm-2'])
|
|
241
|
+
.forward('http', 80, 'http', 80);
|
|
242
|
+
await builder.deploy();
|
|
243
|
+
const putCall = fetchCalls.find(c => c.method === 'PUT');
|
|
244
|
+
assert.ok(putCall);
|
|
245
|
+
assert.ok(putCall.url.endsWith('/load_balancers/lb-123'));
|
|
246
|
+
assert.deepStrictEqual(putCall.body.droplet_ids, [111, 222]);
|
|
247
|
+
});
|
|
248
|
+
test('destroys load balancer successfully', async () => {
|
|
249
|
+
mockResponses['GET /load_balancers'] = {
|
|
250
|
+
status: 200,
|
|
251
|
+
body: {
|
|
252
|
+
load_balancers: [
|
|
253
|
+
{ id: 'lb-123', name: 'my-lb' }
|
|
254
|
+
]
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
mockResponses['DELETE /load_balancers/lb-123'] = {
|
|
258
|
+
status: 204,
|
|
259
|
+
body: {}
|
|
260
|
+
};
|
|
261
|
+
const builder = new LoadBalancerBuilder('my-lb');
|
|
262
|
+
await builder.discoveryPromise;
|
|
263
|
+
const result = await builder.destroy();
|
|
264
|
+
assert.deepStrictEqual(result, { destroyed: 'my-lb' });
|
|
265
|
+
const deleteCall = fetchCalls.find(c => c.method === 'DELETE');
|
|
266
|
+
assert.ok(deleteCall);
|
|
267
|
+
assert.ok(deleteCall.url.endsWith('/load_balancers/lb-123'));
|
|
268
|
+
});
|
|
269
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import fs from 'node:fs';
|
|
2
2
|
import { GoogleAuth } from 'google-auth-library';
|
|
3
3
|
import { Config } from '../../core/config.js';
|
|
4
4
|
function resolveFirebaseConfig() {
|
|
@@ -8,7 +8,7 @@ function resolveFirebaseConfig() {
|
|
|
8
8
|
// Fallback: auto-configure from FIREBASE_SA env var so the decorator option is optional
|
|
9
9
|
const saPath = process.env.FIREBASE_SA;
|
|
10
10
|
if (saPath) {
|
|
11
|
-
const sa = JSON.parse(readFileSync(saPath, 'utf8'));
|
|
11
|
+
const sa = JSON.parse(fs.readFileSync(saPath, 'utf8'));
|
|
12
12
|
return { projectId: sa.project_id, serviceAccountPath: saPath };
|
|
13
13
|
}
|
|
14
14
|
throw new Error('Firebase not configured. Set FIREBASE_SA=/path/to/sa.json or use @Deploy({ firebase: "..." })');
|
|
@@ -19,6 +19,7 @@ export declare class FirebaseFunctionsBuilder extends BaseBuilder {
|
|
|
19
19
|
private _minInstances;
|
|
20
20
|
private _env;
|
|
21
21
|
constructor(functionName: string);
|
|
22
|
+
private discoverFunction;
|
|
22
23
|
source(path: string): this;
|
|
23
24
|
entryPoint(e: string): this;
|
|
24
25
|
runtime(r: string): this;
|