registry: fleet placement helpers + fleet_box route binding
4 files changed,
+345
−17
internal/registry/fleets.go+28 −0internal/registry/fleets_test.go+37 −0internal/registry/registry.go+85 −17internal/registry/registry_test.go+195 −0
diff --git a/internal/registry/fleets.go b/internal/registry/fleets.go| index 56d201f08..9d2de77b6 100644 |
| --- a/internal/registry/fleets.go |
| +++ b/internal/registry/fleets.go |
| @@ -11,6 +11,7 @@ import ( |
| "database/sql" |
| "fmt" |
| "regexp" |
| + "strings" |
| "time" |
| ) |
| @@ -319,6 +320,33 @@ func (r *Registry) ListFleetBoxes(fleet string) ([]FleetBox, error) { |
| return out, rows.Err() |
| } |
| +// fleetPlacementPrefix is the Region-column prefix that marks a route as |
| +// bound to a customer fleet rather than a residency code — see |
| +// FleetPlacement/PlacementTarget below. |
| +const fleetPlacementPrefix = "fleet/" |
| + |
| +// FleetPlacement parses a Route.Region value as a fleet placement target: |
| +// "fleet/pi" -> ("pi", true). Anything else answers ("", false) — "" (no |
| +// placement), a bare region code like "eu" (a FUTURE placement form this v1 |
| +// does not parse — SetPlacement refuses it too), and "fleet/<name>" where |
| +// name fails ValidTunnelName (a syntactically-right prefix is not enough; |
| +// the name half must be one Registry would itself accept as a fleet name). |
| +func FleetPlacement(region string) (fleet string, ok bool) { |
| + name, hasPrefix := strings.CutPrefix(region, fleetPlacementPrefix) |
| + if !hasPrefix || !ValidTunnelName(name) { |
| + return "", false |
| + } |
| + return name, true |
| +} |
| + |
| +// PlacementTarget renders a fleet name as the Region-column value that binds |
| +// a route to it — the inverse of FleetPlacement. It does not validate name; |
| +// callers that need "is this fleet name legal" should ask ValidTunnelName |
| +// (or round-trip through FleetPlacement) themselves. |
| +func PlacementTarget(fleet string) string { |
| + return fleetPlacementPrefix + fleet |
| +} |
| + |
| // FleetChannelAuth returns every ACTIVE box's token hash, keyed by its |
| // channel name "<fleet>/<label>" — the edge's auth table for an incoming |
| // remote-host-agent dial-in. Fresh connection: this is exactly the |
diff --git a/internal/registry/fleets_test.go b/internal/registry/fleets_test.go| index 9009b2cab..3946abb9a 100644 |
| --- a/internal/registry/fleets_test.go |
| +++ b/internal/registry/fleets_test.go |
| @@ -310,3 +310,40 @@ func TestFingerprintChangesOnTokenRotation(t *testing.T) { |
| t.Fatalf("fingerprint unchanged across a same-length token rotation: %q", after) |
| } |
| } |
| + |
| +// TestFleetPlacement pins the "fleet/<name>" grammar Route.Region carries |
| +// when a route is bound to a customer fleet: the prefix alone is not enough, |
| +// the name half must also pass ValidTunnelName, and a bare region code (a |
| +// FUTURE placement form this v1 does not parse) must answer false rather |
| +// than partially matching. |
| +func TestFleetPlacement(t *testing.T) { |
| + cases := []struct { |
| + region string |
| + wantFleet string |
| + wantOK bool |
| + }{ |
| + {"fleet/pi", "pi", true}, |
| + {"", "", false}, |
| + {"eu", "", false}, |
| + {"fleet/UPPER", "", false}, |
| + {"fleet/", "", false}, |
| + {"fleet", "", false}, |
| + } |
| + for _, c := range cases { |
| + fleet, ok := FleetPlacement(c.region) |
| + if fleet != c.wantFleet || ok != c.wantOK { |
| + t.Errorf("FleetPlacement(%q) = (%q, %v), want (%q, %v)", c.region, fleet, ok, c.wantFleet, c.wantOK) |
| + } |
| + } |
| +} |
| + |
| +// TestPlacementTarget pins the inverse rendering FleetPlacement parses back. |
| +func TestPlacementTarget(t *testing.T) { |
| + if got := PlacementTarget("pi"); got != "fleet/pi" { |
| + t.Errorf("PlacementTarget(%q) = %q, want %q", "pi", got, "fleet/pi") |
| + } |
| + fleet, ok := FleetPlacement(PlacementTarget("pi-box")) |
| + if !ok || fleet != "pi-box" { |
| + t.Fatalf("FleetPlacement(PlacementTarget(%q)) = (%q, %v), want (%q, true)", "pi-box", fleet, ok, "pi-box") |
| + } |
| +} |
diff --git a/internal/registry/registry.go b/internal/registry/registry.go| index 6e8f3a4f0..c7095cc10 100644 |
| --- a/internal/registry/registry.go |
| +++ b/internal/registry/registry.go |
| @@ -42,9 +42,12 @@ type Route struct { |
| Version string |
| PrevVersion string |
| - App string // which app's releases this route runs |
| - Pool string // placement pool ("" = shared) |
| - Region string // residency region code ("" = unconstrained) |
| + App string // which app's releases this route runs |
| + Pool string // placement pool ("" = shared) |
| + Region string // residency region code ("" = unconstrained); ALSO doubles |
| + // as a route's fleet placement target ("fleet/<name>" — see |
| + // FleetPlacement/PlacementTarget, fleets.go) written by SetPlacement, not |
| + // this comment's original meaning alone. |
| Channel string // release channel this route follows ("" = stable) |
| // Environment is the named config bundle this route is wired to ("" = |
| // the app's default bundle, config.json, alone — see |
| @@ -74,6 +77,13 @@ type Route struct { |
| // today). Written only by SetTunnel / carlos route -tunnel; Upsert |
| // preserves non-empty values — see Environment's comment for why. |
| Tunnel string |
| + // FleetBox is the label of the fleet box CURRENTLY bound to serve this |
| + // route ("" = no binding) — distinct from Region, which carries the |
| + // placement TARGET (see FleetPlacement/PlacementTarget, fleets.go); a |
| + // route can want "fleet/pi" and not yet have a box assigned. Written only |
| + // by SetFleetBox; Upsert preserves non-empty values — see Environment's |
| + // comment for why. |
| + FleetBox string |
| // Caps is the OPERATOR-granted capability tokens for this route ON THIS |
| // BOX: comma-separated and canonically sorted, so "port:25,host-role" |
| @@ -256,6 +266,7 @@ CREATE TABLE IF NOT EXISTS routes ( |
| `ALTER TABLE routes ADD COLUMN tunnel TEXT NOT NULL DEFAULT ''`, |
| `ALTER TABLE routes ADD COLUMN provisioned INTEGER NOT NULL DEFAULT 0`, |
| `ALTER TABLE routes ADD COLUMN seed TEXT NOT NULL DEFAULT ''`, |
| + `ALTER TABLE routes ADD COLUMN fleet_box TEXT NOT NULL DEFAULT ''`, |
| } { |
| if _, err := r.sql.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") { |
| return err |
| @@ -422,7 +433,7 @@ func (r *Registry) Accounts() ([]Account, error) { |
| } |
| // routeCols is the column list every route read and the one route write |
| -// share — 29 names, in this exact order. scanRoute's destinations, Upsert's |
| +// share — 30 names, in this exact order. scanRoute's destinations, Upsert's |
| // placeholder row and Upsert's argument list all count from here: adding a |
| // column means adding it in FOUR places, and a miscount is a silent column |
| // shift rather than an error (TestUpsertRoundTripsAllColumns is what catches |
| @@ -431,7 +442,7 @@ const routeCols = `host, slug, kind, socket, addr, status, last_error, db_path, |
| version, prev_version, app, pool, region, channel, hibernate, canary, |
| replication, next_due, backing, status_named, platform, dns_status, |
| dns_checked_at, cert_expires_at, account, environment, caps, tunnel, |
| -provisioned, seed, created_at` |
| +provisioned, seed, fleet_box, created_at` |
| func scanRoute(scan func(...any) error) (Route, error) { |
| var t Route |
| @@ -440,7 +451,7 @@ func scanRoute(scan func(...any) error) (Route, error) { |
| &t.LastError, &t.DBPath, &t.Version, &t.PrevVersion, &t.App, &t.Pool, |
| &t.Region, &t.Channel, &hib, &can, &t.Replication, &t.NextDue, &t.Backing, |
| &sn, &plat, &t.DNSStatus, &t.DNSCheckedAt, &t.CertExpiresAt, &t.Account, |
| - &t.Environment, &t.Caps, &t.Tunnel, &prov, &t.Seed, &t.CreatedAt) |
| + &t.Environment, &t.Caps, &t.Tunnel, &prov, &t.Seed, &t.FleetBox, &t.CreatedAt) |
| t.Hibernate = hib != 0 |
| t.Canary = can != 0 |
| t.StatusNamed = sn != 0 |
| @@ -501,12 +512,12 @@ func (r *Registry) Delete(host string) error { |
| return err |
| } |
| -// Upsert inserts or updates a route by host. Five columns are deliberately |
| -// not plain overwrites — `environment`, `caps`, `tunnel`, `provisioned` and |
| -// `seed` all keep the existing row's value when the incoming Route carries |
| -// the zero one. |
| +// Upsert inserts or updates a route by host. Six columns are deliberately |
| +// not plain overwrites — `environment`, `caps`, `tunnel`, `provisioned`, |
| +// `seed` and `fleet_box` all keep the existing row's value when the incoming |
| +// Route carries the zero one. |
| // |
| -// The reason is the same for all five, and it is structural: a full-row |
| +// The reason is the same for all six, and it is structural: a full-row |
| // Upsert caller (`carlos add`, the domains reconciler's two mint paths, the |
| // provision pass's mint) builds its Route from what IT knows, and none of |
| // them knows about the columns another feature owns. `environment` was the |
| @@ -520,13 +531,16 @@ func (r *Registry) Delete(host string) error { |
| // the stakes again: `carlos add` on a provisioned host would write |
| // provisioned=0, and the reconcile pass would then lose removal authority |
| // over that route FOREVER — deleting the bucket record could no longer |
| -// retire it, and only a human noticing would. |
| +// retire it, and only a human noticing would. `fleet_box` is the same shape |
| +// as `tunnel`: a placement pass stamps which box is CURRENTLY serving a |
| +// route, and a full-row Upsert from provisioning (which does not know about |
| +// fleet placement at all) must not un-stamp it mid-lifecycle. |
| // |
| // An explicit non-zero value still wins in every case; clearing keeps |
| // exactly one door per column, none of which goes through Upsert: |
| // SetEnvironment (`carlos route -environment -`), SetCaps (`carlos route |
| -// -revoke`), SetTunnel (`carlos route -tunnel -`), SetSeed, and for |
| -// `provisioned` the row's own deletion. |
| +// -revoke`), SetTunnel (`carlos route -tunnel -`), SetSeed, SetFleetBox, and |
| +// for `provisioned` the row's own deletion. |
| func (r *Registry) Upsert(t Route) error { |
| if t.CreatedAt == 0 { |
| t.CreatedAt = time.Now().Unix() |
| @@ -545,7 +559,7 @@ func (r *Registry) Upsert(t Route) error { |
| } |
| _, err := r.sql.Exec(` |
| INSERT INTO routes (`+routeCols+`) |
| -VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |
| +VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |
| ON CONFLICT(host) DO UPDATE SET |
| slug=excluded.slug, kind=excluded.kind, socket=excluded.socket, |
| addr=excluded.addr, status=excluded.status, last_error=excluded.last_error, |
| @@ -562,13 +576,14 @@ ON CONFLICT(host) DO UPDATE SET |
| caps=CASE WHEN excluded.caps = '' THEN routes.caps ELSE excluded.caps END, |
| tunnel=CASE WHEN excluded.tunnel = '' THEN routes.tunnel ELSE excluded.tunnel END, |
| provisioned=CASE WHEN excluded.provisioned = 0 THEN routes.provisioned ELSE excluded.provisioned END, |
| - seed=CASE WHEN excluded.seed = '' THEN routes.seed ELSE excluded.seed END`, |
| + seed=CASE WHEN excluded.seed = '' THEN routes.seed ELSE excluded.seed END, |
| + fleet_box=CASE WHEN excluded.fleet_box = '' THEN routes.fleet_box ELSE excluded.fleet_box END`, |
| t.Host, t.Slug, t.Kind, t.Socket, t.Addr, t.Status, t.LastError, |
| t.DBPath, t.Version, t.PrevVersion, t.App, t.Pool, t.Region, t.Channel, |
| b2i(t.Hibernate), b2i(t.Canary), t.Replication, t.NextDue, t.Backing, |
| b2i(t.StatusNamed), b2i(t.Platform), t.DNSStatus, t.DNSCheckedAt, |
| t.CertExpiresAt, t.Account, t.Environment, t.Caps, t.Tunnel, |
| - b2i(t.Provisioned), t.Seed, t.CreatedAt) |
| + b2i(t.Provisioned), t.Seed, t.FleetBox, t.CreatedAt) |
| return err |
| } |
| @@ -820,6 +835,59 @@ func (r *Registry) SetTunnel(host, tunnel string) error { |
| return nil |
| } |
| +// SetFleetBox stamps the label of the fleet box CURRENTLY serving this |
| +// route as a narrow UPDATE — same rationale and same "error on an unmatched |
| +// host" contract as SetTunnel. label == "" clears the binding; this is a |
| +// placement pass's door, distinct from SetPlacement (which writes the |
| +// placement TARGET, not the box currently fulfilling it). |
| +func (r *Registry) SetFleetBox(host, label string) error { |
| + res, err := r.sql.Exec(`UPDATE routes SET fleet_box = ? WHERE host = ?`, label, host) |
| + if err != nil { |
| + return err |
| + } |
| + n, err := res.RowsAffected() |
| + if err != nil { |
| + return err |
| + } |
| + if n == 0 { |
| + return fmt.Errorf("no route for host %q", host) |
| + } |
| + return nil |
| +} |
| + |
| +// SetPlacement repoints a route's placement TARGET — stored in the Region |
| +// column, which already carries a route's residency constraint and, as of |
| +// this task, doubles as where a fleet-bound route wants to run (see |
| +// FleetPlacement/PlacementTarget, fleets.go, for the grammar that |
| +// distinguishes the two). target == "" clears the binding. Any other value |
| +// must parse as "fleet/<name>" via FleetPlacement — a bare region code like |
| +// "eu" is a FUTURE placement form this v1 does not accept here (readers use |
| +// FleetPlacement to tell the two apart; it simply answers ("", false) for a |
| +// bare code, same as for anything else this setter refuses). |
| +// |
| +// Same "error on an unmatched host" contract as SetTunnel/SetFleetBox: a |
| +// human or a placement pass typing a hostname here should get a refusal on a |
| +// typo, not a silent no-op. |
| +func (r *Registry) SetPlacement(host, target string) error { |
| + if target != "" { |
| + if _, ok := FleetPlacement(target); !ok { |
| + return fmt.Errorf("invalid placement target %q (want \"\" or \"fleet/<name>\")", target) |
| + } |
| + } |
| + res, err := r.sql.Exec(`UPDATE routes SET region = ? WHERE host = ?`, target, host) |
| + if err != nil { |
| + return err |
| + } |
| + n, err := res.RowsAffected() |
| + if err != nil { |
| + return err |
| + } |
| + if n == 0 { |
| + return fmt.Errorf("no route for host %q", host) |
| + } |
| + return nil |
| +} |
| + |
| // SetEnvironments is SetEnvironment's bulk sibling, mirroring SetChannels: |
| // one transaction across every host in hosts, so `carlos route -app |
| // -environment` can never leave an app split across two environments the |
diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go| index b7f120211..f3793bb27 100644 |
| --- a/internal/registry/registry_test.go |
| +++ b/internal/registry/registry_test.go |
| @@ -1347,3 +1347,198 @@ func TestValidTunnelNameLengthBoundary(t *testing.T) { |
| t.Errorf("ValidTunnelName(33 chars) = true, want false") |
| } |
| } |
| + |
| +// TestSetFleetBoxRoundTrips mirrors TestSetTunnel's shape: SetFleetBox is a |
| +// narrow UPDATE on the new fleet_box column, which records which fleet box |
| +// is CURRENTLY serving a placed route (distinct from Region/placement |
| +// target, which says where the route WANTS to run). |
| +func TestSetFleetBoxRoundTrips(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.Upsert(Route{Host: "demo.example", Kind: "instance", Status: "ready"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetFleetBox("demo.example", "pi-1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := reg.Get("demo.example") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.FleetBox != "pi-1" { |
| + t.Fatalf("FleetBox = %q, want pi-1", got.FleetBox) |
| + } |
| + if err := reg.SetFleetBox("demo.example", ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err = reg.Get("demo.example") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.FleetBox != "" { |
| + t.Fatalf("SetFleetBox clear failed: %q", got.FleetBox) |
| + } |
| +} |
| + |
| +// Same contract as TestSetTunnelUnknownHostErrors: a human/reconciler typo'd |
| +// host must get a refusal, not a silent no-op. |
| +func TestSetFleetBoxUnknownHostErrors(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.SetFleetBox("nope.example.com", "pi-1"); err == nil { |
| + t.Fatal("SetFleetBox on an unknown host: got nil error, want one") |
| + } |
| +} |
| + |
| +// Same rationale as TestUpsertPreservesTunnel: a full-row Upsert caller that |
| +// does not know about fleet placement must not silently unbind a box another |
| +// pass stamped. |
| +func TestUpsertPreservesFleetBox(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + base := Route{Host: "demo.example", Kind: "instance", Status: "ready"} |
| + if err := reg.Upsert(base); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetFleetBox("demo.example", "pi-1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + // A full-row re-Upsert with zero-value FleetBox must not unbind. |
| + if err := reg.Upsert(base); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := reg.Get("demo.example") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.FleetBox != "pi-1" { |
| + t.Fatalf("Upsert clobbered FleetBox: %q", got.FleetBox) |
| + } |
| + // An explicit non-empty value still wins. |
| + base.FleetBox = "pi-2" |
| + if err := reg.Upsert(base); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err = reg.Get("demo.example") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.FleetBox != "pi-2" { |
| + t.Fatalf("FleetBox = %q, want an explicit Upsert value to win", got.FleetBox) |
| + } |
| + // Clearing has exactly one door: SetFleetBox(host, ""). |
| + if err := reg.SetFleetBox("demo.example", ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err = reg.Get("demo.example") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.FleetBox != "" { |
| + t.Fatalf("SetFleetBox clear failed: %q", got.FleetBox) |
| + } |
| +} |
| + |
| +// A registry file written BEFORE the fleet_box column existed must still |
| +// open, and its rows must read as "no box bound" — the same additive- |
| +// migration contract as TestProvisionedSeedMigratesFromPreChangeDB. |
| +func TestFleetBoxMigratesFromPreChangeDB(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 routes ( |
| + host TEXT PRIMARY KEY, |
| + kind TEXT NOT NULL, |
| + socket TEXT NOT NULL DEFAULT '', |
| + status TEXT NOT NULL DEFAULT 'ready', |
| + last_error TEXT NOT NULL DEFAULT '', |
| + db_path TEXT NOT NULL DEFAULT '', |
| + version TEXT NOT NULL DEFAULT '', |
| + prev_version TEXT NOT NULL DEFAULT '', |
| + created_at INTEGER NOT NULL |
| +);`); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := db.Exec(`INSERT INTO routes (host, kind, created_at) VALUES (?, ?, ?)`, |
| + "old.example.com", "instance", 1234); 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-change DB: %v", err) |
| + } |
| + defer r.Close() |
| + got, err := r.Get("old.example.com") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got == nil { |
| + t.Fatal("pre-existing row vanished on migrate") |
| + } |
| + if got.FleetBox != "" { |
| + t.Fatalf("pre-existing row should default fleet_box=\"\": %+v", got) |
| + } |
| +} |
| + |
| +// TestSetPlacementRoundTrips pins SetPlacement's grammar: "" clears, |
| +// "fleet/<name>" sets Region to the fleet placement target, and anything |
| +// else — a bare region code, an invalid fleet name — is refused rather than |
| +// silently written. Bare region codes are a FUTURE placement form; v1 only |
| +// accepts "" or "fleet/<name>" here (FleetPlacement, fleets.go, is what |
| +// distinguishes the two forms on read). |
| +func TestSetPlacementRoundTrips(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.Upsert(Route{Host: "demo.example", Kind: "instance", Status: "ready"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetPlacement("demo.example", "fleet/pi"); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := reg.Get("demo.example") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.Region != "fleet/pi" { |
| + t.Fatalf("Region = %q, want fleet/pi", got.Region) |
| + } |
| + if err := reg.SetPlacement("demo.example", ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err = reg.Get("demo.example") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.Region != "" { |
| + t.Fatalf("SetPlacement(\"\") did not clear Region: %q", got.Region) |
| + } |
| +} |
| + |
| +func TestSetPlacementRejectsBadGrammar(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.Upsert(Route{Host: "demo.example", Kind: "instance", Status: "ready"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, bad := range []string{"bogus", "eu", "fleet/UPPER", "fleet/", "fleet"} { |
| + if err := reg.SetPlacement("demo.example", bad); err == nil { |
| + t.Errorf("SetPlacement(%q): got nil error, want one", bad) |
| + } |
| + } |
| + got, err := reg.Get("demo.example") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.Region != "" { |
| + t.Fatalf("a rejected SetPlacement wrote Region anyway: %q", got.Region) |
| + } |
| +} |
| + |
| +func TestSetPlacementUnknownHostErrors(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.SetPlacement("nope.example.com", "fleet/pi"); err == nil { |
| + t.Fatal("SetPlacement on an unknown host: got nil error, want one") |
| + } |
| +} |