go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/config_service/internal/importer/importer.go (about)

     1  // Copyright 2023 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  // Package importer handles all configs importing.
    16  package importer
    17  
    18  import (
    19  	"archive/tar"
    20  	"bytes"
    21  	"context"
    22  	"crypto/sha256"
    23  	"encoding/hex"
    24  	"fmt"
    25  	"io"
    26  	"net/http"
    27  	"strings"
    28  	"time"
    29  
    30  	"cloud.google.com/go/storage"
    31  	"github.com/klauspost/compress/gzip"
    32  	"google.golang.org/protobuf/proto"
    33  
    34  	"go.chromium.org/luci/common/api/gitiles"
    35  	"go.chromium.org/luci/common/clock"
    36  	"go.chromium.org/luci/common/data/stringset"
    37  	"go.chromium.org/luci/common/errors"
    38  	"go.chromium.org/luci/common/gcloud/gs"
    39  	"go.chromium.org/luci/common/logging"
    40  	cfgcommonpb "go.chromium.org/luci/common/proto/config"
    41  	"go.chromium.org/luci/common/proto/git"
    42  	gitilespb "go.chromium.org/luci/common/proto/gitiles"
    43  	"go.chromium.org/luci/common/sync/parallel"
    44  	"go.chromium.org/luci/config"
    45  	"go.chromium.org/luci/gae/service/datastore"
    46  	"go.chromium.org/luci/server/auth"
    47  	"go.chromium.org/luci/server/cron"
    48  	"go.chromium.org/luci/server/router"
    49  	"go.chromium.org/luci/server/tq"
    50  
    51  	"go.chromium.org/luci/config_service/internal/acl"
    52  	"go.chromium.org/luci/config_service/internal/clients"
    53  	"go.chromium.org/luci/config_service/internal/common"
    54  	"go.chromium.org/luci/config_service/internal/metrics"
    55  	"go.chromium.org/luci/config_service/internal/model"
    56  	"go.chromium.org/luci/config_service/internal/settings"
    57  	"go.chromium.org/luci/config_service/internal/taskpb"
    58  	"go.chromium.org/luci/config_service/internal/validation"
    59  )
    60  
    61  const (
    62  	// compressedContentLimit is the maximum allowed compressed content size to
    63  	// store into Datastore, in order to avoid exceeding 1MiB limit per entity.
    64  	compressedContentLimit = 800 * 1024
    65  
    66  	// maxRetryCount is the maximum number of retries allowed when importing
    67  	// a single config set hit non-fatal error.
    68  	maxRetryCount = 5
    69  
    70  	// importAllConfigsInterval is the interval between two import config cron
    71  	// jobs.
    72  	importAllConfigsInterval = 10 * time.Minute
    73  )
    74  
    75  var (
    76  	// ErrFatalTag is an error tag to indicate an unrecoverable error in the
    77  	// configs importing flow.
    78  	ErrFatalTag = errors.BoolTag{Key: errors.NewTagKey("A config importing unrecoverable error")}
    79  )
    80  
    81  // validator defines the interface to interact with validation logic.
    82  type validator interface {
    83  	Validate(context.Context, config.Set, []validation.File) (*cfgcommonpb.ValidationResult, error)
    84  }
    85  
    86  // Importer is able to import a config set.
    87  type Importer struct {
    88  	// GSBucket is the bucket name where the imported configs will be stored to.
    89  	GSBucket string
    90  	// Validator is used to validate the configs before import.
    91  	Validator validator
    92  }
    93  
    94  // RegisterImportConfigsCron register the cron to trigger import for all config
    95  // sets
    96  func (i *Importer) RegisterImportConfigsCron(dispatcher *tq.Dispatcher) {
    97  	i.registerTQTask(dispatcher)
    98  	cron.RegisterHandler("import-configs", func(ctx context.Context) error {
    99  		return importAllConfigs(ctx, dispatcher)
   100  	})
   101  }
   102  
   103  func (i *Importer) registerTQTask(dispatcher *tq.Dispatcher) {
   104  	dispatcher.RegisterTaskClass(tq.TaskClass{
   105  		ID:        "import-configs",
   106  		Kind:      tq.NonTransactional,
   107  		Prototype: (*taskpb.ImportConfigs)(nil),
   108  		Queue:     "backend-v2",
   109  		Handler: func(ctx context.Context, payload proto.Message) error {
   110  			task := payload.(*taskpb.ImportConfigs)
   111  			switch err := i.ImportConfigSet(ctx, config.Set(task.GetConfigSet())); {
   112  			case ErrFatalTag.In(err):
   113  				return tq.Fatal.Apply(err)
   114  			case err != nil: // non-fatal error
   115  				if info := tq.TaskExecutionInfo(ctx); info != nil && info.ExecutionCount >= maxRetryCount {
   116  					// ignore the task as it exceeds the max retry count. Alert should be
   117  					// set up to monitor the number of retries.
   118  					return tq.Ignore.Apply(err)
   119  				}
   120  				return err // tq will retry the error
   121  			default:
   122  				return nil
   123  			}
   124  		},
   125  	})
   126  }
   127  
   128  // importAllConfigs schedules a task for each service and project config set to
   129  // import configs from Gitiles and clean up stale config sets.
   130  func importAllConfigs(ctx context.Context, dispatcher *tq.Dispatcher) error {
   131  	cfgLoc := settings.GetGlobalConfigLoc(ctx)
   132  
   133  	// Get all config sets.
   134  	cfgSets, err := getAllServiceCfgSets(ctx, cfgLoc)
   135  	if err != nil {
   136  		return errors.Annotate(err, "failed to load service config sets").Err()
   137  	}
   138  	sets, err := getAllProjCfgSets(ctx)
   139  	if err != nil {
   140  		return errors.Annotate(err, "failed to load project config sets").Err()
   141  	}
   142  	cfgSets = append(cfgSets, sets...)
   143  
   144  	// Enqueue tasks
   145  	err = parallel.WorkPool(8, func(workCh chan<- func() error) {
   146  		for _, cs := range cfgSets {
   147  			cs := cs
   148  			workCh <- func() error {
   149  				err := dispatcher.AddTask(ctx, &tq.Task{
   150  					Payload: &taskpb.ImportConfigs{ConfigSet: cs},
   151  					Title:   fmt.Sprintf("configset/%s", cs),
   152  					ETA:     clock.Now(ctx).Add(common.DistributeOffset(importAllConfigsInterval, "config_set", string(cs))),
   153  				})
   154  				return errors.Annotate(err, "failed to enqueue ImportConfigs task for %q: %s", cs, err).Err()
   155  			}
   156  		}
   157  	})
   158  	if err != nil {
   159  		return err
   160  	}
   161  
   162  	// Delete stale config sets.
   163  	var keys []*datastore.Key
   164  	if err := datastore.GetAll(ctx, datastore.NewQuery(model.ConfigSetKind).KeysOnly(true), &keys); err != nil {
   165  		return errors.Annotate(err, "failed to fetch all config sets from Datastore").Err()
   166  	}
   167  	cfgSetsInDB := stringset.New(len(keys))
   168  	for _, key := range keys {
   169  		cfgSetsInDB.Add(key.StringID())
   170  	}
   171  	cfgSetsInDB.DelAll(cfgSets)
   172  	if len(cfgSetsInDB) > 0 {
   173  		logging.Infof(ctx, "deleting stale config sets: %v", cfgSetsInDB)
   174  		var toDel []*datastore.Key
   175  		for _, cs := range cfgSetsInDB.ToSlice() {
   176  			toDel = append(toDel, datastore.KeyForObj(ctx, &model.ConfigSet{ID: config.Set(cs)}))
   177  		}
   178  		return datastore.Delete(ctx, toDel)
   179  	}
   180  	return nil
   181  }
   182  
   183  // getAllProjCfgSets fetches all "projects/*" config sets
   184  func getAllProjCfgSets(ctx context.Context) ([]string, error) {
   185  	projectsCfg := &cfgcommonpb.ProjectsCfg{}
   186  	var nerr *model.NoSuchConfigError
   187  	switch err := common.LoadSelfConfig[*cfgcommonpb.ProjectsCfg](ctx, common.ProjRegistryFilePath, projectsCfg); {
   188  	case errors.As(err, &nerr) && nerr.IsUnknownConfigSet():
   189  		// May happen on the cron job first run. Just log the warning.
   190  		logging.Warningf(ctx, "failed to compose all project config sets because the self config set is missing")
   191  		return nil, nil
   192  	case err != nil:
   193  		return nil, err
   194  	}
   195  	cfgsets := make([]string, len(projectsCfg.Projects))
   196  	for i, proj := range projectsCfg.Projects {
   197  		cs, err := config.ProjectSet(proj.Id)
   198  		if err != nil {
   199  			return nil, err
   200  		}
   201  		cfgsets[i] = string(cs)
   202  	}
   203  	return cfgsets, nil
   204  }
   205  
   206  // getAllServiceCfgSets returns all "service/*" config sets.
   207  func getAllServiceCfgSets(ctx context.Context, cfgLoc *cfgcommonpb.GitilesLocation) ([]string, error) {
   208  	if cfgLoc == nil {
   209  		return nil, nil
   210  	}
   211  	host, project, err := gitiles.ParseRepoURL(cfgLoc.Repo)
   212  	if err != nil {
   213  		return nil, errors.Annotate(err, "invalid gitiles repo: %s", cfgLoc.Repo).Err()
   214  	}
   215  	gitilesClient, err := clients.NewGitilesClient(ctx, host, "")
   216  	if err != nil {
   217  		return nil, errors.Annotate(err, "failed to create a gitiles client").Err()
   218  	}
   219  
   220  	res, err := gitilesClient.ListFiles(ctx, &gitilespb.ListFilesRequest{
   221  		Project:    project,
   222  		Committish: cfgLoc.Ref,
   223  		Path:       cfgLoc.Path,
   224  	})
   225  	if err != nil {
   226  		return nil, errors.Annotate(err, "failed to call Gitiles to list files").Err()
   227  	}
   228  	var cfgSets []string
   229  	for _, f := range res.GetFiles() {
   230  		if f.Type != git.File_TREE {
   231  			continue
   232  		}
   233  		cs, err := config.ServiceSet(f.GetPath())
   234  		if err != nil {
   235  			logging.Errorf(ctx, "skip importing service config: %s", err)
   236  			continue
   237  		}
   238  		cfgSets = append(cfgSets, string(cs))
   239  	}
   240  	return cfgSets, nil
   241  }
   242  
   243  // ImportConfigSet tries to import a config set.
   244  // TODO(crbug.com/1446839): Optional: for ErrFatalTag errors or errors which are
   245  // retried many times, may send notifications to Config Service owners in future
   246  // after the notification functionality is done.
   247  func (i *Importer) ImportConfigSet(ctx context.Context, cfgSet config.Set) error {
   248  	if sID := cfgSet.Service(); sID != "" {
   249  		globalCfgLoc := settings.GetGlobalConfigLoc(ctx)
   250  		return i.importConfigSet(ctx, cfgSet, &cfgcommonpb.GitilesLocation{
   251  			Repo: globalCfgLoc.Repo,
   252  			Ref:  globalCfgLoc.Ref,
   253  			Path: strings.TrimPrefix(fmt.Sprintf("%s/%s", globalCfgLoc.Path, sID), "/"),
   254  		})
   255  	} else if pID := cfgSet.Project(); pID != "" {
   256  		return i.importProject(ctx, pID)
   257  	}
   258  	return errors.Reason("Invalid config set: %q", cfgSet).Tag(ErrFatalTag).Err()
   259  }
   260  
   261  // importProject imports a project config set.
   262  func (i *Importer) importProject(ctx context.Context, projectID string) error {
   263  	projectsCfg := &cfgcommonpb.ProjectsCfg{}
   264  	if err := common.LoadSelfConfig[*cfgcommonpb.ProjectsCfg](ctx, common.ProjRegistryFilePath, projectsCfg); err != nil {
   265  		return ErrFatalTag.Apply(err)
   266  	}
   267  	var projLoc *cfgcommonpb.GitilesLocation
   268  	for _, p := range projectsCfg.GetProjects() {
   269  		if p.Id == projectID {
   270  			projLoc = p.GetGitilesLocation()
   271  			break
   272  		}
   273  	}
   274  	if projLoc == nil {
   275  		return errors.Reason("project %q not exist or has no gitiles location", projectID).Tag(ErrFatalTag).Err()
   276  	}
   277  	return i.importConfigSet(ctx, config.MustProjectSet(projectID), projLoc)
   278  }
   279  
   280  // importConfigSet tries to import the latest version of the given config set.
   281  // TODO(crbug.com/1446839): Add code to report to metrics
   282  func (i *Importer) importConfigSet(ctx context.Context, cfgSet config.Set, loc *cfgcommonpb.GitilesLocation) error {
   283  	ctx = logging.SetFields(ctx, logging.Fields{
   284  		"ConfigSet": string(cfgSet),
   285  		"Location":  common.GitilesURL(loc),
   286  	})
   287  	logging.Infof(ctx, "Start importing configs")
   288  	saveAttempt := func(success bool, msg string, commit *git.Commit) error {
   289  		attempt := &model.ImportAttempt{
   290  			ConfigSet: datastore.KeyForObj(ctx, &model.ConfigSet{ID: cfgSet}),
   291  			Success:   success,
   292  			Message:   msg,
   293  			Revision: model.RevisionInfo{
   294  				Location: &cfgcommonpb.Location{
   295  					Location: &cfgcommonpb.Location_GitilesLocation{
   296  						GitilesLocation: proto.Clone(loc).(*cfgcommonpb.GitilesLocation),
   297  					},
   298  				},
   299  			},
   300  		}
   301  		if commit != nil {
   302  			attempt.Revision.ID = commit.Id
   303  			attempt.Revision.Location.GetGitilesLocation().Ref = commit.Id
   304  			attempt.Revision.CommitTime = commit.Committer.GetTime().AsTime()
   305  			attempt.Revision.CommitterEmail = commit.Committer.GetEmail()
   306  			attempt.Revision.AuthorEmail = commit.Author.GetEmail()
   307  		}
   308  		return datastore.Put(ctx, attempt)
   309  	}
   310  
   311  	host, project, err := gitiles.ParseRepoURL(loc.Repo)
   312  	if err != nil {
   313  		err = errors.Annotate(err, "invalid gitiles repo: %s", loc.Repo).Err()
   314  		return ErrFatalTag.Apply(errors.Append(err, saveAttempt(false, err.Error(), nil)))
   315  	}
   316  	gtClient, err := clients.NewGitilesClient(ctx, host, cfgSet.Project())
   317  	if err != nil {
   318  		err = errors.Annotate(err, "failed to create a gitiles client").Err()
   319  		return ErrFatalTag.Apply(errors.Append(err, saveAttempt(false, err.Error(), nil)))
   320  	}
   321  
   322  	logRes, err := gtClient.Log(ctx, &gitilespb.LogRequest{
   323  		Project:    project,
   324  		Committish: loc.Ref,
   325  		Path:       loc.Path,
   326  		PageSize:   1,
   327  	})
   328  	if err != nil {
   329  		err = errors.Annotate(err, "cannot fetch logs from %s", common.GitilesURL(loc)).Err()
   330  		return errors.Append(err, saveAttempt(false, err.Error(), nil))
   331  	}
   332  	if logRes == nil || len(logRes.GetLog()) == 0 {
   333  		logging.Warningf(ctx, "No commits")
   334  		return saveAttempt(true, "no commit logs", nil)
   335  	}
   336  	latestCommit := logRes.Log[0]
   337  
   338  	cfgSetInDB := &model.ConfigSet{ID: cfgSet}
   339  	lastAttempt := &model.ImportAttempt{ConfigSet: datastore.KeyForObj(ctx, cfgSetInDB)}
   340  	switch err := datastore.Get(ctx, cfgSetInDB, lastAttempt); {
   341  	case errors.Contains(err, datastore.ErrNoSuchEntity): // proceed with importing
   342  	case err != nil:
   343  		err = errors.Annotate(err, "failed to load config set %q or its last attempt", cfgSet).Err()
   344  		return errors.Append(err, saveAttempt(false, err.Error(), latestCommit))
   345  	case cfgSetInDB.LatestRevision.ID == latestCommit.Id &&
   346  		proto.Equal(cfgSetInDB.Location.GetGitilesLocation(), loc) &&
   347  		cfgSetInDB.Version == model.CurrentCfgSetVersion &&
   348  		lastAttempt.Success && // otherwise, something wrong with lastAttempt, better to import again.
   349  		len(lastAttempt.ValidationResult.GetMessages()) == 0: // avoid overriding lastAttempt's validationResult.
   350  		logging.Debugf(ctx, "Already up-to-date")
   351  		return saveAttempt(true, "Up-to-date", latestCommit)
   352  	}
   353  
   354  	logging.Infof(ctx, "Rolling %s => %s", cfgSetInDB.LatestRevision.ID, latestCommit.Id)
   355  	err = i.importRevision(ctx, cfgSet, loc, latestCommit, gtClient, project)
   356  	if err != nil {
   357  		err = errors.Annotate(err, "Failed to import %s revision %s", cfgSet, latestCommit.Id).Err()
   358  		return errors.Append(err, saveAttempt(false, err.Error(), latestCommit))
   359  	}
   360  	return nil
   361  }
   362  
   363  // importRevision imports a referenced Gitiles revision into a config set.
   364  // It only imports when all files are valid.
   365  // TODO(crbug.com/1446839): send notifications for any validation errors.
   366  func (i *Importer) importRevision(ctx context.Context, cfgSet config.Set, loc *cfgcommonpb.GitilesLocation, commit *git.Commit, gtClient gitilespb.GitilesClient, gitilesProj string) error {
   367  	if loc == nil || commit == nil {
   368  		return nil
   369  	}
   370  	res, err := gtClient.Archive(ctx, &gitilespb.ArchiveRequest{
   371  		Project: gitilesProj,
   372  		Ref:     commit.Id,
   373  		Path:    loc.Path,
   374  		Format:  gitilespb.ArchiveRequest_GZIP,
   375  	})
   376  	if err != nil {
   377  		return err
   378  	}
   379  	rev := model.RevisionInfo{
   380  		ID:             commit.Id,
   381  		CommitTime:     commit.Committer.GetTime().AsTime(),
   382  		CommitterEmail: commit.Committer.GetEmail(),
   383  		AuthorEmail:    commit.Author.GetEmail(),
   384  		Location: &cfgcommonpb.Location{
   385  			Location: &cfgcommonpb.Location_GitilesLocation{
   386  				GitilesLocation: &cfgcommonpb.GitilesLocation{
   387  					Repo: loc.Repo,
   388  					Ref:  commit.Id, // TODO(crbug.com/1446839): rename to committish.
   389  					Path: loc.Path,
   390  				},
   391  			},
   392  		},
   393  	}
   394  	attempt := &model.ImportAttempt{
   395  		ConfigSet: datastore.MakeKey(ctx, model.ConfigSetKind, string(cfgSet)),
   396  		Revision:  rev,
   397  	}
   398  	configSet := &model.ConfigSet{
   399  		ID: cfgSet,
   400  		Location: &cfgcommonpb.Location{
   401  			Location: &cfgcommonpb.Location_GitilesLocation{GitilesLocation: loc},
   402  		},
   403  		LatestRevision: rev,
   404  	}
   405  
   406  	if res == nil || len(res.Contents) == 0 {
   407  		logging.Warningf(ctx, "Configs for %s don't exist. They may be deleted", cfgSet)
   408  		attempt.Success = true
   409  		attempt.Message = "No Configs. Imported as empty"
   410  		return datastore.RunInTransaction(ctx, func(c context.Context) error {
   411  			return datastore.Put(ctx, configSet, attempt)
   412  		}, nil)
   413  	}
   414  
   415  	var files []*model.File
   416  	gzReader, err := gzip.NewReader(bytes.NewReader(res.Contents))
   417  	if err != nil {
   418  		return errors.Annotate(err, "failed to ungzip gitiles archive").Err()
   419  	}
   420  	defer func() {
   421  		// Ignore the error. Failing to close it doesn't impact Luci-config
   422  		// recognize configs correctly, as they are already saved into data storage.
   423  		_ = gzReader.Close()
   424  	}()
   425  
   426  	tarReader := tar.NewReader(gzReader)
   427  	for {
   428  		header, err := tarReader.Next()
   429  		if err == io.EOF {
   430  			break
   431  		}
   432  		if err != nil {
   433  			return errors.Annotate(err, "failed to extract gitiles archive").Err()
   434  		}
   435  		if header.Typeflag != tar.TypeReg {
   436  			continue
   437  		}
   438  
   439  		filePath := header.Name
   440  		logging.Infof(ctx, "Processing file: %q", filePath)
   441  		file := &model.File{
   442  			Path:     filePath,
   443  			Revision: datastore.MakeKey(ctx, model.ConfigSetKind, string(cfgSet), model.RevisionKind, commit.Id),
   444  			Size:     header.Size,
   445  			Location: &cfgcommonpb.Location{
   446  				Location: &cfgcommonpb.Location_GitilesLocation{
   447  					GitilesLocation: &cfgcommonpb.GitilesLocation{
   448  						Repo: loc.Repo,
   449  						Ref:  commit.Id,
   450  						Path: strings.TrimPrefix(fmt.Sprintf("%s/%s", loc.Path, filePath), "/"),
   451  					},
   452  				},
   453  			},
   454  		}
   455  		if file.ContentSHA256, file.Content, err = hashAndCompressConfig(tarReader); err != nil {
   456  			return errors.Annotate(err, "filepath: %q", filePath).Err()
   457  		}
   458  		gsFileName := fmt.Sprintf("%s/sha256/%s", common.GSProdCfgFolder, file.ContentSHA256)
   459  		_, err = clients.GetGsClient(ctx).UploadIfMissing(ctx, i.GSBucket, gsFileName, file.Content, func(attrs *storage.ObjectAttrs) {
   460  			attrs.ContentEncoding = "gzip"
   461  		})
   462  		if err != nil {
   463  			return errors.Annotate(err, "failed to upload file %s as %s", filePath, gsFileName).Err()
   464  		}
   465  		file.GcsURI = gs.MakePath(i.GSBucket, gsFileName)
   466  		if len(file.Content) > compressedContentLimit {
   467  			// Don't save the compressed content if the content is above the limit.
   468  			// Since Datastore has 1MiB entity size limit.
   469  			file.Content = nil
   470  		}
   471  		files = append(files, file)
   472  	}
   473  	if err := i.validateAndPopulateAttempt(ctx, cfgSet, files, attempt); err != nil {
   474  		return err
   475  	}
   476  	if !attempt.Success {
   477  		if author, ok := strings.CutSuffix(commit.Author.GetEmail(), "@google.com"); ok {
   478  			metrics.RejectedCfgImportCounter.Add(ctx, 1, string(cfgSet), commit.Id, author)
   479  		} else {
   480  			metrics.RejectedCfgImportCounter.Add(ctx, 1, string(cfgSet), commit.Id, "")
   481  		}
   482  		return errors.Annotate(datastore.Put(ctx, attempt), "saving attempt").Err()
   483  	}
   484  	// The rejection event rarely happen. Add by 0 to ensure the corresponding
   485  	// streamz counter can properly handle this metric.
   486  	metrics.RejectedCfgImportCounter.Add(ctx, 0, string(cfgSet), "", "")
   487  
   488  	logging.Infof(ctx, "Storing %d files, updating ConfigSet %s and ImportAttempt", len(files), cfgSet)
   489  	now := clock.Now(ctx).UTC()
   490  	for _, f := range files {
   491  		f.CreateTime = now
   492  	}
   493  	// Datastore transaction has a maximum size of 10MB.
   494  	if err := datastore.Put(ctx, files); err != nil {
   495  		return errors.Annotate(err, "failed to store files").Err()
   496  	}
   497  	return datastore.RunInTransaction(ctx, func(c context.Context) error {
   498  		return datastore.Put(ctx, configSet, attempt)
   499  	}, nil)
   500  }
   501  
   502  // hashAndCompressConfig reads the config and returns the sha256 of the config
   503  // and the gzip-compressed bytes.
   504  func hashAndCompressConfig(reader io.Reader) (string, []byte, error) {
   505  	sha := sha256.New()
   506  	compressed := &bytes.Buffer{}
   507  	gzipWriter := gzip.NewWriter(compressed)
   508  	multiWriter := io.MultiWriter(sha, gzipWriter)
   509  	if _, err := io.Copy(multiWriter, reader); err != nil {
   510  		_ = gzipWriter.Close()
   511  		return "", nil, errors.Annotate(err, "error reading tar file").Err()
   512  	}
   513  	if err := gzipWriter.Close(); err != nil {
   514  		return "", nil, errors.Annotate(err, "failed to close gzip writer").Err()
   515  	}
   516  	return hex.EncodeToString(sha.Sum(nil)), compressed.Bytes(), nil
   517  }
   518  
   519  func (i *Importer) validateAndPopulateAttempt(ctx context.Context, cfgSet config.Set, files []*model.File, attempt *model.ImportAttempt) error {
   520  	vfs := make([]validation.File, len(files))
   521  	for i, f := range files {
   522  		vfs[i] = f
   523  	}
   524  	vr, err := i.Validator.Validate(ctx, cfgSet, vfs)
   525  	if err != nil {
   526  		return errors.Annotate(err, "validating config set %q", cfgSet).Err()
   527  	}
   528  	attempt.Success = true // be optimistic
   529  	attempt.Message = "Imported"
   530  	attempt.ValidationResult = vr
   531  	for _, msg := range attempt.ValidationResult.GetMessages() {
   532  		switch sev := msg.GetSeverity(); {
   533  		case sev >= cfgcommonpb.ValidationResult_ERROR:
   534  			attempt.Success = false
   535  			attempt.Message = "Invalid config"
   536  			return nil
   537  		case sev == cfgcommonpb.ValidationResult_WARNING:
   538  			attempt.Message = "Imported with warnings"
   539  		}
   540  	}
   541  	return nil
   542  }
   543  
   544  // Reimport handles the HTTP request of reimporting a single config set.
   545  func (i *Importer) Reimport(c *router.Context) {
   546  	ctx := c.Request.Context()
   547  	caller := auth.CurrentIdentity(ctx)
   548  	cs := config.Set(strings.Trim(c.Params.ByName("ConfigSet"), "/"))
   549  
   550  	if cs == "" {
   551  		http.Error(c.Writer, "config set is not specified", http.StatusBadRequest)
   552  		return
   553  	} else if err := cs.Validate(); err != nil {
   554  		http.Error(c.Writer, fmt.Sprintf("invalid config set: %s", err), http.StatusBadRequest)
   555  		return
   556  	}
   557  
   558  	switch hasPerm, err := acl.CanReimportConfigSet(ctx, cs); {
   559  	case err != nil:
   560  		logging.Errorf(ctx, "cannot check permission for %q: %s", caller, err)
   561  		http.Error(c.Writer, fmt.Sprintf("cannot check permission for %q", caller), http.StatusInternalServerError)
   562  		return
   563  	case !hasPerm:
   564  		logging.Infof(ctx, "%q does not have access to %s", caller, cs)
   565  		http.Error(c.Writer, fmt.Sprintf("%q is not allowed to reimport %s", caller, cs), http.StatusForbidden)
   566  		return
   567  	}
   568  
   569  	switch exists, err := datastore.Exists(ctx, &model.ConfigSet{ID: cs}); {
   570  	case err != nil:
   571  		logging.Errorf(ctx, "failed to check existence of %s", cs)
   572  		http.Error(c.Writer, fmt.Sprintf("error when reimporting  %s", cs), http.StatusInternalServerError)
   573  		return
   574  	case !exists.All():
   575  		logging.Infof(ctx, "config set %s doesn't exist", cs)
   576  		http.Error(c.Writer, fmt.Sprintf("%q is not found", cs), http.StatusNotFound)
   577  		return
   578  	}
   579  
   580  	if err := i.ImportConfigSet(ctx, cs); err != nil {
   581  		logging.Errorf(ctx, "cannot re-import config set %s: %s", cs, err)
   582  		http.Error(c.Writer, fmt.Sprintf("error when reimporting %q", cs), http.StatusInternalServerError)
   583  		return
   584  	}
   585  	c.Writer.WriteHeader(http.StatusOK)
   586  }