blob: a5914c8bd05dbd07af3d2d9a7b8477e319981f1c [file]
// Copyright 2018 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package gitstore
import (
"context"
"fmt"
"regexp"
"strings"
"infra/appengine/crosskylabadmin/app/config"
"infra/libs/skylab/inventory"
"github.com/golang/protobuf/ptypes/empty"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/common/proto/gerrit"
"go.chromium.org/luci/common/proto/gitiles"
"google.golang.org/grpc"
)
// InventoryStore exposes skylab inventory data in git.
//
// Call InventoryStore.Refresh() to obtain the initial inventory data. After making
// modifications to the inventory, call InventoryStore.Commit().
// Call InventoryStore.Refresh() again if you want to use the object beyond a
// InventoryStore.Commit(), to re-validate the store.
type InventoryStore struct {
*inventory.Lab
*inventory.Infrastructure
gerritC GerritClient
gitilesC GitilesClient
latestFiles map[string]string
latestSHA1 string
}
// GerritClient is a client interface for Gerrit API.
//
// This is a subset of the interface defined at luci/common/proto/gerrit.
type GerritClient interface {
GetChange(ctx context.Context, in *gerrit.GetChangeRequest, opts ...grpc.CallOption) (*gerrit.ChangeInfo, error)
CreateChange(ctx context.Context, in *gerrit.CreateChangeRequest, opts ...grpc.CallOption) (*gerrit.ChangeInfo, error)
SubmitChange(ctx context.Context, in *gerrit.SubmitChangeRequest, opts ...grpc.CallOption) (*gerrit.ChangeInfo, error)
AbandonChange(ctx context.Context, in *gerrit.AbandonChangeRequest, opts ...grpc.CallOption) (*gerrit.ChangeInfo, error)
ChangeEditFileContent(ctx context.Context, in *gerrit.ChangeEditFileContentRequest, opts ...grpc.CallOption) (*empty.Empty, error)
DeleteEditFileContent(ctx context.Context, in *gerrit.DeleteEditFileContentRequest, opts ...grpc.CallOption) (*empty.Empty, error)
ChangeEditPublish(ctx context.Context, in *gerrit.ChangeEditPublishRequest, opts ...grpc.CallOption) (*empty.Empty, error)
SetReview(ctx context.Context, in *gerrit.SetReviewRequest, opts ...grpc.CallOption) (*gerrit.ReviewResult, error)
}
// GitilesClient is a client interface for Gitiles API.
//
// This is a subset of the interface defined at luci/common/proto/gitiles.
type GitilesClient interface {
Log(ctx context.Context, req *gitiles.LogRequest, opts ...grpc.CallOption) (*gitiles.LogResponse, error)
Archive(ctx context.Context, in *gitiles.ArchiveRequest, opts ...grpc.CallOption) (*gitiles.ArchiveResponse, error)
}
// NewInventoryStore returns a new InventoryStore.
//
// The returned store is not refreshed, hence all inventory data is empty.
func NewInventoryStore(gerritC GerritClient, gitilesC GitilesClient) *InventoryStore {
store := &InventoryStore{
gerritC: gerritC,
gitilesC: gitilesC,
}
store.clear()
return store
}
// IsEmptyErr returns true if the given error is because of am empty
// commit.
func IsEmptyErr(e error) bool {
_, ok := e.(emptyError)
return ok
}
type emptyError struct {
err error
}
func (e emptyError) Error() string {
return e.err.Error()
}
func (g *InventoryStore) commitInfrastructure(ctx context.Context, changed map[string]string, toDelete map[string]bool, path string) error {
is, err := inventory.WriteInfrastructureToString(g.Infrastructure)
if err != nil {
return errors.Annotate(err, "InventoryStore::commitInfrastructure: inventory store commit").Err()
}
if is != g.latestFiles[path] {
changed[path] = is
}
toDelete[path] = false
return nil
}
func (g *InventoryStore) commitDuts(ctx context.Context, changed map[string]string, toDelete map[string]bool) error {
for _, dut := range g.Lab.GetDuts() {
h := dut.GetCommon().GetHostname()
p := InvPathForDut(h)
oneDutLab := &inventory.Lab{
Duts: []*inventory.DeviceUnderTest{dut},
}
ls, err := inventory.WriteLabToString(oneDutLab)
if err != nil {
return errors.Annotate(err, "InventoryStore::commitDuts: inventory store commit").Err()
}
if ls != g.latestFiles[p] {
changed[p] = ls
}
toDelete[p] = false
}
return nil
}
// CommitAll commits the current inventory data in the store to git, replacing Commit().
//
// Successful Commit() invalidates the data cached in InventoryStore().
// To continue using the store, call Refresh() again
// TODO(xixuan): rename this to Commit() after per-file inventory is landed and tested.
func (g *InventoryStore) commitAll(ctx context.Context, reason string) (string, error) {
if g.latestSHA1 == "" {
return "", errors.New("can not commit invalid store")
}
ic := config.Get(ctx).Inventory
changed := make(map[string]string)
toDelete := make(map[string]bool, len(g.latestFiles))
for p := range g.latestFiles {
toDelete[p] = true
}
g.commitDuts(ctx, changed, toDelete)
g.commitInfrastructure(ctx, changed, toDelete, ic.InfrastructureDataPath)
// For removal
for p, delete := range toDelete {
if delete {
changed[p] = ""
}
}
if len(changed) == 0 {
return "", emptyError{errors.New("inventory store commit: nothing to commit")}
}
cn, err := commitFileContents(ctx, g.gerritC, ic.Project, ic.Branch, g.latestSHA1, reason, changed)
if err != nil {
return "", errors.Annotate(err, "InventoryStore::commitAll: inventory store commit: commitFileContents: ").Err()
}
u, err := changeURL(ic.GerritHost, ic.Project, cn)
if err != nil {
return "", errors.Annotate(err, "InventoryStore::commitAll: inventory store commit: changeURL: ").Err()
}
// Successful commit implies our refreshed data is not longer current, so
// the store cache is invalid.
g.clear()
return u, err
}
// regexp for chromeos15/chromeos15-XXX.textpb
const crosPrefix = `chromeos`
const invRegexp = `data/skylab/chromeos.*\/.*-.*\.textpb`
var invRe = regexp.MustCompile(invRegexp)
// InvPathForDut generates the relative path to the inventory git repo for a given DUT.
// e.g. data/skylab/chromeos6/chromeos6-***.textpb
func InvPathForDut(hostname string) string {
comps := strings.Split(hostname, "-")
var path string
if len(comps) == 0 || !strings.HasPrefix(comps[0], crosPrefix) {
// Keep chromeos as prefix for regular expression.
path = "chromeos-misc"
} else {
path = comps[0]
}
return fmt.Sprintf("data/skylab/%s/%s.textpb", path, hostname)
}
func validateInvPathForDut(p string) bool {
return invRe.MatchString(p)
}
// Commit commits the current inventory data in the store to git.
//
// Successful Commit() invalidates the data cached in InventoryStore().
// To continue using the store, call Refresh() again.
// TODO(xixuan): remove this after per-file inventory is landed and tested.
func (g *InventoryStore) Commit(ctx context.Context, reason string) (string, error) {
ic := config.Get(ctx).Inventory
if ic.GetMultifile() {
return g.commitAll(ctx, reason)
}
if g.latestSHA1 == "" {
return "", errors.New("InventoryStore::Commit: can not commit invalid store ")
}
changed := make(map[string]string)
ls, err := inventory.WriteLabToString(g.Lab)
if err != nil {
return "", errors.Annotate(err, "InventoryStore::Commit: inventory store commit: write lab to string: ").Err()
}
if ls != g.latestFiles[ic.LabDataPath] {
changed[ic.LabDataPath] = ls
}
is, err := inventory.WriteInfrastructureToString(g.Infrastructure)
if err != nil {
return "", errors.Annotate(err, "InventoryStore::Commit: inventory store commit: write infrastructure to string: ").Err()
}
if is != g.latestFiles[ic.InfrastructureDataPath] {
changed[ic.InfrastructureDataPath] = is
}
if len(changed) == 0 {
return "", emptyError{errors.New("InventoryStore::Commit: inventory store commit: nothing to commit")}
}
cn, err := commitFileContents(ctx, g.gerritC, ic.Project, ic.Branch, g.latestSHA1, reason, changed)
if err != nil {
return "", errors.Annotate(err, "InventoryStore::Commit: inventory store commit: commitFileContents: ").Err()
}
u, err := changeURL(ic.GerritHost, ic.Project, cn)
if err != nil {
return "", errors.Annotate(err, "InventoryStore::Commit: inventory store commit: changeURL: ").Err()
}
// Successful commit implies our refreshed data is not longer current, so
// the store cache is invalid.
g.clear()
return u, err
}
// RefreshAll populates all device data in the store from git, replacing Refresh().
// TODO(xixuan): rename this to Refresh() after per-file inventory is landed and tested.
func (g *InventoryStore) refreshAll(ctx context.Context) (rerr error) {
defer func() {
if rerr != nil {
g.clear()
}
}()
ic := config.Get(ctx).Inventory
var err error
g.latestSHA1, err = fetchLatestSHA1(ctx, g.gitilesC, ic.Project, ic.Branch)
if err != nil {
return errors.Annotate(err, "inventory store refresh").Err()
}
pmap := map[string]bool{
ic.InfrastructureDataPath: true,
}
g.latestFiles, err = fetchAllFromGitiles(ctx, g.gitilesC, ic.Project, g.latestSHA1, pmap)
if err != nil {
return errors.Annotate(err, "inventory store refresh").Err()
}
// Parse infrastructure
data, ok := g.latestFiles[ic.InfrastructureDataPath]
if !ok {
return errors.New("No infrastructure data in inventory")
}
g.Infrastructure = &inventory.Infrastructure{}
if err := inventory.LoadInfrastructureFromString(data, g.Infrastructure); err != nil {
return errors.Annotate(err, "inventory store refresh").Err()
}
g.Lab = &inventory.Lab{}
for p, data := range g.latestFiles {
if pmap[p] {
continue
}
var oneDutLab inventory.Lab
if err := inventory.LoadLabFromString(data, &oneDutLab); err != nil {
return errors.Annotate(err, "cannot dump text").Err()
}
g.Lab.Duts = append(g.Lab.Duts, oneDutLab.Duts...)
}
return nil
}
// Refresh populates inventory data in the store from git.
// TODO(xixuan): remove this after per-file inventory is landed and tested.
func (g *InventoryStore) Refresh(ctx context.Context) (rerr error) {
ic := config.Get(ctx).Inventory
if ic.GetMultifile() {
return g.refreshAll(ctx)
}
defer func() {
if rerr != nil {
g.clear()
}
}()
// TODO(pprabhu) Replace these checks with config validation.
if ic.LabDataPath == "" {
return errors.New("no lab data file path provided in config")
}
if ic.InfrastructureDataPath == "" {
return errors.New("no infrastructure data file path provided in config")
}
var err error
g.latestSHA1, err = fetchLatestSHA1(ctx, g.gitilesC, ic.Project, ic.Branch)
if err != nil {
return errors.Annotate(err, "inventory store refresh").Err()
}
g.latestFiles, err = fetchFilesFromGitiles(ctx, g.gitilesC, ic.Project, g.latestSHA1, []string{ic.LabDataPath, ic.InfrastructureDataPath})
if err != nil {
return errors.Annotate(err, "inventory store refresh").Err()
}
data, ok := g.latestFiles[ic.LabDataPath]
if !ok {
return errors.New("No lab data in inventory")
}
g.Lab = &inventory.Lab{}
if err := inventory.LoadLabFromString(data, g.Lab); err != nil {
return errors.Annotate(err, "inventory store refresh").Err()
}
data, ok = g.latestFiles[ic.InfrastructureDataPath]
if !ok {
return errors.New("No infrastructure data in inventory")
}
g.Infrastructure = &inventory.Infrastructure{}
if err := inventory.LoadInfrastructureFromString(data, g.Infrastructure); err != nil {
return errors.Annotate(err, "inventory store refresh").Err()
}
return nil
}
func (g *InventoryStore) clear() {
g.Lab = nil
g.Infrastructure = nil
g.latestSHA1 = ""
g.latestFiles = make(map[string]string)
}