Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
fleets, manage devices, and control who can reach them. It is a single static
binary for managing Superstack from a terminal.

Uploading Lua code and streaming logs are not available yet.
Streaming logs is not available yet.

## Install

Expand Down
16 changes: 8 additions & 8 deletions internal/device/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,12 +161,12 @@ func List(invocation api.Invocation, arguments []string) error {
imeiWidth := len("IMEI")
nameWidth := len("NAME")
fleetWidth := len("FLEET")
runStateWidth := len("RUN STATE")
stateWidth := len("STATE")
storageWidth := len("STORAGE")
imeiValues := make([]string, len(filtered))
nameValues := make([]string, len(filtered))
fleetValues := make([]string, len(filtered))
runStateValues := make([]string, len(filtered))
stateValues := make([]string, len(filtered))
storageValues := make([]string, len(filtered))
lastSeenValues := make([]string, len(filtered))

Expand Down Expand Up @@ -206,10 +206,10 @@ func List(invocation api.Invocation, arguments []string) error {
fleetName = "-"
}

runState := "-"
state := "-"

if device.RunState != nil {
runState = *device.RunState
state = strings.ReplaceAll(*device.RunState, "_", " ")
}

storage := "-"
Expand All @@ -221,24 +221,24 @@ func List(invocation api.Invocation, arguments []string) error {
imeiValues[index] = api.Printable(device.Imei)
nameValues[index] = api.Printable(name)
fleetValues[index] = api.Printable(fleetName)
runStateValues[index] = runState
stateValues[index] = state
storageValues[index] = storage
lastSeenValues[index] = lastSeen
imeiWidth = max(imeiWidth, len(imeiValues[index]))
nameWidth = max(nameWidth, len(nameValues[index]))
fleetWidth = max(fleetWidth, len(fleetValues[index]))
runStateWidth = max(runStateWidth, len(runStateValues[index]))
stateWidth = max(stateWidth, len(stateValues[index]))
storageWidth = max(storageWidth, len(storageValues[index]))
}

fmt.Fprintf(invocation.Out, "%-*s %-*s %-*s %-*s %-*s %s\n",
imeiWidth, "IMEI", nameWidth, "NAME", fleetWidth, "FLEET", runStateWidth, "RUN STATE",
imeiWidth, "IMEI", nameWidth, "NAME", fleetWidth, "FLEET", stateWidth, "STATE",
storageWidth, "STORAGE", "LAST SEEN")

for index := range filtered {
fmt.Fprintf(invocation.Out, "%-*s %-*s %-*s %-*s %-*s %s\n",
imeiWidth, imeiValues[index], nameWidth, nameValues[index], fleetWidth, fleetValues[index],
runStateWidth, runStateValues[index], storageWidth, storageValues[index], lastSeenValues[index])
stateWidth, stateValues[index], storageWidth, storageValues[index], lastSeenValues[index])
}

