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