@ebowwa/hetzner 0.1.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.
Files changed (46) hide show
  1. package/actions.js +802 -0
  2. package/actions.ts +1053 -0
  3. package/auth.js +35 -0
  4. package/auth.ts +37 -0
  5. package/bootstrap/FIREWALL.md +326 -0
  6. package/bootstrap/KERNEL-HARDENING.md +258 -0
  7. package/bootstrap/SECURITY-INTEGRATION.md +281 -0
  8. package/bootstrap/TESTING.md +301 -0
  9. package/bootstrap/cloud-init.js +279 -0
  10. package/bootstrap/cloud-init.ts +394 -0
  11. package/bootstrap/firewall.js +279 -0
  12. package/bootstrap/firewall.ts +342 -0
  13. package/bootstrap/genesis.js +406 -0
  14. package/bootstrap/genesis.ts +518 -0
  15. package/bootstrap/index.js +35 -0
  16. package/bootstrap/index.ts +71 -0
  17. package/bootstrap/kernel-hardening.js +266 -0
  18. package/bootstrap/kernel-hardening.test.ts +230 -0
  19. package/bootstrap/kernel-hardening.ts +272 -0
  20. package/bootstrap/security-audit.js +118 -0
  21. package/bootstrap/security-audit.ts +124 -0
  22. package/bootstrap/ssh-hardening.js +182 -0
  23. package/bootstrap/ssh-hardening.ts +192 -0
  24. package/client.js +137 -0
  25. package/client.ts +177 -0
  26. package/config.js +5 -0
  27. package/config.ts +5 -0
  28. package/errors.js +270 -0
  29. package/errors.ts +371 -0
  30. package/index.js +28 -0
  31. package/index.ts +55 -0
  32. package/package.json +56 -0
  33. package/pricing.js +284 -0
  34. package/pricing.ts +422 -0
  35. package/schemas.js +660 -0
  36. package/schemas.ts +765 -0
  37. package/server-status.ts +81 -0
  38. package/servers.js +424 -0
  39. package/servers.ts +568 -0
  40. package/ssh-keys.js +90 -0
  41. package/ssh-keys.ts +122 -0
  42. package/ssh-setup.ts +218 -0
  43. package/types.js +96 -0
  44. package/types.ts +389 -0
  45. package/volumes.js +172 -0
  46. package/volumes.ts +229 -0
