Skip to content

Commit e70c610

Browse files
authored
Merge pull request #493 from SerhiiZahuba/main
add hetzner source
2 parents 441ee7c + 29d9059 commit e70c610

10 files changed

Lines changed: 346 additions & 1 deletion

File tree

docs/source_hetzner.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Source: hetzner
2+
3+
## Setup
4+
You need to have a source section in your `settings.ini` file with following type:
5+
```ini
6+
type = hetzner
7+
```
8+
9+
10+
### Hetzner api
11+
You need to create a "Read-only" api_token
12+
https://docs.hetzner.com/cloud/api/getting-started/generating-api-token/

module/sources/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
# define all available sources here
1111
from module.sources.vmware.connection import VMWareHandler
12+
from module.sources.hetzner.connection import HetznerHandler
1213
from module.sources.check_redfish.import_inventory import CheckRedfish
1314

1415
from module.common.logging import get_logger
@@ -18,7 +19,7 @@
1819
from module.config import source_config_section_name
1920

2021
# list of valid sources
21-
valid_sources = [VMWareHandler, CheckRedfish]
22+
valid_sources = [VMWareHandler, CheckRedfish, HetznerHandler]
2223

2324

2425
def validate_source(source_class_object=None, state="pre"):

module/sources/hetzner/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from module.sources.hetzner.connection import HetznerHandler

module/sources/hetzner/client.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from hcloud import Client
2+
3+
4+
class HetznerClient:
5+
6+
def __init__(self, token):
7+
self.client = Client(token=token)
8+
9+
def get_servers(self):
10+
return self.client.servers.get_all()

module/sources/hetzner/config.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from module.config import source_config_section_name
2+
from module.config.base import ConfigBase
3+
from module.config.option import ConfigOption
4+
5+
6+
class HetznerConfig(ConfigBase):
7+
8+
section_name = source_config_section_name
9+
10+
def __init__(self):
11+
self.options = [
12+
13+
ConfigOption(
14+
"enabled",
15+
bool,
16+
default_value=True,
17+
description="Enable or disable the Hetzner Cloud source."
18+
),
19+
20+
ConfigOption(
21+
"type",
22+
str,
23+
default_value="hetzner",
24+
description="Source type identifier. Must remain 'hetzner'."
25+
),
26+
27+
ConfigOption(
28+
"api_token",
29+
str,
30+
mandatory=True,
31+
description="Hetzner Cloud API token used to authenticate against the Hetzner Cloud API."
32+
),
33+
]
34+
35+
super().__init__()
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
from module.common.logging import get_logger
2+
from module.sources.common.source_base import SourceBase
3+
from module.sources.hetzner.client import HetznerClient
4+
from module.sources.hetzner.config import HetznerConfig
5+
from module.sources.hetzner.network import sync_vm_network
6+
from module.sources.hetzner.disk import sync_vm_disks
7+
8+
9+
10+
from module.netbox.inventory import (
11+
NetBoxInventory,
12+
NBVM,
13+
NBSite,
14+
NBCluster,
15+
NBClusterType,
16+
NBVMInterface,
17+
NBIPAddress,
18+
NBVirtualDisk,
19+
)
20+
21+
22+
23+
class HetznerHandler(SourceBase):
24+
25+
source_type = "hetzner"
26+
source_tag = "hetzner"
27+
28+
settings = HetznerConfig()
29+
30+
31+
32+
dependent_netbox_objects = [
33+
NBVM,
34+
NBCluster,
35+
NBSite,
36+
NBClusterType,
37+
NBIPAddress,
38+
NBVirtualDisk,
39+
NBVMInterface,
40+
]
41+
42+
43+
def __init__(self, name=None):
44+
45+
if name is None:
46+
raise ValueError(f"Invalid value for attribute 'name': '{name}'.")
47+
48+
self.inventory = NetBoxInventory()
49+
self.name = name
50+
self.log = get_logger()
51+
52+
settings_handler = HetznerConfig()
53+
settings_handler.source_name = self.name
54+
self.settings = settings_handler.parse()
55+
56+
self.set_source_tag()
57+
58+
if self.settings.enabled is False:
59+
log.info(f"Source '{name}' is currently disabled. Skipping")
60+
return
61+
62+
self.init_successful = True
63+
64+
65+
66+
67+
@classmethod
68+
def implements(cls, source_type):
69+
return source_type == "hetzner"
70+
71+
def apply(self):
72+
73+
token = self.settings.api_token
74+
75+
self.log.error(f"TOKEN DEBUG >>> {repr(token)}")
76+
77+
if not token:
78+
self.log.error("Hetzner api_token not defined in settings.ini")
79+
return
80+
81+
self.client = HetznerClient(token=token)
82+
83+
servers = self.client.get_servers()
84+
85+
self.log.info(f"Connected to Hetzner, found {len(servers)} servers")
86+
87+
88+
89+
# ---------------------------
90+
# main object
91+
# ---------------------------
92+
93+
site = self.inventory.add_update_object(
94+
NBSite,
95+
data={"name": "cloud"},
96+
source=self,
97+
)
98+
99+
cluster_type = self.inventory.add_update_object(
100+
NBClusterType,
101+
data={"name": "cloud"},
102+
source=self,
103+
)
104+
105+
cluster_name = f"Hetzner: {self.name}"
106+
107+
cluster = self.inventory.add_update_object(
108+
NBCluster,
109+
data={
110+
"name": cluster_name,
111+
"type": cluster_type,
112+
"scope_type": 17,
113+
"scope_id": site,
114+
},
115+
source=self,
116+
)
117+
118+
# ---------------------------
119+
# servers loop
120+
# ---------------------------
121+
122+
for server in servers:
123+
124+
# -------- VM --------
125+
vm = self.inventory.add_update_object(
126+
NBVM,
127+
data={
128+
"name": server.name,
129+
"status": "active",
130+
"cluster": cluster,
131+
"site": site,
132+
},
133+
source=self,
134+
)
135+
136+
# -------- interfaces --------
137+
sync_vm_network(self, vm, server)
138+
139+
# -------- disks --------
140+
sync_vm_disks(self, vm, server)

