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