blob: 8e8c30c742a4ebc29d3d02a2035a898e511bd36d [file] [log] [blame]
package buildskia
import (
"context"
"errors"
"fmt"
"io"
"os"
"path"
"path/filepath"
"strings"
"sync"
"time"
"go.skia.org/infra/go/metrics2"
"go.skia.org/infra/go/sklog"
"go.skia.org/infra/go/util"
"go.skia.org/infra/go/vcsinfo"
)
const (
GOOD_BUILDS_FILENAME = "goodbuilds.txt"
// PRESERVE_DURATION is used to determine if an LKGR commit should be
// preserved. i.e. if a the distance between two commits is greater than
// PRESERVER_DURATION then they both should be preserved.
PRESERVE_DURATION = 30 * 24 * time.Hour
// DECIMATION_PERIOD is the time between decimation runs.
DECIMATION_PERIOD = time.Hour
// BUILD_TYPE is the type of build we use throughout.
BUILD_TYPE = RELEASE_BUILD
)
// errors
var (
AlreadyExistsErr = errors.New("Checkout already exists.")
)
// PerBuild is a callback function where ContinuousBuilder clients can
// perform specific builds within the newest Skia checkout.
type PerBuild func(ctx context.Context, checkout, depotTools string) error
// ContinuousBuilder is for building versions of the Skia library and then compiling and
// running command-line apps against those built versions.
//
// For each LKGR of Skia, that version of code will be checked out under
// workDir/"versions"/<git hash>.
type ContinuousBuilder struct {
workRoot string
depotTools string
repo vcsinfo.VCS
perBuild PerBuild
preserve int
useGn bool
buildFailures metrics2.Counter
buildLiveness metrics2.Liveness
repoSyncFailures metrics2.Counter
timeBetweenBuilds time.Duration
// hashes is a cache of the hashes returned from Available.
hashes []string
// current is the current commit we are building at.
current *vcsinfo.LongCommit
// mutex protects access to hashes, current, and GOOD_BUILDS_FILENAME.
mutex sync.Mutex
}
// New returns a new ContinuousBuilder instance.
//
// workRoot - The root directory where work is stored.
// depotTools - The directory where depot_tools is checked out.
// repo - A vcs to pull hash info from.
// perBuild - A PerBuild callback that gets called every time a new successful build of Skia is available.
// preserve - The number of LKGR builds to preserve after decimation.
// timeBetweenBuilds - The duration between attempts to pull LGKR and built it.
// useGn - Use GN as the meta build system, as opposed to GYP.
//
// Call Start() to begin the continous build Go routine.
func New(ctx context.Context, workRoot, depotTools string, repo vcsinfo.VCS, perBuild PerBuild, preserve int, timeBetweenBuilds time.Duration, useGn bool) *ContinuousBuilder {
b := &ContinuousBuilder{
workRoot: workRoot,
depotTools: depotTools,
repo: repo,
perBuild: perBuild,
preserve: preserve,
buildFailures: metrics2.GetCounter("builds_failed", nil),
buildLiveness: metrics2.NewLiveness("build"),
repoSyncFailures: metrics2.GetCounter("repo_sync_failed", nil),
timeBetweenBuilds: timeBetweenBuilds,
useGn: useGn,
}
_, _ = b.AvailableBuilds() // Called for side-effect of loading hashes.
go b.startDecimation(ctx)
b.updateCurrent(ctx)
return b
}
func (b *ContinuousBuilder) singleBuildLatest(ctx context.Context) {
if err := b.repo.Update(ctx, true, true); err != nil {
sklog.Errorf("Failed to update skia repo used to look up git hashes: %s", err)
b.repoSyncFailures.Inc(1)
}
b.repoSyncFailures.Reset()
ci, err := b.BuildLatestSkia(ctx, false, true, false)
if err != nil {
sklog.Errorf("Failed to build LKGR: %s", err)
// Only measure real build failures, not a failure if LKGR hasn't updated.
if err != AlreadyExistsErr {
b.buildFailures.Inc(1)
}
return
}
b.buildFailures.Reset()
b.buildLiveness.Reset()
sklog.Infof("Successfully built: %s %s", ci.Hash, ci.Subject)
}
// Start the continuous build latest LKGR Go routine.
func (b *ContinuousBuilder) Start(ctx context.Context) {
go func() {
b.singleBuildLatest(ctx)
for range time.Tick(b.timeBetweenBuilds) {
b.singleBuildLatest(ctx)
}
}()
}
// prepDirectory adds the 'versions' directory to the workRoot
// and returns the full path of that directory.
func prepDirectory(workRoot string) (string, error) {
versions := path.Join(workRoot, "versions")
if err := os.MkdirAll(versions, 0777); err != nil {
return "", fmt.Errorf("Failed to create WORK_ROOT/versions dir: %s", err)
}
return versions, nil
}
// BuildLatestSkia builds the LKGR of Skia in the given workRoot directory.
//
// The library will be checked out into workRoot + "/" + githash, where githash
// is the githash of the LKGR of Skia.
//
// force - If true then checkout and build even if the directory already exists.
// head - If true then build Skia at HEAD, otherwise build Skia at LKGR.
// deps - If true then install Skia dependencies.
//
// Returns the commit info for the revision of Skia checked out.
// Returns an error if any step fails, or return AlreadyExistsErr if
// the target checkout directory already exists and force is false.
//
// In GN mode ninja files are not generated, the caller must call GNGen
// in their PerBuild callback before calling GNNinjaBuild.
func (b *ContinuousBuilder) BuildLatestSkia(ctx context.Context, force bool, head bool, deps bool) (*vcsinfo.LongCommit, error) {
versions, err := prepDirectory(b.workRoot)
if err != nil {
return nil, err
}
githash := ""
if head {
if githash, err = GetSkiaHead(nil); err != nil {
return nil, fmt.Errorf("Failed to retrieve Skia HEAD: %s", err)
}
} else {
if githash, err = GetSkiaHash(nil); err != nil {
return nil, fmt.Errorf("Failed to retrieve Skia LKGR: %s", err)
}
}
checkout := path.Join(versions, githash)
fi, err := os.Stat(checkout)
// If the file is present and a directory then only proceed if 'force' is true.
if err == nil && fi.IsDir() == true && !force {
sklog.Infof("Dir already exists: %s", checkout)
return nil, AlreadyExistsErr
}
var ret *vcsinfo.LongCommit
if b.useGn {
ret, err = GNDownloadSkia(ctx, "", githash, checkout, b.depotTools, false, deps)
if err != nil {
return nil, fmt.Errorf("Failed to fetch: %s", err)
}
} else {
ret, err = DownloadSkia(ctx, "", githash, checkout, b.depotTools, false, deps)
if err != nil {
return nil, fmt.Errorf("Failed to fetch: %s", err)
}
}
if b.perBuild != nil {
if err := b.perBuild(ctx, checkout, b.depotTools); err != nil {
return nil, err
}
}
b.mutex.Lock()
defer b.mutex.Unlock()
b.hashes = append(b.hashes, githash)
b.updateCurrent(ctx)
fb, err := os.OpenFile(filepath.Join(b.workRoot, GOOD_BUILDS_FILENAME), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return nil, fmt.Errorf("Failed to open %s for writing: %s", GOOD_BUILDS_FILENAME, err)
}
defer util.Close(fb)
_, err = fmt.Fprintf(fb, "%s\n", githash)
if err != nil {
return nil, fmt.Errorf("Failed to write %s: %s", GOOD_BUILDS_FILENAME, err)
}
return ret, nil
}
// updateCurrent updates the value of b.current with the new gitinfo for the most recent build.
//
// Or a mildly informative stand-in if somehow the update fails.
//
// updateCurrent presumes the caller already has a lock on the mutex.
func (b *ContinuousBuilder) updateCurrent(ctx context.Context) {
fallback := &vcsinfo.LongCommit{ShortCommit: &vcsinfo.ShortCommit{Hash: "unknown"}}
if len(b.hashes) == 0 {
sklog.Errorf("There are no hashes.")
if b.current == nil {
b.current = fallback
}
return
}
details, err := b.repo.Details(ctx, b.hashes[len(b.hashes)-1], false)
if err != nil {
sklog.Errorf("Unable to retrieve build info: %s", err)
if b.current == nil {
b.current = fallback
}
return
}
b.current = details
}
// AvailableBuilds returns a list of git hashes, all the versions of Skia that
// can be built against. This returns the list with the newest builds last.
// The list will always be of length > 1, otherwise and error is returned.
func (b *ContinuousBuilder) AvailableBuilds() ([]string, error) {
b.mutex.Lock()
defer b.mutex.Unlock()
if len(b.hashes) > 0 {
return b.hashes, nil
}
fi, err := os.Open(filepath.Join(b.workRoot, GOOD_BUILDS_FILENAME))
if err != nil {
return nil, fmt.Errorf("Failed to open %s for reading: %s", GOOD_BUILDS_FILENAME, err)
}
defer util.Close(fi)
buf, err := io.ReadAll(fi)
if err != nil {
return nil, fmt.Errorf("Failed to read: %s", err)
}
hashes := strings.Split(string(buf), "\n")
realHashes := []string{}
for _, h := range hashes {
if h != "" {
realHashes = append(realHashes, h)
}
}
b.hashes = realHashes
if len(b.hashes) == 0 {
return nil, fmt.Errorf("List of available builds is empty.")
}
return realHashes, nil
}
func (b *ContinuousBuilder) Current() *vcsinfo.LongCommit {
b.mutex.Lock()
defer b.mutex.Unlock()
return b.current
}
func (b *ContinuousBuilder) writeNewGoodBuilds(hashes []string) error {
if len(hashes) < 1 {
return fmt.Errorf("At least one good build must be kept around.")
}
b.mutex.Lock()
defer b.mutex.Unlock()
b.hashes = hashes
fb, err := os.Create(filepath.Join(b.workRoot, GOOD_BUILDS_FILENAME))
if err != nil {
return fmt.Errorf("Failed to open %s for writing: %s", GOOD_BUILDS_FILENAME, err)
}
defer util.Close(fb)
if _, err := fb.Write([]byte(strings.Join(hashes, "\n") + "\n")); err != nil {
return fmt.Errorf("Failed to write %s: %s", GOOD_BUILDS_FILENAME, err)
}
return nil
}
func (b *ContinuousBuilder) startDecimation(ctx context.Context) {
decimateLiveness := metrics2.NewLiveness("decimate")
decimateFailures := metrics2.GetCounter("decimate_failed", nil)
for range time.Tick(DECIMATION_PERIOD) {
hashes, err := b.AvailableBuilds()
if err != nil {
sklog.Errorf("Failed to get available builds while decimating: %s", err)
decimateFailures.Inc(1)
continue
}
keep, remove, err := decimate(ctx, hashes, b.repo, b.preserve)
if err != nil {
sklog.Errorf("Failed to calc removals while decimating: %s", err)
decimateFailures.Inc(1)
continue
}
sklog.Infof("Decimate: Keep %v Remove %v", keep, remove)
for _, hash := range remove {
sklog.Infof("Decimate: Beginning %s", hash)
if err := os.RemoveAll(filepath.Join(b.workRoot, "versions", hash)); err != nil {
sklog.Errorf("Failed to remove directory for %s: %s", hash, err)
continue
}
sklog.Infof("Decimate: Finished %s", hash)
}
if err := b.writeNewGoodBuilds(keep); err != nil {
continue
}
decimateFailures.Reset()
decimateLiveness.Reset()
}
}
// decimate returns a list of hashes to keep, the list to remove,
// and an error if one occurred.
//
// The algorithm is:
//
// Preserve all hashes that are spaced one month apart.
// Then if there are more than 'limit' remaining hashes
// remove every other one to bring the count down to 'limit'/2.
func decimate(ctx context.Context, hashes []string, vcs vcsinfo.VCS, limit int) ([]string, []string, error) {
keep := []string{}
remove := []string{}
// The hashes are stored with the oldest first, newest last.
// So we start at the front and work forward until we start to find hashes that are less than
// PRESERVE_DURATION apart. Once we find that spot set oldiesEnd
// to that index.
oldiesEnd := 0
c, err := vcs.Details(ctx, hashes[0], false)
if err != nil {
return nil, nil, fmt.Errorf("Failed to get hash details: %s", err)
}
lastTS := time.Time{}
for i, h := range hashes {
c, err = vcs.Details(ctx, h, false)
if err != nil {
return nil, nil, fmt.Errorf("Failed to get hash details: %s", err)
}
fmt.Printf("%v", c.Timestamp.Sub(lastTS))
if c.Timestamp.Sub(lastTS) < PRESERVE_DURATION {
break
}
lastTS = c.Timestamp
oldiesEnd = i
}
// Now that we know where the old hashes that we want to preserve are, we
// will chop them off and ignore them for the rest of the decimation process.
oldies := hashes[:oldiesEnd]
hashes = hashes[oldiesEnd:]
fmt.Println(oldies, hashes)
// Only do decimation if we have enough fresh hashes.
if len(hashes) < limit {
return append(oldies, hashes...), remove, nil
}
last := hashes[len(hashes)-1]
for i, h := range hashes[:len(hashes)-1] {
if i%2 == 0 {
keep = append(keep, h)
} else {
remove = append(remove, h)
}
}
keep = append(keep, last)
// Once done with decimation add the oldies back into the list of hashes to keep.
return append(oldies, keep...), remove, nil
}