Skip to content

Commit 94b7e6a

Browse files
committed
feat(client): a SubmitQueue client library, with list and watch
## Summary ### Why? Seeing what a queue was doing meant running the demo tool, which creates pull requests as a side effect. The live table it draws is the good part, and it was trapped inside a tool whose job is generating traffic. Meanwhile the gateway has exposed a paged `List` RPC that no client has ever called, and the CLI's `status` reads one request at a time by id — so there was no way to ask what a whole queue was doing without adding to it. Underneath that sat a duplication problem heading somewhere worse. Three binaries dialled the gateway for themselves, `parseStrategy` existed twice verbatim, and the demo was growing a second copy of everything the CLI would eventually need. Extracting only the table would have treated the symptom. ### What? `submitqueue/client` is now the client for the domain: dialling, the calls made against a gateway, and the terminal view of a queue. Both binaries become thin over it — the CLI is flag parsing, and the demo is GitHub scaffolding plus calls into the library. The demo's `main.go` drops from 1069 lines to 369 with no change in what it does, and its GitHub REST helpers move to their own file, since they are not SubmitQueue client code. **`list` and `watch`.** `list` draws a queue's recent requests once, following continuation tokens so a caller gets the answer rather than a cursor. `watch` seeds its rows from the same listing, or from named ids, and then follows them with the tracker that already existed. Its set is fixed when it starts: a watch that grew as its queue did would never finish, and finishing is what makes it usable from a script — it exits non-zero if anything settles anywhere other than `landed`, the contract the demo used to own alone. **Addressing.** `-addr` is unchanged and passed to the dialler untouched, so `dns:///host:port` and `unix:///path.sock` work alongside a plain `host:port`. Transport security is a separate `-tls` flag rather than part of the address, because gRPC keeps target resolution and credentials apart — there is no scheme meaning "use TLS", and inventing one would only mislead. The demo's odd `-gateway` flag is renamed to `-addr` to match the three real clients. **The changes column.** It was the one part of the table tied to having created the pull requests. A row now carries cells of text and an optional URL, supplied by the caller: the demo passes pull request numbers linked to their pages, and a client watching a queue it did not create passes the change URIs the gateway reports. The hyperlink and width handling is shared, including that padding counts on-screen width — a hyperlink is mostly escape bytes occupying no columns. **Credentials.** The client can present a bearer token. `TokenEnv` names the variable holding it rather than carrying the token, so a secret never reaches a command line, and an unset variable sends nothing rather than failing. Nothing in this repository checks it — the gateway admits every caller — so it is there for a gateway reached through something that does: a proxy, a sidecar, an ingress terminating auth ahead of the service. gRPC refuses per-RPC credentials on an insecure connection unless they declare they do not need transport security, which is why the credential declares it and `-tls` stays a separate choice. ## Test Plan ✅ `bazel test //...` — all 112 targets pass, including the Docker-backed integration and end-to-end suites. ✅ The extraction is behaviour-preserving by construction: all 23 view tests moved to `submitqueue/client` and pass unchanged there. None was lost — the demo had 28, and 23 plus the 5 file-layout tests that stayed accounts for all of them. ✅ `TestCredentialsReachTheServerOverPlaintext` runs a real gRPC server on a loopback port and asserts the token arrives as `Bearer …`. This is the case that silently breaks otherwise: gRPC refuses per-RPC credentials on an insecure connection unless they declare they do not need transport security, so a token that works over TLS can simply never be sent without it. ✅ `List` paging: pages are followed to the end, a limit cuts across pages without over-fetching, an empty page ends the walk so a server that never stops handing out tokens cannot spin, and `-since` becomes a receipt-time bound while its absence leaves the window open. Not driven by hand: `list` and `watch` have not been pointed at a running gateway, so the end-to-end shape of the rendered table against real data is unverified. Their paging, settle and verdict logic is covered hermetically above.
1 parent b7e0fbd commit 94b7e6a

18 files changed

Lines changed: 2476 additions & 1451 deletions

File tree

Makefile

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ DEMO_REPO ?= behinddwalls/sq-demo
5757
COUNT ?= 3
5858
FILES ?= 3
5959
STACKED ?= false
60+
SINCE ?= 1h
61+
LIMIT ?= 50
6062
LAND ?= true
6163
WATCH ?= true
6264
QUEUE ?= demo-queue
@@ -159,7 +161,7 @@ demo-pr: ## Create N PRs in the demo repo, enqueue each as it is created, and wa
159161
-count $(COUNT) \
160162
-files $(FILES) \
161163
-stacked=$(STACKED) \
162-
-gateway $(GATEWAY_ADDR) \
164+
-addr $(GATEWAY_ADDR) \
163165
-queue $(QUEUE) \
164166
-strategy $(STRATEGY) \
165167
-land=$(LAND) -watch=$(WATCH)
@@ -227,6 +229,14 @@ land-status: ## Read a landed request's status (SQID=... [QUEUE=demo-queue])
227229
@$(BAZEL) run //service/submitqueue/gateway/client:gateway -- \
228230
-addr $(GATEWAY_ADDR) status -queue $(QUEUE) -sqid $(SQID)
229231

