console: placement moves from the app Instances tab and fleet page (D4/D5/D6)
Placement was a CLI-only verb: `carlos apps place`. Two browser forms now reach the same placeAppTarget core — the app's Instances tab (move THIS app to any fleet the account owns, or back to the shared one) and the fleet page (move any of the account's apps onto THIS fleet). Neither is a new capability; both are the same registry and record writes on a session instead of a bearer token, audited "(console)" against the CLI's "(cli)". Both carry D6's warning beside the form: moving changes where the app SERVES; data does not move, hibernated instance state stays in the old placement's bucket. "Move" is the one word a customer could reasonably read as "take my data with it", so the page says otherwise where the decision is made, not in a doc. Gates: owner-only forms, CSRF, and a target naming another account's fleet answering the byte-identical 404 an unrouted URL gives — fleet names are one flat, cross-account namespace, so a distinguishable refusal there would be an existence oracle. The fleet page's own form proves the FLEET is yours (ownerFleet) and the APP is yours (HasAppIn) as two independent gates. The tab states where an app runs for members too, but only claims it when the read SUCCEEDED (AppPlacementKnown) — "" is otherwise both "the shared fleet" and "we could not check", and only one of those is a sentence a page earns.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+574
−0
internal/console/fleets.go+166 −0internal/console/fleets_test.go+342 −0internal/console/server.go+32 −0internal/console/templates/app_instances.html+20 −0internal/console/templates/fleetdetail.html+14 −0
diff --git a/internal/console/fleets.go b/internal/console/fleets.go| index 46ced56bf..6c51ca487 100644 |
| --- a/internal/console/fleets.go |
| +++ b/internal/console/fleets.go |
| @@ -689,6 +689,170 @@ func (s *Server) handleAPIPlace(w http.ResponseWriter, r *http.Request) { |
| writeJSON(w, http.StatusOK, map[string]any{}) |
| } |
| +// placeFlash is the one sentence both browser doors say about an outcome, |
| +// and the one audit detail both record — kept together here so the two forms |
| +// can never describe the same act differently. suffix is the door's own |
| +// provenance mark ("(console)"), the same convention the fleet-box page |
| +// doors use against the CLI API's "(cli)". |
| +func placeFlash(app, target, suffix string) (flash, detail string) { |
| + if target == "" { |
| + return app + " is back on the shared fleet.", "cleared placement for " + app + " " + suffix |
| + } |
| + return app + " is now placed on " + target + ".", "placed " + app + " on " + target + " " + suffix |
| +} |
| + |
| +// handleAppPlace answers the Placement form on an app's Instances tab: move |
| +// THIS app onto one of the account's fleets, or back to the shared one. |
| +// |
| +// The gate skeleton is handleAppRename's, deliberately — accountApp, then |
| +// CSRF, then owner (a member gets the byte-identical 404, never a 403 that |
| +// would confirm both the app's existence and the caller's under-privilege). |
| +// The move itself is placeAppTarget's; this door only translates its outcome |
| +// into a flash, except for the cross-account target refusal, which it must |
| +// answer as an ordinary http.NotFound and nothing else (see the core's doc |
| +// comment on why an empty message is that signal). |
| +func (s *Server) handleAppPlace(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, acct, role, app, ok := s.accountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| + s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| + return |
| + } |
| + if role != RoleOwner { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + back := func(kind, msg string) { |
| + s.setFlash(w, kind, msg) |
| + http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/instances", |
| + http.StatusSeeOther) |
| + } |
| + target := strings.TrimSpace(r.FormValue("target")) |
| + switch status, msg := s.placeAppTarget(r.Context(), acct, app, target); { |
| + case status == http.StatusNotFound: |
| + http.NotFound(w, r) |
| + return |
| + case status != 0: |
| + back("err", msg) |
| + return |
| + } |
| + flash, detail := placeFlash(app, target, "(console)") |
| + s.audit(acct, app, addr, "place", detail) |
| + back("ok", flash) |
| +} |
| + |
| +// handleFleetPlacePage answers the fleet page's "Place an app here" form: the |
| +// same move from the other end — this fleet is fixed, the app is chosen. |
| +// |
| +// ownerFleet proves the FLEET is this account's; it says nothing about the |
| +// app named in the body, so HasAppIn is a second, independent gate here. Its |
| +// refusal is accountApp's own "No such app here." 404, which is the same |
| +// answer a mistyped name gets: app names are per-account, so this reveals |
| +// nothing about another account's apps. |
| +func (s *Server) handleFleetPlacePage(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, acct, fleet, _, ok := s.ownerFleet(w, r) |
| + if !ok { |
| + return |
| + } |
| + if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| + s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| + return |
| + } |
| + back := func(kind, msg string) { |
| + s.setFlash(w, kind, msg) |
| + http.Redirect(w, r, fleetPageURL(acct.Sqid, fleet.Name), http.StatusSeeOther) |
| + } |
| + app := strings.TrimSpace(r.FormValue("app")) |
| + has, err := s.store.HasAppIn(acct.ID, app) |
| + if err != nil { |
| + log.Printf("console: fleet place %s/%s: has app %s: %v", acct.Sqid, fleet.Name, app, err) |
| + back("err", "Could not read your apps — try again.") |
| + return |
| + } |
| + if !has { |
| + s.renderMessage(w, r, http.StatusNotFound, "Not found", "No such app here.") |
| + return |
| + } |
| + target := registry.PlacementTarget(fleet.Name) |
| + switch status, msg := s.placeAppTarget(r.Context(), acct, app, target); { |
| + case status == http.StatusNotFound: |
| + http.NotFound(w, r) |
| + return |
| + case status != 0: |
| + back("err", msg) |
| + return |
| + } |
| + flash, detail := placeFlash(app, target, "(console)") |
| + s.audit(acct, app, addr, "place", detail) |
| + back("ok", flash) |
| +} |
| + |
| +// appPlacementSection reads the app Instances tab's Placement section: where |
| +// this app runs now, and every fleet the account owns to offer as somewhere |
| +// else it could. |
| +// |
| +// known is the section's twin of sharedFleetCard.PlacementKnown and exists |
| +// for the identical reason: "" is both "this app runs on the shared fleet" |
| +// and "the read that would have told us failed", and only the first of those |
| +// is a sentence this page has earned. False means the section says it could |
| +// not check, rather than making the positive claim — and withholds the form, |
| +// whose pre-selected option would otherwise be a guess. |
| +func (s *Server) appPlacementSection(acct *Account, app string) (placement string, fleets []string, known bool, warn string) { |
| + reg, err := openRegistry(s.cfg.Registry) |
| + if err != nil { |
| + log.Printf("console: app placement %s/%s: open registry: %v", acct.Sqid, app, err) |
| + return "", nil, false, "Could not reach the fleet registry." |
| + } |
| + placement, err = s.appPlacement(reg, acct, app) |
| + if err != nil { |
| + log.Printf("console: app placement %s/%s: %v", acct.Sqid, app, err) |
| + return "", nil, false, "Could not read where this app runs." |
| + } |
| + rows, err := reg.ListFleets() |
| + if err != nil { |
| + log.Printf("console: app placement %s/%s: list fleets: %v", acct.Sqid, app, err) |
| + // Placement itself IS known — only the list of alternatives is |
| + // missing, which costs the form and nothing else. |
| + return placement, nil, true, "Could not list your fleets." |
| + } |
| + for _, f := range rows { |
| + // Fleet names are one flat, cross-account namespace (this file's |
| + // package doc comment): this filter is the only thing keeping another |
| + // account's fleet names off the page. ListFleets is already ORDER BY |
| + // name, so the offered order is stable without a second sort. |
| + if f.Account == acct.Sqid { |
| + fleets = append(fleets, f.Name) |
| + } |
| + } |
| + return placement, fleets, true, "" |
| +} |
| + |
| +// fleetPlaceableApps is what the fleet page's own form may offer: the |
| +// account's live apps that are not ALREADY placed here, derived from the |
| +// placed-apps grouping loadFleetPage just walked rather than a second route |
| +// pass. A failed apps read costs the form (nil), never the page — the same |
| +// soft-fail posture everything else on this page has. |
| +func (s *Server) fleetPlaceableApps(acct *Account, placed []fleetPagePlacedApp) []string { |
| + apps, err := s.store.AppsIn(acct.ID) |
| + if err != nil { |
| + log.Printf("console: fleet page %s: list apps: %v", acct.Sqid, err) |
| + return nil |
| + } |
| + here := make(map[string]bool, len(placed)) |
| + for _, p := range placed { |
| + here[p.App] = true |
| + } |
| + out := make([]string, 0, len(apps)) |
| + for _, a := range apps { |
| + if !here[a] { |
| + out = append(out, a) |
| + } |
| + } |
| + return out |
| +} |
| + |
| // ---- the console fleet page (Task 9) -------------------------------------- |
| // |
| // GET /a/{sqid}/fleets/{fleet} is member-readable (any role sees the boxes |
| @@ -911,6 +1075,7 @@ func (s *Server) handleFleetPage(w http.ResponseWriter, r *http.Request) { |
| boxes, apps, warn := s.loadFleetPage(r.Context(), reg, acct, fleet) |
| p.FleetBoxes = boxes |
| p.FleetApps = apps |
| + p.FleetPlaceableApps = s.fleetPlaceableApps(acct, apps) |
| if warn != "" && p.FlashErr == "" { |
| p.FlashErr = warn |
| } |
| @@ -973,6 +1138,7 @@ func (s *Server) handleFleetRotateBoxPage(w http.ResponseWriter, r *http.Request |
| boxes, apps, warn := s.loadFleetPage(r.Context(), reg, acct, fleet) |
| p.FleetBoxes = boxes |
| p.FleetApps = apps |
| + p.FleetPlaceableApps = s.fleetPlaceableApps(acct, apps) |
| p.FlashErr = warn |
| p.RotatedLabel = label |
| p.RotatedToken = plaintext |
diff --git a/internal/console/fleets_test.go b/internal/console/fleets_test.go| index 274fb8214..adaf2e77c 100644 |
| --- a/internal/console/fleets_test.go |
| +++ b/internal/console/fleets_test.go |
| @@ -1166,3 +1166,345 @@ func TestFleetPageTagBoxBadGrammarFlashesErrorNothingWritten(t *testing.T) { |
| t.Fatalf("registry tags after rejected write = %q, want unchanged office", boxes[0].Tags) |
| } |
| } |
| + |
| +// ---- Task 6: the browser placement doors --------------------------------- |
| +// |
| +// Two forms, one mechanism: the app's Instances tab moves THIS app to any of |
| +// the account's fleets (or back to the shared one), and the fleet page moves |
| +// any of the account's apps onto THIS fleet. Both call placeAppTarget, so |
| +// what these tests pin is the doors' own share — the owner gate, CSRF, the |
| +// cross-account 404, the redirect, and that the same routes-and-records |
| +// stamping the CLI API's tests already prove really happened. |
| + |
| +// placeFixture is a console with titogo claimed, two of its routes and one |
| +// already-provisioned instance record seeded, and an owned fleet "pi" to |
| +// move it onto. Routes are seeded through a second handle BEFORE the console |
| +// opens its own (openTestReg's doc comment). |
| +func placeFixture(t *testing.T, code string) (*consoleFixture, string) { |
| + t.Helper() |
| + fx, token := fleetFixture(t, code) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg := openTestReg(t, fx) |
| + for _, host := range []string{"acme.go.tito.io", "beta.go.tito.io"} { |
| + if err := reg.Upsert(registry.Route{Host: host, Kind: "instance", App: "titogo", Account: fx.sqid}); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + // Region "" throughout: the app starts genuinely unplaced, so "nothing |
| + // moved" and "moved back to the shared fleet" are the same assertion — |
| + // assertPlacement can check one value across all three stores. (The API |
| + // door's own tripwire test covers stamping OVER a pre-existing region.) |
| + rec := provision.Record{Schema: 1, Host: "acme.go.tito.io", Slug: "acme", App: "titogo", |
| + Account: fx.sqid, Channel: "stable", CreatedAt: now.UTC()} |
| + if err := provision.CreateRecord(context.Background(), fx.rel, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new", |
| + map[string]string{"name": "pi", "bucket": "pi-fleet-data"}, token); st != http.StatusOK { |
| + t.Fatalf("create fleet = %d %v", st, body) |
| + } |
| + return fx, token |
| +} |
| + |
| +// assertPlacement is the whole tripwire in one call: the store of record, the |
| +// app's routes, and its already-provisioned instance record all agree on |
| +// where the app runs. Anything less is the routes-only half-move the API |
| +// door's own tests (TestPlaceStampsRoutesAndInstances) exist to refuse. |
| +func assertPlacement(t *testing.T, fx *consoleFixture, want string) { |
| + t.Helper() |
| + if got, err := fx.st.AppPlacement(OpsAccountID, "titogo"); err != nil || got != want { |
| + t.Fatalf("store placement = %q %v, want %q", got, err, want) |
| + } |
| + reg := openTestReg(t, fx) |
| + for _, host := range []string{"acme.go.tito.io", "beta.go.tito.io"} { |
| + rt, err := reg.Get(host) |
| + if err != nil || rt == nil { |
| + t.Fatalf("route %s: %v", host, err) |
| + } |
| + if rt.Region != want { |
| + t.Fatalf("route %s Region = %q, want %q", host, rt.Region, want) |
| + } |
| + } |
| + got, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "titogo", "acme.go.tito.io") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.Region != want { |
| + t.Fatalf("instance record Region = %q, want %q", got.Region, want) |
| + } |
| +} |
| + |
| +// TestAppPlacePageMovesAndClears: the owner's Placement form on the app's |
| +// Instances tab stamps routes AND records (placeAppTarget's whole job) and |
| +// comes back to the tab with a flash saying what happened — then clears the |
| +// same way, which is a MOVE BACK to the shared fleet, not a no-op. |
| +func TestAppPlacePageMovesAndClears(t *testing.T) { |
| + fx, _ := placeFixture(t, "code-appplace-move") |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-appplace-move-browser") |
| + csrf := sessionToken(t, fx, c) |
| + |
| + status, loc, _ := postForm(t, c, fx.u("/apps/titogo/place"), |
| + url.Values{"csrf": {csrf}, "target": {"fleet/pi"}}) |
| + if status != http.StatusSeeOther || loc != fx.path("/apps/titogo/instances") { |
| + t.Fatalf("place = %d -> %q, want 303 -> %q", status, loc, fx.path("/apps/titogo/instances")) |
| + } |
| + assertPlacement(t, fx, "fleet/pi") |
| + |
| + _, _, body := get(t, c, fx.u("/apps/titogo/instances")) |
| + if !strings.Contains(body, "placed on fleet/pi") { |
| + t.Fatalf("no placed flash after the redirect:\n%s", body) |
| + } |
| + |
| + status, loc, _ = postForm(t, c, fx.u("/apps/titogo/place"), |
| + url.Values{"csrf": {csrf}, "target": {""}}) |
| + if status != http.StatusSeeOther || loc != fx.path("/apps/titogo/instances") { |
| + t.Fatalf("clear = %d -> %q, want 303 -> %q", status, loc, fx.path("/apps/titogo/instances")) |
| + } |
| + assertPlacement(t, fx, "") |
| + _, _, body = get(t, c, fx.u("/apps/titogo/instances")) |
| + if !strings.Contains(body, "back on the shared fleet") { |
| + t.Fatalf("no cleared flash after the redirect:\n%s", body) |
| + } |
| +} |
| + |
| +// TestAppPlacePageMemberRefused: placement is an owner act. A plain member |
| +// gets the same byte-identical 404 every other owner-only app POST gives — |
| +// never a 403 that would confirm the app is there and the caller merely |
| +// under-privileged — and nothing moves. |
| +func TestAppPlacePageMemberRefused(t *testing.T) { |
| + fx, _ := placeFixture(t, "code-appplace-member") |
| + if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.setUser("sam@keymail.dev") |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-appplace-member-browser") |
| + csrf := sessionToken(t, fx, c) |
| + |
| + status, _, body := postForm(t, c, fx.u("/apps/titogo/place"), |
| + url.Values{"csrf": {csrf}, "target": {"fleet/pi"}}) |
| + ghostStatus, _, ghostBody := get(t, c, fx.ts.URL+"/definitely-not-a-page") |
| + if status != http.StatusNotFound || status != ghostStatus || body != ghostBody { |
| + t.Fatalf("member place = %d %q, want the unrouted page's %d %q", status, body, ghostStatus, ghostBody) |
| + } |
| + assertPlacement(t, fx, "") |
| +} |
| + |
| +// TestAppPlacePageWithoutCSRFForbidden: the form is CSRF-gated like every |
| +// other mutation on this console. |
| +func TestAppPlacePageWithoutCSRFForbidden(t *testing.T) { |
| + fx, _ := placeFixture(t, "code-appplace-csrf") |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-appplace-csrf-browser") |
| + |
| + status, _, _ := postForm(t, c, fx.u("/apps/titogo/place"), |
| + url.Values{"csrf": {"wrong"}, "target": {"fleet/pi"}}) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("place with a bad csrf = %d, want 403", status) |
| + } |
| + assertPlacement(t, fx, "") |
| +} |
| + |
| +// TestAppPlacePageForeignFleetIsIdenticalNotFound: the browser twin of |
| +// TestPlaceRefusesForeignFleet. A target naming a fleet ANOTHER account owns |
| +// must answer exactly what an unrouted URL does — fleet names are one flat, |
| +// cross-account namespace, so a distinguishable refusal here would be a |
| +// cross-account existence oracle. |
| +func TestAppPlacePageForeignFleetIsIdenticalNotFound(t *testing.T) { |
| + fx, _ := placeFixture(t, "code-appplace-foreign") |
| + now := time.Now() |
| + if err := fx.st.CreateAccount(42, "cdef", "widgets", "bob@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + bobToken := cliToken(t, fx, "bob@keymail.dev", "code-appplace-foreign-bob") |
| + if st, body := postJSON(t, fleetsURL(fx, "cdef")+"/new", |
| + map[string]string{"name": "widgets", "bucket": "bob-data"}, bobToken); st != http.StatusOK { |
| + t.Fatalf("bob's fleet create = %d %v", st, body) |
| + } |
| + |
| + fx.setUser("paul@keymail.dev") |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-appplace-foreign-browser") |
| + csrf := sessionToken(t, fx, c) |
| + |
| + status, _, body := postForm(t, c, fx.u("/apps/titogo/place"), |
| + url.Values{"csrf": {csrf}, "target": {"fleet/widgets"}}) |
| + ghostStatus, _, ghostBody := get(t, c, fx.ts.URL+"/definitely-not-a-page") |
| + if status != http.StatusNotFound || status != ghostStatus || body != ghostBody { |
| + t.Fatalf("place onto a foreign fleet = %d %q, want the unrouted page's %d %q", |
| + status, body, ghostStatus, ghostBody) |
| + } |
| + assertPlacement(t, fx, "") |
| +} |
| + |
| +// TestAppInstancesPlacementSection: the tab states where the app runs for |
| +// everyone, and offers the move only to the owner — with the |
| +// data-does-not-move warning beside the form, because "move" is the one word |
| +// a customer could reasonably read as "take my data with it". |
| +func TestAppInstancesPlacementSection(t *testing.T) { |
| + fx, _ := placeFixture(t, "code-appplace-section") |
| + if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + owner := newBrowser(t) |
| + signUp(t, fx, owner, "code-appplace-section-owner") |
| + st, _, body := get(t, owner, fx.u("/apps/titogo/instances")) |
| + if st != http.StatusOK { |
| + t.Fatalf("GET instances = %d:\n%s", st, body) |
| + } |
| + for _, want := range []string{ |
| + "<h2>Placement</h2>", |
| + "This app runs on the shared fleet.", |
| + `action="` + fx.path("/apps/titogo/place") + `"`, |
| + `<option value="fleet/pi"`, |
| + // The D6 warning, rendered across two source lines — matched in the |
| + // two halves the template actually emits (and stopped short of the |
| + // apostrophe html/template escapes). |
| + "Data does not move:", |
| + "hibernated instance state stays in the old placement", |
| + } { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("owner instances tab missing %q in:\n%s", want, body) |
| + } |
| + } |
| + |
| + fx.setUser("sam@keymail.dev") |
| + member := newBrowser(t) |
| + signUp(t, fx, member, "code-appplace-section-member") |
| + st, _, memberBody := get(t, member, fx.u("/apps/titogo/instances")) |
| + if st != http.StatusOK { |
| + t.Fatalf("member GET instances = %d:\n%s", st, memberBody) |
| + } |
| + if !strings.Contains(memberBody, "This app runs on the shared fleet.") { |
| + t.Fatalf("member instances tab lost the placement statement:\n%s", memberBody) |
| + } |
| + if strings.Contains(memberBody, fx.path("/apps/titogo/place")) { |
| + t.Fatalf("member instances tab renders the move form it must not have:\n%s", memberBody) |
| + } |
| +} |
| + |
| +// TestAppInstancesPlacementShowsFleet: once placed, the tab names the fleet |
| +// and links to its page, and the form's current fleet is the selected option |
| +// rather than a form that reads as "unplaced" over a placed app. |
| +func TestAppInstancesPlacementShowsFleet(t *testing.T) { |
| + fx, token := placeFixture(t, "code-appplace-shows") |
| + if st, body := postJSON(t, placeURL(fx, "titogo"), map[string]string{"target": "fleet/pi"}, token); st != http.StatusOK { |
| + t.Fatalf("place = %d %v", st, body) |
| + } |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-appplace-shows-browser") |
| + st, _, body := get(t, c, fx.u("/apps/titogo/instances")) |
| + if st != http.StatusOK { |
| + t.Fatalf("GET instances = %d:\n%s", st, body) |
| + } |
| + if !strings.Contains(body, "/fleets/pi") || !strings.Contains(body, "<code>pi</code>") { |
| + t.Fatalf("placed app's tab does not name and link its fleet:\n%s", body) |
| + } |
| + if !strings.Contains(body, `<option value="fleet/pi" selected>`) { |
| + t.Fatalf("the current fleet is not the form's selected option:\n%s", body) |
| + } |
| +} |
| + |
| +// TestFleetPagePlaceFormPlacesApp: the fleet page's own form is the same move |
| +// from the other end — pick one of the account's apps, and it lands here. |
| +func TestFleetPagePlaceFormPlacesApp(t *testing.T) { |
| + fx, _ := placeFixture(t, "code-fleetplace-form") |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-fleetplace-form-browser") |
| + csrf := sessionToken(t, fx, c) |
| + |
| + st, _, body := get(t, c, fx.u("/fleets/pi")) |
| + if st != http.StatusOK { |
| + t.Fatalf("GET fleet page = %d:\n%s", st, body) |
| + } |
| + for _, want := range []string{ |
| + `action="` + fx.path("/fleets/pi/place") + `"`, |
| + `<option value="titogo">titogo</option>`, |
| + // The D6 warning, rendered across two source lines — matched in the |
| + // two halves the template actually emits (and stopped short of the |
| + // apostrophe html/template escapes). |
| + "Data does not move:", |
| + "hibernated instance state stays in the old placement", |
| + } { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("fleet page missing %q in:\n%s", want, body) |
| + } |
| + } |
| + |
| + status, loc, _ := postForm(t, c, fx.u("/fleets/pi/place"), |
| + url.Values{"csrf": {csrf}, "app": {"titogo"}}) |
| + if status != http.StatusSeeOther || loc != fx.path("/fleets/pi") { |
| + t.Fatalf("fleet place = %d -> %q, want 303 -> %q", status, loc, fx.path("/fleets/pi")) |
| + } |
| + assertPlacement(t, fx, "fleet/pi") |
| + |
| + _, _, body = get(t, c, fx.u("/fleets/pi")) |
| + if !strings.Contains(body, "placed on fleet/pi") { |
| + t.Fatalf("no placed flash on the fleet page:\n%s", body) |
| + } |
| + // The app is now among the placed apps, so it is no longer offered as |
| + // something to place HERE. |
| + if strings.Contains(body, `<option value="titogo">titogo</option>`) { |
| + t.Fatalf("an app already placed on this fleet is still offered as placeable:\n%s", body) |
| + } |
| +} |
| + |
| +// TestFleetPagePlaceFormMemberRefused: same gate as the fleet page's other |
| +// mutations — a member's POST is byte-identical to rotate's refusal. |
| +func TestFleetPagePlaceFormMemberRefused(t *testing.T) { |
| + fx, _ := placeFixture(t, "code-fleetplace-member") |
| + if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.setUser("sam@keymail.dev") |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-fleetplace-member-browser") |
| + csrf := sessionToken(t, fx, c) |
| + |
| + placeStatus, _, placeBody := postForm(t, c, fx.u("/fleets/pi/place"), |
| + url.Values{"csrf": {csrf}, "app": {"titogo"}}) |
| + rotStatus, _, rotBody := postForm(t, c, fx.u("/fleets/pi/rotate"), |
| + url.Values{"csrf": {csrf}, "label": {"pi-1"}}) |
| + if placeStatus != http.StatusNotFound || placeStatus != rotStatus || placeBody != rotBody { |
| + t.Fatalf("member fleet place refusal (%d %q) not byte-identical to rotate's (%d %q)", |
| + placeStatus, placeBody, rotStatus, rotBody) |
| + } |
| + assertPlacement(t, fx, "") |
| + |
| + // And the form is not even offered on their page. |
| + _, _, body := get(t, c, fx.u("/fleets/pi")) |
| + if strings.Contains(body, fx.path("/fleets/pi/place")) { |
| + t.Fatalf("member fleet page renders the place form it must not have:\n%s", body) |
| + } |
| +} |
| + |
| +// TestFleetPagePlaceFormForeignApp: an app the account does not claim is the |
| +// same "no such app here" a mistyped app name gets on any other app door — |
| +// the fleet's own ownership having already been proven says nothing about |
| +// the app named in the body. |
| +func TestFleetPagePlaceFormForeignApp(t *testing.T) { |
| + fx, _ := placeFixture(t, "code-fleetplace-foreignapp") |
| + now := time.Now() |
| + if err := fx.st.CreateAccount(42, "cdef", "widgets", "bob@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.st.CreateAppIn(42, "bobapp", "bob@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-fleetplace-foreignapp-browser") |
| + csrf := sessionToken(t, fx, c) |
| + |
| + status, _, _ := postForm(t, c, fx.u("/fleets/pi/place"), |
| + url.Values{"csrf": {csrf}, "app": {"bobapp"}}) |
| + if status != http.StatusNotFound { |
| + t.Fatalf("placing another account's app = %d, want 404", status) |
| + } |
| + if got, err := fx.st.AppPlacement(42, "bobapp"); err != nil || got != "" { |
| + t.Fatalf("another account's app placement = %q %v, want untouched", got, err) |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index 320749f43..1f3a659d6 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -138,6 +138,12 @@ var templates = template.Must(template.New("").Funcs(template.FuncMap{ |
| // expects back, so an owner opening the form sees the same string they'd |
| // type to leave it unchanged. |
| "join": strings.Join, |
| + // fleetName reads the fleet out of a placement target ("fleet/pi" -> "pi") |
| + // so the Placement section can NAME the fleet and link to its page rather |
| + // than printing the raw Region value at a customer. A target that does not |
| + // parse yields "", which the template's own {{if}} already handles as |
| + // "not placed" — the same reading appPlacement itself takes. |
| + "fleetName": func(target string) string { f, _ := registry.FleetPlacement(target); return f }, |
| }).ParseFS(tmplFS, "templates/*.html")) |
| const ( |
| @@ -337,6 +343,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/canary/retire", s.handleRetireCanary) |
| // The app Settings writes (appsettings.go): owner-only, and delete/move |
| // behind passkey step-up besides. |
| + s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/place", s.handleAppPlace) |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/rename", s.handleAppRename) |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/delete", s.handleAppDelete) |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/move", s.handleAppMoveAccount) |
| @@ -348,6 +355,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| s.mux.HandleFunc("POST /a/{sqid}/fleets/{fleet}/rotate", s.handleFleetRotateBoxPage) |
| s.mux.HandleFunc("POST /a/{sqid}/fleets/{fleet}/detach", s.handleFleetDetachBoxPage) |
| s.mux.HandleFunc("POST /a/{sqid}/fleets/{fleet}/tags", s.handleFleetTagBoxPage) |
| + s.mux.HandleFunc("POST /a/{sqid}/fleets/{fleet}/place", s.handleFleetPlacePage) |
| s.mux.HandleFunc("POST /a/{sqid}/invite", s.handleInvite) |
| s.mux.HandleFunc("POST /a/{sqid}/invite/revoke", s.handleRevokeInvite) |
| // Owner-only account administration, every one of them behind passkey |
| @@ -542,6 +550,20 @@ type page struct { |
| FleetDetail *registry.Fleet |
| FleetBoxes []fleetPageBox |
| FleetApps []fleetPagePlacedApp |
| + // FleetPlaceableApps is the fleet page's "Place an app here" form: the |
| + // account's apps that are not already placed on THIS fleet |
| + // (fleetPlaceableApps). Empty means the form renders nothing to choose, |
| + // so the form itself is withheld — never an empty select. |
| + FleetPlaceableApps []string |
| + |
| + // App Instances tab, "Placement" (Task 6): where this app runs |
| + // ("fleet/<name>", or "" for the shared fleet), and the account's own |
| + // fleets as the owner's move options. AppPlacementKnown false means the |
| + // read FAILED — the section then says so instead of claiming the shared |
| + // fleet, and the move form is withheld rather than pre-selecting a guess. |
| + AppPlacement string |
| + AppPlacementKnown bool |
| + OwnedFleetNames []string |
| // /a/{sqid}/fleet (accountfleet.go): the account-level Fleet TAB, which |
| // is neither of the two pages above — the shared platform fleet as this |
| @@ -1599,6 +1621,16 @@ func (s *Server) handleAppInstances(w http.ResponseWriter, r *http.Request) { |
| p.Instances = s.instances.Report(r.Context(), p.Detail.Routes) |
| p.AppMetrics = s.appResourceMetrics(r.Context(), p.Detail, acct) |
| p.InstanceRows = assembleInstanceRows(p.Detail, acct, p.AppMetrics) |
| + // Placement (Task 6): where this app runs, and where else it could. Soft |
| + // like everything else on this tab — a failed read costs the section's |
| + // certainty (AppPlacementKnown) and a flash, never the page. |
| + placement, fleets, known, warn := s.appPlacementSection(acct, p.AppName) |
| + p.AppPlacement = placement |
| + p.OwnedFleetNames = fleets |
| + p.AppPlacementKnown = known |
| + if warn != "" && p.FlashErr == "" { |
| + p.FlashErr = warn |
| + } |
| s.render(w, http.StatusOK, "app_instances", p) |
| } |
diff --git a/internal/console/templates/app_instances.html b/internal/console/templates/app_instances.html| index ef50d2681..4a58045c1 100644 |
| --- a/internal/console/templates/app_instances.html |
| +++ b/internal/console/templates/app_instances.html |
| @@ -32,6 +32,26 @@ instance's own peak.</p> |
| to this app: <code>carlos add -app {{.Detail.App}} <host></code>.</p> |
| {{end}} |
| +<h2>Placement</h2> |
| +{{if .AppPlacementKnown}} |
| +<p>{{if .AppPlacement}}This app runs on your fleet <a href="{{.Nav}}/fleets/{{fleetName .AppPlacement}}"><code>{{fleetName .AppPlacement}}</code></a>.{{else}}This app runs on the shared fleet.{{end}}</p> |
| +{{if and .IsOwner .OwnedFleetNames}} |
| +<form method="post" action="{{.Base}}/apps/{{.AppName}}/place" class="settings-row"> |
| + <input type="hidden" name="csrf" value="{{.CSRF}}"> |
| + <label class="visually-hidden" for="place-target">Where this app runs</label> |
| + <select id="place-target" name="target"> |
| + <option value=""{{if not .AppPlacement}} selected{{end}}>Shared fleet</option> |
| + {{range .OwnedFleetNames}}<option value="fleet/{{.}}"{{if eq (printf "fleet/%s" .) $.AppPlacement}} selected{{end}}>fleet/{{.}}</option>{{end}} |
| + </select> |
| + <button type="submit" class="btn-sm">Move</button> |
| +</form> |
| +<p class="small">Moving changes where this app <em>serves</em>. Data does not move: |
| +hibernated instance state stays in the old placement's bucket.</p> |
| +{{end}} |
| +{{else}} |
| +<p class="small">Could not read where this app runs.</p> |
| +{{end}} |
| + |
| {{if and .Instances .Instances.Reporting .Instances.Instances}} |
| <h2>Inside the app</h2> |
| <p class="small">This app manages its own internal instances, and reports |
diff --git a/internal/console/templates/fleetdetail.html b/internal/console/templates/fleetdetail.html| index efcae53df..a9f0f774b 100644 |
| --- a/internal/console/templates/fleetdetail.html |
| +++ b/internal/console/templates/fleetdetail.html |
| @@ -85,4 +85,18 @@ not evict a channel it already holds; a compromised box needs Detach instead.</p |
| </table> |
| </div> |
| {{else}}<p class="small">No apps placed on this fleet yet — <code>carlos apps place</code> sends one here.</p>{{end}} |
| + |
| +{{if and .IsOwner .FleetPlaceableApps}} |
| +<h3>Place an app here</h3> |
| +<form method="post" action="{{.Base}}/fleets/{{.FleetDetail.Name}}/place" class="settings-row"> |
| + <input type="hidden" name="csrf" value="{{.CSRF}}"> |
| + <label class="visually-hidden" for="place-app">App to place on this fleet</label> |
| + <select id="place-app" name="app"> |
| + {{range .FleetPlaceableApps}}<option value="{{.}}">{{.}}</option>{{end}} |
| + </select> |
| + <button type="submit" class="btn-sm">Move</button> |
| +</form> |
| +<p class="small">Moving changes where that app <em>serves</em>. Data does not move: |
| +hibernated instance state stays in the old placement's bucket.</p> |
| +{{end}} |
| {{template "bottom" .}}{{end}} |