summaryrefslogtreecommitdiff
diff options
authorAlfonso Sánchez-Beato <alfonso.sanchez-beato@canonical.com>2023-06-07 14:45:40 +0100
committerMichael Vogt <michael.vogt@gmail.com>2023-06-22 12:44:28 +0200
commit45b57be9faed078d136541c5f1413626e9b64ffa (patch)
tree29b7235e17f1427362df643ff9efc0d86f43504d
parent347977dda12c084de5ee60500bb33aa1c08b8290 (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.go113
-rw-r--r--client/model_test.go90
-rw-r--r--cmd/snap/cmd_help.go2
-rw-r--r--cmd/snap/cmd_remodel.go34
-rw-r--r--cmd/snap/cmd_remodel_test.go109
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()
+}