Hydracluster is the persistent store for all node data. Each field has a distinct controller:
| Field | Controller | Mechanism |
|---|---|---|
| Venue | hydravenues (defines venues) + hydraneck (observes physical presence) | hydraneck writes venue to hydracluster when it sees a node on a router |
| District | hydradistrict | operator assigns at enrollment; hydraneck never touches it |
| MAC, Hostname, IP, OS, Arch, Uptime, NodeVersion, ServiceVersions | hydranode | self-reported on every heartbeat |
| ID, Token, Status, CreatedAt | hydracluster | generated at enrollment |
The goal of this work: wire up the two missing auto-flows — venue (hydraneck → hydracluster) and MAC address (hydranode → hydracluster).
Three machines (cederikmini, cheeky-cactus-86, ipad-head) are on the mobile-kit hydraneck but have no venue set in hydracluster — this should resolve automatically on the next scan.
Nodes travel between venues. Hydraneck must detect a node at any venue (even if hydracluster says it belongs elsewhere) and push the correct venue. A spam guard (skip API call when venue already matches) keeps it quiet under steady state.
Hydraneck never touches district — that is hydradistrict's domain. To avoid accidentally clearing district when writing venue, hydracluster needs a dedicated POST /api/v1/nodes/{id}/venue endpoint (venue only, district untouched).
1. hydraneck/pkg/store/store.go — add ClusterVenue to CorrelatedNode
type CorrelatedNode struct {
...
ClusterVenue string `yaml:"cluster_venue,omitempty" json:"cluster_venue,omitempty"`
...
}
This is what hydracluster currently believes the venue to be. Used as the spam guard.
2. hydraneck/pkg/scanner/scanner.go — two changes
2a. fetchNodesWithCandidates: add a third "misplaced" category — nodes with a non-empty, non-matching venue whose IP or hostname is seen on this venue's network. Return them alongside venue nodes and candidates.
func (s *Scanner) fetchNodesWithCandidates(venue string, allClients map[string]clientInfo) (venueNodes, candidateNodes, misplacedNodes []ClusterNode, err error) {
...
for _, n := range allNodes {
switch {
case n.Venue == venue:
venueNodes = append(venueNodes, n)
case n.Venue == "":
if (n.Hostname != "" && clientNames[strings.ToLower(n.Hostname)]) ||
(n.Name != "" && clientNames[strings.ToLower(n.Name)]) {
candidateNodes = append(candidateNodes, n)
}
default:
// Node belongs to a different venue — check if it's physically here
if (n.Hostname != "" && clientNames[strings.ToLower(n.Hostname)]) ||
(n.Name != "" && clientNames[strings.ToLower(n.Name)]) ||
(n.IP != "" && allClients[n.IP].client.IP != "") {
misplacedNodes = append(misplacedNodes, n)
}
}
}
return
}
2b. correlateNodes: set ClusterVenue from the source ClusterNode:
cn := store.CorrelatedNode{
...
ClusterVenue: node.Venue,
}
Also correlate misplaced nodes: in Scan, add a third correlateNodes call for misplaced nodes (same path as candidates, candidate=true or a new flag — see note below).
Note: misplaced nodes should be visually distinct from candidates in the UI (a node at the wrong venue is different from a node with no venue). Add
Misplaced booltoCorrelatedNodeand pass it through, so the dashboard can show them separately.
3. hydraneck/pkg/api/handlers.go — autoAssignVenue
Called from scanVenue after st.Save(). Assigns venue to any node physically seen here whose ClusterVenue doesn't already match.
func (s *Server) autoAssignVenue(vc scanner.VenueConfig, result store.ScanResult) {
if s.cluster == nil || s.cluster.URL == "" {
return
}
for _, node := range result.Nodes {
if node.RouterName == "" {
continue // not physically observed this scan
}
if node.ClusterVenue == vc.Name {
continue // already correct — no API call needed
}
body, _ := json.Marshal(map[string]string{
"district": vc.District,
"venue": vc.Name,
})
url := fmt.Sprintf("%s/api/v1/nodes/%s/district", s.cluster.URL, node.NodeID)
req, _ := http.NewRequest("POST", url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+s.cluster.Token)
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
log.Printf("auto-assign %s: %v", node.NodeID, err)
continue
}
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
log.Printf("auto-assigned node %s (%s) venue: %q → %q", node.NodeID, node.NodeName, node.ClusterVenue, vc.Name)
} else {
log.Printf("auto-assign %s: cluster returned %d", node.NodeID, resp.StatusCode)
}
}
}
In scanVenue after st.Save():
s.autoAssignVenue(*vc, result)
Behaviour summary:
hydraclusterapi — shared library, add MACAddress fieldhydracluster — server, store MAC from heartbeathydranode — agent, collect and send MACOld agents omit the field; server stores empty string. Fully backwards-compatible.
MACAddress string `json:"mac_address,omitempty"`
Add MACAddress to SystemInfo, implement detectMAC(primaryIP string). Strategy: prefer the interface whose addresses include the primary IP (so MAC matches the IP the node reports); fallback to first UP non-loopback interface with a hardware address.
func detectMAC(primaryIP string) string {
ifaces, _ := net.Interfaces()
for _, iface := range ifaces {
if iface.Flags&net.FlagLoopback != 0 || len(iface.HardwareAddr) == 0 {
continue
}
addrs, _ := iface.Addrs()
for _, addr := range addrs {
var ip net.IP
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
if ip != nil && ip.String() == primaryIP {
return iface.HardwareAddr.String()
}
}
}
// Fallback
for _, iface := range ifaces {
if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 || len(iface.HardwareAddr) == 0 {
continue
}
return iface.HardwareAddr.String()
}
return ""
}
Add MACAddress: info.MACAddress to the HeartbeatRequest literal.
Add to Node struct:
MACAddress string `yaml:"mac_address,omitempty" json:"mac_address,omitempty"`
Update Heartbeat():
if hb.MACAddress != "" {
n.MACAddress = hb.MACAddress
}
| Repo | File | Change |
|---|---|---|
| hydraneck | pkg/store/store.go |
add ClusterVenue, Misplaced to CorrelatedNode |
| hydraneck | pkg/scanner/scanner.go |
add misplaced category in fetchNodesWithCandidates; set ClusterVenue + Misplaced in correlateNodes; correlate misplaced nodes in Scan |
| hydraneck | pkg/api/handlers.go |
add autoAssignVenue (calls /api/v1/nodes/{id}/venue), call after st.Save() in scanVenue |
| hydracluster | pkg/store/store.go |
add SetVenue(id, venue) method; add MACAddress to Node; update Heartbeat() to store MAC |
| hydracluster | pkg/api/handlers_api.go |
add handleAPISetVenue handler |
| hydracluster | pkg/api/server.go |
register POST /api/v1/nodes/{id}/venue |
| hydraclusterapi | types.go |
add MACAddress to HeartbeatRequest |
| hydranode | pkg/body/sysinfo.go |
add MACAddress to SystemInfo, implement detectMAC |
| hydranode | pkg/body/heartbeat.go |
include MACAddress in heartbeat |
| hydracluster | docs/runbooks/node-model.md |
new: node field ownership table, API-only access rule, endpoint map per controller |
Auto-assign (new nodes): Trigger a scan on mobile-kit. Check hydraneck logs for auto-assigned. Confirm cederikmini, cheeky-cactus-86, ipad-head now have venue: mobile-kit in hydracluster. Subsequent scans produce no log lines for those nodes (spam guard works).
Auto-assign (traveling node): Manually set a node's venue to ad6 in hydracluster. Trigger a mobile-kit scan. Confirm the log shows venue: "ad6" → "mobile-kit" and hydracluster is updated.
MAC address: After releasing hydranode, check a node in the hydracluster admin — MACAddress should be populated. Cross-check against RouterMAC in hydraneck for the same node — they should match.
Create hydracluster/docs/runbooks/node-model.md documenting:
nodes.yaml directly; all external writes go through its HTTP API/venue, hydranode → /heartbeat + /enroll, hydradistrict → validated at write time)Before writing any code, grep across hydraneck, hydranode, hydradistrict, hydravenues and any other service for:
nodes.yaml (should be zero outside hydracluster)Flag any violations in the doc or fix them as part of this work.
hydraclusterapi (update import in hydracluster + hydranode go.mod)hydracluster (new /venue endpoint + SetVenue + MAC storage + node-model.md)hydranode (MAC heartbeat)hydraneck (auto-assign + misplaced detection)