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
58 changes: 58 additions & 0 deletions .github/workflows/flake.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
name: flake

on:
push:
tags: ["v*"]
pull_request:
paths:
- .github/workflows/flake.yml
- flake.lock
- flake.nix
- go.mod
- main.go
workflow_dispatch:

permissions:
contents: read

concurrency:
group: flake-${{ github.ref }}
cancel-in-progress: true

jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
extra_nix_config: |
experimental-features = nix-command flakes

- name: Build the package the flake defines
run: nix build --print-build-logs .#superstack

- name: Verify the flake and the binary it built agree with main.go
run: |
declared=$(sed -n 's/^const version = "\(.*\)"$/\1/p' main.go)

if [ -z "$declared" ]; then
echo "could not read 'const version' from main.go"
exit 1
fi

evaluated=$(nix eval --raw .#superstack.version)

if [ "$evaluated" != "$declared" ]; then
echo "main.go declares $declared, but flake.nix reads $evaluated"
exit 1
fi

reported=$(./result/bin/superstack --version)

if [ "$reported" != "$declared" ]; then
echo "main.go declares $declared, but the built binary reports $reported"
exit 1
fi
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
/superstack
/superstack-cli

# Compiled test binaries, which go test leaves behind when a run is interrupted
*.test

# GoReleaser output
/dist/

Expand Down
23 changes: 15 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,19 +42,19 @@ Uploading Lua code and streaming logs are not available yet.

## Local development

1. Install the toolchain:

- **Any platform:** [Go](https://go.dev) 1.25 or newer.
- **Nix:** `nix develop`, or `direnv allow` once with
[direnv](https://direnv.net) hooked into your shell.

1. Clone the repository:

```sh
git clone git@github.com:siliconwitchery/superstack-cli.git ~/projects/superstack-cli
git clone https://github.com/siliconwitchery/superstack-cli.git ~/projects/superstack-cli
cd ~/projects/superstack-cli
```

1. Install the toolchain:

- **Any platform:** [Go](https://go.dev) 1.25 or newer.
- **Nix:** `nix develop` from inside the clone, or `direnv allow` once with
[direnv](https://direnv.net) hooked into your shell.

1. Build and run:

```sh
Expand Down Expand Up @@ -88,7 +88,9 @@ Uploading Lua code and streaming logs are not available yet.
git switch -C dev origin/main
```

1. Change `version` in `main.go`, run the checks, commit, and push:
1. Change `version` in `main.go`.

1. Run every check:

```sh
gofmt -l .
Expand All @@ -97,6 +99,11 @@ Uploading Lua code and streaming logs are not available yet.
CGO_ENABLED=0 go vet ./...
CGO_ENABLED=0 go test ./...
git diff --check
```

1. Commit and push:

```sh
git add main.go
git commit -m "Version <version>"
git push -u origin dev
Expand Down
16 changes: 11 additions & 5 deletions internal/account/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ func Balance(session api.Session, arguments []string) error {
}

if jsonOutput {
return json.NewEncoder(session.Out).Encode(balances)
err = json.NewEncoder(session.Out).Encode(balances)

return err
}

if len(balances) == 0 {
Expand Down Expand Up @@ -166,7 +168,7 @@ func Delete(session api.Session, arguments []string) error {
return errors.New("account delete takes no arguments")
}

request, err := api.AuthenticatedRequest(session, http.MethodGet, "/", nil)
request, err := api.AuthenticatedRequest(session, http.MethodGet, "/fleets", nil)

if err != nil {
return err
Expand All @@ -178,12 +180,16 @@ func Delete(session api.Session, arguments []string) error {
return errors.New("the server could not be reached, check your connection")
}

response.Body.Close()
if response.StatusCode != http.StatusOK {
refusal := api.ServerError(response)

if response.StatusCode == http.StatusUnauthorized {
return errors.New("you are not logged in, run login first")
response.Body.Close()

return refusal
}

response.Body.Close()

fmt.Fprint(session.Out, "Delete your account, its logins, and your access to every fleet? This cannot be undone. [y/N] ")

answer, _ := bufio.NewReader(session.In).ReadString('\n')
Expand Down
45 changes: 41 additions & 4 deletions internal/account/account_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ func TestAccountBalance(t *testing.T) {
arguments []string
fleets string
balances string
refusal string
wantLines []string
wantAbsent []string
wantExact string
Expand Down Expand Up @@ -81,13 +82,27 @@ func TestAccountBalance(t *testing.T) {
balances: `[]`,
wantExact: "No credit on that fleet yet.\n",
},
{
name: "a fleet the list does not name",
arguments: []string{},
fleets: `[{"id":1,"name":"crew","owner":true}]`,
balances: `[{"fleet":99,"balance":"15.000000","currency":"eur"}]`,
wantLines: []string{"99 -"},
},
{
name: "an unknown fleet",
arguments: []string{"9"},
fleets: `[{"id":1,"name":"crew","owner":true}]`,
balances: `[]`,
wantError: "no such fleet",
},
{
name: "server refusal",
arguments: []string{},
fleets: `[{"id":1,"name":"crew","owner":true}]`,
refusal: "balances unavailable",
wantError: "balances unavailable",
},
{
name: "a wordy id",
arguments: []string{"crew"},
Expand All @@ -109,6 +124,11 @@ func TestAccountBalance(t *testing.T) {
})

mux.HandleFunc("GET /balance", func(w http.ResponseWriter, r *http.Request) {
if test.refusal != "" {
http.Error(w, test.refusal, http.StatusServiceUnavailable)
return
}

fmt.Fprint(w, test.balances)
})

Expand Down Expand Up @@ -330,6 +350,12 @@ func TestAccountDelete(t *testing.T) {

mux := http.NewServeMux()

// The command asks the server whether the stored login still works
// before it puts the question, so every case has to answer this.
mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `[]`)
})

mux.HandleFunc("DELETE /account", func(w http.ResponseWriter, r *http.Request) {
deleted = true

Expand Down Expand Up @@ -404,10 +430,17 @@ func TestAccountDeleteAsksNothingWhenTheServerIsGone(t *testing.T) {
}

func TestAccountDeleteStopsWhenTheStoredLoginIsNoLongerValid(t *testing.T) {
deleted := false

mux := http.NewServeMux()

mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
// What the server's own auth middleware answers for a revoked login.
mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "the login is no longer valid, log in again", http.StatusUnauthorized)
})

mux.HandleFunc("DELETE /account", func(w http.ResponseWriter, r *http.Request) {
deleted = true
})

session, out := apitest.LoggedInSession(t, mux)
Expand All @@ -416,11 +449,15 @@ func TestAccountDeleteStopsWhenTheStoredLoginIsNoLongerValid(t *testing.T) {

err := Delete(session, nil)

if err == nil || !strings.Contains(err.Error(), "not logged in") {
t.Fatalf("error = %v, want it to say the login is not valid", err)
if err == nil || !strings.Contains(err.Error(), "no longer valid") {
t.Fatalf("error = %v, want the server's own refusal", err)
}

if out.String() != "" {
t.Errorf("output = %q, want the question never to be put", out.String())
}

if deleted {
t.Error("the server saw the account deleted although the login was refused")
}
}
30 changes: 30 additions & 0 deletions internal/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,36 @@ func TestFetchFleetsFailures(t *testing.T) {
}
}

func TestFetchDevicesFailures(t *testing.T) {
tests := []struct {
name string
status int
body string
wantError string
}{
{name: "server refusal", status: http.StatusServiceUnavailable, body: "devices unavailable", wantError: "devices unavailable"},
{name: "undecodable body", status: http.StatusOK, body: `[{`, wantError: "could not be read"},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("GET /devices", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(test.status)
fmt.Fprint(w, test.body)
})

session, _ := apitest.LoggedInSession(t, mux)

_, err := api.FetchDevices(session)

if err == nil || !strings.Contains(err.Error(), test.wantError) {
t.Fatalf("error = %v, want it to mention %q", err, test.wantError)
}
})
}
}

func TestFetchKeysFailures(t *testing.T) {
tests := []struct {
name string
Expand Down
19 changes: 8 additions & 11 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,8 @@ import (
"strings"
)

// Larger than any answer the server can produce: it caps fleets at 100 per
// user and keys at 100 per fleet, and only the device list is uncapped.
const maximumBody = 32 << 20

func Request(session Session, method string, path string, body io.Reader) (*http.Request, error) {
request, err := http.NewRequest(method, strings.TrimSuffix(session.Base, "/")+path, body)
func Request(session Session, method string, path string, reader io.Reader) (*http.Request, error) {
request, err := http.NewRequest(method, strings.TrimSuffix(session.Base, "/")+path, reader)

if err != nil {
return nil, err
Expand All @@ -28,7 +24,7 @@ func Request(session Session, method string, path string, body io.Reader) (*http
return request, nil
}

func AuthenticatedRequest(session Session, method string, path string, body io.Reader) (*http.Request, error) {
func AuthenticatedRequest(session Session, method string, path string, reader io.Reader) (*http.Request, error) {
storedKeyPath, err := KeyPath()

if err != nil {
Expand All @@ -51,7 +47,7 @@ func AuthenticatedRequest(session Session, method string, path string, body io.R
return nil, errors.New("you are not logged in, run login first")
}

request, err := Request(session, method, path, body)
request, err := Request(session, method, path, reader)

if err != nil {
return nil, err
Expand All @@ -75,7 +71,8 @@ func ServerError(response *http.Response) error {
}

func Decode(response *http.Response, value any) error {
err := json.NewDecoder(io.LimitReader(response.Body, maximumBody)).Decode(value)
// 32 MiB is past every capped list and is the ceiling on the uncapped ones.
err := json.NewDecoder(io.LimitReader(response.Body, 32<<20)).Decode(value)

if err != nil {
return errors.New("the server's answer could not be read, try again in a moment")
Expand All @@ -93,7 +90,7 @@ func KeyPath() (string, error) {
home, err := os.UserHomeDir()

if err != nil {
return "", errors.New("your home folder could not be found, so the login has nowhere to live")
return "", errors.New("your home folder could not be found, so the login cannot be read or saved")
}

stateHome = filepath.Join(home, ".local", "state")
Expand All @@ -105,7 +102,7 @@ func KeyPath() (string, error) {
configDirectory, err := os.UserConfigDir()

if err != nil {
return "", errors.New("your settings folder could not be found, so the login has nowhere to live")
return "", errors.New("your settings folder could not be found, so the login cannot be read or saved")
}

return filepath.Join(configDirectory, "superstack", "key"), nil
Expand Down
6 changes: 2 additions & 4 deletions internal/api/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ import (
)

const DefaultBase = "https://supernext.siliconwitchery.com"
const defaultGithubBase = "https://github.com"
const defaultGitlabBase = "https://gitlab.com"

type Session struct {
Base string
Expand All @@ -27,8 +25,8 @@ type Session struct {
func NewSession(base string, version string, in io.Reader, out io.Writer) Session {
return Session{
Base: base,
GithubBase: defaultGithubBase,
GitlabBase: defaultGitlabBase,
GithubBase: "https://github.com",
GitlabBase: "https://gitlab.com",
Version: version,
Client: &http.Client{Timeout: 30 * time.Second},
In: in,
Expand Down
Loading