1
0
mirror of https://github.com/raseels-repos/golang-saas-starter-kit.git synced 2025-06-25 00:46:51 +02:00

Scale db middleware

Ensure the database is active and has not been auto paused by RDS.
Resume database for signup and login pages.
This commit is contained in:
Lee Brown
2019-10-26 16:52:38 -08:00
parent 2b93f2e3b4
commit 28cf4924ff
7 changed files with 271 additions and 13 deletions

View File

@ -104,7 +104,7 @@ func main() {
TemplateDir string `default:"./templates" envconfig:"TEMPLATE_DIR"` TemplateDir string `default:"./templates" envconfig:"TEMPLATE_DIR"`
DebugHost string `default:"0.0.0.0:4000" envconfig:"DEBUG_HOST"` DebugHost string `default:"0.0.0.0:4000" envconfig:"DEBUG_HOST"`
ShutdownTimeout time.Duration `default:"5s" envconfig:"SHUTDOWN_TIMEOUT"` ShutdownTimeout time.Duration `default:"5s" envconfig:"SHUTDOWN_TIMEOUT"`
ScaleToZero time.Duration `envconfig:"SCALE_TO_ZERO"` ScaleToZero time.Duration `envconfig:"SCALE_TO_ZERO"`
} }
Project struct { Project struct {
Name string `default:"" envconfig:"PROJECT_NAME"` Name string `default:"" envconfig:"PROJECT_NAME"`

View File

@ -25,6 +25,7 @@ import (
"geeks-accelerator/oss/saas-starter-kit/internal/user_account/invite" "geeks-accelerator/oss/saas-starter-kit/internal/user_account/invite"
"geeks-accelerator/oss/saas-starter-kit/internal/user_auth" "geeks-accelerator/oss/saas-starter-kit/internal/user_auth"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/ikeikeikeike/go-sitemap-generator/v2/stm" "github.com/ikeikeikeike/go-sitemap-generator/v2/stm"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"gopkg.in/DataDog/dd-trace-go.v1/contrib/go-redis/redis" "gopkg.in/DataDog/dd-trace-go.v1/contrib/go-redis/redis"
@ -40,6 +41,7 @@ type AppContext struct {
Log *log.Logger Log *log.Logger
Env webcontext.Env Env webcontext.Env
MasterDB *sqlx.DB MasterDB *sqlx.DB
MasterDbHost string
Redis *redis.Client Redis *redis.Client
UserRepo *user.Repository UserRepo *user.Repository
UserAccountRepo *user_account.Repository UserAccountRepo *user_account.Repository
@ -57,6 +59,7 @@ type AppContext struct {
ProjectRoute project_route.ProjectRoute ProjectRoute project_route.ProjectRoute
PreAppMiddleware []web.Middleware PreAppMiddleware []web.Middleware
PostAppMiddleware []web.Middleware PostAppMiddleware []web.Middleware
AwsSession *session.Session
} }
// API returns a handler for a set of routes. // API returns a handler for a set of routes.
@ -81,6 +84,24 @@ func APP(shutdown chan os.Signal, appCtx *AppContext) http.Handler {
// Construct the web.App which holds all routes as well as common Middleware. // Construct the web.App which holds all routes as well as common Middleware.
app := web.NewApp(shutdown, appCtx.Log, appCtx.Env, middlewares...) app := web.NewApp(shutdown, appCtx.Log, appCtx.Env, middlewares...)
// Register serverless endpoint. This route is not authenticated.
serverless := Serverless{
MasterDB: appCtx.MasterDB,
MasterDbHost: appCtx.MasterDbHost,
AwsSession: appCtx.AwsSession,
Renderer: appCtx.Renderer,
}
app.Handle("GET", "/serverless/pending", serverless.Pending)
// waitDbMid ensures the database is active before allowing the user to access the requested URI.
waitDbMid := mid.WaitForDbResumed(mid.WaitForDbResumedConfig{
// Database handle to be used to ensure its online.
DB: appCtx.MasterDB,
// WaitHandler defines the handler to render for the user to when the database is being resumed.
WaitHandler: serverless.Pending,
})
// Build a sitemap. // Build a sitemap.
sm := stm.NewSitemap(1) sm := stm.NewSitemap(1)
sm.SetVerbose(false) sm.SetVerbose(false)
@ -146,7 +167,7 @@ func APP(shutdown chan os.Signal, appCtx *AppContext) http.Handler {
Renderer: appCtx.Renderer, Renderer: appCtx.Renderer,
} }
app.Handle("POST", "/user/login", u.Login) app.Handle("POST", "/user/login", u.Login)
app.Handle("GET", "/user/login", u.Login) app.Handle("GET", "/user/login", u.Login, waitDbMid)
app.Handle("GET", "/user/logout", u.Logout) app.Handle("GET", "/user/logout", u.Logout)
app.Handle("POST", "/user/reset-password/:hash", u.ResetConfirm) app.Handle("POST", "/user/reset-password/:hash", u.ResetConfirm)
app.Handle("GET", "/user/reset-password/:hash", u.ResetConfirm) app.Handle("GET", "/user/reset-password/:hash", u.ResetConfirm)
@ -188,7 +209,7 @@ func APP(shutdown chan os.Signal, appCtx *AppContext) http.Handler {
} }
// This route is not authenticated // This route is not authenticated
app.Handle("POST", "/signup", s.Step1) app.Handle("POST", "/signup", s.Step1)
app.Handle("GET", "/signup", s.Step1) app.Handle("GET", "/signup", s.Step1, waitDbMid)
// Register example endpoints. // Register example endpoints.
ex := Examples{ ex := Examples{
@ -224,6 +245,15 @@ func APP(shutdown chan os.Signal, appCtx *AppContext) http.Handler {
app.Handle("GET", "/robots.txt", r.RobotTxt) app.Handle("GET", "/robots.txt", r.RobotTxt)
app.Handle("GET", "/sitemap.xml", r.SitemapXml) app.Handle("GET", "/sitemap.xml", r.SitemapXml)
// Register health check endpoint. This route is not authenticated.
check := Check{
MasterDB: appCtx.MasterDB,
Redis: appCtx.Redis,
}
app.Handle("GET", "/v1/health", check.Health)
app.Handle("GET", "/ping", check.Ping)
// Add sitemap entries for Root. // Add sitemap entries for Root.
smLocAddModified(stm.URL{{"loc", "/"}, {"changefreq", "weekly"}, {"mobile", true}, {"priority", 0.9}}, "site-index.gohtml") smLocAddModified(stm.URL{{"loc", "/"}, {"changefreq", "weekly"}, {"mobile", true}, {"priority", 0.9}}, "site-index.gohtml")
smLocAddModified(stm.URL{{"loc", "/pricing"}, {"changefreq", "monthly"}, {"mobile", true}, {"priority", 0.8}}, "site-pricing.gohtml") smLocAddModified(stm.URL{{"loc", "/pricing"}, {"changefreq", "monthly"}, {"mobile", true}, {"priority", 0.8}}, "site-pricing.gohtml")
@ -232,14 +262,6 @@ func APP(shutdown chan os.Signal, appCtx *AppContext) http.Handler {
smLocAddModified(stm.URL{{"loc", "/legal/privacy"}, {"changefreq", "monthly"}, {"mobile", true}, {"priority", 0.5}}, "legal-privacy.gohtml") smLocAddModified(stm.URL{{"loc", "/legal/privacy"}, {"changefreq", "monthly"}, {"mobile", true}, {"priority", 0.5}}, "legal-privacy.gohtml")
smLocAddModified(stm.URL{{"loc", "/legal/terms"}, {"changefreq", "monthly"}, {"mobile", true}, {"priority", 0.5}}, "legal-terms.gohtml") smLocAddModified(stm.URL{{"loc", "/legal/terms"}, {"changefreq", "monthly"}, {"mobile", true}, {"priority", 0.5}}, "legal-terms.gohtml")
// Register health check endpoint. This route is not authenticated.
check := Check{
MasterDB: appCtx.MasterDB,
Redis: appCtx.Redis,
}
app.Handle("GET", "/v1/health", check.Health)
app.Handle("GET", "/ping", check.Ping)
// Handle static files/pages. Render a custom 404 page when file not found. // Handle static files/pages. Render a custom 404 page when file not found.
static := func(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { static := func(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
err := web.StaticHandler(ctx, w, r, params, appCtx.StaticDir, "") err := web.StaticHandler(ctx, w, r, params, appCtx.StaticDir, "")

View File

@ -0,0 +1,104 @@
package handlers
import (
"context"
"net/http"
"net/url"
"geeks-accelerator/oss/saas-starter-kit/internal/mid"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/rds"
"github.com/jmoiron/sqlx"
"github.com/pkg/errors"
)
// Serverless provides support for ensuring serverless resources are available for the user. .
type Serverless struct {
Renderer web.Renderer
MasterDB *sqlx.DB
MasterDbHost string
AwsSession *session.Session
}
// WaitDb validates the the database is resumed and ready to accept requests.
func (h *Serverless) Pending(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
var redirectUri string
if v, ok := ctx.Value(mid.ServerlessKey).(error); ok && v != nil {
redirectUri = r.RequestURI
} else {
redirectUri = r.URL.Query().Get("redirect")
}
if redirectUri == "" {
redirectUri = "/"
}
f := func() (bool, error) {
svc := rds.New(h.AwsSession)
res, err := svc.DescribeDBClusters(&rds.DescribeDBClustersInput{})
if err != nil {
return false, errors.WithMessage(err, "Failed to list AWS db clusters.")
}
var targetCluster *rds.DBCluster
for _, c := range res.DBClusters {
if c.Endpoint == nil || *c.Endpoint != h.MasterDbHost {
continue
}
targetCluster = c
}
if targetCluster == nil {
return false, errors.New("Failed to find database cluster.")
} else if targetCluster.ScalingConfigurationInfo == nil || targetCluster.ScalingConfigurationInfo.MinCapacity == nil {
return false, errors.New("Database cluster has now scaling configuration.")
}
if targetCluster.Capacity != nil && *targetCluster.Capacity > 0 {
return true, nil
}
_, err = svc.ModifyCurrentDBClusterCapacity(&rds.ModifyCurrentDBClusterCapacityInput{
DBClusterIdentifier: targetCluster.DBClusterIdentifier,
Capacity: targetCluster.ScalingConfigurationInfo.MinCapacity,
SecondsBeforeTimeout: aws.Int64(10),
TimeoutAction: aws.String("ForceApplyCapacityChange"),
})
if err != nil {
return false, err
}
return false, nil
}
end, err := f()
if err != nil {
return web.RenderError(ctx, w, r, err, h.Renderer, TmplLayoutBase, TmplContentErrorGeneric, web.MIMETextHTMLCharsetUTF8)
}
if web.RequestIsJson(r) {
data := map[string]interface{}{
"redirectUri": redirectUri,
"statusCode": http.StatusServiceUnavailable,
}
if end {
data["statusCode"] = http.StatusOK
}
return web.RespondJson(ctx, w, data, http.StatusOK)
}
if end {
// Redirect the user to their requested page.
return web.Redirect(ctx, w, r, redirectUri, http.StatusFound)
}
data := map[string]interface{}{
"statusUrl": "/serverless/pending?redirect=" + url.QueryEscape(redirectUri),
}
return h.Renderer.Render(ctx, w, r, TmplLayoutBase, "serverless-db.gohtml", web.MIMETextHTMLCharsetUTF8, http.StatusOK, data)
}

View File

@ -107,7 +107,7 @@ func main() {
SessionName string `default:"" envconfig:"SESSION_NAME"` SessionName string `default:"" envconfig:"SESSION_NAME"`
DebugHost string `default:"0.0.0.0:4000" envconfig:"DEBUG_HOST"` DebugHost string `default:"0.0.0.0:4000" envconfig:"DEBUG_HOST"`
ShutdownTimeout time.Duration `default:"5s" envconfig:"SHUTDOWN_TIMEOUT"` ShutdownTimeout time.Duration `default:"5s" envconfig:"SHUTDOWN_TIMEOUT"`
ScaleToZero time.Duration `envconfig:"SCALE_TO_ZERO"` ScaleToZero time.Duration `envconfig:"SCALE_TO_ZERO"`
} }
Project struct { Project struct {
Name string `default:"" envconfig:"PROJECT_NAME"` Name string `default:"" envconfig:"PROJECT_NAME"`
@ -456,6 +456,7 @@ func main() {
Log: log, Log: log,
Env: cfg.Env, Env: cfg.Env,
MasterDB: masterDb, MasterDB: masterDb,
MasterDbHost: cfg.DB.Host,
Redis: redisClient, Redis: redisClient,
TemplateDir: cfg.Service.TemplateDir, TemplateDir: cfg.Service.TemplateDir,
StaticDir: cfg.Service.StaticFiles.Dir, StaticDir: cfg.Service.StaticFiles.Dir,
@ -470,6 +471,7 @@ func main() {
InviteRepo: inviteRepo, InviteRepo: inviteRepo,
ProjectRepo: prjRepo, ProjectRepo: prjRepo,
Authenticator: authenticator, Authenticator: authenticator,
AwsSession: awsSession,
} }
// ========================================================================= // =========================================================================

View File

@ -0,0 +1,51 @@
{{define "title"}}Service Scaling{{end}}
{{define "description"}}Service is scaling.{{end}}
{{define "style"}}
{{end}}
{{ define "partials/app-wrapper" }}
<div class="container" id="page-content">
<div class="row justify-content-center">
<div class="col-xl-10 col-lg-12 col-md-9">
<div class="card o-hidden border-0 shadow-lg my-5">
<div class="card-body p-0">
<!-- Nested Row within Card Body -->
<div class="row">
<div class="col-lg-6 d-none d-lg-block bg-login-image"></div>
<div class="col-lg-6">
<div class="p-5">
{{ template "app-flashes" . }}
<div class="text-center" style="margin-bottom: 250px; ">
<h1 class="h4 text-gray-900 mb-4">The service is scaling up!</h1>
<p>Please wait a moment, you will be redirected to your request page when operation complete.</p>
<div class="spinner-border" role="status">
<span class="sr-only">Scaling...</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{{end}}
{{define "js"}}
<script>
$(document).ready(function() {
$(document).find('body').addClass('bg-gradient-primary');
$.ajax({
contentType: "application/json",
url: '{{ $.statusUrl }}',
dataType: "json"
}).done(function(data) {
if (data.statusCode == 200) {
window.location = data.redirectUri;
}
});
});
</script>
{{end}}

View File

@ -0,0 +1,79 @@
package mid
import (
"context"
"net/http"
"time"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web"
"github.com/jmoiron/sqlx"
"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer"
)
// ctxServerlessKey represents the type of value for the context key.
type ctxServerlessKey int
// Key is used to store/retrieve a Serverless value from a context.Context.
const ServerlessKey ctxServerlessKey = 1
type (
// WaitForDbResumedConfig defines the config for WaitForDbResumed middleware.
WaitForDbResumedConfig struct {
RedirectConfig
// Database handle to be used to ensure its online.
DB *sqlx.DB
// WaitHandler defines the handler to render for the user to when the database is being resumed.
WaitHandler web.Handler
}
)
// WaitForDbResumed returns an middleware with for ensuring an serverless database is resumed.
func WaitForDbResumed(config WaitForDbResumedConfig) web.Middleware {
if config.Skipper == nil {
config.Skipper = DefaultSkipper
}
if config.Code == 0 {
config.Code = DefaultRedirectConfig.Code
}
verifyDb := func() error {
// When the database is paused, Postgres will return the error, "Canceling statement due to user request"
ctx, cancel := context.WithTimeout(context.Background(), time.Second*1)
defer cancel()
_, err := config.DB.ExecContext(ctx, "SELECT NULL")
if err != nil {
return err
}
return nil
}
// This is the actual middleware function to be executed.
f := func(after web.Handler) web.Handler {
h := func(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
span, ctx := tracer.StartSpanFromContext(ctx, "internal.mid.serverless")
defer span.Finish()
if config.Skipper(ctx, w, r, params) {
return after(ctx, w, r, params)
}
if err := verifyDb(); err != nil {
ctx = context.WithValue(ctx, ServerlessKey, err)
return config.WaitHandler(ctx, w, r, params)
}
return after(ctx, w, r, params)
}
return h
}
return f
}

View File

@ -413,7 +413,7 @@ func (r *TemplateRenderer) Render(ctx context.Context, w http.ResponseWriter, re
StackTrace() errors.StackTrace StackTrace() errors.StackTrace
} }
if st, ok := err.(stackTracer); !ok ||st == nil || st.StackTrace() == nil { if st, ok := err.(stackTracer); !ok || st == nil || st.StackTrace() == nil {
err = errors.WithStack(err) err = errors.WithStack(err)
} }