diff options
| author | Alfonso Sánchez-Beato <alfonso.sanchez-beato@canonical.com> | 2023-06-07 14:45:40 +0100 |
|---|---|---|
| committer | Michael Vogt <michael.vogt@gmail.com> | 2023-06-22 12:44:28 +0200 |
| commit | 45b57be9faed078d136541c5f1413626e9b64ffa (patch) | |
| tree | 29b7235e17f1427362df643ff9efc0d86f43504d | |
| parent | 347977dda12c084de5ee60500bb33aa1c08b8290 (diff) | |
client,cmd: support for offline remodeling
This adds options --snap and --assertion to the "snap remodel" command, which can be used to pass local files to snapd so offline remodelling is possible.
| -rw-r--r-- | client/model.go | 113 | ||||
| -rw-r--r-- | client/model_test.go | 90 | ||||
| -rw-r--r-- | cmd/snap/cmd_help.go | 2 | ||||
| -rw-r--r-- | cmd/snap/cmd_remodel.go | 34 | ||||
| -rw-r--r-- | cmd/snap/cmd_remodel_test.go | 109 |
5 files changed, 341 insertions, 7 deletions
diff --git a/client/model.go b/client/model.go index 72a307a368..57bc53541e 100644 --- a/client/model.go +++ b/client/model.go @@ -24,7 +24,12 @@ import ( "context" "encoding/json" "fmt" + "io" + "mime/multipart" + "net/textproto" "net/url" + "os" + "path/filepath" "golang.org/x/xerrors" @@ -50,6 +55,114 @@ func (client *Client) Remodel(b []byte) (changeID string, err error) { return client.doAsync("POST", "/v2/model", nil, headers, bytes.NewReader(data)) } +// RemodelOffline tries to remodel the system with the given model assertion +// and local snaps and assertion files. +func (client *Client) RemodelOffline( + model []byte, snapPaths, assertPaths []string) (changeID string, err error) { + + // Check if all files exist before starting the go routine + snapFiles, err := checkAndOpenFiles(snapPaths) + if err != nil { + return "", err + } + assertsFiles, err := checkAndOpenFiles(assertPaths) + if err != nil { + return "", err + } + + pr, pw := io.Pipe() + mw := multipart.NewWriter(pw) + go sendRemodelFiles(model, snapPaths, snapFiles, assertsFiles, pw, mw) + + headers := map[string]string{ + "Content-Type": mw.FormDataContentType(), + } + + _, changeID, err = client.doAsyncFull("POST", "/v2/model", nil, headers, pr, doNoTimeoutAndRetry) + return changeID, err +} + +func checkAndOpenFiles(paths []string) ([]*os.File, error) { + var files []*os.File + for _, path := range paths { + f, err := os.Open(path) + if err != nil { + for _, openFile := range files { + openFile.Close() + } + return nil, fmt.Errorf("cannot open %q: %w", path, err) + } + + files = append(files, f) + } + + return files, nil +} + +func createAssertionPart(name string, mw *multipart.Writer) (io.Writer, error) { + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", + fmt.Sprintf(`form-data; name="%s"`, name)) + h.Set("Content-Type", asserts.MediaType) + return mw.CreatePart(h) +} + +func sendRemodelFiles(model []byte, paths []string, files, assertFiles []*os.File, pw *io.PipeWriter, mw *multipart.Writer) { + defer func() { + for _, f := range files { + f.Close() + } + }() + + w, err := createAssertionPart("new-model", mw) + if err != nil { + pw.CloseWithError(err) + return + } + _, err = w.Write(model) + if err != nil { + pw.CloseWithError(err) + return + } + + for _, file := range assertFiles { + if err := sendPartFromFile(file, + func() (io.Writer, error) { + return createAssertionPart("assertion", mw) + }); err != nil { + pw.CloseWithError(err) + return + } + } + + for i, file := range files { + if err := sendPartFromFile(file, + func() (io.Writer, error) { + return mw.CreateFormFile("snap", filepath.Base(paths[i])) + }); err != nil { + pw.CloseWithError(err) + return + } + } + + mw.Close() + pw.Close() +} + +func sendPartFromFile(file *os.File, writeHeader func() (io.Writer, error)) error { + fw, err := writeHeader() + if err != nil { + return err + } + + _, err = io.Copy(fw, file) + if err != nil { + return err + } + + return nil +} + // CurrentModelAssertion returns the current model assertion func (client *Client) CurrentModelAssertion() (*asserts.Model, error) { assert, err := currentAssertion(client, "/v2/model") diff --git a/client/model_test.go b/client/model_test.go index c9e535123e..2a23c55568 100644 --- a/client/model_test.go +++ b/client/model_test.go @@ -24,11 +24,15 @@ import ( "errors" "io/ioutil" "net/http" + "path/filepath" + "regexp" + "strings" "golang.org/x/xerrors" . "gopkg.in/check.v1" "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/dirs" ) const happyModelAssertionResponse = `type: model @@ -173,3 +177,89 @@ func (cs *clientSuite) TestClientCurrentModelAssertionErrIsWrapped(c *C) { var e xerrors.Wrapper c.Assert(err, Implements, &e) } + +func (cs *clientSuite) TestClientOfflineRemodel(c *C) { + cs.status = 202 + cs.rsp = `{ + "type": "async", + "status-code": 202, + "result": {}, + "change": "d728" + }` + rawModel := []byte(`some-model`) + + var err error + snapPaths := []string{filepath.Join(dirs.GlobalRootDir, "snap1.snap")} + err = ioutil.WriteFile(snapPaths[0], []byte("snap1"), 0644) + c.Assert(err, IsNil) + assertsPaths := []string{filepath.Join(dirs.GlobalRootDir, "f1.asserts")} + err = ioutil.WriteFile(assertsPaths[0], []byte("asserts1"), 0644) + c.Assert(err, IsNil) + + id, err := cs.cli.RemodelOffline(rawModel, snapPaths, assertsPaths) + c.Assert(err, IsNil) + c.Check(id, Equals, "d728") + contentTypeReStr := "^multipart/form-data; boundary=([A-Za-z0-9]*)$" + contentType := cs.req.Header.Get("Content-Type") + c.Assert(contentType, Matches, contentTypeReStr) + contentTypeRe := regexp.MustCompile(contentTypeReStr) + matches := contentTypeRe.FindStringSubmatch(contentType) + c.Assert(len(matches), Equals, 2) + boundary := "--" + matches[1] + + body, err := ioutil.ReadAll(cs.req.Body) + c.Assert(err, IsNil) + expected := boundary + ` +Content-Disposition: form-data; name="new-model" +Content-Type: application/x.ubuntu.assertion + +some-model +` + boundary + ` +Content-Disposition: form-data; name="assertion" +Content-Type: application/x.ubuntu.assertion + +asserts1 +` + boundary + ` +Content-Disposition: form-data; name="snap"; filename="snap1.snap" +Content-Type: application/octet-stream + +snap1 +` + boundary + `-- +` + expected = strings.Replace(expected, "\n", "\r\n", -1) + c.Assert(string(body), Equals, expected) +} + +func (cs *clientSuite) TestClientOfflineRemodelServerError(c *C) { + cs.status = 404 + cs.rsp = noSerialAssertionYetResponse + rawModel := []byte(`some-model`) + + var err error + snapPaths := []string{filepath.Join(dirs.GlobalRootDir, "snap1.snap")} + err = ioutil.WriteFile(snapPaths[0], []byte("snap1"), 0644) + c.Assert(err, IsNil) + assertsPaths := []string{filepath.Join(dirs.GlobalRootDir, "f1.asserts")} + err = ioutil.WriteFile(assertsPaths[0], []byte("asserts1"), 0644) + c.Assert(err, IsNil) + + id, err := cs.cli.RemodelOffline(rawModel, snapPaths, assertsPaths) + c.Assert(err.Error(), Equals, "no serial assertion yet") + c.Check(id, Equals, "") +} + +func (cs *clientSuite) TestClientOfflineRemodelNoFile(c *C) { + rawModel := []byte(`some-model`) + + paths := []string{filepath.Join(dirs.GlobalRootDir, "snap1.snap")} + + // No snap file + id, err := cs.cli.RemodelOffline(rawModel, paths, nil) + c.Assert(err, ErrorMatches, `cannot open .*: no such file or directory`) + c.Assert(id, Equals, "") + + // No assertions file + id, err = cs.cli.RemodelOffline(rawModel, nil, paths) + c.Assert(err, ErrorMatches, `cannot open .*: no such file or directory`) + c.Assert(id, Equals, "") +} diff --git a/cmd/snap/cmd_help.go b/cmd/snap/cmd_help.go index f269db3e8b..942f1a4eaf 100644 --- a/cmd/snap/cmd_help.go +++ b/cmd/snap/cmd_help.go @@ -240,7 +240,7 @@ var helpCategories = []helpCategory{ }, { Label: i18n.G("Device"), Description: i18n.G("manage device"), - Commands: []string{"model", "reboot", "recovery"}, + Commands: []string{"model", "remodel", "reboot", "recovery"}, }, { Label: i18n.G("Warnings"), Other: true, diff --git a/cmd/snap/cmd_remodel.go b/cmd/snap/cmd_remodel.go index 335abdfab3..efd65eafec 100644 --- a/cmd/snap/cmd_remodel.go +++ b/cmd/snap/cmd_remodel.go @@ -36,29 +36,40 @@ revision or a full new model. In the process it applies any implied changes to the device: new required snaps, new kernel or gadget etc. + +Snaps and assertions are downloaded from the store unless they are provided as +local files specified by --snap and --assertion options. If using these +options, it is expected that all the needed snaps and assertions are provided +locally, otherwise the remodel will fail. `) ) type cmdRemodel struct { waitMixin + SnapFiles []string `long:"snap"` + AssertionFiles []string `long:"assertion"` RemodelOptions struct { NewModelFile flags.Filename } `positional-args:"true" required:"true"` } func init() { - cmd := addCommand("remodel", + addCommand("remodel", shortRemodelHelp, longRemodelHelp, func() flags.Commander { return &cmdRemodel{} - }, nil, []argDesc{{ + }, + waitDescs.also(map[string]string{ + "snap": i18n.G("Use one or more locally available snaps."), + "assertion": i18n.G("Use one or more locally available assertion files."), + }), + []argDesc{{ // TRANSLATORS: This needs to begin with < and end with > name: i18n.G("<new model file>"), // TRANSLATORS: This should not start with a lowercase letter. desc: i18n.G("New model file"), }}) - cmd.hidden = true } func (x *cmdRemodel) Execute(args []string) error { @@ -70,9 +81,20 @@ func (x *cmdRemodel) Execute(args []string) error { if err != nil { return err } - changeID, err := x.client.Remodel(modelData) - if err != nil { - return fmt.Errorf("cannot remodel: %v", err) + + var changeID string + if len(x.SnapFiles) > 0 || len(x.AssertionFiles) > 0 { + // don't log the request's body as it will be large + x.client.SetMayLogBody(false) + changeID, err = x.client.RemodelOffline(modelData, x.SnapFiles, x.AssertionFiles) + if err != nil { + return fmt.Errorf("cannot do offline remodel: %v", err) + } + } else { + changeID, err = x.client.Remodel(modelData) + if err != nil { + return fmt.Errorf("cannot remodel: %v", err) + } } if _, err := x.wait(changeID); err != nil { diff --git a/cmd/snap/cmd_remodel_test.go b/cmd/snap/cmd_remodel_test.go new file mode 100644 index 0000000000..f55cc51433 --- /dev/null +++ b/cmd/snap/cmd_remodel_test.go @@ -0,0 +1,109 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2023 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +package main_test + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + + . "gopkg.in/check.v1" + + snap "github.com/snapcore/snapd/cmd/snap" + "github.com/snapcore/snapd/dirs" +) + +const remodelOk = `{ + "type": "async", + "status-code": 202, + "status": "OK", + "change": "101" +}` + +const remodelError = `{ + "type": "error", + "result": { + "message": "bad snap", + "kind": "bad snap" + }, + "status-code": 400 +}` + +func (s *SnapSuite) TestRemodelOfflineOk(c *C) { + n := 0 + + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "POST") + c.Check(r.URL.Path, Equals, "/v2/model") + w.WriteHeader(202) + fmt.Fprint(w, remodelOk) + n++ + }) + + var err error + modelPath := filepath.Join(dirs.GlobalRootDir, "new-model") + err = os.WriteFile(modelPath, []byte("snap1"), 0644) + c.Assert(err, IsNil) + snapPath := filepath.Join(dirs.GlobalRootDir, "snap1.snap") + err = os.WriteFile(snapPath, []byte("snap1"), 0644) + c.Assert(err, IsNil) + + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"remodel", "--no-wait", "--snap", snapPath, modelPath}) + + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + c.Assert(n, Equals, 1) + + c.Check(s.Stdout(), Matches, "101\n") + c.Check(s.Stderr(), Equals, "") + + s.ResetStdStreams() +} + +func (s *SnapSuite) TestRemodelOfflineError(c *C) { + n := 0 + + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "POST") + c.Check(r.URL.Path, Equals, "/v2/model") + w.WriteHeader(400) + fmt.Fprint(w, remodelError) + n++ + }) + + var err error + modelPath := filepath.Join(dirs.GlobalRootDir, "new-model") + err = os.WriteFile(modelPath, []byte("snap1"), 0644) + c.Assert(err, IsNil) + snapPath := filepath.Join(dirs.GlobalRootDir, "snap1.snap") + err = os.WriteFile(snapPath, []byte("snap1"), 0644) + c.Assert(err, IsNil) + + _, err = snap.Parser(snap.Client()).ParseArgs([]string{"remodel", "--no-wait", "--snap", snapPath, modelPath}) + + c.Assert(err.Error(), Equals, "cannot do offline remodel: bad snap") + c.Check(n, Equals, 1) + + c.Check(s.Stdout(), Matches, "") + c.Check(s.Stderr(), Equals, "") + + s.ResetStdStreams() +} |