module/sources/hetzner/disk.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from module.netbox.inventory import NBVirtualDisk
2+
3+
4+
def sync_vm_disks(handler, vm, server):
5+
"""
6+
Sync Hetzner volumes → NetBox virtual disks
7+
"""
8+
9+
inventory = handler.inventory
10+
11+
if not server.volumes:
12+
return
13+
14+
for volume in server.volumes:
15+
16+
disk_name = f"{server.name}-{volume.name}"[:60]
17+
size_mb = int(volume.size) * 1024 # Hetzner size = GB
18+
19+
disk_data = {
20+
"name": disk_name,
21+
"virtual_machine": vm, # object, не id
22+
"size": size_mb,
23+
}
24+
25+
existing_disk = None
26+
27+
for disk in inventory.get_all_items(NBVirtualDisk):
28+
if (
29+
disk.data.get("name") == disk_name
30+
and disk.data.get("virtual_machine") == vm
31+
):
32+
existing_disk = disk
33+
break
34+
35+
if existing_disk is None:
36+
inventory.add_object(
37+
NBVirtualDisk,
38+
data=disk_data,
39+
source=handler,
40+
)
41+
else:
42+
existing_disk.update(disk_data, source=handler)

module/sources/hetzner/network.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
from module.netbox.inventory import NBVMInterface, NBIPAddress
2+
3+
4+
def sync_vm_network(handler, vm, server):
5+
"""
6+
Create interfaces + assign IPs for Hetzner VM
7+
"""
8+
9+
inventory = handler.inventory
10+
interfaces = []
11+
12+
# -----------------------
13+
# interfaces
14+
# -----------------------
15+
16+
# public → eth0
17+
if server.public_net and server.public_net.ipv4:
18+
iface = inventory.add_update_object(
19+
NBVMInterface,
20+
data={
21+
"name": "eth0",
22+
"virtual_machine": vm,
23+
"enabled": True,
24+
},
25+
source=handler,
26+
)
27+
interfaces.append(iface)
28+
29+
# private → ethX
30+
if server.private_net:
31+
start_index = 1 if len(interfaces) > 0 else 0
32+
33+
for idx, net in enumerate(server.private_net, start=start_index):
34+
iface = inventory.add_update_object(
35+
NBVMInterface,
36+
data={
37+
"name": f"eth{idx}",
38+
"virtual_machine": vm,
39+
"enabled": True,
40+
},
41+
source=handler,
42+
)
43+
interfaces.append(iface)
44+
45+
# -----------------------
46+
# IP assignment
47+
# -----------------------
48+
49+
# public ip
50+
if server.public_net and server.public_net.ipv4 and len(interfaces) >= 1:
51+
ip_addr = server.public_net.ipv4.ip
52+
if "/" not in ip_addr:
53+
ip_addr += "/32"
54+
55+
assign_ip(inventory, handler, ip_addr, interfaces[0])
56+
57+
# private ips
58+
if server.private_net:
59+
private_start_index = 1 if (server.public_net and server.public_net.ipv4) else 0
60+
61+
for idx, net in enumerate(server.private_net, start=private_start_index):
62+
63+
if len(interfaces) <= idx:
64+
continue
65+
66+
ip_addr = net.ip
67+
if "/" not in ip_addr:
68+
ip_addr += "/32"
69+
70+
assign_ip(inventory, handler, ip_addr, interfaces[idx])
71+
72+
73+
def assign_ip(inventory, handler, ip_addr, interface):
74+
"""
75+
Safe IP assign without duplicates
76+
"""
77+
78+
ip_data = {
79+
"address": ip_addr,
80+
"assigned_object_type": "virtualization.vminterface",
81+
"assigned_object_id": interface,
82+
}
83+
84+
existing_ip = next(
85+
(ip for ip in inventory.get_all_items(NBIPAddress)
86+
if ip.data.get("address") == ip_addr),
87+
None
88+
)
89+
90+
if existing_ip is None:
91+
inventory.add_object(NBIPAddress, data=ip_data, source=handler)
92+
else:
93+
existing_ip.update(ip_data, source=handler)

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ setuptools==81.0.0
1313
six==1.17.0
1414
urllib3==2.6.3
1515
wheel==0.46.3
16+
hcloud==2.17.0

settings-example.ini

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,4 +472,14 @@ inventory_file_path = /full/path/to/inventory/files
472472
; If the device has a tenant then this one will be used. If not, the prefix tenant will be used if defined
473473
;ip_tenant_inheritance_order = device, prefix
474474

475+
[source/hetzner]
476+
# Enable or disable the Hetzner Cloud source.
477+
#enabled = True
478+
479+
# Source type identifier. Must remain 'hetzner'.
480+
#type = hetzner
481+
482+
# Hetzner Cloud API token used to authenticate against the Hetzner Cloud API.
483+
#api_token =
484+
475485
;EOF

0 commit comments

Comments
 (0)