diff --git a/.github/dependabot.yml b/.github/dependabot.yml index cdfeaa04..a73ba661 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -13,11 +13,19 @@ # # Org-wide rollout beyond backend + cli is an open decision — flagged # to @saadqbal on the PR that added this file. +# +# target-branch is "develop", NOT the default (main): all work in this +# repo flows feature → develop → main (promote PRs). Dependabot defaults +# to the repo default branch (main); left unset, bumps landed straight on +# main, bypassing develop and regressing on the next promote (the develop +# side has the older deps). Point them at develop so they ride the normal +# review + promote path. version: 2 updates: - package-ecosystem: "gomod" directory: "/" + target-branch: "develop" schedule: interval: "weekly" labels: @@ -34,6 +42,7 @@ updates: - package-ecosystem: "github-actions" directory: "/" + target-branch: "develop" schedule: interval: "monthly" labels: diff --git a/.github/workflows/add-to-kanban.yml b/.github/workflows/add-to-kanban.yml index 45aa70ac..28640d55 100644 --- a/.github/workflows/add-to-kanban.yml +++ b/.github/workflows/add-to-kanban.yml @@ -10,7 +10,7 @@ jobs: add-to-project: runs-on: ubuntu-latest steps: - - uses: actions/add-to-project@v1.0.2 + - uses: actions/add-to-project@v2.0.0 with: project-url: https://github.com/orgs/tracebloc/projects/2 github-token: ${{ secrets.PROJECTS_KANBAN_TOKEN }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 530a78ab..271a645c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -285,7 +285,7 @@ jobs: "$BIN" dataset push --help >/dev/null - name: Upload binary as artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: tracebloc-${{ matrix.os }}-${{ matrix.arch }} path: dist/tracebloc-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.ext }} diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 1a9eada2..e48d5c67 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -58,7 +58,7 @@ jobs: # kubeconfig resolution. kind ships a default StorageClass # (local-path), so the test's PVC binds. - name: Create kind cluster - uses: helm/kind-action@v1.10.0 + uses: helm/kind-action@v1.14.0 # Run the unit suite + the integration suite each under coverage, then merge # them (go tool covdata). The merge credits the real-cluster I/O seams — diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 0bc26267..118b4bbf 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -110,7 +110,7 @@ jobs: - name: Upload machine-readable report if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: gremlins-report path: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4e63cb11..b1e0b089 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -153,7 +153,7 @@ jobs: cat "$BIN_NAME.sha256" - name: Upload per-matrix artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: dist-${{ matrix.os }}-${{ matrix.arch }} path: dist/ @@ -174,7 +174,7 @@ jobs: ref: ${{ inputs.ref || github.ref }} - name: Download all matrix artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: path: dist/ merge-multiple: true @@ -205,7 +205,7 @@ jobs: echo "tag=$REF" >> $GITHUB_OUTPUT - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ steps.tag.outputs.tag }} name: ${{ steps.tag.outputs.tag }} diff --git a/.github/workflows/stale-backlog.yml b/.github/workflows/stale-backlog.yml index 318e3729..c4adb55d 100644 --- a/.github/workflows/stale-backlog.yml +++ b/.github/workflows/stale-backlog.yml @@ -13,7 +13,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v9 + - uses: actions/stale@v10 with: days-before-issue-stale: 42 # 6 weeks of no activity → warning days-before-issue-close: 14 # +2 weeks of silence → close diff --git a/go.mod b/go.mod index 1661c571..3f89f67c 100644 --- a/go.mod +++ b/go.mod @@ -17,14 +17,14 @@ require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/fatih/color v1.19.0 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 - github.com/schollz/progressbar/v3 v3.19.0 + github.com/schollz/progressbar/v3 v3.19.1 github.com/spf13/cobra v1.10.2 golang.org/x/term v0.45.0 golang.org/x/text v0.40.0 gopkg.in/yaml.v3 v3.0.1 - k8s.io/api v0.36.1 - k8s.io/apimachinery v0.36.1 - k8s.io/client-go v0.36.1 + k8s.io/api v0.36.2 + k8s.io/apimachinery v0.36.2 + k8s.io/client-go v0.36.2 ) require ( @@ -44,7 +44,7 @@ require ( github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/moby/spdystream v0.5.1 // indirect @@ -65,7 +65,7 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect - k8s.io/streaming v0.36.1 // indirect + k8s.io/streaming v0.36.2 // indirect k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect diff --git a/go.sum b/go.sum index d554cdb7..9c664976 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVc github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= @@ -92,8 +92,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= -github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc= -github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec= +github.com/schollz/progressbar/v3 v3.19.1 h1:iv8BgwOvdML/S3p84uBpy/IMigv4U9594vPZYa2EdrU= +github.com/schollz/progressbar/v3 v3.19.1/go.mod h1:LFL7jqimKxfhero4K1eCkUr/6R39AgQeiPCJtlTWIW8= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= @@ -135,7 +135,6 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -166,18 +165,18 @@ gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.36.1 h1:XbL/EMj8K2aJpJtePmqUyQMsM0D4QI2pvl7YKJ20FTY= -k8s.io/api v0.36.1/go.mod h1:KOWo4ey3TINlXjeHVuwB3i+tXXnu+UcwFBHlI/9dvEo= -k8s.io/apimachinery v0.36.1 h1:G63Gjx2W+q0YD+72Vo8oY0nDnePVwnuzTmmy5ENrVSA= -k8s.io/apimachinery v0.36.1/go.mod h1:ibYOR00vW/I1kzvi5SF0dRuJ52BvKtfvRdOn35GPQ+8= -k8s.io/client-go v0.36.1 h1:FN/K8QIT2CEDt+2WB2HnWrUANZ50AP5GII43/SP2JR0= -k8s.io/client-go v0.36.1/go.mod h1:s6rAnCtTGYDQnpNjEhSaISV+2O8jwruZ6m3QOYBFbtU= +k8s.io/api v0.36.2 h1:TF6YDLIzKfccK7cq9YpTcGX8TJmEkHVRv78DM51fRYY= +k8s.io/api v0.36.2/go.mod h1:F4LbMO4brjZYh7yFkXWhynSvtB7YauxV4c+HHkNRGNg= +k8s.io/apimachinery v0.36.2 h1:0PE/W/WNy1UX61NLbXY5TMbJ6UwLL6E6lAPkYrKFxbQ= +k8s.io/apimachinery v0.36.2/go.mod h1:fvf/HOLXq9RId0rnDIbN1OEBvHXdQbLMM8nu0LcBUf4= +k8s.io/client-go v0.36.2 h1:bfgxmFKc9CgqsgX4xKLAAdmTQlWee7Ob/HlDOrJ5TBI= +k8s.io/client-go v0.36.2/go.mod h1:1vgO4OAlfPnoLcb+Rze2GF5rAr14w8qjrYMoyXJzQj0= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= -k8s.io/streaming v0.36.1 h1:L+K68n4Gg940BGNNYtUBvL1WTLL0YnKT3s+P1MNAmR4= -k8s.io/streaming v0.36.1/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s= +k8s.io/streaming v0.36.2 h1:NSKthPPg9UFSKsRauVJUVGH2Dvn8fhKmY4qrMkw/p98= +k8s.io/streaming v0.36.2/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= diff --git a/internal/api/client.go b/internal/api/client.go index 15a95bd5..d5ed292a 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -505,6 +505,33 @@ func (c *Client) RevokeClient(ctx context.Context, id int) error { // against a misbehaving `next` chain, set well above any real account. const maxListPages = 100 +// GetClient fetches a single client by its dashboard id (GET /edge-device/{id}/). +// The detail route is the same one PatchClientClusterID/RevokeClient address, and +// returns one ProvisionedClient. This is the O(1) way to check ONE client's +// status — unlike ListClients, which pages through the whole account (the +// home-screen heartbeat must not do that under its ~1.2s budget, cli#338). +// A 404 returns (nil, nil) so the caller can distinguish "no such client" from +// a transport/backend error. +func (c *Client) GetClient(ctx context.Context, id int) (*ProvisionedClient, error) { + path := fmt.Sprintf("/edge-device/%d/", id) + url := c.BaseURL + path + status, raw, err := c.get(ctx, path) + if err != nil { + return nil, err + } + if status == http.StatusNotFound { + return nil, nil + } + if status < 200 || status >= 300 { + return nil, &APIError{StatusCode: status, Body: string(raw), URL: url} + } + var out ProvisionedClient + if err := json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("decoding get-client response: %w", err) + } + return &out, nil +} + // ListClients returns ALL clients in the caller's account (GET /edge-device/). // The endpoint is DRF-paginated, so this follows `next` to the end — list, // `use `, and create-time collision detection must see every client, not diff --git a/internal/api/client_get_test.go b/internal/api/client_get_test.go new file mode 100644 index 00000000..d71728ae --- /dev/null +++ b/internal/api/client_get_test.go @@ -0,0 +1,47 @@ +package api + +import ( + "context" + "net/http" + "testing" +) + +// TestGetClient covers the single-client detail fetch (GET /edge-device/{id}/) +// that backs the home-screen heartbeat (cli#338): a 2xx decodes one client, a +// 404 is (nil, nil) — "no such client", not an error — and any other non-2xx is +// an APIError. It also pins the path so the heartbeat can't regress to a list. +func TestGetClient(t *testing.T) { + t.Run("2xx decodes a single client + hits the detail path", func(t *testing.T) { + c := stubClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/edge-device/1070/" { + t.Errorf("path = %q, want /edge-device/1070/ (detail route, not the list)", r.URL.Path) + } + _, _ = w.Write([]byte(`{"id":1070,"first_name":"asad-macbook","status":1,"namespace":"asad-macbook-3"}`)) + }) + pc, err := c.GetClient(context.Background(), 1070) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pc == nil || pc.ID != 1070 || pc.Status != 1 { + t.Fatalf("GetClient = %+v, want id=1070 status=1", pc) + } + }) + + t.Run("404 -> (nil, nil), not an error", func(t *testing.T) { + c := stubClient(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) }) + pc, err := c.GetClient(context.Background(), 999) + if pc != nil || err != nil { + t.Fatalf("404 must be (nil, nil); got (%+v, %v)", pc, err) + } + }) + + t.Run("non-2xx (500) -> APIError", func(t *testing.T) { + c := stubClient(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"detail":"boom"}`)) + }) + if _, err := c.GetClient(context.Background(), 1); err == nil { + t.Error("a 500 must return an error") + } + }) +} diff --git a/internal/cli/client.go b/internal/cli/client.go index 8a449194..126175f5 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -864,30 +864,31 @@ func runClientStatus(ctx context.Context, p *ui.Printer, wait bool, timeout time } } -// lookupClientStatus finds the account client whose numeric id matches active and -// returns its backend status code. found=false means no such client (deleted, or -// signed into the wrong account). A list error is returned verbatim so --wait can -// treat it as transient and retry. +// lookupClientStatus fetches the active client directly and returns its backend +// status code. found=false means no such client (deleted, or signed into the +// wrong account). A lookup error is returned verbatim so --wait can treat it as +// transient and retry. Fetches the single client by id (GET /edge-device/{id}/) +// rather than listing the whole account — the home-screen heartbeat runs this +// under a ~1.2s budget, and paging every client blew it (cli#338). func lookupClientStatus(ctx context.Context, client *api.Client, active string) (status int, found bool, err error) { - clients, err := client.ListClients(ctx) + id, err := strconv.Atoi(active) if err != nil { - return 0, false, err + // A non-numeric active id can never match a backend client, so report it + // as not-found — exactly what the old ListClients+match path did — rather + // than a permanent error. A --wait loop fail-fasts on a missing client but + // treats errors as transient, so returning an error here would make it + // poll a permanent parse failure to the timeout (Bugbot: poll/retry loops + // must fail-fast on non-transient errors). + return 0, false, nil } - if c := findClientByID(clients, active); c != nil { - return c.Status, true, nil + c, err := client.GetClient(ctx, id) + if err != nil { + return 0, false, err } - return 0, false, nil -} - -// findClientByID returns the account client whose numeric dashboard id equals id -// (the string form stored as the active-client pointer), or nil if none match. -func findClientByID(clients []api.ProvisionedClient, id string) *api.ProvisionedClient { - for i := range clients { - if strconv.Itoa(clients[i].ID) == id { - return &clients[i] - } + if c == nil { + return 0, false, nil // 404 — no such client } - return nil + return c.Status, true, nil } // EdgeDevice.status codes mirrored from the backend (metaApi User.py). diff --git a/internal/cli/client_test.go b/internal/cli/client_test.go index cb0b8477..29e2ceb2 100644 --- a/internal/cli/client_test.go +++ b/internal/cli/client_test.go @@ -1223,8 +1223,8 @@ func setActiveClientID(t *testing.T, id string) { // reports the active client online, and says so. func TestClientStatus_WaitOnline_Exit0(t *testing.T) { withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet && r.URL.Path == "/edge-device/" { - _, _ = w.Write([]byte(`[{"id":5,"first_name":"c","namespace":"c","status":1}]`)) // 1 = online + if r.Method == http.MethodGet && r.URL.Path == "/edge-device/5/" { + _, _ = w.Write([]byte(`{"id":5,"first_name":"c","namespace":"c","status":1}`)) // 1 = online return } t.Errorf("unexpected %s %s", r.Method, r.URL.Path) @@ -1245,8 +1245,8 @@ func TestClientStatus_WaitOnline_Exit0(t *testing.T) { // so the loop never sleeps. func TestClientStatus_WaitTimeout_Exit1(t *testing.T) { withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet && r.URL.Path == "/edge-device/" { - _, _ = w.Write([]byte(`[{"id":5,"first_name":"c","namespace":"c","status":0}]`)) // 0 = offline + if r.Method == http.MethodGet && r.URL.Path == "/edge-device/5/" { + _, _ = w.Write([]byte(`{"id":5,"first_name":"c","namespace":"c","status":0}`)) // 0 = offline } }) setActiveClientID(t, "5") @@ -1265,8 +1265,8 @@ func TestClientStatus_WaitTimeout_Exit1(t *testing.T) { // TestClientStatus_OneShot: without --wait, report the current state and exit 0. func TestClientStatus_OneShot(t *testing.T) { withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet && r.URL.Path == "/edge-device/" { - _, _ = w.Write([]byte(`[{"id":5,"first_name":"c","namespace":"c","status":2}]`)) // 2 = pending + if r.Method == http.MethodGet && r.URL.Path == "/edge-device/5/" { + _, _ = w.Write([]byte(`{"id":5,"first_name":"c","namespace":"c","status":2}`)) // 2 = pending } }) setActiveClientID(t, "5") @@ -1297,7 +1297,7 @@ func TestClientStatus_NoActiveClient(t *testing.T) { // proves we didn't poll to exhaustion. func TestClientStatus_WaitFailsFastOn426(t *testing.T) { withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet && r.URL.Path == "/edge-device/" { + if r.Method == http.MethodGet && r.URL.Path == "/edge-device/5/" { w.WriteHeader(http.StatusUpgradeRequired) // 426 _, _ = w.Write([]byte(`{"error":"upgrade_required","min_version":"1.2.3"}`)) } @@ -1320,8 +1320,8 @@ func TestClientStatus_WaitFailsFastOn426(t *testing.T) { // matching the one-shot path, rather than polling to the timeout. func TestClientStatus_WaitFailsFastOnMissingClient(t *testing.T) { withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet && r.URL.Path == "/edge-device/" { - _, _ = w.Write([]byte(`[{"id":9,"first_name":"other","namespace":"other","status":1}]`)) // active id 5 absent + if r.Method == http.MethodGet && r.URL.Path == "/edge-device/5/" { + w.WriteHeader(http.StatusNotFound) // active id 5 absent → 404 } }) setActiveClientID(t, "5") @@ -1335,13 +1335,36 @@ func TestClientStatus_WaitFailsFastOnMissingClient(t *testing.T) { } } +// TestClientStatus_WaitFailsFastOnNonNumericID (Bugbot, #338 follow-up): a +// corrupt (non-numeric) active client id can never match a backend client, so +// --wait must fail fast on a missing client rather than retry a permanent parse +// error to the timeout. No backend call is expected (the id never parses). +func TestClientStatus_WaitFailsFastOnNonNumericID(t *testing.T) { + withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { + t.Errorf("no backend call expected for a non-numeric id, got %s %s", r.Method, r.URL.Path) + }) + setActiveClientID(t, "not-a-number") + // Long timeout: the test would hang if a non-numeric id were treated as a + // transient error instead of a missing client. + err := runClientStatus(context.Background(), ui.New(&bytes.Buffer{}), true, 10*time.Minute) + if got := ExitCodeFromError(err); got != 1 { + t.Fatalf("exit code = %d, want 1", got) + } + if err != nil && strings.Contains(err.Error(), "timed out") { + t.Errorf("a non-numeric id must fail fast, not time out: %v", err) + } + if err == nil || !strings.Contains(err.Error(), "isn't in your account") { + t.Errorf("want a missing-client error, got: %v", err) + } +} + // TestClientStatus_WaitTimeoutSurfacesListError (Bugbot #146-F): when every // status check fails, the timeout message must name the real error, not a bare // "unreachable". A 1ns timeout means the deadline passes on the first failure. func TestClientStatus_WaitTimeoutSurfacesListError(t *testing.T) { withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet && r.URL.Path == "/edge-device/" { - w.WriteHeader(http.StatusInternalServerError) // persistent list failure + if r.Method == http.MethodGet && r.URL.Path == "/edge-device/5/" { + w.WriteHeader(http.StatusInternalServerError) // persistent status-check failure } }) setActiveClientID(t, "5") @@ -1362,7 +1385,7 @@ func TestClientStatus_WaitTimeoutSurfacesListError(t *testing.T) { // the full timeout. A 10-minute timeout would hang the test if it didn't. func TestClientStatus_WaitFailsFastOn401(t *testing.T) { withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet && r.URL.Path == "/edge-device/" { + if r.Method == http.MethodGet && r.URL.Path == "/edge-device/5/" { w.WriteHeader(http.StatusUnauthorized) // dead credential } }) @@ -1398,13 +1421,13 @@ func TestClientStatus_TimeoutWithoutWaitRejected(t *testing.T) { func TestClientStatus_WaitTimeoutClearsStaleError(t *testing.T) { calls := 0 withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet && r.URL.Path == "/edge-device/" { + if r.Method == http.MethodGet && r.URL.Path == "/edge-device/5/" { calls++ if calls == 1 { w.WriteHeader(http.StatusBadGateway) // one transient blip return } - _, _ = w.Write([]byte(`[{"id":5,"first_name":"c","namespace":"c","status":0}]`)) // then offline + _, _ = w.Write([]byte(`{"id":5,"first_name":"c","namespace":"c","status":0}`)) // then offline } }) setActiveClientID(t, "5") @@ -1430,8 +1453,8 @@ func TestClientStatus_WaitTimeoutClearsStaleError(t *testing.T) { // during --wait exits quietly with code 130 — not a bare "Error: context canceled". func TestClientStatus_WaitCtrlCIsSilent(t *testing.T) { withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet && r.URL.Path == "/edge-device/" { - _, _ = w.Write([]byte(`[{"id":5,"first_name":"c","namespace":"c","status":0}]`)) // offline + if r.Method == http.MethodGet && r.URL.Path == "/edge-device/5/" { + _, _ = w.Write([]byte(`{"id":5,"first_name":"c","namespace":"c","status":0}`)) // offline } }) setActiveClientID(t, "5") diff --git a/internal/cli/delete_test.go b/internal/cli/delete_test.go index f70bb56c..c8d89a70 100644 --- a/internal/cli/delete_test.go +++ b/internal/cli/delete_test.go @@ -137,9 +137,9 @@ func TestDelete_Yes_FullSequence(t *testing.T) { revokePath := "" withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { switch { - case r.Method == http.MethodGet && r.URL.Path == "/edge-device/": + case r.Method == http.MethodGet && r.URL.Path == "/edge-device/5/": // Guard's status lookup: report the client OFFLINE so it doesn't block. - _, _ = w.Write([]byte(`[{"id":5,"first_name":"gpu-box-01","namespace":"gpu-box-01","status":0}]`)) + _, _ = w.Write([]byte(`{"id":5,"first_name":"gpu-box-01","namespace":"gpu-box-01","status":0}`)) case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/revoke"): revokePath = r.URL.Path if got := r.Header.Get("Authorization"); got != "Bearer tok" { @@ -200,8 +200,8 @@ func TestDelete_Yes_FullSequence(t *testing.T) { func TestDelete_RevokeNon403_ContinuesTeardown(t *testing.T) { withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { switch { - case r.Method == http.MethodGet && r.URL.Path == "/edge-device/": - _, _ = w.Write([]byte(`[{"id":5,"first_name":"gpu-box-01","namespace":"gpu-box-01","status":0}]`)) + case r.Method == http.MethodGet && r.URL.Path == "/edge-device/5/": + _, _ = w.Write([]byte(`{"id":5,"first_name":"gpu-box-01","namespace":"gpu-box-01","status":0}`)) case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/revoke"): w.WriteHeader(http.StatusNotFound) // 404: stale pointer / backend predates /revoke default: @@ -248,8 +248,8 @@ func TestDelete_RevokeNon403_ContinuesTeardown(t *testing.T) { func TestDelete_RevokeNon403_Degraded_HonestClosing(t *testing.T) { withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { switch { - case r.Method == http.MethodGet && r.URL.Path == "/edge-device/": - _, _ = w.Write([]byte(`[{"id":5,"first_name":"gpu-box-01","namespace":"gpu-box-01","status":0}]`)) + case r.Method == http.MethodGet && r.URL.Path == "/edge-device/5/": + _, _ = w.Write([]byte(`{"id":5,"first_name":"gpu-box-01","namespace":"gpu-box-01","status":0}`)) case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/revoke"): w.WriteHeader(http.StatusNotFound) // 404: best-effort revoke fails default: @@ -329,8 +329,8 @@ func TestDelete_RevokeUnauthorized_FailsFast(t *testing.T) { func TestDelete_KeepData_SparesDataDir(t *testing.T) { withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { switch { - case r.Method == http.MethodGet && r.URL.Path == "/edge-device/": - _, _ = w.Write([]byte(`[{"id":5,"first_name":"gpu-box-01","namespace":"gpu-box-01","status":0}]`)) + case r.Method == http.MethodGet && r.URL.Path == "/edge-device/5/": + _, _ = w.Write([]byte(`{"id":5,"first_name":"gpu-box-01","namespace":"gpu-box-01","status":0}`)) case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/revoke"): w.WriteHeader(http.StatusOK) default: @@ -375,8 +375,8 @@ func TestDelete_KeepData_SparesDataDir(t *testing.T) { func TestDelete_WipeFails_StillClearsPointer(t *testing.T) { withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { switch { - case r.Method == http.MethodGet && r.URL.Path == "/edge-device/": - _, _ = w.Write([]byte(`[{"id":5,"first_name":"gpu-box-01","namespace":"gpu-box-01","status":0}]`)) + case r.Method == http.MethodGet && r.URL.Path == "/edge-device/5/": + _, _ = w.Write([]byte(`{"id":5,"first_name":"gpu-box-01","namespace":"gpu-box-01","status":0}`)) case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/revoke"): w.WriteHeader(http.StatusOK) } @@ -407,8 +407,8 @@ func TestDelete_WipeFails_StillClearsPointer(t *testing.T) { func TestDelete_TeardownFailure_HonestClosing(t *testing.T) { withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { switch { - case r.Method == http.MethodGet && r.URL.Path == "/edge-device/": - _, _ = w.Write([]byte(`[{"id":5,"first_name":"gpu-box-01","namespace":"gpu-box-01","status":0}]`)) + case r.Method == http.MethodGet && r.URL.Path == "/edge-device/5/": + _, _ = w.Write([]byte(`{"id":5,"first_name":"gpu-box-01","namespace":"gpu-box-01","status":0}`)) case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/revoke"): w.WriteHeader(http.StatusOK) } @@ -441,7 +441,7 @@ func TestDelete_Guard426_FailsFast(t *testing.T) { if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/revoke") { revoked = true } - if r.Method == http.MethodGet && r.URL.Path == "/edge-device/" { + if r.Method == http.MethodGet && r.URL.Path == "/edge-device/5/" { w.WriteHeader(http.StatusUpgradeRequired) // 426 _, _ = w.Write([]byte(`{"error":"upgrade_required","min_version":"1.2.3"}`)) } @@ -472,7 +472,7 @@ func TestDelete_GuardAuthError_FailsFast(t *testing.T) { if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/revoke") { revoked = true } - if r.Method == http.MethodGet && r.URL.Path == "/edge-device/" { + if r.Method == http.MethodGet && r.URL.Path == "/edge-device/5/" { w.WriteHeader(http.StatusUnauthorized) // 401 — token revoked/expired _, _ = w.Write([]byte(`{"detail":"invalid token"}`)) } @@ -500,8 +500,8 @@ func TestDelete_GuardAuthError_FailsFast(t *testing.T) { func TestDelete_SelfRemovalFails_HonestClosing(t *testing.T) { withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { switch { - case r.Method == http.MethodGet && r.URL.Path == "/edge-device/": - _, _ = w.Write([]byte(`[{"id":5,"first_name":"gpu-box-01","namespace":"gpu-box-01","status":0}]`)) + case r.Method == http.MethodGet && r.URL.Path == "/edge-device/5/": + _, _ = w.Write([]byte(`{"id":5,"first_name":"gpu-box-01","namespace":"gpu-box-01","status":0}`)) case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/revoke"): w.WriteHeader(http.StatusOK) } @@ -532,8 +532,8 @@ func TestDelete_SelfRemovalFails_HonestClosing(t *testing.T) { func TestDelete_KubeconfigContext_ReachHelm(t *testing.T) { withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { switch { - case r.Method == http.MethodGet && r.URL.Path == "/edge-device/": - _, _ = w.Write([]byte(`[{"id":5,"first_name":"gpu-box-01","namespace":"gpu-box-01","status":0}]`)) + case r.Method == http.MethodGet && r.URL.Path == "/edge-device/5/": + _, _ = w.Write([]byte(`{"id":5,"first_name":"gpu-box-01","namespace":"gpu-box-01","status":0}`)) case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/revoke"): w.WriteHeader(http.StatusOK) } @@ -559,9 +559,9 @@ func TestDelete_RunningJob_RefusesUnlessForce(t *testing.T) { newHandler := func() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { switch { - case r.Method == http.MethodGet && r.URL.Path == "/edge-device/": + case r.Method == http.MethodGet && r.URL.Path == "/edge-device/5/": // status 1 = online (a running client). - _, _ = w.Write([]byte(`[{"id":5,"first_name":"gpu-box-01","namespace":"gpu-box-01","status":1}]`)) + _, _ = w.Write([]byte(`{"id":5,"first_name":"gpu-box-01","namespace":"gpu-box-01","status":1}`)) case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/revoke"): w.WriteHeader(http.StatusOK) } @@ -619,8 +619,8 @@ func TestDelete_RunningJob_RefusesUnlessForce(t *testing.T) { func TestDelete_ShowsRetainedAndLeftCopy(t *testing.T) { withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { switch { - case r.Method == http.MethodGet && r.URL.Path == "/edge-device/": - _, _ = w.Write([]byte(`[{"id":5,"first_name":"gpu-box-01","namespace":"gpu-box-01","status":0}]`)) + case r.Method == http.MethodGet && r.URL.Path == "/edge-device/5/": + _, _ = w.Write([]byte(`{"id":5,"first_name":"gpu-box-01","namespace":"gpu-box-01","status":0}`)) case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/revoke"): w.WriteHeader(http.StatusOK) } @@ -653,7 +653,7 @@ func TestDelete_TypedNameMismatch_Cancels(t *testing.T) { if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/revoke") { revoked = true } - _, _ = w.Write([]byte(`[{"id":5,"first_name":"gpu-box-01","namespace":"gpu-box-01","status":0}]`)) + _, _ = w.Write([]byte(`{"id":5,"first_name":"gpu-box-01","namespace":"gpu-box-01","status":0}`)) }) setActiveForDelete(t, "5", "gpu-box-01", "gpu-box-01") fn := &fakeNodeboot{executable: filepath.Join(t.TempDir(), "tracebloc")} @@ -675,8 +675,8 @@ func TestDelete_TypedNameMismatch_Cancels(t *testing.T) { func TestDelete_BrewManagedBinary_Hint(t *testing.T) { withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { switch { - case r.Method == http.MethodGet && r.URL.Path == "/edge-device/": - _, _ = w.Write([]byte(`[{"id":5,"first_name":"gpu-box-01","namespace":"gpu-box-01","status":0}]`)) + case r.Method == http.MethodGet && r.URL.Path == "/edge-device/5/": + _, _ = w.Write([]byte(`{"id":5,"first_name":"gpu-box-01","namespace":"gpu-box-01","status":0}`)) case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/revoke"): w.WriteHeader(http.StatusOK) } @@ -704,8 +704,8 @@ func TestDelete_BrewManagedBinary_Hint(t *testing.T) { func TestDelete_NoNamespace_WarnsAndSkipsUninstall(t *testing.T) { withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { switch { - case r.Method == http.MethodGet && r.URL.Path == "/edge-device/": - _, _ = w.Write([]byte(`[{"id":5,"first_name":"gpu-box-01","namespace":"gpu-box-01","status":0}]`)) + case r.Method == http.MethodGet && r.URL.Path == "/edge-device/5/": + _, _ = w.Write([]byte(`{"id":5,"first_name":"gpu-box-01","namespace":"gpu-box-01","status":0}`)) case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/revoke"): w.WriteHeader(http.StatusOK) } @@ -733,8 +733,8 @@ func TestDelete_NoNamespace_WarnsAndSkipsUninstall(t *testing.T) { func TestDelete_RevokeForbidden_OffboardCopy(t *testing.T) { withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { switch { - case r.Method == http.MethodGet && r.URL.Path == "/edge-device/": - _, _ = w.Write([]byte(`[{"id":5,"first_name":"gpu-box-01","namespace":"gpu-box-01","status":0}]`)) + case r.Method == http.MethodGet && r.URL.Path == "/edge-device/5/": + _, _ = w.Write([]byte(`{"id":5,"first_name":"gpu-box-01","namespace":"gpu-box-01","status":0}`)) case r.Method == http.MethodGet && r.URL.Path == "/edge-device/admins/": _, _ = w.Write([]byte(`[]`)) case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/revoke"): @@ -814,8 +814,8 @@ func writeBinaryWithTBAlias(t *testing.T) string { func TestDelete_OwnTBAlias_Removed(t *testing.T) { withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { switch { - case r.Method == http.MethodGet && r.URL.Path == "/edge-device/": - _, _ = w.Write([]byte(`[{"id":5,"first_name":"gpu-box-01","namespace":"gpu-box-01","status":0}]`)) + case r.Method == http.MethodGet && r.URL.Path == "/edge-device/5/": + _, _ = w.Write([]byte(`{"id":5,"first_name":"gpu-box-01","namespace":"gpu-box-01","status":0}`)) case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/revoke"): w.WriteHeader(http.StatusOK) } @@ -839,8 +839,8 @@ func TestDelete_OwnTBAlias_Removed(t *testing.T) { func TestDelete_ForeignTBAlias_Left(t *testing.T) { withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { switch { - case r.Method == http.MethodGet && r.URL.Path == "/edge-device/": - _, _ = w.Write([]byte(`[{"id":5,"first_name":"gpu-box-01","namespace":"gpu-box-01","status":0}]`)) + case r.Method == http.MethodGet && r.URL.Path == "/edge-device/5/": + _, _ = w.Write([]byte(`{"id":5,"first_name":"gpu-box-01","namespace":"gpu-box-01","status":0}`)) case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/revoke"): w.WriteHeader(http.StatusOK) }