blob: 79ee11828e3740ed9cad3980042e2ec8dada2a9b [file] [log] [blame]
package main
// The webserver for demos.skia.org. It serves a main page and a set of js+html+css demos.
import (
"context"
"encoding/json"
"flag"
"fmt"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"sync"
"time"
"github.com/gorilla/mux"
"go.skia.org/infra/go/auth"
"go.skia.org/infra/go/common"
"go.skia.org/infra/go/git"
"go.skia.org/infra/go/gitauth"
"go.skia.org/infra/go/httputils"
"go.skia.org/infra/go/skerr"
"go.skia.org/infra/go/sklog"
"go.skia.org/infra/go/util"
)
const (
repoPollInterval = 3 * time.Minute
)
var (
port = flag.String("port", ":8000", "HTTP service address (e.g., ':8000')")
local = flag.Bool("local", false, "Is this running locally for development (use gcloud for auth)")
resourcesDir = flag.String("resources_dir", "./dist", "The directory to find templates, JS, and CSS files. If blank ./dist will be used.")
demosRepo = flag.String("repo_url", "https://skia.googlesource.com/infra-internal", "The repo from where to fetch the demos. Defaults to https://skia.googlesource.com/infra-internal")
demosRepoPath = flag.String("demos_dir", "demos/internal", "The top level directory in the repo that holds the demos.")
demosRepoDefaultBranch = flag.String("repo_default_branch", git.MasterBranch, "The default branch of the repo (ie. master or main).")
)
type syncedDemos struct {
sync.RWMutex
repo *git.Checkout
repoURL string
// Absolute path where demos are located.
demoPath string
}
// newSyncedDemos creates a *syncedDemos that maintains a thread compatible, updating repo.
//
// It periodically updates the repo under a lock, and writes a metadata file to demoPath listing
// the available subdirectories and other repo information. The repo files can be safely served
// by exposing DemoHandler().
func newSyncedDemos(ctx context.Context, repoURL, checkoutDir, demoPath string) *syncedDemos {
sklog.Infof("Creating new syncedDemos for %s at %s", repoURL, checkoutDir)
s := new(syncedDemos)
var err error
s.repo, err = git.NewCheckout(ctx, repoURL, checkoutDir)
if err != nil {
sklog.Fatal(err)
}
s.repoURL = repoURL
s.demoPath = filepath.Join(s.repo.Dir(), demoPath)
sklog.Infof("Serving demos out of '%s'", s.demoPath)
go util.RepeatCtx(ctx, repoPollInterval, s.Sync)
return s
}
// Revision represents repo HEAD info for storage as json.
type Revision struct {
Hash string `json:"hash"`
URL string `json:"url"`
}
// Metadata represents repo metadata and list of demos, for storage as json.
type Metadata struct {
Rev Revision `json:"revision"`
// In the future we may include actual author information etc, but for now we just list the
// available demos.
DemoList []string `json:"demos"`
}
// writeMetadata writes a json file containing the list of subdirectories in s.demoPath as well as
// the hash and URL of the current s.repo revision.
//
// This is used to generate a list of demo links on the skia-demos main page.
func (s *syncedDemos) writeMetadata(ctx context.Context, rev string) error {
file, err := os.Open(s.demoPath)
if err != nil {
return skerr.Wrapf(err, "Failed to Open '%s'.", s.demoPath)
}
defer file.Close()
metadata := Metadata{
Rev: Revision{
Hash: rev,
URL: fmt.Sprintf("%s/+/%s", s.repoURL, rev),
},
}
metadata.DemoList, err = file.Readdirnames(0)
if err != nil {
return skerr.Wrapf(err, "Failed to Readdirnames of '%s'.", file.Name())
}
sklog.Infof("Available demos: %v", metadata.DemoList)
obj, err := json.MarshalIndent(metadata, "", " ")
if err != nil {
return skerr.Wrap(err)
}
metadataPath := filepath.Join(s.demoPath, "metadata.json")
err = ioutil.WriteFile(metadataPath, obj, 0644)
if err != nil {
return skerr.Wrapf(err, "Unable to write json to '%s'.", metadataPath)
}
return nil
}
// Sync performs a repo Update under s.Lock().
//
// If the HEAD revision changes it rewrites the metadata.json file with updated information.
func (s *syncedDemos) Sync(ctx context.Context) {
sklog.Info("Syncing and rewriting metadata file.")
s.Lock()
defer s.Unlock()
if err := s.repo.UpdateBranch(ctx, *demosRepoDefaultBranch); err != nil {
sklog.Errorf("Failed to update repo: %s", err)
}
hash, err := s.repo.FullHash(ctx, "HEAD")
if err != nil {
sklog.Fatal(err)
}
sklog.Infof("Checkout at %s.", hash)
if err = s.writeMetadata(ctx, hash); err != nil {
sklog.Fatalf("Unable to write metadata: %s", err)
}
}
// demoHandler returns a fileserver handler for dir that won't serve while the underlying repo
// is being updated.
func (s *syncedDemos) demoHandler() func(http.ResponseWriter, *http.Request) {
h := http.StripPrefix("/demo", http.FileServer(http.Dir(s.demoPath)))
return func(w http.ResponseWriter, r *http.Request) {
s.RLock()
defer s.RUnlock()
h.ServeHTTP(w, r)
}
}
// setupGit acquires necessary credentials to clone the repo.
func setupGit() error {
// Start the gitauth package because we will need to read from infra-internal.
ts, err := auth.NewDefaultTokenSource(*local, auth.SCOPE_USERINFO_EMAIL, auth.SCOPE_GERRIT)
if err != nil {
return err
}
if !*local {
if _, err := gitauth.New(ts, filepath.Join(os.TempDir(), "gitcookies"), true, ""); err != nil {
return skerr.Wrapf(err, "Failed to create git cookie updater")
}
}
return nil
}
func main() {
common.InitWithMust(
"demos",
)
if err := setupGit(); err != nil {
sklog.Fatalf("Failed to setup git: %s", err)
}
// Create a threadsafe checkout to serve from.
ctx := context.Background()
checkoutDir, err := ioutil.TempDir("", "demos_repo")
if err != nil {
sklog.Fatalf("Unable to create temporary directory for demos checkout: %s", err)
}
syncedDemos := newSyncedDemos(ctx, *demosRepo, checkoutDir, *demosRepoPath)
r := mux.NewRouter()
r.PathPrefix("/demo/").HandlerFunc(syncedDemos.demoHandler())
r.PathPrefix("/dist/").Handler(http.StripPrefix("/dist/", http.FileServer(http.Dir(*resourcesDir))))
r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, filepath.Join(*resourcesDir, "main.html"))
})
h := httputils.LoggingGzipRequestResponse(r)
h = httputils.HealthzAndHTTPS(h)
http.Handle("/", h)
sklog.Info("Ready to serve on http://localhost" + *port)
sklog.Fatal(http.ListenAndServe(*port, nil))
}