carlos / platform Public

registry: fleet_boxes.tags — additive column, grammar, rev-bumped writer

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 3117197d11c258e4218d0fa9cf2eb0f5f2394aa0 parent b31d938
2 files changed, +193 −5
  • internal/registry/fleets.go +66 −5
  • internal/registry/fleets_test.go +127 −0
diff --git a/internal/registry/fleets.go b/internal/registry/fleets.go
index ce06a2b8f..3593ddfef 100644
--- a/internal/registry/fleets.go
+++ b/internal/registry/fleets.go
@@ -43,6 +43,7 @@ type FleetBox struct {
TokenHash string // sha256 hex of the bearer token; never plaintext
Status string // "active" | "detached"
AddedAt int64
+ Tags string // comma-separated, ValidBoxTags grammar; "" = untagged
}
// tokenHashRe: exactly 64 lowercase hex characters — a sha256 digest,
@@ -55,6 +56,33 @@ func validTokenHash(hash string) bool {
return tokenHashRe.MatchString(hash)
}
+// boxTagRe: a single tag segment — lowercase alphanumerics and interior
+// hyphens, 1-24 characters, never leading or trailing with a hyphen (a
+// leading '-' is how a value gets mistaken for a flag by anything that ever
+// builds an argv from a tag, the same reasoning unitNameRe's first-character
+// carve-out documents). Same shape family as ValidTunnelName, but its own
+// regexp since tags are a comma-separated LIST rather than a single name.
+var boxTagRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,22}[a-z0-9])?$`)
+
+// ValidBoxTags reports whether tags is a well-formed comma-separated tag
+// list: each tag matches boxTagRe, at most 8 tags, no empty segments. ""
+// is valid and means untagged.
+func ValidBoxTags(tags string) bool {
+ if tags == "" {
+ return true
+ }
+ parts := strings.Split(tags, ",")
+ if len(parts) > 8 {
+ return false
+ }
+ for _, p := range parts {
+ if !boxTagRe.MatchString(p) {
+ return false
+ }
+ }
+ return true
+}
+
// validFleetBoxStatus reports whether status is one of the two values the
// FleetBox.Status doc comment promises. Rejecting anything else at the
// writer matters because AddFleetBox's uniqueness guard keys off status =
@@ -82,7 +110,7 @@ CREATE TABLE IF NOT EXISTS fleets (
);`); err != nil {
return err
}
- _, err := r.sql.Exec(`
+ if _, err := r.sql.Exec(`
CREATE TABLE IF NOT EXISTS fleet_boxes (
fleet TEXT NOT NULL,
label TEXT NOT NULL,
@@ -91,8 +119,15 @@ CREATE TABLE IF NOT EXISTS fleet_boxes (
added_at INTEGER NOT NULL,
rev INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (fleet, label)
-);`)
- return err
+);`); err != nil {
+ return err
+ }
+ // tags: additive (2026-08-10-fleets-console-design.md D3). Pre-change rows
+ // read as '' — untagged.
+ if _, err := r.sql.Exec(`ALTER TABLE fleet_boxes ADD COLUMN tags TEXT NOT NULL DEFAULT ''`); err != nil && !strings.Contains(err.Error(), "duplicate column") {
+ return err
+ }
+ return nil
}
// freshFleetsConn opens a NEW connection over the same file — the List /
@@ -310,6 +345,32 @@ func (r *Registry) SetFleetBoxToken(fleet, label, tokenHash string) error {
return nil
}
+// SetFleetBoxTags mirrors SetFleetBoxToken: same rev bump, same
+// RowsAffected guard for an unknown (fleet, label).
+func (r *Registry) SetFleetBoxTags(fleet, label, tags string) error {
+ if !ValidTunnelName(fleet) {
+ return fmt.Errorf("invalid fleet name %q", fleet)
+ }
+ if !ValidTunnelName(label) {
+ return fmt.Errorf("invalid box label %q", label)
+ }
+ if !ValidBoxTags(tags) {
+ return fmt.Errorf("invalid box tags %q", tags)
+ }
+ res, err := r.sql.Exec(`UPDATE fleet_boxes SET tags = ?, rev = rev + 1 WHERE fleet = ? AND label = ?`, tags, fleet, label)
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return fmt.Errorf("no box %q/%q", fleet, label)
+ }
+ return nil
+}
+
// SetFleetBoxStatus flips a box between "active" and "detached". Errors when
// no (fleet, label) row exists, same contract as SetFleetBoxToken. Rejects
// any status outside those two values — AddFleetBox's uniqueness guard keys
@@ -347,7 +408,7 @@ func (r *Registry) ListFleetBoxes(fleet string) ([]FleetBox, error) {
return nil, err
}
defer db.Close()
- rows, err := db.Query(`SELECT fleet, label, token_hash, status, added_at FROM fleet_boxes WHERE fleet = ? ORDER BY label`, fleet)
+ rows, err := db.Query(`SELECT fleet, label, token_hash, status, added_at, tags FROM fleet_boxes WHERE fleet = ? ORDER BY label`, fleet)
if err != nil {
return nil, err
}
@@ -355,7 +416,7 @@ func (r *Registry) ListFleetBoxes(fleet string) ([]FleetBox, error) {
var out []FleetBox
for rows.Next() {
var b FleetBox
- if err := rows.Scan(&b.Fleet, &b.Label, &b.TokenHash, &b.Status, &b.AddedAt); err != nil {
+ if err := rows.Scan(&b.Fleet, &b.Label, &b.TokenHash, &b.Status, &b.AddedAt, &b.Tags); err != nil {
return nil, err
}
out = append(out, b)
diff --git a/internal/registry/fleets_test.go b/internal/registry/fleets_test.go
index b5f4d3e98..ca052b334 100644
--- a/internal/registry/fleets_test.go
+++ b/internal/registry/fleets_test.go
@@ -1,7 +1,9 @@
package registry
import (
+ "database/sql"
"errors"
+ "path/filepath"
"regexp"
"strings"
"sync"
@@ -571,3 +573,128 @@ func TestPlacementTarget(t *testing.T) {
t.Fatalf("FleetPlacement(PlacementTarget(%q)) = (%q, %v), want (%q, true)", "pi-box", fleet, ok, "pi-box")
}
}
+
+// mustCreateFleet is a tiny helper for tests that don't care about anything
+// beyond fixed test values for account/bucket.
+func mustCreateFleet(t *testing.T, r *Registry, fleet, account, bucket string) {
+ t.Helper()
+ if _, err := r.CreateFleet(Fleet{Name: fleet, Account: account, Bucket: bucket}); err != nil {
+ t.Fatalf("CreateFleet(%q): %v", fleet, err)
+ }
+}
+
+// mustAddBox is a tiny helper for tests that don't care about anything
+// beyond a fixed test token hash and an "active" status.
+func mustAddBox(t *testing.T, r *Registry, fleet, label string) {
+ t.Helper()
+ if err := r.AddFleetBox(FleetBox{Fleet: fleet, Label: label, TokenHash: tokenHashA, Status: "active"}); err != nil {
+ t.Fatalf("AddFleetBox(%q, %q): %v", fleet, label, err)
+ }
+}
+
+// TestFleetBoxTags pins ValidBoxTags's grammar and SetFleetBoxTags's
+// round-trip through ListFleetBoxes, including the invalid-grammar and
+// unknown-box refusals and clearing back to "".
+func TestFleetBoxTags(t *testing.T) {
+ r := openTestRegistry(t)
+ mustCreateFleet(t, r, "pi", "acct1", "bkt")
+ mustAddBox(t, r, "pi", "box-a")
+ // grammar
+ for _, bad := range []string{"Office", "a b", "-lead", "toolongtoolongtoolongtoolo", "a,,b",
+ "a,b,c,d,e,f,g,h,i"} {
+ if ValidBoxTags(bad) {
+ t.Errorf("ValidBoxTags(%q) = true, want false", bad)
+ }
+ }
+ for _, good := range []string{"", "office", "office,free", "a-1"} {
+ if !ValidBoxTags(good) {
+ t.Errorf("ValidBoxTags(%q) = false, want true", good)
+ }
+ }
+ if err := r.SetFleetBoxTags("pi", "box-a", "Office"); err == nil {
+ t.Fatal("invalid grammar accepted")
+ }
+ if err := r.SetFleetBoxTags("pi", "nope", "office"); err == nil {
+ t.Fatal("unknown box accepted")
+ }
+ if err := r.SetFleetBoxTags("pi", "box-a", "office,free"); err != nil {
+ t.Fatal(err)
+ }
+ boxes, err := r.ListFleetBoxes("pi")
+ if err != nil || len(boxes) != 1 || boxes[0].Tags != "office,free" {
+ t.Fatalf("tags round-trip: %+v err=%v", boxes, err)
+ }
+ // clearing
+ if err := r.SetFleetBoxTags("pi", "box-a", ""); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// TestFleetBoxTagsFingerprintMoves mirrors
+// TestFingerprintChangesOnTokenRotation for tags: a tags write must move
+// Fingerprint the same way a token rotation does, or the edge would never
+// notice a tag change land.
+func TestFleetBoxTagsFingerprintMoves(t *testing.T) {
+ r := openTestRegistry(t)
+ mustCreateFleet(t, r, "pi", "acct1", "bkt")
+ mustAddBox(t, r, "pi", "box-a")
+ before, err := r.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := r.SetFleetBoxTags("pi", "box-a", "office"); err != nil {
+ t.Fatal(err)
+ }
+ after, err := r.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if before == after {
+ t.Fatalf("fingerprint unchanged across a fleet_boxes tags write: %q", after)
+ }
+}
+
+// TestFleetBoxesPreTagsRowsParse mirrors TestFleetBoxMigratesFromPreChangeDB
+// (registry_test.go): a fleet_boxes row written before the tags column
+// existed is simulated by creating the table without it and inserting a raw
+// row, then Open() (which runs migrateFleets) must still open the file and
+// ListFleetBoxes must read the row back with Tags == "".
+func TestFleetBoxesPreTagsRowsParse(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "registry.db")
+ db, err := sql.Open("sqlite", path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.Exec(`
+CREATE TABLE fleet_boxes (
+ fleet TEXT NOT NULL,
+ label TEXT NOT NULL,
+ token_hash TEXT NOT NULL,
+ status TEXT NOT NULL,
+ added_at INTEGER NOT NULL,
+ rev INTEGER NOT NULL DEFAULT 0,
+ PRIMARY KEY (fleet, label)
+);`); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.Exec(`INSERT INTO fleet_boxes (fleet, label, token_hash, status, added_at) VALUES (?, ?, ?, ?, ?)`,
+ "pi", "box-a", tokenHashA, "active", 1700000000); err != nil {
+ t.Fatal(err)
+ }
+ if err := db.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ r, err := Open(path)
+ if err != nil {
+ t.Fatalf("Open over a pre-tags DB: %v", err)
+ }
+ defer r.Close()
+ boxes, err := r.ListFleetBoxes("pi")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(boxes) != 1 || boxes[0].Tags != "" {
+ t.Fatalf("pre-tags row should default tags=\"\": %+v", boxes)
+ }
+}