232+
land-list: ## Show a queue's recent requests as a table (QUEUE=demo-queue SINCE=1h LIMIT=50)
233+
@$(BAZEL) run //service/submitqueue/gateway/client:gateway -- \
234+
-addr $(GATEWAY_ADDR) list -queue $(QUEUE) -since $(SINCE) -limit $(LIMIT)
235+
236+
land-watch: ## Follow a queue's requests until they settle (QUEUE=demo-queue SINCE=15m LIMIT=50)
237+
@$(BAZEL) run //service/submitqueue/gateway/client:gateway -- \
238+
-addr $(GATEWAY_ADDR) watch -queue $(QUEUE) -since $(SINCE) -limit $(LIMIT)
239+
230240
license-fix: ## Add missing license headers to source files
231241
@$(BAZEL) run //tool/linter/licenseheader -- --fix
232242

doc/howto/PROVIDER-E2E.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,40 @@ The command exits non-zero if any request settles anywhere other than `landed`,
129129

130130
## Watching it work
131131

132+
The queue itself is readable without creating any traffic:
133+
134+
```bash
135+
make land-list # a table of recent requests
136+
make land-list SINCE=24h LIMIT=200 # a wider window
137+
make land-watch # follow them until they settle
138+
```
139+
140+
Both draw the same table `make demo-pr` does — the demo tool and the CLI share it — but against whatever the queue already holds, so watching a queue no longer means adding to it. `land-watch` fixes its set when it starts and exits non-zero if any request in that set finishes anywhere other than `landed`, which makes it usable from a script. A request accepted after the watch begins is not picked up: a watch that grew as the queue did would never finish.
141+
142+
Under the hood these are `client list` and `client watch`, which take a queue and reach any gateway:
143+
144+
```bash
145+
bazel run //service/submitqueue/gateway/client:gateway -- \
146+
-addr sq.example.com:443 -tls list -queue my-queue -since 1h
147+
```
148+
149+
`-addr` is passed to the dialler untouched, so `dns:///host:port` and `unix:///path.sock` work as well as a plain `host:port`. Transport security is a separate flag rather than part of the address, because gRPC keeps target resolution and credentials apart — there is no `grpcs://` to write.
150+
151+
Bear in mind that a request reads `batched` for the whole of its active life (see above), so a listing of a busy queue is mostly `batched` rows until the pipeline reports its finer stages.
152+
153+
### Authentication
154+
155+
The gateway admits every caller. It is a sandbox stack, and nothing in it checks a credential.
156+
157+
The client can still present one, for a gateway reached through something that does — a proxy, a mesh sidecar, an ingress that terminates auth ahead of the service. It reads `SQ_TOKEN` by default and sends it as `Authorization: Bearer …`; `-token-env` names a different variable, and an unset one sends nothing rather than failing, which is how it stays usable against a stack that wants no credential.
158+
159+
```bash
160+
SQ_TOKEN=$(cat ~/.sq-token) bazel run //service/submitqueue/gateway/client:gateway -- \
161+
-addr sq.example.com:443 -tls list -queue my-queue
162+
```
163+
164+
### Service logs
165+
132166
```bash
133167
docker compose -p submitqueue-provider logs -f runway-service
134168
```

service/submitqueue/demo/pr/BUILD.bazel

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,16 @@ load("@rules_go//go:def.bzl", "go_binary", "go_library", "go_test")
22

33
go_library(
44
name = "go_default_library",
5-
srcs = ["main.go"],
5+
srcs = [
6+
"github.go",
7+
"main.go",
8+
],
69
importpath = "github.com/uber/submitqueue/service/submitqueue/demo/pr",
710
visibility = ["//visibility:private"],
811
deps = [
9-
"//api/base/change/protopb:go_default_library",
1012
"//api/base/mergestrategy/protopb:go_default_library",
11-
"//api/submitqueue/gateway/protopb:go_default_library",
1213
"//platform/base/change/github:go_default_library",
13-
"//submitqueue/entity:go_default_library",
14-
"@org_golang_google_grpc//:go_default_library",
15-
"@org_golang_google_grpc//credentials/insecure:go_default_library",
14+
"//submitqueue/client:go_default_library",
1615
],
1716
)
1817

