You've already forked golang-saas-starter-kit
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:
@ -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, "")
|
||||||
|
104
cmd/web-app/handlers/serverless.go
Normal file
104
cmd/web-app/handlers/serverless.go
Normal 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)
|
||||||
|
}
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
51
cmd/web-app/templates/content/serverless-db.gohtml
Normal file
51
cmd/web-app/templates/content/serverless-db.gohtml
Normal 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}}
|
79
internal/mid/serverless.go
Normal file
79
internal/mid/serverless.go
Normal 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
|
||||||
|
}
|
Reference in New Issue
Block a user