@rollupdate/hctl 0.0.0-stage → 3.0.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/LICENSE +21 -0
- package/README.md +1066 -2
- package/dist/auction/cache.d.ts +6 -0
- package/dist/auction/cache.js +55 -0
- package/dist/auction/client.d.ts +24 -0
- package/dist/auction/client.js +236 -0
- package/dist/auction/commands.d.ts +2 -0
- package/dist/auction/commands.js +440 -0
- package/dist/auction/diff-formatter.d.ts +3 -0
- package/dist/auction/diff-formatter.js +61 -0
- package/dist/auction/diff.d.ts +11 -0
- package/dist/auction/diff.js +28 -0
- package/dist/auction/formatter.d.ts +21 -0
- package/dist/auction/formatter.js +182 -0
- package/dist/auction/store.d.ts +24 -0
- package/dist/auction/store.js +177 -0
- package/dist/auction/types.d.ts +74 -0
- package/dist/auction/types.js +5 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +74 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.js +4 -0
- package/dist/cloud/browser-bootstrap.d.ts +16 -0
- package/dist/cloud/browser-bootstrap.js +438 -0
- package/dist/cloud/client.d.ts +520 -0
- package/dist/cloud/client.js +755 -0
- package/dist/cloud/commands/certificate.d.ts +2 -0
- package/dist/cloud/commands/certificate.js +108 -0
- package/dist/cloud/commands/context.d.ts +2 -0
- package/dist/cloud/commands/context.js +133 -0
- package/dist/cloud/commands/datacenter.d.ts +2 -0
- package/dist/cloud/commands/datacenter.js +24 -0
- package/dist/cloud/commands/firewall.d.ts +2 -0
- package/dist/cloud/commands/firewall.js +142 -0
- package/dist/cloud/commands/floating-ip.d.ts +2 -0
- package/dist/cloud/commands/floating-ip.js +138 -0
- package/dist/cloud/commands/image.d.ts +2 -0
- package/dist/cloud/commands/image.js +98 -0
- package/dist/cloud/commands/index.d.ts +2 -0
- package/dist/cloud/commands/index.js +47 -0
- package/dist/cloud/commands/inventory.d.ts +2 -0
- package/dist/cloud/commands/inventory.js +13 -0
- package/dist/cloud/commands/iso.d.ts +2 -0
- package/dist/cloud/commands/iso.js +27 -0
- package/dist/cloud/commands/load-balancer-type.d.ts +2 -0
- package/dist/cloud/commands/load-balancer-type.js +24 -0
- package/dist/cloud/commands/load-balancer.d.ts +2 -0
- package/dist/cloud/commands/load-balancer.js +269 -0
- package/dist/cloud/commands/location.d.ts +2 -0
- package/dist/cloud/commands/location.js +24 -0
- package/dist/cloud/commands/network.d.ts +2 -0
- package/dist/cloud/commands/network.js +157 -0
- package/dist/cloud/commands/placement-group.d.ts +2 -0
- package/dist/cloud/commands/placement-group.js +79 -0
- package/dist/cloud/commands/pricing.d.ts +2 -0
- package/dist/cloud/commands/pricing.js +15 -0
- package/dist/cloud/commands/primary-ip.d.ts +2 -0
- package/dist/cloud/commands/primary-ip.js +139 -0
- package/dist/cloud/commands/server-type.d.ts +2 -0
- package/dist/cloud/commands/server-type.js +24 -0
- package/dist/cloud/commands/server.d.ts +2 -0
- package/dist/cloud/commands/server.js +346 -0
- package/dist/cloud/commands/ssh-key.d.ts +2 -0
- package/dist/cloud/commands/ssh-key.js +94 -0
- package/dist/cloud/commands/volume.d.ts +2 -0
- package/dist/cloud/commands/volume.js +138 -0
- package/dist/cloud/context.d.ts +34 -0
- package/dist/cloud/context.js +215 -0
- package/dist/cloud/formatter.d.ts +40 -0
- package/dist/cloud/formatter.js +824 -0
- package/dist/cloud/helpers.d.ts +27 -0
- package/dist/cloud/helpers.js +67 -0
- package/dist/cloud/inventory.d.ts +100 -0
- package/dist/cloud/inventory.js +229 -0
- package/dist/cloud/pricing.d.ts +27 -0
- package/dist/cloud/pricing.js +103 -0
- package/dist/cloud/token-instructions.d.ts +2 -0
- package/dist/cloud/token-instructions.js +21 -0
- package/dist/cloud/types.d.ts +437 -0
- package/dist/cloud/types.js +5 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.js +2 -0
- package/dist/formatter.d.ts +3 -0
- package/dist/formatter.js +6 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +21 -0
- package/dist/robot/client.d.ts +246 -0
- package/dist/robot/client.js +719 -0
- package/dist/robot/commands/auth.d.ts +2 -0
- package/dist/robot/commands/auth.js +54 -0
- package/dist/robot/commands/boot.d.ts +2 -0
- package/dist/robot/commands/boot.js +76 -0
- package/dist/robot/commands/cancel.d.ts +2 -0
- package/dist/robot/commands/cancel.js +37 -0
- package/dist/robot/commands/failover.d.ts +2 -0
- package/dist/robot/commands/failover.js +46 -0
- package/dist/robot/commands/firewall.d.ts +2 -0
- package/dist/robot/commands/firewall.js +72 -0
- package/dist/robot/commands/index.d.ts +2 -0
- package/dist/robot/commands/index.js +36 -0
- package/dist/robot/commands/interactive.d.ts +2 -0
- package/dist/robot/commands/interactive.js +152 -0
- package/dist/robot/commands/ip.d.ts +2 -0
- package/dist/robot/commands/ip.js +55 -0
- package/dist/robot/commands/key.d.ts +2 -0
- package/dist/robot/commands/key.js +68 -0
- package/dist/robot/commands/order.d.ts +2 -0
- package/dist/robot/commands/order.js +33 -0
- package/dist/robot/commands/rdns.d.ts +2 -0
- package/dist/robot/commands/rdns.js +41 -0
- package/dist/robot/commands/reset.d.ts +2 -0
- package/dist/robot/commands/reset.js +77 -0
- package/dist/robot/commands/server.d.ts +2 -0
- package/dist/robot/commands/server.js +32 -0
- package/dist/robot/commands/storagebox.d.ts +2 -0
- package/dist/robot/commands/storagebox.js +126 -0
- package/dist/robot/commands/subnet.d.ts +2 -0
- package/dist/robot/commands/subnet.js +24 -0
- package/dist/robot/commands/traffic.d.ts +2 -0
- package/dist/robot/commands/traffic.js +23 -0
- package/dist/robot/commands/vswitch.d.ts +2 -0
- package/dist/robot/commands/vswitch.js +65 -0
- package/dist/robot/commands/wol.d.ts +2 -0
- package/dist/robot/commands/wol.js +20 -0
- package/dist/robot/formatter.d.ts +56 -0
- package/dist/robot/formatter.js +546 -0
- package/dist/robot/types.d.ts +353 -0
- package/dist/robot/types.js +5 -0
- package/dist/shared/config.d.ts +86 -0
- package/dist/shared/config.js +289 -0
- package/dist/shared/formatter.d.ts +29 -0
- package/dist/shared/formatter.js +121 -0
- package/dist/shared/helpers.d.ts +39 -0
- package/dist/shared/helpers.js +86 -0
- package/dist/shared/init.d.ts +2 -0
- package/dist/shared/init.js +107 -0
- package/dist/shared/reference.d.ts +5 -0
- package/dist/shared/reference.js +688 -0
- package/dist/shared/schemas.d.ts +1 -0
- package/dist/shared/schemas.js +1022 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.js +2 -0
- package/package.json +131 -4
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { HetznerCloudClient } from "./client.js";
|
|
2
|
+
export interface CloudActionOptions {
|
|
3
|
+
json?: boolean;
|
|
4
|
+
token?: string;
|
|
5
|
+
yes?: boolean;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Parse a comma-separated "key=value,key2=value2" string into a labels object.
|
|
9
|
+
*/
|
|
10
|
+
export declare function parseLabels(val: string): Record<string, string>;
|
|
11
|
+
/**
|
|
12
|
+
* Read and parse a JSON file, returning the parsed content.
|
|
13
|
+
*/
|
|
14
|
+
export declare function readJsonFile<T = unknown>(path: string): T;
|
|
15
|
+
/**
|
|
16
|
+
* Resolve a numeric ID string or a resource name to a numeric ID.
|
|
17
|
+
* If the input is purely digits, returns it directly without any API call.
|
|
18
|
+
* Otherwise, calls the resolver to look up the name and expects exactly one match.
|
|
19
|
+
*/
|
|
20
|
+
export declare function resolveIdOrName(idOrName: string, resourceType: string, resolver: (name: string) => Promise<{
|
|
21
|
+
id: number;
|
|
22
|
+
name: string | null;
|
|
23
|
+
}[]>): Promise<number>;
|
|
24
|
+
/**
|
|
25
|
+
* Wrap a cloud API action with token resolution and error handling.
|
|
26
|
+
*/
|
|
27
|
+
export declare function cloudAction<T extends unknown[]>(fn: (client: HetznerCloudClient, ...args: T) => Promise<void>): (...args: [...T, ...unknown[]]) => Promise<void>;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { handleActionError, resolveActionArgs } from "../shared/helpers.js";
|
|
3
|
+
import { HetznerCloudClient } from "./client.js";
|
|
4
|
+
import { resolveToken } from "./context.js";
|
|
5
|
+
const DIGITS_ONLY_REGEX = /^\d+$/;
|
|
6
|
+
/**
|
|
7
|
+
* Parse a comma-separated "key=value,key2=value2" string into a labels object.
|
|
8
|
+
*/
|
|
9
|
+
export function parseLabels(val) {
|
|
10
|
+
const labels = {};
|
|
11
|
+
for (const pair of val.split(",")) {
|
|
12
|
+
const trimmed = pair.trim();
|
|
13
|
+
if (!trimmed) {
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
const eqIdx = trimmed.indexOf("=");
|
|
17
|
+
if (eqIdx === -1) {
|
|
18
|
+
labels[trimmed] = "";
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
labels[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return labels;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Read and parse a JSON file, returning the parsed content.
|
|
28
|
+
*/
|
|
29
|
+
export function readJsonFile(path) {
|
|
30
|
+
const content = readFileSync(path, "utf-8");
|
|
31
|
+
return JSON.parse(content);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Resolve a numeric ID string or a resource name to a numeric ID.
|
|
35
|
+
* If the input is purely digits, returns it directly without any API call.
|
|
36
|
+
* Otherwise, calls the resolver to look up the name and expects exactly one match.
|
|
37
|
+
*/
|
|
38
|
+
export async function resolveIdOrName(idOrName, resourceType, resolver) {
|
|
39
|
+
if (DIGITS_ONLY_REGEX.test(idOrName)) {
|
|
40
|
+
return Number.parseInt(idOrName, 10);
|
|
41
|
+
}
|
|
42
|
+
const matches = await resolver(idOrName);
|
|
43
|
+
if (matches.length === 0) {
|
|
44
|
+
throw new Error(`${resourceType} '${idOrName}' not found`);
|
|
45
|
+
}
|
|
46
|
+
if (matches.length > 1) {
|
|
47
|
+
throw new Error(`Multiple ${resourceType}s match '${idOrName}' (IDs: ${matches.map((m) => m.id).join(", ")}). Use numeric ID instead.`);
|
|
48
|
+
}
|
|
49
|
+
return matches[0].id;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Wrap a cloud API action with token resolution and error handling.
|
|
53
|
+
*/
|
|
54
|
+
export function cloudAction(fn) {
|
|
55
|
+
return async (...args) => {
|
|
56
|
+
const { handlerArgs, options: merged } = resolveActionArgs(args);
|
|
57
|
+
const options = merged;
|
|
58
|
+
try {
|
|
59
|
+
const token = await resolveToken(options.token);
|
|
60
|
+
const client = new HetznerCloudClient(token);
|
|
61
|
+
await fn(client, ...handlerArgs);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
handleActionError(error);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { HetznerCloudClient } from "./client.js";
|
|
2
|
+
import type { Certificate, CloudFirewall, CloudPricing, CloudServer, CloudSshKey, FloatingIp, Image, LoadBalancer, Network, PlacementGroup, PrimaryIp, Volume } from "./types.js";
|
|
3
|
+
export interface InventoryServerEntry {
|
|
4
|
+
backup: boolean;
|
|
5
|
+
created: string;
|
|
6
|
+
id: number;
|
|
7
|
+
ipv4: string | null;
|
|
8
|
+
ipv6: string | null;
|
|
9
|
+
location: string;
|
|
10
|
+
monthly: number | null;
|
|
11
|
+
name: string;
|
|
12
|
+
primaryDiskGb: number;
|
|
13
|
+
status: string;
|
|
14
|
+
type: string | null;
|
|
15
|
+
volumeGb: number;
|
|
16
|
+
volumes: number;
|
|
17
|
+
warnings: string[];
|
|
18
|
+
}
|
|
19
|
+
export interface InventoryVolumeEntry {
|
|
20
|
+
attachedTo: number | null;
|
|
21
|
+
id: number;
|
|
22
|
+
location: string;
|
|
23
|
+
monthly: number | null;
|
|
24
|
+
name: string;
|
|
25
|
+
sizeGb: number;
|
|
26
|
+
status: string;
|
|
27
|
+
warnings: string[];
|
|
28
|
+
}
|
|
29
|
+
export interface InventoryIpEntry {
|
|
30
|
+
assignedTo: string | null;
|
|
31
|
+
id: number;
|
|
32
|
+
ip: string;
|
|
33
|
+
kind: "primary" | "floating";
|
|
34
|
+
location: string;
|
|
35
|
+
monthly: number | null;
|
|
36
|
+
type: "ipv4" | "ipv6";
|
|
37
|
+
warnings: string[];
|
|
38
|
+
}
|
|
39
|
+
export interface InventoryLoadBalancerEntry {
|
|
40
|
+
id: number;
|
|
41
|
+
location: string;
|
|
42
|
+
monthly: number | null;
|
|
43
|
+
name: string;
|
|
44
|
+
publicIpv4: string | null;
|
|
45
|
+
targets: number;
|
|
46
|
+
type: string | null;
|
|
47
|
+
warnings: string[];
|
|
48
|
+
}
|
|
49
|
+
export interface InventoryImageEntry {
|
|
50
|
+
boundTo: number | null;
|
|
51
|
+
id: number;
|
|
52
|
+
monthly: number | null;
|
|
53
|
+
name: string | null;
|
|
54
|
+
sizeGb: number;
|
|
55
|
+
type: string;
|
|
56
|
+
warnings: string[];
|
|
57
|
+
}
|
|
58
|
+
export interface InventoryReport {
|
|
59
|
+
counts: {
|
|
60
|
+
networks: number;
|
|
61
|
+
firewalls: number;
|
|
62
|
+
certificates: number;
|
|
63
|
+
sshKeys: number;
|
|
64
|
+
placementGroups: number;
|
|
65
|
+
};
|
|
66
|
+
currency: string;
|
|
67
|
+
generatedAt: string;
|
|
68
|
+
images: InventoryImageEntry[];
|
|
69
|
+
ips: InventoryIpEntry[];
|
|
70
|
+
loadBalancers: InventoryLoadBalancerEntry[];
|
|
71
|
+
note: string;
|
|
72
|
+
servers: InventoryServerEntry[];
|
|
73
|
+
totals: {
|
|
74
|
+
monthly: number | null;
|
|
75
|
+
servers: number | null;
|
|
76
|
+
volumes: number | null;
|
|
77
|
+
ips: number | null;
|
|
78
|
+
loadBalancers: number | null;
|
|
79
|
+
images: number | null;
|
|
80
|
+
};
|
|
81
|
+
vatRate: string;
|
|
82
|
+
volumes: InventoryVolumeEntry[];
|
|
83
|
+
warnings: string[];
|
|
84
|
+
}
|
|
85
|
+
export declare function buildInventory(client: HetznerCloudClient): Promise<InventoryReport>;
|
|
86
|
+
interface RawInventory {
|
|
87
|
+
certificates: Certificate[];
|
|
88
|
+
firewalls: CloudFirewall[];
|
|
89
|
+
floatingIps: FloatingIp[];
|
|
90
|
+
images: Image[];
|
|
91
|
+
loadBalancers: LoadBalancer[];
|
|
92
|
+
networks: Network[];
|
|
93
|
+
placementGroups: PlacementGroup[];
|
|
94
|
+
primaryIps: PrimaryIp[];
|
|
95
|
+
servers: CloudServer[];
|
|
96
|
+
sshKeys: CloudSshKey[];
|
|
97
|
+
volumes: Volume[];
|
|
98
|
+
}
|
|
99
|
+
export declare function assembleReport(pricing: CloudPricing, raw: RawInventory): InventoryReport;
|
|
100
|
+
export {};
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { backupMonthly, floatingIpMonthly, imageMonthly, loadBalancerTypeMonthly, primaryIpMonthly, roundCost, serverTypeMonthly, volumeMonthly, } from "./pricing.js";
|
|
2
|
+
/**
|
|
3
|
+
* Fleet inventory with list-price cost estimates.
|
|
4
|
+
*
|
|
5
|
+
* Hetzner has no billing API (verified 2026-10-09: /billing, /invoices,
|
|
6
|
+
* /costs all 404 on the Cloud API; Robot invoices live only in the web
|
|
7
|
+
* console), so costs here are estimates: current list prices (GET /pricing)
|
|
8
|
+
* times live inventory. Dedicated (Robot) servers are out of scope for
|
|
9
|
+
* costing — their price is contract-specific and not exposed via any API.
|
|
10
|
+
*/
|
|
11
|
+
function primaryIpAssignee(ip) {
|
|
12
|
+
if (ip.assignee_type === "server") {
|
|
13
|
+
return `server ${ip.assignee_id}`;
|
|
14
|
+
}
|
|
15
|
+
if (ip.assignee_type === "load_balancer") {
|
|
16
|
+
return `load balancer ${ip.assignee_id}`;
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
function sumNullable(values) {
|
|
21
|
+
let total = 0;
|
|
22
|
+
let seen = false;
|
|
23
|
+
for (const v of values) {
|
|
24
|
+
if (v !== null) {
|
|
25
|
+
total += v;
|
|
26
|
+
seen = true;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return seen ? roundCost(total) : null;
|
|
30
|
+
}
|
|
31
|
+
export async function buildInventory(client) {
|
|
32
|
+
const pricing = await client.getPricing();
|
|
33
|
+
const [servers, volumes, floatingIps, primaryIps, loadBalancers, images, networks, firewalls, certificates, sshKeys, placementGroups,] = await Promise.all([
|
|
34
|
+
client.listServers(),
|
|
35
|
+
client.listVolumes(),
|
|
36
|
+
client.listFloatingIps(),
|
|
37
|
+
client.listPrimaryIps(),
|
|
38
|
+
client.listLoadBalancers(),
|
|
39
|
+
client.listImages({}),
|
|
40
|
+
client.listNetworks(),
|
|
41
|
+
client.listFirewalls(),
|
|
42
|
+
client.listCertificates(),
|
|
43
|
+
client.listSshKeys(),
|
|
44
|
+
client.listPlacementGroups(),
|
|
45
|
+
]);
|
|
46
|
+
return assembleReport(pricing, {
|
|
47
|
+
servers,
|
|
48
|
+
volumes,
|
|
49
|
+
floatingIps,
|
|
50
|
+
primaryIps,
|
|
51
|
+
loadBalancers,
|
|
52
|
+
images,
|
|
53
|
+
networks,
|
|
54
|
+
firewalls,
|
|
55
|
+
certificates,
|
|
56
|
+
sshKeys,
|
|
57
|
+
placementGroups,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
export function assembleReport(pricing, raw) {
|
|
61
|
+
const volumeByServer = new Map();
|
|
62
|
+
for (const v of raw.volumes) {
|
|
63
|
+
if (v.server !== null) {
|
|
64
|
+
const entry = volumeByServer.get(v.server) ?? { count: 0, gb: 0 };
|
|
65
|
+
entry.count += 1;
|
|
66
|
+
entry.gb += v.size;
|
|
67
|
+
volumeByServer.set(v.server, entry);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const servers = raw.servers.map((s) => {
|
|
71
|
+
const typeName = s.server_type?.name;
|
|
72
|
+
const base = serverTypeMonthly(pricing, typeName, s.location?.name);
|
|
73
|
+
const withBackup = base !== null && s.backup_window
|
|
74
|
+
? base + backupMonthly(pricing, base)
|
|
75
|
+
: base;
|
|
76
|
+
const warnings = [];
|
|
77
|
+
if (s.status === "off") {
|
|
78
|
+
warnings.push("stopped server still billed (delete to stop cost)");
|
|
79
|
+
}
|
|
80
|
+
if (base === null) {
|
|
81
|
+
warnings.push(`server type ${typeName ?? "?"} not found in pricing`);
|
|
82
|
+
}
|
|
83
|
+
if (s.server_type?.deprecated) {
|
|
84
|
+
warnings.push("deprecated server type");
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
id: s.id,
|
|
88
|
+
name: s.name,
|
|
89
|
+
type: typeName ?? null,
|
|
90
|
+
location: s.location?.name ?? "?",
|
|
91
|
+
status: s.status,
|
|
92
|
+
created: s.created,
|
|
93
|
+
ipv4: s.public_net?.ipv4?.ip ?? null,
|
|
94
|
+
ipv6: s.public_net?.ipv6?.ip ?? null,
|
|
95
|
+
primaryDiskGb: s.primary_disk_size,
|
|
96
|
+
volumes: volumeByServer.get(s.id)?.count ?? 0,
|
|
97
|
+
volumeGb: volumeByServer.get(s.id)?.gb ?? 0,
|
|
98
|
+
backup: s.backup_window !== null,
|
|
99
|
+
monthly: roundCost(withBackup),
|
|
100
|
+
warnings,
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
const volumes = raw.volumes.map((v) => {
|
|
104
|
+
const warnings = [];
|
|
105
|
+
if (v.server === null) {
|
|
106
|
+
warnings.push("unattached volume still billed");
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
id: v.id,
|
|
110
|
+
name: v.name,
|
|
111
|
+
sizeGb: v.size,
|
|
112
|
+
location: v.location?.name ?? "?",
|
|
113
|
+
status: v.status,
|
|
114
|
+
attachedTo: v.server,
|
|
115
|
+
monthly: roundCost(volumeMonthly(pricing, v.size)),
|
|
116
|
+
warnings,
|
|
117
|
+
};
|
|
118
|
+
});
|
|
119
|
+
const ips = [
|
|
120
|
+
...raw.primaryIps.map((p) => {
|
|
121
|
+
const warnings = [];
|
|
122
|
+
if (p.assignee_type === "unassigned") {
|
|
123
|
+
warnings.push("unassigned primary IP still billed");
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
id: p.id,
|
|
127
|
+
kind: "primary",
|
|
128
|
+
type: p.type,
|
|
129
|
+
ip: p.ip,
|
|
130
|
+
location: p.location?.name ?? "?",
|
|
131
|
+
assignedTo: primaryIpAssignee(p),
|
|
132
|
+
monthly: roundCost(primaryIpMonthly(pricing, p.type, p.location?.name)),
|
|
133
|
+
warnings,
|
|
134
|
+
};
|
|
135
|
+
}),
|
|
136
|
+
...raw.floatingIps.map((f) => {
|
|
137
|
+
const warnings = [];
|
|
138
|
+
if (f.server === null) {
|
|
139
|
+
warnings.push("unassigned floating IP still billed");
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
id: f.id,
|
|
143
|
+
kind: "floating",
|
|
144
|
+
type: f.type,
|
|
145
|
+
ip: f.ip,
|
|
146
|
+
location: f.home_location?.name ?? "?",
|
|
147
|
+
assignedTo: f.server === null ? null : `server ${f.server}`,
|
|
148
|
+
monthly: roundCost(floatingIpMonthly(pricing, f.type, f.home_location?.name)),
|
|
149
|
+
warnings,
|
|
150
|
+
};
|
|
151
|
+
}),
|
|
152
|
+
];
|
|
153
|
+
const loadBalancers = raw.loadBalancers.map((lb) => ({
|
|
154
|
+
id: lb.id,
|
|
155
|
+
name: lb.name,
|
|
156
|
+
type: lb.load_balancer_type?.name ?? null,
|
|
157
|
+
location: lb.location?.name ?? "?",
|
|
158
|
+
publicIpv4: lb.public_net?.enabled
|
|
159
|
+
? (lb.public_net.ipv4?.ip ?? null)
|
|
160
|
+
: null,
|
|
161
|
+
targets: lb.targets?.length ?? 0,
|
|
162
|
+
monthly: roundCost(loadBalancerTypeMonthly(pricing, lb.load_balancer_type?.name, lb.location?.name)),
|
|
163
|
+
warnings: [],
|
|
164
|
+
}));
|
|
165
|
+
const billedImages = raw.images.filter((i) => i.type === "snapshot" || i.type === "backup");
|
|
166
|
+
const imagesReport = billedImages.map((i) => ({
|
|
167
|
+
id: i.id,
|
|
168
|
+
name: i.name,
|
|
169
|
+
type: i.type,
|
|
170
|
+
sizeGb: Math.ceil(i.image_size ?? i.disk_size ?? 0),
|
|
171
|
+
boundTo: i.bound_to,
|
|
172
|
+
monthly: roundCost(imageMonthly(pricing, Math.ceil(i.image_size ?? i.disk_size ?? 0))),
|
|
173
|
+
warnings: [],
|
|
174
|
+
}));
|
|
175
|
+
const warnings = [];
|
|
176
|
+
for (const s of servers) {
|
|
177
|
+
for (const w of s.warnings) {
|
|
178
|
+
warnings.push(`server ${s.name}: ${w}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
for (const v of volumes) {
|
|
182
|
+
for (const w of v.warnings) {
|
|
183
|
+
warnings.push(`volume ${v.name}: ${w}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
for (const ip of ips) {
|
|
187
|
+
for (const w of ip.warnings) {
|
|
188
|
+
warnings.push(`${ip.kind} ip ${ip.ip}: ${w}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const serverCosts = servers.map((s) => s.monthly);
|
|
192
|
+
const volumeCosts = volumes.map((v) => v.monthly);
|
|
193
|
+
const ipCosts = ips.map((i) => i.monthly);
|
|
194
|
+
const lbCosts = loadBalancers.map((l) => l.monthly);
|
|
195
|
+
const imageCosts = imagesReport.map((i) => i.monthly);
|
|
196
|
+
return {
|
|
197
|
+
generatedAt: new Date().toISOString(),
|
|
198
|
+
currency: pricing.currency,
|
|
199
|
+
vatRate: pricing.vat_rate,
|
|
200
|
+
note: "Costs are list-price estimates (GET /pricing gross monthly x inventory); Hetzner has no billing API. Dedicated (Robot) servers are contract-priced and not included.",
|
|
201
|
+
servers,
|
|
202
|
+
volumes,
|
|
203
|
+
ips,
|
|
204
|
+
loadBalancers,
|
|
205
|
+
images: imagesReport,
|
|
206
|
+
counts: {
|
|
207
|
+
networks: raw.networks.length,
|
|
208
|
+
firewalls: raw.firewalls.length,
|
|
209
|
+
certificates: raw.certificates.length,
|
|
210
|
+
sshKeys: raw.sshKeys.length,
|
|
211
|
+
placementGroups: raw.placementGroups.length,
|
|
212
|
+
},
|
|
213
|
+
totals: {
|
|
214
|
+
monthly: sumNullable([
|
|
215
|
+
...serverCosts,
|
|
216
|
+
...volumeCosts,
|
|
217
|
+
...ipCosts,
|
|
218
|
+
...lbCosts,
|
|
219
|
+
...imageCosts,
|
|
220
|
+
]),
|
|
221
|
+
servers: sumNullable(serverCosts),
|
|
222
|
+
volumes: sumNullable(volumeCosts),
|
|
223
|
+
ips: sumNullable(ipCosts),
|
|
224
|
+
loadBalancers: sumNullable(lbCosts),
|
|
225
|
+
images: sumNullable(imageCosts),
|
|
226
|
+
},
|
|
227
|
+
warnings,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { CloudPricing, IpPrice, PriceAmount, PricingServerTypePrice } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Pure helpers over the GET /pricing payload.
|
|
4
|
+
*
|
|
5
|
+
* Hetzner exposes list prices, not per-account billing: there is no
|
|
6
|
+
* billing/invoice API on either the Cloud or the Robot API, so "cost" here
|
|
7
|
+
* always means an estimate from list prices (gross, monthly) times live
|
|
8
|
+
* inventory. See `hctl cloud inventory`.
|
|
9
|
+
*/
|
|
10
|
+
export declare function toNumber(amount: PriceAmount | undefined): number | null;
|
|
11
|
+
export declare function serverTypeMonthly(pricing: CloudPricing, serverTypeName: string | undefined, location: string | undefined): number | null;
|
|
12
|
+
export declare function loadBalancerTypeMonthly(pricing: CloudPricing, lbTypeName: string | undefined, location: string | undefined): number | null;
|
|
13
|
+
export declare function primaryIpMonthly(pricing: CloudPricing, ipType: "ipv4" | "ipv6", location: string | undefined): number | null;
|
|
14
|
+
export declare function floatingIpMonthly(pricing: CloudPricing, ipType: "ipv4" | "ipv6", location: string | undefined): number | null;
|
|
15
|
+
export declare function volumeMonthly(pricing: CloudPricing, sizeGb: number): number | null;
|
|
16
|
+
export declare function imageMonthly(pricing: CloudPricing, sizeGb: number): number | null;
|
|
17
|
+
/**
|
|
18
|
+
* Server backups cost a percentage on top of the base server price.
|
|
19
|
+
*/
|
|
20
|
+
export declare function backupMonthly(pricing: CloudPricing, baseMonthly: number): number;
|
|
21
|
+
/**
|
|
22
|
+
* Overage cost for traffic beyond the included allowance. Returns null when
|
|
23
|
+
* the pricing entry lacks a per-TB traffic price.
|
|
24
|
+
*/
|
|
25
|
+
export declare function trafficOverageMonthly(price: PricingServerTypePrice | IpPrice | undefined, outgoingBytes: number | null, includedBytes: number): number | null;
|
|
26
|
+
/** Round for display: list prices carry 4+ decimal places. */
|
|
27
|
+
export declare function roundCost(value: number | null): number | null;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers over the GET /pricing payload.
|
|
3
|
+
*
|
|
4
|
+
* Hetzner exposes list prices, not per-account billing: there is no
|
|
5
|
+
* billing/invoice API on either the Cloud or the Robot API, so "cost" here
|
|
6
|
+
* always means an estimate from list prices (gross, monthly) times live
|
|
7
|
+
* inventory. See `hctl cloud inventory`.
|
|
8
|
+
*/
|
|
9
|
+
export function toNumber(amount) {
|
|
10
|
+
if (!amount) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
const value = Number.parseFloat(amount.gross);
|
|
14
|
+
return Number.isFinite(value) ? value : null;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Pick the price entry for a location, falling back to the cheapest
|
|
18
|
+
* available location when the resource's location is not priced (new
|
|
19
|
+
* region, stale pricing payload).
|
|
20
|
+
*/
|
|
21
|
+
function pickLocationPrice(prices, location) {
|
|
22
|
+
if (prices.length === 0) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
const exact = location
|
|
26
|
+
? prices.find((p) => p.location === location)
|
|
27
|
+
: undefined;
|
|
28
|
+
if (exact) {
|
|
29
|
+
return exact;
|
|
30
|
+
}
|
|
31
|
+
return prices.reduce((cheapest, p) => (toNumber(p.price_monthly) ?? Number.POSITIVE_INFINITY) <
|
|
32
|
+
(toNumber(cheapest.price_monthly) ?? Number.POSITIVE_INFINITY)
|
|
33
|
+
? p
|
|
34
|
+
: cheapest);
|
|
35
|
+
}
|
|
36
|
+
function namedTypeMonthly(table, name, location) {
|
|
37
|
+
const entry = table.find((t) => t.name === name);
|
|
38
|
+
if (!entry) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
return toNumber(pickLocationPrice(entry.prices, location)?.price_monthly);
|
|
42
|
+
}
|
|
43
|
+
export function serverTypeMonthly(pricing, serverTypeName, location) {
|
|
44
|
+
return namedTypeMonthly(pricing.server_types, serverTypeName, location);
|
|
45
|
+
}
|
|
46
|
+
export function loadBalancerTypeMonthly(pricing, lbTypeName, location) {
|
|
47
|
+
return namedTypeMonthly(pricing.load_balancer_types, lbTypeName, location);
|
|
48
|
+
}
|
|
49
|
+
function ipMonthly(table, ipType, location) {
|
|
50
|
+
const entry = table.find((t) => t.type === ipType);
|
|
51
|
+
if (!entry) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const price = pickLocationPrice(entry.prices, location);
|
|
55
|
+
return toNumber(price?.price_monthly);
|
|
56
|
+
}
|
|
57
|
+
export function primaryIpMonthly(pricing, ipType, location) {
|
|
58
|
+
return ipMonthly(pricing.primary_ips, ipType, location);
|
|
59
|
+
}
|
|
60
|
+
export function floatingIpMonthly(pricing, ipType, location) {
|
|
61
|
+
return ipMonthly(pricing.floating_ips, ipType, location);
|
|
62
|
+
}
|
|
63
|
+
export function volumeMonthly(pricing, sizeGb) {
|
|
64
|
+
const perGb = toNumber(pricing.volume.price_per_gb_month);
|
|
65
|
+
return perGb === null ? null : perGb * sizeGb;
|
|
66
|
+
}
|
|
67
|
+
export function imageMonthly(pricing, sizeGb) {
|
|
68
|
+
const perGb = toNumber(pricing.image.price_per_gb_month);
|
|
69
|
+
return perGb === null ? null : perGb * sizeGb;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Server backups cost a percentage on top of the base server price.
|
|
73
|
+
*/
|
|
74
|
+
export function backupMonthly(pricing, baseMonthly) {
|
|
75
|
+
const percentage = Number.parseFloat(pricing.server_backup.percentage);
|
|
76
|
+
if (!Number.isFinite(percentage)) {
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
return (baseMonthly * percentage) / 100;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Overage cost for traffic beyond the included allowance. Returns null when
|
|
83
|
+
* the pricing entry lacks a per-TB traffic price.
|
|
84
|
+
*/
|
|
85
|
+
export function trafficOverageMonthly(price, outgoingBytes, includedBytes) {
|
|
86
|
+
if (outgoingBytes === null) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
const overBytes = Math.max(0, outgoingBytes - includedBytes);
|
|
90
|
+
if (overBytes === 0) {
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
const perTb = toNumber(price?.price_per_tb_traffic);
|
|
94
|
+
if (perTb === null) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const TB = 1024 ** 4;
|
|
98
|
+
return (overBytes / TB) * perTb;
|
|
99
|
+
}
|
|
100
|
+
/** Round for display: list prices carry 4+ decimal places. */
|
|
101
|
+
export function roundCost(value) {
|
|
102
|
+
return value === null ? null : Math.round(value * 100) / 100;
|
|
103
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { heading } from "../shared/formatter.js";
|
|
2
|
+
export const HETZNER_CLOUD_CONSOLE_URL = "https://console.hetzner.cloud";
|
|
3
|
+
export function printCloudTokenInstructions() {
|
|
4
|
+
console.log("");
|
|
5
|
+
console.log(heading("Hetzner Cloud API Token"));
|
|
6
|
+
console.log("");
|
|
7
|
+
console.log("To create a Cloud API token:");
|
|
8
|
+
console.log(` 1. Open ${HETZNER_CLOUD_CONSOLE_URL}`);
|
|
9
|
+
console.log(" 2. Select your project (or create one)");
|
|
10
|
+
console.log(" 3. Left menu: Security");
|
|
11
|
+
console.log(" 4. Top menu: API Tokens");
|
|
12
|
+
console.log(' 5. Click "Generate API Token"');
|
|
13
|
+
console.log(' 6. Name the token, for example "hctl"');
|
|
14
|
+
console.log(' 7. Choose "Read & Write" for full hctl management, or "Read" for list/describe only');
|
|
15
|
+
console.log(" 8. Copy the token now; Hetzner only shows it once.");
|
|
16
|
+
console.log("");
|
|
17
|
+
console.log("Note: Tokens are scoped per project (one token per project).");
|
|
18
|
+
console.log("This is separate from Robot web service credentials at https://robot.hetzner.com.");
|
|
19
|
+
console.log("hctl verifies Cloud tokens with api.hetzner.cloud before saving them; use --no-verify only when offline.");
|
|
20
|
+
console.log("");
|
|
21
|
+
}
|