github.com/tompreston/snapd@v0.0.0-20210817193607-954edfcb9611/cmd/snap/cmd_snap_op.go (about)

     1  // -*- Mode: Go; indent-tabs-mode: t -*-
     2  
     3  /*
     4   * Copyright (C) 2016-2017 Canonical Ltd
     5   *
     6   * This program is free software: you can redistribute it and/or modify
     7   * it under the terms of the GNU General Public License version 3 as
     8   * published by the Free Software Foundation.
     9   *
    10   * This program is distributed in the hope that it will be useful,
    11   * but WITHOUT ANY WARRANTY; without even the implied warranty of
    12   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    13   * GNU General Public License for more details.
    14   *
    15   * You should have received a copy of the GNU General Public License
    16   * along with this program.  If not, see <http://www.gnu.org/licenses/>.
    17   *
    18   */
    19  
    20  package main
    21  
    22  import (
    23  	"errors"
    24  	"fmt"
    25  	"os"
    26  	"path/filepath"
    27  	"sort"
    28  	"strings"
    29  	"time"
    30  	"unicode/utf8"
    31  
    32  	"github.com/jessevdk/go-flags"
    33  
    34  	"github.com/snapcore/snapd/client"
    35  	"github.com/snapcore/snapd/dirs"
    36  	"github.com/snapcore/snapd/i18n"
    37  	"github.com/snapcore/snapd/osutil"
    38  	"github.com/snapcore/snapd/release"
    39  	"github.com/snapcore/snapd/snap/channel"
    40  	"github.com/snapcore/snapd/strutil"
    41  )
    42  
    43  var (
    44  	shortInstallHelp = i18n.G("Install snaps on the system")
    45  	shortRemoveHelp  = i18n.G("Remove snaps from the system")
    46  	shortRefreshHelp = i18n.G("Refresh snaps in the system")
    47  	shortTryHelp     = i18n.G("Test an unpacked snap in the system")
    48  	shortEnableHelp  = i18n.G("Enable a snap in the system")
    49  	shortDisableHelp = i18n.G("Disable a snap in the system")
    50  )
    51  
    52  var longInstallHelp = i18n.G(`
    53  The install command installs the named snaps on the system.
    54  
    55  To install multiple instances of the same snap, append an underscore and a
    56  unique identifier (for each instance) to a snap's name.
    57  
    58  With no further options, the snaps are installed tracking the stable channel,
    59  with strict security confinement.
    60  
    61  Revision choice via the --revision override requires the user to
    62  have developer access to the snap, either directly or through the
    63  store's collaboration feature, and to be logged in (see 'snap help login').
    64  
    65  Note that a later refresh will typically undo a revision override, taking the snap
    66  back to the current revision of the channel it's tracking.
    67  
    68  Use --name to set the instance name when installing from snap file.
    69  `)
    70  
    71  var longRemoveHelp = i18n.G(`
    72  The remove command removes the named snap instance from the system.
    73  
    74  By default all the snap revisions are removed, including their data and the
    75  common data directory. When a --revision option is passed only the specified
    76  revision is removed.
    77  
    78  Unless automatic snapshots are disabled, a snapshot of all data for the snap is 
    79  saved upon removal, which is then available for future restoration with snap
    80  restore. The --purge option disables automatically creating snapshots.
    81  `)
    82  
    83  var longRefreshHelp = i18n.G(`
    84  The refresh command updates the specified snaps, or all snaps in the system if
    85  none are specified.
    86  
    87  With no further options, the snaps are refreshed to the current revision of the
    88  channel they're tracking, preserving their confinement options.
    89  
    90  Revision choice via the --revision override requires the user to
    91  have developer access to the snap, either directly or through the
    92  store's collaboration feature, and to be logged in (see 'snap help login').
    93  
    94  Note a later refresh will typically undo a revision override.
    95  `)
    96  
    97  var longTryHelp = i18n.G(`
    98  The try command installs an unpacked snap into the system for testing purposes.
    99  The unpacked snap content continues to be used even after installation, so
   100  non-metadata changes there go live instantly. Metadata changes such as those
   101  performed in snap.yaml will require reinstallation to go live.
   102  
   103  If snap-dir argument is omitted, the try command will attempt to infer it if
   104  either snapcraft.yaml file and prime directory or meta/snap.yaml file can be
   105  found relative to current working directory.
   106  `)
   107  
   108  var longEnableHelp = i18n.G(`
   109  The enable command enables a snap that was previously disabled.
   110  `)
   111  
   112  var longDisableHelp = i18n.G(`
   113  The disable command disables a snap. The binaries and services of the
   114  snap will no longer be available, but all the data is still available
   115  and the snap can easily be enabled again.
   116  `)
   117  
   118  type cmdRemove struct {
   119  	waitMixin
   120  
   121  	Revision   string `long:"revision"`
   122  	Purge      bool   `long:"purge"`
   123  	Positional struct {
   124  		Snaps []installedSnapName `positional-arg-name:"<snap>" required:"1"`
   125  	} `positional-args:"yes" required:"yes"`
   126  }
   127  
   128  func (x *cmdRemove) removeOne(opts *client.SnapOptions) error {
   129  	name := string(x.Positional.Snaps[0])
   130  
   131  	changeID, err := x.client.Remove(name, opts)
   132  	if err != nil {
   133  		msg, err := errorToCmdMessage(name, err, opts)
   134  		if err != nil {
   135  			return err
   136  		}
   137  		fmt.Fprintln(Stderr, msg)
   138  		return nil
   139  	}
   140  
   141  	if _, err := x.wait(changeID); err != nil {
   142  		if err == noWait {
   143  			return nil
   144  		}
   145  		return err
   146  	}
   147  
   148  	if opts.Revision != "" {
   149  		fmt.Fprintf(Stdout, i18n.G("%s (revision %s) removed\n"), name, opts.Revision)
   150  	} else {
   151  		fmt.Fprintf(Stdout, i18n.G("%s removed\n"), name)
   152  	}
   153  	return nil
   154  }
   155  
   156  func (x *cmdRemove) removeMany(opts *client.SnapOptions) error {
   157  	names := installedSnapNames(x.Positional.Snaps)
   158  	changeID, err := x.client.RemoveMany(names, opts)
   159  	if err != nil {
   160  		return err
   161  	}
   162  
   163  	chg, err := x.wait(changeID)
   164  	if err != nil {
   165  		if err == noWait {
   166  			return nil
   167  		}
   168  		return err
   169  	}
   170  
   171  	var removed []string
   172  	if err := chg.Get("snap-names", &removed); err != nil && err != client.ErrNoData {
   173  		return err
   174  	}
   175  
   176  	seen := make(map[string]bool)
   177  	for _, name := range removed {
   178  		fmt.Fprintf(Stdout, i18n.G("%s removed\n"), name)
   179  		seen[name] = true
   180  	}
   181  	for _, name := range names {
   182  		if !seen[name] {
   183  			// FIXME: this is the only reason why a name can be
   184  			// skipped, but it does feel awkward
   185  			fmt.Fprintf(Stdout, i18n.G("%s not installed\n"), name)
   186  		}
   187  	}
   188  
   189  	return nil
   190  
   191  }
   192  
   193  func (x *cmdRemove) Execute([]string) error {
   194  	opts := &client.SnapOptions{Revision: x.Revision, Purge: x.Purge}
   195  	if len(x.Positional.Snaps) == 1 {
   196  		return x.removeOne(opts)
   197  	}
   198  
   199  	if x.Purge || x.Revision != "" {
   200  		return errors.New(i18n.G("a single snap name is needed to specify options"))
   201  	}
   202  	return x.removeMany(nil)
   203  }
   204  
   205  type channelMixin struct {
   206  	Channel string `long:"channel"`
   207  
   208  	// shortcuts
   209  	EdgeChannel      bool `long:"edge"`
   210  	BetaChannel      bool `long:"beta"`
   211  	CandidateChannel bool `long:"candidate"`
   212  	StableChannel    bool `long:"stable" `
   213  }
   214  
   215  type mixinDescs map[string]string
   216  
   217  func (mxd mixinDescs) also(m map[string]string) mixinDescs {
   218  	n := make(map[string]string, len(mxd)+len(m))
   219  	for k, v := range mxd {
   220  		n[k] = v
   221  	}
   222  	for k, v := range m {
   223  		n[k] = v
   224  	}
   225  	return n
   226  }
   227  
   228  var channelDescs = mixinDescs{
   229  	// TRANSLATORS: This should not start with a lowercase letter.
   230  	"channel": i18n.G("Use this channel instead of stable"),
   231  	// TRANSLATORS: This should not start with a lowercase letter.
   232  	"beta": i18n.G("Install from the beta channel"),
   233  	// TRANSLATORS: This should not start with a lowercase letter.
   234  	"edge": i18n.G("Install from the edge channel"),
   235  	// TRANSLATORS: This should not start with a lowercase letter.
   236  	"candidate": i18n.G("Install from the candidate channel"),
   237  	// TRANSLATORS: This should not start with a lowercase letter.
   238  	"stable": i18n.G("Install from the stable channel"),
   239  }
   240  
   241  func (mx *channelMixin) setChannelFromCommandline() error {
   242  	for _, ch := range []struct {
   243  		enabled bool
   244  		chName  string
   245  	}{
   246  		{mx.StableChannel, "stable"},
   247  		{mx.CandidateChannel, "candidate"},
   248  		{mx.BetaChannel, "beta"},
   249  		{mx.EdgeChannel, "edge"},
   250  	} {
   251  		if !ch.enabled {
   252  			continue
   253  		}
   254  		if mx.Channel != "" {
   255  			return fmt.Errorf("Please specify a single channel")
   256  		}
   257  		mx.Channel = ch.chName
   258  	}
   259  
   260  	if mx.Channel != "" {
   261  		if _, err := channel.Parse(mx.Channel, ""); err != nil {
   262  			full, er := channel.Full(mx.Channel)
   263  			if er != nil {
   264  				// the parse error has more detailed info
   265  				return err
   266  			}
   267  
   268  			// TODO: get escapes in here so we can bold the Warning
   269  			head := i18n.G("Warning:")
   270  			msg := i18n.G("Specifying a channel %q is relying on undefined behaviour. Interpreting it as %q for now, but this will be an error later.\n")
   271  			warn := fill(fmt.Sprintf(msg, mx.Channel, full), utf8.RuneCountInString(head)+1) // +1 for the space
   272  			fmt.Fprint(Stderr, head, " ", warn, "\n\n")
   273  			mx.Channel = full // so a malformed-but-eh channel will always be full, i.e. //stable// -> latest/stable
   274  		}
   275  	}
   276  
   277  	return nil
   278  }
   279  
   280  // isSnapInPath checks whether the snap binaries dir (e.g. /snap/bin)
   281  // is in $PATH.
   282  //
   283  // TODO: consider symlinks
   284  func isSnapInPath() bool {
   285  	paths := filepath.SplitList(os.Getenv("PATH"))
   286  	for _, path := range paths {
   287  		if filepath.Clean(path) == dirs.SnapBinariesDir {
   288  			return true
   289  		}
   290  	}
   291  	return false
   292  }
   293  
   294  func isSameRisk(tracking, current string) (bool, error) {
   295  	if tracking == current {
   296  		return true, nil
   297  	}
   298  	var trackingRisk, currentRisk string
   299  	if tracking != "" {
   300  		traCh, err := channel.Parse(tracking, "")
   301  		if err != nil {
   302  			return false, err
   303  		}
   304  		trackingRisk = traCh.Risk
   305  	}
   306  	if current != "" {
   307  		curCh, err := channel.Parse(current, "")
   308  		if err != nil {
   309  			return false, err
   310  		}
   311  		currentRisk = curCh.Risk
   312  	}
   313  	return trackingRisk == currentRisk, nil
   314  }
   315  
   316  func maybeWithSudoSecurePath() bool {
   317  	// Some distributions set secure_path to a known list of paths in
   318  	// /etc/sudoers. The snapd package currently has no means of overwriting
   319  	// or extending that setting.
   320  	if _, isSet := os.LookupEnv("SUDO_UID"); !isSet {
   321  		return false
   322  	}
   323  	// Known distros setting secure_path that does not include
   324  	// $SNAP_MOUNT_DIR/bin:
   325  	return release.DistroLike("fedora", "opensuse", "debian")
   326  }
   327  
   328  // show what has been done
   329  func showDone(cli *client.Client, names []string, op string, opts *client.SnapOptions, esc *escapes) error {
   330  	snaps, err := cli.List(names, nil)
   331  	if err != nil {
   332  		return err
   333  	}
   334  
   335  	needsPathWarning := !isSnapInPath() && !maybeWithSudoSecurePath()
   336  	for _, snap := range snaps {
   337  		channelStr := ""
   338  		if snap.Channel != "" {
   339  			ch, err := channel.Parse(snap.Channel, "")
   340  			if err != nil {
   341  				return err
   342  			}
   343  			if ch.Name != "stable" {
   344  				channelStr = fmt.Sprintf(" (%s)", ch.Name)
   345  			}
   346  		}
   347  		switch op {
   348  		case "install":
   349  			if needsPathWarning {
   350  				head := i18n.G("Warning:")
   351  				warn := fill(fmt.Sprintf(i18n.G("%s was not found in your $PATH. If you've not restarted your session since you installed snapd, try doing that. Please see https://forum.snapcraft.io/t/9469 for more details."), dirs.SnapBinariesDir), utf8.RuneCountInString(head)+1) // +1 for the space
   352  				fmt.Fprint(Stderr, esc.bold, head, esc.end, " ", warn, "\n\n")
   353  				needsPathWarning = false
   354  			}
   355  
   356  			if opts != nil && opts.Classic && snap.Confinement != client.ClassicConfinement {
   357  				// requested classic but the snap is not classic
   358  				head := i18n.G("Warning:")
   359  				// TRANSLATORS: the arg is a snap name (e.g. "some-snap")
   360  				warn := fill(fmt.Sprintf(i18n.G("flag --classic ignored for strictly confined snap %s"), snap.Name), utf8.RuneCountInString(head)+1) // +1 for the space
   361  				fmt.Fprint(Stderr, esc.bold, head, esc.end, " ", warn, "\n\n")
   362  			}
   363  
   364  			if snap.Publisher != nil {
   365  				// TRANSLATORS: the args are a snap name optionally followed by a channel, then a version, then the developer name (e.g. "some-snap (beta) 1.3 from Alice installed")
   366  				fmt.Fprintf(Stdout, i18n.G("%s%s %s from %s installed\n"), snap.Name, channelStr, snap.Version, longPublisher(esc, snap.Publisher))
   367  			} else {
   368  				// TRANSLATORS: the args are a snap name optionally followed by a channel, then a version (e.g. "some-snap (beta) 1.3 installed")
   369  				fmt.Fprintf(Stdout, i18n.G("%s%s %s installed\n"), snap.Name, channelStr, snap.Version)
   370  			}
   371  		case "refresh":
   372  			if snap.Publisher != nil {
   373  				// TRANSLATORS: the args are a snap name optionally followed by a channel, then a version, then the developer name (e.g. "some-snap (beta) 1.3 from Alice refreshed")
   374  				fmt.Fprintf(Stdout, i18n.G("%s%s %s from %s refreshed\n"), snap.Name, channelStr, snap.Version, longPublisher(esc, snap.Publisher))
   375  			} else {
   376  				// TRANSLATORS: the args are a snap name optionally followed by a channel, then a version (e.g. "some-snap (beta) 1.3 refreshed")
   377  				fmt.Fprintf(Stdout, i18n.G("%s%s %s refreshed\n"), snap.Name, channelStr, snap.Version)
   378  			}
   379  		case "revert":
   380  			// TRANSLATORS: first %s is a snap name, second %s is a revision
   381  			fmt.Fprintf(Stdout, i18n.G("%s reverted to %s\n"), snap.Name, snap.Version)
   382  		case "switch":
   383  			switchCohort := opts.CohortKey != ""
   384  			switchChannel := opts.Channel != ""
   385  			var msg string
   386  			// we have three boolean things to check, meaning 2³=8 possibilities,
   387  			// minus 3 error cases which are handled before the call to showDone.
   388  			switch {
   389  			case switchCohort && !opts.LeaveCohort && !switchChannel:
   390  				// TRANSLATORS: the first %q will be the (quoted) snap name, the second an ellipted cohort string
   391  				msg = fmt.Sprintf(i18n.G("%q switched to the %q cohort\n"), snap.Name, strutil.ElliptLeft(opts.CohortKey, 10))
   392  			case switchCohort && !opts.LeaveCohort && switchChannel:
   393  				// TRANSLATORS: the first %q will be the (quoted) snap name, the second a channel, the third an ellipted cohort string
   394  				msg = fmt.Sprintf(i18n.G("%q switched to the %q channel and the %q cohort\n"), snap.Name, snap.TrackingChannel, strutil.ElliptLeft(opts.CohortKey, 10))
   395  			case !switchCohort && !opts.LeaveCohort && switchChannel:
   396  				// TRANSLATORS: the first %q will be the (quoted) snap name, the second a channel
   397  				msg = fmt.Sprintf(i18n.G("%q switched to the %q channel\n"), snap.Name, snap.TrackingChannel)
   398  			case !switchCohort && opts.LeaveCohort && switchChannel:
   399  				// TRANSLATORS: the first %q will be the (quoted) snap name, the second a channel
   400  				msg = fmt.Sprintf(i18n.G("%q left the cohort, and switched to the %q channel"), snap.Name, snap.TrackingChannel)
   401  			case !switchCohort && opts.LeaveCohort && !switchChannel:
   402  				// TRANSLATORS: %q will be the (quoted) snap name
   403  				msg = fmt.Sprintf(i18n.G("%q left the cohort"), snap.Name)
   404  			}
   405  			fmt.Fprintln(Stdout, msg)
   406  		default:
   407  			fmt.Fprintf(Stdout, "internal error: unknown op %q", op)
   408  		}
   409  		if op == "install" || op == "refresh" {
   410  			if snap.TrackingChannel != snap.Channel && snap.Channel != "" {
   411  				if sameRisk, err := isSameRisk(snap.TrackingChannel, snap.Channel); err == nil && !sameRisk {
   412  					// TRANSLATORS: first %s is a channel name, following %s is a snap name, last %s is a channel name again.
   413  					fmt.Fprintf(Stdout, i18n.G("Channel %s for %s is closed; temporarily forwarding to %s.\n"), snap.TrackingChannel, snap.Name, snap.Channel)
   414  				}
   415  			}
   416  		}
   417  	}
   418  
   419  	return nil
   420  }
   421  
   422  func (mx *channelMixin) asksForChannel() bool {
   423  	return mx.Channel != ""
   424  }
   425  
   426  type modeMixin struct {
   427  	DevMode  bool `long:"devmode"`
   428  	JailMode bool `long:"jailmode"`
   429  	Classic  bool `long:"classic"`
   430  }
   431  
   432  var modeDescs = mixinDescs{
   433  	// TRANSLATORS: This should not start with a lowercase letter.
   434  	"classic": i18n.G("Put snap in classic mode and disable security confinement"),
   435  	// TRANSLATORS: This should not start with a lowercase letter.
   436  	"devmode": i18n.G("Put snap in development mode and disable security confinement"),
   437  	// TRANSLATORS: This should not start with a lowercase letter.
   438  	"jailmode": i18n.G("Put snap in enforced confinement mode"),
   439  }
   440  
   441  var errModeConflict = errors.New(i18n.G("cannot use devmode and jailmode flags together"))
   442  
   443  func (mx modeMixin) validateMode() error {
   444  	if mx.DevMode && mx.JailMode {
   445  		return errModeConflict
   446  	}
   447  	return nil
   448  }
   449  
   450  func (mx modeMixin) asksForMode() bool {
   451  	return mx.DevMode || mx.JailMode || mx.Classic
   452  }
   453  
   454  func (mx modeMixin) setModes(opts *client.SnapOptions) {
   455  	opts.DevMode = mx.DevMode
   456  	opts.JailMode = mx.JailMode
   457  	opts.Classic = mx.Classic
   458  }
   459  
   460  type cmdInstall struct {
   461  	colorMixin
   462  	waitMixin
   463  
   464  	channelMixin
   465  	modeMixin
   466  	Revision string `long:"revision"`
   467  
   468  	Dangerous bool `long:"dangerous"`
   469  	// alias for --dangerous, deprecated but we need to support it
   470  	// because we released 2.14.2 with --force-dangerous
   471  	ForceDangerous bool `long:"force-dangerous" hidden:"yes"`
   472  
   473  	Unaliased bool `long:"unaliased"`
   474  
   475  	Name string `long:"name"`
   476  
   477  	Cohort        string `long:"cohort"`
   478  	IgnoreRunning bool   `long:"ignore-running" hidden:"yes"`
   479  	Positional    struct {
   480  		Snaps []remoteSnapName `positional-arg-name:"<snap>"`
   481  	} `positional-args:"yes" required:"yes"`
   482  }
   483  
   484  func (x *cmdInstall) installOne(nameOrPath, desiredName string, opts *client.SnapOptions) error {
   485  	var err error
   486  	var changeID string
   487  	var snapName string
   488  	var path string
   489  
   490  	if strings.Contains(nameOrPath, "/") || strings.HasSuffix(nameOrPath, ".snap") || strings.Contains(nameOrPath, ".snap.") {
   491  		path = nameOrPath
   492  		changeID, err = x.client.InstallPath(path, x.Name, opts)
   493  	} else {
   494  		snapName = nameOrPath
   495  		if desiredName != "" {
   496  			return errors.New(i18n.G("cannot use explicit name when installing from store"))
   497  		}
   498  		changeID, err = x.client.Install(snapName, opts)
   499  	}
   500  	if err != nil {
   501  		msg, err := errorToCmdMessage(nameOrPath, err, opts)
   502  		if err != nil {
   503  			return err
   504  		}
   505  		fmt.Fprintln(Stderr, msg)
   506  		return nil
   507  	}
   508  
   509  	chg, err := x.wait(changeID)
   510  	if err != nil {
   511  		if err == noWait {
   512  			return nil
   513  		}
   514  		return err
   515  	}
   516  
   517  	// extract the snapName from the change, important for sideloaded
   518  	if path != "" {
   519  		if err := chg.Get("snap-name", &snapName); err != nil {
   520  			return fmt.Errorf("cannot extract the snap-name from local file %q: %s", nameOrPath, err)
   521  		}
   522  	}
   523  
   524  	// TODO: mention details of the install (e.g. like switch does)
   525  	return showDone(x.client, []string{snapName}, "install", opts, x.getEscapes())
   526  }
   527  
   528  func (x *cmdInstall) installMany(names []string, opts *client.SnapOptions) error {
   529  	// sanity check
   530  	for _, name := range names {
   531  		if strings.Contains(name, "/") || strings.HasSuffix(name, ".snap") || strings.Contains(name, ".snap.") {
   532  			return fmt.Errorf("only one snap file can be installed at a time")
   533  		}
   534  	}
   535  
   536  	changeID, err := x.client.InstallMany(names, opts)
   537  	if err != nil {
   538  		var snapName string
   539  		if err, ok := err.(*client.Error); ok {
   540  			snapName, _ = err.Value.(string)
   541  		}
   542  		msg, err := errorToCmdMessage(snapName, err, opts)
   543  		if err != nil {
   544  			return err
   545  		}
   546  		fmt.Fprintln(Stderr, msg)
   547  		return nil
   548  	}
   549  
   550  	chg, err := x.wait(changeID)
   551  	if err != nil {
   552  		if err == noWait {
   553  			return nil
   554  		}
   555  		return err
   556  	}
   557  
   558  	var installed []string
   559  	if err := chg.Get("snap-names", &installed); err != nil && err != client.ErrNoData {
   560  		return err
   561  	}
   562  
   563  	if len(installed) > 0 {
   564  		if err := showDone(x.client, installed, "install", opts, x.getEscapes()); err != nil {
   565  			return err
   566  		}
   567  	}
   568  
   569  	// show skipped
   570  	seen := make(map[string]bool)
   571  	for _, name := range installed {
   572  		seen[name] = true
   573  	}
   574  	for _, name := range names {
   575  		if !seen[name] {
   576  			// FIXME: this is the only reason why a name can be
   577  			// skipped, but it does feel awkward
   578  			fmt.Fprintf(Stdout, i18n.G("%s already installed\n"), name)
   579  		}
   580  	}
   581  
   582  	return nil
   583  }
   584  
   585  func (x *cmdInstall) Execute([]string) error {
   586  	if err := x.setChannelFromCommandline(); err != nil {
   587  		return err
   588  	}
   589  	if err := x.validateMode(); err != nil {
   590  		return err
   591  	}
   592  
   593  	dangerous := x.Dangerous || x.ForceDangerous
   594  	opts := &client.SnapOptions{
   595  		Channel:       x.Channel,
   596  		Revision:      x.Revision,
   597  		Dangerous:     dangerous,
   598  		Unaliased:     x.Unaliased,
   599  		CohortKey:     x.Cohort,
   600  		IgnoreRunning: x.IgnoreRunning,
   601  	}
   602  	x.setModes(opts)
   603  
   604  	names := remoteSnapNames(x.Positional.Snaps)
   605  	if len(names) == 0 {
   606  		return errors.New(i18n.G("cannot install zero snaps"))
   607  	}
   608  	for _, name := range names {
   609  		if len(name) == 0 {
   610  			return errors.New(i18n.G("cannot install snap with empty name"))
   611  		}
   612  	}
   613  
   614  	if len(names) == 1 {
   615  		return x.installOne(names[0], x.Name, opts)
   616  	}
   617  
   618  	if x.asksForMode() || x.asksForChannel() {
   619  		return errors.New(i18n.G("a single snap name is needed to specify mode or channel flags"))
   620  	}
   621  
   622  	if x.Name != "" {
   623  		return errors.New(i18n.G("cannot use instance name when installing multiple snaps"))
   624  	}
   625  	return x.installMany(names, nil)
   626  }
   627  
   628  type cmdRefresh struct {
   629  	colorMixin
   630  	timeMixin
   631  	waitMixin
   632  	channelMixin
   633  	modeMixin
   634  
   635  	Amend            bool   `long:"amend"`
   636  	Revision         string `long:"revision"`
   637  	Cohort           string `long:"cohort"`
   638  	LeaveCohort      bool   `long:"leave-cohort"`
   639  	List             bool   `long:"list"`
   640  	Time             bool   `long:"time"`
   641  	IgnoreValidation bool   `long:"ignore-validation"`
   642  	IgnoreRunning    bool   `long:"ignore-running" hidden:"yes"`
   643  	Positional       struct {
   644  		Snaps []installedSnapName `positional-arg-name:"<snap>"`
   645  	} `positional-args:"yes"`
   646  }
   647  
   648  func (x *cmdRefresh) refreshMany(snaps []string, opts *client.SnapOptions) error {
   649  	changeID, err := x.client.RefreshMany(snaps, opts)
   650  	if err != nil {
   651  		return err
   652  	}
   653  
   654  	chg, err := x.wait(changeID)
   655  	if err != nil {
   656  		if err == noWait {
   657  			return nil
   658  		}
   659  		return err
   660  	}
   661  
   662  	var refreshed []string
   663  	if err := chg.Get("snap-names", &refreshed); err != nil && err != client.ErrNoData {
   664  		return err
   665  	}
   666  
   667  	if len(refreshed) > 0 {
   668  		return showDone(x.client, refreshed, "refresh", opts, x.getEscapes())
   669  	}
   670  
   671  	fmt.Fprintln(Stderr, i18n.G("All snaps up to date."))
   672  
   673  	return nil
   674  }
   675  
   676  func (x *cmdRefresh) refreshOne(name string, opts *client.SnapOptions) error {
   677  	changeID, err := x.client.Refresh(name, opts)
   678  	if err != nil {
   679  		msg, err := errorToCmdMessage(name, err, opts)
   680  		if err != nil {
   681  			return err
   682  		}
   683  		fmt.Fprintln(Stderr, msg)
   684  		return nil
   685  	}
   686  
   687  	if _, err := x.wait(changeID); err != nil {
   688  		if err == noWait {
   689  			return nil
   690  		}
   691  		return err
   692  	}
   693  
   694  	// TODO: this doesn't really tell about all the things you
   695  	// could set while refreshing (something switch does)
   696  	return showDone(x.client, []string{name}, "refresh", opts, x.getEscapes())
   697  }
   698  
   699  func parseSysinfoTime(s string) time.Time {
   700  	t, err := time.Parse(time.RFC3339, s)
   701  	if err != nil {
   702  		return time.Time{}
   703  	}
   704  	return t
   705  }
   706  
   707  func (x *cmdRefresh) showRefreshTimes() error {
   708  	sysinfo, err := x.client.SysInfo()
   709  	if err != nil {
   710  		return err
   711  	}
   712  
   713  	if sysinfo.Refresh.Timer != "" {
   714  		fmt.Fprintf(Stdout, "timer: %s\n", sysinfo.Refresh.Timer)
   715  	} else if sysinfo.Refresh.Schedule != "" {
   716  		fmt.Fprintf(Stdout, "schedule: %s\n", sysinfo.Refresh.Schedule)
   717  	} else {
   718  		return errors.New("internal error: both refresh.timer and refresh.schedule are empty")
   719  	}
   720  	last := parseSysinfoTime(sysinfo.Refresh.Last)
   721  	hold := parseSysinfoTime(sysinfo.Refresh.Hold)
   722  	next := parseSysinfoTime(sysinfo.Refresh.Next)
   723  
   724  	if !last.IsZero() {
   725  		fmt.Fprintf(Stdout, "last: %s\n", x.fmtTime(last))
   726  	} else {
   727  		fmt.Fprintf(Stdout, "last: n/a\n")
   728  	}
   729  	if !hold.IsZero() {
   730  		fmt.Fprintf(Stdout, "hold: %s\n", x.fmtTime(hold))
   731  	}
   732  	// only show "next" if its after "hold" to not confuse users
   733  	if !next.IsZero() {
   734  		// Snapstate checks for holdTime.After(limitTime) so we need
   735  		// to check for before or equal here to be fully correct.
   736  		if next.Before(hold) || next.Equal(hold) {
   737  			fmt.Fprintf(Stdout, "next: %s (but held)\n", x.fmtTime(next))
   738  		} else {
   739  			fmt.Fprintf(Stdout, "next: %s\n", x.fmtTime(next))
   740  		}
   741  	} else {
   742  		fmt.Fprintf(Stdout, "next: n/a\n")
   743  	}
   744  	return nil
   745  }
   746  
   747  func (x *cmdRefresh) listRefresh() error {
   748  	snaps, _, err := x.client.Find(&client.FindOptions{
   749  		Refresh: true,
   750  	})
   751  	if err != nil {
   752  		return err
   753  	}
   754  	if len(snaps) == 0 {
   755  		fmt.Fprintln(Stderr, i18n.G("All snaps up to date."))
   756  		return nil
   757  	}
   758  
   759  	sort.Sort(snapsByName(snaps))
   760  
   761  	esc := x.getEscapes()
   762  	w := tabWriter()
   763  	defer w.Flush()
   764  
   765  	// TRANSLATORS: the %s is to insert a filler escape sequence (please keep it flush to the column header, with no extra spaces)
   766  	fmt.Fprintf(w, i18n.G("Name\tVersion\tRev\tPublisher%s\tNotes\n"), fillerPublisher(esc))
   767  	for _, snap := range snaps {
   768  		fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", snap.Name, snap.Version, snap.Revision, shortPublisher(esc, snap.Publisher), NotesFromRemote(snap, nil))
   769  	}
   770  
   771  	return nil
   772  }
   773  
   774  func (x *cmdRefresh) Execute([]string) error {
   775  	if err := x.setChannelFromCommandline(); err != nil {
   776  		return err
   777  	}
   778  	if err := x.validateMode(); err != nil {
   779  		return err
   780  	}
   781  
   782  	if x.Time {
   783  		if x.asksForMode() || x.asksForChannel() {
   784  			return errors.New(i18n.G("--time does not take mode or channel flags"))
   785  		}
   786  		return x.showRefreshTimes()
   787  	}
   788  
   789  	if x.List {
   790  		if len(x.Positional.Snaps) > 0 || x.asksForMode() || x.asksForChannel() {
   791  			return errors.New(i18n.G("--list does not accept additional arguments"))
   792  		}
   793  
   794  		return x.listRefresh()
   795  	}
   796  
   797  	if len(x.Positional.Snaps) == 0 && os.Getenv("SNAP_REFRESH_FROM_TIMER") == "1" {
   798  		fmt.Fprintf(Stdout, "Ignoring `snap refresh` from the systemd timer")
   799  		return nil
   800  	}
   801  
   802  	names := installedSnapNames(x.Positional.Snaps)
   803  	if len(names) == 1 {
   804  		opts := &client.SnapOptions{
   805  			Amend:            x.Amend,
   806  			Channel:          x.Channel,
   807  			IgnoreValidation: x.IgnoreValidation,
   808  			IgnoreRunning:    x.IgnoreRunning,
   809  			Revision:         x.Revision,
   810  			CohortKey:        x.Cohort,
   811  			LeaveCohort:      x.LeaveCohort,
   812  		}
   813  		x.setModes(opts)
   814  		return x.refreshOne(names[0], opts)
   815  	}
   816  
   817  	if x.asksForMode() || x.asksForChannel() {
   818  		return errors.New(i18n.G("a single snap name is needed to specify mode or channel flags"))
   819  	}
   820  
   821  	if x.IgnoreValidation {
   822  		return errors.New(i18n.G("a single snap name must be specified when ignoring validation"))
   823  	}
   824  	if x.IgnoreRunning {
   825  		return errors.New(i18n.G("a single snap name must be specified when ignoring running apps and hooks"))
   826  	}
   827  
   828  	return x.refreshMany(names, nil)
   829  }
   830  
   831  type cmdTry struct {
   832  	waitMixin
   833  
   834  	modeMixin
   835  	Positional struct {
   836  		SnapDir string `positional-arg-name:"<snap-dir>"`
   837  	} `positional-args:"yes"`
   838  }
   839  
   840  func hasSnapcraftYaml() bool {
   841  	for _, loc := range []string{
   842  		"snap/snapcraft.yaml",
   843  		"snapcraft.yaml",
   844  		".snapcraft.yaml",
   845  	} {
   846  		if osutil.FileExists(loc) {
   847  			return true
   848  		}
   849  	}
   850  
   851  	return false
   852  }
   853  
   854  func (x *cmdTry) Execute([]string) error {
   855  	if err := x.validateMode(); err != nil {
   856  		return err
   857  	}
   858  	name := x.Positional.SnapDir
   859  	opts := &client.SnapOptions{}
   860  	x.setModes(opts)
   861  
   862  	if name == "" {
   863  		if hasSnapcraftYaml() && osutil.IsDirectory("prime") {
   864  			name = "prime"
   865  		} else {
   866  			if osutil.FileExists("meta/snap.yaml") {
   867  				name = "./"
   868  			}
   869  		}
   870  		if name == "" {
   871  			return fmt.Errorf(i18n.G("error: the `<snap-dir>` argument was not provided and couldn't be inferred"))
   872  		}
   873  	}
   874  
   875  	path, err := filepath.Abs(name)
   876  	if err != nil {
   877  		// TRANSLATORS: %q gets what the user entered, %v gets the resulting error message
   878  		return fmt.Errorf(i18n.G("cannot get full path for %q: %v"), name, err)
   879  	}
   880  
   881  	changeID, err := x.client.Try(path, opts)
   882  	if err != nil {
   883  		msg, err := errorToCmdMessage(name, err, opts)
   884  		if err != nil {
   885  			return err
   886  		}
   887  		fmt.Fprintln(Stderr, msg)
   888  		return nil
   889  	}
   890  
   891  	chg, err := x.wait(changeID)
   892  	if err != nil {
   893  		if err == noWait {
   894  			return nil
   895  		}
   896  		return err
   897  	}
   898  
   899  	// extract the snap name
   900  	var snapName string
   901  	if err := chg.Get("snap-name", &snapName); err != nil {
   902  		// TRANSLATORS: %q gets the snap name, %v gets the resulting error message
   903  		return fmt.Errorf(i18n.G("cannot extract the snap-name from local file %q: %v"), name, err)
   904  	}
   905  	name = snapName
   906  
   907  	// show output as speced
   908  	snaps, err := x.client.List([]string{name}, nil)
   909  	if err != nil {
   910  		return err
   911  	}
   912  	if len(snaps) != 1 {
   913  		// TRANSLATORS: %q gets the snap name, %v the list of things found when trying to list it
   914  		return fmt.Errorf(i18n.G("cannot get data for %q: %v"), name, snaps)
   915  	}
   916  	snap := snaps[0]
   917  	// TRANSLATORS: 1. snap name, 2. snap version (keep those together please). the 3rd %s is a path (where it's mounted from).
   918  	fmt.Fprintf(Stdout, i18n.G("%s %s mounted from %s\n"), name, snap.Version, path)
   919  	return nil
   920  }
   921  
   922  type cmdEnable struct {
   923  	waitMixin
   924  
   925  	Positional struct {
   926  		Snap installedSnapName `positional-arg-name:"<snap>"`
   927  	} `positional-args:"yes" required:"yes"`
   928  }
   929  
   930  func (x *cmdEnable) Execute([]string) error {
   931  	name := string(x.Positional.Snap)
   932  	opts := &client.SnapOptions{}
   933  	changeID, err := x.client.Enable(name, opts)
   934  	if err != nil {
   935  		return err
   936  	}
   937  
   938  	if _, err := x.wait(changeID); err != nil {
   939  		if err == noWait {
   940  			return nil
   941  		}
   942  		return err
   943  	}
   944  
   945  	fmt.Fprintf(Stdout, i18n.G("%s enabled\n"), name)
   946  	return nil
   947  }
   948  
   949  type cmdDisable struct {
   950  	waitMixin
   951  
   952  	Positional struct {
   953  		Snap installedSnapName `positional-arg-name:"<snap>"`
   954  	} `positional-args:"yes" required:"yes"`
   955  }
   956  
   957  func (x *cmdDisable) Execute([]string) error {
   958  	name := string(x.Positional.Snap)
   959  	opts := &client.SnapOptions{}
   960  	changeID, err := x.client.Disable(name, opts)
   961  	if err != nil {
   962  		return err
   963  	}
   964  
   965  	if _, err := x.wait(changeID); err != nil {
   966  		if err == noWait {
   967  			return nil
   968  		}
   969  		return err
   970  	}
   971  
   972  	fmt.Fprintf(Stdout, i18n.G("%s disabled\n"), name)
   973  	return nil
   974  }
   975  
   976  type cmdRevert struct {
   977  	waitMixin
   978  
   979  	modeMixin
   980  	Revision      string `long:"revision"`
   981  	IgnoreRunning bool   `long:"ignore-running" hidden:"yes"`
   982  	Positional    struct {
   983  		Snap installedSnapName `positional-arg-name:"<snap>"`
   984  	} `positional-args:"yes" required:"yes"`
   985  }
   986  
   987  var shortRevertHelp = i18n.G("Reverts the given snap to the previous state")
   988  var longRevertHelp = i18n.G(`
   989  The revert command reverts the given snap to its state before
   990  the latest refresh. This will reactivate the previous snap revision,
   991  and will use the original data that was associated with that revision,
   992  discarding any data changes that were done by the latest revision. As
   993  an exception, data which the snap explicitly chooses to share across
   994  revisions is not touched by the revert process.
   995  `)
   996  
   997  func (x *cmdRevert) Execute(args []string) error {
   998  	if len(args) > 0 {
   999  		return ErrExtraArgs
  1000  	}
  1001  
  1002  	if err := x.validateMode(); err != nil {
  1003  		return err
  1004  	}
  1005  
  1006  	name := string(x.Positional.Snap)
  1007  	opts := &client.SnapOptions{
  1008  		Revision:      x.Revision,
  1009  		IgnoreRunning: x.IgnoreRunning,
  1010  	}
  1011  	x.setModes(opts)
  1012  	changeID, err := x.client.Revert(name, opts)
  1013  	if err != nil {
  1014  		return err
  1015  	}
  1016  
  1017  	if _, err := x.wait(changeID); err != nil {
  1018  		if err == noWait {
  1019  			return nil
  1020  		}
  1021  		return err
  1022  	}
  1023  
  1024  	return showDone(x.client, []string{name}, "revert", nil, nil)
  1025  }
  1026  
  1027  var shortSwitchHelp = i18n.G("Switches snap to a different channel")
  1028  var longSwitchHelp = i18n.G(`
  1029  The switch command switches the given snap to a different channel without
  1030  doing a refresh.
  1031  `)
  1032  
  1033  type cmdSwitch struct {
  1034  	waitMixin
  1035  	channelMixin
  1036  
  1037  	Cohort      string `long:"cohort"`
  1038  	LeaveCohort bool   `long:"leave-cohort"`
  1039  
  1040  	Positional struct {
  1041  		Snap installedSnapName `positional-arg-name:"<snap>" required:"1"`
  1042  	} `positional-args:"yes" required:"yes"`
  1043  }
  1044  
  1045  func (x cmdSwitch) Execute(args []string) error {
  1046  	if err := x.setChannelFromCommandline(); err != nil {
  1047  		return err
  1048  	}
  1049  
  1050  	name := string(x.Positional.Snap)
  1051  	channel := string(x.Channel)
  1052  
  1053  	switchCohort := x.Cohort != ""
  1054  	switchChannel := x.Channel != ""
  1055  
  1056  	// we have three boolean things to check, meaning 2³=8 possibilities
  1057  	// of which 3 are errors (which is why we look at the errors first).
  1058  	// the 5 valid cases are handled by showDone.
  1059  	if switchCohort && x.LeaveCohort {
  1060  		// this one counts as two (no channel filter)
  1061  		return fmt.Errorf(i18n.G("cannot specify both --cohort and --leave-cohort"))
  1062  	}
  1063  	if !switchCohort && !x.LeaveCohort && !switchChannel {
  1064  		return fmt.Errorf(i18n.G("nothing to switch; specify --channel (and/or one of --cohort/--leave-cohort)"))
  1065  	}
  1066  
  1067  	opts := &client.SnapOptions{
  1068  		Channel:     channel,
  1069  		CohortKey:   x.Cohort,
  1070  		LeaveCohort: x.LeaveCohort,
  1071  	}
  1072  	changeID, err := x.client.Switch(name, opts)
  1073  	if err != nil {
  1074  		return err
  1075  	}
  1076  
  1077  	if _, err := x.wait(changeID); err != nil {
  1078  		if err == noWait {
  1079  			return nil
  1080  		}
  1081  		return err
  1082  	}
  1083  
  1084  	return showDone(x.client, []string{name}, "switch", opts, nil)
  1085  }
  1086  
  1087  func init() {
  1088  	addCommand("remove", shortRemoveHelp, longRemoveHelp, func() flags.Commander { return &cmdRemove{} },
  1089  		waitDescs.also(map[string]string{
  1090  			// TRANSLATORS: This should not start with a lowercase letter.
  1091  			"revision": i18n.G("Remove only the given revision"),
  1092  			// TRANSLATORS: This should not start with a lowercase letter.
  1093  			"purge": i18n.G("Remove the snap without saving a snapshot of its data"),
  1094  		}), nil)
  1095  	addCommand("install", shortInstallHelp, longInstallHelp, func() flags.Commander { return &cmdInstall{} },
  1096  		colorDescs.also(waitDescs).also(channelDescs).also(modeDescs).also(map[string]string{
  1097  			// TRANSLATORS: This should not start with a lowercase letter.
  1098  			"revision": i18n.G("Install the given revision of a snap, to which you must have developer access"),
  1099  			// TRANSLATORS: This should not start with a lowercase letter.
  1100  			"dangerous": i18n.G("Install the given snap file even if there are no pre-acknowledged signatures for it, meaning it was not verified and could be dangerous (--devmode implies this)"),
  1101  			// TRANSLATORS: This should not start with a lowercase letter.
  1102  			"force-dangerous": i18n.G("Alias for --dangerous (DEPRECATED)"),
  1103  			// TRANSLATORS: This should not start with a lowercase letter.
  1104  			"unaliased": i18n.G("Install the given snap without enabling its automatic aliases"),
  1105  			// TRANSLATORS: This should not start with a lowercase letter.
  1106  			"name": i18n.G("Install the snap file under the given instance name"),
  1107  			// TRANSLATORS: This should not start with a lowercase letter.
  1108  			"cohort": i18n.G("Install the snap in the given cohort"),
  1109  			// TRANSLATORS: This should not start with a lowercase letter.
  1110  			"ignore-running": i18n.G("Ignore running hooks or applications blocking the installation"),
  1111  		}), nil)
  1112  	addCommand("refresh", shortRefreshHelp, longRefreshHelp, func() flags.Commander { return &cmdRefresh{} },
  1113  		colorDescs.also(waitDescs).also(channelDescs).also(modeDescs).also(timeDescs).also(map[string]string{
  1114  			// TRANSLATORS: This should not start with a lowercase letter.
  1115  			"amend": i18n.G("Allow refresh attempt on snap unknown to the store"),
  1116  			// TRANSLATORS: This should not start with a lowercase letter.
  1117  			"revision": i18n.G("Refresh to the given revision, to which you must have developer access"),
  1118  			// TRANSLATORS: This should not start with a lowercase letter.
  1119  			"list": i18n.G("Show the new versions of snaps that would be updated with the next refresh"),
  1120  			// TRANSLATORS: This should not start with a lowercase letter.
  1121  			"time": i18n.G("Show auto refresh information but do not perform a refresh"),
  1122  			// TRANSLATORS: This should not start with a lowercase letter.
  1123  			"ignore-validation": i18n.G("Ignore validation by other snaps blocking the refresh"),
  1124  			// TRANSLATORS: This should not start with a lowercase letter.
  1125  			"ignore-running": i18n.G("Ignore running hooks or applications blocking the refresh"),
  1126  			// TRANSLATORS: This should not start with a lowercase letter.
  1127  			"cohort": i18n.G("Refresh the snap into the given cohort"),
  1128  			// TRANSLATORS: This should not start with a lowercase letter.
  1129  			"leave-cohort": i18n.G("Refresh the snap out of its cohort"),
  1130  		}), nil)
  1131  	addCommand("try", shortTryHelp, longTryHelp, func() flags.Commander { return &cmdTry{} }, waitDescs.also(modeDescs), nil)
  1132  	addCommand("enable", shortEnableHelp, longEnableHelp, func() flags.Commander { return &cmdEnable{} }, waitDescs, nil)
  1133  	addCommand("disable", shortDisableHelp, longDisableHelp, func() flags.Commander { return &cmdDisable{} }, waitDescs, nil)
  1134  	addCommand("revert", shortRevertHelp, longRevertHelp, func() flags.Commander { return &cmdRevert{} }, waitDescs.also(modeDescs).also(map[string]string{
  1135  		// TRANSLATORS: This should not start with a lowercase letter.
  1136  		"revision": i18n.G("Revert to the given revision"),
  1137  		// TRANSLATORS: This should not start with a lowercase letter.
  1138  		"ignore-running": i18n.G("Ignore running hooks or applications blocking the revert"),
  1139  	}), nil)
  1140  	addCommand("switch", shortSwitchHelp, longSwitchHelp, func() flags.Commander { return &cmdSwitch{} }, waitDescs.also(channelDescs).also(map[string]string{
  1141  		// TRANSLATORS: This should not start with a lowercase letter.
  1142  		"cohort": i18n.G("Switch the snap into the given cohort"),
  1143  		// TRANSLATORS: This should not start with a lowercase letter.
  1144  		"leave-cohort": i18n.G("Switch the snap out of its cohort"),
  1145  	}), nil)
  1146  }