carlos / platform Public

Merge origin/main into console-ux-01-status

Paul Campbell pushed by paul@keymail.dev 7c18dafdafe05dc3c46fd0ce4d8b9deeb5702c37 parent 72bcb5dparent 0bc32c4
8 files changed, +278 −16
  • internal/console/apicli.go +1 −1
  • internal/console/browserrestart.go +144 −0
  • internal/console/browserrestart_test.go +88 −0
  • internal/console/overview.go +11 −8
  • internal/console/server.go +23 −7
  • internal/console/templates/app_instance.html +5 −0
  • internal/console/templates/app_instances.html +4 −0
  • internal/console/view.go +2 −0

CI passed — run details

diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index f57ae5bc2..fb43a2099 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -1125,7 +1125,7 @@ func (s *Server) handleAPIRestart(w http.ResponseWriter, r *http.Request) {
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
- if err := release.TouchRestartGen(ctx, s.cfg.Releases, acct.Sqid, app); err != nil {
+ if err := s.requestAppRestart(ctx, acct.Sqid, app); err != nil {
// Logged, not returned: a store error carries bucket names, endpoints
// and request ids. The caller gets the fixed sentence every other
// bucket failure here gets, and a retry is safe — the touch is
diff --git a/internal/console/browserrestart.go b/internal/console/browserrestart.go
new file mode 100644
index 000000000..1429f13f6
--- /dev/null
+++ b/internal/console/browserrestart.go
@@ -0,0 +1,144 @@
+package console
+
+import (
+ "context"
+ "crypto/subtle"
+ "log"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+type BrowserRestartCopy struct {
+ AppTitle, AppLine, HostTitle, HostLine string
+ Button, Success, Audit string
+ RateLimited, Failed string
+}
+
+var approvedBrowserRestartCopy = BrowserRestartCopy{
+ AppTitle: "Restart all app instances?",
+ AppLine: "Request a restart for this app's running instances. Sleeping instances stay asleep.",
+ HostTitle: "Restart {host}?",
+ HostLine: "Only {host} receives a restart request. If it is asleep, it remains asleep.",
+ Button: "Restart",
+ Success: "Restart requested for {target}.",
+ Audit: "Restart requested for {target}.",
+ RateLimited: "Too many restart requests. Try again later.",
+ Failed: "The restart request failed. Try again.",
+}
+
+func (s *Server) requestAppRestart(ctx context.Context, acct, app string) error {
+ return release.TouchRestartGen(ctx, s.cfg.Releases, acct, app)
+}
+
+func (s *Server) handleAppRestart(w http.ResponseWriter, r *http.Request) {
+ s.handleBrowserRestart(w, r, "", false)
+}
+
+func (s *Server) handleInstanceRestart(w http.ResponseWriter, r *http.Request) {
+ s.handleBrowserRestart(w, r, strings.TrimSpace(r.PathValue("host")), true)
+}
+
+func (s *Server) handleBrowserRestart(w http.ResponseWriter, r *http.Request, host string, scoped bool) {
+ addr, raw, acct, role, ok := s.account(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
+ }
+ app := r.PathValue("app")
+ has, err := s.store.HasAppIn(acct.ID, app)
+ if err != nil {
+ s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read your apps. Please try again.")
+ return
+ }
+ if !has {
+ http.NotFound(w, r)
+ return
+ }
+ if scoped && host == "" {
+ http.NotFound(w, r)
+ return
+ }
+ var scope release.RestartScope
+ if host != "" {
+ if s.cfg.Registry == "" {
+ http.NotFound(w, r)
+ return
+ }
+ reg, err := openRegistry(s.cfg.Registry)
+ if err != nil {
+ log.Printf("console: browser restart registry: %v", err)
+ http.NotFound(w, r)
+ return
+ }
+ routes, err := reg.List()
+ if err != nil {
+ log.Printf("console: browser restart routes: %v", err)
+ http.NotFound(w, r)
+ return
+ }
+ found := false
+ for _, rt := range routes {
+ if rt.Host == host && rt.App == app && release.RouteAccount(rt) == acct.Sqid && release.Adoptable(rt) {
+ found = true
+ break
+ }
+ }
+ if !found {
+ http.NotFound(w, r)
+ return
+ }
+ scope = release.RestartScope{Kind: release.RestartScopeHost, Value: host}
+ }
+ words := approvedBrowserRestartCopy
+ if !confirmed(r) {
+ cv := &ConfirmView{Action: r.URL.Path, Fields: [][2]string{{"csrf", raw}}, CancelTab: "instances"}
+ if host == "" {
+ cv.Title, cv.Lines, cv.Button = words.AppTitle, []string{words.AppLine}, words.Button
+ } else {
+ cv.Title = strings.ReplaceAll(words.HostTitle, "{host}", host)
+ cv.Lines, cv.Button = []string{strings.ReplaceAll(words.HostLine, "{host}", host)}, words.Button
+ }
+ s.renderConfirm(w, r, addr, raw, acct, role, app, cv)
+ return
+ }
+ if !s.allowWrite(Principal{Kind: PrincipalUser, Address: addr}, time.Now()) {
+ s.setFlash(w, "err", words.RateLimited)
+ http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/instances", http.StatusSeeOther)
+ return
+ }
+ if s.cfg.Releases == nil {
+ s.setFlash(w, "err", words.Failed)
+ http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/instances", http.StatusSeeOther)
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
+ defer cancel()
+ var restartErr error
+ if host == "" {
+ restartErr = s.requestAppRestart(ctx, acct.Sqid, app)
+ } else {
+ restartErr = s.writeRestartScope(ctx, acct.Sqid, app, scope)
+ }
+ if restartErr != nil {
+ log.Printf("console: browser restart %s/%s/%s: %v", acct.Sqid, app, host, restartErr)
+ s.setFlash(w, "err", words.Failed)
+ } else {
+ target := app
+ if host != "" {
+ target = host
+ }
+ detail := strings.ReplaceAll(words.Audit, "{target}", target)
+ success := strings.ReplaceAll(words.Success, "{target}", target)
+ s.audit(acct, app, addr, "restart", detail)
+ s.bumpSerial(ctx)
+ s.setFlash(w, "ok", success)
+ }
+ http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/instances", http.StatusSeeOther)
+}
diff --git a/internal/console/browserrestart_test.go b/internal/console/browserrestart_test.go
new file mode 100644
index 000000000..3d506a76c
--- /dev/null
+++ b/internal/console/browserrestart_test.go
@@ -0,0 +1,88 @@
+package console
+
+import (
+ "net/http"
+ "net/url"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestBrowserAppRestartUsesSharedGeneration(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ status, _, _ := postForm(t, c, fx.u("/apps/kass/restart"), url.Values{"csrf": {csrf}})
+ if status != http.StatusOK {
+ t.Fatalf("confirm = %d", status)
+ }
+ if restartGenEtag(t, fx, "kass") != "" || len(scopeLog(t, fx, "kass").Requests) != 0 {
+ t.Fatal("confirmation page wrote restart state")
+ }
+ status, loc, _ := postGated(t, fx, c, fx.u("/apps/kass/restart"), url.Values{"csrf": {csrf}, "confirmed": {"1"}})
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/instances") {
+ t.Fatalf("restart = %d -> %q", status, loc)
+ }
+ if restartGenEtag(t, fx, "kass") == "" {
+ t.Fatal("browser restart did not touch app generation")
+ }
+ if got := serial(t, fx); got != 1 {
+ t.Fatalf("serial = %d", got)
+ }
+}
+
+func TestBrowserInstanceRestartIsExactAndNeverWidens(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.addRoute(t, registry.Route{Host: "sleep.kass.test", App: "kass", Account: "bab", Kind: "instance", Status: "ready", Hibernate: true})
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ endpoint := fx.u("/apps/kass/instances/sleep.kass.test/restart")
+ status, _, _ := postGated(t, fx, c, endpoint, url.Values{"csrf": {csrf}, "confirmed": {"1"}})
+ if status != http.StatusSeeOther {
+ t.Fatalf("restart = %d", status)
+ }
+ if restartGenEtag(t, fx, "kass") != "" {
+ t.Fatal("instance restart widened into app generation")
+ }
+ log := scopeLog(t, fx, "kass")
+ if len(log.Requests) != 1 || log.Requests[0].Scope.Kind != release.RestartScopeHost || log.Requests[0].Scope.Value != "sleep.kass.test" {
+ t.Fatalf("scope log = %+v", log)
+ }
+ reg, _ := registry.Open(fx.regPath)
+ routes, _ := reg.List()
+ for _, rt := range routes {
+ if rt.Host == "sleep.kass.test" && !rt.Hibernate {
+ t.Fatal("restart request woke sleeping route")
+ }
+ }
+}
+
+func TestBrowserInstanceRestartRefusesUnaddressableHosts(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.addRoute(t, registry.Route{Host: "foreign.test", App: "kass", Account: "elsewhere", Kind: "instance", Status: "ready"})
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ for _, host := range []string{"missing.test", "foreign.test", "docs.kass.carlosapp.dev", "%20", "%09", "%20%20"} {
+ status, _, body := postGated(t, fx, c, fx.u("/apps/kass/instances/"+host+"/restart"), url.Values{"csrf": {csrf}, "confirmed": {"1"}})
+ if status != http.StatusNotFound || body != "404 page not found\n" {
+ t.Errorf("%s = %d %q", host, status, body)
+ }
+ }
+ if restartGenEtag(t, fx, "kass") != "" || len(scopeLog(t, fx, "kass").Requests) != 0 {
+ t.Fatal("refusal wrote restart state")
+ }
+}
+
+func TestBrowserRestartRequiresCSRF(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ status, _, _ := postForm(t, c, fx.u("/apps/kass/restart"), url.Values{"csrf": {"wrong"}})
+ if status != http.StatusForbidden {
+ t.Fatalf("csrf = %d", status)
+ }
+ status, _, _ = postForm(t, c, fx.u("/apps/no-such-app/restart"), url.Values{"csrf": {"wrong"}})
+ if status != http.StatusForbidden {
+ t.Fatalf("csrf before app membership = %d", status)
+ }
+}
diff --git a/internal/console/overview.go b/internal/console/overview.go
index 27d664f31..d5ba9e4f3 100644
--- a/internal/console/overview.go
+++ b/internal/console/overview.go
@@ -199,7 +199,9 @@ type InstanceRow struct {
// HealthWord is the Status enum + hibernate + reporting folded into one
// plain word — the column's whole job.
- HealthWord string
+ HealthWord string
+ Restartable bool
+ RestartURL string
}
// assembleInstanceRows joins the app's routes with the sweep's readings, in
@@ -221,13 +223,14 @@ func assembleInstanceRows(view *AppView, acct *Account, metrics []InstanceMetric
status = "ready" // registry contract: empty status means ready
}
rows = append(rows, InstanceRow{
- Host: rt.Host,
- Channel: rt.Channel,
- Version: rt.Version,
- Kind: rt.Kind,
- Status: status,
- Hibernate: rt.Hibernate,
- Metrics: byHost[rt.Host],
+ Host: rt.Host,
+ Channel: rt.Channel,
+ Version: rt.Version,
+ Kind: rt.Kind,
+ Status: status,
+ Hibernate: rt.Hibernate,
+ Metrics: byHost[rt.Host],
+ Restartable: rt.Restartable,
HealthWord: healthWord(rt.Kind, status, rt.Hibernate, byHost[rt.Host].Reporting(),
expectsReadings(rt.Provisioned, rt.Unit, rt.Backing)),
})
diff --git a/internal/console/server.go b/internal/console/server.go
index 0bcf3ebbd..1b91ff9ed 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -1117,6 +1117,8 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/account-review", s.handleAppAccountReview)
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/instances", s.handleAppInstances)
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/instances/{host}", s.handleAppInstance)
+ s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/restart", s.handleAppRestart)
+ s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/instances/{host}/restart", s.handleInstanceRestart)
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/logs", s.handleAppLogs)
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/logs/data", s.handleAppLogsData)
// The Errors tab and its two setting doors (errorsview.go): the tab is
@@ -1308,12 +1310,13 @@ type page struct {
InstanceListURL string
DeploymentTargets []deploymentTarget
- Title string
- Address string // signed-in address, "" when signed out
- CSRF string // the session token, double-submitted on the signout POST
- Error string
- Welcome bool // one-shot "account created" banner
- StatusCopy StatusCopy
+ Title string
+ Address string // signed-in address, "" when signed out
+ CSRF string // the session token, double-submitted on the signout POST
+ Error string
+ Welcome bool // one-shot "account created" banner
+ BrowserRestartCopy BrowserRestartCopy
+ StatusCopy StatusCopy
AccountReview AccountReview
MismatchCopy MismatchCopy
@@ -1461,7 +1464,8 @@ type page struct {
Flow FlowView
// App Instances: the one table — routes joined with their readings.
- InstanceRows []InstanceRow
+ InstanceRows []InstanceRow
+ CanRestartApp bool
// App Instances: the search/health-filter/cursor controls (Task 8) —
// InstancePage is the computed page (rows, counts, links, pager),
@@ -1632,6 +1636,7 @@ type appRow struct {
func (s *Server) render(w http.ResponseWriter, status int, name string, p page) {
p.StatusCopy = approvedStatusCopy
+ p.BrowserRestartCopy = approvedBrowserRestartCopy
p.MismatchCopy = approvedMismatchCopy
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
@@ -2870,8 +2875,19 @@ func (s *Server) handleAppInstances(w http.ResponseWriter, r *http.Request) {
p.AppMetrics = s.appResourceMetrics(r.Context(), p.Detail, acct)
q := parseInstanceQuery(r.URL.Query())
rows := assembleInstanceRows(p.Detail, acct, p.AppMetrics)
+ for _, row := range rows {
+ if row.Restartable {
+ p.CanRestartApp = true
+ break
+ }
+ }
pg := pageInstanceRows(rows, q, instancePageSize)
p.InstanceRows = pg.Rows
+ for i := range p.InstanceRows {
+ if p.InstanceRows[i].Restartable {
+ p.InstanceRows[i].RestartURL = p.Base + "/apps/" + url.PathEscape(p.AppName) + "/instances/" + url.PathEscape(p.InstanceRows[i].Host) + "/restart"
+ }
+ }
p.InstancePage = pg
p.InstanceQuery = q
p.InstanceChannels = instanceChannels(rows)
diff --git a/internal/console/templates/app_instance.html b/internal/console/templates/app_instance.html
index 11d699189..6fa8d7e0c 100644
--- a/internal/console/templates/app_instance.html
+++ b/internal/console/templates/app_instance.html
@@ -5,6 +5,11 @@
<p><a href="{{.InstanceListURL}}">Instances</a></p>
{{with .InstanceDetail}}
<h2 class="instance-address">{{.Row.Host}} <a class="instance-open" href="https://{{.Row.Host}}" aria-label="{{$.InstanceCopy.Open}} {{.Row.Host}}">{{icon "external-link"}}</a></h2>
+<p><a href="{{$.Nav}}/apps/{{$.AppName}}/logs?host={{.Row.Host}}">Logs</a></p>
+{{if .Row.Restartable}}<form method="post" action="{{$.Base}}/apps/{{$.AppName}}/instances/{{.Row.Host}}/restart">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <button type="submit" class="btn-sm">{{$.BrowserRestartCopy.Button}}</button>
+</form>{{end}}
<dl class="instance-facts">
<dt>Health</dt><dd>{{.Row.HealthWord}}</dd>
<dt>{{$.InstanceCopy.LastReading}}</dt><dd>{{if .Row.Metrics.ObservedAt.IsZero}}—{{else}}<time title="{{fmtTime .Row.Metrics.ObservedAt}}">{{ago .Row.Metrics.ObservedAt}}</time>{{end}}</dd>
diff --git a/internal/console/templates/app_instances.html b/internal/console/templates/app_instances.html
index f212f47d7..184e7e952 100644
--- a/internal/console/templates/app_instances.html
+++ b/internal/console/templates/app_instances.html
@@ -4,6 +4,10 @@
{{template "appnav" .}}
<h2>Instances</h2>
+{{if .CanRestartApp}}<form method="post" action="{{.Base}}/apps/{{.AppName}}/restart" class="inline">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <button type="submit" class="btn-sm">{{.BrowserRestartCopy.Button}}</button>
+</form>{{end}}
{{if or .InstanceRows .InstanceQuery.Q .InstanceQuery.Health .InstanceQuery.Channel}}
<form method="get" class="listctl">
<input type="search" name="q" value="{{.InstanceQuery.Q}}" placeholder="Filter addresses" aria-label="Filter addresses">
diff --git a/internal/console/view.go b/internal/console/view.go
index 7b25d9f20..b3ea2c477 100644
--- a/internal/console/view.go
+++ b/internal/console/view.go
@@ -319,6 +319,7 @@ type RouteView struct {
Host, Kind, Status, Version, Channel string
Hibernate bool
Platform bool
+ Restartable bool
// Account is what the ROUTE says its account is (release.RouteAccount:
// the stamped account, or the ops account when unstamped). Routes are
// grouped by app NAME here, and app names are unique per account rather
@@ -569,6 +570,7 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct strin
Environment: rt.Environment,
Caps: capsFor,
Unit: rt.Unit, Backing: rt.Backing, Provisioned: rt.Provisioned,
+ Restartable: release.Adoptable(rt),
})
domainsByApp[rt.App] = append(domainsByApp[rt.App], domainView(rt, dns))
}