Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions cmd/snap/cmd_changes.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import (
"sort"

"github.com/jessevdk/go-flags"

"github.com/snapcore/snapd/client"
"github.com/snapcore/snapd/i18n"
)
Expand All @@ -51,14 +50,15 @@ type cmdChanges struct {
type cmdTasks struct {
timeMixin
changeIDMixin
formatMixin
}

func init() {
addCommand("changes", shortChangesHelp, longChangesHelp,
func() flags.Commander { return &cmdChanges{} }, timeDescs, nil)
addCommand("tasks", shortTasksHelp, longTasksHelp,
func() flags.Commander { return &cmdTasks{} },
changeIDMixinOptDesc.also(timeDescs),
changeIDMixinOptDesc.also(timeDescs).also(formatArgsHelp),
changeIDMixinArgDesc).alias = "change"
}

Expand Down Expand Up @@ -160,6 +160,11 @@ func (c *cmdTasks) showChange(chid string) error {
return err
}

if c.Format != "text" && c.Format != "" {
err = c.formatNonText(chg)
return err
}

w := tabWriter()

fmt.Fprint(w, i18n.G("Status\tSpawn\tReady\tSummary\n"))
Expand Down
33 changes: 33 additions & 0 deletions cmd/snap/cmd_changes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,14 @@
package main_test

import (
"encoding/json"
"fmt"
"net/http"
"strings"

"gopkg.in/check.v1"

"github.com/snapcore/snapd/client"
snap "github.com/snapcore/snapd/cmd/snap"
)

Expand Down Expand Up @@ -232,6 +234,37 @@ Doing +2016-04-21T01:02:03Z +2016-04-21T01:02:04Z +some summary \(50.00%\)
c.Check(s.Stderr(), check.Equals, "")
}

func (s *SnapSuite) TestTasksJSON(c *check.C) {
s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) {
c.Check(r.Method, check.Equals, "GET")
c.Check(r.URL.Path, check.Equals, "/v2/changes/42")
fmt.Fprintln(w, mockChangeJSON)
})

rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"tasks", "--format=json", "42"})
c.Assert(err, check.IsNil)
c.Assert(rest, check.DeepEquals, []string{})

var chg client.Change
c.Assert(json.Unmarshal([]byte(s.Stdout()), &chg), check.IsNil)
c.Check(chg.ID, check.Equals, "uno")
c.Check(chg.Kind, check.Equals, "foo")
c.Check(chg.Summary, check.Equals, "...")
c.Check(chg.Status, check.Equals, "Do")
c.Check(chg.Ready, check.Equals, false)
c.Assert(chg.Tasks, check.HasLen, 1)
c.Check(chg.Tasks[0].Kind, check.Equals, "bar")
c.Check(chg.Tasks[0].Summary, check.Equals, "some summary")
c.Check(chg.Tasks[0].Status, check.Equals, "Do")

// If format (which has defined values) gets passed an invalid value, the parser wraps it in `'.
_, err = snap.Parser(snap.Client()).ParseArgs([]string{"tasks", "--format=", "42"})
c.Assert(err, check.ErrorMatches, ".*Invalid value `' for option `--format'. Allowed values are: .* or json")
_, err = snap.Parser(snap.Client()).ParseArgs([]string{"tasks", "--format=random", "42"})
c.Assert(err, check.ErrorMatches, ".*Invalid value `random' for option `--format'. Allowed values are: .* or json")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no newline needed here

}

