package store import ( "context" "database/sql" "encoding/json" "fmt" "net/netip" "steward/internal/model" ) const settingsKey = "settings" // LoadState reads the whole configuration. func (s *Store) LoadState(ctx context.Context) (*model.State, error) { st := &model.State{Settings: model.DefaultSettings(), Subnets: []*model.Subnet{}, Reservations: []*model.Reservation{}} if v, err := s.GetMeta(ctx, settingsKey); err != nil { return nil, err } else if v != "" { if err := json.Unmarshal([]byte(v), &st.Settings); err != nil { return nil, fmt.Errorf("reading settings: %w", err) } } if st.Settings.Options == nil { st.Settings.Options = []model.OptionValue{} } rows, err := s.db.QueryContext(ctx, `SELECT id, name, interface, prefix, pools, pool_order, address_order, lease_time, options, disabled, comment FROM subnets ORDER BY id`) if err != nil { return nil, err } defer rows.Close() for rows.Next() { sn := &model.Subnet{} var prefix, pools, opts string var disabled int if err := rows.Scan(&sn.ID, &sn.Name, &sn.Interface, &prefix, &pools, &sn.PoolOrder, &sn.AddressOrder, &sn.LeaseTime, &opts, &disabled, &sn.Comment); err != nil { return nil, err } sn.Prefix, _ = netip.ParsePrefix(prefix) sn.Disabled = disabled != 0 if err := json.Unmarshal([]byte(pools), &sn.Pools); err != nil { return nil, fmt.Errorf("subnet %d pools: %w", sn.ID, err) } if err := json.Unmarshal([]byte(opts), &sn.Options); err != nil { return nil, fmt.Errorf("subnet %d options: %w", sn.ID, err) } st.Subnets = append(st.Subnets, sn) } if err := rows.Err(); err != nil { return nil, err } rows, err = s.db.QueryContext(ctx, `SELECT id, mac, client_id, ip, hostname, options, lease_time, disabled, comment FROM reservations ORDER BY id`) if err != nil { return nil, err } defer rows.Close() for rows.Next() { r := &model.Reservation{} var ip, opts string var disabled int if err := rows.Scan(&r.ID, &r.MAC, &r.ClientID, &ip, &r.Hostname, &opts, &r.LeaseTime, &disabled, &r.Comment); err != nil { return nil, err } r.IP, _ = netip.ParseAddr(ip) r.Disabled = disabled != 0 if err := json.Unmarshal([]byte(opts), &r.Options); err != nil { return nil, fmt.Errorf("reservation %d options: %w", r.ID, err) } st.Reservations = append(st.Reservations, r) } if err := rows.Err(); err != nil { return nil, err } for _, sn := range st.Subnets { if sn.Pools == nil { sn.Pools = []model.Pool{} } if sn.Options == nil { sn.Options = []model.OptionValue{} } } for _, r := range st.Reservations { if r.Options == nil { r.Options = []model.OptionValue{} } } st.Sort() return st, nil } // SaveChanges writes configuration changes and their history entry in one // transaction. The entry gets its ID and, when it undoes another, that // entry is marked reverted. func (s *Store) SaveChanges(ctx context.Context, changes []model.Change, e *model.HistoryEntry) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() for _, c := range changes { if err := applyChange(ctx, tx, c); err != nil { return fmt.Errorf("saving %s %d: %w", c.Kind, c.ID, err) } } if e != nil { var uid any if e.UserID != 0 { uid = e.UserID } var revertOf any if e.RevertOf != 0 { revertOf = e.RevertOf } res, err := tx.ExecContext(ctx, `INSERT INTO history (time, user_id, username, via, description, changes, revert_of) VALUES (?,?,?,?,?,?,?)`, ts(e.Time), uid, e.Username, e.Via, e.Description, jsonText(e.Changes), revertOf) if err != nil { return err } if e.ID, err = res.LastInsertId(); err != nil { return err } if e.RevertOf != 0 { if _, err := tx.ExecContext(ctx, `UPDATE history SET reverted_by = ? WHERE id = ?`, e.ID, e.RevertOf); err != nil { return err } } } return tx.Commit() } func applyChange(ctx context.Context, tx *sql.Tx, c model.Change) error { switch c.Kind { case model.KindSettings: if c.After == nil { return fmt.Errorf("settings can't be deleted") } _, err := tx.ExecContext(ctx, `INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, settingsKey, string(c.After)) return err case model.KindSubnet: if c.After == nil { _, err := tx.ExecContext(ctx, `DELETE FROM subnets WHERE id = ?`, c.ID) return err } var sn model.Subnet if err := json.Unmarshal(c.After, &sn); err != nil { return err } _, err := tx.ExecContext(ctx, `INSERT INTO subnets (id, name, interface, prefix, pools, pool_order, address_order, lease_time, options, disabled, comment) VALUES (?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, interface = excluded.interface, prefix = excluded.prefix, pools = excluded.pools, pool_order = excluded.pool_order, address_order = excluded.address_order, lease_time = excluded.lease_time, options = excluded.options, disabled = excluded.disabled, comment = excluded.comment`, sn.ID, sn.Name, sn.Interface, sn.Prefix.String(), jsonText(nonNil(sn.Pools)), sn.PoolOrder, sn.AddressOrder, sn.LeaseTime, jsonText(nonNilOpts(sn.Options)), boolInt(sn.Disabled), sn.Comment) return err case model.KindReservation: if c.After == nil { _, err := tx.ExecContext(ctx, `DELETE FROM reservations WHERE id = ?`, c.ID) return err } var r model.Reservation if err := json.Unmarshal(c.After, &r); err != nil { return err } _, err := tx.ExecContext(ctx, `INSERT INTO reservations (id, mac, client_id, ip, hostname, options, lease_time, disabled, comment) VALUES (?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET mac = excluded.mac, client_id = excluded.client_id, ip = excluded.ip, hostname = excluded.hostname, options = excluded.options, lease_time = excluded.lease_time, disabled = excluded.disabled, comment = excluded.comment`, r.ID, r.MAC, r.ClientID, r.IP.String(), r.Hostname, jsonText(nonNilOpts(r.Options)), r.LeaseTime, boolInt(r.Disabled), r.Comment) return err } return fmt.Errorf("unknown kind %q", c.Kind) } func nonNil(p []model.Pool) []model.Pool { if p == nil { return []model.Pool{} } return p } func nonNilOpts(o []model.OptionValue) []model.OptionValue { if o == nil { return []model.OptionValue{} } return o }