release / release (push) Successful in 5m53s
- A disabled reservation is kept but ignored: the client gets a pool address like any other (steward reservation disable|enable, buttons and a checkbox in the web UI). Schema migration 3. - Subnets get Disable/Enable buttons on their page and in the list. - A rule left without its selector in style.css broke the page heading's layout, so the New ... buttons sat cramped under the description. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
185 lines
5.9 KiB
Go
185 lines
5.9 KiB
Go
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, 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.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, 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, &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, lease_time, options, disabled, comment) VALUES (?,?,?,?,?,?,?,?,?)
|
|
ON CONFLICT(id) DO UPDATE SET name = excluded.name, interface = excluded.interface, prefix = excluded.prefix, pools = excluded.pools,
|
|
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.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, 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, disabled = excluded.disabled, comment = excluded.comment`,
|
|
r.ID, r.MAC, r.ClientID, r.IP.String(), r.Hostname, jsonText(nonNilOpts(r.Options)), 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
|
|
}
|