package/actions.ts ADDED
@@ -0,0 +1,1053 @@
1
+ /**
2
+ * Hetzner Cloud API action polling and management
3
+ *
4
+ * Provides a robust polling service for monitoring async Hetzner actions
5
+ * with progress tracking, error handling, and cancellation support.
6
+ */
7
+
8
+ import type { HetznerClient } from "./client.js";
9
+ import type {
10
+ HetznerAction,
11
+ ActionPollingOptions,
12
+ RateLimitInfo,
13
+ } from "./types.js";
14
+ import {
15
+ ActionStatus,
16
+ ActionCommand,
17
+ } from "./types.js";
18
+ import {
19
+ HetznerActionError,
20
+ HetznerTimeoutError,
21
+ isRetryableError,
22
+ calculateRetryDelay,
23
+ type ErrorHandler,
24
+ } from "./errors.js";
25
+
26
+ // ============================================================================
27
+ // Enhanced Polling Options
28
+ // ============================================================================
29
+
30
+ /**
31
+ * Enhanced options for action polling with more granular control
32
+ */
33
+ export interface EnhancedActionPollingOptions {
34
+ /** Polling interval in milliseconds (default: 2000) */
35
+ pollInterval?: number;
36
+ /** Maximum number of polling attempts before timeout */
37
+ maxRetries?: number;
38
+ /** Maximum time to wait in milliseconds before timeout */
39
+ timeout?: number;
40
+ /** Callback fired on each progress update */
41
+ onProgress?: (action: HetznerAction) => void;
42
+ /** Callback fired when action completes successfully */
43
+ onComplete?: (action: HetznerAction) => void;
44
+ /** Callback fired when action fails */
45
+ onError?: (error: HetznerActionError, action: HetznerAction) => void;
46
+ /** Callback fired on each retry attempt */
47
+ onRetry?: (attempt: number, delay: number) => void;
48
+ /** Abort signal for cancellation */
49
+ signal?: AbortSignal;
50
+ /** Whether to use adaptive polling intervals (default: false) */
51
+ adaptive?: boolean;
52
+ /** Concurrency limit for multiple actions (default: 5) */
53
+ concurrency?: number;
54
+ }
55
+
56
+ /**
57
+ * Result of a polling operation
58
+ */
59
+ export interface PollingResult<T = HetznerAction> {
60
+ /** Whether the operation completed successfully */
61
+ success: boolean;
62
+ /** The final action state (if successful) */
63
+ action?: HetznerAction;
64
+ /** Error that occurred (if failed) */
65
+ error?: Error;
66
+ /** Number of polling attempts made */
67
+ attempts: number;
68
+ /** Total time elapsed in milliseconds */
69
+ elapsed: number;
70
+ }
71
+
72
+ /**
73
+ * Result for batch polling operations
74
+ */
75
+ export interface BatchPollingResult {
76
+ /** Map of action ID to result */
77
+ results: Map<number, PollingResult>;
78
+ /** Count of successful actions */
79
+ successful: number;
80
+ /** Count of failed actions */
81
+ failed: number;
82
+ /** Total time elapsed in milliseconds */
83
+ elapsed: number;
84
+ }
85
+
86
+ // ============================================================================
87
+ // Action Operations Class
88
+ // ============================================================================
89
+
90
+ /**
91
+ * Action operations for the Hetzner Cloud API
92
+ *
93
+ * Provides methods for managing and polling actions with enhanced
94
+ * polling capabilities including cancellation, progress tracking,
95
+ * and batch operations.
96
+ */
97
+ export class ActionOperations {
98
+ constructor(private client: HetznerClient) {}
99
+
100
+ /**
101
+ * Get a specific action by ID
102
+ *
103
+ * @param id - Action ID
104
+ * @returns Action details
105
+ */
106
+ async get(id: number): Promise<HetznerAction> {
107
+ const response = await this.client.request<{ action: HetznerAction }>(
108
+ `/actions/${id}`
109
+ );
110
+ return response.action;
111
+ }
112
+
113
+ /**
114
+ * List all actions
115
+ *
116
+ * @param options - Optional filters (status, sort, etc.)
117
+ * @returns Array of actions
118
+ */
119
+ async list(options?: {
120
+ status?: ActionStatus;
121
+ sort?: string;
122
+ page?: number;
123
+ per_page?: number;
124
+ }): Promise<HetznerAction[]> {
125
+ const params = new URLSearchParams();
126
+ if (options?.status) params.append("status", options.status);
127
+ if (options?.sort) params.append("sort", options.sort);
128
+ if (options?.page) params.append("page", options.page.toString());
129
+ if (options?.per_page)
130
+ params.append("per_page", options.per_page.toString());
131
+
132
+ const query = params.toString();
133
+ const response = await this.client.request<{ actions: HetznerAction[] }>(
134
+ `/actions${query ? `?${query}` : ""}`
135
+ );
136
+ return response.actions;
137
+ }
138
+
139
+ /**
140
+ * Poll a single action until completion (enhanced version)
141
+ *
142
+ * This is the main polling service method with full feature support:
143
+ * - Progress tracking with onProgress callback
144
+ * - Success/failure callbacks
145
+ * - Cancellation via AbortSignal
146
+ * - Adaptive polling intervals
147
+ * - Comprehensive error handling
148
+ *
149
+ * @param actionId - Action ID to poll
150
+ * @param options - Polling options
151
+ * @returns Promise resolving to the completed action
152
+ *
153
+ * @example
154
+ * ```typescript
155
+ * const action = await client.actions.poll(123, {
156
+ * onProgress: (a) => console.log(`Progress: ${a.progress}%`),
157
+ * onComplete: (a) => console.log('Done!'),
158
+ * onError: (e) => console.error('Failed:', e.message),
159
+ * timeout: 300000, // 5 minutes
160
+ * signal: abortController.signal
161
+ * });
162
+ * ```
163
+ */
164
+ async poll(
165
+ actionId: number,
166
+ options: EnhancedActionPollingOptions = {}
167
+ ): Promise<HetznerAction> {
168
+ return pollAction(this.client, actionId, options);
169
+ }
170
+
171
+ /**
172
+ * Poll multiple actions until completion
173
+ *
174
+ * Handles concurrent polling of multiple actions with optional
175
+ * concurrency limit and individual action tracking.
176
+ *
177
+ * @param actionIds - Array of action IDs to poll
178
+ * @param options - Polling options
179
+ * @returns Promise resolving to array of completed actions
180
+ *
181
+ * @example
182
+ * ```typescript
183
+ * const actions = await client.actions.pollMany([1, 2, 3], {
184
+ * onProgress: (a) => updateUI(a),
185
+ * concurrency: 3, // Poll 3 at a time
186
+ * signal: abortController.signal
187
+ * });
188
+ * ```
189
+ */
190
+ async pollMany(
191
+ actionIds: number[],
192
+ options: EnhancedActionPollingOptions = {}
193
+ ): Promise<HetznerAction[]> {
194
+ return pollActions(this.client, actionIds, options);
195
+ }
196
+
197
+ /**
198
+ * Poll multiple actions with detailed result tracking
199
+ *
200
+ * Returns a BatchPollingResult with success/failure counts and
201
+ * per-action results for better error handling.
202
+ *
203
+ * @param actionIds - Array of action IDs to poll
204
+ * @param options - Polling options
205
+ * @returns Batch polling result with detailed status
206
+ *
207
+ * @example
208
+ * ```typescript
209
+ * const result = await client.actions.pollManyDetailed([1, 2, 3], {
210
+ * concurrency: 2
211
+ * });
212
+ * console.log(`Success: ${result.successful}, Failed: ${result.failed}`);
213
+ * for (const [id, r] of result.results) {
214
+ * if (r.error) console.error(`Action ${id} failed:`, r.error);
215
+ * }
216
+ * ```
217
+ */
218
+ async pollManyDetailed(
219
+ actionIds: number[],
220
+ options: EnhancedActionPollingOptions = {}
221
+ ): Promise<BatchPollingResult> {
222
+ return pollActionsDetailed(this.client, actionIds, options);
223
+ }
224
+
225
+ /**
226
+ * Wait for an action to complete (backward compatible alias)
227
+ *
228
+ * @param id - Action ID
229
+ * @param options - Polling options
230
+ * @returns Completed action
231
+ * @deprecated Use poll() instead for new code
232
+ */
233
+ async waitFor(
234
+ id: number,
235
+ options?: ActionPollingOptions
236
+ ): Promise<HetznerAction> {
237
+ return waitForAction(this.client, id, options);
238
+ }
239
+
240
+ /**
241
+ * Wait for multiple actions to complete (backward compatible alias)
242
+ *
243
+ * @param ids - Array of action IDs
244
+ * @param options - Polling options
245
+ * @returns Array of completed actions
246
+ * @deprecated Use pollMany() instead for new code
247
+ */
248
+ async waitForMany(
249
+ ids: number[],
250
+ options?: ActionPollingOptions
251
+ ): Promise<HetznerAction[]> {
252
+ return waitForMultipleActions(this.client, ids, options);
253
+ }
254
+
255
+ /**
256
+ * Batch check multiple actions (no polling, single fetch)
257
+ *
258
+ * @param ids - Array of action IDs
259
+ * @returns Map of action ID to action
260
+ */
261
+ async batchCheck(
262
+ ids: number[]
263
+ ): Promise<Map<number, HetznerAction>> {
264
+ return batchCheckActions(this.client, ids);
265
+ }
266
+ }
267
+
268
+ // ============================================================================
269
+ // Action Timeouts
270
+ // ============================================================================
271
+
272
+ /**
273
+ * Default timeouts for different action types (in milliseconds)
274
+ * Adjusted based on typical operation durations
275
+ */
276
+ export const ACTION_TIMEOUTS: Record<ActionCommand, number> = {
277
+ // Quick operations (under 1 minute)
278
+ [ActionCommand.StartServer]: 60000,
279
+ [ActionCommand.StopServer]: 60000,
280
+ [ActionCommand.RebootServer]: 120000,
281
+ [ActionCommand.Poweroff]: 60000,
282
+ [ActionCommand.ShutdownServer]: 60000,
283
+ [ActionCommand.ResetServer]: 60000,
284
+
285
+ // Medium operations (1-5 minutes)
286
+ [ActionCommand.CreateServer]: 300000,
287
+ [ActionCommand.DeleteServer]: 180000,
288
+ [ActionCommand.ChangeServerType]: 600000,
289
+ [ActionCommand.ChangeDnsPtr]: 30000,
290
+
291
+ // Long operations (5-30 minutes)
292
+ [ActionCommand.RebuildServer]: 900000,
293
+ [ActionCommand.CreateImage]: 1800000,
294
+ [ActionCommand.EnableRescue]: 120000,
295
+ [ActionCommand.DisableRescue]: 60000,
296
+
297
+ // Volume operations
298
+ [ActionCommand.CreateVolume]: 300000,
299
+ [ActionCommand.DeleteVolume]: 180000,
300
+ [ActionCommand.AttachVolume]: 120000,
301
+ [ActionCommand.DetachVolume]: 60000,
302
+ [ActionCommand.ResizeVolume]: 600000,
303
+
304
+ // Network operations
305
+ [ActionCommand.AddSubnet]: 60000,
306
+ [ActionCommand.DeleteSubnet]: 60000,
307
+ [ActionCommand.AddRoute]: 60000,
308
+ [ActionCommand.DeleteRoute]: 60000,
309
+ [ActionCommand.ChangeIpRange]: 120000,
310
+ [ActionCommand.AttachToNetwork]: 60000,
311
+ [ActionCommand.DetachFromNetwork]: 60000,
312
+
313
+ // Floating IP operations
314
+ [ActionCommand.AssignFloatingIp]: 60000,
315
+ [ActionCommand.UnassignFloatingIp]: 60000,
316
+
317
+ // Load Balancer operations
318
+ [ActionCommand.CreateLoadBalancer]: 300000,
319
+ [ActionCommand.DeleteLoadBalancer]: 180000,
320
+ [ActionCommand.AddTarget]: 60000,
321
+ [ActionCommand.RemoveTarget]: 60000,
322
+ [ActionCommand.AddService]: 60000,
323
+ [ActionCommand.UpdateService]: 60000,
324
+ [ActionCommand.DeleteService]: 60000,
325
+ [ActionCommand.LoadBalancerAttachToNetwork]: 60000,
326
+ [ActionCommand.LoadBalancerDetachFromNetwork]: 60000,
327
+ [ActionCommand.ChangeAlgorithm]: 60000,
328
+ [ActionCommand.ChangeType]: 60000,
329
+
330
+ // Certificate operations
331
+ [ActionCommand.IssueCertificate]: 600000, // Can take up to 10 minutes
332
+ [ActionCommand.RetryCertificate]: 600000,
333
+
334
+ // Firewall operations
335
+ [ActionCommand.SetFirewallRules]: 60000,
336
+ [ActionCommand.ApplyFirewall]: 120000,
337
+ [ActionCommand.RemoveFirewall]: 60000,
338
+
339
+ // Floating IP DNS
340
+ [ActionCommand.FloatingIpChangeDnsPtr]: 30000,
341
+
342
+ // Backup operations
343
+ [ActionCommand.EnableBackup]: 60000,
344
+ [ActionCommand.DisableBackup]: 60000,
345
+
346
+ // Protection operations
347
+ [ActionCommand.ChangeProtection]: 30000,
348
+ [ActionCommand.VolumeChangeProtection]: 30000,
349
+ [ActionCommand.NetworkChangeProtection]: 30000,
350
+ [ActionCommand.FloatingIpChangeProtection]: 30000,
351
+ [ActionCommand.LoadBalancerChangeProtection]: 30000,
352
+ [ActionCommand.FirewallChangeProtection]: 30000,
353
+ [ActionCommand.ImageChangeProtection]: 30000,
354
+
355
+ // Other operations
356
+ [ActionCommand.ChangeAliasIps]: 60000,
357
+ };
358
+
359
+ /**
360
+ * Get default timeout for an action command
361
+ */
362
+ export function getActionTimeout(command: ActionCommand): number {
363
+ return ACTION_TIMEOUTS[command] || 300000; // Default 5 minutes
364
+ }
365
+
366
+ // ============================================================================
367
+ // Enhanced Action Polling Service
368
+ // ============================================================================
369
+
370
+ /**
371
+ * Default polling options
372
+ */
373
+ const DEFAULT_POLLING_OPTIONS: Omit<Required<EnhancedActionPollingOptions>, 'signal'> & { signal: AbortSignal | undefined } = {
374
+ pollInterval: 2000,
375
+ maxRetries: 60,
376
+ timeout: 300000,
377
+ onProgress: () => {},
378
+ onComplete: () => {},
379
+ onError: () => {},
380
+ onRetry: () => {},
381
+ signal: undefined,
382
+ adaptive: false,
383
+ concurrency: 5,
384
+ };
385
+
386
+ /**
387
+ * Poll for an action to complete with full feature support
388
+ *
389
+ * This is the core polling service function that provides:
390
+ * - Progress tracking via onProgress callback
391
+ * - Success/error notification via onComplete/onError
392
+ * - Cancellation support via AbortSignal
393
+ * - Adaptive polling intervals
394
+ * - Retry logic with exponential backoff
395
+ *
396
+ * @param client - Hetzner API client
397
+ * @param actionId - Action ID to poll
398
+ * @param options - Polling options
399
+ * @returns Completed action
400
+ * @throws {HetznerActionError} If action fails
401
+ * @throws {HetznerTimeoutError} If action times out
402
+ *
403
+ * @example
404
+ * ```typescript
405
+ * const action = await pollAction(client, 123, {
406
+ * onProgress: (a) => console.log(`Progress: ${a.progress}%`),
407
+ * onComplete: (a) => console.log('Completed:', a.command),
408
+ * onError: (e) => console.error('Failed:', e.message),
409
+ * timeout: 60000,
410
+ * signal: abortSignal
411
+ * });
412
+ * ```
413
+ */
414
+ export async function pollAction(
415
+ client: HetznerClient,
416
+ actionId: number,
417
+ options: EnhancedActionPollingOptions = {}
418
+ ): Promise<HetznerAction> {
419
+ const opts = { ...DEFAULT_POLLING_OPTIONS, ...options };
420
+ const startTime = Date.now();
421
+ let lastProgress = 0;
422
+ let lastError: Error | null = null;
423
+ let attempt = 0;
424
+
425
+ // Check if already aborted
426
+ if (opts.signal?.aborted) {
427
+ throw new Error("Polling aborted before start");
428
+ }
429
+
430
+ // Set up abort listener
431
+ const abortListener = () => {
432
+ throw new Error("Polling aborted");
433
+ };
434
+ opts.signal?.addEventListener("abort", abortListener);
435
+
436
+ try {
437
+ while (attempt < opts.maxRetries) {
438
+ // Check timeout
439
+ const elapsed = Date.now() - startTime;
440
+ if (elapsed > opts.timeout) {
441
+ throw new HetznerTimeoutError(actionId, opts.timeout, lastProgress);
442
+ }
443
+
444
+ try {
445
+ const response = await client.request<{ action: HetznerAction }>(
446
+ `/actions/${actionId}`
447
+ );
448
+ const action = response.action;
449
+ attempt++;
450
+
451
+ // Notify progress callback on any change
452
+ if (action.progress !== lastProgress) {
453
+ lastProgress = action.progress;
454
+ opts.onProgress(action);
455
+ }
456
+
457
+ // Check if action completed successfully
458
+ if (action.status === "success") {
459
+ opts.onComplete(action);
460
+ return action;
461
+ }
462
+
463
+ // Check if action failed
464
+ if (action.status === "error") {
465
+ const error = new HetznerActionError(
466
+ action.error!,
467
+ actionId
468
+ );
469
+ opts.onError(error, action);
470
+ throw error;
471
+ }
472
+
473
+ // Still running - calculate wait time
474
+ let waitTime = opts.pollInterval;
475
+ if (opts.adaptive) {
476
+ waitTime = getAdaptivePollInterval(action.progress);
477
+ }
478
+
479
+ await sleep(waitTime);
480
+
481
+ } catch (error) {
482
+ // Check if this is a retryable error
483
+ if (isRetryableError(error)) {
484
+ const delay = calculateRetryDelay(attempt);
485
+ opts.onRetry(attempt, delay);
486
+
487
+ if (opts.signal?.aborted) {
488
+ throw new Error("Polling aborted during retry");
489
+ }
490
+
491
+ await sleep(delay);
492
+ continue;
493
+ }
494
+
495
+ // Non-retryable error - throw immediately
496
+ throw error;
497
+ }
498
+ }
499
+
500
+ // Max retries exceeded
501
+ throw new HetznerTimeoutError(actionId, opts.timeout, lastProgress);
502
+
503
+ } finally {
504
+ opts.signal?.removeEventListener("abort", abortListener);
505
+ }
506
+ }
507
+
508
+ /**
509
+ * Poll multiple actions concurrently
510
+ *
511
+ * Manages polling of multiple actions with optional concurrency limit.
512
+ * Each action is polled independently with shared callbacks.
513
+ *
514
+ * @param client - Hetzner API client
515
+ * @param actionIds - Array of action IDs to poll
516
+ * @param options - Polling options
517
+ * @returns Array of completed actions in same order as input
518
+ *
519
+ * @example
520
+ * ```typescript
521
+ * const actions = await pollActions(client, [1, 2, 3], {
522
+ * onProgress: (a) => console.log(`Action ${a.id}: ${a.progress}%`),
523
+ * onComplete: (a) => console.log(`Action ${a.id} complete`),
524
+ * concurrency: 3 // Poll 3 actions at a time
525
+ * });
526
+ * ```
527
+ */
528
+ export async function pollActions(
529
+ client: HetznerClient,
530
+ actionIds: number[],
531
+ options: EnhancedActionPollingOptions = {}
532
+ ): Promise<HetznerAction[]> {
533
+ const concurrency = options.concurrency ?? DEFAULT_POLLING_OPTIONS.concurrency;
534
+
535
+ // If concurrency is high enough, poll all at once
536
+ if (concurrency >= actionIds.length) {
537
+ const promises = actionIds.map((id) =>
538
+ pollAction(client, id, options)
539
+ );
540
+ return Promise.all(promises);
541
+ }
542
+
543
+ // Otherwise, process in batches with concurrency limit
544
+ const results: HetznerAction[] = new Array(actionIds.length);
545
+ let currentIndex = 0;
546
+
547
+ const processBatch = async (): Promise<void> => {
548
+ while (currentIndex < actionIds.length) {
549
+ const batchStart = currentIndex;
550
+ const batchEnd = Math.min(currentIndex + concurrency, actionIds.length);
551
+ const batchIds = actionIds.slice(batchStart, batchEnd);
552
+ currentIndex = batchEnd;
553
+
554
+ const batchResults = await Promise.all(
555
+ batchIds.map((id, i) => pollAction(client, id, options))
556
+ );
557
+
558
+ // Store results in correct positions
559
+ batchResults.forEach((result, i) => {
560
+ results[batchStart + i] = result;
561
+ });
562
+ }
563
+ };
564
+
565
+ // Process batches sequentially but actions within each batch concurrently
566
+ await processBatch();
567
+ return results;
568
+ }
569
+
570
+ /**
571
+ * Poll multiple actions with detailed result tracking
572
+ *
573
+ * Similar to pollActions but returns a BatchPollingResult with
574
+ * success/failure counts and per-action results for better
575
+ * error handling and monitoring.
576
+ *
577
+ * @param client - Hetzner API client
578
+ * @param actionIds - Array of action IDs to poll
579
+ * @param options - Polling options
580
+ * @returns Batch polling result with detailed status
581
+ *
582
+ * @example
583
+ * ```typescript
584
+ * const result = await pollActionsDetailed(client, [1, 2, 3], {
585
+ * onProgress: (a) => updateProgressBar(a)
586
+ * });
587
+ *
588
+ * console.log(`Completed: ${result.successful}/${actionIds.length}`);
589
+ * for (const [id, r] of result.results) {
590
+ * if (r.error) {
591
+ * console.error(`Action ${id} failed:`, r.error.message);
592
+ * }
593
+ * }
594
+ * ```
595
+ */
596
+ export async function pollActionsDetailed(
597
+ client: HetznerClient,
598
+ actionIds: number[],
599
+ options: EnhancedActionPollingOptions = {}
600
+ ): Promise<BatchPollingResult> {
601
+ const startTime = Date.now();
602
+ const results = new Map<number, PollingResult>();
603
+ let successful = 0;
604
+ let failed = 0;
605
+
606
+ // Wrap callbacks to track results
607
+ const wrappedOptions: EnhancedActionPollingOptions = {
608
+ ...options,
609
+ onComplete: (action) => {
610
+ results.set(action.id, {
611
+ success: true,
612
+ action,
613
+ attempts: 0,
614
+ elapsed: Date.now() - startTime,
615
+ });
616
+ successful++;
617
+ options.onComplete?.(action);
618
+ },
619
+ onError: (error, action) => {
620
+ results.set(action.id, {
621
+ success: false,
622
+ action,
623
+ error,
624
+ attempts: 0,
625
+ elapsed: Date.now() - startTime,
626
+ });
627
+ failed++;
628
+ options.onError?.(error, action);
629
+ },
630
+ };
631
+
632
+ try {
633
+ // Poll all actions
634
+ await pollActions(client, actionIds, wrappedOptions);
635
+ } catch (error) {
636
+ // Some actions may have failed, but we still return results
637
+ console.warn("Some actions failed during batch polling:", error);
638
+ }
639
+
640
+ return {
641
+ results,
642
+ successful,
643
+ failed,
644
+ elapsed: Date.now() - startTime,
645
+ };
646
+ }
647
+
648
+ // ============================================================================
649
+ // Backward Compatible Polling Functions
650
+ // ============================================================================
651
+
652
+ /**
653
+ * Poll for an action to complete (backward compatible)
654
+ *
655
+ * @deprecated Use pollAction() instead for new code
656
+ */
657
+ export async function waitForAction(
658
+ client: HetznerClient,
659
+ actionId: number,
660
+ options: ActionPollingOptions = {}
661
+ ): Promise<HetznerAction> {
662
+ const opts = { ...DEFAULT_POLLING_OPTIONS, ...options };
663
+ const startTime = Date.now();
664
+ let lastProgress = 0;
665
+ let lastError: Error | null = null;
666
+
667
+ for (let attempt = 0; attempt < opts.maxRetries; attempt++) {
668
+ // Check timeout
669
+ if (Date.now() - startTime > opts.timeout) {
670
+ throw new HetznerTimeoutError(actionId, opts.timeout, lastProgress);
671
+ }
672
+
673
+ try {
674
+ const action = await client.request<{ action: HetznerAction }>(
675
+ `/actions/${actionId}`
676
+ );
677
+
678
+ // Notify progress callback
679
+ if (action.action.progress !== lastProgress) {
680
+ lastProgress = action.action.progress;
681
+ opts.onProgress(action.action);
682
+ }
683
+
684
+ // Check if action is complete
685
+ if (action.action.status === "success") {
686
+ return action.action;
687
+ }
688
+
689
+ if (action.action.status === "error") {
690
+ throw new HetznerActionError(
691
+ action.action.error!,
692
+ actionId
693
+ );
694
+ }
695
+
696
+ // Still running - wait before next poll
697
+ await sleep(opts.pollInterval);
698
+
699
+ } catch (error) {
700
+ // Check if error is retryable
701
+ if (isRetryableError(error)) {
702
+ const delay = calculateRetryDelay(attempt);
703
+ console.warn(
704
+ `Retrying action ${actionId} after ${delay}ms (attempt ${attempt + 1}/${opts.maxRetries})`
705
+ );
706
+ await sleep(delay);
707
+ continue;
708
+ }
709
+
710
+ // Non-retryable error - throw immediately
711
+ throw error;
712
+ }
713
+ }
714
+
715
+ // Max retries exceeded
716
+ throw new HetznerTimeoutError(actionId, opts.timeout, lastProgress);
717
+ }
718
+
719
+ /**
720
+ * Poll for multiple actions concurrently (backward compatible)
721
+ *
722
+ * @deprecated Use pollActions() instead for new code
723
+ */
724
+ export async function waitForMultipleActions(
725
+ client: HetznerClient,
726
+ actionIds: number[],
727
+ options: ActionPollingOptions = {}
728
+ ): Promise<HetznerAction[]> {
729
+ const promises = actionIds.map((id) => waitForAction(client, id, options));
730
+ return Promise.all(promises);
731
+ }
732
+
733
+ /**
734
+ * Poll for multiple actions with concurrency limit (backward compatible)
735
+ *
736
+ * @deprecated Use pollActions() with concurrency option instead
737
+ */
738
+ export async function waitForMultipleActionsWithLimit(
739
+ client: HetznerClient,
740
+ actionIds: number[],
741
+ concurrency: number = 5,
742
+ options: ActionPollingOptions = {}
743
+ ): Promise<HetznerAction[]> {
744
+ const results: HetznerAction[] = [];
745
+ const chunks: number[][] = [];
746
+
747
+ // Split action IDs into chunks
748
+ for (let i = 0; i < actionIds.length; i += concurrency) {
749
+ chunks.push(actionIds.slice(i, i + concurrency));
750
+ }
751
+
752
+ // Process each chunk sequentially
753
+ for (const chunk of chunks) {
754
+ const chunkResults = await waitForMultipleActions(client, chunk, options);
755
+ results.push(...chunkResults);
756
+ }
757
+
758
+ return results;
759
+ }
760
+
761
+ /**
762
+ * Batch check multiple actions in a single API call
763
+ *
764
+ * Uses the /actions?id=42&id=43 endpoint to fetch multiple actions at once
765
+ *
766
+ * @param client - Hetzner API client
767
+ * @param actionIds - Array of action IDs to check
768
+ * @returns Map of action ID to action
769
+ */
770
+ export async function batchCheckActions(
771
+ client: HetznerClient,
772
+ actionIds: number[]
773
+ ): Promise<Map<number, HetznerAction>> {
774
+ const MAX_BATCH_SIZE = 50; // API limit for ID parameter
775
+ const results = new Map<number, HetznerAction>();
776
+
777
+ // Process in batches
778
+ for (let i = 0; i < actionIds.length; i += MAX_BATCH_SIZE) {
779
+ const batch = actionIds.slice(i, i + MAX_BATCH_SIZE);
780
+ const idParams = batch.map((id) => `id=${id}`).join("&");
781
+
782
+ const response = await client.request<{ actions: HetznerAction[] }>(
783
+ `/actions?${idParams}`
784
+ );
785
+
786
+ for (const action of response.actions) {
787
+ results.set(action.id, action);
788
+ }
789
+ }
790
+
791
+ return results;
792
+ }
793
+
794
+ // ============================================================================
795
+ // Action Status Utilities
796
+ // ============================================================================
797
+
798
+ /**
799
+ * Check if an action is running
800
+ */
801
+ export function isActionRunning(action: HetznerAction): boolean {
802
+ return action.status === "running";
803
+ }
804
+
805
+ /**
806
+ * Check if an action completed successfully
807
+ */
808
+ export function isActionSuccess(action: HetznerAction): boolean {
809
+ return action.status === "success";
810
+ }
811
+
812
+ /**
813
+ * Check if an action failed
814
+ */
815
+ export function isActionError(action: HetznerAction): boolean {
816
+ return action.status === "error";
817
+ }
818
+
819
+ /**
820
+ * Format action progress for display
821
+ */
822
+ export function formatActionProgress(action: HetznerAction): string {
823
+ const command = action.command.replace(/_/g, " ");
824
+ const status = action.status.toUpperCase();
825
+ return `${status}: ${command} (${action.progress}%)`;
826
+ }
827
+
828
+ /**
829
+ * Get human-readable action description
830
+ */
831
+ export function getActionDescription(command: ActionCommand | string): string {
832
+ const descriptions: Record<ActionCommand, string> = {
833
+ [ActionCommand.CreateServer]: "Creating server",
834
+ [ActionCommand.DeleteServer]: "Deleting server",
835
+ [ActionCommand.StartServer]: "Starting server",
836
+ [ActionCommand.StopServer]: "Stopping server",
837
+ [ActionCommand.RebootServer]: "Rebooting server",
838
+ [ActionCommand.ResetServer]: "Resetting server",
839
+ [ActionCommand.ShutdownServer]: "Shutting down server",
840
+ [ActionCommand.Poweroff]: "Cutting power to server",
841
+ [ActionCommand.ChangeServerType]: "Changing server type",
842
+ [ActionCommand.RebuildServer]: "Rebuilding server",
843
+ [ActionCommand.EnableBackup]: "Enabling backups",
844
+ [ActionCommand.DisableBackup]: "Disabling backups",
845
+ [ActionCommand.CreateImage]: "Creating image",
846
+ [ActionCommand.ChangeDnsPtr]: "Changing reverse DNS",
847
+ [ActionCommand.AttachToNetwork]: "Attaching to network",
848
+ [ActionCommand.DetachFromNetwork]: "Detaching from network",
849
+ [ActionCommand.ChangeAliasIps]: "Changing alias IPs",
850
+ [ActionCommand.EnableRescue]: "Enabling rescue mode",
851
+ [ActionCommand.DisableRescue]: "Disabling rescue mode",
852
+ [ActionCommand.ChangeProtection]: "Changing protection",
853
+ [ActionCommand.CreateVolume]: "Creating volume",
854
+ [ActionCommand.DeleteVolume]: "Deleting volume",
855
+ [ActionCommand.AttachVolume]: "Attaching volume",
856
+ [ActionCommand.DetachVolume]: "Detaching volume",
857
+ [ActionCommand.ResizeVolume]: "Resizing volume",
858
+ [ActionCommand.VolumeChangeProtection]: "Changing volume protection",
859
+ [ActionCommand.AddSubnet]: "Adding subnet",
860
+ [ActionCommand.DeleteSubnet]: "Deleting subnet",
861
+ [ActionCommand.AddRoute]: "Adding route",
862
+ [ActionCommand.DeleteRoute]: "Deleting route",
863
+ [ActionCommand.ChangeIpRange]: "Changing IP range",
864
+ [ActionCommand.NetworkChangeProtection]: "Changing network protection",
865
+ [ActionCommand.AssignFloatingIp]: "Assigning floating IP",
866
+ [ActionCommand.UnassignFloatingIp]: "Unassigning floating IP",
867
+ [ActionCommand.FloatingIpChangeDnsPtr]: "Changing floating IP DNS",
868
+ [ActionCommand.FloatingIpChangeProtection]: "Changing floating IP protection",
869
+ [ActionCommand.CreateLoadBalancer]: "Creating load balancer",
870
+ [ActionCommand.DeleteLoadBalancer]: "Deleting load balancer",
871
+ [ActionCommand.AddTarget]: "Adding target to load balancer",
872
+ [ActionCommand.RemoveTarget]: "Removing target from load balancer",
873
+ [ActionCommand.AddService]: "Adding service to load balancer",
874
+ [ActionCommand.UpdateService]: "Updating load balancer service",
875
+ [ActionCommand.DeleteService]: "Deleting load balancer service",
876
+ [ActionCommand.LoadBalancerAttachToNetwork]: "Attaching load balancer to network",
877
+ [ActionCommand.LoadBalancerDetachFromNetwork]: "Detaching load balancer from network",
878
+ [ActionCommand.ChangeAlgorithm]: "Changing load balancer algorithm",
879
+ [ActionCommand.ChangeType]: "Changing load balancer type",
880
+ [ActionCommand.LoadBalancerChangeProtection]: "Changing load balancer protection",
881
+ [ActionCommand.IssueCertificate]: "Issuing certificate",
882
+ [ActionCommand.RetryCertificate]: "Retrying certificate",
883
+ [ActionCommand.SetFirewallRules]: "Setting firewall rules",
884
+ [ActionCommand.ApplyFirewall]: "Applying firewall",
885
+ [ActionCommand.RemoveFirewall]: "Removing firewall",
886
+ [ActionCommand.FirewallChangeProtection]: "Changing firewall protection",
887
+ [ActionCommand.ImageChangeProtection]: "Changing image protection",
888
+ };
889
+
890
+ return (descriptions as Record<string, string>)[command] || command.replace(/_/g, " ");
891
+ }
892
+
893
+ // ============================================================================
894
+ // Adaptive Polling
895
+ // ============================================================================
896
+
897
+ /**
898
+ * Get adaptive polling interval based on operation type
899
+ */
900
+ export function getPollInterval(command: ActionCommand): number {
901
+ const intervals: Partial<Record<ActionCommand, number>> = {
902
+ // Quick operations - poll frequently
903
+ [ActionCommand.StartServer]: 5000,
904
+ [ActionCommand.StopServer]: 5000,
905
+ [ActionCommand.RebootServer]: 10000,
906
+ [ActionCommand.Poweroff]: 5000,
907
+ [ActionCommand.ShutdownServer]: 5000,
908
+
909
+ // Medium operations
910
+ [ActionCommand.CreateServer]: 15000,
911
+ [ActionCommand.CreateVolume]: 10000,
912
+ [ActionCommand.AttachVolume]: 8000,
913
+ [ActionCommand.DetachVolume]: 5000,
914
+
915
+ // Long operations - poll less frequently
916
+ [ActionCommand.RebuildServer]: 30000,
917
+ [ActionCommand.CreateImage]: 60000,
918
+ [ActionCommand.IssueCertificate]: 30000,
919
+ };
920
+
921
+ return intervals[command] || 10000; // Default 10 seconds
922
+ }
923
+
924
+ /**
925
+ * Get adaptive polling interval based on action progress
926
+ */
927
+ export function getAdaptivePollInterval(progress: number): number {
928
+ if (progress < 10) return 2000; // Quick progress at start
929
+ if (progress < 50) return 5000; // Medium progress
930
+ if (progress < 90) return 10000; // Slowing down
931
+ return 15000; // Near completion
932
+ }
933
+
934
+ /**
935
+ * Poll with adaptive intervals (backward compatible)
936
+ *
937
+ * @deprecated Use pollAction() with adaptive: true option instead
938
+ */
939
+ export async function waitForActionAdaptive(
940
+ client: HetznerClient,
941
+ actionId: number,
942
+ command: ActionCommand,
943
+ options: ActionPollingOptions = {}
944
+ ): Promise<HetznerAction> {
945
+ const startTime = Date.now();
946
+ const timeout = options.timeout ?? getActionTimeout(command);
947
+ let lastProgress = -1;
948
+
949
+ while (Date.now() - startTime < timeout) {
950
+ const action = await client.request<{ action: HetznerAction }>(
951
+ `/actions/${actionId}`
952
+ );
953
+
954
+ // Notify progress callback
955
+ if (action.action.progress !== lastProgress) {
956
+ lastProgress = action.action.progress;
957
+ options.onProgress?.(action.action);
958
+ }
959
+
960
+ if (action.action.status === "success") {
961
+ return action.action;
962
+ }
963
+
964
+ if (action.action.status === "error") {
965
+ throw new HetznerActionError(action.action.error!, actionId);
966
+ }
967
+
968
+ // Adaptive polling based on progress
969
+ const interval = getAdaptivePollInterval(action.action.progress);
970
+ await sleep(interval);
971
+ }
972
+
973
+ throw new HetznerTimeoutError(actionId, timeout, lastProgress);
974
+ }
975
+
976
+ // ============================================================================
977
+ // Rate Limit Handling
978
+ // ============================================================================
979
+
980
+ /**
981
+ * Parse rate limit headers from response
982
+ */
983
+ export function parseRateLimitHeaders(headers: Headers): RateLimitInfo | null {
984
+ const limit = headers.get("RateLimit-Limit");
985
+ const remaining = headers.get("RateLimit-Remaining");
986
+ const reset = headers.get("RateLimit-Reset");
987
+
988
+ if (!limit || !remaining || !reset) {
989
+ return null;
990
+ }
991
+
992
+ return {
993
+ limit: parseInt(limit, 10),
994
+ remaining: parseInt(remaining, 10),
995
+ reset: parseInt(reset, 10),
996
+ };
997
+ }
998
+
999
+ /**
1000
+ * Check if rate limit is low (should warn user)
1001
+ */
1002
+ export function isRateLimitLow(info: RateLimitInfo, threshold: number = 100): boolean {
1003
+ return info.remaining < threshold;
1004
+ }
1005
+
1006
+ /**
1007
+ * Get human-readable rate limit status
1008
+ */
1009
+ export function formatRateLimitStatus(info: RateLimitInfo): string {
1010
+ const resetDate = new Date(info.reset * 1000);
1011
+ const remaining = info.remaining;
1012
+ const limit = info.limit;
1013
+ const percentage = ((remaining / limit) * 100).toFixed(1);
1014
+
1015
+ return `${remaining}/${limit} (${percentage}%) - resets at ${resetDate.toISOString()}`;
1016
+ }
1017
+
1018
+ /**
1019
+ * Wait for rate limit to reset
1020
+ */
1021
+ export async function waitForRateLimitReset(info: RateLimitInfo): Promise<void> {
1022
+ const resetTime = info.reset * 1000;
1023
+ const now = Date.now();
1024
+ const waitTime = Math.max(0, resetTime - now);
1025
+
1026
+ if (waitTime > 0) {
1027
+ const waitSeconds = Math.ceil(waitTime / 1000);
1028
+ console.log(`Rate limit exhausted. Waiting ${waitSeconds}s for reset...`);
1029
+ await sleep(waitTime);
1030
+ }
1031
+ }
1032
+
1033
+ // ============================================================================
1034
+ // Utilities
1035
+ // ============================================================================
1036
+
1037
+ /**
1038
+ * Sleep for specified milliseconds
1039
+ */
1040
+ function sleep(ms: number): Promise<void> {
1041
+ return new Promise((resolve) => setTimeout(resolve, ms));
1042
+ }
1043
+
1044
+ /**
1045
+ * Create a progress logger for actions
1046
+ */
1047
+ export function createProgressLogger(prefix: string = "Action") {
1048
+ return (action: HetznerAction) => {
1049
+ console.log(
1050
+ `[${prefix}] ${getActionDescription(action.command)}: ${action.progress}%`
1051
+ );
1052
+ };
1053
+ }