[Leasing server] Backend changes necessary for lit-html migration

Bug: skia:10116
Change-Id: I8bf7c44a7173d22bcb031a29b43516a2dcccb356
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/282261
Commit-Queue: Ravi Mistry <rmistry@google.com>
Reviewed-by: Joe Gregorio <jcgregorio@google.com>
diff --git a/leasing/go/leasing/datastore.go b/leasing/go/leasing/datastore.go
index 7c89a02..d56ed9f 100644
--- a/leasing/go/leasing/datastore.go
+++ b/leasing/go/leasing/datastore.go
@@ -9,13 +9,15 @@
 	"fmt"
 
 	"cloud.google.com/go/datastore"
-	"go.skia.org/infra/go/auth"
-	"go.skia.org/infra/go/ds"
 	"google.golang.org/api/option"
+
+	"go.skia.org/infra/go/auth"
+	"go.skia.org/infra/go/baseapp"
+	"go.skia.org/infra/go/ds"
 )
 
 func DatastoreInit(project string, ns string) error {
-	ts, err := auth.NewDefaultTokenSource(*local, "https://www.googleapis.com/auth/datastore")
+	ts, err := auth.NewDefaultTokenSource(*baseapp.Local, "https://www.googleapis.com/auth/datastore")
 	if err != nil {
 		return fmt.Errorf("Problem setting up default token source: %s", err)
 	}
diff --git a/leasing/go/leasing/debugger.go b/leasing/go/leasing/debugger.go
index 68a4c41..99feb41 100644
--- a/leasing/go/leasing/debugger.go
+++ b/leasing/go/leasing/debugger.go
@@ -11,12 +11,14 @@
 	"sync"
 
 	"cloud.google.com/go/storage"
+	"google.golang.org/api/iterator"
+	"google.golang.org/api/option"
+
 	"go.skia.org/infra/go/auth"
+	"go.skia.org/infra/go/baseapp"
 	"go.skia.org/infra/go/common"
 	"go.skia.org/infra/go/git/gitinfo"
 	"go.skia.org/infra/go/vcsinfo"
-	"google.golang.org/api/iterator"
-	"google.golang.org/api/option"
 )
 
 const (
@@ -41,7 +43,7 @@
 		return fmt.Errorf("Failed to checkout %s: %s", common.REPO_SKIA, err)
 	}
 
-	ts, err := auth.NewDefaultTokenSource(*local, auth.SCOPE_READ_WRITE)
+	ts, err := auth.NewDefaultTokenSource(*baseapp.Local, auth.SCOPE_READ_WRITE)
 	if err != nil {
 		return fmt.Errorf("Problem setting up default token source: %s", err)
 	}
