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 all 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
148 changes: 148 additions & 0 deletions client/files.go
Expand Up @@ -16,11 +16,16 @@ package client

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/textproto"
"net/url"
"os"
"strconv"
"strings"
"time"
)

Expand Down Expand Up @@ -364,3 +369,146 @@ func (client *Client) RemovePath(opts *RemovePathOptions) error {

return nil
}

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 of the parent directories of path do not exist.
MakeDirs bool

// Permissions indicates the mode of the file on the destination machine.
// If 0 or unset, defaults to 0644. Note that, when used together with MakeDirs,
// the directories that are created will not use this mode, but 0755.
Permissions os.FileMode

// UserID indicates the user ID of the owner for the file on the destination
// machine. When used together with MakeDirs, the directories that are
// created will also be owned by this user.
UserID *int

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

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

// Group indicates the name of the owner group for the file on the
// machine. When used together with MakeDirs, the directories that are
// created will also be owned by this user.
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"`
}

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 {
var permissions string
if opts.Permissions != 0 {
permissions = fmt.Sprintf("%03o", opts.Permissions)
}

payload := writeFilesPayload{
Action: "write",
Files: []writeFilesItem{{
flotter marked this conversation as resolved.
Show resolved Hide resolved
Path: opts.Path,
MakeDirs: opts.MakeDirs,
Permissions: permissions,
UserID: opts.UserID,
User: opts.User,
GroupID: opts.GroupID,
Group: opts.Group,
}},
}

var body bytes.Buffer
mw := multipart.NewWriter(&body)

// 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)
}

// Buffer for multipart header/footer
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 := body.String()

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

resp, err := client.Requester().Do(context.Background(), &RequestOptions{
Type: SyncRequest,
Method: "POST",
Path: "/v1/files",
Headers: map[string]string{"Content-Type": mw.FormDataContentType()},
Body: io.MultiReader(strings.NewReader(header), opts.Source, strings.NewReader(footer)),
})
if err != nil {
return err
}

var result []fileResult
if err = resp.DecodeResult(&result); err != nil {
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
}
131 changes: 131 additions & 0 deletions client/files_test.go
Expand Up @@ -15,8 +15,12 @@
package client_test

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

. "gopkg.in/check.v1"
Expand Down Expand Up @@ -574,3 +578,130 @@ func (cs *clientSuite) TestRemovePathFailsWithMultipleAPIResults(c *C) {
}},
})
}

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 internals/cli/cmd_help.go
Expand Up @@ -192,7 +192,7 @@ var HelpCategories = []HelpCategory{{
}, {
Label: "Files",
Description: "work with files and execute commands",
Commands: []string{"ls", "mkdir", "rm", "exec"},
Commands: []string{"push", "ls", "mkdir", "rm", "exec"},
}, {
Label: "Changes",
Description: "manage changes and their tasks",
Expand Down
24 changes: 12 additions & 12 deletions internals/cli/cmd_mkdir.go
Expand Up @@ -32,13 +32,13 @@ The mkdir command creates the specified directory.
type cmdMkdir struct {
client *client.Client

MakeParents bool `short:"p"`
Permissions string `short:"m"`
UserID *int `long:"uid"`
User string `long:"user"`
GroupID *int `long:"gid"`
Group string `long:"group"`
Positional struct {
Parents bool `short:"p"`
Mode string `short:"m"`
UserID *int `long:"uid"`
User string `long:"user"`
GroupID *int `long:"gid"`
Group string `long:"group"`
Positional struct {
Path string `positional-arg-name:"<path>"`
} `positional-args:"yes" required:"yes"`
}
Expand All @@ -50,7 +50,7 @@ func init() {
Description: cmdMkdirDescription,
ArgsHelp: map[string]string{
"-p": "Create parent directories as needed",
"-m": "Set permissions (e.g. 0644)",
"-m": "Override mode bits (3-digit octal)",
"--uid": "Use specified user ID",
"--user": "Use specified username",
"--gid": "Use specified group ID",
Expand All @@ -69,17 +69,17 @@ func (cmd *cmdMkdir) Execute(args []string) error {

opts := client.MakeDirOptions{
Path: cmd.Positional.Path,
MakeParents: cmd.MakeParents,
MakeParents: cmd.Parents,
UserID: cmd.UserID,
User: cmd.User,
GroupID: cmd.GroupID,
Group: cmd.Group,
}

if cmd.Permissions != "" {
p, err := strconv.ParseUint(cmd.Permissions, 8, 32)
if cmd.Mode != "" {
p, err := strconv.ParseUint(cmd.Mode, 8, 32)
if err != nil {
return fmt.Errorf("invalid mode for directory: %q", cmd.Permissions)
return fmt.Errorf("invalid mode for directory: %q", cmd.Mode)
}
opts.Permissions = os.FileMode(p)
}
Expand Down