puls-dev 0.1.9 → 0.2.1

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.
Files changed (69) hide show
  1. package/README.md +8 -8
  2. package/dist/index.d.ts +0 -7
  3. package/dist/index.js +0 -7
  4. package/dist/providers/aws/api.d.ts +4 -0
  5. package/dist/providers/aws/api.js +4 -0
  6. package/dist/providers/aws/cloudwatch.d.ts +44 -0
  7. package/dist/providers/aws/cloudwatch.js +205 -0
  8. package/dist/providers/aws/cloudwatch.test.d.ts +1 -0
  9. package/dist/providers/aws/cloudwatch.test.js +224 -0
  10. package/dist/providers/aws/fargate.d.ts +2 -0
  11. package/dist/providers/aws/fargate.js +6 -0
  12. package/dist/providers/aws/iam.d.ts +52 -0
  13. package/dist/providers/aws/iam.js +307 -0
  14. package/dist/providers/aws/iam.test.d.ts +1 -0
  15. package/dist/providers/aws/iam.test.js +367 -0
  16. package/dist/providers/aws/index.d.ts +8 -0
  17. package/dist/providers/aws/index.js +8 -0
  18. package/dist/providers/aws/lambda.d.ts +3 -1
  19. package/dist/providers/aws/lambda.js +17 -8
  20. package/dist/providers/aws/lambda.test.d.ts +1 -0
  21. package/dist/providers/aws/lambda.test.js +189 -0
  22. package/dist/providers/aws/rds.d.ts +1 -0
  23. package/dist/providers/aws/rds.js +4 -1
  24. package/dist/providers/aws/route53.d.ts +1 -1
  25. package/dist/providers/aws/route53.js +20 -12
  26. package/dist/providers/aws/route53.test.d.ts +1 -0
  27. package/dist/providers/aws/route53.test.js +229 -0
  28. package/dist/providers/aws/s3.d.ts +3 -0
  29. package/dist/providers/aws/s3.js +65 -3
  30. package/dist/providers/aws/s3.test.d.ts +1 -0
  31. package/dist/providers/aws/s3.test.js +172 -0
  32. package/dist/providers/aws/sns.d.ts +22 -0
  33. package/dist/providers/aws/sns.js +146 -0
  34. package/dist/providers/aws/sns.test.d.ts +1 -0
  35. package/dist/providers/aws/sns.test.js +162 -0
  36. package/dist/providers/do/api.js +5 -1
  37. package/dist/providers/do/certificate.test.d.ts +1 -0
  38. package/dist/providers/do/certificate.test.js +133 -0
  39. package/dist/providers/do/domain.d.ts +12 -1
  40. package/dist/providers/do/domain.js +129 -13
  41. package/dist/providers/do/domain.test.d.ts +1 -0
  42. package/dist/providers/do/domain.test.js +200 -0
  43. package/dist/providers/do/droplet.js +2 -2
  44. package/dist/providers/do/droplet.test.d.ts +1 -0
  45. package/dist/providers/do/droplet.test.js +265 -0
  46. package/dist/providers/do/firewall.test.d.ts +1 -0
  47. package/dist/providers/do/firewall.test.js +176 -0
  48. package/dist/providers/do/index.d.ts +1 -0
  49. package/dist/providers/do/index.js +1 -0
  50. package/dist/providers/do/load_balancer.d.ts +39 -5
  51. package/dist/providers/do/load_balancer.js +272 -30
  52. package/dist/providers/do/load_balancer.test.d.ts +1 -0
  53. package/dist/providers/do/load_balancer.test.js +269 -0
  54. package/dist/providers/firebase/api.js +2 -2
  55. package/dist/providers/firebase/functions.d.ts +1 -0
  56. package/dist/providers/firebase/functions.js +24 -10
  57. package/dist/providers/firebase/functions.test.d.ts +1 -0
  58. package/dist/providers/firebase/functions.test.js +297 -0
  59. package/dist/providers/firebase/hosting.js +5 -5
  60. package/dist/providers/firebase/hosting.test.d.ts +1 -0
  61. package/dist/providers/firebase/hosting.test.js +181 -0
  62. package/dist/providers/proxmox/index.d.ts +1 -0
  63. package/dist/providers/proxmox/index.js +1 -0
  64. package/dist/providers/proxmox/vm.d.ts +2 -1
  65. package/dist/providers/proxmox/vm.js +39 -53
  66. package/dist/providers/proxmox/vm.test.d.ts +1 -0
  67. package/dist/providers/proxmox/vm.test.js +155 -0
  68. package/dist/types/aws.d.ts +11 -0
  69. package/package.json +105 -6
@@ -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
- config = {
6
- region: Config.get().providers.do?.defaultRegion ?? 'fra1',
7
- };
7
+ _region = Config.get().providers.do?.defaultRegion ?? 'fra1';
8
8
  targetNames = [];
9
- discoveryPromise;
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
- const data = await api.get('/load_balancers?per_page=200');
17
- return data.load_balancers.find(lb => lb.name === name) ?? null;
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
- targets(droplets) {
31
- this.targetNames = droplets.map(d => (typeof d === 'string' ? d : d.name));
32
- return this;
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 = Config.isGlobalDryRun();
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
- console.log(`✅ Load balancer ${this.name} already exists (ip=${existing.ip}).`);
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] Create load balancer ${this.name} targeting [${this.targetNames.join(', ')}]`);
44
- return this.config;
244
+ console.log(` 📝 [PLAN] Would delete load balancer id=${this.lbId}`);
45
245
  }
46
- const dropletIds = await this.resolveDropletIds();
47
- const api = getDoApi();
48
- const result = await api.post('/load_balancers', {
49
- name: this.name,
50
- region: this.config.region,
51
- forwarding_rules: [
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 { readFileSync } from 'node:fs';
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;