blob: 5823abdc17da2c1082f6065cc44f8c488d98eb69 [file] [log] [blame]
// Copyright 2020 The LUCI Authors. All rights reserved.
// Use of this source code is governed under the Apache License, Version 2.0
// that can be found in the LICENSE file.
package main
import (
"context"
"fmt"
"os"
"runtime/debug"
"github.com/maruel/subcommands"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/common/logging"
"go.chromium.org/luci/common/logging/gologger"
"go.chromium.org/luci/common/logging/teelogger"
configpb "go.chromium.org/luci/common/proto/config"
"go.chromium.org/luci/common/sync/parallel"
"go.chromium.org/luci/config/cfgclient"
"infra/tools/migrator"
"infra/tools/migrator/internal/plugsupport"
)
func cmdScan(opts cmdBaseOptions) *subcommands.Command {
return &subcommands.Command{
UsageLine: "scan",
ShortDesc: "Scans the current state of the world and checks out non-compliant projects.",
LongDesc: `Scans current state of data in luci-config.
This command must be run within a migrator project. The scan will run all LUCI
projects through the project's plugin. Note that this scans the state of the
files in the luci-config service, NOT the state of the files in the migrator
project.
If the plugin's 'FindProblems' function is undefined OR it makes any Report
calls, this will ensure that the project is checked out locally on disk. If
FindProblems is defined and does NOT make any Report calls, this will inform
you that the checkout can be removed (pass '-clean' to automatically delete
them).
If scan does a new checkout, and the plugin defines ApplyFix, it will be invoked
once on the checked-out project.
If a checkout already exists on disk, this will NOT attempt to update it; It's
recommended to use standard git tooling to pull/rebase/etc. If you really want
a new checkout, you can delete the checked-out project and run 'scan' again
to get a fresh top-of-tree version.
`,
CommandRun: func() subcommands.CommandRun {
ret := cmdScanImpl{}
ret.initFlags(opts)
ret.Flags.BoolVar(&ret.squeaky, "squeaky", false,
"If set in conjunction with `clean`, will checkout all repos from scratch.")
ret.Flags.BoolVar(&ret.clean, "clean", false,
"If set, will automatically delete project checkouts which have no reported problems.")
ret.Flags.BoolVar(&ret.reapply, "re-apply", false,
"If set, will re-run ApplyFix, even if no new checkout was made.")
return &ret
},
}
}
type cmdScanImpl struct {
cmdBase
projectDir plugsupport.ProjectDir
squeaky bool
clean bool
reapply bool
}
func (r *cmdScanImpl) positionalRange() (min, max int) { return 0, 0 }
func (r *cmdScanImpl) validateFlags(ctx context.Context, positionals []string, env subcommands.Env) error {
if r.squeaky && !r.clean {
return errors.New("you can't be squeaky without being clean! (pass -clean flag)")
}
wd, err := os.Getwd()
if err != nil {
return errors.Annotate(err, "getting working directory").Err()
}
r.projectDir, err = plugsupport.FindProjectRoot(wd)
return err
}
func (r *cmdScanImpl) scanProject(ctx context.Context, inst migrator.API, proj migrator.Project) {
defer func() {
if rcov := recover(); rcov != nil {
// TODO(iannucci): report this better
logging.Errorf(ctx, "fatal error: %s", rcov)
logging.Errorf(ctx, string(debug.Stack()))
proj.Report("FATAL_ERROR", fmt.Sprintf("%s", rcov))
}
}()
inst.FindProblems(ctx, proj)
}
// Callback should return true to delete the project's log file.
func (r *cmdScanImpl) perProjectContext(ctx context.Context, projPB *configpb.Project, cb func(ctx context.Context) bool) {
ctx = plugsupport.InitReportSink(ctx)
ctx = (&gologger.LoggerConfig{
Out: os.Stderr,
// We pick a more helpful format here which includes the project.
// The gorey details of the filename are recorded to the .log file.
Format: fmt.Sprintf("%%{color}[%%{level:.1s}|%s]%%{color:reset} %%{message}", projPB.Id),
}).Use(ctx)
logFile, err := os.Create(r.projectDir.ProjectLog(projPB.Id))
if err != nil {
// should never happen, let it fly
panic(errors.Annotate(err, "opening logfile").Err())
}
defer logFile.Close()
ctx = teelogger.Use(
ctx,
(&gologger.LoggerConfig{
Out: logFile,
Format: `[%{level:.1s} %{shortfile}] %{message}`,
}).NewLogger,
)
if removeLog := cb(ctx); removeLog {
logFile.Close()
os.Remove(logFile.Name())
}
}
func (r *cmdScanImpl) doRepoCreation(ctx context.Context, inst migrator.API, projPB *configpb.Project, reporter migrator.Reportable) (repo migrator.Repo, newCheckout bool) {
if r.squeaky && r.clean {
if err := os.RemoveAll(r.projectDir.ProjectRepo(projPB.Id)); err != nil && !os.IsNotExist(err) {
logging.Errorf(ctx, "Failed to clean repo, creation may fail: %s", err)
}
}
repo, newCheckout, err := plugsupport.CreateOrLoadRepo(ctx, r.projectDir, projPB.Id, projPB)
if err != nil {
logging.Errorf(ctx, "Failed to checkout repo: %s", err)
reporter.Report("REPO_CREATION_FAILURE", "Failed to checkout/update repo")
return
}
return
}
func (r *cmdScanImpl) doApplyFix(ctx context.Context, inst migrator.API, reporter migrator.Reportable, repo migrator.Repo) {
defer func() {
if rcov := recover(); rcov != nil {
// TODO(iannucci): report this better
logging.Errorf(ctx, "fatal error: %s", rcov)
logging.Errorf(ctx, string(debug.Stack()))
reporter.Report("APPLY_FIX_FAILURE", "Failed to run ApplyFix")
}
}()
inst.ApplyFix(ctx, repo)
}
func (r *cmdScanImpl) doRepoCleanup(ctx context.Context, projID string) {
checkoutDir := r.projectDir.ProjectRepo(projID)
if r.clean {
if _, err := os.Stat(checkoutDir); !os.IsNotExist(err) {
logging.Warningf(ctx, "Cleaning checkout.")
if err := os.RemoveAll(checkoutDir); err != nil {
logging.Errorf(ctx, "Failed to remove repo: %s", err)
}
}
return
}
if _, err := os.Stat(checkoutDir); err == nil {
logging.Warningf(ctx, "No reports found; This checkout can be removed.")
logging.Warningf(ctx, "Pass `-clean` to do this automatically.")
}
}
func (r *cmdScanImpl) execute(ctx context.Context) error {
return withPlugin(ctx, r.projectDir, func(factory migrator.InstantiateAPI) error {
// Note; we use this formulation because the GetProjects API excludes vital
// information on how to check out the project from Git (specifically, the
// ref and path are omitted).
projectPB := &configpb.ProjectsCfg{}
err := cfgclient.Get(ctx, "services/luci-config", "projects.cfg", cfgclient.ProtoText(projectPB), nil)
if err != nil {
panic(errors.Annotate(err, "loading luci-config projects.cfg").Err())
}
allReports := &migrator.ReportDump{}
err = parallel.WorkPool(8, func(ch chan<- func() error) {
for _, projPB := range projectPB.Projects {
projPB := projPB
ch <- func() (err error) {
inst := factory()
r.perProjectContext(ctx, projPB, func(ctx context.Context) bool {
defer func() {
numReports := allReports.UpdateFrom(plugsupport.DumpReports(ctx))
if numReports > 0 {
logging.Warningf(ctx, "%d reports", numReports)
}
}()
proj := plugsupport.RemoteProject(ctx, projPB.Id)
r.scanProject(ctx, inst, proj)
if !plugsupport.HasActionableReports(ctx) {
r.doRepoCleanup(ctx, projPB.Id)
return true
}
// Otherwise create it, and maybe ApplyFix.
repo, newCheckout := r.doRepoCreation(ctx, inst, projPB, proj)
if repo != nil {
if newCheckout || r.reapply {
r.doApplyFix(ctx, inst, proj, repo)
} else if !newCheckout {
logging.Infof(ctx, "checkout already exists, skipping ApplyFix (pass -re-apply to run anyway).")
}
}
return false
})
return nil
}
}
})
if err != nil {
// Nothing above should return an error.
panic(err)
}
scanOut, err := os.Create(r.projectDir.ReportPath())
if err != nil {
return err
}
defer scanOut.Close()
return allReports.WriteToCSV(scanOut)
})
}
func (r *cmdScanImpl) Run(a subcommands.Application, args []string, env subcommands.Env) int {
return r.doContextExecute(a, r, args, env)
}