Steward 0.1.0 (in development): a DHCPv4 server with a web UI, API and CLI
Subnets served per interface, pools, reservations, options at three levels, leases in SQLite saved before the ACK, ping and decline conflict checks, validated changes with history and undo, users and tokens, an audit log, a Kea importer, a sandboxed systemd unit and Debian packages. Tested with real udhcpc and dhcpcd clients in Docker (scripts/e2e.sh). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
3c0808591f
111 files changed
+17851
No files matched your search
@@ -0,0 +1,459 @@
|
||||
// Package engine changes Steward's configuration. Every change, from the
|
||||
// API, the CLI or an import, goes through Apply: the change is made on a
|
||||
// copy of the state, the result is validated as a whole, and only then are
|
||||
// the changed objects and a history entry saved together and the new state
|
||||
// handed to the DHCP server. Revert undoes a history entry the same way.
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"steward/internal/auth"
|
||||
"steward/internal/dhcp4"
|
||||
"steward/internal/model"
|
||||
"steward/internal/store"
|
||||
"steward/internal/validate"
|
||||
)
|
||||
|
||||
// Kinds of errors, for the API's status codes.
|
||||
const (
|
||||
KindBadRequest = "bad_request"
|
||||
KindNotFound = "not_found"
|
||||
KindConflict = "conflict"
|
||||
KindValidation = "validation"
|
||||
)
|
||||
|
||||
// Error is a refused change.
|
||||
type Error struct {
|
||||
Kind string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Problems []validate.Problem `json:"problems,omitempty"`
|
||||
}
|
||||
|
||||
func (e *Error) Error() string { return e.Message }
|
||||
|
||||
// Errorf makes an Error.
|
||||
func Errorf(kind, format string, args ...any) *Error {
|
||||
return &Error{Kind: kind, Message: fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
// AsError unwraps an Error.
|
||||
func AsError(err error) (*Error, bool) {
|
||||
var e *Error
|
||||
ok := errors.As(err, &e)
|
||||
return e, ok
|
||||
}
|
||||
|
||||
// Result describes an applied (or previewed) change.
|
||||
type Result struct {
|
||||
Applied bool `json:"applied"`
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
HistoryID int64 `json:"history_id,omitempty"`
|
||||
Changes []model.Change `json:"changes"`
|
||||
Warnings []validate.Problem `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
// Engine holds the current configuration.
|
||||
type Engine struct {
|
||||
store *store.Store
|
||||
log *slog.Logger
|
||||
// Env describes the host's interfaces for validation (nil: skip).
|
||||
Env func() *validate.Env
|
||||
// OnChange is called with each new state (the DHCP server's config).
|
||||
OnChange func(*model.State)
|
||||
|
||||
mu sync.Mutex
|
||||
state *model.State
|
||||
}
|
||||
|
||||
// New loads the configuration.
|
||||
func New(ctx context.Context, st *store.Store, log *slog.Logger) (*Engine, error) {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
s, err := st.LoadState(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
Normalize(s)
|
||||
return &Engine{store: st, log: log, state: s}, nil
|
||||
}
|
||||
|
||||
// Store is the engine's database.
|
||||
func (e *Engine) Store() *store.Store { return e.store }
|
||||
|
||||
// State returns a copy of the current configuration.
|
||||
func (e *Engine) State() *model.State {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return e.state.Clone()
|
||||
}
|
||||
|
||||
func (e *Engine) env() *validate.Env {
|
||||
if e.Env == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Env()
|
||||
}
|
||||
|
||||
// Problems validates the current configuration (for the status page).
|
||||
func (e *Engine) Problems() []validate.Problem {
|
||||
return validate.Check(e.State(), e.env())
|
||||
}
|
||||
|
||||
// Edit changes a configuration in place. NewID gives IDs for new objects.
|
||||
type Edit func(st *model.State, newID func() int64) error
|
||||
|
||||
// Options say how to apply a change.
|
||||
type Options struct {
|
||||
DryRun bool
|
||||
Via string // web, cli, api, import
|
||||
}
|
||||
|
||||
// Apply makes a change: edit runs on a copy of the configuration, the
|
||||
// result is validated, and the changed objects are saved with a history
|
||||
// entry. A change that touches nothing is not recorded.
|
||||
func (e *Engine) Apply(ctx context.Context, p *auth.Principal, description string, opts Options, edit Edit) (*Result, error) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
next := e.state.Clone()
|
||||
var idErr error
|
||||
newID := func() int64 {
|
||||
ids, err := e.store.AllocateIDs(ctx, 1)
|
||||
if err != nil {
|
||||
idErr = err
|
||||
return 0
|
||||
}
|
||||
return ids[0]
|
||||
}
|
||||
if err := edit(next, newID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if idErr != nil {
|
||||
return nil, idErr
|
||||
}
|
||||
return e.commit(ctx, p, description, opts, next, 0)
|
||||
}
|
||||
|
||||
// commit validates next and saves it as the new state.
|
||||
func (e *Engine) commit(ctx context.Context, p *auth.Principal, description string, opts Options, next *model.State, revertOf int64) (*Result, error) {
|
||||
Normalize(next)
|
||||
next.Sort()
|
||||
changes := Diff(e.state, next)
|
||||
res := &Result{DryRun: opts.DryRun, Changes: changes}
|
||||
if len(changes) == 0 {
|
||||
return res, nil
|
||||
}
|
||||
problems := validate.Check(next, e.env())
|
||||
if validate.HasErrors(problems) {
|
||||
errs := validate.Errors(problems)
|
||||
msg := errs[0].Message
|
||||
if errs[0].Object != "" {
|
||||
msg = errs[0].Object + ": " + msg
|
||||
}
|
||||
if len(errs) > 1 {
|
||||
msg += fmt.Sprintf(" (and %d more problems)", len(errs)-1)
|
||||
}
|
||||
return res, &Error{Kind: KindValidation, Message: msg, Problems: errs}
|
||||
}
|
||||
// Only report warnings the change brings; old ones are on the status
|
||||
// page.
|
||||
res.Warnings = newProblems(validate.Check(e.state, e.env()), problems)
|
||||
if opts.DryRun {
|
||||
return res, nil
|
||||
}
|
||||
entry := &model.HistoryEntry{Time: time.Now(), Username: p.Name(), Via: opts.Via, Description: description, Changes: changes, RevertOf: revertOf}
|
||||
if p != nil && p.User != nil {
|
||||
entry.UserID = p.User.ID
|
||||
}
|
||||
if entry.Description == "" {
|
||||
entry.Description = Describe(changes)
|
||||
}
|
||||
if err := e.store.SaveChanges(ctx, changes, entry); err != nil {
|
||||
return res, err
|
||||
}
|
||||
e.state = next
|
||||
res.Applied, res.HistoryID = true, entry.ID
|
||||
e.Audit(ctx, p, "config.change", fmt.Sprintf("history %d", entry.ID), "success", entry.Description)
|
||||
e.log.Info("configuration changed", "history", entry.ID, "user", p.Name(), "via", opts.Via, "description", entry.Description)
|
||||
if e.OnChange != nil {
|
||||
e.OnChange(next.Clone())
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func newProblems(before, after []validate.Problem) []validate.Problem {
|
||||
seen := map[string]bool{}
|
||||
for _, p := range before {
|
||||
seen[p.Code+"\x00"+p.Object+"\x00"+p.Message] = true
|
||||
}
|
||||
var out []validate.Problem
|
||||
for _, p := range after {
|
||||
if !seen[p.Code+"\x00"+p.Object+"\x00"+p.Message] {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Revert undoes a history entry, if the objects it changed are still as
|
||||
// it left them.
|
||||
func (e *Engine) Revert(ctx context.Context, p *auth.Principal, id int64, opts Options) (*Result, error) {
|
||||
h, err := e.store.GetHistory(ctx, id)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return nil, Errorf(KindNotFound, "history entry %d not found", id)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if h.RevertedBy != 0 {
|
||||
return nil, Errorf(KindConflict, "history entry %d was already undone by entry %d", id, h.RevertedBy)
|
||||
}
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
cur := objects(e.state)
|
||||
next := e.state.Clone()
|
||||
for i := len(h.Changes) - 1; i >= 0; i-- {
|
||||
c := h.Changes[i]
|
||||
now := cur[key{c.Kind, c.ID}]
|
||||
if !sameJSON(now, c.After) {
|
||||
what := c.Label
|
||||
if what == "" {
|
||||
what = fmt.Sprintf("%s %d", c.Kind, c.ID)
|
||||
}
|
||||
return nil, Errorf(KindConflict, "%s %s has changed since entry %d; undo the later changes first, or change it by hand", c.Kind, what, id)
|
||||
}
|
||||
if err := put(next, c.Kind, c.ID, c.Before); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return e.commit(ctx, p, fmt.Sprintf("undo %d: %s", h.ID, h.Description), opts, next, h.ID)
|
||||
}
|
||||
|
||||
// Replace makes the configuration next as a whole (imports).
|
||||
func (e *Engine) Replace(ctx context.Context, p *auth.Principal, description string, opts Options, next *model.State) (*Result, error) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return e.commit(ctx, p, description, opts, next.Clone(), 0)
|
||||
}
|
||||
|
||||
// Audit records an action in the audit log.
|
||||
func (e *Engine) Audit(ctx context.Context, p *auth.Principal, action, object, result, detail string) {
|
||||
ev := &model.AuditEvent{Action: action, Object: object, Result: result, Detail: detail}
|
||||
if p != nil {
|
||||
ev.Username, ev.Auth, ev.IP = p.Name(), p.Method, p.IP
|
||||
if p.User != nil {
|
||||
ev.UserID = p.User.ID
|
||||
}
|
||||
if p.Token != nil {
|
||||
ev.TokenID = p.Token.ID
|
||||
}
|
||||
}
|
||||
if err := e.store.AddAudit(ctx, ev); err != nil {
|
||||
e.log.Warn("writing the audit log failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- diffs
|
||||
|
||||
type key struct {
|
||||
kind string
|
||||
id int64
|
||||
}
|
||||
|
||||
func objects(st *model.State) map[key]json.RawMessage {
|
||||
m := map[key]json.RawMessage{{model.KindSettings, 0}: mustJSON(st.Settings)}
|
||||
for _, s := range st.Subnets {
|
||||
m[key{model.KindSubnet, s.ID}] = mustJSON(s)
|
||||
}
|
||||
for _, r := range st.Reservations {
|
||||
m[key{model.KindReservation, r.ID}] = mustJSON(r)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func labels(st *model.State) map[key]string {
|
||||
m := map[key]string{{model.KindSettings, 0}: "settings"}
|
||||
for _, s := range st.Subnets {
|
||||
m[key{model.KindSubnet, s.ID}] = s.Label()
|
||||
}
|
||||
for _, r := range st.Reservations {
|
||||
m[key{model.KindReservation, r.ID}] = r.Label()
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Diff lists what changes from a to b: settings, then subnets, then
|
||||
// reservations, each by ID.
|
||||
func Diff(a, b *model.State) []model.Change {
|
||||
oa, ob := objects(a), objects(b)
|
||||
la, lb := labels(a), labels(b)
|
||||
var keys []key
|
||||
for k := range oa {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
for k := range ob {
|
||||
if _, ok := oa[k]; !ok {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
order := map[string]int{model.KindSettings: 0, model.KindSubnet: 1, model.KindReservation: 2}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
if keys[i].kind != keys[j].kind {
|
||||
return order[keys[i].kind] < order[keys[j].kind]
|
||||
}
|
||||
return keys[i].id < keys[j].id
|
||||
})
|
||||
var out []model.Change
|
||||
for _, k := range keys {
|
||||
before, after := oa[k], ob[k]
|
||||
if sameJSON(before, after) {
|
||||
continue
|
||||
}
|
||||
label := lb[k]
|
||||
if label == "" {
|
||||
label = la[k]
|
||||
}
|
||||
out = append(out, model.Change{Kind: k.kind, ID: k.id, Label: label, Before: before, After: after})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Describe sums up changes: "create subnet office (10.0.10.0/24)", or
|
||||
// "3 changes" for many.
|
||||
func Describe(cs []model.Change) string {
|
||||
if len(cs) == 0 {
|
||||
return "no changes"
|
||||
}
|
||||
parts := []string{}
|
||||
for _, c := range cs {
|
||||
if c.Kind == model.KindSettings {
|
||||
parts = append(parts, "update settings")
|
||||
continue
|
||||
}
|
||||
parts = append(parts, c.Action()+" "+c.Kind+" "+c.Label)
|
||||
}
|
||||
if len(parts) > 3 {
|
||||
return fmt.Sprintf("%s and %d more changes", strings.Join(parts[:2], ", "), len(parts)-2)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func mustJSON(v any) json.RawMessage {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func sameJSON(a, b json.RawMessage) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == nil && b == nil
|
||||
}
|
||||
return bytes.Equal(a, b)
|
||||
}
|
||||
|
||||
// put sets (or with nil data deletes) one object in st.
|
||||
func put(st *model.State, kind string, id int64, data json.RawMessage) error {
|
||||
switch kind {
|
||||
case model.KindSettings:
|
||||
if data == nil {
|
||||
return fmt.Errorf("settings can't be deleted")
|
||||
}
|
||||
var s model.Settings
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
st.Settings = s
|
||||
case model.KindSubnet:
|
||||
var keep []*model.Subnet
|
||||
for _, s := range st.Subnets {
|
||||
if s.ID != id {
|
||||
keep = append(keep, s)
|
||||
}
|
||||
}
|
||||
if data != nil {
|
||||
var s model.Subnet
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
keep = append(keep, &s)
|
||||
}
|
||||
st.Subnets = keep
|
||||
case model.KindReservation:
|
||||
var keep []*model.Reservation
|
||||
for _, r := range st.Reservations {
|
||||
if r.ID != id {
|
||||
keep = append(keep, r)
|
||||
}
|
||||
}
|
||||
if data != nil {
|
||||
var r model.Reservation
|
||||
if err := json.Unmarshal(data, &r); err != nil {
|
||||
return err
|
||||
}
|
||||
keep = append(keep, &r)
|
||||
}
|
||||
st.Reservations = keep
|
||||
default:
|
||||
return fmt.Errorf("unknown kind %q", kind)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Normalize fills in option names and writes option values, pools and
|
||||
// lists in their canonical form, so equal configurations compare equal.
|
||||
func Normalize(st *model.State) {
|
||||
st.Settings.Options = normOptions(st.Settings.Options)
|
||||
if st.Subnets == nil {
|
||||
st.Subnets = []*model.Subnet{}
|
||||
}
|
||||
if st.Reservations == nil {
|
||||
st.Reservations = []*model.Reservation{}
|
||||
}
|
||||
for _, s := range st.Subnets {
|
||||
s.Options = normOptions(s.Options)
|
||||
if s.Pools == nil {
|
||||
s.Pools = []model.Pool{}
|
||||
}
|
||||
if s.Prefix.IsValid() && s.Prefix.Addr().Is4() {
|
||||
s.Prefix = s.Prefix.Masked()
|
||||
}
|
||||
sort.SliceStable(s.Pools, func(i, j int) bool { return s.Pools[i].Start.Less(s.Pools[j].Start) })
|
||||
}
|
||||
for _, r := range st.Reservations {
|
||||
r.Options = normOptions(r.Options)
|
||||
}
|
||||
}
|
||||
|
||||
func normOptions(opts []model.OptionValue) []model.OptionValue {
|
||||
out := []model.OptionValue{}
|
||||
for _, o := range opts {
|
||||
if o.Code > 0 && o.Code < 255 {
|
||||
def := dhcp4.ByCode(byte(o.Code))
|
||||
o.Name = def.Name
|
||||
typ := def.Type
|
||||
if dhcp4.Known(byte(o.Code)) {
|
||||
o.Type = ""
|
||||
} else if o.Type != "" {
|
||||
typ = o.Type
|
||||
}
|
||||
if v, err := dhcp4.Normalize(typ, o.Value); err == nil {
|
||||
o.Value = v
|
||||
}
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].Code < out[j].Code })
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"steward/internal/auth"
|
||||
"steward/internal/model"
|
||||
"steward/internal/store"
|
||||
)
|
||||
|
||||
func newEngine(t *testing.T) *Engine {
|
||||
t.Helper()
|
||||
st, err := store.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e, err := New(context.Background(), st, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
var admin = &auth.Principal{User: &model.User{ID: 1, Username: "admin", Admin: true}, Method: "session"}
|
||||
|
||||
func addOffice(t *testing.T, e *Engine) int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
res, err := e.Apply(context.Background(), admin, "", Options{Via: "cli"}, func(st *model.State, newID func() int64) error {
|
||||
p, _ := model.ParsePool("10.0.10.100-10.0.10.199")
|
||||
id = newID()
|
||||
st.Subnets = append(st.Subnets, &model.Subnet{ID: id, Name: "office", Interface: "eth0", Prefix: netip.MustParsePrefix("10.0.10.0/24"),
|
||||
Pools: []model.Pool{p}, Options: []model.OptionValue{{Code: 6, Value: "10.0.10.2,10.0.10.3"}}})
|
||||
return nil
|
||||
})
|
||||
if err != nil || !res.Applied || res.HistoryID == 0 {
|
||||
t.Fatalf("apply: %+v %v", res, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestApplyNormalizesAndRecords(t *testing.T) {
|
||||
e := newEngine(t)
|
||||
id := addOffice(t, e)
|
||||
s := e.State().SubnetByID(id)
|
||||
if s.Options[0].Value != "10.0.10.2, 10.0.10.3" || s.Options[0].Name != "domain-name-servers" {
|
||||
t.Errorf("options not normalized: %+v", s.Options)
|
||||
}
|
||||
h, _ := e.Store().ListHistory(context.Background(), 10, 0)
|
||||
if len(h) != 1 || h[0].Description != "create subnet office (10.0.10.0/24)" || h[0].Via != "cli" || h[0].Username != "admin" {
|
||||
t.Errorf("history %+v", h[0])
|
||||
}
|
||||
// Nothing changed: nothing recorded.
|
||||
res, err := e.Apply(context.Background(), admin, "", Options{}, func(st *model.State, _ func() int64) error { return nil })
|
||||
if err != nil || res.Applied || len(res.Changes) != 0 {
|
||||
t.Errorf("empty change: %+v %v", res, err)
|
||||
}
|
||||
// Reloading from the database gives the same state.
|
||||
e2, err := New(context.Background(), e.Store(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d := Diff(e.State(), e2.State()); len(d) != 0 {
|
||||
t.Errorf("reloaded state differs: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationRefuses(t *testing.T) {
|
||||
e := newEngine(t)
|
||||
addOffice(t, e)
|
||||
_, err := e.Apply(context.Background(), admin, "", Options{}, func(st *model.State, newID func() int64) error {
|
||||
st.Subnets = append(st.Subnets, &model.Subnet{ID: newID(), Interface: "eth1", Prefix: netip.MustParsePrefix("10.0.0.0/16")})
|
||||
return nil
|
||||
})
|
||||
ee, ok := AsError(err)
|
||||
if !ok || ee.Kind != KindValidation || !strings.Contains(ee.Message, "overlaps subnet office") {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
if len(e.State().Subnets) != 1 {
|
||||
t.Error("refused change was kept")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRun(t *testing.T) {
|
||||
e := newEngine(t)
|
||||
res, err := e.Apply(context.Background(), admin, "", Options{DryRun: true}, func(st *model.State, newID func() int64) error {
|
||||
st.Subnets = append(st.Subnets, &model.Subnet{ID: newID(), Interface: "eth0", Prefix: netip.MustParsePrefix("10.0.10.0/24")})
|
||||
return nil
|
||||
})
|
||||
if err != nil || res.Applied || len(res.Changes) != 1 || len(res.Warnings) != 1 || res.Warnings[0].Code != "subnet_no_pools" {
|
||||
t.Fatalf("dry run: %+v %v", res, err)
|
||||
}
|
||||
if len(e.State().Subnets) != 0 {
|
||||
t.Error("dry run changed the state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevert(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
e := newEngine(t)
|
||||
var got *model.State
|
||||
e.OnChange = func(st *model.State) { got = st }
|
||||
id := addOffice(t, e)
|
||||
res, err := e.Apply(ctx, admin, "longer leases", Options{}, func(st *model.State, _ func() int64) error {
|
||||
st.SubnetByID(id).LeaseTime = 86400
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == nil || got.SubnetByID(id).LeaseTime != 86400 {
|
||||
t.Error("OnChange didn't get the new state")
|
||||
}
|
||||
// Undoing the creation conflicts: the subnet changed since.
|
||||
if _, err := e.Revert(ctx, admin, 1, Options{}); err == nil || !strings.Contains(err.Error(), "has changed since entry 1") {
|
||||
t.Fatalf("revert of an older entry: %v", err)
|
||||
}
|
||||
if _, err := e.Revert(ctx, admin, res.HistoryID, Options{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.State().SubnetByID(id).LeaseTime != 0 {
|
||||
t.Error("revert didn't restore the lease time")
|
||||
}
|
||||
if _, err := e.Revert(ctx, admin, res.HistoryID, Options{}); err == nil || !strings.Contains(err.Error(), "already undone") {
|
||||
t.Errorf("second revert: %v", err)
|
||||
}
|
||||
if _, err := e.Revert(ctx, admin, 1, Options{}); err != nil {
|
||||
t.Fatalf("revert of the creation: %v", err)
|
||||
}
|
||||
if len(e.State().Subnets) != 0 {
|
||||
t.Error("subnet still there")
|
||||
}
|
||||
h, _ := e.Store().GetHistory(ctx, 1)
|
||||
if h.RevertedBy == 0 {
|
||||
t.Error("entry 1 not marked undone")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user