Skip to content

Commit

Permalink
feat: push client functionality and CLI command (#147)
Browse files Browse the repository at this point in the history
Implements Client.Push in the Go client, and the "push" CLI command.
  • Loading branch information
anpep authored Nov 9, 2023
1 parent 3c076b1 commit b36325c
Show file tree
Hide file tree
Showing 6 changed files with 603 additions and 13 deletions.
148 changes: 148 additions & 0 deletions client/files.go
Original file line number Diff line number Diff line change
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{{
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
Original file line number Diff line number Diff line change
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")

// 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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Loading

0 comments on commit b36325c

Please sign in to comment.