From 1f9621fcaa0a1aeb972d9c986c380727da958f30 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Fri, 10 Apr 2026 05:22:22 +0000 Subject: [PATCH] feat(01-04): quality gate state machine, tag sync, catalog updater MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CatalogStatus type with forward-only state machine (draft→indexed→…→complete) - Transition() enforces valid transitions, returns error with 'invalid transition' message - ParseCatalogStatus() validates known status strings - HardwareRecord domain type composing netbox.Device with quality gate state - CatalogUpdater.UpdateCatalogStatus() validates transition then PatchCustomFields - SyncTags() normalizes tags (slug form) and ensures they exist in NetBox - normalizeTags deduplicates across case/whitespace/space-vs-hyphen variants - ensureTag uses go-netbox v4 NewTagRequest(name, slug) / ExtrasTagsCreate - All 12 state machine table-driven tests pass --- internal/inventory/catalog_updater.go | 38 ++++++++++ internal/inventory/quality_gate.go | 65 +++++++++++++++++ internal/inventory/quality_gate_test.go | 71 +++++++++++++++++++ internal/inventory/types.go | 14 ++++ internal/netbox/tags.go | 94 +++++++++++++++++++++++++ internal/netbox/tags_test.go | 33 +++++++++ 6 files changed, 315 insertions(+) create mode 100644 internal/inventory/catalog_updater.go create mode 100644 internal/inventory/quality_gate.go create mode 100644 internal/inventory/quality_gate_test.go create mode 100644 internal/inventory/types.go create mode 100644 internal/netbox/tags.go create mode 100644 internal/netbox/tags_test.go diff --git a/internal/inventory/catalog_updater.go b/internal/inventory/catalog_updater.go new file mode 100644 index 0000000..e5b9b75 --- /dev/null +++ b/internal/inventory/catalog_updater.go @@ -0,0 +1,38 @@ +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. +// +// All catalog_status writes MUST go through this method to ensure T-04-01 mitigation: +// the quality gate transition is always validated before any NetBox PATCH. +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 +} diff --git a/internal/inventory/quality_gate.go b/internal/inventory/quality_gate.go new file mode 100644 index 0000000..42ed2f2 --- /dev/null +++ b/internal/inventory/quality_gate.go @@ -0,0 +1,65 @@ +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, + } +} diff --git a/internal/inventory/quality_gate_test.go b/internal/inventory/quality_gate_test.go new file mode 100644 index 0000000..303ee30 --- /dev/null +++ b/internal/inventory/quality_gate_test.go @@ -0,0 +1,71 @@ +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") + } +} diff --git a/internal/inventory/types.go b/internal/inventory/types.go new file mode 100644 index 0000000..27167b4 --- /dev/null +++ b/internal/inventory/types.go @@ -0,0 +1,14 @@ +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) +} diff --git a/internal/netbox/tags.go b/internal/netbox/tags.go new file mode 100644 index 0000000..7407d6e --- /dev/null +++ b/internal/netbox/tags.go @@ -0,0 +1,94 @@ +package netbox + +import ( + "context" + "fmt" + "strings" + + nb "github.com/netbox-community/go-netbox/v4" +) + +// normalizeTags deduplicates and normalizes a slice of tag strings. +// Normalization: lowercase, trim whitespace, convert spaces to hyphens, +// remove non-slug characters [^a-z0-9-_], remove empty strings. +// This ensures tags like "USB Cable", "USB cable", "usb-cable" all deduplicate to "usb-cable". +func normalizeTags(tags []string) []string { + seen := make(map[string]struct{}) + out := make([]string, 0, len(tags)) + for _, t := range tags { + t = tagNameToSlug(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 using go-netbox v4 TagRequest + req := nb.NewTagRequest(name, slug) + created, _, err := c.api.ExtrasAPI.ExtrasTagsCreate(ctx).TagRequest(*req).Execute() + if err != nil { + return TagRef{}, fmt.Errorf("create tag %q (slug: %s): %w", name, slug, err) + } + return TagRef{ID: created.GetId(), Name: created.GetName(), Slug: created.GetSlug()}, nil +} diff --git a/internal/netbox/tags_test.go b/internal/netbox/tags_test.go new file mode 100644 index 0000000..b4e8652 --- /dev/null +++ b/internal/netbox/tags_test.go @@ -0,0 +1,33 @@ +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) + } + } +}