From b32c74c29ae7b0f68334aee2dad4595bfc4befcb Mon Sep 17 00:00:00 2001 From: jleung Date: Thu, 22 Oct 2026 18:01:29 +0000 Subject: [PATCH] M2 prep: per-device snmp_port, device_components table and store contract Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP --- internal/api/devices.go | 18 ++++- internal/store/20261022000002_snmp_port.go | 19 ++++++ internal/store/20261022000003_components.go | 33 +++++++++ internal/store/components.go | 76 +++++++++++++++++++++ internal/store/components_test.go | 41 +++++++++++ internal/store/device.go | 8 ++- internal/store/models.go | 4 ++ internal/store/store.go | 1 + internal/store/store_test.go | 2 +- 9 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 internal/store/20261022000002_snmp_port.go create mode 100644 internal/store/20261022000003_components.go create mode 100644 internal/store/components.go create mode 100644 internal/store/components_test.go diff --git a/internal/api/devices.go b/internal/api/devices.go index 227ed86..0ea0e16 100644 --- a/internal/api/devices.go +++ b/internal/api/devices.go @@ -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 +} diff --git a/internal/store/20261022000002_snmp_port.go b/internal/store/20261022000002_snmp_port.go new file mode 100644 index 0000000..f48b360 --- /dev/null +++ b/internal/store/20261022000002_snmp_port.go @@ -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 + }) +} diff --git a/internal/store/20261022000003_components.go b/internal/store/20261022000003_components.go new file mode 100644 index 0000000..c133685 --- /dev/null +++ b/internal/store/20261022000003_components.go @@ -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 + }) +} diff --git a/internal/store/components.go b/internal/store/components.go new file mode 100644 index 0000000..31f7c3e --- /dev/null +++ b/internal/store/components.go @@ -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) +} diff --git a/internal/store/components_test.go b/internal/store/components_test.go new file mode 100644 index 0000000..6b93f95 --- /dev/null +++ b/internal/store/components_test.go @@ -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)) + } + }) +} diff --git a/internal/store/device.go b/internal/store/device.go index 7dd50bc..59f0eda 100644 --- a/internal/store/device.go +++ b/internal/store/device.go @@ -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) diff --git a/internal/store/models.go b/internal/store/models.go index 5aba819..f330af1 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -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"` diff --git a/internal/store/store.go b/internal/store/store.go index 823ca4b..ff7e01e 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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. diff --git a/internal/store/store_test.go b/internal/store/store_test.go index d22ed46..e1c933a 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -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)} {