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:
@@ -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
|
||||
}
|
||||
|
||||
19
internal/store/20261022000002_snmp_port.go
Normal file
19
internal/store/20261022000002_snmp_port.go
Normal 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
|
||||
})
|
||||
}
|
||||
33
internal/store/20261022000003_components.go
Normal file
33
internal/store/20261022000003_components.go
Normal 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
|
||||
})
|
||||
}
|
||||
76
internal/store/components.go
Normal file
76
internal/store/components.go
Normal 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)
|
||||
}
|
||||
41
internal/store/components_test.go
Normal file
41
internal/store/components_test.go
Normal 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))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)} {
|
||||
|
||||
Reference in New Issue
Block a user