Commit 28cf4924 authored by Lee Brown's avatar Lee Brown

Scale db middleware

Ensure the database is active and has not been auto paused by RDS.
Resume database for signup and login pages.
parent 2b93f2e3
......@@ -104,7 +104,7 @@ func main() {
TemplateDir string `default:"./templates" envconfig:"TEMPLATE_DIR"`
DebugHost string `default:"0.0.0.0:4000" envconfig:"DEBUG_HOST"`
ShutdownTimeout time.Duration `default:"5s" envconfig:"SHUTDOWN_TIMEOUT"`
ScaleToZero time.Duration `envconfig:"SCALE_TO_ZERO"`
ScaleToZero time.Duration `envconfig:"SCALE_TO_ZERO"`
}
Project struct {
Name string `default:"" envconfig:"PROJECT_NAME"`
......
......@@ -25,6 +25,7 @@ import (
"geeks-accelerator/oss/saas-starter-kit/internal/user_account/invite"
"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/jmoiron/sqlx"
"gopkg.in/DataDog/dd-trace-go.v1/contrib/go-redis/redis"
......@@ -40,6 +41,7 @@ type AppContext struct {
Log *log.Logger
Env webcontext.Env
MasterDB *sqlx.DB
MasterDbHost string
Redis *redis.Client
UserRepo *user.Repository
UserAccountRepo *user_account.Repository
......@@ -57,6 +59,7 @@ type AppContext struct {
ProjectRoute project_route.ProjectRoute
PreAppMiddleware []web.Middleware
PostAppMiddleware []web.Middleware
AwsSession *session.Session
}
// 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.
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.
sm := stm.NewSitemap(1)
sm.SetVerbose(false)
......@@ -146,7 +167,7 @@ func APP(shutdown chan os.Signal, appCtx *AppContext) http.Handler {
Renderer: appCtx.Renderer,
}
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("POST", "/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
app.Handle("POST", "/signup", s.Step1)
app.Handle("GET", "/signup", s.Step1)
app.Handle("GET", "/signup", s.Step1, waitDbMid)
// Register example endpoints.
ex := Examples{
......@@ -224,22 +245,23 @@ func APP(shutdown chan os.Signal, appCtx *AppContext) http.Handler {
app.Handle("GET", "/robots.txt", r.RobotTxt)
app.Handle("GET", "/sitemap.xml", r.SitemapXml)
// Add sitemap entries for Root.
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", "/support"}, {"changefreq", "monthly"}, {"mobile", true}, {"priority", 0.8}}, "site-support.gohtml")
smLocAddModified(stm.URL{{"loc", "/api"}, {"changefreq", "monthly"}, {"mobile", true}, {"priority", 0.7}}, "site-api.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")
// 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.
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", "/support"}, {"changefreq", "monthly"}, {"mobile", true}, {"priority", 0.8}}, "site-support.gohtml")
smLocAddModified(stm.URL{{"loc", "/api"}, {"changefreq", "monthly"}, {"mobile", true}, {"priority", 0.7}}, "site-api.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")
// 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 {
err := web.StaticHandler(ctx, w, r, params, appCtx.StaticDir, "")
......
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)
}
......@@ -107,7 +107,7 @@ func main() {
SessionName string `default:"" envconfig:"SESSION_NAME"`
DebugHost string `default:"0.0.0.0:4000" envconfig:"DEBUG_HOST"`
ShutdownTimeout time.Duration `default:"5s" envconfig:"SHUTDOWN_TIMEOUT"`
ScaleToZero time.Duration `envconfig:"SCALE_TO_ZERO"`
ScaleToZero time.Duration `envconfig:"SCALE_TO_ZERO"`
}
Project struct {
Name string `default:"" envconfig:"PROJECT_NAME"`
......@@ -456,6 +456,7 @@ func main() {
Log: log,
Env: cfg.Env,
MasterDB: masterDb,
MasterDbHost: cfg.DB.Host,
Redis: redisClient,
TemplateDir: cfg.Service.TemplateDir,
StaticDir: cfg.Service.StaticFiles.Dir,
......@@ -470,6 +471,7 @@ func main() {
InviteRepo: inviteRepo,
ProjectRepo: prjRepo,
Authenticator: authenticator,
AwsSession: awsSession,
}
// =========================================================================
......
{{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}}
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
}
......@@ -413,7 +413,7 @@ func (r *TemplateRenderer) Render(ctx context.Context, w http.ResponseWriter, re
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)
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment