@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/pricing.ts ADDED
@@ -0,0 +1,422 @@
1
+ /**
2
+ * Hetzner pricing operations - fetch server types, locations, and calculate costs
3
+ */
4
+
5
+ import { z } from "zod";
6
+ import type {
7
+ HetznerServerType,
8
+ HetznerLocation,
9
+ HetznerDatacenter,
10
+ } from "./types.js";
11
+ import type { HetznerClient } from "./client.js";
12
+ import type { Environment } from "@ebowwa/codespaces-types/compile";
13
+ import {
14
+ HetznerListServerTypesResponseSchema,
15
+ HetznerListLocationsResponseSchema,
16
+ HetznerListDatacentersResponseSchema,
17
+ } from "./schemas.js";
18
+
19
+ // ============================================================================
20
+ // Cost Calculation Types
21
+ // ============================================================================
22
+
23
+ /**
24
+ * Price information for a server type (in EUR)
25
+ */
26
+ export interface ServerTypePrice {
27
+ /** Server type name (e.g., "cpx11") */
28
+ serverType: string;
29
+ /** Monthly price in EUR */
30
+ priceMonthly: number;
31
+ /** Hourly price in EUR */
32
+ priceHourly: number;
33
+ /** Whether the server type is deprecated */
34
+ deprecated: boolean;
35
+ }
36
+
37
+ /**
38
+ * Cost breakdown for a single environment
39
+ */
40
+ export interface EnvironmentCost {
41
+ /** Environment ID */
42
+ environmentId: string;
43
+ /** Environment name */
44
+ environmentName: string;
45
+ /** Server type name */
46
+ serverType: string;
47
+ /** Whether the environment is currently running */
48
+ isRunning: boolean;
49
+ /** Monthly cost in EUR */
50
+ costMonthly: number;
51
+ /** Hourly cost in EUR */
52
+ costHourly: number;
53
+ /** Price info (undefined if server type not found) */
54
+ priceInfo?: ServerTypePrice;
55
+ }
56
+
57
+ /**
58
+ * Total cost calculation result
59
+ */
60
+ export interface CostCalculationResult {
61
+ /** Total monthly cost for all running environments (EUR) */
62
+ totalMonthly: number;
63
+ /** Total hourly cost for all running environments (EUR) */
64
+ totalHourly: number;
65
+ /** Number of environments included in calculation */
66
+ runningEnvironmentCount: number;
67
+ /** Number of environments excluded (not running) */
68
+ stoppedEnvironmentCount: number;
69
+ /** Number of environments with unknown server types */
70
+ unknownServerTypeCount: number;
71
+ /** Detailed breakdown by environment */
72
+ breakdown: EnvironmentCost[];
73
+ /** Map of server type names to their prices (for reference) */
74
+ priceMap: Map<string, ServerTypePrice>;
75
+ }
76
+
77
+ /**
78
+ * Default fallback price for unknown server types (EUR/month)
79
+ * Conservative estimate based on Hetzner's smallest standard server
80
+ */
81
+ const DEFAULT_FALLBACK_PRICE_MONTHLY = 5.0;
82
+
83
+ /**
84
+ * Hours per month for hourly cost calculation
85
+ * Using 730 hours (average month: 365.25 days * 24 hours / 12 months)
86
+ */
87
+ const HOURS_PER_MONTH = 730;
88
+
89
+ // ============================================================================
90
+ // Zod Schemas for Cost Calculation
91
+ // ============================================================================
92
+
93
+ /**
94
+ * Schema for server type price entry
95
+ */
96
+ export const ServerTypePriceSchema = z.object({
97
+ serverType: z.string(),
98
+ priceMonthly: z.number().nonnegative(),
99
+ priceHourly: z.number().nonnegative(),
100
+ deprecated: z.boolean().optional().default(false),
101
+ });
102
+
103
+ /**
104
+ * Schema for environment cost entry
105
+ */
106
+ export const EnvironmentCostSchema = z.object({
107
+ environmentId: z.string(),
108
+ environmentName: z.string(),
109
+ serverType: z.string(),
110
+ isRunning: z.boolean(),
111
+ costMonthly: z.number().nonnegative(),
112
+ costHourly: z.number().nonnegative(),
113
+ priceInfo: ServerTypePriceSchema.optional(),
114
+ });
115
+
116
+ /**
117
+ * Schema for cost calculation result
118
+ */
119
+ export const CostCalculationResultSchema = z.object({
120
+ totalMonthly: z.number().nonnegative(),
121
+ totalHourly: z.number().nonnegative(),
122
+ runningEnvironmentCount: z.number().nonnegative().int(),
123
+ stoppedEnvironmentCount: z.number().nonnegative().int(),
124
+ unknownServerTypeCount: z.number().nonnegative().int(),
125
+ breakdown: z.array(EnvironmentCostSchema),
126
+ priceMap: z.custom<Map<string, ServerTypePrice>>(),
127
+ });
128
+
129
+ // ============================================================================
130
+ // Cost Calculation Utilities
131
+ // ============================================================================
132
+
133
+ /**
134
+ * Parse Hetzner price string to number (EUR)
135
+ * Hetzner returns prices as strings in gross format (e.g., "5.3400" for 5.34 EUR)
136
+ *
137
+ * @param priceString - Price string from Hetzner API (gross field)
138
+ * @returns Price in EUR as number
139
+ */
140
+ export function parseHetznerPrice(priceString: string): number {
141
+ const parsed = parseFloat(priceString);
142
+ if (isNaN(parsed)) {
143
+ console.warn(`Failed to parse Hetzner price string: ${priceString}`);
144
+ return 0;
145
+ }
146
+ return parsed;
147
+ }
148
+
149
+ /**
150
+ * Extract pricing information from a Hetzner server type
151
+ * Uses the first price entry (location-agnostic pricing)
152
+ *
153
+ * @param serverType - Hetzner server type object
154
+ * @returns Server type price info or undefined if pricing unavailable
155
+ */
156
+ export function extractServerTypePrice(
157
+ serverType: HetznerServerType,
158
+ ): ServerTypePrice | undefined {
159
+ if (!serverType.prices || serverType.prices.length === 0) {
160
+ console.warn(`Server type ${serverType.name} has no pricing information`);
161
+ return undefined;
162
+ }
163
+
164
+ // Use first price entry (Hetzner API returns location-agnostic pricing first)
165
+ const priceEntry = serverType.prices[0];
166
+
167
+ const priceMonthly = parseHetznerPrice(priceEntry.price_monthly.gross);
168
+ const priceHourly = parseHetznerPrice(priceEntry.price_hourly.gross);
169
+
170
+ return {
171
+ serverType: serverType.name,
172
+ priceMonthly,
173
+ priceHourly,
174
+ deprecated: serverType.deprecated ?? false,
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Build a price lookup map from server types
180
+ *
181
+ * @param serverTypes - Array of Hetzner server types
182
+ * @returns Map of server type name to price info
183
+ */
184
+ export function buildPriceMap(
185
+ serverTypes: HetznerServerType[],
186
+ ): Map<string, ServerTypePrice> {
187
+ const priceMap = new Map<string, ServerTypePrice>();
188
+
189
+ for (const serverType of serverTypes) {
190
+ const priceInfo = extractServerTypePrice(serverType);
191
+ if (priceInfo) {
192
+ priceMap.set(serverType.name, priceInfo);
193
+ }
194
+ }
195
+
196
+ return priceMap;
197
+ }
198
+
199
+ /**
200
+ * Get the monthly price for a server type from the price map
201
+ * Returns fallback price for unknown/deprecated types
202
+ *
203
+ * @param serverTypeName - Name of the server type
204
+ * @param priceMap - Price lookup map
205
+ * @param fallbackPrice - Fallback price for unknown types (default: 5.0 EUR)
206
+ * @returns Monthly price in EUR
207
+ */
208
+ export function getServerTypeMonthlyPrice(
209
+ serverTypeName: string,
210
+ priceMap: Map<string, ServerTypePrice>,
211
+ fallbackPrice: number = DEFAULT_FALLBACK_PRICE_MONTHLY,
212
+ ): number {
213
+ const priceInfo = priceMap.get(serverTypeName);
214
+ return priceInfo?.priceMonthly ?? fallbackPrice;
215
+ }
216
+
217
+ /**
218
+ * Calculate hourly price from monthly price
219
+ * Uses standard 730 hours per month
220
+ *
221
+ * @param monthlyPrice - Monthly price in EUR
222
+ * @returns Hourly price in EUR
223
+ */
224
+ export function calculateHourlyFromMonthly(monthlyPrice: number): number {
225
+ return monthlyPrice / HOURS_PER_MONTH;
226
+ }
227
+
228
+ // ============================================================================
229
+ // Main Cost Calculation Function
230
+ // ============================================================================
231
+
232
+ /**
233
+ * Calculate total costs for a list of environments
234
+ *
235
+ * This function:
236
+ * - Only includes environments with "running" status
237
+ * - Handles missing/deprecated server types with fallback pricing
238
+ * - Returns detailed breakdown and aggregated totals
239
+ *
240
+ * @param environments - Array of environment objects
241
+ * @param serverTypes - Array of Hetzner server types with pricing
242
+ * @param options - Optional configuration
243
+ * @returns Cost calculation result with totals and breakdown
244
+ */
245
+ export function calculateCosts(
246
+ environments: Environment[],
247
+ serverTypes: HetznerServerType[],
248
+ options: {
249
+ /** Custom fallback price for unknown server types (EUR/month) */
250
+ fallbackPrice?: number;
251
+ /** Whether to include stopped environments in breakdown (default: false) */
252
+ includeStopped?: boolean;
253
+ } = {},
254
+ ): CostCalculationResult {
255
+ const { fallbackPrice = DEFAULT_FALLBACK_PRICE_MONTHLY, includeStopped = false } =
256
+ options;
257
+
258
+ // Build price lookup map
259
+ const priceMap = buildPriceMap(serverTypes);
260
+
261
+ // Calculate costs for each environment
262
+ const breakdown: EnvironmentCost[] = [];
263
+ let totalMonthly = 0;
264
+ let runningCount = 0;
265
+ let stoppedCount = 0;
266
+ let unknownTypeCount = 0;
267
+
268
+ for (const env of environments) {
269
+ const isRunning = env.status === "running";
270
+ const priceInfo = priceMap.get(env.serverType);
271
+ const isUnknownType = !priceInfo;
272
+
273
+ // Count environments by status
274
+ if (isRunning) {
275
+ runningCount++;
276
+ } else {
277
+ stoppedCount++;
278
+ }
279
+
280
+ // Count unknown server types (only for running environments)
281
+ if (isRunning && isUnknownType) {
282
+ unknownTypeCount++;
283
+ }
284
+
285
+ // Get price (use fallback for unknown types, or 0 for stopped environments)
286
+ const monthlyPrice = isRunning
287
+ ? (priceInfo?.priceMonthly ?? fallbackPrice)
288
+ : 0;
289
+ const hourlyPrice = isRunning
290
+ ? (priceInfo?.priceHourly ?? calculateHourlyFromMonthly(fallbackPrice))
291
+ : 0;
292
+
293
+ // Add to totals (only running environments)
294
+ if (isRunning) {
295
+ totalMonthly += monthlyPrice;
296
+ }
297
+
298
+ // Include in breakdown if running or if includeStopped is true
299
+ if (isRunning || includeStopped) {
300
+ breakdown.push({
301
+ environmentId: env.id,
302
+ environmentName: env.name,
303
+ serverType: env.serverType,
304
+ isRunning,
305
+ costMonthly: monthlyPrice,
306
+ costHourly: hourlyPrice,
307
+ priceInfo: priceInfo,
308
+ });
309
+ }
310
+ }
311
+
312
+ // Calculate total hourly from monthly total
313
+ const totalHourly = calculateHourlyFromMonthly(totalMonthly);
314
+
315
+ return {
316
+ totalMonthly,
317
+ totalHourly,
318
+ runningEnvironmentCount: runningCount,
319
+ stoppedEnvironmentCount: stoppedCount,
320
+ unknownServerTypeCount: unknownTypeCount,
321
+ breakdown,
322
+ priceMap,
323
+ };
324
+ }
325
+
326
+ // ============================================================================
327
+ // Pricing Operations Class
328
+ // ============================================================================
329
+
330
+ export class PricingOperations {
331
+ constructor(private client: HetznerClient) {}
332
+
333
+ /**
334
+ * List all server types
335
+ */
336
+ async listServerTypes(): Promise<HetznerServerType[]> {
337
+ const response = await this.client.request<{ server_types: HetznerServerType[] }>(
338
+ "/server_types"
339
+ );
340
+
341
+ // Validate response with Zod
342
+ const validated = HetznerListServerTypesResponseSchema.safeParse(response);
343
+ if (!validated.success) {
344
+ console.warn('Hetzner list server types validation warning:', validated.error.issues);
345
+ return response.server_types; // Return unvalidated data for backward compatibility
346
+ }
347
+
348
+ return validated.data.server_types;
349
+ }
350
+
351
+ /**
352
+ * Get a specific server type by name
353
+ */
354
+ async getServerType(name: string): Promise<HetznerServerType | undefined> {
355
+ const types = await this.listServerTypes();
356
+ return types.find(t => t.name === name);
357
+ }
358
+
359
+ /**
360
+ * List all locations
361
+ */
362
+ async listLocations(): Promise<HetznerLocation[]> {
363
+ const response = await this.client.request<{ locations: HetznerLocation[] }>(
364
+ "/locations"
365
+ );
366
+
367
+ // Validate response with Zod
368
+ const validated = HetznerListLocationsResponseSchema.safeParse(response);
369
+ if (!validated.success) {
370
+ console.warn('Hetzner list locations validation warning:', validated.error.issues);
371
+ return response.locations; // Return unvalidated data for backward compatibility
372
+ }
373
+
374
+ return validated.data.locations;
375
+ }
376
+
377
+ /**
378
+ * Get a specific location by name
379
+ */
380
+ async getLocation(name: string): Promise<HetznerLocation | undefined> {
381
+ const locations = await this.listLocations();
382
+ return locations.find(l => l.name === name);
383
+ }
384
+
385
+ /**
386
+ * List all datacenters
387
+ */
388
+ async listDatacenters(): Promise<HetznerDatacenter[]> {
389
+ const response = await this.client.request<{ datacenters: HetznerDatacenter[] }>(
390
+ "/datacenters"
391
+ );
392
+
393
+ // Validate response with Zod
394
+ const validated = HetznerListDatacentersResponseSchema.safeParse(response);
395
+ if (!validated.success) {
396
+ console.warn('Hetzner list datacenters validation warning:', validated.error.issues);
397
+ return response.datacenters; // Return unvalidated data for backward compatibility
398
+ }
399
+
400
+ return validated.data.datacenters;
401
+ }
402
+
403
+ /**
404
+ * Calculate costs for environments using current Hetzner pricing
405
+ *
406
+ * Convenience method that fetches server types and calculates costs in one call
407
+ *
408
+ * @param environments - Array of environment objects
409
+ * @param options - Optional configuration for cost calculation
410
+ * @returns Cost calculation result
411
+ */
412
+ async calculateEnvironmentCosts(
413
+ environments: Environment[],
414
+ options?: {
415
+ fallbackPrice?: number;
416
+ includeStopped?: boolean;
417
+ },
418
+ ): Promise<CostCalculationResult> {
419
+ const serverTypes = await this.listServerTypes();
420
+ return calculateCosts(environments, serverTypes, options);
421
+ }
422
+ }