M2 prep: per-device snmp_port, device_components table and store contract

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
This commit is contained in:
2026-10-22 18:01:29 +00:00
parent 02dce40e2a
commit b32c74c29a
9 changed files with 197 additions and 5 deletions

View File

@@ -25,6 +25,7 @@ type SNMPBody struct {
V3PrivProto string `json:"v3_priv_proto,omitempty" doc:"DES, AES (default), AES192 or AES256."`
V3PrivPass string `json:"v3_priv_pass,omitempty"`
V3Context string `json:"v3_context,omitempty"`
Port int `json:"port,omitempty" minimum:"1" maximum:"65535" doc:"UDP port; default 161."`
}
// AddDeviceBody is the body of POST /api/v1/devices.
@@ -132,7 +133,7 @@ func (s *server) addDevice(ctx context.Context, in *struct{ Body AddDeviceBody }
break
}
}
if res, err = s.collect(ctx, pref, cred); err != nil {
if res, err = s.collect(ctx, pref, s.port(b.SNMP.Port), cred); err != nil {
return nil, err
}
}
@@ -140,6 +141,9 @@ func (s *server) addDevice(ctx context.Context, in *struct{ Body AddDeviceBody }
var slug string
err := s.Store.WithTx(ctx, func(tx store.Store) error {
d := &store.Device{Name: b.Name, Kind: kind, Site: b.Site, Role: b.Role, Enabled: true, Profile: "generic"}
if b.SNMP != nil {
d.SNMPPort = b.SNMP.Port
}
if cred != nil {
if err := tx.CreateCredential(ctx, cred); err != nil {
return err
@@ -201,10 +205,10 @@ func (s *server) credential(b *SNMPBody) (*store.SNMPCredential, error) {
}
// collect runs the seed walk against addr before anything is stored.
func (s *server) collect(ctx context.Context, addr netip.Addr, cred *store.SNMPCredential) (*discovery.Result, error) {
func (s *server) collect(ctx context.Context, addr netip.Addr, port int, cred *store.SNMPCredential) (*discovery.Result, error) {
dctx, cancel := context.WithTimeout(ctx, s.DiscoveryTimeout)
defer cancel()
sess, err := snmp.Dial(dctx, discovery.Target(addr, s.SNMPPort, cred, s.Clock), "discovery")
sess, err := snmp.Dial(dctx, discovery.Target(addr, port, cred, s.Clock), "discovery")
if err != nil {
return nil, huma.Error422UnprocessableEntity("snmp: " + err.Error())
}
@@ -279,3 +283,11 @@ func utcp(t *time.Time) *time.Time {
u := t.UTC()
return &u
}
// port is the device's SNMP port unless the dev-only --snmp-port override is set.
func (s *server) port(p int) int {
if s.SNMPPort != 0 {
return s.SNMPPort
}
return p
}

View File

@@ -0,0 +1,19 @@
package store
import (
"context"
"github.com/uptrace/bun"
)
// Migration 2 adds devices.snmp_port: the UDP port SNMP is polled on (161 unless
// the operator says otherwise; fixtures and the loadtest use other ports).
func init() {
Migrations.MustRegister(func(ctx context.Context, db *bun.DB) error {
_, err := db.NewAddColumn().Table("devices").ColumnExpr("snmp_port INTEGER NOT NULL DEFAULT 161").Exec(ctx)
return err
}, func(ctx context.Context, db *bun.DB) error {
_, err := db.NewDropColumn().Table("devices").Column("snmp_port").Exec(ctx)
return err
})
}

View File

@@ -0,0 +1,33 @@
package store
import (
"context"
"time"
"github.com/uptrace/bun"
)
// m0003Component is the frozen shape of device_components as created by migration 3.
type m0003Component struct {
bun.BaseModel `bun:"table:device_components,alias:dc"`
DeviceID int64 `bun:"device_id,pk"`
TableName string `bun:"table_name,pk"`
Idx string `bun:"idx,pk"`
Label string `bun:"label,notnull"`
FirstSeen time.Time `bun:"first_seen,notnull"`
RetiredAt *time.Time `bun:"retired_at"`
}
// Migration 3 adds device_components: the rows of profile tables (CPUs,
// storage, sensors, supplies) that discovery found and the poller polls by index.
func init() {
Migrations.MustRegister(func(ctx context.Context, db *bun.DB) error {
_, err := db.NewCreateTable().Model((*m0003Component)(nil)).
ForeignKey("(?) REFERENCES ? (?)", bun.Ident("device_id"), bun.Ident("devices"), bun.Ident("id")).Exec(ctx)
return err
}, func(ctx context.Context, db *bun.DB) error {
_, err := db.NewDropTable().Model((*m0003Component)(nil)).IfExists().Exec(ctx)
return err
})
}

View File

@@ -0,0 +1,76 @@
package store
import (
"context"
"time"
"github.com/uptrace/bun"
)
// Component is one row of a profile table on a device: Idx is the OID index
// suffix (e.g. "1" or "1.3"), Label the value of the profile's index_label column.
type Component struct {
bun.BaseModel `bun:"table:device_components,alias:dc"`
DeviceID int64 `bun:"device_id,pk"`
TableName string `bun:"table_name,pk"`
Idx string `bun:"idx,pk"`
Label string `bun:"label,notnull"`
FirstSeen time.Time `bun:"first_seen,notnull"`
RetiredAt *time.Time `bun:"retired_at"`
}
// Components stores discovered table rows.
type Components interface {
// ReplaceComponents makes rows the current set for (deviceID, table): new rows
// are inserted, existing rows updated (label, un-retired), missing rows retired.
ReplaceComponents(ctx context.Context, deviceID int64, table string, rows []Component) error
// ListComponents returns a device's active rows, all tables, ordered by table then index.
ListComponents(ctx context.Context, deviceID int64) ([]Component, error)
}
func (s *bunStore) ReplaceComponents(ctx context.Context, deviceID int64, table string, rows []Component) error {
return s.WithTx(ctx, func(tx Store) error {
t := tx.(*bunStore)
var cur []Component
if err := t.db.NewSelect().Model(&cur).Where("device_id = ? AND table_name = ?", deviceID, table).Scan(ctx); err != nil {
return mapErr(err)
}
now := s.now()
have := make(map[string]Component, len(cur))
for _, c := range cur {
have[c.Idx] = c
}
seen := make(map[string]bool, len(rows))
for _, r := range rows {
r.DeviceID, r.TableName = deviceID, table
seen[r.Idx] = true
if old, ok := have[r.Idx]; ok {
r.FirstSeen = old.FirstSeen
if _, err := t.db.NewUpdate().Model(&r).Column("label", "retired_at").WherePK().Exec(ctx); err != nil {
return mapErr(err)
}
continue
}
r.FirstSeen, r.RetiredAt = now, nil
if _, err := t.db.NewInsert().Model(&r).Exec(ctx); err != nil {
return mapErr(err)
}
}
for _, c := range cur {
if !seen[c.Idx] && c.RetiredAt == nil {
if _, err := t.db.NewUpdate().Model(&c).Set("retired_at = ?", now).WherePK().Exec(ctx); err != nil {
return mapErr(err)
}
}
}
return nil
})
}
func (s *bunStore) ListComponents(ctx context.Context, deviceID int64) ([]Component, error) {
var rows []Component
err := s.db.NewSelect().Model(&rows).Where("device_id = ? AND retired_at IS NULL", deviceID).
OrderExpr("table_name, idx").Scan(ctx)
return rows, mapErr(err)
}

View File

@@ -0,0 +1,41 @@
package store
import (
"context"
"testing"
"time"
)
func TestReplaceComponents(t *testing.T) {
run(t, func(t *testing.T, e *env) {
ctx := context.Background()
d := newDevice(t, e, "sw1")
if d.SNMPPort != DefaultSNMPPort {
t.Fatalf("snmp_port default = %d", d.SNMPPort)
}
must(t, e.ReplaceComponents(ctx, d.ID, "hrStorageTable", []Component{{Idx: "1", Label: "Physical memory"}, {Idx: "31", Label: "/"}}))
first, err := e.ListComponents(ctx, d.ID)
must(t, err)
if len(first) != 2 {
t.Fatalf("got %d rows", len(first))
}
e.clk.Advance(time.Hour)
// "/" renamed, "Physical memory" gone, a new disk appears.
must(t, e.ReplaceComponents(ctx, d.ID, "hrStorageTable", []Component{{Idx: "31", Label: "/root"}, {Idx: "32", Label: "/var"}}))
got, err := e.ListComponents(ctx, d.ID)
must(t, err)
if len(got) != 2 || got[0].Idx != "31" || got[0].Label != "/root" || got[1].Idx != "32" {
t.Fatalf("got %+v", got)
}
if !got[0].FirstSeen.Equal(first[1].FirstSeen) {
t.Fatalf("first_seen moved: %v vs %v", got[0].FirstSeen, first[1].FirstSeen)
}
// A returning row is un-retired.
must(t, e.ReplaceComponents(ctx, d.ID, "hrStorageTable", []Component{{Idx: "1", Label: "Physical memory"}, {Idx: "31", Label: "/root"}, {Idx: "32", Label: "/var"}}))
got, err = e.ListComponents(ctx, d.ID)
must(t, err)
if len(got) != 3 {
t.Fatalf("got %d rows after return", len(got))
}
})
}

View File

@@ -89,6 +89,9 @@ func (s *bunStore) createWithSlug(ctx context.Context, d *Device, slug string) e
c := *d
c.ID, c.Slug, c.CreatedAt = 0, slug, s.now()
c.RetiredAt, c.PurgedAt, c.LastSeen = nil, nil, nil
if c.SNMPPort == 0 {
c.SNMPPort = DefaultSNMPPort
}
if c.PollState == "" {
c.PollState = PollOK
}
@@ -154,9 +157,12 @@ func (s *bunStore) UpdateDevice(ctx context.Context, d *Device) error {
if strings.TrimSpace(d.Name) == "" || !validKind(d.Kind) {
return fmt.Errorf("%w: device needs a name and a valid kind", ErrInvalid)
}
if d.SNMPPort == 0 {
d.SNMPPort = DefaultSNMPPort
}
res, err := s.db.NewUpdate().Model(d).
Column("name", "kind", "mgmt_ip", "snmp_credential_id", "profile", "site", "role", "enabled",
"external_id", "sys_name", "sys_object_id", "sys_descr", "hw_serial").
"external_id", "sys_name", "sys_object_id", "sys_descr", "hw_serial", "snmp_port").
WherePK().Exec(ctx)
if err != nil {
return mapErr(err)

View File

@@ -69,8 +69,12 @@ type Device struct {
Warnings StringList `bun:"warnings,notnull"`
CreatedAt time.Time `bun:"created_at,notnull"`
PurgedAt *time.Time `bun:"purged_at"`
SNMPPort int `bun:"snmp_port,notnull"` // migration 2
}
// DefaultSNMPPort is used when a device has no explicit port.
const DefaultSNMPPort = 161
// DeviceAddress is one management address of a device.
type DeviceAddress struct {
bun.BaseModel `bun:"table:device_addresses,alias:da"`

View File

@@ -163,6 +163,7 @@ type Store interface {
Parents
Audit
Identity
Components
// WithTx runs fn in one transaction; the Store it receives is bound to it.
// It commits if fn returns nil and rolls back otherwise.

View File

@@ -659,7 +659,7 @@ func TestCredentialCiphertextBoundToRow(t *testing.T) {
// TestModelsMatchMigrations catches drift between the live models and the migrations.
func TestModelsMatchMigrations(t *testing.T) {
run(t, func(t *testing.T, e *env) {
for _, m := range []any{(*credentialRow)(nil), (*Device)(nil), (*DeviceAddress)(nil), (*DeviceParent)(nil),
for _, m := range []any{(*Component)(nil), (*credentialRow)(nil), (*Device)(nil), (*DeviceAddress)(nil), (*DeviceParent)(nil),
(*Interface)(nil), (*group)(nil), (*groupMember)(nil), (*check)(nil), (*alert)(nil), (*alertDelivery)(nil),
(*maintenanceWindow)(nil), (*rollupProgress)(nil), (*pushToken)(nil), (*apiToken)(nil), (*AuditEntry)(nil),
(*IdentityReview)(nil), (*rollupWindow)(nil), (*Discovery)(nil)} {