return nil
Expand Down
48 changes: 47 additions & 1 deletion internal/device/device_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func TestDeviceList(t *testing.T) {
fleets string
refusal string
}{
{name: "table", wantShown: []string{"IMEI NAME FLEET", "RUN STATE", "STORAGE", "LAST SEEN", "roof", "pilot", "running", "128 B / 1.0 KiB", "workshop", "crashed", "1.5 KiB / 1.0 MiB", "3 h ago", "never"}},
{name: "table", wantShown: []string{"IMEI NAME FLEET", "STATE", "STORAGE", "LAST SEEN", "roof", "pilot", "running", "128 B / 1.0 KiB", "workshop", "crashed", "1.5 KiB / 1.0 MiB", "3 h ago", "never"}, wantHidden: []string{"RUN STATE"}},
{name: "filtered", arguments: []string{"3"}, wantShown: []string{"111111111111111", "333333333333333"}, wantHidden: []string{"222222222222222", "workshop"}},
{name: "json flag anywhere", arguments: []string{"3", "--json"}, wantShown: []string{`"imei":"111111111111111"`, `"fleet_id":3`, `"last_seen_at":`, `"run_state":"running"`, `"storage_used":128`, `"storage_total":1024`}, wantHidden: []string{"LAST SEEN", "222222222222222", `"reported_state"`}},
{name: "empty fleet", arguments: []string{"5"}, wantExact: "No devices in that fleet.\n"},
Expand All @@ -47,6 +47,52 @@ func TestDeviceList(t *testing.T) {
{name: "days ago", devices: fmt.Sprintf(`[{"imei":"111111111111111","name":"roof","fleet_id":3,"last_seen_at":%q}]`, now.Add(-49*time.Hour).Format(time.RFC3339)), wantShown: []string{"2 d ago"}},
}

for _, state := range []struct {
served string
shown string
}{
{`"no_space"`, "no space"},
{`"rejected"`, "rejected"},
{`"failed"`, "failed"},
{`"no_code"`, "no code"},
{`"updating"`, "updating"},
{`"running"`, "running"},
{`"stopped"`, "stopped"},
{`"crashed"`, "crashed"},
{`null`, "-"},
} {
tests = append(tests, struct {
name string
arguments []string
wantShown []string
wantHidden []string
wantExact string
wantError string
devices string
fleets string
refusal string
}{
name: "state " + state.shown,
devices: `[{"imei":"111111111111111","name":"roof","fleet_id":3,"last_seen_at":null,"run_state":` + state.served + `}]`,
wantShown: []string{"111111111111111 roof pilot " + state.shown + " "},
}, struct {
name string
arguments []string
wantShown []string
wantHidden []string
wantExact string
wantError string
devices string
fleets string
refusal string
}{
name: "json state " + state.shown,
arguments: []string{"--json"},
devices: `[{"imei":"111111111111111","name":"roof","fleet_id":3,"last_seen_at":null,"run_state":` + state.served + `}]`,
wantShown: []string{`"run_state":` + state.served},
})
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
servedDevices := test.devices
Expand Down
184 changes: 184 additions & 0 deletions internal/files/files.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
package files

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"

"github.com/siliconwitchery/superstack-cli/internal/api"
)

func Upload(invocation api.Invocation, arguments []string) error {
if len(arguments) < 2 {
return errors.New("upload takes an IMEI or fleet id, then the files or directories to upload")
}

target := arguments[0]
isImei := len(target) == 15 && !strings.ContainsFunc(target, func(digit rune) bool { return digit < '0' || digit > '9' })
fleetID := int64(0)

if !isImei {
parsed, err := strconv.ParseInt(target, 10, 64)

if err != nil || parsed < 1 {
return errors.New("the first argument is the 15-digit IMEI printed on the device, or the fleet id shown by fleet list")
}

fleetID = parsed
}

collected := map[string][]byte{}
sources := map[string]string{}

add := func(devicePath string, localPath string) error {
if previous, seen := sources[devicePath]; seen {
return fmt.Errorf("%s would be uploaded twice, from %s and %s", devicePath, previous, localPath)
}

content, err := os.ReadFile(localPath)

if err != nil {
return fmt.Errorf("%s could not be read", localPath)
}

collected[devicePath] = content
sources[devicePath] = localPath

return nil
}

for _, argument := range arguments[1:] {
info, err := os.Stat(argument)

if err != nil {
return fmt.Errorf("%s does not exist", argument)
}

if !info.IsDir() {
err = add(filepath.Base(argument), argument)

if err != nil {
return err
}

continue
}

err = filepath.WalkDir(argument, func(path string, entry fs.DirEntry, walkError error) error {
if walkError != nil {
return fmt.Errorf("%s could not be read", path)
}

if path == argument {
return nil
}

if strings.HasPrefix(entry.Name(), ".") {
if entry.IsDir() {
return filepath.SkipDir
}

return nil
}

if !entry.Type().IsRegular() {
return nil
}

relative, err := filepath.Rel(argument, path)

if err != nil {
return fmt.Errorf("%s could not be read", path)
}

return add(filepath.ToSlash(relative), path)
})

if err != nil {
return err
}
}

if len(collected) == 0 {
return errors.New("there are no files to upload")
}

body, err := json.Marshal(struct {
Files map[string][]byte `json:"files"`
}{collected})

if err != nil {
return err
}

path := "/devices/" + target + "/files"

if !isImei {
path = "/fleets/" + strconv.FormatInt(fleetID, 10) + "/files"
}

request, err := api.AuthenticatedRequest(invocation, http.MethodPut, path, bytes.NewReader(body))

if err != nil {
return err
}

request.Header.Set("Content-Type", "application/json")

response, err := invocation.Client.Do(request)

if err != nil {
return errors.New("the server could not be reached, check your internet access")
}

defer response.Body.Close()

fileNoun := "files"

if len(collected) == 1 {
fileNoun = "file"
}

if isImei {
if response.StatusCode != http.StatusNoContent {
return api.ServerError(response)
}

fmt.Fprintf(invocation.Out, "Uploaded %d %s to device %s. They arrive at its next check-in.\n",
len(collected), fileNoun, target)

return nil
}

if response.StatusCode != http.StatusOK {
return api.ServerError(response)
}

result := struct {
Devices int `json:"devices"`
}{}

err = api.Decode(response, &result)

if err != nil {
return err
}

deviceNoun := "devices"

if result.Devices == 1 {
deviceNoun = "device"
}

fmt.Fprintf(invocation.Out, "Uploaded %d %s to %d %s in fleet %d. They arrive at each device's next check-in.\n",
len(collected), fileNoun, result.Devices, deviceNoun, fleetID)

return nil
}
Loading
Loading