a73x

4c8de033

server: a --cidr-pool that lost says so

a73x   2026-08-23 10:36

Commit message
server: a --cidr-pool that lost says so

The first Open seeds the guest-subnet pool and every later --cidr-pool is
discarded. That is right -- a host holds the subnet it was allocated for
life, and honoring a changed flag would re-plan subnets under a fleet
already routing on the old ones -- but the operator was told nothing. They
saw a clean startup and a plane that had quietly kept the old value, and
learned the flag was inert only when a newly-enrolled host landed on a
subnet nobody routes to.

Open now warns when the passed pool differs from the stored one, naming
both. A warning rather than a refusal: a unit file whose pool has already
drifted must still be able to start the plane.

internal/server/store/store.go
Old New
@@ -12,6 +12,7 @@ import (
12 "encoding/json" 12 "encoding/json"
13 "errors" 13 "errors"
14 "fmt" 14 "fmt"
15 "log/slog"
15 "net/netip" 16 "net/netip"
16 "os" 17 "os"
17 "path/filepath" 18 "path/filepath"
@@ -444,6 +445,25 @@ func Open(path, cidrPool string) (*Store, error) {
444 db.Close() 445 db.Close()
445 return nil, fmt.Errorf("seed cidr_pool: %w", err) 446 return nil, fmt.Errorf("seed cidr_pool: %w", err)
446 } 447 }
448 // The flag lost, so say so. A host holds the subnet it was allocated for
449 // life, which is why the stored pool wins and must — re-planning subnets
450 // under a fleet already routing on the old ones is not something a restart
451 // gets to do. But an operator who edited the flag deliberately otherwise
452 // sees a clean startup and a control plane that quietly kept the old value,
453 // and only learns the flag was inert when a newly-enrolled host lands on a
454 // subnet nobody expected. A warning rather than a refusal because a unit
455 // file that has already drifted must still be able to start the plane.
456 var stored string
457 if err := db.QueryRow(`SELECT value FROM meta WHERE key='cidr_pool'`).Scan(&stored); err != nil {
458 db.Close()
459 return nil, fmt.Errorf("read cidr_pool: %w", err)
460 }
461 if stored != cidrPool {
462 slog.Warn("--cidr-pool was ignored: this database was created with a different guest-subnet pool, "+
463 "and hosts keep the subnet they were allocated, so the stored pool stands and every host "+
464 "enrolled from now on is still allocated out of it",
465 "stored", stored, "passed", cidrPool)
466 }
447 467
448 s := &Store{db: db, dbDir: filepath.Dir(path)} 468 s := &Store{db: db, dbDir: filepath.Dir(path)}
449 469
internal/server/store/store_test.go
Old New
@@ -1,8 +1,10 @@
1 package store 1 package store
2 2
3 import ( 3 import (
4 "bytes"
4 "context" 5 "context"
5 "database/sql" 6 "database/sql"
7 "log/slog"
6 "path/filepath" 8 "path/filepath"
7 "strings" 9 "strings"
8 "testing" 10 "testing"
@@ -1356,10 +1358,9 @@ func TestCidrPoolFirstOpenWins(t *testing.T) {
1356 require.Equal(t, testTenant, tn.ID) 1358 require.Equal(t, testTenant, tn.ID)
1357 require.NoError(t, first.Close()) 1359 require.NoError(t, first.Close())
1358 1360
1359 // The operator restarts the server with a different pool. Note this is 1361 // The operator restarts the server with a different pool. It is ignored —
1360 // silently ignored, with no signal either way — a footgun in its own right, 1362 // but no longer in silence; TestChangedCidrPoolWarns owns the signal. What
1361 // tracked separately; what the test pins is that the fleet's subnets do not 1363 // this test pins is that the fleet's subnets do not move underneath it.
1362 // move underneath it.
1363 second, err := Open(path, "10.99.0.0/16") 1364 second, err := Open(path, "10.99.0.0/16")
1364 require.NoError(t, err) 1365 require.NoError(t, err)
1365 t.Cleanup(func() { second.Close() }) 1366 t.Cleanup(func() { second.Close() })
@@ -1368,3 +1369,34 @@ func TestCidrPoolFirstOpenWins(t *testing.T) {
1368 assert.True(t, strings.HasPrefix(h.BridgeCIDR, "10.77."), 1369 assert.True(t, strings.HasPrefix(h.BridgeCIDR, "10.77."),
1369 "the cidr_pool is fixed at first Open: this host got %s, allocated from the pool passed at restart, so a fleet already routing 10.77.x has a peer on a subnet nothing routes to", h.BridgeCIDR) 1370 "the cidr_pool is fixed at first Open: this host got %s, allocated from the pool passed at restart, so a fleet already routing 10.77.x has a peer on a subnet nothing routes to", h.BridgeCIDR)
1370 } 1371 }
1372
1373 // TestChangedCidrPoolWarns pins the signal, not the behaviour: the stored pool
1374 // still wins (TestCidrPoolFirstOpenWins says why). What this asserts is that
1375 // the operator is told. Without the line, editing --cidr-pool produces a clean
1376 // startup, no complaint, and a plane that quietly kept the old value — and the
1377 // flag is discovered to have been inert only when a newly-enrolled host lands
1378 // on a subnet nobody routes to, or never.
1379 func TestChangedCidrPoolWarns(t *testing.T) {
1380 path := t.TempDir() + "/eitri.db"
1381 openLogging := func(t *testing.T, pool string) string {
1382 t.Helper()
1383 var buf bytes.Buffer
1384 prev := slog.Default()
1385 t.Cleanup(func() { slog.SetDefault(prev) })
1386 slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})))
1387 s, err := Open(path, pool)
1388 require.NoError(t, err, "a drifted pool must never stop the plane booting: refusing would brick a "+
1389 "restart for anyone whose unit file already carries one, and their fleet is routing fine")
1390 t.Cleanup(func() { s.Close() })
1391 return buf.String()
1392 }
1393
1394 assert.Empty(t, openLogging(t, "10.77.0.0/16"), "the first Open is what seeded the pool and has nothing to report")
1395 assert.Empty(t, openLogging(t, "10.77.0.0/16"), "a restart passing the pool that is already stored is not a misconfiguration")
1396
1397 out := openLogging(t, "10.99.0.0/16")
1398 assert.Contains(t, out, "--cidr-pool was ignored",
1399 "a restart with a changed pool must say the flag did nothing, or the operator goes on believing it took")
1400 assert.Contains(t, out, "10.77.0.0/16", "the warning must name the pool that actually governs")
1401 assert.Contains(t, out, "10.99.0.0/16", "and the one that was discarded")
1402 }