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`

```go
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.

```go
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`:

```go
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.

```go
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()`:
```go
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

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

```go
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:
```go
MACAddress string `yaml:"mac_address,omitempty" json:"mac_address,omitempty"`
```

Update `Heartbeat()`:
```go
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)