func (s *SnapSuite) TestNoChanges(c *check.C) {
n := 0
s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) {
Expand Down
23 changes: 23 additions & 0 deletions cmd/snap/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
package main

import (
"encoding/json"
"errors"
"fmt"
"io"
Expand Down Expand Up @@ -215,6 +216,28 @@ func (ch *clientMixin) setClient(cli *client.Client) {
ch.client = cli
}

type formatMixin struct {
//lint:ignore SA5008 "choice" tag is intentionally duplicated
Format string `long:"format" default:"text" choice:"text" choice:"json"`
}

var formatArgsHelp = map[string]string{
"format": i18n.G("Output format"),
}

func (mx formatMixin) formatNonText(result any) error {
switch mx.Format {
case "json":
data, err := json.Marshal(result)
if err != nil {
return err
}
fmt.Fprintln(Stdout, string(data))
return nil
}
panic(fmt.Sprintf("internal error: invalid format option %q", mx.Format))
}

func firstNonOptionIsRun() bool {
if len(os.Args) < 2 {
return false
Expand Down
9 changes: 6 additions & 3 deletions overlord/hookstate/ctlcmd/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,7 @@ type installCommand struct {
Positional struct {
Names []string `positional-arg-name:"<snap|snap+comp|+comp>" required:"yes" description:"Components to be installed (snap must be the caller snap if specified)."`
} `positional-args:"yes"`
// TODO: temporarily disabled to prevent partial implementation in release
// NoWait bool `long:"no-wait" description:"Run the command in asynchronous mode, returning a change id that can be used to determine if the change is ready using the is-ready command."`
NoWait bool `long:"no-wait" description:"Run the command in asynchronous mode, returning a change id that can be used to determine if the change is ready using the is-ready command."`
}

func (c *installCommand) Execute([]string) error {
Expand All @@ -58,7 +57,7 @@ func (c *installCommand) Execute([]string) error {
return err
}

_, affectedComponents, err := runSnapManagementCommand(ctx, managementCommand{operation: installManagementCommand, components: comps, async: false})
id, affectedComponents, err := runSnapManagementCommand(ctx, managementCommand{operation: installManagementCommand, components: comps, async: c.NoWait})

if err != nil {
if _, ok := err.(*snap.AlreadyInstalledError); !ok {
Expand All @@ -75,5 +74,9 @@ func (c *installCommand) Execute([]string) error {
}
}

if c.NoWait {
fmt.Fprintf(c.stdout, "%s", id)
}

return nil
}
24 changes: 11 additions & 13 deletions overlord/hookstate/ctlcmd/is_ready.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,30 +32,29 @@ type isReadyCommand struct {

const (
changeReadyExitCode = iota
changeNotReadyExitCode
fatalErrorExitCode
changeUnsuccessfulExitCode
otherErrorExitCode
changeNotReadyExitCode
)

var _ = i18n.G(`Return the status of the associated change id.`)
var _ = i18n.G(`
var shortIsReadyHelp = i18n.G(`Return the status of the associated change id.`)
var longIsReadyHelp = i18n.G(`
The is-ready command is used to query the status of change ids that are returned
by asynchronous snapctl commands.

$ snapctl is-ready <change-id>
0: change completed successfully (Done)
1: change is not ready
1: fatal errors (invalid change id, permissions error)
2: change is ready but did not complete successfully (Undone, Error, Hold)
3: other errors (invalid change id, permissions error)
3: change is not ready
stdout: empty, exit code conveys change readiness
stderr: empty for exit codes 0 and 1. Contains relevant errors for exit codes 2 and 3.
stderr: empty for exit codes 0 and 3. Contains relevant errors for exit codes 1 and 2.
`)

func init() {
// TODO: temporarily disabled to prevent partial implementation in release
//addCommand("is-ready", shortIsReadyHelp, longIsReadyHelp, func() command {
// return &isReadyCommand{}
//})
addCommand("is-ready", shortIsReadyHelp, longIsReadyHelp, func() command {
return &isReadyCommand{}
})
}

func (c *isReadyCommand) Execute(args []string) error {
Expand All @@ -73,8 +72,7 @@ func (c *isReadyCommand) Execute(args []string) error {
ready, err := isReady(ctx, changeID)

if err != nil {
fmt.Fprint(c.stderr, err.Error())
return &UnsuccessfulError{ExitCode: otherErrorExitCode}
return err
}

if !ready.Ready() {
Expand Down
52 changes: 16 additions & 36 deletions overlord/hookstate/ctlcmd/is_ready_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,6 @@ func (s *isReadySuite) SetUpTest(c *C) {
// setupChangeAndContext creates a state, a change (with an optional initiator),
// and a non-ephemeral hook context for "test-snap".
func (s *isReadySuite) setupChangeAndContext(c *C, taskStatus state.Status, initiatorSnap string) (*state.State, *hookstate.Context, string) {
c.Skip("Content removed for release 2.76")

st := state.New(nil)
st.Lock()
defer st.Unlock()
Expand All @@ -74,15 +72,11 @@ func (s *isReadySuite) setupChangeAndContext(c *C, taskStatus state.Status, init
}

func (s *isReadySuite) TestIsReadyNoContext(c *C) {
c.Skip("Content removed for release 2.76")

_, _, err := ctlcmd.Run(nil, []string{"is-ready", "1"}, 0, nil)
c.Assert(err, ErrorMatches, `cannot invoke snapctl operation commands.*from outside of a snap`)
}

func (s *isReadySuite) TestIsReadyArgCount(c *C) {
c.Skip("Content removed for release 2.76")

_, ctx, _ := s.setupChangeAndContext(c, state.DoneStatus, "test-snap")
_, _, err := ctlcmd.Run(ctx, []string{"is-ready"}, 0, nil)
c.Assert(err, ErrorMatches, `invalid number of arguments: expected 1, got 0`)
Expand All @@ -92,43 +86,39 @@ func (s *isReadySuite) TestIsReadyArgCount(c *C) {
}

func (s *isReadySuite) TestIsReadyChangeNotFound(c *C) {
c.Skip("Content removed for release 2.76")

_, ctx, _ := s.setupChangeAndContext(c, state.DoneStatus, "")
_, stderr, err := ctlcmd.Run(ctx, []string{"is-ready", "nonexistent-id"}, 0, nil)
c.Assert(err, DeepEquals, &ctlcmd.UnsuccessfulError{ExitCode: 3})
c.Check(string(stderr), Matches, `change "nonexistent-id" not found`)
_, _, err := ctlcmd.Run(ctx, []string{"is-ready", "nonexistent-id"}, 0, nil)
c.Check(err, ErrorMatches, `change "nonexistent-id" not found`)
}

func (s *isReadySuite) TestIsReadyLogic(c *C) {
c.Skip("Content removed for release 2.76")
func (s *isReadySuite) TestIsReadyChangeWithoutInitiatorNotFound(c *C) {
_, ctx, changeID := s.setupChangeAndContext(c, state.DoneStatus, "")
_, _, err := ctlcmd.Run(ctx, []string{"is-ready", changeID}, 0, nil)
c.Assert(err, ErrorMatches, `change .* not found`)
}

func (s *isReadySuite) TestIsReadyChangeFromOtherSnapNotFound(c *C) {
_, ctx, changeID := s.setupChangeAndContext(c, state.DoneStatus, "other-snap")
_, _, err := ctlcmd.Run(ctx, []string{"is-ready", changeID}, 0, nil)
c.Assert(err, ErrorMatches, `change .* not found`)
}

func (s *isReadySuite) TestIsReadyLogic(c *C) {
var logicTests = []struct {
taskStatus state.Status
initiatorSnap string // empty = don't set initiated-by-snap on the change
errValue error // if set, expect err to deep equal this value
expectedOut string
expectedStderr string // if set, checked as regexp match against stderr
}{
{
taskStatus: state.DoneStatus,
errValue: &ctlcmd.UnsuccessfulError{ExitCode: 3},
expectedStderr: `change .* not found`,
},
{
taskStatus: state.DoneStatus,
initiatorSnap: "other-snap", // different from context snap "test-snap"
errValue: &ctlcmd.UnsuccessfulError{ExitCode: 3},
expectedStderr: `change .* not found`,
},
{
taskStatus: state.DoneStatus,
initiatorSnap: "test-snap",
},
{
taskStatus: state.DoingStatus,
initiatorSnap: "test-snap",
errValue: &ctlcmd.UnsuccessfulError{ExitCode: 1},
errValue: &ctlcmd.UnsuccessfulError{ExitCode: 3},
},
{
taskStatus: state.ErrorStatus,
Expand Down Expand Up @@ -163,8 +153,6 @@ func (s *isReadySuite) TestIsReadyLogic(c *C) {

// Rate-limiting tests
func (s *isReadySuite) rateLimitSetup(c *C, taskStatus state.Status, lastAccessedTime any) (*hookstate.Context, string) {
c.Skip("Content removed for release 2.76")

st := state.New(nil)
st.Lock()
defer st.Unlock()
Expand All @@ -191,8 +179,6 @@ func (s *isReadySuite) rateLimitSetup(c *C, taskStatus state.Status, lastAccesse
// last-accessed cache entry (e.g. after a snapd restart) as a first access and
// proceeds to report the real change status rather than returning an error.
func (s *isReadySuite) TestIsReadyMissingLastAccessed(c *C) {
c.Skip("Content removed for release 2.76")

ctx, changeID := s.rateLimitSetup(c, state.DoneStatus, nil)

_, _, err := ctlcmd.Run(ctx, []string{"is-ready", changeID}, 0, nil)
Expand All @@ -204,8 +190,6 @@ func (s *isReadySuite) TestIsReadyMissingLastAccessed(c *C) {
// 200 ms debounce window, is-ready sleeps for the remaining window duration
// before checking the change status.
func (s *isReadySuite) TestIsReadyRateLimitDelaysPolling(c *C) {
c.Skip("Content removed for release 2.76")

// A last-accessed time in the future guarantees we are within the debounce
// window, ensuring timeAfter is called with a positive duration.
ctx, changeID := s.rateLimitSetup(c, state.DoneStatus, time.Now().Add(time.Second).UnixNano())
Expand All @@ -227,8 +211,6 @@ func (s *isReadySuite) TestIsReadyRateLimitDelaysPolling(c *C) {
// change is ready, is-ready reports DoingStatus (exit code 1) and the timer
// channel is drained.
func (s *isReadySuite) TestIsReadyRateLimitTimerFires(c *C) {
c.Skip("Content removed for release 2.76")

// A last-accessed time in the future puts us inside the debounce window.
// The task is left in DoingStatus so chg.Ready() never fires, ensuring
// the timer case is the only one that can win the select.
Expand All @@ -243,15 +225,13 @@ func (s *isReadySuite) TestIsReadyRateLimitTimerFires(c *C) {

_, _, err := ctlcmd.Run(ctx, []string{"is-ready", changeID}, 0, nil)

c.Assert(err, DeepEquals, &ctlcmd.UnsuccessfulError{ExitCode: 1})
c.Assert(err, DeepEquals, &ctlcmd.UnsuccessfulError{ExitCode: 3})
c.Check(len(timerCh), Equals, 0) // element was consumed by the select
}

// TestIsReadyExpiredWindowSkipsTimeAfter verifies that when the debounce window
// has already elapsed, is-ready returns the change status directly
func (s *isReadySuite) TestIsReadyExpiredWindowSkipsTimeAfter(c *C) {
c.Skip("Content removed for release 2.76")

// A last-accessed time sufficiently in the past guarantees toWait <= 0.
ctx, changeID := s.rateLimitSetup(c, state.DoneStatus, time.Now().Add(-time.Second).UnixNano())

Expand Down
11 changes: 8 additions & 3 deletions overlord/hookstate/ctlcmd/remove.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
package ctlcmd

import (
"fmt"

"github.com/snapcore/snapd/i18n"
)

Expand All @@ -39,8 +41,7 @@ type removeCommand struct {
Positional struct {
Names []string `positional-arg-name:"<snap|snap+comp|+comp>" required:"yes" description:"Components to be removed (snap must be the caller snap if specified)."`
} `positional-args:"yes"`
// TODO: temporarily disabled to prevent partial implementation in release
// NoWait bool `long:"no-wait" description:"Run the command in asynchronous mode, returning a change id that can be used to determine if the change is ready using the is-ready command."`
NoWait bool `long:"no-wait" description:"Run the command in asynchronous mode, returning a change id that can be used to determine if the change is ready using the is-ready command."`
}

func (c *removeCommand) Execute([]string) error {
Expand All @@ -54,10 +55,14 @@ func (c *removeCommand) Execute([]string) error {
return err
}

_, _, err = runSnapManagementCommand(ctx, managementCommand{operation: removeManagementCommand, components: comps, async: false})
id, _, err := runSnapManagementCommand(ctx, managementCommand{operation: removeManagementCommand, components: comps, async: c.NoWait})
if err != nil {
return err
}

if c.NoWait {
fmt.Fprintf(c.stdout, "%s", id)
}

return nil
}
4 changes: 0 additions & 4 deletions overlord/hookstate/ctlcmd/snap_management_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -303,8 +303,6 @@ func (s *installSuite) TestRemoveCommandBadCompName(c *C) {
}

func (s *installSuite) TestNoWaitNonEphemeralReturnsError(c *C) {
c.Skip("Content removed for release 2.76")

for _, cmd := range []string{"install", "remove"} {
s.st.Lock()
task := s.st.NewTask("test", "test task")
Expand All @@ -329,8 +327,6 @@ func (s *installSuite) TestNoWaitNonEphemeralReturnsError(c *C) {
}

func (s *installSuite) TestNoWaitInstallAndRemoveCommands(c *C) {
c.Skip("Content removed for release 2.76")

for _, cmd := range []string{"install", "remove"} {
s.st.Lock()
task := s.st.NewTask("test", "test task")
Expand Down
Loading
Loading