diff --git a/cmd/web-api/main.go b/cmd/web-api/main.go index 96ce962..0e92a7f 100644 --- a/cmd/web-api/main.go +++ b/cmd/web-api/main.go @@ -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"` diff --git a/cmd/web-app/handlers/routes.go b/cmd/web-app/handlers/routes.go index 9dc54b7..b0df47b 100644 --- a/cmd/web-app/handlers/routes.go +++ b/cmd/web-app/handlers/routes.go @@ -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,6 +245,15 @@ func APP(shutdown chan os.Signal, appCtx *AppContext) http.Handler { app.Handle("GET", "/robots.txt", r.RobotTxt) 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. 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") @@ -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/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. 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, "") diff --git a/cmd/web-app/handlers/serverless.go b/cmd/web-app/handlers/serverless.go new file mode 100644 index 0000000..70b6ac8 --- /dev/null +++ b/cmd/web-app/handlers/serverless.go @@ -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) +} diff --git a/cmd/web-app/main.go b/cmd/web-app/main.go index 3db39b9..0db88db 100644 --- a/cmd/web-app/main.go +++ b/cmd/web-app/main.go @@ -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, } // ========================================================================= diff --git a/cmd/web-app/templates/content/serverless-db.gohtml b/cmd/web-app/templates/content/serverless-db.gohtml new file mode 100644 index 0000000..c1c5329 --- /dev/null +++ b/cmd/web-app/templates/content/serverless-db.gohtml @@ -0,0 +1,51 @@ +{{define "title"}}Service Scaling{{end}} +{{define "description"}}Service is scaling.{{end}} +{{define "style"}} + +{{end}} +{{ define "partials/app-wrapper" }} +
Please wait a moment, you will be redirected to your request page when operation complete.
+