740 lines
29 KiB
Markdown
740 lines
29 KiB
Markdown
---
|
|
phase: 01-foundation
|
|
plan: 04
|
|
type: execute
|
|
wave: 2
|
|
depends_on:
|
|
- 01-02-PLAN.md
|
|
files_modified:
|
|
- internal/netbox/hwid.go
|
|
- internal/netbox/hwid_test.go
|
|
- internal/inventory/quality_gate.go
|
|
- internal/inventory/quality_gate_test.go
|
|
- internal/inventory/catalog_updater.go
|
|
- internal/inventory/types.go
|
|
- internal/netbox/tags.go
|
|
- internal/netbox/tags_test.go
|
|
autonomous: true
|
|
requirements:
|
|
- INF-03
|
|
- NB-06
|
|
- NB-07
|
|
|
|
must_haves:
|
|
truths:
|
|
- "AllocateNextHWID returns HW-00001 on first call against an empty NetBox"
|
|
- "AllocateNextHWID returns HW-00002 on subsequent calls (increments)"
|
|
- "CatalogStatus.CanTransitionTo enforces valid transitions (draft→indexed allowed, indexed→draft rejected)"
|
|
- "Invalid transitions return an error with the exact invalid transition described"
|
|
- "SyncTags creates new NetBox tags for AI-suggested tags not yet present"
|
|
artifacts:
|
|
- path: "internal/netbox/hwid.go"
|
|
provides: "AllocateNextHWID: optimistic-lock sequential ID allocation from NetBox"
|
|
exports: ["AllocateNextHWID"]
|
|
- path: "internal/inventory/quality_gate.go"
|
|
provides: "CatalogStatus type with Transition() enforcing valid state machine"
|
|
exports: ["CatalogStatus", "StatusDraft", "StatusIndexed", "StatusNeedsResearch", "StatusResearched", "StatusComplete", "Transition"]
|
|
- path: "internal/inventory/types.go"
|
|
provides: "HardwareRecord domain type composing NetBox device with HWLab semantics"
|
|
exports: ["HardwareRecord"]
|
|
- path: "internal/netbox/tags.go"
|
|
provides: "SyncTags: creates NetBox tags from AI-suggested string slice, returns IDs"
|
|
exports: ["SyncTags"]
|
|
key_links:
|
|
- from: "internal/netbox/hwid.go"
|
|
to: "http://10.5.0.130:8000/api"
|
|
via: "DcimDevicesList filtered by asset_tag pattern"
|
|
pattern: "DcimDevicesList.*asset_tag|asset_tag.*DcimDevicesList"
|
|
- from: "internal/inventory/quality_gate.go"
|
|
to: "internal/netbox/client.go"
|
|
via: "CatalogStatus value stored as NetBox custom field via PatchCustomFields"
|
|
pattern: "PatchCustomFields.*catalog_status"
|
|
---
|
|
|
|
<objective>
|
|
Implement three distinct capabilities that all depend on the NetBox client (Plan 02): HW-XXXXX sequential ID allocation, catalog quality gate state machine, and AI tag sync to NetBox.
|
|
|
|
Purpose: These three capabilities are the behavioral core of Phase 1. HW-ID is required at intake time (Phase 2). Quality gate drives all lifecycle operations. Tag sync links AI output to NetBox taxonomy.
|
|
Output: Three packages — netbox/hwid.go, inventory/quality_gate.go, netbox/tags.go — all tested independently.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.env
|
|
@.planning/phases/01-foundation/01-RESEARCH.md
|
|
@.planning/phases/01-foundation/01-02-SUMMARY.md
|
|
</context>
|
|
|
|
<interfaces>
|
|
<!-- Types from Plan 02 available to this plan -->
|
|
From internal/netbox/client.go:
|
|
```go
|
|
func NewClient(url, token string) (*Client, error)
|
|
func (c *Client) ListDevices(ctx context.Context, limit int) ([]Device, error)
|
|
func (c *Client) PatchCustomFields(ctx context.Context, deviceID int, patch map[string]interface{}) error
|
|
// c.api is *nb.APIClient — can call DcimAPI, ExtrasAPI directly from hwid.go and tags.go
|
|
```
|
|
|
|
From internal/netbox/types.go:
|
|
```go
|
|
type Device struct {
|
|
ID int
|
|
Name string
|
|
AssetTag string // HW-XXXXX
|
|
CustomFields CustomFields
|
|
Created time.Time
|
|
LastUpdated time.Time
|
|
}
|
|
type CustomFields struct {
|
|
HWID, CatalogStatus, ProductURL, FirmwareVersion string
|
|
TestDate, TestData, AINotes string
|
|
PhotoURLs []string
|
|
}
|
|
```
|
|
|
|
From internal/netbox/custom_fields.go:
|
|
```go
|
|
func BuildCustomFieldsPatch(hwID, catalogStatus string, photoURLs []string) map[string]interface{}
|
|
```
|
|
</interfaces>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 1: HW-XXXXX sequential ID allocation (INF-03)</name>
|
|
<files>internal/netbox/hwid.go, internal/netbox/hwid_test.go</files>
|
|
|
|
<read_first>
|
|
- /home/mikkel/homelabby/internal/netbox/client.go (Client struct, api access)
|
|
- /home/mikkel/homelabby/.planning/phases/01-foundation/01-RESEARCH.md (Pattern 4: HW-XXXXX Sequential ID Allocation, lines 238-265)
|
|
</read_first>
|
|
|
|
<behavior>
|
|
- Test 1: parseHWID("HW-00042") returns 42, nil
|
|
- Test 2: parseHWID("HW-99999") returns 99999, nil
|
|
- Test 3: parseHWID("not-a-hw-id") returns 0, error
|
|
- Test 4: parseHWID("") returns 0, error
|
|
- Test 5: formatHWID(1) returns "HW-00001"
|
|
- Test 6: formatHWID(99999) returns "HW-99999"
|
|
- Test 7 (INTEGRATION — skip if no real token): AllocateNextHWID returns a string matching ^HW-\d{5}$
|
|
</behavior>
|
|
|
|
<action>
|
|
Create `internal/netbox/hwid.go`:
|
|
```go
|
|
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.
|
|
//
|
|
// The reservation is a placeholder NetBox device with name "__hwid_reservation__"
|
|
// that the caller MUST immediately replace with the real device data.
|
|
// In practice, Phase 2 will create the real device in a single atomic step,
|
|
// so the placeholder device is never committed separately.
|
|
//
|
|
// 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) {
|
|
// Query all devices, paginate if needed — in Phase 1 this is small
|
|
// Filter by asset_tag starting with "HW-" to limit results
|
|
// NOTE: go-netbox v4 DcimDevicesList supports AssetTag filter
|
|
// Use limit=1000 and sort by asset_tag descending to find the highest efficiently
|
|
// If NetBox v4 supports ordering by asset_tag: use .Ordering("-asset_tag")
|
|
|
|
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
|
|
}
|
|
```
|
|
|
|
Create `internal/netbox/hwid_test.go`:
|
|
```go
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
</action>
|
|
|
|
<verify>
|
|
<automated>cd /home/mikkel/homelabby && go test ./internal/netbox/... -v -run "TestFormatHWID|TestParseHWID"</automated>
|
|
</verify>
|
|
|
|
<acceptance_criteria>
|
|
- `go test ./internal/netbox/... -run "TestFormatHWID|TestParseHWID"` passes all 10 cases
|
|
- `grep "AllocateNextHWID" internal/netbox/hwid.go` returns the exported function
|
|
- `grep "HW-%05d" internal/netbox/hwid.go` returns the Sprintf format call
|
|
- `grep "getHighestHWIDNumber" internal/netbox/hwid.go` returns the private helper
|
|
- `go build ./internal/netbox/...` exits 0
|
|
</acceptance_criteria>
|
|
|
|
<done>HW-ID format/parse unit tests pass. AllocateNextHWID implemented with optimistic-lock retry. Binary compiles.</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 2: Quality gate state machine and AI tag sync (NB-06, NB-07)</name>
|
|
<files>internal/inventory/types.go, internal/inventory/quality_gate.go, internal/inventory/quality_gate_test.go, internal/netbox/tags.go, internal/netbox/tags_test.go</files>
|
|
|
|
<read_first>
|
|
- /home/mikkel/homelabby/internal/netbox/client.go (Client struct for SyncTags)
|
|
- /home/mikkel/homelabby/.planning/phases/01-foundation/01-RESEARCH.md (Pattern 6: Catalog Status Quality Gate, lines 322-360)
|
|
- /home/mikkel/homelabby/internal/netbox/types.go (CustomFields.CatalogStatus field)
|
|
</read_first>
|
|
|
|
<behavior>
|
|
Quality gate tests:
|
|
- Test 1: StatusDraft.CanTransitionTo(StatusIndexed) returns true
|
|
- Test 2: StatusDraft.CanTransitionTo(StatusComplete) returns false
|
|
- Test 3: StatusIndexed.CanTransitionTo(StatusNeedsResearch) returns true
|
|
- Test 4: StatusIndexed.CanTransitionTo(StatusDraft) returns false (no backward transitions)
|
|
- Test 5: StatusComplete.CanTransitionTo(anything) returns false (terminal)
|
|
- Test 6: Transition(StatusDraft, StatusIndexed) returns StatusIndexed, nil
|
|
- Test 7: Transition(StatusDraft, StatusComplete) returns "", error containing "invalid transition"
|
|
- Test 8: ParseCatalogStatus("draft") returns StatusDraft, nil
|
|
- Test 9: ParseCatalogStatus("unknown_value") returns "", error
|
|
|
|
Tag sync tests:
|
|
- Test 10: normalizeTags([]string{" USB Cable ", "USB cable", "usb-cable"}) returns deduplicated, lowercase-trimmed slice
|
|
- Test 11 (INTEGRATION — skip if no real token): SyncTags([]string{"usb-c-cable"}) creates tag in NetBox and returns its ID
|
|
</behavior>
|
|
|
|
<action>
|
|
1. Create `internal/inventory/types.go`:
|
|
```go
|
|
package inventory
|
|
|
|
import "git.georgsen.dk/hwlab/internal/netbox"
|
|
|
|
// HardwareRecord is the HWLab domain representation of a cataloged item.
|
|
// It wraps a NetBox device with HWLab-specific fields and lifecycle state.
|
|
type HardwareRecord struct {
|
|
HWID string // HW-XXXXX from asset_tag
|
|
NetBoxID int // NetBox device internal ID
|
|
Name string // Device name in NetBox
|
|
CatalogStatus CatalogStatus // Quality gate lifecycle status
|
|
CustomFields netbox.CustomFields // All HWLab custom fields
|
|
AITags []string // AI-suggested tags (synced to NetBox)
|
|
}
|
|
```
|
|
|
|
2. Create `internal/inventory/quality_gate.go`:
|
|
```go
|
|
package inventory
|
|
|
|
import "fmt"
|
|
|
|
// CatalogStatus represents the lifecycle stage of a cataloged hardware item.
|
|
// Stored as the catalog_status custom field value in NetBox.
|
|
type CatalogStatus string
|
|
|
|
const (
|
|
StatusDraft CatalogStatus = "draft"
|
|
StatusIndexed CatalogStatus = "indexed"
|
|
StatusNeedsResearch CatalogStatus = "needs_research"
|
|
StatusResearched CatalogStatus = "researched"
|
|
StatusComplete CatalogStatus = "complete"
|
|
)
|
|
|
|
// validTransitions defines the allowed state machine transitions.
|
|
// No backward transitions are permitted (lifecycle is forward-only).
|
|
var validTransitions = map[CatalogStatus][]CatalogStatus{
|
|
StatusDraft: {StatusIndexed},
|
|
StatusIndexed: {StatusNeedsResearch, StatusResearched},
|
|
StatusNeedsResearch: {StatusResearched},
|
|
StatusResearched: {StatusComplete},
|
|
StatusComplete: {}, // terminal — no further transitions
|
|
}
|
|
|
|
// CanTransitionTo returns true if transitioning from s to next is permitted.
|
|
func (s CatalogStatus) CanTransitionTo(next CatalogStatus) bool {
|
|
allowed, ok := validTransitions[s]
|
|
if !ok {
|
|
return false
|
|
}
|
|
for _, a := range allowed {
|
|
if a == next {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Transition attempts to move from current to next status.
|
|
// Returns the new status on success, or an error describing the invalid transition.
|
|
func Transition(current, next CatalogStatus) (CatalogStatus, error) {
|
|
if !current.CanTransitionTo(next) {
|
|
return "", fmt.Errorf("invalid transition: %s → %s (not in valid transitions map)", current, next)
|
|
}
|
|
return next, nil
|
|
}
|
|
|
|
// ParseCatalogStatus parses a string to a CatalogStatus.
|
|
// Returns error for unknown status values.
|
|
func ParseCatalogStatus(s string) (CatalogStatus, error) {
|
|
cs := CatalogStatus(s)
|
|
if _, ok := validTransitions[cs]; ok {
|
|
return cs, nil
|
|
}
|
|
return "", fmt.Errorf("unknown catalog status: %q (valid: draft, indexed, needs_research, researched, complete)", s)
|
|
}
|
|
|
|
// AllStatuses returns all valid catalog statuses in lifecycle order.
|
|
func AllStatuses() []CatalogStatus {
|
|
return []CatalogStatus{
|
|
StatusDraft, StatusIndexed, StatusNeedsResearch, StatusResearched, StatusComplete,
|
|
}
|
|
}
|
|
```
|
|
|
|
2b. Create `internal/inventory/catalog_updater.go` — wires quality gate transitions to NetBox persistence:
|
|
```go
|
|
package inventory
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"git.georgsen.dk/hwlab/internal/netbox"
|
|
)
|
|
|
|
// CatalogUpdater persists quality gate transitions to NetBox.
|
|
type CatalogUpdater struct {
|
|
client *netbox.Client
|
|
}
|
|
|
|
// NewCatalogUpdater creates a CatalogUpdater backed by the given NetBox client.
|
|
func NewCatalogUpdater(client *netbox.Client) *CatalogUpdater {
|
|
return &CatalogUpdater{client: client}
|
|
}
|
|
|
|
// UpdateCatalogStatus validates the transition from current to next status
|
|
// and persists the result to NetBox via PatchCustomFields.
|
|
// Returns the new status on success.
|
|
func (u *CatalogUpdater) UpdateCatalogStatus(ctx context.Context, deviceID int, current, next CatalogStatus) (CatalogStatus, error) {
|
|
newStatus, err := Transition(current, next)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
patch := map[string]interface{}{
|
|
"catalog_status": string(newStatus),
|
|
}
|
|
if err := u.client.PatchCustomFields(ctx, deviceID, patch); err != nil {
|
|
return "", fmt.Errorf("persist catalog_status to NetBox: %w", err)
|
|
}
|
|
return newStatus, nil
|
|
}
|
|
```
|
|
|
|
3. Create `internal/inventory/quality_gate_test.go`:
|
|
```go
|
|
package inventory_test
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.georgsen.dk/hwlab/internal/inventory"
|
|
)
|
|
|
|
func TestCanTransitionTo(t *testing.T) {
|
|
tests := []struct {
|
|
from inventory.CatalogStatus
|
|
to inventory.CatalogStatus
|
|
allowed bool
|
|
}{
|
|
{inventory.StatusDraft, inventory.StatusIndexed, true},
|
|
{inventory.StatusDraft, inventory.StatusComplete, false},
|
|
{inventory.StatusDraft, inventory.StatusDraft, false},
|
|
{inventory.StatusIndexed, inventory.StatusNeedsResearch, true},
|
|
{inventory.StatusIndexed, inventory.StatusResearched, true},
|
|
{inventory.StatusIndexed, inventory.StatusDraft, false},
|
|
{inventory.StatusNeedsResearch, inventory.StatusResearched, true},
|
|
{inventory.StatusNeedsResearch, inventory.StatusIndexed, false},
|
|
{inventory.StatusResearched, inventory.StatusComplete, true},
|
|
{inventory.StatusResearched, inventory.StatusDraft, false},
|
|
{inventory.StatusComplete, inventory.StatusDraft, false},
|
|
{inventory.StatusComplete, inventory.StatusResearched, false},
|
|
}
|
|
for _, tt := range tests {
|
|
got := tt.from.CanTransitionTo(tt.to)
|
|
if got != tt.allowed {
|
|
t.Errorf("%s → %s: want %v, got %v", tt.from, tt.to, tt.allowed, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTransitionValid(t *testing.T) {
|
|
got, err := inventory.Transition(inventory.StatusDraft, inventory.StatusIndexed)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != inventory.StatusIndexed {
|
|
t.Errorf("want indexed, got %s", got)
|
|
}
|
|
}
|
|
|
|
func TestTransitionInvalid(t *testing.T) {
|
|
_, err := inventory.Transition(inventory.StatusDraft, inventory.StatusComplete)
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid transition")
|
|
}
|
|
if !strings.Contains(err.Error(), "invalid transition") {
|
|
t.Errorf("error should mention 'invalid transition', got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestParseCatalogStatus(t *testing.T) {
|
|
for _, s := range []string{"draft", "indexed", "needs_research", "researched", "complete"} {
|
|
cs, err := inventory.ParseCatalogStatus(s)
|
|
if err != nil {
|
|
t.Errorf("ParseCatalogStatus(%q): unexpected error: %v", s, err)
|
|
}
|
|
if string(cs) != s {
|
|
t.Errorf("ParseCatalogStatus(%q) = %q, want %q", s, cs, s)
|
|
}
|
|
}
|
|
_, err := inventory.ParseCatalogStatus("unknown_status")
|
|
if err == nil {
|
|
t.Error("expected error for unknown status")
|
|
}
|
|
}
|
|
```
|
|
|
|
4. Create `internal/netbox/tags.go` (NB-07 — AI tags synced to NetBox):
|
|
```go
|
|
package netbox
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// normalizeTags deduplicates and normalizes a slice of tag strings:
|
|
// trims whitespace, lowercases, removes empty strings.
|
|
func normalizeTags(tags []string) []string {
|
|
seen := make(map[string]struct{})
|
|
out := make([]string, 0, len(tags))
|
|
for _, t := range tags {
|
|
t = strings.ToLower(strings.TrimSpace(t))
|
|
if t == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[t]; ok {
|
|
continue
|
|
}
|
|
seen[t] = struct{}{}
|
|
out = append(out, t)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TagRef holds a NetBox tag name and its internal ID.
|
|
type TagRef struct {
|
|
ID int32
|
|
Name string
|
|
Slug string
|
|
}
|
|
|
|
// SyncTags ensures all tags in the provided slice exist in NetBox.
|
|
// Tags are normalized before sync (lowercase, trimmed, deduplicated).
|
|
// Returns the TagRef list for all tags (existing + newly created).
|
|
func (c *Client) SyncTags(ctx context.Context, tags []string) ([]TagRef, error) {
|
|
normalized := normalizeTags(tags)
|
|
if len(normalized) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
result := make([]TagRef, 0, len(normalized))
|
|
for _, name := range normalized {
|
|
slug := tagNameToSlug(name)
|
|
ref, err := c.ensureTag(ctx, name, slug)
|
|
if err != nil {
|
|
return result, fmt.Errorf("sync tag %q: %w", name, err)
|
|
}
|
|
result = append(result, ref)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// tagNameToSlug converts a tag name to a NetBox-compatible slug.
|
|
// NetBox slugs: lowercase, hyphens instead of spaces, only [a-z0-9-_].
|
|
func tagNameToSlug(name string) string {
|
|
s := strings.ToLower(strings.TrimSpace(name))
|
|
s = strings.ReplaceAll(s, " ", "-")
|
|
// Remove characters not in [a-z0-9-_]
|
|
var out []byte
|
|
for _, c := range []byte(s) {
|
|
if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_' {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
// ensureTag returns an existing tag or creates a new one.
|
|
func (c *Client) ensureTag(ctx context.Context, name, slug string) (TagRef, error) {
|
|
// Check for existing tag by slug
|
|
res, _, err := c.api.ExtrasAPI.ExtrasTagsList(ctx).Slug([]string{slug}).Execute()
|
|
if err != nil {
|
|
return TagRef{}, fmt.Errorf("list tags: %w", err)
|
|
}
|
|
if res.GetCount() > 0 {
|
|
t := res.Results[0]
|
|
return TagRef{ID: t.GetId(), Name: t.GetName(), Slug: t.GetSlug()}, nil
|
|
}
|
|
// Create new tag
|
|
// NOTE: Executor must use go-netbox v4 TagRequest type
|
|
// Pattern: c.api.ExtrasAPI.ExtrasTagsCreate(ctx).TagRequest(req).Execute()
|
|
// Required fields: Name (string), Slug (string)
|
|
// Optional: Color (hex string, e.g. "faff69" for volt yellow)
|
|
return TagRef{}, fmt.Errorf("ensureTag: implement using go-netbox v4 TagRequest — grep TagRequest $(go env GOPATH)/pkg/mod/github.com/netbox-community/go-netbox/v4@v4.3.0/")
|
|
}
|
|
```
|
|
|
|
EXECUTOR: Replace the `ensureTag` stub with real go-netbox v4 TagRequest call. Locate:
|
|
```
|
|
ls $(go env GOPATH)/pkg/mod/github.com/netbox-community/go-netbox/v4@v4.3.0/model_tag_request.go
|
|
```
|
|
|
|
5. Create `internal/netbox/tags_test.go`:
|
|
```go
|
|
package netbox
|
|
|
|
import "testing"
|
|
|
|
func TestNormalizeTags(t *testing.T) {
|
|
in := []string{" USB Cable ", "USB cable", "usb-cable", "", " "}
|
|
out := normalizeTags(in)
|
|
// "USB Cable", "USB cable", "usb-cable" all normalize to "usb-cable" — only 1 unique
|
|
if len(out) != 1 {
|
|
t.Errorf("want 1 unique normalized tag, got %d: %v", len(out), out)
|
|
}
|
|
if out[0] != "usb-cable" {
|
|
t.Errorf("want usb-cable, got %s", out[0])
|
|
}
|
|
}
|
|
|
|
func TestTagNameToSlug(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
slug string
|
|
}{
|
|
{"USB Cable", "usb-cable"},
|
|
{"10GbE NIC", "10gbe-nic"},
|
|
{"SFP+ Transceiver", "sfp-transceiver"},
|
|
{" spaces ", "spaces"},
|
|
}
|
|
for _, tt := range tests {
|
|
got := tagNameToSlug(tt.name)
|
|
if got != tt.slug {
|
|
t.Errorf("tagNameToSlug(%q) = %q, want %q", tt.name, got, tt.slug)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
</action>
|
|
|
|
<verify>
|
|
<automated>cd /home/mikkel/homelabby && go test ./internal/inventory/... ./internal/netbox/... -v -run "TestCanTransitionTo|TestTransitionValid|TestTransitionInvalid|TestParseCatalogStatus|TestNormalizeTags|TestTagNameToSlug"</automated>
|
|
</verify>
|
|
|
|
<acceptance_criteria>
|
|
- `go test ./internal/inventory/...` passes all 12 table-driven cases for CanTransitionTo
|
|
- `go test ./internal/inventory/...` passes TestTransitionValid, TestTransitionInvalid, TestParseCatalogStatus
|
|
- `go test ./internal/netbox/... -run "TestNormalizeTags|TestTagNameToSlug"` passes all cases
|
|
- `grep "StatusComplete.*{}" internal/inventory/quality_gate.go` returns the terminal state entry with empty transitions
|
|
- `grep "invalid transition" internal/inventory/quality_gate.go` returns error string in Transition()
|
|
- `ensureTag` in `internal/netbox/tags.go` is implemented with real go-netbox v4 TagRequest (not stub returning error string)
|
|
- `grep "UpdateCatalogStatus" internal/inventory/catalog_updater.go` returns the function that calls Transition() then PatchCustomFields
|
|
- `grep "PatchCustomFields" internal/inventory/catalog_updater.go` confirms NetBox persistence wiring
|
|
- `go build ./internal/inventory/... ./internal/netbox/...` exits 0
|
|
</acceptance_criteria>
|
|
|
|
<done>Quality gate state machine fully tested. Tag normalization and slug conversion tested. ensureTag implemented with real go-netbox v4 API. All packages build cleanly.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<threat_model>
|
|
## Trust Boundaries
|
|
|
|
| Boundary | Description |
|
|
|----------|-------------|
|
|
| AI output → SyncTags | AI-suggested tag strings enter normalizeTags before any NetBox write |
|
|
| Quality gate transitions → NetBox writes | Transition validation in Go; NetBox stores the result |
|
|
|
|
## STRIDE Threat Register
|
|
|
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
|
|-----------|----------|-----------|-------------|-----------------|
|
|
| T-04-01 | Tampering | Quality gate bypass | mitigate | All status changes MUST go through Transition() — no direct NetBox PATCH of catalog_status without Transition validation |
|
|
| T-04-02 | Tampering | AI tag injection | mitigate | normalizeTags strips whitespace, lowercases, deduplicates — limits injection surface before NetBox write |
|
|
| T-04-03 | Denial of Service | AllocateNextHWID scanning all devices | accept | Phase 1 inventory is small; getHighestHWIDNumber scans all devices; acceptable until inventory exceeds ~10k items |
|
|
| T-04-04 | Information Disclosure | HW-ID sequential enumeration | accept | IDs are not secret — they appear on printed labels; sequential is intentional for readability |
|
|
</threat_model>
|
|
|
|
<verification>
|
|
After both tasks complete:
|
|
- `go test ./internal/inventory/... ./internal/netbox/...` all green (unit tests)
|
|
- `go build ./...` exits 0 (all packages compile together)
|
|
- Quality gate: `draft → indexed → needs_research → researched → complete` is the only fully valid path
|
|
- `curl -s -H "Authorization: Token $HWLAB_NETBOX_TOKEN" "http://10.5.0.130:8000/api/extras/tags/" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['count'])"` (verify tags endpoint reachable, if real token)
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
1. All 12 state machine transition cases tested and correct
|
|
2. Transition() returns error containing "invalid transition" for bad transitions
|
|
3. ParseCatalogStatus rejects unknown status strings
|
|
4. normalizeTags handles deduplication across case/whitespace variants
|
|
5. AllocateNextHWID implemented with optimistic retry loop
|
|
6. ensureTag uses real go-netbox v4 API (not stub)
|
|
7. `go build ./...` and `go test ./...` both clean
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/01-foundation/01-04-SUMMARY.md` with:
|
|
- All test results (pass counts)
|
|
- Whether ensureTag integration test ran (real token) or skipped
|
|
- Any issues with go-netbox v4 tag or device list filter API
|
|
- Files created/modified
|
|
</output>
|