blob: a064f306a1564cca05e1507a419b54a7b157d6f0 [file] [log] [blame]
package asset
import (
"bufio"
"context"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"github.com/urfave/cli/v2"
cipd_api "go.chromium.org/luci/cipd/client/cipd"
"go.chromium.org/luci/cipd/client/cipd/pkg"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"go.skia.org/infra/go/auth"
"go.skia.org/infra/go/cipd"
"go.skia.org/infra/go/exec"
"go.skia.org/infra/go/git"
"go.skia.org/infra/go/httputils"
"go.skia.org/infra/go/repo_root"
"go.skia.org/infra/go/skerr"
)
const (
cipdPackageNameTmpl = "skia/bots/%s"
tagProject = "project:skia"
tagVersionPrefix = "version:"
tagVersionTmpl = tagVersionPrefix + "%d"
creationScriptBaseName = "create.py"
creationScriptInitialContents = `#!/usr/bin/env python
#
# Copyright 2017 Google Inc.
#
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Create the asset."""
import argparse
def create_asset(target_dir):
"""Create the asset."""
raise NotImplementedError('Implement me!')
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--target_dir', '-t', required=True)
args = parser.parse_args()
create_asset(args.target_dir)
if __name__ == '__main__':
main()
`
versionFileBaseName = "VERSION"
)
var (
skipFilesRegex = []*regexp.Regexp{
regexp.MustCompile(`^\.git$`),
regexp.MustCompile(`^\.svn$`),
regexp.MustCompile(`.*\.pyc$`),
regexp.MustCompile(`^.DS_STORE$`),
}
tagVersionRegex = regexp.MustCompile(tagVersionPrefix + `(\d+)`)
)
// Command returns a cli.Command instance which represents the "asset" command.
func Command() *cli.Command {
flagIn := "in"
return &cli.Command{
Name: "asset",
Description: "Manage assets used by developers and CI.",
Usage: "asset <subcommand>",
Subcommands: []*cli.Command{
{
Name: "add",
Description: "Add a new asset.",
Usage: "add <asset name>",
Action: func(ctx *cli.Context) error {
args := ctx.Args().Slice()
if len(args) != 1 {
return skerr.Fmt("Expected exactly one positional argument.")
}
return cmdAdd(ctx.Context, args[0])
},
},
{
Name: "remove",
Description: "Remove an existing asset. Does not delete uploaded packages.",
Usage: "remove <asset name>",
Action: func(ctx *cli.Context) error {
args := ctx.Args().Slice()
if len(args) != 1 {
return skerr.Fmt("Expected exactly one positional argument.")
}
return cmdRemove(ctx.Context, args[0])
},
},
{
Name: "download",
Description: "Download an asset.",
Usage: "download <asset name> <target directory>",
Action: func(ctx *cli.Context) error {
args := ctx.Args().Slice()
if len(args) != 2 {
return skerr.Fmt("Expected exactly two positional arguments.")
}
return cmdDownload(ctx.Context, args[0], args[1])
},
},
{
Name: "upload",
Description: "Upload a new version of the asset. If --in is provided, use the contents of the provided directory, otherwise run the creation script to generate the package contents.",
Usage: "upload <asset name>",
Flags: []cli.Flag{
&cli.StringFlag{
Name: flagIn,
Value: "",
Usage: "Use the contents of this directory as the package contents. If not provided, expects a creation script to be present within the asset dir.",
},
},
Action: func(ctx *cli.Context) error {
args := ctx.Args().Slice()
if len(args) != 1 {
return skerr.Fmt("Expected exactly one positional argument.")
}
return cmdUpload(ctx.Context, args[0], ctx.String(flagIn))
},
},
{
Name: "get-version",
Description: "Print the current version of the asset.",
Usage: "get-version <asset name>",
Action: func(ctx *cli.Context) error {
args := ctx.Args().Slice()
if len(args) != 1 {
return skerr.Fmt("Expected exactly one positional argument.")
}
ver, err := getVersion(args[0])
if err != nil {
return skerr.Wrap(err)
}
fmt.Println(strconv.Itoa(ver))
return nil
},
},
{
Name: "set-version",
Description: "Manually set the asset to an already-uploaded version.",
Usage: "set-version <asset name> <version>",
Action: func(ctx *cli.Context) error {
args := ctx.Args().Slice()
if len(args) != 2 {
return skerr.Fmt("Expected exactly two positional arguments.")
}
version, err := strconv.Atoi(args[1])
if err != nil {
return skerr.Wrapf(err, "invalid version number")
}
return cmdSetVersion(ctx.Context, args[0], version)
},
},
{
Name: "list-versions",
Description: "List the uploaded versions of the asset.",
Usage: "list-versions <asset name>",
Action: func(ctx *cli.Context) error {
args := ctx.Args().Slice()
if len(args) != 1 {
return skerr.Fmt("Expected exactly one positional argument.")
}
return cmdListVersions(ctx.Context, args[0])
},
},
},
}
}
// cmdAdd implements the "add" subcommand.
func cmdAdd(ctx context.Context, name string) error {
// Create the asset directory.
assetDir, err := getAssetDir(name)
if err != nil {
return skerr.Wrap(err)
}
if _, err := os.Stat(assetDir); !os.IsNotExist(err) {
return skerr.Fmt("Asset %q already exists in %s", name, assetDir)
}
if err := os.MkdirAll(assetDir, os.ModePerm); err != nil {
return skerr.Wrap(err)
}
// Write the initial (empty) version file.
versionFile := filepath.Join(assetDir, versionFileBaseName)
if err := ioutil.WriteFile(versionFile, []byte{}, os.ModePerm); err != nil {
return skerr.Wrap(err)
}
// Optionally write a creation script skeleton.
fmt.Printf("Do you want to add a creation script for this asset? (y/n): ")
read, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil {
return skerr.Wrap(err)
}
read = strings.TrimSpace(read)
if read == "y" {
creationScript := filepath.Join(assetDir, creationScriptBaseName)
if err := ioutil.WriteFile(creationScript, []byte(creationScriptInitialContents), os.ModePerm); err != nil {
return skerr.Wrap(err)
}
fmt.Println(fmt.Sprintf("Created %s; you will need to add implementation before uploading the asset.", creationScript))
}
// "git add" the new directory.
if _, err := git.GitDir(".").Git(ctx, "add", assetDir); err != nil {
return skerr.Wrap(err)
}
return nil
}
// cmdRemove implements the "remove" subcommand.
func cmdRemove(ctx context.Context, name string) error {
assetDir, err := getAssetDir(name)
if err != nil {
return skerr.Wrap(err)
}
if _, err := git.GitDir(".").Git(ctx, "rm", "-rf", assetDir); err != nil {
return skerr.Wrap(err)
}
return nil
}
// getCIPDClient creates and returns a cipd.CIPDClient.
func getCIPDClient(ctx context.Context, rootDir string) (cipd.CIPDClient, error) {
ts, err := auth.NewDefaultTokenSource(true, auth.SCOPE_USERINFO_EMAIL)
if err != nil {
return nil, skerr.Wrap(err)
}
httpClient := httputils.DefaultClientConfig().WithTokenSource(ts).Client()
cipdClient, err := cipd.NewClient(httpClient, rootDir, cipd.DefaultServiceURL)
if err != nil {
return nil, skerr.Wrap(err)
}
return cipdClient, nil
}
// cmdDownload implements the "download" subcommand.
func cmdDownload(ctx context.Context, name, dest string) error {
cipdClient, err := getCIPDClient(ctx, dest)
if err != nil {
return skerr.Wrap(err)
}
version, err := getVersion(name)
if err != nil {
return skerr.Wrap(err)
}
versionTag := fmt.Sprintf(tagVersionTmpl, version)
packagePath := fmt.Sprintf(cipdPackageNameTmpl, name)
pin, err := cipdClient.ResolveVersion(ctx, packagePath, versionTag)
if err != nil {
return skerr.Wrap(err)
}
if err := cipdClient.FetchAndDeployInstance(ctx, "", pin, 0); err != nil {
return skerr.Wrap(err)
}
return nil
}
// cmdUpload implements the "upload" subcommand.
func cmdUpload(ctx context.Context, name, src string) (rvErr error) {
cipdClient, err := getCIPDClient(ctx, ".")
if err != nil {
return skerr.Wrap(err)
}
// Run the creation script, if one exists.
assetDir, err := getAssetDir(name)
if err != nil {
return skerr.Wrap(err)
}
creationScript := filepath.Join(assetDir, creationScriptBaseName)
if _, err := os.Stat(creationScript); err == nil {
if src != "" {
return skerr.Fmt("Target directory is not supplied when using a creation script.")
}
src, err = ioutil.TempDir("", "")
if err != nil {
return skerr.Wrap(err)
}
defer func() {
if err := os.RemoveAll(src); err != nil && rvErr == nil {
rvErr = err
}
}()
cmd := []string{"python", creationScript, "-t", src}
fmt.Println(fmt.Sprintf("Running: %s", strings.Join(cmd, " ")))
if _, err := exec.RunCwd(ctx, ".", cmd...); err != nil {
return skerr.Wrap(err)
}
}
// Find the next version number.
instances, err := getAvailableVersions(ctx, cipdClient, name)
if err != nil {
return skerr.Wrap(err)
}
highestVersion := -1
for version := range instances {
if version > highestVersion {
highestVersion = version
}
}
nextVersion := highestVersion + 1
// Create the new package instance.
refs := []string{"latest"}
tags := []string{
fmt.Sprintf(tagVersionTmpl, nextVersion),
tagProject,
}
packagePath := fmt.Sprintf(cipdPackageNameTmpl, name)
if _, err := cipdClient.Create(ctx, packagePath, src, pkg.InstallModeSymlink, skipFilesRegex, refs, tags, nil); err != nil {
return skerr.Wrap(err)
}
// Write the new version file.
if err := setVersion(name, nextVersion); err != nil {
return skerr.Wrap(err)
}
return nil
}
// cmdSetVersion implements the "set-version" sub-command.
func cmdSetVersion(ctx context.Context, name string, version int) error {
cipdClient, err := getCIPDClient(ctx, ".")
if err != nil {
return skerr.Wrap(err)
}
// Ensure that the requested version actually exists.
instances, err := getAvailableVersions(ctx, cipdClient, name)
if err != nil {
return skerr.Wrap(err)
}
if _, ok := instances[version]; !ok {
errMsg := fmt.Sprintf("Version %d not found. Available versions:\n", version)
errMsg += printVersions(instances)
errMsg += "\n\n"
return skerr.Fmt(errMsg)
}
// Update the version file for the asset.
if err := setVersion(name, version); err != nil {
if os.IsNotExist(skerr.Unwrap(err)) {
// If the version file doesn't exist (eg. this is an asset which is
// managed in another repo), offer to create it.
fmt.Printf("Entry for asset %q does not exist. Create it? (y/n): ", name)
read, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil {
return skerr.Wrap(err)
}
read = strings.TrimSpace(read)
if read == "y" {
if err := cmdAdd(ctx, name); err != nil {
return skerr.Wrap(err)
}
return setVersion(name, version)
}
}
}
return nil
}
// cmdListVersions implements the "list-versions" subcommand.
func cmdListVersions(ctx context.Context, name string) error {
cipdClient, err := getCIPDClient(ctx, ".")
if err != nil {
return skerr.Wrap(err)
}
instances, err := getAvailableVersions(ctx, cipdClient, name)
if err != nil {
return skerr.Wrap(err)
}
fmt.Println(printVersions(instances))
return nil
}
// getAssetDir finds the directory for the asset within the current repo.
func getAssetDir(name string) (string, error) {
repoRoot, err := repo_root.GetLocal()
if err != nil {
return "", skerr.Wrap(err)
}
return filepath.Join(repoRoot, "infra", "bots", "assets", name), nil
}
// getVersionFilePath finds the path to the version file for the asset within
// the current repo.
func getVersionFilePath(name string) (string, error) {
assetDir, err := getAssetDir(name)
if err != nil {
return "", skerr.Wrap(err)
}
return filepath.Join(assetDir, versionFileBaseName), nil
}
// getVersion reads the version file for the asset and returns the version
// number.
func getVersion(name string) (int, error) {
versionFile, err := getVersionFilePath(name)
if err != nil {
return -1, skerr.Wrap(err)
}
b, err := ioutil.ReadFile(versionFile)
if err != nil {
if os.IsNotExist(err) {
return -1, skerr.Wrapf(err, "unknown asset %q", name)
}
return -1, skerr.Wrap(err)
}
version, err := strconv.Atoi(string(b))
if err != nil {
return -1, skerr.Wrap(err)
}
return version, nil
}
// setVersion writes the version file for the asset.
func setVersion(name string, version int) error {
versionFile, err := getVersionFilePath(name)
if err != nil {
return skerr.Wrap(err)
}
if err := ioutil.WriteFile(versionFile, []byte(strconv.Itoa(version)), os.ModePerm); err != nil {
return skerr.Wrap(err)
}
return nil
}
// getAvailableVersions retrieves all uploaded versions of the asset and returns
// a map of version number to InstanceDescription
func getAvailableVersions(ctx context.Context, cipdClient cipd.CIPDClient, name string) (map[int]*cipd_api.InstanceDescription, error) {
packagePath := fmt.Sprintf(cipdPackageNameTmpl, name)
iter, err := cipdClient.ListInstances(ctx, packagePath)
if err != nil {
return nil, skerr.Wrap(err)
}
// Iterate the package instances.
rv := map[int]*cipd_api.InstanceDescription{}
for {
infos, err := iter.Next(ctx, 10)
if err != nil {
// TODO(borenet): Why doesn't the code work?
if status.Code(err) == codes.NotFound || strings.Contains(err.Error(), "no such package") {
// There aren't any instances of this package yet.
break
} else {
return nil, skerr.Wrap(err)
}
}
if len(infos) == 0 {
break
}
for _, info := range infos {
// Retrieve the details for the instance, which include the tags.
instance, err := cipdClient.DescribeInstance(ctx, info.Pin, &cipd_api.DescribeInstanceOpts{
DescribeTags: true,
})
if err != nil {
return nil, skerr.Wrap(err)
}
for _, tag := range instance.Tags {
// If this is a version tag, parse the version number out and
// add an entry to the map. Note that we may have multiple
// version tags on a given package instance, eg. if we uploaded
// a package again but its contents didn't change.
m := tagVersionRegex.FindStringSubmatch(tag.Tag)
if len(m) == 2 {
version, err := strconv.Atoi(m[1])
if err != nil {
return nil, skerr.Wrap(err)
}
rv[version] = instance
}
}
}
}
return rv, nil
}
// printVersions returns a human-friendly string describing the given package
// instances.
func printVersions(instances map[int]*cipd_api.InstanceDescription) string {
versions := make([]int, 0, len(instances))
for version := range instances {
versions = append(versions, version)
}
sort.Ints(versions)
strs := make([]string, 0, len(versions))
for _, version := range versions {
instance := instances[version]
shortID := instance.Pin.InstanceID[:12]
strs = append(strs, fmt.Sprintf("%d.\t%s...\t%s", version, shortID, instance.RegisteredTs))
}
return strings.Join(strs, "\n")
}