Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: push client functionality and CLI command #147

Merged
merged 19 commits into from Nov 9, 2023
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
162 changes: 162 additions & 0 deletions client/files.go
@@ -0,0 +1,162 @@
// Copyright (c) 2022 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 client

import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/textproto"
"strings"
)

// PushOptions contains the options for a call to Push.
anpep marked this conversation as resolved.
Show resolved Hide resolved
type PushOptions struct {
// Source is the source of data to write (required).
Source io.Reader

// Path indicates the absolute path of the file in the destination
// machine (required).
Path string

// MakeDirs, if true, will create any non-existing directories in the path
// to the remote file. If false, the default, the call to Push will
// fail if any non-existing directory is found on the remote path.
anpep marked this conversation as resolved.
Show resolved Hide resolved
MakeDirs bool

// Permissions indicates the mode of the file in the destination machine.
// Defaults to 0644. Note that, when used together with MakeDirs, the
// directories that might be created will not use this mode, but 0755.
Permissions string
anpep marked this conversation as resolved.
Show resolved Hide resolved

// UserID indicates the user ID of the owner for the file in the destination
// machine. When used together with MakeDirs, the directories that might be
// created will also be owned by this user.
anpep marked this conversation as resolved.
Show resolved Hide resolved
UserID *int

// User indicates the name of the owner user for the file in the destination
// machine. When used together with MakeDirs, the directories that might be
// created will also be owned by this user.
User string

// GroupID indicates the ID of the owner group for the file in the
// destination machine. When used together with MakeDirs, the directories
// that might be created will also be owned by this group.
GroupID *int

// Group indicates the name of the owner group for the file in the
// destination machine. When used together with MakeDirs, the directories
// that might be created will also be owned by this group.
Group string
}

type writeFilesPayload struct {
Action string `json:"action"`
Files []writeFilesItem `json:"files"`
}

type writeFilesItem struct {
Path string `json:"path"`
MakeDirs bool `json:"make-dirs"`
Permissions string `json:"permissions"`
UserID *int `json:"user-id"`
User string `json:"user"`
GroupID *int `json:"group-id"`
Group string `json:"group"`
}

type fileResult struct {
Path string `json:"path"`
Error *Error `json:"error,omitempty"`
}

var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"")

func escapeQuotes(s string) string {
return quoteEscaper.Replace(s)
}

// Push writes content to a path on the remote system.
func (client *Client) Push(opts *PushOptions) error {
// Buffer for multipart header/footer
var b bytes.Buffer
mw := multipart.NewWriter(&b)

// Encode metadata part of the header
part, err := mw.CreatePart(textproto.MIMEHeader{
"Content-Type": {"application/json"},
"Content-Disposition": {`form-data; name="request"`},
})
if err != nil {
return fmt.Errorf("cannot encode metadata in request payload: %w", err)
}

payload := writeFilesPayload{
Action: "write",
Files: []writeFilesItem{{
flotter marked this conversation as resolved.
Show resolved Hide resolved
Path: opts.Path,
MakeDirs: opts.MakeDirs,
Permissions: opts.Permissions,
anpep marked this conversation as resolved.
Show resolved Hide resolved
UserID: opts.UserID,
User: opts.User,
GroupID: opts.GroupID,
Group: opts.Group,
}},
}
if err = json.NewEncoder(part).Encode(&payload); err != nil {
return err
}

// Encode file part of the header
escapedPath := escapeQuotes(opts.Path)
_, err = mw.CreatePart(textproto.MIMEHeader{
"Content-Type": {"application/octet-stream"},
"Content-Disposition": {fmt.Sprintf(`form-data; name="files"; filename="%s"`, escapedPath)},
})
if err != nil {
return fmt.Errorf("cannot encode file in request payload: %w", err)
}

header := b.String()

// Encode multipart footer
b.Reset()
mw.Close()
footer := b.String()

var result []fileResult
body := io.MultiReader(strings.NewReader(header), opts.Source, strings.NewReader(footer))
headers := map[string]string{
"Content-Type": mw.FormDataContentType(),
}
if _, err := client.doSync("POST", "/v1/files", nil, headers, body, &result); err != nil {
anpep marked this conversation as resolved.
Show resolved Hide resolved
return err
}

if len(result) != 1 {
return fmt.Errorf("expected exactly one result from API, got %d", len(result))
}
if result[0].Error != nil {
return &Error{
Kind: result[0].Error.Kind,
Value: result[0].Error.Value,
Message: result[0].Error.Message,
}
}

return nil
}
154 changes: 154 additions & 0 deletions client/files_test.go
@@ -0,0 +1,154 @@
// Copyright (c) 2022 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 client_test

