diff --git a/internal/netbox/hwid.go b/internal/netbox/hwid.go new file mode 100644 index 0000000..ccd5ad2 --- /dev/null +++ b/internal/netbox/hwid.go @@ -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 +} diff --git a/internal/netbox/hwid_test.go b/internal/netbox/hwid_test.go new file mode 100644 index 0000000..d977012 --- /dev/null +++ b/internal/netbox/hwid_test.go @@ -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) + } + } +}