feat(01-04): quality gate state machine, tag sync, catalog updater

- 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
This commit is contained in:
Mikkel Georgsen 2026-04-10 05:22:22 +00:00
parent e1cee31620
commit 1f9621fcaa
6 changed files with 315 additions and 0 deletions

View file

@ -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
}

View file

@ -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,
}
}

View file

@ -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")
}
}

View file

@ -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)
}

94
internal/netbox/tags.go Normal file
View file

@ -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
}

View file

@ -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)
}
}
}