import (
"bytes"
"encoding/json"
"io"
"path"
"strings"

. "gopkg.in/check.v1"

"github.com/canonical/pebble/client"
)

type writeFilesPayload struct {
Action string `json:"action"`
Files []writeFilesItem `json:"files"`
}

type writeFilesItem struct {
Path string `json:"path"`
MakeDirs bool `json:"make-dirs"`
Permissions string `json:"permissions"`
UserID *int `json:"user-id"`
User string `json:"user"`
GroupID *int `json:"group-id"`
Group string `json:"group"`
}

func (cs *clientSuite) TestPush(c *C) {
cs.rsp = `{"type": "sync", "result": [{"path": "/file.dat"}]}`

err := cs.cli.Push(&client.PushOptions{
Path: "/file.dat",
Source: strings.NewReader("Hello, world!"),
})
c.Assert(err, IsNil)
mr, err := cs.req.MultipartReader()
c.Assert(err, IsNil)

c.Assert(cs.req.URL.Path, Equals, "/v1/files")
c.Assert(cs.req.Method, Equals, "POST")
anpep marked this conversation as resolved.
Show resolved Hide resolved

// Check metadata part
metadata, err := mr.NextPart()
c.Assert(err, IsNil)
c.Assert(metadata.Header.Get("Content-Type"), Equals, "application/json")
c.Assert(metadata.FormName(), Equals, "request")

buf := bytes.NewBuffer(make([]byte, 0))
_, err = buf.ReadFrom(metadata)
c.Assert(err, IsNil)

// Decode metadata
var payload writeFilesPayload
err = json.NewDecoder(buf).Decode(&payload)
c.Assert(err, IsNil)
c.Assert(payload, DeepEquals, writeFilesPayload{
Action: "write",
Files: []writeFilesItem{{
Path: "/file.dat",
}},
})

// Check file part
file, err := mr.NextPart()
c.Assert(err, IsNil)
c.Assert(file.Header.Get("Content-Type"), Equals, "application/octet-stream")
c.Assert(file.FormName(), Equals, "files")
c.Assert(path.Base(file.FileName()), Equals, "file.dat")

buf.Reset()
_, err = buf.ReadFrom(file)
c.Assert(err, IsNil)
c.Assert(buf.String(), Equals, "Hello, world!")

// Check end of multipart request
_, err = mr.NextPart()
c.Assert(err, Equals, io.EOF)
}

func (cs *clientSuite) TestPushFails(c *C) {
cs.rsp = `{"type": "error", "result": {"message": "could not foo"}}`

err := cs.cli.Push(&client.PushOptions{
Path: "/file.dat",
Source: strings.NewReader("Hello, world!"),
})
c.Assert(err, ErrorMatches, "could not foo")
}

func (cs *clientSuite) TestPushFailsOnFile(c *C) {
cs.rsp = `{
"type": "sync",
"result": [{
"path": "/file.dat",
"error": {
"message": "could not bar",
"kind": "permission-denied",
"value": 42
}
}]
}`

err := cs.cli.Push(&client.PushOptions{
Path: "/file.dat",
Source: strings.NewReader("Hello, world!"),
})
clientErr, ok := err.(*client.Error)
c.Assert(ok, Equals, true)
c.Assert(clientErr.Message, Equals, "could not bar")
c.Assert(clientErr.Kind, Equals, "permission-denied")
}

func (cs *clientSuite) TestPushFailsWithMultipleAPIResults(c *C) {
cs.rsp = `{
"type": "sync",
"result": [{
"path": "/file.dat",
"error": {
"message": "could not bar",
"kind": "permission-denied",
"value": 42
}
}, {
"path": "/file.dat",
"error": {
"message": "could not baz",
"kind": "generic-file-error",
"value": 41
}
}]
}`

err := cs.cli.Push(&client.PushOptions{
Path: "/file.dat",
Source: strings.NewReader("Hello, world!"),
})
c.Assert(err, ErrorMatches, "expected exactly one result from API, got 2")
}
2 changes: 1 addition & 1 deletion cmd/pebble/cmd_help.go
Expand Up @@ -180,7 +180,7 @@ var helpCategories = []helpCategory{{
}, {
Label: "Files",
Description: "work with files and execute commands",
Commands: []string{"exec"},
Commands: []string{"push", "exec"},
}, {
Label: "Changes",
Description: "manage changes and their tasks",
Expand Down