HydraIssues

Venue auto-assignment from hydraneck + MAC address in hydranode
open feature Project: hydraneck Reporter: cederik 19 May 2026 09:39

Description

Plan: Venue auto-assignment from hydraneck + MAC address in hydranode

Context

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).


Feature 1: Venue auto-assignment in hydraneck

Changes required

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 bool to CorrelatedNode and 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:

  • Node seen here, venue already matches → silent (no call)
  • Node seen here, no venue set (candidate) → assign once, then silent
  • Node seen here, wrong venue (misplaced traveler) → reassign, then silent
  • Node not seen this scan → untouched (may be offline, not moved)

Feature 2: MAC address in hydranode

Rollout order (dependency chain)

  1. hydraclusterapi — shared library, add MACAddress field
  2. hydracluster — server, store MAC from heartbeat
  3. hydranode — agent, collect and send MAC

Old agents omit the field; server stores empty string. Fully backwards-compatible.

2a. hydraclusterapi/types.go

MACAddress string `json:"mac_address,omitempty"`

2b. hydranode/pkg/body/sysinfo.go

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 ""
}

2c. hydranode/pkg/body/heartbeat.go

Add MACAddress: info.MACAddress to the HeartbeatRequest literal.

2d. hydracluster/pkg/store/store.go

Add to Node struct:

MACAddress string `yaml:"mac_address,omitempty" json:"mac_address,omitempty"`

Update Heartbeat():

if hb.MACAddress != "" {
    n.MACAddress = hb.MACAddress
}

Files to change (summary)

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

Verification

  1. 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).

  2. 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.

  3. 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.



Feature 3: Node model documentation + API audit

3a. Architecture doc

Create hydracluster/docs/runbooks/node-model.md documenting:

  • The ownership table (controller per field group)
  • The rule: hydracluster is the only process that reads/writes nodes.yaml directly; all external writes go through its HTTP API
  • Each API endpoint used per controller (hydraneck → /venue, hydranode → /heartbeat + /enroll, hydradistrict → validated at write time)

3b. API access audit

Before writing any code, grep across hydraneck, hydranode, hydradistrict, hydravenues and any other service for:

  • Direct reads of nodes.yaml (should be zero outside hydracluster)
  • Any file path references to the nodes file from external repos
  • Any writes that bypass the hydracluster API

Flag any violations in the doc or fix them as part of this work.


Release order

  1. Tag + push hydraclusterapi (update import in hydracluster + hydranode go.mod)
  2. Tag + push hydracluster (new /venue endpoint + SetVenue + MAC storage + node-model.md)
  3. Tag + push hydranode (MAC heartbeat)
  4. Tag + push hydraneck (auto-assign + misplaced detection)