diff --git a/README.md b/README.md index 7cf9c6b..97a17b4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/internal/device/device.go b/internal/device/device.go index c6939d5..169447e 100644 --- a/internal/device/device.go +++ b/internal/device/device.go @@ -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)) @@ -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 := "-" @@ -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 diff --git a/internal/device/device_test.go b/internal/device/device_test.go index 6f5c6bf..bfea528 100644 --- a/internal/device/device_test.go +++ b/internal/device/device_test.go @@ -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"}, @@ -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 diff --git a/internal/files/files.go b/internal/files/files.go new file mode 100644 index 0000000..97e218f --- /dev/null +++ b/internal/files/files.go @@ -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 +} diff --git a/internal/files/files_test.go b/internal/files/files_test.go new file mode 100644 index 0000000..a5bf41c --- /dev/null +++ b/internal/files/files_test.go @@ -0,0 +1,175 @@ +package files + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/siliconwitchery/superstack-cli/internal/api" + "github.com/siliconwitchery/superstack-cli/internal/api/apitest" +) + +func writeTestProject(t *testing.T) string { + t.Helper() + + project := t.TempDir() + + files := map[string]string{ + "main.lua": "print(1)", + "lib/sensor.lua": "return 2", + "lib/.hidden.lua": "return 3", + ".git/HEAD": "ref: refs/heads/main", + "notes/.DS_Store": "junk", + "notes/threshold.txt": "3", + } + + for name, content := range files { + path := filepath.Join(project, filepath.FromSlash(name)) + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + return project +} + +func TestUpload(t *testing.T) { + project := writeTestProject(t) + extra := filepath.Join(t.TempDir(), "config.txt") + + if err := os.WriteFile(extra, []byte("interval=5"), 0o644); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + arguments []string + wantPath string + wantFiles map[string]string + wantOutput string + }{ + { + name: "a directory and a file to a device", + arguments: []string{"354820091234567", project, extra}, + wantPath: "/devices/354820091234567/files", + wantFiles: map[string]string{ + "main.lua": "print(1)", "lib/sensor.lua": "return 2", "notes/threshold.txt": "3", "config.txt": "interval=5", + }, + wantOutput: "Uploaded 4 files to device 354820091234567. They arrive at its next check-in.\n", + }, + { + name: "one file to a fleet", + arguments: []string{"3", filepath.Join(project, "main.lua")}, + wantPath: "/fleets/3/files", + wantFiles: map[string]string{"main.lua": "print(1)"}, + wantOutput: "Uploaded 1 file to 2 devices in fleet 3. They arrive at each device's next check-in.\n", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + gotPath := "" + gotFiles := map[string]string{} + mux := http.NewServeMux() + mux.HandleFunc("PUT /", func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("content type = %q, want application/json", r.Header.Get("Content-Type")) + } + + decoded := struct { + Files map[string][]byte `json:"files"` + }{} + + if err := json.NewDecoder(r.Body).Decode(&decoded); err != nil { + t.Fatal(err) + } + + for path, content := range decoded.Files { + gotFiles[path] = string(content) + } + + if strings.HasPrefix(r.URL.Path, "/fleets/") { + w.Write([]byte(`{"devices":2}`)) + return + } + + w.WriteHeader(http.StatusNoContent) + }) + + invocation, out := apitest.LoggedInInvocation(t, mux) + + err := Upload(invocation, test.arguments) + + if err != nil { + t.Fatal(err) + } + + if gotPath != test.wantPath { + t.Errorf("request path = %q, want %q", gotPath, test.wantPath) + } + + if !reflect.DeepEqual(gotFiles, test.wantFiles) { + t.Errorf("uploaded files = %v, want %v", gotFiles, test.wantFiles) + } + + if out.String() != test.wantOutput { + t.Errorf("output = %q, want %q", out.String(), test.wantOutput) + } + }) + } +} + +func TestUploadArgumentsAndRefusal(t *testing.T) { + project := writeTestProject(t) + empty := t.TempDir() + + tests := []struct { + name string + arguments []string + wantError string + }{ + {"no arguments", nil, "takes an IMEI or fleet id"}, + {"no files", []string{"354820091234567"}, "takes an IMEI or fleet id"}, + {"wordy target", []string{"rooftop", project}, "15-digit IMEI"}, + {"zero fleet id", []string{"0", project}, "15-digit IMEI"}, + {"missing file", []string{"3", filepath.Join(project, "absent.lua")}, "does not exist"}, + {"the same path twice", []string{"3", project, filepath.Join(project, "main.lua")}, "uploaded twice"}, + {"an empty directory", []string{"3", empty}, "no files to upload"}, + } + + for _, test := range tests { + err := Upload(api.Invocation{}, test.arguments) + + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Errorf("%s: error = %v, want it to mention %q", test.name, err, test.wantError) + } + } + + mux := http.NewServeMux() + mux.HandleFunc("PUT /devices/354820091234567/files", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "the upload must include main.lua", http.StatusBadRequest) + }) + + invocation, out := apitest.LoggedInInvocation(t, mux) + + err := Upload(invocation, []string{"354820091234567", filepath.Join(project, "lib")}) + + if err == nil || err.Error() != "the upload must include main.lua" { + t.Errorf("error = %v, want the server's refusal", err) + } + + if out.String() != "" { + t.Errorf("output = %q, want nothing", out.String()) + } +} diff --git a/main.go b/main.go index 26aa628..35e9104 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "github.com/siliconwitchery/superstack-cli/internal/account" "github.com/siliconwitchery/superstack-cli/internal/device" "github.com/siliconwitchery/superstack-cli/internal/dispatch" + "github.com/siliconwitchery/superstack-cli/internal/files" "github.com/siliconwitchery/superstack-cli/internal/fleet" "github.com/siliconwitchery/superstack-cli/internal/fleetkey" "github.com/siliconwitchery/superstack-cli/internal/login" @@ -48,7 +49,7 @@ var sections = []dispatch.Section{ { Title: "Files", Commands: []dispatch.Command{ - {Name: "upload", Arguments: " ...", Summary: "Upload files or directories to a device or fleet"}, + {Name: "upload", Arguments: " ...", Summary: "Upload files or directories to a device or fleet", Run: files.Upload}, {Name: "download", Arguments: " ", Summary: "Download a device or fleet's files into "}, {Name: "dev", Arguments: " ... [--log-file ]", Summary: "Upload on every change, and tail"}, }, diff --git a/main_test.go b/main_test.go index 42b986f..da56442 100644 --- a/main_test.go +++ b/main_test.go @@ -46,7 +46,6 @@ func TestOnlyPlannedCommandsAreUnimplemented(t *testing.T) { "device start": true, "device stop": true, "device restart": true, - "upload": true, "download": true, "dev": true, "tail": true, @@ -98,6 +97,7 @@ func TestNoPartImportsAnother(t *testing.T) { "dispatch": {"api"}, "account": {"api"}, "device": {"api"}, + "files": {"api"}, "fleet": {"api"}, "fleetkey": {"api"}, "login": {"api"}, @@ -166,6 +166,7 @@ func TestTheTableWiresEveryCommandOffered(t *testing.T) { "key create", "key list", "key revoke", "login", "logout", "member add", "member list", "member remove", + "upload", } offered := []string{}