diff --git a/leasing/go/leasing/mail.go b/leasing/go/leasing/mail.go
index a91729a..250605c 100644
--- a/leasing/go/leasing/mail.go
+++ b/leasing/go/leasing/mail.go
@@ -14,11 +14,11 @@
 )
 
 const (
-	LEASING_EMAIL_DISPLAY_NAME = "Leasing Server"
+	leasingEmailDisplayName = "Leasing Server"
 
-	GMAIL_CACHED_TOKEN = "leasing_gmail_cached_token"
+	gmailCachedToken = "leasing_gmail_cached_token"
 
-	CONNECTION_INSTRUCTIONS_PAGE = "https://skia.org/dev/testing/swarmingbots#connecting-to-swarming-bots"
+	connectionInstructionsPage = "https://skia.org/dev/testing/swarmingbots#connecting-to-swarming-bots"
 )
 
 var (
@@ -76,12 +76,12 @@
 		<br/><br/>
 		Thanks!
 	`
-	body := fmt.Sprintf(bodyTemplate, taskLink, GetSwarmingBotLink(swarmingServer, swarmingBot), swarmingBot, sectionAboutIsolates, CONNECTION_INSTRUCTIONS_PAGE, fmt.Sprintf("%s%s", PROD_URI, MY_LEASES_URI))
+	body := fmt.Sprintf(bodyTemplate, taskLink, GetSwarmingBotLink(swarmingServer, swarmingBot), swarmingBot, sectionAboutIsolates, connectionInstructionsPage, fmt.Sprintf("%s%s", prodURI, myLeasesURI))
 	markup, err := getSwarmingLinkMarkup(taskLink)
 	if err != nil {
 		return fmt.Errorf("Failed to get view action markup: %s", err)
 	}
-	if err := gmail.SendWithMarkup(LEASING_EMAIL_DISPLAY_NAME, getRecipients(ownerEmail), subject, body, markup); err != nil {
+	if err := gmail.SendWithMarkup(leasingEmailDisplayName, getRecipients(ownerEmail), subject, body, markup); err != nil {
 		return fmt.Errorf("Could not send start email: %s", err)
 	}
 	return nil
@@ -97,12 +97,12 @@
 		<br/><br/>
 		Thanks!
 	`
-	body := fmt.Sprintf(bodyTemplate, taskLink, fmt.Sprintf("%s%s", PROD_URI, MY_LEASES_URI))
+	body := fmt.Sprintf(bodyTemplate, taskLink, fmt.Sprintf("%s%s", prodURI, myLeasesURI))
 	markup, err := getSwarmingLinkMarkup(taskLink)
 	if err != nil {
 		return fmt.Errorf("Failed to get view action markup: %s", err)
 	}
-	if err := gmail.SendWithMarkup(LEASING_EMAIL_DISPLAY_NAME, getRecipients(ownerEmail), subject, body, markup); err != nil {
+	if err := gmail.SendWithMarkup(leasingEmailDisplayName, getRecipients(ownerEmail), subject, body, markup); err != nil {
 		return fmt.Errorf("Could not send warning email: %s", err)
 	}
 	return nil
@@ -120,12 +120,12 @@
 		<br/><br/>
 		Thanks!
 	`
-	body := fmt.Sprintf(bodyTemplate, taskLink, swarmingTaskState, PROD_URI)
+	body := fmt.Sprintf(bodyTemplate, taskLink, swarmingTaskState, prodURI)
 	markup, err := getSwarmingLinkMarkup(taskLink)
 	if err != nil {
 		return fmt.Errorf("Failed to get view action markup: %s", err)
 	}
-	if err := gmail.SendWithMarkup(LEASING_EMAIL_DISPLAY_NAME, getRecipients(ownerEmail), subject, body, markup); err != nil {
+	if err := gmail.SendWithMarkup(leasingEmailDisplayName, getRecipients(ownerEmail), subject, body, markup); err != nil {
 		return fmt.Errorf("Could not send failure email: %s", err)
 	}
 	return nil
@@ -141,12 +141,12 @@
 		<br/><br/>
 		Thanks!
 	`
-	body := fmt.Sprintf(bodyTemplate, taskLink, durationHrs, PROD_URI)
+	body := fmt.Sprintf(bodyTemplate, taskLink, durationHrs, prodURI)
 	markup, err := getSwarmingLinkMarkup(taskLink)
 	if err != nil {
 		return fmt.Errorf("Failed to get view action markup: %s", err)
 	}
-	if err := gmail.SendWithMarkup(LEASING_EMAIL_DISPLAY_NAME, getRecipients(ownerEmail), subject, body, markup); err != nil {
+	if err := gmail.SendWithMarkup(leasingEmailDisplayName, getRecipients(ownerEmail), subject, body, markup); err != nil {
 		return fmt.Errorf("Could not send completion email: %s", err)
 	}
 	return nil
@@ -162,12 +162,12 @@
 		<br/><br/>
 		Thanks!
 	`
-	body := fmt.Sprintf(bodyTemplate, taskLink, PROD_URI)
+	body := fmt.Sprintf(bodyTemplate, taskLink, prodURI)
 	markup, err := getSwarmingLinkMarkup(taskLink)
 	if err != nil {
 		return fmt.Errorf("Failed to get view action markup: %s", err)
 	}
-	if err := gmail.SendWithMarkup(LEASING_EMAIL_DISPLAY_NAME, getRecipients(ownerEmail), subject, body, markup); err != nil {
+	if err := gmail.SendWithMarkup(leasingEmailDisplayName, getRecipients(ownerEmail), subject, body, markup); err != nil {
 		return fmt.Errorf("Could not send completion email: %s", err)
 	}
 	return nil
diff --git a/leasing/go/leasing/main.go b/leasing/go/leasing/main.go
index 935807f..7f68713 100644
--- a/leasing/go/leasing/main.go
+++ b/leasing/go/leasing/main.go
@@ -14,53 +14,48 @@
 	"io/ioutil"
 	"net/http"
 	"path/filepath"
-	"runtime"
 	"sort"
 	"strconv"
 	"sync"
 	"time"
 
 	"github.com/gorilla/mux"
+	"github.com/unrolled/secure"
+	"google.golang.org/api/iterator"
+
 	"go.skia.org/infra/go/allowed"
-	"go.skia.org/infra/go/common"
+	"go.skia.org/infra/go/baseapp"
 	"go.skia.org/infra/go/httputils"
 	"go.skia.org/infra/go/login"
 	"go.skia.org/infra/go/metrics2"
-	"go.skia.org/infra/go/skiaversion"
 	"go.skia.org/infra/go/sklog"
 	"go.skia.org/infra/go/swarming"
 	"go.skia.org/infra/go/util"
-	"google.golang.org/api/iterator"
 )
 
 const (
-	// OAUTH2_CALLBACK_PATH is callback endpoint used for the Oauth2 flow.
-	OAUTH2_CALLBACK_PATH = "/oauth2callback/"
+	maxLeaseDurationHrs = 23
 
-	MAX_LEASE_DURATION_HRS = 23
+	swarmingHardTimeout = 24 * time.Hour
 
-	SWARMING_HARD_TIMEOUT = 24 * time.Hour
+	leaseTaskPriority = 50
 
-	LEASE_TASK_PRIORITY = 50
-
-	MY_LEASES_URI         = "/my_leases"
-	ALL_LEASES_URI        = "/all_leases"
-	GET_TASK_STATUS_URI   = "/_/get_task_status"
-	POOL_DETAILS_POST_URI = "/_/pooldetails"
-	ADD_TASK_POST_URI     = "/_/add_leasing_task"
-	EXTEND_TASK_POST_URI  = "/_/extend_leasing_task"
-	EXPIRE_TASK_POST_URI  = "/_/expire_leasing_task"
-	PROD_URI              = "https://leasing.skia.org"
+	myLeasesURI              = "/my_leases"
+	allLeasesURI             = "/all_leases"
+	getTaskStatusURI         = "/_/get_task_status"
+	getLeasesPostURI         = "/_/get_leases"
+	getSupportedPoolsPostURI = "/_/get_supported_pools"
+	poolDetailsPostURI       = "/_/pooldetails"
+	addTaskPostURI           = "/_/add_leasing_task"
+	extendTaskPostURI        = "/_/extend_leasing_task"
+	expireTaskPostURI        = "/_/expire_leasing_task"
+	prodURI                  = "https://leasing.skia.org"
 )
 
 var (
 	// Flags
-	host                       = flag.String("host", "localhost", "HTTP service host")
-	promPort                   = flag.String("prom_port", ":20000", "Metrics service address (e.g., ':20000')")
-	port                       = flag.String("port", ":8002", "HTTP service port (e.g., ':8002')")
-	local                      = flag.Bool("local", false, "Running locally if true. As opposed to in production.")
 	workdir                    = flag.String("workdir", ".", "Directory to use for scratch work.")
-	resourcesDir               = flag.String("resources_dir", "", "The directory to find templates, JS, and CSS files.  If blank then the directory two directories up from this source file will be used.")
+	isolatesDir                = flag.String("isolates_dir", "", "The directory to find leasing server's isolates files.")
 	pollInterval               = flag.Duration("poll_interval", 1*time.Minute, "How often the leasing server will check if tasks have expired.")
 	emailClientSecretFile      = flag.String("email_client_secret_file", "/etc/leasing-email-secrets/client_secret.json", "OAuth client secret JSON file for sending email.")
 	emailTokenCacheFile        = flag.String("email_token_cache_file", "/etc/leasing-email-secrets/client_token.json", "OAuth token cache file for sending email.")
@@ -74,48 +69,162 @@
 	// OAUTH params
 	authWhiteList = flag.String("auth_whitelist", "google.com", "White space separated list of domains and email addresses that are allowed to login.")
 
-	// indexTemplate is the main index.html page we serve.
-	indexTemplate *template.Template = nil
-
-	// leasesListTemplate is the page we serve on the my-leases and all-leases pages.
-	leasesListTemplate *template.Template = nil
-
 	serverURL string
 
 	poolToDetails      map[string]*PoolDetails
 	poolToDetailsMutex sync.Mutex
 )
 
-func reloadTemplates() {
-	if *resourcesDir == "" {
-		// If resourcesDir is not specified then consider the directory two directories up from this
-		// source file as the resourcesDir.
-		_, filename, _, _ := runtime.Caller(0)
-		*resourcesDir = filepath.Join(filepath.Dir(filename), "../..")
+type ClientConfig struct {
+	ClientID     string `json:"client_id"`
+	ClientSecret string `json:"client_secret"`
+}
+
+type ClientSecretJSON struct {
+	Installed ClientConfig `json:"installed"`
+}
+
+// See baseapp.Constructor.
+func New() (baseapp.App, error) {
+	// Initialize mailing library.
+	var cfg ClientSecretJSON
+	err := util.WithReadFile(*emailClientSecretFile, func(f io.Reader) error {
+		return json.NewDecoder(f).Decode(&cfg)
+	})
+	if err != nil {
+		sklog.Fatalf("Failed to read client secrets from %q: %s", *emailClientSecretFile, err)
 	}
-	indexTemplate = template.Must(template.ParseFiles(
-		filepath.Join(*resourcesDir, "templates/index.html"),
-		filepath.Join(*resourcesDir, "templates/header.html"),
-	))
-	leasesListTemplate = template.Must(template.ParseFiles(
-		filepath.Join(*resourcesDir, "templates/leases_list.html"),
-		filepath.Join(*resourcesDir, "templates/header.html"),
+	// Create a copy of the token cache file since mounted secrets are read-only
+	// and the access token will need to be updated for the oauth2 flow.
+	if !*baseapp.Local {
+		fout, err := ioutil.TempFile("", "")
+		if err != nil {
+			sklog.Fatalf("Unable to create temp file %q: %s", fout.Name(), err)
+		}
+		err = util.WithReadFile(*emailTokenCacheFile, func(fin io.Reader) error {
+			_, err := io.Copy(fout, fin)
+			if err != nil {
+				err = fout.Close()
+			}
+			return err
+		})
+		if err != nil {
+			sklog.Fatalf("Failed to write token cache file from %q to %q: %s", *emailTokenCacheFile, fout.Name(), err)
+		}
+		*emailTokenCacheFile = fout.Name()
+	}
+	if err := MailInit(cfg.Installed.ClientID, cfg.Installed.ClientSecret, *emailTokenCacheFile); err != nil {
+		sklog.Fatalf("Failed to init mail library: %s", err)
+	}
+
+	var allow allowed.Allow
+	if !*baseapp.Local {
+		allow = allowed.NewAllowedFromList([]string{*authWhiteList})
+	} else {
+		allow = allowed.NewAllowedFromList([]string{"fred@example.org", "barney@example.org", "wilma@example.org"})
+	}
+	login.SimpleInitWithAllow(*baseapp.Port, *baseapp.Local, nil, nil, allow)
+
+	// Initialize isolate and swarming.
+	if err := SwarmingInit(*serviceAccountFile); err != nil {
+		sklog.Fatalf("Failed to init isolate and swarming: %s", err)
+	}
+
+	// Initialize cloud datastore.
+	if err := DatastoreInit(*projectName, *namespace); err != nil {
+		sklog.Fatalf("Failed to init cloud datastore: %s", err)
+	}
+
+	poolToDetails, err = GetDetailsOfAllPools()
+	if err != nil {
+		sklog.Fatalf("Could not get details of all pools: %s", err)
+	}
+	go func() {
+		for range time.Tick(*poolDetailsUpdateFrequency) {
+			poolToDetailsMutex.Lock()
+			poolToDetails, err = GetDetailsOfAllPools()
+			poolToDetailsMutex.Unlock()
+			if err != nil {
+				sklog.Errorf("Could not get details of all pools: %s", err)
+			}
+		}
+	}()
+
+	healthyGauge := metrics2.GetInt64Metric("healthy")
+	go func() {
+		for range time.Tick(*pollInterval) {
+			healthyGauge.Update(1)
+			if err := pollSwarmingTasks(); err != nil {
+				sklog.Errorf("Error when checking for expired tasks: %v", err)
+			}
+		}
+	}()
+
+	srv := &Server{}
+	srv.loadTemplates()
+
+	return srv, nil
+}
+
+// Server is the state of the server.
+type Server struct {
+	templates *template.Template
+}
+
+func (srv *Server) loadTemplates() {
+	srv.templates = template.Must(template.New("").Delims("{%", "%}").ParseFiles(
+		filepath.Join(*baseapp.ResourcesDir, "index.html"),
+		filepath.Join(*baseapp.ResourcesDir, "leases_list.html"),
 	))
 }
 
-func loginHandler(w http.ResponseWriter, r *http.Request) {
-	http.Redirect(w, r, login.LoginURL(w, r), http.StatusFound)
-	return
+// user returns the currently logged in user, or a placeholder if running locally.
+func (srv *Server) user(r *http.Request) string {
+	user := "barney@example.org"
+	if !*baseapp.Local {
+		user = login.LoggedInAs(r)
+	}
+	return user
 }
 
-func indexHandler(w http.ResponseWriter, r *http.Request) {
-	if *local {
-		reloadTemplates()
+// See baseapp.App.
+func (srv *Server) AddHandlers(r *mux.Router) {
+	// For login/logout.
+	r.HandleFunc(login.DEFAULT_OAUTH2_CALLBACK, login.OAuth2CallbackHandler)
+	r.HandleFunc("/logout/", login.LogoutHandler)
+	r.HandleFunc("/loginstatus/", login.StatusHandler)
+	// Get task status will be used from swarming bots.
+	r.HandleFunc(getTaskStatusURI, srv.statusHandler).Methods("GET")
+
+	// All endpoints that require authentication should be added to this router.
+	appRouter := mux.NewRouter()
+	appRouter.HandleFunc("/", srv.indexHandler)
+	appRouter.HandleFunc(myLeasesURI, srv.myLeasesHandler)
+	appRouter.HandleFunc(allLeasesURI, srv.allLeasesHandler)
+	appRouter.HandleFunc(poolDetailsPostURI, srv.poolDetailsHandler).Methods("POST")
+	appRouter.HandleFunc(getSupportedPoolsPostURI, srv.supportedPoolsHandler).Methods("POST")
+	appRouter.HandleFunc(getLeasesPostURI, srv.getLeasesHandler).Methods("POST")
+	appRouter.HandleFunc(addTaskPostURI, srv.addTaskHandler).Methods("POST")
+	appRouter.HandleFunc(extendTaskPostURI, srv.extendTaskHandler).Methods("POST")
+	appRouter.HandleFunc(expireTaskPostURI, srv.expireTaskHandler).Methods("POST")
+
+	// Use the appRouter as a handler and wrap it into middleware that enforces authentication.
+	appHandler := http.Handler(appRouter)
+	if !*baseapp.Local {
+		appHandler = login.ForceAuth(appRouter, login.DEFAULT_REDIRECT_URL)
 	}
+
+	r.PathPrefix("/").Handler(appHandler)
+}
+
+func (srv *Server) indexHandler(w http.ResponseWriter, r *http.Request) {
 	w.Header().Set("Content-Type", "text/html")
 
-	if err := indexTemplate.Execute(w, nil); err != nil {
-		httputils.ReportError(w, err, "Failed to expand template", http.StatusInternalServerError)
+	if err := srv.templates.ExecuteTemplate(w, "index.html", map[string]string{
+		// Look in webpack.config.js for where the nonce templates are injected.
+		"Nonce": secure.CSPNonce(r.Context()),
+	}); err != nil {
+		httputils.ReportError(w, err, "Failed to expand template.", http.StatusInternalServerError)
 		return
 	}
 	return
@@ -126,7 +235,7 @@
 	Expired bool
 }
 
-func statusHandler(w http.ResponseWriter, r *http.Request) {
+func (srv *Server) statusHandler(w http.ResponseWriter, r *http.Request) {
 	w.Header().Set("Content-Type", "application/json")
 
 	taskParam := r.FormValue("task")
@@ -159,7 +268,7 @@
 	return
 }
 
-func poolDetailsHandler(w http.ResponseWriter, r *http.Request) {
+func (srv *Server) poolDetailsHandler(w http.ResponseWriter, r *http.Request) {
 	w.Header().Set("Content-Type", "application/json")
 
 	poolParam := r.FormValue("pool")
@@ -180,6 +289,22 @@
 	}
 }
 
+func (srv *Server) supportedPoolsHandler(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Content-Type", "application/json")
+
+	supportedPools := []string{}
+	poolToDetailsMutex.Lock()
+	defer poolToDetailsMutex.Unlock()
+	for p := range poolToDetails {
+		supportedPools = append(supportedPools, p)
+	}
+	sort.Strings(supportedPools)
+	if err := json.NewEncoder(w).Encode(supportedPools); err != nil {
+		httputils.ReportError(w, err, fmt.Sprintf("Failed to encode JSON: %v", err), http.StatusInternalServerError)
+		return
+	}
+}
+
 type Task struct {
 	Requester          string    `json:"requester"`
 	OsType             string    `json:"osType"`
@@ -233,64 +358,61 @@
 	return tasks, nil
 }
 
-func leasesHandlerHelper(w http.ResponseWriter, r *http.Request, filterUser string) {
-	if *local {
-		reloadTemplates()
-	}
-	w.Header().Set("Content-Type", "text/html")
+func (srv *Server) getLeasesHandler(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Content-Type", "application/json")
 
-	tasks, err := getLeasingTasks(filterUser)
+	reqGetLeasesRequest := struct {
+		FilterByUser string `json:"filter_by_user"`
+	}{}
+	if err := json.NewDecoder(r.Body).Decode(&reqGetLeasesRequest); err != nil {
+		httputils.ReportError(w, err, "Failed to decode add note request", http.StatusInternalServerError)
+		return
+	}
+	tasks, err := getLeasingTasks(reqGetLeasesRequest.FilterByUser)
 	if err != nil {
 		httputils.ReportError(w, err, "Failed to expand template", http.StatusInternalServerError)
 		return
 	}
-
-	var templateTasks = struct {
-		Tasks []*Task
-	}{
-		Tasks: tasks,
+	if err := json.NewEncoder(w).Encode(tasks); err != nil {
+		sklog.Errorf("Failed to send response: %s", err)
 	}
-	if err := leasesListTemplate.Execute(w, templateTasks); err != nil {
-		httputils.ReportError(w, err, "Failed to expand template", http.StatusInternalServerError)
+}
+
+func (srv *Server) leasesHandlerHelper(w http.ResponseWriter, r *http.Request, filterByUser string) {
+	w.Header().Set("Content-Type", "text/html")
+
+	if err := srv.templates.ExecuteTemplate(w, "leases_list.html", map[string]string{
+		"FilterByUser": filterByUser,
+		// Look in webpack.config.js for where the nonce templates are injected.
+		"Nonce": secure.CSPNonce(r.Context()),
+	}); err != nil {
+		httputils.ReportError(w, err, "Failed to expand template.", http.StatusInternalServerError)
 		return
 	}
 	return
 }
 
-func myLeasesHandler(w http.ResponseWriter, r *http.Request) {
-	leasesHandlerHelper(w, r, login.LoggedInAs(r))
+func (srv *Server) myLeasesHandler(w http.ResponseWriter, r *http.Request) {
+	srv.leasesHandlerHelper(w, r, login.LoggedInAs(r))
 }
 
-func allLeasesHandler(w http.ResponseWriter, r *http.Request) {
-	leasesHandlerHelper(w, r, "" /* filterUser */)
+func (srv *Server) allLeasesHandler(w http.ResponseWriter, r *http.Request) {
+	srv.leasesHandlerHelper(w, r, "" /* filterByUser */)
 }
 
-func extendTaskHandler(w http.ResponseWriter, r *http.Request) {
+func (srv *Server) extendTaskHandler(w http.ResponseWriter, r *http.Request) {
 	w.Header().Set("Content-Type", "application/json")
 
-	taskParam := r.FormValue("task")
-	if taskParam == "" {
-		httputils.ReportError(w, nil, "Missing task parameter", http.StatusInternalServerError)
-		return
-	}
-	taskID, err := strconv.ParseInt(taskParam, 10, 64)
-	if err != nil {
-		httputils.ReportError(w, err, "Invalid task parameter", http.StatusInternalServerError)
+	extendRequest := struct {
+		TaskID      int64 `json:"task"`
+		DurationHrs int   `json:"duration"`
+	}{}
+	if err := json.NewDecoder(r.Body).Decode(&extendRequest); err != nil {
+		httputils.ReportError(w, err, "Failed to decode extend request", http.StatusInternalServerError)
 		return
 	}
 
-	durationParam := r.FormValue("duration")
-	if durationParam == "" {
-		httputils.ReportError(w, nil, "Missing duration parameter", http.StatusInternalServerError)
-		return
-	}
-	durationHrs, err := strconv.Atoi(durationParam)
-	if err != nil {
-		httputils.ReportError(w, err, fmt.Sprintf("Failed to parse %s", durationParam), http.StatusInternalServerError)
-		return
-	}
-
-	k, t, err := GetDSTask(taskID)
+	k, t, err := GetDSTask(extendRequest.TaskID)
 	if err != nil {
 		httputils.ReportError(w, err, "Could not find task", http.StatusInternalServerError)
 		return
@@ -298,10 +420,10 @@
 
 	// Add duration hours to the task's lease end time only if ends up being
 	// less than 23 hours after the task's creation time.
-	newLeaseEndTime := t.LeaseEndTime.Add(time.Hour * time.Duration(durationHrs))
-	maxPossibleLeaseEndTime := t.Created.Add(time.Hour * time.Duration(MAX_LEASE_DURATION_HRS))
+	newLeaseEndTime := t.LeaseEndTime.Add(time.Hour * time.Duration(extendRequest.DurationHrs))
+	maxPossibleLeaseEndTime := t.Created.Add(time.Hour * time.Duration(maxLeaseDurationHrs))
 	if newLeaseEndTime.After(maxPossibleLeaseEndTime) {
-		httputils.ReportError(w, nil, fmt.Sprintf("Can not extend lease beyond %d hours of the task creation time", MAX_LEASE_DURATION_HRS), http.StatusInternalServerError)
+		httputils.ReportError(w, nil, fmt.Sprintf("Can not extend lease beyond %d hours of the task creation time", maxLeaseDurationHrs), http.StatusInternalServerError)
 		return
 	}
 
@@ -314,27 +436,28 @@
 		return
 	}
 	// Inform the requester that the task has been extended by durationHrs.
-	if err := SendExtensionEmail(t.Requester, t.SwarmingServer, t.SwarmingTaskId, t.SwarmingBotId, durationHrs); err != nil {
+	if err := SendExtensionEmail(t.Requester, t.SwarmingServer, t.SwarmingTaskId, t.SwarmingBotId, extendRequest.DurationHrs); err != nil {
 		httputils.ReportError(w, err, "Error sending extension email", http.StatusInternalServerError)
 		return
 	}
+
+	if err := json.NewEncoder(w).Encode(t); err != nil {
+		sklog.Errorf("Failed to send response: %s", err)
+	}
 }
 
-func expireTaskHandler(w http.ResponseWriter, r *http.Request) {
+func (srv *Server) expireTaskHandler(w http.ResponseWriter, r *http.Request) {
 	w.Header().Set("Content-Type", "application/json")
 
-	taskParam := r.FormValue("task")
-	if taskParam == "" {
-		httputils.ReportError(w, nil, "Missing task parameter", http.StatusInternalServerError)
-		return
-	}
-	taskID, err := strconv.ParseInt(taskParam, 10, 64)
-	if err != nil {
-		httputils.ReportError(w, err, "Invalid task parameter", http.StatusInternalServerError)
+	expireRequest := struct {
+		TaskID int64 `json:"task"`
+	}{}
+	if err := json.NewDecoder(r.Body).Decode(&expireRequest); err != nil {
+		httputils.ReportError(w, err, "Failed to decode expire request", http.StatusInternalServerError)
 		return
 	}
 
-	k, t, err := GetDSTask(taskID)
+	k, t, err := GetDSTask(expireRequest.TaskID)
 	if err != nil {
 		httputils.ReportError(w, err, "Could not find task", http.StatusInternalServerError)
 		return
@@ -354,9 +477,13 @@
 		httputils.ReportError(w, err, "Error sending completion email", http.StatusInternalServerError)
 		return
 	}
+
+	if err := json.NewEncoder(w).Encode(t); err != nil {
+		sklog.Errorf("Failed to send response: %s", err)
+	}
 }
 
-func addTaskHandler(w http.ResponseWriter, r *http.Request) {
+func (srv *Server) addTaskHandler(w http.ResponseWriter, r *http.Request) {
 	w.Header().Set("Content-Type", "application/json")
 	ctx := context.Background()
 
@@ -436,133 +563,16 @@
 	}
 
 	sklog.Infof("Added %v task into the datastore with key %s", task, datastoreKey)
+	if err := json.NewEncoder(w).Encode(task); err != nil {
+		sklog.Errorf("Failed to send response: %s", err)
+	}
 }
 
-func runServer() {
-	r := mux.NewRouter()
-	r.PathPrefix("/res/").HandlerFunc(httputils.MakeResourceHandler(*resourcesDir))
-
-	r.HandleFunc("/", indexHandler)
-	r.HandleFunc(MY_LEASES_URI, myLeasesHandler)
-	r.HandleFunc(ALL_LEASES_URI, allLeasesHandler)
-	r.HandleFunc(POOL_DETAILS_POST_URI, poolDetailsHandler).Methods("POST")
-	r.HandleFunc(ADD_TASK_POST_URI, addTaskHandler).Methods("POST")
-	r.HandleFunc(EXTEND_TASK_POST_URI, extendTaskHandler).Methods("POST")
-	r.HandleFunc(EXPIRE_TASK_POST_URI, expireTaskHandler).Methods("POST")
-	r.HandleFunc("/json/version", skiaversion.JsonHandler)
-	r.HandleFunc("/loginstatus/", login.StatusHandler)
-
-	h := httputils.LoggingGzipRequestResponse(r)
-	h = login.RestrictViewer(h)
-	h = login.ForceAuth(h, login.DEFAULT_REDIRECT_URL)
-	h = httputils.HealthzAndHTTPS(h)
-
-	http.Handle("/", h)
-	http.HandleFunc(GET_TASK_STATUS_URI, statusHandler)
-
-	sklog.Infof("Ready to serve on %s", serverURL)
-	sklog.Fatal(http.ListenAndServe(*port, nil))
-}
-
-type ClientConfig struct {
-	ClientID     string `json:"client_id"`
-	ClientSecret string `json:"client_secret"`
-}
-
-type Installed struct {
-	Installed ClientConfig `json:"installed"`
+// See baseapp.App.
+func (srv *Server) AddMiddleware() []mux.MiddlewareFunc {
+	return []mux.MiddlewareFunc{}
 }
 
 func main() {
-	flag.Parse()
-
-	common.InitWithMust(
-		"leasing",
-		common.PrometheusOpt(promPort),
-		common.MetricsLoggingOpt(),
-	)
-
-	skiaversion.MustLogVersion()
-
-	reloadTemplates()
-	serverURL = "https://" + *host
-	if *local {
-		serverURL = "http://" + *host + *port
-	}
-
-	// Initialize mailing library.
-	var cfg Installed
-	err := util.WithReadFile(*emailClientSecretFile, func(f io.Reader) error {
-		return json.NewDecoder(f).Decode(&cfg)
-	})
-	if err != nil {
-		sklog.Fatalf("Failed to read client secrets from %q: %s", *emailClientSecretFile, err)
-	}
-	// Create a copy of the token cache file since mounted secrets are read-only
-	// and the access token will need to be updated for the oauth2 flow.
-	if !*local {
-		fout, err := ioutil.TempFile("", "")
-		if err != nil {
-			sklog.Fatalf("Unable to create temp file %q: %s", fout.Name(), err)
-		}
-		err = util.WithReadFile(*emailTokenCacheFile, func(fin io.Reader) error {
-			_, err := io.Copy(fout, fin)
-			if err != nil {
-				err = fout.Close()
-			}
-			return err
-		})
-		if err != nil {
-			sklog.Fatalf("Failed to write token cache file from %q to %q: %s", *emailTokenCacheFile, fout.Name(), err)
-		}
-		*emailTokenCacheFile = fout.Name()
-	}
-	if err := MailInit(cfg.Installed.ClientID, cfg.Installed.ClientSecret, *emailTokenCacheFile); err != nil {
-		sklog.Fatalf("Failed to init mail library: %s", err)
-	}
-
-	var allow allowed.Allow
-	if !*local {
-		allow = allowed.NewAllowedFromList([]string{*authWhiteList})
-	} else {
-		allow = allowed.NewAllowedFromList([]string{"fred@example.org", "barney@example.org", "wilma@example.org"})
-	}
-	login.SimpleInitWithAllow(*port, *local, nil, nil, allow)
-
-	// Initialize isolate and swarming.
-	if err := SwarmingInit(*serviceAccountFile); err != nil {
-		sklog.Fatalf("Failed to init isolate and swarming: %s", err)
-	}
-
-	// Initialize cloud datastore.
-	if err := DatastoreInit(*projectName, *namespace); err != nil {
-		sklog.Fatalf("Failed to init cloud datastore: %s", err)
-	}
-
-	poolToDetails, err = GetDetailsOfAllPools()
-	if err != nil {
-		sklog.Fatalf("Could not get details of all pools: %s", err)
-	}
-	go func() {
-		for range time.Tick(*poolDetailsUpdateFrequency) {
-			poolToDetailsMutex.Lock()
-			poolToDetails, err = GetDetailsOfAllPools()
-			poolToDetailsMutex.Unlock()
-			if err != nil {
-				sklog.Errorf("Could not get details of all pools: %s", err)
-			}
-		}
-	}()
-
-	healthyGauge := metrics2.GetInt64Metric("healthy")
-	go func() {
-		for range time.Tick(*pollInterval) {
-			healthyGauge.Update(1)
-			if err := pollSwarmingTasks(); err != nil {
-				sklog.Errorf("Error when checking for expired tasks: %v", err)
-			}
-		}
-	}()
-
-	runServer()
+	baseapp.Serve(New, []string{"leasing.skia.org"})
 }
diff --git a/leasing/go/leasing/swarming.go b/leasing/go/leasing/swarming.go
index cdb32cd..a6e2816 100644
--- a/leasing/go/leasing/swarming.go
+++ b/leasing/go/leasing/swarming.go
@@ -12,7 +12,9 @@
 	"strings"
 
 	swarming_api "go.chromium.org/luci/common/api/swarming/swarming/v1"
+
 	"go.skia.org/infra/go/auth"
+	"go.skia.org/infra/go/baseapp"
 	"go.skia.org/infra/go/exec"
 	"go.skia.org/infra/go/httputils"
 	"go.skia.org/infra/go/isolate"
@@ -81,7 +83,7 @@
 	}
 
 	// Authenticated HTTP client.
-	ts, err := auth.NewDefaultTokenSource(*local, swarming.AUTH_SCOPE)
+	ts, err := auth.NewDefaultTokenSource(*baseapp.Local, swarming.AUTH_SCOPE)
 	if err != nil {
 		return fmt.Errorf("Problem setting up default token source: %s", err)
 	}
@@ -225,9 +227,9 @@
 func GetIsolateHash(ctx context.Context, pool, isolateDep string) (string, error) {
 	isolateClient := *GetIsolateClient(pool)
 	isolateTask := &isolate.Task{
-		BaseDir:     path.Join(*resourcesDir, "isolates"),
+		BaseDir:     path.Join(*isolatesDir),
 		Blacklist:   []string{},
-		IsolateFile: path.Join(*resourcesDir, "isolates", "leasing.isolate"),
+		IsolateFile: path.Join(*isolatesDir, "leasing.isolate"),
 	}
 	if isolateDep != "" {
 		isolateTask.Deps = []string{isolateDep}
@@ -328,12 +330,12 @@
 
 	isolateServer := GetSwarmingInstance(pool).IsolateServer
 	expirationSecs := int64(swarming.RECOMMENDED_EXPIRATION.Seconds())
-	executionTimeoutSecs := int64(SWARMING_HARD_TIMEOUT.Seconds())
-	ioTimeoutSecs := int64(SWARMING_HARD_TIMEOUT.Seconds())
+	executionTimeoutSecs := int64(swarmingHardTimeout.Seconds())
+	ioTimeoutSecs := int64(swarmingHardTimeout.Seconds())
 	taskName := fmt.Sprintf("Leased by %s using leasing.skia.org", requester)
 	taskRequest := &swarming_api.SwarmingRpcsNewTaskRequest{
 		Name:     taskName,
-		Priority: LEASE_TASK_PRIORITY,
+		Priority: leaseTaskPriority,
 		TaskSlices: []*swarming_api.SwarmingRpcsTaskSlice{
 			{
 				ExpirationSecs: expirationSecs,