registry: fleet_boxes.tags — additive column, grammar, rev-bumped writer
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+193
−5
internal/registry/fleets.go+66 −5internal/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) |
| + } |
| +} |