blob: 7b5c8d36512084ed1b75adb701b0830ab86d93f7 [file] [log] [blame]
package main
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"html/template"
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/unrolled/secure"
"golang.org/x/oauth2/google"
"go.skia.org/infra/go/alogin"
"go.skia.org/infra/go/alogin/proxylogin"
"go.skia.org/infra/go/auth"
"go.skia.org/infra/go/baseapp"
"go.skia.org/infra/go/exec"
"go.skia.org/infra/go/gerrit"
"go.skia.org/infra/go/git"
"go.skia.org/infra/go/gitiles"
"go.skia.org/infra/go/httputils"
"go.skia.org/infra/go/metrics2"
"go.skia.org/infra/go/roles"
"go.skia.org/infra/go/skerr"
"go.skia.org/infra/go/sklog"
"go.skia.org/infra/tool/go/tool"
"go.skia.org/infra/tool/go/types"
)
const (
refreshConfigsDuration = time.Minute
repoConfigDirectory = "tool"
commitMessage = "Update tools.luci.app config."
)
// flags
var (
host = flag.String("host", "tools.luci.app", "HTTP service host")
project = flag.String("project", "skia-public", "The Google Cloud project name.")
configs = flag.String("configs", "./configs", "The directory that contains all the config files")
repo = flag.String("repo", "", "If supplied this will be the URL of a Git repo to checkout that contains the config files. They will be presumed to be in a subdirectory as supplied by the --configs flag.")
)
// server is the state of the server.
type server struct {
templates *template.Template
alogin alogin.Login
configRefreshLiveness metrics2.Liveness
gitilesRepo gitiles.GitilesRepo
gerritRepo gerrit.GerritInterface
gerritProject string
// Full path to the git executable.
gitExe string
// mutex protects tools.
mutex sync.Mutex
tools []byte // []Tool serialized as JSON.
}
// New implements baseapp.Constructor.
func New() (baseapp.App, error) {
ctx := context.Background()
gitExe, err := git.Executable(ctx)
if err != nil {
return nil, skerr.Wrap(err)
}
client, err := google.DefaultClient(ctx, auth.ScopeGerrit)
if err != nil {
return nil, skerr.Wrapf(err, "Creating authenticated HTTP client.")
}
gerritRepo, err := gerrit.NewGerrit(gerrit.GerritSkiaURL, client)
if err != nil {
return nil, skerr.Wrapf(err, "Creating Gerrit Client.")
}
gitilesRepo := gitiles.NewRepo("https://skia.googlesource.com/k8s-config", client)
gerritProject := ""
if *repo != "" {
u, err := url.Parse(*repo)
if err != nil {
return nil, skerr.Wrapf(err, "--repo is not a valid URL: %q", *repo)
}
if len(u.Path) <= 1 {
return nil, skerr.Wrapf(err, "--repo does not contain a project: %q", *repo)
}
gerritProject = u.Path[1:]
}
srv := &server{
alogin: proxylogin.NewWithDefaults(),
configRefreshLiveness: metrics2.NewLiveness("tools_config_refresh"),
gitExe: gitExe,
gitilesRepo: gitilesRepo,
gerritRepo: gerritRepo,
gerritProject: gerritProject,
}
srv.loadTemplates()
if err := srv.loadConfigs(ctx); err != nil {
return nil, skerr.Wrap(err)
}
return srv, nil
}
func (srv *server) StartRefreshingConfigs(ctx context.Context) {
for range time.Tick(refreshConfigsDuration) {
if err := srv.loadConfigs(ctx); err != nil {
sklog.Errorf("Failed to refresh configs: %s", err)
}
}
}
func (srv *server) loadConfigs(ctx context.Context) error {
var configDir string
if *repo != "" {
// Do a shallow checkout of the repo where the configs are found. In
// testing this was the fastest option completing in ~1s.
tmpDir, err := os.MkdirTemp("", "tool_config_checkout")
if err != nil {
return skerr.Wrap(err)
}
defer func() {
if err := os.RemoveAll(tmpDir); err != nil {
sklog.Error(err)
}
}()
if _, err := exec.RunCwd(ctx, ".", srv.gitExe, "clone", "--depth=1", *repo, tmpDir); err != nil {
return fmt.Errorf("cloning repo %s: %s", *repo, err)
}
configDir = filepath.Join(tmpDir, *configs)
} else {
configDir = *configs
}
allTools, messages, err := tool.LoadAndValidateFromFS(os.DirFS(configDir))
if err != nil {
return skerr.Wrapf(err, "Validation messages: %v", messages)
}
b, err := json.Marshal(allTools)
if err != nil {
return skerr.Wrap(err)
}
srv.mutex.Lock()
defer srv.mutex.Unlock()
srv.tools = b
srv.configRefreshLiveness.Reset()
return nil
}
func (srv *server) loadTemplates() {
srv.templates = template.Must(template.New("").Delims("{%", "%}").ParseFiles(
filepath.Join(*baseapp.ResourcesDir, "index.html"),
))
}
// Serves up all the configs as serialized JSON.
func (srv *server) configHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
srv.mutex.Lock()
defer srv.mutex.Unlock()
_, err := w.Write(srv.tools)
if err != nil {
httputils.ReportError(w, err, "Failed serving configs.", http.StatusInternalServerError)
}
}
func (srv *server) createOrUpdateHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
ctx := r.Context()
var t tool.Tool
var b bytes.Buffer
if _, err := io.Copy(&b, r.Body); err != nil {
httputils.ReportError(w, err, "Failed copy incoming JSON", http.StatusInternalServerError)
return
}
// Validate that we have valid JSON.
if err := json.Unmarshal(b.Bytes(), &t); err != nil {
httputils.ReportError(w, err, "Failed decoding JSON", http.StatusInternalServerError)
return
}
configFile := t.ID + ".json"
if *configs != "" {
configFile = path.Join(*configs, configFile)
}
baseCommit, err := srv.gitilesRepo.ResolveRef(ctx, git.MainBranch)
if err != nil {
httputils.ReportError(w, err, "Failed to find base commit.", http.StatusInternalServerError)
return
}
// TODO(jcgregorio) Default reviewer set via command line flag?
author := srv.alogin.LoggedInAs(r)
ci, err := gerrit.CreateAndEditChange(ctx, srv.gerritRepo, srv.gerritProject, git.MainBranch, commitMessage, baseCommit, "", func(ctx context.Context, g gerrit.GerritInterface, ci *gerrit.ChangeInfo) error {
if ci.Owner == nil {
ci.Owner = &gerrit.Person{}
}
ci.Owner.Email = author.String()
return g.EditFile(ctx, ci, configFile, b.String())
})
if err != nil {
if ci != nil {
if err2 := srv.gerritRepo.Abandon(ctx, ci, "Failed to create CL"); err2 != nil {
sklog.Errorf("Failed to create CL with: %s\nAnd failed to abandon the change with: %s", err, err2)
}
}
httputils.ReportError(w, err, "Failed creating CL.", http.StatusInternalServerError)
return
}
resp := types.CreateOrUpdateResponse{
URL: srv.gerritRepo.Url(ci.Issue),
}
if err := json.NewEncoder(w).Encode(resp); err != nil {
httputils.ReportError(w, err, "Failed writing response.", http.StatusInternalServerError)
}
}
func (srv *server) mainHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
if *baseapp.Local {
srv.loadTemplates()
}
if err := srv.templates.ExecuteTemplate(w, "index.html", map[string]string{
"Nonce": secure.CSPNonce(r.Context()),
}); err != nil {
sklog.Errorf("Failed to expand template: %s", err)
}
}
// See baseapp.App.
func (srv *server) AddHandlers(r chi.Router) {
r.HandleFunc("/", srv.mainHandler)
r.HandleFunc("/_/configs", srv.configHandler)
r.HandleFunc("/_/put", srv.createOrUpdateHandler)
r.HandleFunc(("/_/login/status"), alogin.LoginStatusHandler(srv.alogin))
}
// See baseapp.App.
func (srv *server) AddMiddleware() []func(http.Handler) http.Handler {
ret := []func(http.Handler) http.Handler{}
if !*baseapp.Local {
ret = append(ret, alogin.ForceRoleMiddleware(srv.alogin, roles.Viewer))
}
return ret
}
func main() {
// Parse flags to be able to send *host to baseapp.Serve
flag.Parse()
baseapp.Serve(New, []string{*host})
}