newt/install/install.go (484 lines of code) (raw):
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you 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.
// ----------------------------------------------------------------------------
// install: Handles project upgrades.
// ----------------------------------------------------------------------------
//
// This file implements one newt operation:
// * Upgrade - Downloads a version of each repo that satisfies `project.yml`
// and all repo rependencies.
//
// Within the `project.yml` file, repo requirements are expressed with one of
// the following forms:
// * [Normalized version]: #.#.#
// (e.g., "1.3.0")
// * [Floating version]: #[.#]-<stability
// (e.g., "0-dev")
// * [Git commit]: <git-commit-ish>-commit
// (e.g., "0aae710654b48d9a84d54de771cc18427709df7d-commit")
//
// The first two types (normalized version and floating version) are called
// "version specifiers". Version specifiers map to "official releases", while
// git commits typically map to "custom versions".
//
// ### VERSION SPECIFIERS
//
// A repo's `repository.yml` file maps version specifiers to git commits in its
// `repo.versions` field. For example:
// repo.versions:
// "0.0.0": "master"
// "1.0.0": "mynewt_1_0_0_tag"
// "1.1.0": "mynewt_1_1_0_tag"
// "0-dev": "0.0.0"
//
// By performing a series of recursive lookups, newt converts a version
// specifier to a normalized-version,git-commit pair.
//
// ### VERSION STRINGS
//
// Newt uses the following procedure when displaying a repo version to the
// user:
//
// Official releases are expressed as a normalized version.
// e.g., 1.10.0
//
// Custom versions are expressed as a git hash.
// e.g., 0aae710654b48d9a84d54de771cc18427709df7d
// ----------------------------------------------------------------------------
package install
import (
"bufio"
"fmt"
"os"
"strings"
log "github.com/sirupsen/logrus"
"mynewt.apache.org/newt/newt/compat"
"mynewt.apache.org/newt/newt/deprepo"
"mynewt.apache.org/newt/newt/newtutil"
"mynewt.apache.org/newt/newt/repo"
"mynewt.apache.org/newt/util"
)
type installOp int
const (
INSTALL_OP_INSTALL installOp = iota
INSTALL_OP_UPGRADE
)
// Determines the currently installed version of the specified repo. If the
// repo isn't using a commit that maps to a version, 0.0.0 is returned.
func detectVersion(r *repo.Repo) (newtutil.RepoVersion, error) {
ver, err := r.InstalledVersion()
if err != nil {
return newtutil.RepoVersion{}, err
}
// Fallback to 0.0.0 if version detection failed.
if ver == nil {
commit, err := r.CurrentHash()
if err != nil {
return newtutil.RepoVersion{}, err
}
// Create a 0.0.0 version specifier with the indicated commit string.
ver = &newtutil.RepoVersion{
Commit: commit,
}
log.Infof(
"Could not detect version of installed repo \"%s\"; assuming %s",
r.Name(), ver.String())
}
log.Debugf("currently installed version of repo \"%s\": %s",
r.Name(), ver.String())
return *ver, nil
}
type Installer struct {
// Map of all repos in the project.
repos deprepo.RepoMap
// Version of each installed repo.
vers deprepo.VersionMap
// Required versions of installed repos, as read from `project.yml`.
reqs deprepo.RequirementMap
}
func NewInstaller(repos deprepo.RepoMap,
reqs deprepo.RequirementMap) (Installer, error) {
inst := Installer{
repos: repos,
vers: deprepo.VersionMap{},
reqs: reqs,
}
// Detect the installed versions of all repos.
var firstErr error
for n, r := range inst.repos {
if !r.IsLocal() && !r.IsNewlyCloned() {
ver, err := detectVersion(r)
if err != nil {
if firstErr == nil {
firstErr = err
}
} else {
inst.vers[n] = ver
}
}
}
return inst, firstErr
}
// Retrieves the installed version of the specified repo. Versions get
// detected and cached when the installer is constructed. This function just
// retrieves the corresponding entry from the cache.
func (inst *Installer) installedVer(repoName string) *newtutil.RepoVersion {
ver, ok := inst.vers[repoName]
if !ok {
return nil
} else {
return &ver
}
}
// Given a slice of repos, recursively appends all depended-on repos, ensuring
// each element is unique.
//
// @param repos The list of dependent repos to process.
// @param vm Indicates the version of each repo to consider.
// Pass nil to consider all versions of all
// repos.
//
// @return []*repo.Repo The original list, augmented with all
// depended-on repos.
func (inst *Installer) ensureDepsInList(repos []*repo.Repo,
vm deprepo.VersionMap) []*repo.Repo {
seen := map[string]struct{}{}
var recurse func(r *repo.Repo) []*repo.Repo
recurse = func(r *repo.Repo) []*repo.Repo {
// Don't process this repo a second time.
if _, ok := seen[r.Name()]; ok {
return nil
}
seen[r.Name()] = struct{}{}
result := []*repo.Repo{r}
var deps []*repo.RepoDependency
if vm == nil {
deps = r.AllDeps()
} else {
deps = r.DepsForVersion(vm[r.Name()])
}
for _, d := range deps {
depRepo := inst.repos[d.Name]
result = append(result, recurse(depRepo)...)
}
return result
}
deps := []*repo.Repo{}
for _, r := range repos {
deps = append(deps, recurse(r)...)
}
return deps
}
// Indicates whether a repo should be upgraded to the specified version. A
// repo should be upgraded if it is not currently installed, or if a version
// other than the desired one is installed.
func (inst *Installer) shouldUpgradeRepo(
repoName string, destVer newtutil.RepoVersion) (bool, error) {
curVer := inst.installedVer(repoName)
// If the repo isn't installed, it needs to be upgraded.
if curVer == nil {
return true, nil
}
r := inst.repos[repoName]
if r == nil {
return false, util.FmtNewtError(
"internal error: nonexistent repo has version: %s", repoName)
}
// If the repo is not in a "detached head" state, it needs to be fixed up.
detached, err := r.IsDetached()
if err != nil {
return false, err
}
if !detached {
return true, nil
}
if !r.VersionsEqual(*curVer, destVer) {
return true, nil
}
return false, nil
}
// Removes repos that shouldn't be upgraded from the specified list. A repo
// should not be upgraded if the desired version is already installed.
//
// @param repos The list of repos to filter.
// @param vm Specifies the desired version of each repo.
//
// @return []*Repo The filtered list of repos.
func (inst *Installer) filterUpgradeList(
vm deprepo.VersionMap) (deprepo.VersionMap, error) {
filtered := deprepo.VersionMap{}
for _, name := range vm.SortedNames() {
ver := vm[name]
doUpgrade, err := inst.shouldUpgradeRepo(name, ver)
if err != nil {
return nil, err
}
if doUpgrade {
filtered[name] = ver
} else {
curVer := inst.installedVer(name)
if curVer == nil {
return nil, util.FmtNewtError(
"internal error: should upgrade repo %s, "+
"but no version installed",
name)
}
curVer.Commit = ver.Commit
util.StatusMessage(util.VERBOSITY_DEFAULT,
"Skipping \"%s\": already upgraded (%s)\n",
name, curVer.String())
}
}
return filtered, nil
}
// Describes an imminent install or upgrade operation to the user. The
// displayed message applies to the specified repo.
func (inst *Installer) installMessageOneRepo(
r *repo.Repo, op installOp, force bool, curVer *newtutil.RepoVersion,
destVer newtutil.RepoVersion) (string, error) {
// If the repo isn't installed yet, this is an install, not an upgrade.
if op == INSTALL_OP_UPGRADE && curVer == nil {
op = INSTALL_OP_INSTALL
}
var verb string
switch op {
case INSTALL_OP_INSTALL:
if !force {
verb = "install"
} else {
verb = "reinstall"
}
case INSTALL_OP_UPGRADE:
if r.VersionsEqual(*curVer, destVer) {
verb = "fixup"
} else {
verb = "upgrade"
}
default:
return "", util.FmtNewtError(
"internal error: invalid install op: %v", op)
}
msg := fmt.Sprintf(" %s %s ", verb, r.Name())
if verb == "upgrade" {
msg += fmt.Sprintf("(%s --> %s)", curVer.String(), destVer.String())
} else {
msg += fmt.Sprintf("(%s)", destVer.String())
}
return msg, nil
}
// Describes an imminent repo operation to the user. In addition, prompts the
// user for confirmation if the `-a` (ask) option was specified.
func (inst *Installer) installPrompt(vm deprepo.VersionMap, op installOp,
force bool, ask bool) (bool, error) {
if len(vm) == 0 {
return true, nil
}
util.StatusMessage(util.VERBOSITY_DEFAULT,
"Making the following changes to the project:\n")
names := vm.SortedNames()
for _, name := range names {
r := inst.repos[name]
curVer := inst.installedVer(name)
if curVer != nil {
if curVer.Commit != "" {
c, err := r.CurrentHash()
if err == nil {
curVer.Commit = c
}
}
destVer := vm[name]
msg, err := inst.installMessageOneRepo(
r, op, force, curVer, destVer)
if err != nil {
return false, err
}
util.StatusMessage(util.VERBOSITY_DEFAULT, "%s\n", msg)
}
}
if !ask {
return true, nil
}
for {
fmt.Printf("Proceed? [Y/n] ")
line, more, err := bufio.NewReader(os.Stdin).ReadLine()
if more || err != nil {
return false, util.ChildNewtError(err)
}
trimmed := strings.ToLower(strings.TrimSpace(string(line)))
if len(trimmed) == 0 || strings.HasPrefix(trimmed, "y") {
// User wants to proceed.
return true, nil
}
if strings.HasPrefix(trimmed, "n") {
// User wants to cancel.
return false, nil
}
// Invalid response.
fmt.Printf("Invalid response.\n")
}
}
// Creates a slice of repos, each corresponding to an element in the provided
// version map. The returned slice is sorted by repo name.
func (inst *Installer) versionMapRepos(
vm deprepo.VersionMap) ([]*repo.Repo, error) {
repos := make([]*repo.Repo, 0, len(vm))
names := vm.SortedNames()
for _, name := range names {
r := inst.repos[name]
if r == nil {
return nil, util.FmtNewtError(
"internal error: repo \"%s\" missing from Installer#repos",
name)
}
repos = append(repos, r)
}
return repos, nil
}
// Calculates a map of repos and version numbers that should be included in an
// install or upgrade operation.
func (inst *Installer) calcVersionMap(candidates []*repo.Repo) (
deprepo.VersionMap, error) {
// Repos that depend on any specified repos must also be considered during
// the install / upgrade operation.
repoList := inst.ensureDepsInList(candidates, nil)
for _, r := range repoList {
for commit, _ := range r.CommitDepMap() {
commit, err := r.Downloader().LatestRc(r.Path(), commit)
if err != nil {
return nil, err
}
equiv, err := r.Downloader().CommitsFor(r.Path(), commit)
if err != nil || len(equiv) == 0 {
util.OneTimeWarning(
"repo bases a dependency on a nonexistent commit; "+
"repository.yml contains an error; "+
"does a branch name contain a typo? "+
"repo=\"%s\" commit=\"%s\"",
r.Name(), commit)
}
}
}
// Ensure project.yml doesn't specify any invalid versions
for repoName, repoVer := range inst.reqs {
rvp := deprepo.RVPair{
Name: repoName,
Ver: repoVer,
}
r := inst.repos[repoName]
if r == nil {
return nil, util.FmtNewtError(
"project.yml depends on an unknown repo: %s", rvp.String())
}
if !r.VersionIsValid(repoVer) {
return nil, util.FmtNewtError(
"project.yml depends on an unknown repo version: %s",
rvp.String())
}
}
// Construct a repo dependency graph from the `project.yml` version
// requirements and from each repo's dependency list.
dg, err := deprepo.BuildDepGraph(inst.repos, inst.reqs)
if err != nil {
return nil, err
}
log.Debugf("repo dependency graph:\n%s\n", dg.String())
log.Debugf("repo reverse dependency graph:\n%s\n", dg.Reverse().String())
// Don't consider repos that the user excluded (i.e., if a partial repo
// list was specified).
deprepo.PruneDepGraph(dg, repoList)
vm, conflicts := deprepo.ResolveRepoDeps(dg)
if len(conflicts) > 0 {
return nil, deprepo.ConflictError(conflicts)
}
log.Debugf("repo version map:\n%s",
vm.String())
return vm, nil
}
// Checks if any repos in the specified slice are in a dirty state. If any
// repos are dirty and `force` is *not* enabled, an error is returned. If any
// repos are dirty and `force` is enabled, a warning is displayed.
func verifyRepoDirtyState(repos []*repo.Repo, force bool) error {
// [repo] => dirty-state.
var m map[*repo.Repo]string
// Collect all dirty repos and insert them into m.
for _, r := range repos {
dirtyState, err := r.DirtyState()
if err != nil {
return err
}
if dirtyState != "" {
if m == nil {
m = make(map[*repo.Repo]string)
m[r] = dirtyState
}
}
}
if len(m) > 0 {
s := "some repos are in a dirty state:\n"
for r, d := range m {
s += fmt.Sprintf(" %s: contains %s\n", r.Name(), d)
}
if !force {
s += "Specify the `-f` (force) switch to attempt anyway"
return util.NewNewtError(s)
} else {
util.OneTimeWarning("%s", s)
}
}
return nil
}
func verifyNewtCompat(repos []*repo.Repo, vm deprepo.VersionMap) error {
var errors []string
for _, r := range repos {
destVer := vm[r.Name()]
code, msg := r.CheckNewtCompatibility(destVer, newtutil.NewtVersion)
switch code {
case compat.NEWT_COMPAT_WARN:
util.OneTimeWarning("%s", msg)
case compat.NEWT_COMPAT_ERROR:
errors = append(errors, msg)
}
}
if errors != nil {
return util.NewNewtError(strings.Join(errors, "\n"))
}
return nil
}
// Installs or upgrades the specified set of repos.
func (inst *Installer) Upgrade(candidates []*repo.Repo, force bool,
ask bool) error {
if err := verifyRepoDirtyState(candidates, force); err != nil {
return err
}
vm, err := inst.calcVersionMap(candidates)
if err != nil {
return err
}
// Don't upgrade a repo if we already have the desired version.
vm, err = inst.filterUpgradeList(vm)
if err != nil {
return err
}
// Notify the user of what install operations are about to happen, and
// prompt if the `-a` (ask) option was specified.
proceed, err := inst.installPrompt(vm, INSTALL_OP_UPGRADE, false, ask)
if err != nil {
return err
}
if !proceed {
return nil
}
repos, err := inst.versionMapRepos(vm)
if err != nil {
return err
}
if err := verifyNewtCompat(repos, vm); err != nil {
return err
}
// Upgrade each repo in the version map.
for _, r := range repos {
destVer := vm[r.Name()]
if err := r.Upgrade(destVer); err != nil {
return err
}
util.StatusMessage(util.VERBOSITY_DEFAULT,
"%s successfully upgraded to version %s\n",
r.Name(), destVer.String())
}
return nil
}
type repoInfo struct {
installedVer *newtutil.RepoVersion // nil if not installed.
commitHash string
errorText string
dirtyState string
needsUpgrade bool
}
// Collects information about the specified repo. If a version map is provided
// (i.e., vm is not nil), this function also queries the repo's remote to
// determine if the repo can be upgraded.
func (inst *Installer) gatherInfo(r *repo.Repo,
vm *deprepo.VersionMap) repoInfo {
ri := repoInfo{}
if !r.CheckExists() {
return ri
}
if vm != nil {
// The caller requested a remote query. Download this repo's latest
// `repository.yml` file.
if !r.IsExternal(r.Path()) {
if err := r.DownloadDesc(); err != nil {
ri.errorText = strings.TrimSpace(err.Error())
return ri
}
}
}
commitHash, err := r.CurrentHash()
if err != nil {
ri.errorText = strings.TrimSpace(err.Error())
return ri
}
ri.commitHash = commitHash
ver, err := detectVersion(r)
if err != nil {
ri.errorText = strings.TrimSpace(err.Error())
return ri
}
ri.installedVer = &ver
dirty, err := r.DirtyState()
if err != nil {
ri.errorText = strings.TrimSpace(err.Error())
return ri
}
ri.dirtyState = dirty
if vm != nil {
if ver != (*vm)[r.Name()] {
ri.needsUpgrade = true
}
}
return ri
}
// Prints out information about the specified repos:
// * Currently installed version.
// * Whether upgrade is possible.
// * Whether repo is in a dirty state.
//
// @param repos The set of repositories to inspect.
// @param remote Whether to perform any remote queries to
// determine if upgrades are needed.
func (inst *Installer) Info(repos []*repo.Repo, remote bool) error {
var vmp *deprepo.VersionMap
if remote {
// Fetch the latest for all repos.
for _, r := range repos {
if !r.IsLocal() && !r.IsExternal(r.Path()) {
if err := r.DownloadDesc(); err != nil {
return err
}
}
}
vm, err := inst.calcVersionMap(repos)
if err != nil {
return err
}
vmp = &vm
}
util.StatusMessage(util.VERBOSITY_DEFAULT, "Repository info:\n")
for _, r := range repos {
if r.IsLocal() {
inst.localRepoInfo(r)
} else {
inst.remoteRepoInfo(r, vmp)
}
}
return nil
}
// remoteRepoInfo prints information about the specified repo. If `vm` is
// non-nil, the output indicates whether a remote update is available.
func (inst *Installer) remoteRepoInfo(r *repo.Repo, vm *deprepo.VersionMap) {
ri := inst.gatherInfo(r, vm)
s := fmt.Sprintf(" * %s:", r.Name())
s += fmt.Sprintf(" %s", ri.commitHash)
if ri.installedVer == nil {
s += ", (not installed)"
} else if ri.errorText != "" {
s += fmt.Sprintf(", (unknown: %s)", ri.errorText)
} else {
if ri.installedVer.Commit == "" {
s += fmt.Sprintf(", %s", ri.installedVer.String())
}
if ri.dirtyState != "" {
s += fmt.Sprintf(", (dirty: %s)", ri.dirtyState)
}
if ri.needsUpgrade {
s += ", (needs upgrade)"
}
}
util.StatusMessage(util.VERBOSITY_DEFAULT, "%s\n", s)
}
// remoteRepoInfo prints information about the specified local repo (i.e., the
// project itself). It does nothing if the project is not a git repo.
func (inst *Installer) localRepoInfo(r *repo.Repo) {
ri := inst.gatherInfo(r, nil)
if ri.commitHash == "" {
// The project is not a git repo.
return
}
s := fmt.Sprintf(" * %s (project):", r.Name())
s += fmt.Sprintf(" %s", ri.commitHash)
if ri.dirtyState != "" {
s += fmt.Sprintf(" (dirty: %s)", ri.dirtyState)
}
util.StatusMessage(util.VERBOSITY_DEFAULT, "%s\n", s)
}