diff --git a/README.md b/README.md index 6afe336..1a94486 100644 --- a/README.md +++ b/README.md @@ -6,17 +6,19 @@ ## Usage -Use goaci as you would `go get`: +Use `goaci` as you would `go get`: $ goaci github.com/coreos/etcd Wrote etcd.aci $ actool -debug validate etcd.aci etcd.aci: valid app container image +`goaci` provides options for specifying assets, adding arguments for an application, selecting binary is going to be packaged in final ACI and so on. Use --help to read about them. + ## How it works -`goaci` creates a temporary directory and uses it as a `GOPATH`; it then `go get`s the specified package and compiles it statically. -Then it generates a very basic image manifest (using mostly default values, configurables coming soon) and leverages the [appc/spec](https://github.com/appc/spec) libraries to construct an ACI. +`goaci` creates a temporary directory and uses it as a `GOPATH` (unless it is overridden with `--go-path` option); it then `go get`s the specified package and compiles it statically. +Then it generates an image manifest (using mostly default values) and leverages the [appc/spec](https://github.com/appc/spec) libraries to construct an ACI. ## TODO diff --git a/asset.go b/asset.go new file mode 100644 index 0000000..460e254 --- /dev/null +++ b/asset.go @@ -0,0 +1,117 @@ +package main + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +func copyRegularFile(src, dest string) error { + srcFile, err := os.Open(src) + if err != nil { + return err + } + defer srcFile.Close() + destFile, err := os.Create(dest) + if err != nil { + return err + } + defer destFile.Close() + if _, err := io.Copy(destFile, srcFile); err != nil { + return err + } + return nil +} + +func copySymlink(src, dest string) error { + symTarget, err := os.Readlink(src) + if err != nil { + return err + } + if err := os.Symlink(symTarget, dest); err != nil { + return err + } + return nil +} + +func copyTree(src, dest string) error { + return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rootLess := path[len(src):] + target := filepath.Join(dest, rootLess) + mode := info.Mode() + switch { + case mode.IsDir(): + err := os.Mkdir(target, mode.Perm()) + if err != nil { + return err + } + case mode.IsRegular(): + if err := copyRegularFile(path, target); err != nil { + return err + } + case mode&os.ModeSymlink == os.ModeSymlink: + if err := copySymlink(path, target); err != nil { + return err + } + default: + return fmt.Errorf("Unsupported node %q in assets, only regular files, directories and symlinks are supported.", path, mode.String()) + } + return nil + }) +} + +func replacePlaceholders(path string, placeholderMapping map[string]string) string { + Debug("Processing path: ", path) + newPath := path + for placeholder, replacement := range placeholderMapping { + newPath = strings.Replace(newPath, placeholder, replacement, -1) + } + Debug("Processed path: ", newPath) + return newPath +} + +func validateAsset(ACIAsset, localAsset string) error { + if !filepath.IsAbs(ACIAsset) { + return fmt.Errorf("Wrong ACI asset: '%v' - ACI asset has to be absolute path", ACIAsset) + } + if !filepath.IsAbs(localAsset) { + return fmt.Errorf("Wrong local asset: '%v' - local asset has to be absolute path", localAsset) + } + fi, err := os.Stat(localAsset) + if err != nil { + return fmt.Errorf("Error stating %v: %v", localAsset, err) + } + if fi.Mode().IsDir() || fi.Mode().IsRegular() { + return nil + } + return fmt.Errorf("Can't handle local asset %v - not a file, not a dir", fi.Name()) +} + +func PrepareAssets(assets []string, rootfs string, placeholderMapping map[string]string) error { + for _, asset := range assets { + splitAsset := filepath.SplitList(asset) + if len(splitAsset) != 2 { + return fmt.Errorf("Malformed asset option: '%v' - expected two absolute paths separated with %v", asset, ListSeparator()) + } + ACIAsset := replacePlaceholders(splitAsset[0], placeholderMapping) + localAsset := replacePlaceholders(splitAsset[1], placeholderMapping) + if err := validateAsset(ACIAsset, localAsset); err != nil { + return err + } + ACIAssetSubPath := filepath.Join(rootfs, filepath.Dir(ACIAsset)) + err := os.MkdirAll(ACIAssetSubPath, 0755) + if err != nil { + return fmt.Errorf("Failed to create directory tree for asset '%v': %v", asset, err) + } + err = copyTree(localAsset, filepath.Join(rootfs, ACIAsset)) + if err != nil { + return fmt.Errorf("Failed to copy assets for '%v': %v", asset, err) + } + } + return nil +} diff --git a/goaci.go b/goaci.go index e7648c1..801045a 100644 --- a/goaci.go +++ b/goaci.go @@ -2,7 +2,6 @@ package main import ( "archive/tar" - "bytes" "compress/gzip" "flag" "fmt" @@ -17,25 +16,6 @@ import ( "github.com/appc/spec/schema/types" ) -var Debug bool - -func warn(s string, i ...interface{}) { - s = fmt.Sprintf(s, i...) - fmt.Fprintln(os.Stderr, strings.TrimSuffix(s, "\n")) -} - -func die(s string, i ...interface{}) { - warn(s, i...) - os.Exit(1) -} - -func debug(i ...interface{}) { - if Debug { - s := fmt.Sprint(i...) - fmt.Fprintln(os.Stderr, strings.TrimSuffix(s, "\n")) - } -} - type StringVector []string func (v *StringVector) String() string { @@ -47,189 +27,401 @@ func (v *StringVector) Set(str string) error { return nil } -func main() { - var ( - execOpts StringVector - goDefaultBinaryDesc string - goBinaryOpt string - goPathOpt string - ) - - // Find the go binary - gocmd, err := exec.LookPath("go") +type options struct { + exec StringVector + goBinary string + goPath string + useBinary string + assets StringVector + keepTmp bool + project string +} - flag.Var(&execOpts, "exec", "Parameters passed to app, can be used multiple times") +func getOptions() (*options, error) { + opts := &options{} + + // --go-binary + goDefaultBinaryDesc := "Go binary to use" + gocmd, err := exec.LookPath("go") if err != nil { - goDefaultBinaryDesc = "Go binary to use (default: none found in $PATH, so it must be provided)" + goDefaultBinaryDesc += " (default: none found in $PATH, so it must be provided)" } else { - goDefaultBinaryDesc = "Go binary to use (default: whatever go in $PATH)" + goDefaultBinaryDesc += " (default: whatever go in $PATH)" } - flag.StringVar(&goBinaryOpt, "go-binary", gocmd, goDefaultBinaryDesc) - flag.StringVar(&goPathOpt, "go-path", "", "Custom GOPATH (default: a temporary directory)") + flag.StringVar(&opts.goBinary, "go-binary", gocmd, goDefaultBinaryDesc) + + // --exec + flag.Var(&opts.exec, "exec", "Parameters passed to app, can be used multiple times") + + // --go-path + flag.StringVar(&opts.goPath, "go-path", "", "Custom GOPATH (default: a temporary directory)") + + // --use-binary + flag.StringVar(&opts.useBinary, "use-binary", "", "Which executable to put in ACI image") + + // --asset + flag.Var(&opts.assets, "asset", "Additional assets, can be used multiple times; format: "+ListSeparator()+"; placeholders like and can be used there as well") + + // --keep-tmp + flag.BoolVar(&opts.keepTmp, "keep-tmp", false, "Do not delete temporary directory used for creating ACI") flag.Parse() + + args := flag.Args() + if len(args) != 1 { + return nil, fmt.Errorf("Expected exactly one project to build, got %d", len(args)) + } + opts.project = args[0] + if opts.goBinary == "" { + return nil, fmt.Errorf("Go binary not found") + } + + return opts, nil +} + +type pathsAndNames struct { + tmpDirPath string + goPath string + goRootPath string + projectPath string + fakeGoPath string + goBinPath string + aciDirPath string + rootFSPath string + goExecPath string + + imageFileName string + imageACName string +} + +// getGoPath returns go path and fake go path. The former is either in +// /tmp (which is a default) or some other path as specified by +// --go-path parameter. The latter is always in /tmp. +func getGoPath(opts *options, tmpDir string) (string, string) { + fakeGoPath := filepath.Join(tmpDir, "gopath") + if opts.goPath == "" { + return fakeGoPath, fakeGoPath + } + return opts.goPath, fakeGoPath +} + +// getNamesFromProject returns project name, image ACName and ACI +// filename. Names depend on whether project has several binaries. For +// project with single binary (github.com/appc/goaci) returned values +// would be: github.com/appc/goaci, github.com/appc/goaci and +// goaci.aci. For project with multiple binaries +// (github.com/appc/spec/...) returned values would be (assuming ace +// as selected binary): github.com/appc/spec, github.com/appc/spec-ace +// and spec-ace.aci. +func getNamesFromProject(opts *options) (string, string, string) { + imageACName := opts.project + projectName := imageACName + base := filepath.Base(imageACName) + threeDotsBase := base == "..." + if threeDotsBase { + imageACName = filepath.Dir(imageACName) + projectName = imageACName + base = filepath.Base(imageACName) + if opts.useBinary != "" { + suffix := "-" + opts.useBinary + base += suffix + imageACName += suffix + } + } + + return projectName, imageACName, base + schema.ACIExtension +} + +func getPathsAndNames(opts *options) (*pathsAndNames, error) { + tmpDir, err := ioutil.TempDir("", "goaci") + if err != nil { + return nil, fmt.Errorf("error setting up temporary directory: %v", err) + } + + goPath, fakeGoPath := getGoPath(opts, tmpDir) + projectName, imageACName, imageFileName := getNamesFromProject(opts) + if os.Getenv("GOPATH") != "" { - warn("GOPATH env var is ignored, use --go-path=\"$GOPATH\" option instead") + Warn("GOPATH env var is ignored, use --go-path=\"$GOPATH\" option instead") } goRoot := os.Getenv("GOROOT") if goRoot != "" { - warn("Overriding GOROOT env var to %s", goRoot) - } - if os.Getenv("GOACI_DEBUG") != "" { - Debug = true + Warn("Overriding GOROOT env var to ", goRoot) } - if goBinaryOpt == "" { - die("go binary not found") + aciDir := filepath.Join(tmpDir, "aci") + // Project name is path-like string with slashes, but slash is + // not a file separator on every OS. + projectPath := filepath.Join(goPath, "src", filepath.Join(strings.Split(projectName, "/")...)) + + return &pathsAndNames{ + tmpDirPath: tmpDir, // /tmp/XXX + goPath: goPath, + goRootPath: goRoot, + projectPath: projectPath, + fakeGoPath: fakeGoPath, // /tmp/XXX/gopath + goBinPath: filepath.Join(fakeGoPath, "bin"), // /tmp/XXX/gopath/bin + aciDirPath: aciDir, // /tmp/XXX/aci + rootFSPath: filepath.Join(aciDir, "rootfs"), // /tmp/XXX/aci/rootfs + goExecPath: opts.goBinary, + + imageFileName: imageFileName, + imageACName: imageACName, + }, nil +} + +func makeDirectories(pathsNames *pathsAndNames) error { + // /tmp/XXX already exists, not creating it here + + // /tmp/XXX/gopath + if err := os.Mkdir(pathsNames.fakeGoPath, 0755); err != nil { + return err } - // Set up a temporary directory for everything (gopath and builds) - tmpdir, err := ioutil.TempDir("", "goaci") - if err != nil { - die("error setting up temporary directory: %v", err) + // /tmp/XXX/gopath/bin + if err := os.Mkdir(pathsNames.goBinPath, 0755); err != nil { + return err } - defer os.RemoveAll(tmpdir) - if goPathOpt == "" { - goPathOpt = tmpdir + + // /tmp/XXX/aci + if err := os.Mkdir(pathsNames.aciDirPath, 0755); err != nil { + return err } - // Scratch build dir for aci - acidir := filepath.Join(tmpdir, "aci") + // /tmp/XXX/aci/rootfs + if err := os.Mkdir(pathsNames.rootFSPath, 0755); err != nil { + return err + } - // Let's put final binary in tmpdir - gobin := filepath.Join(tmpdir, "bin") + return nil +} +func runGoGet(opts *options, pathsNames *pathsAndNames) error { // Construct args for a go get that does a static build args := []string{ - goBinaryOpt, + pathsNames.goExecPath, "get", "-a", "-tags", "netgo", "-ldflags", "'-w'", - // 1.4 - "-installsuffix", "cgo", - } - - // Extract the package name (which is the last arg). - var ns string - for _, arg := range os.Args[1:] { - // TODO(jonboulle): try to pass the other args on to go get? - // args = append(args, arg) - ns = arg - } - - name, err := types.NewACName(ns) - // TODO(jonboulle): could this ever actually happen? - if err != nil { - die("bad app name: %v", err) - } - args = append(args, ns) - - // Use the last component, e.g. example.com/my/app --> app - ofn := filepath.Base(ns) + ".aci" - mode := os.O_CREATE | os.O_WRONLY | os.O_TRUNC - of, err := os.OpenFile(ofn, mode, 0644) - if err != nil { - die("error opening output file: %v", err) + "-installsuffix", "nocgo", // for 1.4 + opts.project, } env := []string{ - "GOPATH=" + goPathOpt, - "GOBIN=" + gobin, + "GOPATH=" + pathsNames.goPath, + "GOBIN=" + pathsNames.goBinPath, "CGO_ENABLED=0", "PATH=" + os.Getenv("PATH"), } - if goRoot != "" { - env = append(env, "GOROOT="+goRoot) + if pathsNames.goRootPath != "" { + env = append(env, "GOROOT="+pathsNames.goRootPath) } + cmd := exec.Cmd{ Env: env, - Path: goBinaryOpt, + Path: pathsNames.goExecPath, Args: args, Stderr: os.Stderr, Stdout: os.Stdout, } - debug("env:", cmd.Env) - debug("running command:", strings.Join(cmd.Args, " ")) + Debug("env: ", cmd.Env) + Debug("running command: ", strings.Join(cmd.Args, " ")) if err := cmd.Run(); err != nil { - die("error running go: %v", err) + return err } + return nil +} - // Check that we got 1 binary from the go get command - fi, err := ioutil.ReadDir(gobin) +// getBinaryName get a binary name built by go get and selected by +// --use-binary parameter. +func getBinaryName(opts *options, pathsNames *pathsAndNames) (string, error) { + fi, err := ioutil.ReadDir(pathsNames.goBinPath) if err != nil { - die(err.Error()) + return "", err } + switch { case len(fi) < 1: - die("no binaries found in gobin") + return "", fmt.Errorf("No binaries found in gobin.") + case len(fi) == 1: + name := fi[0].Name() + if opts.useBinary != "" && name != opts.useBinary { + return "", fmt.Errorf("No such binary found in gobin: %q. There is only %q", opts.useBinary, name) + } + Debug("found binary: ", name) + return name, nil case len(fi) > 1: - debug(fmt.Sprint(fi)) - die("can't handle multiple binaries") + names := []string{} + for _, v := range fi { + names = append(names, v.Name()) + } + if opts.useBinary == "" { + return "", fmt.Errorf("Found multiple binaries in gobin, but --use-binary option is not used. Please specify which binary to put in ACI. Following binaries are available: %q", strings.Join(names, `", "`)) + } + for _, v := range names { + if v == opts.useBinary { + return v, nil + } + } + return "", fmt.Errorf("No such binary found in gobin: %q. There are following binaries available: %q", opts.useBinary, strings.Join(names, `", "`)) + } + return "", fmt.Errorf("Reaching this point shouldn't be possible.") +} + +func getApp(opts *options, binary string) *types.App { + exec := []string{filepath.Join("/", binary)} + exec = append(exec, opts.exec...) + + return &types.App{ + Exec: exec, + User: "0", + Group: "0", } - fn := fi[0].Name() - debug("found binary: ", fn) +} - // Set up rootfs for ACI layout - rfs := filepath.Join(acidir, "rootfs") - err = os.MkdirAll(rfs, 0755) +func getVCSLabel(pathsNames *pathsAndNames) (*types.Label, error) { + name, value, err := GetVCSInfo(pathsNames.projectPath) + if err != nil { + return nil, fmt.Errorf("Failed to get VCS info: %v", err) + } + acname, err := types.NewACName(name) if err != nil { - die(err.Error()) + return nil, fmt.Errorf("Invalid VCS label: %v", err) } + return &types.Label{ + Name: *acname, + Value: value, + }, nil +} - // Move the binary into the rootfs - ep := filepath.Join(rfs, fn) - err = os.Rename(filepath.Join(gobin, fn), ep) +func prepareManifest(opts *options, pathsNames *pathsAndNames, binary string) (*schema.ImageManifest, error) { + name, err := types.NewACName(pathsNames.imageACName) + // TODO(jonboulle): could this ever actually happen? + if err != nil { + return nil, err + } + + app := getApp(opts, binary) + + vcsLabel, err := getVCSLabel(pathsNames) if err != nil { - die(err.Error()) + return nil, err + } + labels := types.Labels{ + *vcsLabel, + } + + manifest := schema.BlankImageManifest() + manifest.Name = *name + manifest.App = app + manifest.Labels = labels + return manifest, nil +} + +func copyAssets(opts *options, pathsNames *pathsAndNames) error { + placeholderMapping := map[string]string{ + "": pathsNames.projectPath, + "": pathsNames.goPath, + } + if err := PrepareAssets(opts.assets, pathsNames.rootFSPath, placeholderMapping); err != nil { + return err } - debug("moved binary to:", ep) + return nil +} + +func moveBinaryToRootFS(pathsNames *pathsAndNames, binary string) error { + // Move the binary into the rootfs + ep := filepath.Join(pathsNames.rootFSPath, binary) + if err := os.Rename(filepath.Join(pathsNames.goBinPath, binary), ep); err != nil { + return err + } + Debug("moved binary to: ", ep) + return nil +} - exec := []string{filepath.Join("/", fn)} - exec = append(exec, execOpts...) - // Build the ACI - im := schema.ImageManifest{ - ACKind: types.ACKind("ImageManifest"), - ACVersion: schema.AppContainerVersion, - Name: *name, - App: &types.App{ - Exec: exec, - User: "0", - Group: "0", - }, +func writeACI(pathsNames *pathsAndNames, manifest *schema.ImageManifest) error { + mode := os.O_CREATE | os.O_WRONLY | os.O_TRUNC + of, err := os.OpenFile(pathsNames.imageFileName, mode, 0644) + if err != nil { + return fmt.Errorf("Error opening output file: %v", err) } - debug(im) + defer of.Close() gw := gzip.NewWriter(of) + defer gw.Close() + tr := tar.NewWriter(gw) + defer tr.Close() + + // FIXME: the files in the tar archive are added with the + // wrong uid/gid. The uid/gid of the aci builder leaks in the + // tar archive. See: https://github.com/appc/goaci/issues/16 + iw := aci.NewImageWriter(*manifest, tr) + if err := filepath.Walk(pathsNames.aciDirPath, aci.BuildWalker(pathsNames.aciDirPath, iw)); err != nil { + return err + } + if err := iw.Close(); err != nil { + return err + } + Info("Wrote ", of.Name()) + return nil +} + +func mainWithError() error { + InitDebug() + + opts, err := getOptions() + if err != nil { + return err + } + + pathsNames, err := getPathsAndNames(opts) + if err != nil { + return err + } + + if opts.keepTmp { + Info(`Preserving temporary directory "`, pathsNames.tmpDirPath, `"`) + } else { + defer os.RemoveAll(pathsNames.tmpDirPath) + } + + if err := makeDirectories(pathsNames); err != nil { + return err + } - defer func() { - tr.Close() - gw.Close() - of.Close() - }() + if err := runGoGet(opts, pathsNames); err != nil { + return err + } - iw := aci.NewImageWriter(im, tr) - err = filepath.Walk(acidir, aci.BuildWalker(acidir, iw)) + binary, err := getBinaryName(opts, pathsNames) if err != nil { - die(err.Error()) + return err } - err = iw.Close() + + manifest, err := prepareManifest(opts, pathsNames, binary) if err != nil { - die(err.Error()) + return err + } + + if err := copyAssets(opts, pathsNames); err != nil { + return err + } + + if err := moveBinaryToRootFS(pathsNames, binary); err != nil { + return err + } + + if err := writeACI(pathsNames, manifest); err != nil { + return err } - fmt.Println("Wrote", of.Name()) + + return nil } -// strip replaces all characters that are not [a-Z_] with _ -func strip(in string) string { - out := bytes.Buffer{} - for _, c := range in { - if !strings.ContainsRune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTVWXYZ0123456789_", c) { - c = '_' - } - if _, err := out.WriteRune(c); err != nil { - panic(err) - } +func main() { + if err := mainWithError(); err != nil { + Warn(err) + os.Exit(1) } - return out.String() } diff --git a/util.go b/util.go new file mode 100644 index 0000000..130000e --- /dev/null +++ b/util.go @@ -0,0 +1,53 @@ +package main + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + "unicode/utf8" +) + +var debugEnabled bool +var pathListSep string + +func printTo(w io.Writer, i ...interface{}) { + s := fmt.Sprint(i...) + fmt.Fprintln(w, strings.TrimSuffix(s, "\n")) +} + +func Warn(i ...interface{}) { + printTo(os.Stderr, i...) +} + +func Info(i ...interface{}) { + printTo(os.Stdout, i...) +} + +func Debug(i ...interface{}) { + if debugEnabled { + printTo(os.Stdout, i...) + } +} + +func InitDebug() { + if os.Getenv("GOACI_DEBUG") != "" { + debugEnabled = true + } +} + +// ListSeparator returns filepath.ListSeparator rune as a string. +func ListSeparator() string { + if pathListSep == "" { + len := utf8.RuneLen(filepath.ListSeparator) + if len < 0 { + panic("filepath.ListSeparator is not valid utf8?!") + } + buf := make([]byte, len) + len = utf8.EncodeRune(buf, filepath.ListSeparator) + pathListSep = string(buf[:len]) + } + + return pathListSep +} diff --git a/vcs.go b/vcs.go new file mode 100644 index 0000000..743f7dc --- /dev/null +++ b/vcs.go @@ -0,0 +1,118 @@ +package main + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +func repoDirExists(projPath, repoDir string) bool { + path := filepath.Join(projPath, repoDir) + info, err := os.Stat(path) + if err != nil { + return false + } + return info.IsDir() +} + +// getId gets first line of commands output which should hold some VCS +// specific id of current code checkout. +func getId(dir, cmd string, params []string) (string, error) { + args := []string{cmd} + args = append(args, params...) + buffer := new(bytes.Buffer) + cmdPath, err := exec.LookPath(cmd) + if err != nil { + return "", nil + } + process := &exec.Cmd{ + Path: cmdPath, + Args: args, + Env: []string{ + "PATH=" + os.Getenv("PATH"), + }, + Dir: dir, + Stdout: buffer, + } + if err := process.Run(); err != nil { + return "", err + } + output := string(buffer.Bytes()) + if newline := strings.Index(output, "\n"); newline < 0 { + return output, nil + } else { + return output[:newline], nil + } +} + +func getLabelAndId(label, path, cmd string, params []string) (string, string, error) { + if info, err := getId(path, cmd, params); err != nil { + return "", "", err + } else { + return label, info, nil + } +} + +type VCSInfo interface { + IsValid(path string) bool + GetLabelAndId(path string) (string, string, error) +} + +type GitInfo struct{} + +func (info GitInfo) IsValid(path string) bool { + return repoDirExists(path, ".git") +} + +func (info GitInfo) GetLabelAndId(path string) (string, string, error) { + return getLabelAndId("git", path, "git", []string{"rev-parse", "HEAD"}) +} + +type HgInfo struct{} + +func (info HgInfo) IsValid(path string) bool { + return repoDirExists(path, ".hg") +} + +func (info HgInfo) GetLabelAndId(path string) (string, string, error) { + return getLabelAndId("hg", path, "hg", []string{"id", "-i"}) +} + +type SvnInfo struct{} + +func (info SvnInfo) IsValid(path string) bool { + return repoDirExists(path, ".svn") +} + +func (info SvnInfo) GetLabelAndId(path string) (string, string, error) { + return getLabelAndId("svn", path, "svnversion", []string{}) +} + +type BzrInfo struct{} + +func (info BzrInfo) IsValid(path string) bool { + return repoDirExists(path, ".bzr") +} + +func (info BzrInfo) GetLabelAndId(path string) (string, string, error) { + return getLabelAndId("bzr", path, "bzr", []string{"revno"}) +} + +func GetVCSInfo(projPath string) (string, string, error) { + vcses := []VCSInfo{ + GitInfo{}, + HgInfo{}, + SvnInfo{}, + BzrInfo{}, + } + + for _, vcs := range vcses { + if vcs.IsValid(projPath) { + return vcs.GetLabelAndId(projPath) + } + } + return "", "", fmt.Errorf("Unknown code repository in %q", projPath) +}