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:
parent
f15c0c7ea7
commit
e1cee31620
2 changed files with 147 additions and 0 deletions
99
internal/netbox/hwid.go
Normal file
99
internal/netbox/hwid.go
Normal 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
|
||||
}
|
||||
48
internal/netbox/hwid_test.go
Normal file
48
internal/netbox/hwid_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue