[feature] Use maintenance router to serve 503 while server is starting/migrating (#3705)

* [feature] Use maintenance router to serve 503 while server is starting/migrating

* love you linter, kissies
This commit is contained in:
tobi 2025-01-29 16:57:04 +01:00 committed by GitHub
parent 61141ac232
commit d16e4fa34d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 271 additions and 26 deletions

View file

@ -69,6 +69,36 @@
"go.uber.org/automaxprocs/maxprocs" "go.uber.org/automaxprocs/maxprocs"
) )
// Maintenance starts and creates a GoToSocial server
// in maintenance mode (returns 503 for most requests).
var Maintenance action.GTSAction = func(ctx context.Context) error {
route, err := router.New(ctx)
if err != nil {
return fmt.Errorf("error creating maintenance router: %w", err)
}
// Route maintenance handlers.
maintenance := web.NewMaintenance()
maintenance.Route(route)
// Start the maintenance router.
if err := route.Start(); err != nil {
return fmt.Errorf("error starting maintenance router: %w", err)
}
// Catch shutdown signals from the OS.
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
sig := <-sigs // block until signal received
log.Infof(ctx, "received signal %s, shutting down", sig)
if err := route.Stop(); err != nil {
log.Errorf(ctx, "error stopping router: %v", err)
}
return nil
}
// Start creates and starts a gotosocial server // Start creates and starts a gotosocial server
var Start action.GTSAction = func(ctx context.Context) error { var Start action.GTSAction = func(ctx context.Context) error {
// Set GOMAXPROCS / GOMEMLIMIT // Set GOMAXPROCS / GOMEMLIMIT
@ -148,6 +178,23 @@
log.Info(ctx, "done! exiting...") log.Info(ctx, "done! exiting...")
}() }()
// Create maintenance router.
var err error
route, err = router.New(ctx)
if err != nil {
return fmt.Errorf("error creating maintenance router: %w", err)
}
// Route maintenance handlers.
maintenance := web.NewMaintenance()
maintenance.Route(route)
// Start the maintenance router to handle reqs
// while the instance is starting up / migrating.
if err := route.Start(); err != nil {
return fmt.Errorf("error starting maintenance router: %w", err)
}
// Initialize tracing (noop if not enabled). // Initialize tracing (noop if not enabled).
if err := tracing.Initialize(); err != nil { if err := tracing.Initialize(); err != nil {
return fmt.Errorf("error initializing tracing: %w", err) return fmt.Errorf("error initializing tracing: %w", err)
@ -359,9 +406,15 @@ func(context.Context, time.Time) {
HTTP router initialization HTTP router initialization
*/ */
// Close down the maintenance router.
if err := route.Stop(); err != nil {
return fmt.Errorf("error stopping maintenance router: %w", err)
}
// Instantiate the main router.
route, err = router.New(ctx) route, err = router.New(ctx)
if err != nil { if err != nil {
return fmt.Errorf("error creating router: %s", err) return fmt.Errorf("error creating main router: %s", err)
} }
// Start preparing middleware stack. // Start preparing middleware stack.

View file

@ -41,5 +41,19 @@ func serverCommands() *cobra.Command {
} }
config.AddServerFlags(serverStartCmd) config.AddServerFlags(serverStartCmd)
serverCmd.AddCommand(serverStartCmd) serverCmd.AddCommand(serverStartCmd)
serverMaintenanceCmd := &cobra.Command{
Use: "maintenance",
Short: "start the gotosocial server in maintenance mode (returns 503 for almost all requests)",
PreRunE: func(cmd *cobra.Command, args []string) error {
return preRun(preRunArgs{cmd: cmd})
},
RunE: func(cmd *cobra.Command, args []string) error {
return run(cmd.Context(), server.Maintenance)
},
}
config.AddServerFlags(serverMaintenanceCmd)
serverCmd.AddCommand(serverMaintenanceCmd)
return serverCmd return serverCmd
} }

View file

@ -21,10 +21,13 @@
"fmt" "fmt"
"net/http" "net/http"
"path" "path"
"path/filepath"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/router"
) )
type fileSystem struct { type fileSystem struct {
@ -53,7 +56,11 @@ func (fs fileSystem) Open(path string) (http.File, error) {
// getAssetFileInfo tries to fetch the ETag for the given filePath from the module's // getAssetFileInfo tries to fetch the ETag for the given filePath from the module's
// assetsETagCache. If it can't be found there, it uses the provided http.FileSystem // assetsETagCache. If it can't be found there, it uses the provided http.FileSystem
// to generate a new ETag to go in the cache, which it then returns. // to generate a new ETag to go in the cache, which it then returns.
func (m *Module) getAssetETag(filePath string, fs http.FileSystem) (string, error) { func getAssetETag(
wet withETagCache,
filePath string,
fs http.FileSystem,
) (string, error) {
file, err := fs.Open(filePath) file, err := fs.Open(filePath)
if err != nil { if err != nil {
return "", fmt.Errorf("error opening %s: %s", filePath, err) return "", fmt.Errorf("error opening %s: %s", filePath, err)
@ -67,7 +74,8 @@ func (m *Module) getAssetETag(filePath string, fs http.FileSystem) (string, erro
fileLastModified := fileInfo.ModTime() fileLastModified := fileInfo.ModTime()
if cachedETag, ok := m.eTagCache.Get(filePath); ok && !fileLastModified.After(cachedETag.lastModified) { cache := wet.ETagCache()
if cachedETag, ok := cache.Get(filePath); ok && !fileLastModified.After(cachedETag.lastModified) {
// only return our cached etag if the file wasn't // only return our cached etag if the file wasn't
// modified since last time, otherwise generate a // modified since last time, otherwise generate a
// new one; eat fresh! // new one; eat fresh!
@ -80,7 +88,7 @@ func (m *Module) getAssetETag(filePath string, fs http.FileSystem) (string, erro
} }
// put new entry in cache before we return // put new entry in cache before we return
m.eTagCache.Set(filePath, eTagCacheEntry{ cache.Set(filePath, eTagCacheEntry{
eTag: eTag, eTag: eTag,
lastModified: fileLastModified, lastModified: fileLastModified,
}) })
@ -99,7 +107,10 @@ func (m *Module) getAssetETag(filePath string, fs http.FileSystem) (string, erro
// //
// todo: move this middleware out of the 'web' package and into the 'middleware' // todo: move this middleware out of the 'web' package and into the 'middleware'
// package along with the other middlewares // package along with the other middlewares
func (m *Module) assetsCacheControlMiddleware(fs http.FileSystem) gin.HandlerFunc { func assetsCacheControlMiddleware(
wet withETagCache,
fs http.FileSystem,
) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
// Acquire context from gin request. // Acquire context from gin request.
ctx := c.Request.Context() ctx := c.Request.Context()
@ -118,7 +129,7 @@ func (m *Module) assetsCacheControlMiddleware(fs http.FileSystem) gin.HandlerFun
assetFilePath := strings.TrimPrefix(path.Clean(upath), assetsPathPrefix) assetFilePath := strings.TrimPrefix(path.Clean(upath), assetsPathPrefix)
// either fetch etag from ttlcache or generate it // either fetch etag from ttlcache or generate it
eTag, err := m.getAssetETag(assetFilePath, fs) eTag, err := getAssetETag(wet, assetFilePath, fs)
if err != nil { if err != nil {
log.Errorf(ctx, "error getting ETag for %s: %s", assetFilePath, err) log.Errorf(ctx, "error getting ETag for %s: %s", assetFilePath, err)
return return
@ -137,3 +148,23 @@ func (m *Module) assetsCacheControlMiddleware(fs http.FileSystem) gin.HandlerFun
// else let the rest of the request be processed normally // else let the rest of the request be processed normally
} }
} }
// routeAssets attaches *just* the
// assets filesystem to the given router.
func routeAssets(
wet withETagCache,
r *router.Router,
mi ...gin.HandlerFunc,
) {
// Group all static files from assets dir at /assets,
// so that they can use the same cache control middleware.
webAssetsAbsFilePath, err := filepath.Abs(config.GetWebAssetBaseDir())
if err != nil {
log.Panicf(nil, "error getting absolute path of assets dir: %s", err)
}
fs := fileSystem{http.Dir(webAssetsAbsFilePath)}
assetsGroup := r.AttachGroup(assetsPathPrefix)
assetsGroup.Use(assetsCacheControlMiddleware(wet, fs))
assetsGroup.Use(mi...)
assetsGroup.StaticFS("/", fs)
}

View file

@ -29,6 +29,10 @@
"codeberg.org/gruf/go-cache/v3" "codeberg.org/gruf/go-cache/v3"
) )
type withETagCache interface {
ETagCache() cache.Cache[string, eTagCacheEntry]
}
func newETagCache() cache.TTLCache[string, eTagCacheEntry] { func newETagCache() cache.TTLCache[string, eTagCacheEntry] {
eTagCache := cache.NewTTL[string, eTagCacheEntry](0, 1000, 0) eTagCache := cache.NewTTL[string, eTagCacheEntry](0, 1000, 0)
eTagCache.SetTTL(time.Hour, false) eTagCache.SetTTL(time.Hour, false)

View file

@ -0,0 +1,70 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package web
import (
"net/http"
"time"
"codeberg.org/gruf/go-cache/v3"
"github.com/gin-gonic/gin"
"github.com/superseriousbusiness/gotosocial/internal/api/health"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/router"
)
type MaintenanceModule struct {
eTagCache cache.Cache[string, eTagCacheEntry]
}
// NewMaintenance returns a module that routes only
// static assets, and returns a code 503 maintenance
// message template to all other requests.
func NewMaintenance() *MaintenanceModule {
return &MaintenanceModule{
eTagCache: newETagCache(),
}
}
// ETagCache implements withETagCache.
func (m *MaintenanceModule) ETagCache() cache.Cache[string, eTagCacheEntry] {
return m.eTagCache
}
func (m *MaintenanceModule) Route(r *router.Router, mi ...gin.HandlerFunc) {
// Route static assets.
routeAssets(m, r, mi...)
// Serve OK in response to live
// requests, but not ready requests.
liveHandler := func(c *gin.Context) {
c.Status(http.StatusOK)
}
r.AttachHandler(http.MethodGet, health.LivePath, liveHandler)
r.AttachHandler(http.MethodHead, health.LivePath, liveHandler)
// For everything else, serve maintenance template.
obj := map[string]string{"host": config.GetHost()}
r.AttachNoRouteHandler(func(c *gin.Context) {
retryAfter := time.Now().Add(120 * time.Second).UTC()
c.Writer.Header().Add("Retry-After", "120")
c.Writer.Header().Add("Retry-After", retryAfter.Format(http.TimeFormat))
c.Header("Cache-Control", "no-store")
c.HTML(http.StatusServiceUnavailable, "maintenance.tmpl", obj)
})
}

View file

@ -21,14 +21,11 @@
"context" "context"
"net/http" "net/http"
"net/url" "net/url"
"path/filepath"
"codeberg.org/gruf/go-cache/v3" "codeberg.org/gruf/go-cache/v3"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/middleware" "github.com/superseriousbusiness/gotosocial/internal/middleware"
"github.com/superseriousbusiness/gotosocial/internal/processing" "github.com/superseriousbusiness/gotosocial/internal/processing"
"github.com/superseriousbusiness/gotosocial/internal/router" "github.com/superseriousbusiness/gotosocial/internal/router"
@ -87,22 +84,22 @@ func New(db db.DB, processor *processing.Processor) *Module {
} }
} }
func (m *Module) Route(r *router.Router, mi ...gin.HandlerFunc) { // ETagCache implements withETagCache.
// Group all static files from assets dir at /assets, func (m *Module) ETagCache() cache.Cache[string, eTagCacheEntry] {
// so that they can use the same cache control middleware. return m.eTagCache
webAssetsAbsFilePath, err := filepath.Abs(config.GetWebAssetBaseDir()) }
if err != nil {
log.Panicf(nil, "error getting absolute path of assets dir: %s", err)
}
fs := fileSystem{http.Dir(webAssetsAbsFilePath)}
assetsGroup := r.AttachGroup(assetsPathPrefix)
assetsGroup.Use(m.assetsCacheControlMiddleware(fs))
assetsGroup.Use(mi...)
assetsGroup.StaticFS("/", fs)
// handlers that serve profiles and statuses should use the SignatureCheck // Route attaches the assets filesystem and profile,
// middleware, so that requests with content-type application/activity+json // status, and other web handlers to the router.
// can still be served func (m *Module) Route(r *router.Router, mi ...gin.HandlerFunc) {
// Route static assets.
routeAssets(m, r, mi...)
// Route all other endpoints + handlers.
//
// Handlers that serve profiles and statuses should use
// the SignatureCheck middleware, so that requests with
// content-type application/activity+json can be served
profileGroup := r.AttachGroup(profileGroupPath) profileGroup := r.AttachGroup(profileGroupPath)
profileGroup.Use(mi...) profileGroup.Use(mi...)
profileGroup.Use(middleware.SignatureCheck(m.isURIBlocked), middleware.CacheControl(middleware.CacheControlConfig{ profileGroup.Use(middleware.SignatureCheck(m.isURIBlocked), middleware.CacheControl(middleware.CacheControlConfig{
@ -111,7 +108,7 @@ func (m *Module) Route(r *router.Router, mi ...gin.HandlerFunc) {
profileGroup.Handle(http.MethodGet, "", m.profileGETHandler) // use empty path here since it's the base of the group profileGroup.Handle(http.MethodGet, "", m.profileGETHandler) // use empty path here since it's the base of the group
profileGroup.Handle(http.MethodGet, statusPath, m.threadGETHandler) profileGroup.Handle(http.MethodGet, statusPath, m.threadGETHandler)
// Attach individual web handlers which require no specific middlewares // Individual web handlers requiring no specific middlewares.
r.AttachHandler(http.MethodGet, "/", m.indexHandler) // front-page r.AttachHandler(http.MethodGet, "/", m.indexHandler) // front-page
r.AttachHandler(http.MethodGet, settingsPathPrefix, m.SettingsPanelHandler) r.AttachHandler(http.MethodGet, settingsPathPrefix, m.SettingsPanelHandler)
r.AttachHandler(http.MethodGet, settingsPanelGlob, m.SettingsPanelHandler) r.AttachHandler(http.MethodGet, settingsPanelGlob, m.SettingsPanelHandler)
@ -128,7 +125,7 @@ func (m *Module) Route(r *router.Router, mi ...gin.HandlerFunc) {
r.AttachHandler(http.MethodGet, signupPath, m.signupGETHandler) r.AttachHandler(http.MethodGet, signupPath, m.signupGETHandler)
r.AttachHandler(http.MethodPost, signupPath, m.signupPOSTHandler) r.AttachHandler(http.MethodPost, signupPath, m.signupPOSTHandler)
// Attach redirects from old endpoints to current ones for backwards compatibility // Redirects from old endpoints to for back compat.
r.AttachHandler(http.MethodGet, "/auth/edit", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, userPanelPath) }) r.AttachHandler(http.MethodGet, "/auth/edit", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, userPanelPath) })
r.AttachHandler(http.MethodGet, "/user", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, userPanelPath) }) r.AttachHandler(http.MethodGet, "/user", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, userPanelPath) })
r.AttachHandler(http.MethodGet, "/admin", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, adminPanelPath) }) r.AttachHandler(http.MethodGet, "/admin", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, adminPanelPath) })

View file

@ -0,0 +1,76 @@
{{- /*
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ -}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<link rel="icon" href="/assets/logo.webp" type="image/webp">
<link rel="apple-touch-icon" href="/assets/logo.webp" type="image/webp">
<link rel="apple-touch-startup-image" href="/assets/logo.webp" type="image/webp">
<link rel="preload" href="/assets/dist/_colors.css" as="style">
<link rel="preload" href="/assets/dist/base.css" as="style">
<link rel="preload" href="/assets/dist/page.css" as="style">
<link rel="stylesheet" href="/assets/dist/_colors.css">
<link rel="stylesheet" href="/assets/dist/base.css">
<link rel="stylesheet" href="/assets/dist/page.css">
<title>{{- .host -}}</title>
</head>
<body>
<div class="page">
<header class="page-header">
<a aria-label="{{- .host -}}. Go to instance homepage" href="/" class="nounderline">
<picture>
<img
src="/assets/logo.webp"
alt="A cartoon sloth smiling happily."
title="A cartoon sloth smiling happily."
/>
</picture>
<h1>{{- .host -}}</h1>
</a>
</header>
<div class="page-content">
<p>This GoToSocial instance is currently down for maintenance, starting up, or running database migrations. Please wait.</p>
<p>If you are the admin of this instance, check your GoToSocial logs for more details, and make sure to <strong>not interrupt any running database migrations</strong>!</p>
</div>
<footer class="page-footer">
<nav>
<ul class="nodot">
<li id="version">
<a
href="https://github.com/superseriousbusiness/gotosocial"
class="nounderline"
rel="nofollow noreferrer noopener"
target="_blank"
>
<span aria-hidden="true">🦥</span>
Source - GoToSocial
<span aria-hidden="true">🦥</span>
</a>
</li>
</ul>
</nav>
</footer>
</div>
</body>
</html>