feat(01-04): HW-XXXXX sequential ID allocation

- formatHWID/parseHWID with HW-NNNNN regex validation
- AllocateNextHWID with optimistic-lock retry (3 attempts)
- getHighestHWIDNumber scans all devices for highest existing asset_tag
- hwIDExists checks specific asset_tag via DcimDevicesList filter
- Unit tests for format/parse covering valid and invalid cases
This commit is contained in:
Mikkel Georgsen 2026-04-10 05:20:41 +00:00
parent f15c0c7ea7
commit e1cee31620
2 changed files with 147 additions and 0 deletions

99
internal/netbox/hwid.go Normal file
View file

@ -0,0 +1,99 @@
package netbox
import (
"context"
"errors"
"fmt"
"regexp"
"strconv"
"strings"
)
var hwIDPattern = regexp.MustCompile(`^HW-(\d{5})$`)
// formatHWID formats an integer as a HW-XXXXX string.
func formatHWID(n int) string {
return fmt.Sprintf("HW-%05d", n)
}
// parseHWID parses a HW-XXXXX string to an integer.
// Returns error if the format does not match.
func parseHWID(s string) (int, error) {
m := hwIDPattern.FindStringSubmatch(s)
if m == nil {
return 0, fmt.Errorf("invalid HW-ID format: %q (expected HW-NNNNN)", s)
}
n, err := strconv.Atoi(m[1])
if err != nil {
return 0, err
}
return n, nil
}
// AllocateNextHWID allocates the next available HW-XXXXX identifier.
// Strategy: optimistic locking — query the highest existing asset_tag, increment by 1,
// attempt to reserve it. Retry up to 3 times on conflict.
//
// For Phase 1, AllocateNextHWID returns the ID string without creating a device.
// The caller is responsible for creating the device record and setting asset_tag.
func (c *Client) AllocateNextHWID(ctx context.Context) (string, error) {
const maxAttempts = 3
for attempt := 0; attempt < maxAttempts; attempt++ {
highest, err := c.getHighestHWIDNumber(ctx)
if err != nil {
return "", fmt.Errorf("get highest HW-ID: %w", err)
}
candidate := formatHWID(highest + 1)
// Check that this candidate is not already taken
// (handles concurrent allocation if ever needed)
taken, err := c.hwIDExists(ctx, candidate)
if err != nil {
return "", fmt.Errorf("check HW-ID %s: %w", candidate, err)
}
if !taken {
return candidate, nil
}
// Candidate is taken — loop and try highest+2, etc.
}
return "", errors.New("HW-ID allocation failed after 3 attempts — concurrent allocation conflict")
}
// getHighestHWIDNumber queries NetBox for the highest existing HW-XXXXX asset_tag number.
// Returns 0 if no HW-XXXXX asset_tags exist (first allocation will be HW-00001).
func (c *Client) getHighestHWIDNumber(ctx context.Context) (int, error) {
res, _, err := c.api.DcimAPI.DcimDevicesList(ctx).
Limit(1000).
Execute()
if err != nil {
return 0, fmt.Errorf("list devices for HW-ID query: %w", err)
}
highest := 0
for _, d := range res.Results {
tag := d.GetAssetTag()
if !strings.HasPrefix(tag, "HW-") {
continue
}
n, err := parseHWID(tag)
if err != nil {
continue // non-HWLab asset tag — skip
}
if n > highest {
highest = n
}
}
return highest, nil
}
// hwIDExists checks if a given HW-XXXXX asset_tag is already used in NetBox.
func (c *Client) hwIDExists(ctx context.Context, hwid string) (bool, error) {
res, _, err := c.api.DcimAPI.DcimDevicesList(ctx).
AssetTag([]string{hwid}).
Limit(1).
Execute()
if err != nil {
return false, err
}
return res.GetCount() > 0, nil
}

View file

@ -0,0 +1,48 @@
package netbox
import "testing"
func TestFormatHWID(t *testing.T) {
tests := []struct {
n int
want string
}{
{1, "HW-00001"},
{42, "HW-00042"},
{99999, "HW-99999"},
}
for _, tt := range tests {
got := formatHWID(tt.n)
if got != tt.want {
t.Errorf("formatHWID(%d) = %q, want %q", tt.n, got, tt.want)
}
}
}
func TestParseHWID(t *testing.T) {
tests := []struct {
s string
want int
wantErr bool
}{
{"HW-00001", 1, false},
{"HW-00042", 42, false},
{"HW-99999", 99999, false},
{"", 0, true},
{"not-a-hw-id", 0, true},
{"HW-0001", 0, true}, // only 4 digits — invalid
{"hw-00001", 0, true}, // lowercase — invalid
}
for _, tt := range tests {
got, err := parseHWID(tt.s)
if tt.wantErr && err == nil {
t.Errorf("parseHWID(%q): expected error, got nil", tt.s)
}
if !tt.wantErr && err != nil {
t.Errorf("parseHWID(%q): unexpected error: %v", tt.s, err)
}
if !tt.wantErr && got != tt.want {
t.Errorf("parseHWID(%q) = %d, want %d", tt.s, got, tt.want)
}
}
}