@@ -27,9 +26,7 @@ go_test(
2726
srcs = ["main_test.go"],
2827
embed = [":go_default_library"],
2928
deps = [
30-
"//api/submitqueue/gateway/protopb:go_default_library",
3129
"@com_github_stretchr_testify//assert:go_default_library",
3230
"@com_github_stretchr_testify//require:go_default_library",
33-
"@org_golang_google_grpc//:go_default_library",
3431
],
3532
)
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// Copyright (c) 2025 Uber Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package main
16+
17+
import (
18+
"bytes"
19+
"context"
20+
"encoding/base64"
21+
"encoding/json"
22+
"fmt"
23+
"net/http"
24+
"strings"
25+
)
26+
27+
// githubClient is the slice of GitHub's REST API this tool needs: read a
28+
// branch, create a branch, commit a file, open a pull request.
29+
type githubClient struct {
30+
root string
31+
token string
32+
owner string
33+
repo string
34+
}
35+
36+
func (g *githubClient) branchSHA(ctx context.Context, branch string) (string, error) {
37+
var out struct {
38+
Object struct {
39+
SHA string `json:"sha"`
40+
} `json:"object"`
41+
}
42+
if err := g.do(ctx, http.MethodGet, "/git/ref/heads/"+branch, nil, &out); err != nil {
43+
return "", err
44+
}
45+
return out.Object.SHA, nil
46+
}
47+
48+
func (g *githubClient) createBranch(ctx context.Context, branch, fromSHA string) error {
49+
return g.do(ctx, http.MethodPost, "/git/refs",
50+
map[string]string{"ref": "refs/heads/" + branch, "sha": fromSHA}, nil)
51+
}
52+
53+
// commitFile writes a file on a branch and returns the resulting commit SHA —
54+
// the commit a change URI pins the pull request to.
55+
func (g *githubClient) commitFile(ctx context.Context, branch, path, content, message string) (string, error) {
56+
body := map[string]string{
57+
"message": message,
58+
"content": base64.StdEncoding.EncodeToString([]byte(content)),
59+
"branch": branch,
60+
}
61+
var out struct {
62+
Commit struct {
63+
SHA string `json:"sha"`
64+
} `json:"commit"`
65+
}
66+
if err := g.do(ctx, http.MethodPut, "/contents/"+path, body, &out); err != nil {
67+
return "", err
68+
}
69+
return out.Commit.SHA, nil
70+
}
71+
72+
func (g *githubClient) openPR(ctx context.Context, title, head, base string) (int, string, error) {
73+
body := map[string]string{"title": title, "head": head, "base": base, "body": "Opened by service/submitqueue/demo/pr."}
74+
var out struct {
75+
Number int `json:"number"`
76+
HTMLURL string `json:"html_url"`
77+
}
78+
if err := g.do(ctx, http.MethodPost, "/pulls", body, &out); err != nil {
79+
return 0, "", err
80+
}
81+
return out.Number, out.HTMLURL, nil
82+
}
83+
84+
// do issues one authenticated request against the repository, decoding into out
85+
// when it is non-nil.
86+
func (g *githubClient) do(ctx context.Context, method, path string, body any, out any) error {
87+
endpoint := fmt.Sprintf("%s/repos/%s/%s%s", g.root, g.owner, g.repo, path)
88+
89+
var payload []byte
90+
if body != nil {
91+
var err error
92+
if payload, err = json.Marshal(body); err != nil {
93+
return fmt.Errorf("encode request for %s: %w", endpoint, err)
94+
}
95+
}
96+
97+
req, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(payload))
98+
if err != nil {
99+
return fmt.Errorf("build request for %s: %w", endpoint, err)
100+
}
101+
req.Header.Set("Accept", "application/vnd.github+json")
102+
req.Header.Set("Authorization", "Bearer "+g.token)
103+
if payload != nil {
104+
req.Header.Set("Content-Type", "application/json")
105+
}
106+
107+
resp, err := http.DefaultClient.Do(req)
108+
if err != nil {
109+
return fmt.Errorf("%s %s: %w", method, endpoint, err)
110+
}
111+
defer resp.Body.Close()
112+
113+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
114+
var detail bytes.Buffer
115+
_, _ = detail.ReadFrom(resp.Body)
116+
return fmt.Errorf("%s %s returned %s: %s", method, endpoint, resp.Status, strings.TrimSpace(detail.String()))
117+
}
118+
if out == nil {
119+
return nil
120+
}
121+
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
122+
return fmt.Errorf("decode response from %s: %w", endpoint, err)
123+
}
124+
return nil
125+
}

0 commit comments

Comments
 (0)