Skip to content

Commit 0c2e9cf

Browse files
committed
feat(gateway): land and status subcommands for the client
## Summary ### Why? The gateway client only knew how to ping. Submitting a change meant hand-assembling protobuf with grpcurl, including a 40-character commit SHA copied out of a pull request — which is both tedious and easy to get subtly wrong. ### What? `ping | land | status` subcommands. `land` takes repeated `-uri` flags, whose order is significant: several changes in one request are a stack, applied in the order given. `-pr <url>` resolves a pull request's head commit and mints the change URI, so nobody types a SHA. It dispatches on the change URL the same way the merger and the change provider dispatch on the URI scheme, rather than assuming one provider; GitHub is implemented, and adding another is a case beside it. Reading a public repository needs no token, and a 404 without one says so rather than reporting the pull request as missing. ## Test Plan ✅ `go build ./service/submitqueue/gateway/client` and `go vet`. ✅ Manually: `land -queue q` reports the missing-change error; `land -pr https://github.com/uber/cadence/pull/1` resolved against the live GitHub API and printed `github://github.com/cadence-workflow/cadence/pull/1/65c322d4...` before failing to dial a gateway, which is the expected end of that path with no stack running.
1 parent e953b01 commit 0c2e9cf

4 files changed

Lines changed: 302 additions & 26 deletions

File tree

Makefile

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -398,8 +398,10 @@ query-deps:
398398
query-targets:
399399
@$(BAZEL) query //...
400400

401-
run-client-submitqueue-gateway: ## Run the gateway client against a running gateway (SERVER_ADDR, MESSAGE)
402-
@$(BAZEL) run //service/submitqueue/gateway/client:gateway -- -addr $(or $(SERVER_ADDR),localhost:8081) -message "$(or $(MESSAGE),ping)"
401+
run-client-submitqueue-gateway: ## Run the gateway client (ARGS="land -queue q -pr <url>"; defaults to a ping)
402+
@$(BAZEL) run //service/submitqueue/gateway/client:gateway -- \
403+
-addr $(or $(SERVER_ADDR),localhost:8081) \
404+
$(if $(ARGS),$(ARGS),ping -message "$(or $(MESSAGE),ping)")
403405

404406
run-client-submitqueue-orchestrator: ## Run the orchestrator client against a running orchestrator (SERVER_ADDR, MESSAGE)
405407
@$(BAZEL) run //service/submitqueue/orchestrator/client:orchestrator -- -addr $(or $(SERVER_ADDR),localhost:8082) -message "$(or $(MESSAGE),ping)"

client

18.5 MB
Binary file not shown.

service/submitqueue/gateway/client/BUILD.bazel

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ go_library(
66
importpath = "github.com/uber/submitqueue/service/submitqueue/gateway/client",
77
visibility = ["//visibility:private"],
88
deps = [
9+
"//api/base/change/protopb:go_default_library",
10+
"//api/base/mergestrategy/protopb:go_default_library",
911
"//api/submitqueue/gateway/protopb:go_default_library",
12+
"//platform/base/change/github:go_default_library",
1013
"@org_golang_google_grpc//:go_default_library",
1114
"@org_golang_google_grpc//credentials/insecure:go_default_library",
1215
],

service/submitqueue/gateway/client/main.go

Lines changed: 295 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -12,67 +12,338 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15+
// Command client is a small operator CLI for the SubmitQueue gateway: submit a
16+
// land request, read a request's status, and ping the service.
17+
//
18+
// It exists so that driving a real queue does not require hand-assembling
19+
// protobuf with grpcurl — in particular, `land -pr` turns a pull request URL
20+
// into the change URI the pipeline wants, so nobody has to paste a 40-character
21+
// commit SHA by hand.
1522
package main
1623

1724
import (
1825
"context"
26+
"encoding/json"
1927
"flag"
2028
"fmt"
29+
"net/http"
30+
"net/url"
2131
"os"
32+
"strconv"
33+
"strings"
2234
"time"
2335

36+
githubchange "github.com/uber/submitqueue/platform/base/change/github"
37+
38+
changepb "github.com/uber/submitqueue/api/base/change/protopb"
39+
mergestrategypb "github.com/uber/submitqueue/api/base/mergestrategy/protopb"
2440
pb "github.com/uber/submitqueue/api/submitqueue/gateway/protopb"
2541
"google.golang.org/grpc"
2642
"google.golang.org/grpc/credentials/insecure"
2743
)
2844

45+
const usage = `Usage: client [global flags] <command> [command flags]
46+
47+
Commands:
48+
ping Check that the gateway is reachable
49+
land Submit a change, or an ordered stack of changes, to a queue
50+
status Read a request's current status
51+
52+
Global flags:
53+
-addr gateway address (default "localhost:8081")
54+
-timeout request timeout (default 10s)
55+
56+
Examples:
57+
client ping
58+
client land -queue my-queue -pr https://github.com/uber/sq-sandbox/pull/7
59+
client land -queue my-queue -uri github://github.com/uber/r/pull/7/<sha> -strategy SQUASH_REBASE
60+
client land -queue my-queue -pr <url-of-first> -pr <url-of-second>
61+
client status -queue my-queue -sqid my-queue/12
62+
`
63+
2964
func main() {
3065
addr := flag.String("addr", "localhost:8081", "gateway server address")
31-
message := flag.String("message", "", "message to send in ping request")
32-
timeout := flag.Duration("timeout", 5*time.Second, "request timeout")
66+
timeout := flag.Duration("timeout", 10*time.Second, "request timeout")
67+
flag.Usage = func() { fmt.Fprint(os.Stderr, usage) }
3368
flag.Parse()
3469

35-
if err := run(*addr, *message, *timeout); err != nil {
70+
args := flag.Args()
71+
if len(args) == 0 {
72+
fmt.Fprint(os.Stderr, usage)
73+
os.Exit(2)
74+
}
75+
76+
if err := run(*addr, *timeout, args[0], args[1:]); err != nil {
3677
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
3778
os.Exit(1)
3879
}
3980
}
4081

41-
func run(addr, message string, timeout time.Duration) error {
42-
// Create a gRPC connection
43-
conn, err := grpc.NewClient(
44-
addr,
45-
grpc.WithTransportCredentials(insecure.NewCredentials()),
46-
)
82+
func run(addr string, timeout time.Duration, command string, args []string) error {
83+
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
4784
if err != nil {
48-
return fmt.Errorf("failed to connect: %w", err)
85+
return fmt.Errorf("failed to connect to %s: %w", addr, err)
4986
}
5087
defer conn.Close()
5188

52-
// Create a client
5389
client := pb.NewSubmitQueueGatewayClient(conn)
54-
55-
// Create context with timeout
5690
ctx, cancel := context.WithTimeout(context.Background(), timeout)
5791
defer cancel()
5892

59-
// Make the ping request
60-
req := &pb.PingRequest{
61-
Message: message,
93+
switch command {
94+
case "ping":
95+
return runPing(ctx, client, args)
96+
case "land":
97+
return runLand(ctx, client, args)
98+
case "status":
99+
return runStatus(ctx, client, args)
100+
default:
101+
fmt.Fprint(os.Stderr, usage)
102+
return fmt.Errorf("unknown command %q", command)
62103
}
104+
}
63105

64-
fmt.Printf("Sending ping to gateway at %s...\n", addr)
65-
resp, err := client.Ping(ctx, req)
106+
func runPing(ctx context.Context, client pb.SubmitQueueGatewayClient, args []string) error {
107+
fs := flag.NewFlagSet("ping", flag.ExitOnError)
108+
message := fs.String("message", "", "message to echo back")
109+
if err := fs.Parse(args); err != nil {
110+
return err
111+
}
112+
113+
resp, err := client.Ping(ctx, &pb.PingRequest{Message: *message})
66114
if err != nil {
67115
return fmt.Errorf("ping failed: %w", err)
68116
}
69117

70-
// Print the response
71-
fmt.Printf("\nResponse:\n")
72-
fmt.Printf(" Message: %s\n", resp.Message)
73-
fmt.Printf(" Service Name: %s\n", resp.ServiceName)
74-
fmt.Printf(" Timestamp: %d (%s)\n", resp.Timestamp, time.Unix(resp.Timestamp, 0))
75-
fmt.Printf(" Hostname: %s\n", resp.Hostname)
118+
fmt.Printf("Message: %s\n", resp.Message)
119+
fmt.Printf("Service Name: %s\n", resp.ServiceName)
120+
fmt.Printf("Timestamp: %d (%s)\n", resp.Timestamp, time.Unix(resp.Timestamp, 0))
121+
fmt.Printf("Hostname: %s\n", resp.Hostname)
122+
return nil
123+
}
124+
125+
func runLand(ctx context.Context, client pb.SubmitQueueGatewayClient, args []string) error {
126+
fs := flag.NewFlagSet("land", flag.ExitOnError)
127+
queue := fs.String("queue", "", "queue to land on (required)")
128+
strategy := fs.String("strategy", "REBASE", "REBASE, SQUASH_REBASE, MERGE, PROMOTE, or DEFAULT")
129+
130+
// Both are repeatable, and order is significant: several changes in one
131+
// request are a stack, applied in the order given, each on top of the last.
132+
var uris repeatable
133+
var prs repeatable
134+
fs.Var(&uris, "uri", "change URI; repeat for a stack, in application order")
135+
fs.Var(&prs, "pr", "pull request URL to resolve into a change URI; repeat for a stack")
136+
if err := fs.Parse(args); err != nil {
137+
return err
138+
}
139+
140+
if *queue == "" {
141+
return fmt.Errorf("-queue is required")
142+
}
143+
if len(uris) == 0 && len(prs) == 0 {
144+
return fmt.Errorf("at least one -uri or -pr is required")
145+
}
146+
147+
resolved, err := resolvePullRequests(ctx, prs)
148+
if err != nil {
149+
return err
150+
}
151+
// Explicit URIs first, then resolved ones, each in the order given.
152+
all := append(append([]string{}, uris...), resolved...)
153+
154+
parsedStrategy, err := parseStrategy(*strategy)
155+
if err != nil {
156+
return err
157+
}
158+
159+
for i, uri := range all {
160+
fmt.Printf("Change %d: %s\n", i+1, uri)
161+
}
162+
163+
resp, err := client.Land(ctx, &pb.LandRequest{
164+
Queue: *queue,
165+
Change: &changepb.Change{Uris: all},
166+
Strategy: parsedStrategy,
167+
})
168+
if err != nil {
169+
return fmt.Errorf("land failed: %w", err)
170+
}
171+
172+
fmt.Printf("\nLanded request submitted.\n")
173+
fmt.Printf(" sqid: %s\n", resp.Sqid)
174+
fmt.Printf("\nFollow it with: client status -queue %s -sqid %s\n", *queue, resp.Sqid)
175+
return nil
176+
}
177+
178+
func runStatus(ctx context.Context, client pb.SubmitQueueGatewayClient, args []string) error {
179+
fs := flag.NewFlagSet("status", flag.ExitOnError)
180+
sqid := fs.String("sqid", "", "request id returned by land (required)")
181+
// A sqid is only resolvable within its own queue, so the server needs both.
182+
queue := fs.String("queue", "", "queue the request was landed on (required)")
183+
if err := fs.Parse(args); err != nil {
184+
return err
185+
}
186+
if *sqid == "" {
187+
return fmt.Errorf("-sqid is required")
188+
}
189+
if *queue == "" {
190+
return fmt.Errorf("-queue is required")
191+
}
192+
193+
resp, err := client.GetRequestSummaryByID(ctx, &pb.GetRequestSummaryByIDRequest{Sqid: *sqid, Queue: *queue})
194+
if err != nil {
195+
return fmt.Errorf("status failed: %w", err)
196+
}
197+
if resp.Request == nil {
198+
return fmt.Errorf("no request found for %q", *sqid)
199+
}
76200

201+
fmt.Printf("sqid: %s\n", resp.Request.Sqid)
202+
fmt.Printf("queue: %s\n", resp.Request.Queue)
203+
fmt.Printf("status: %s\n", resp.Request.Status)
204+
if resp.Request.LastError != "" {
205+
fmt.Printf("error: %s\n", resp.Request.LastError)
206+
}
207+
for i, uri := range resp.Request.ChangeUris {
208+
fmt.Printf("change %d: %s\n", i+1, uri)
209+
}
77210
return nil
78211
}
212+
213+
// repeatable collects a flag given more than once, preserving the order it was
214+
// given in — which for a stack of changes is the order they must be applied.
215+
type repeatable []string
216+
217+
func (r *repeatable) String() string { return strings.Join(*r, ",") }
218+
func (r *repeatable) Set(v string) error { *r = append(*r, v); return nil }
219+
220+
// parseStrategy maps the -strategy value onto the wire enum.
221+
func parseStrategy(name string) (mergestrategypb.Strategy, error) {
222+
switch strings.ToUpper(strings.TrimSpace(name)) {
223+
case "", "DEFAULT":
224+
return mergestrategypb.Strategy_DEFAULT, nil
225+
case "REBASE":
226+
return mergestrategypb.Strategy_REBASE, nil
227+
case "SQUASH_REBASE":
228+
return mergestrategypb.Strategy_SQUASH_REBASE, nil
229+
case "MERGE":
230+
return mergestrategypb.Strategy_MERGE, nil
231+
case "PROMOTE":
232+
return mergestrategypb.Strategy_PROMOTE, nil
233+
default:
234+
return mergestrategypb.Strategy_DEFAULT, fmt.Errorf("unknown strategy %q", name)
235+
}
236+
}
237+
238+
// resolvePullRequests turns each pull request URL into the change URI the
239+
// pipeline expects, in the order given.
240+
func resolvePullRequests(ctx context.Context, urls []string) ([]string, error) {
241+
resolved := make([]string, 0, len(urls))
242+
for _, raw := range urls {
243+
uri, err := resolvePullRequest(ctx, raw)
244+
if err != nil {
245+
return nil, err
246+
}
247+
resolved = append(resolved, uri)
248+
}
249+
return resolved, nil
250+
}
251+
252+
// resolvePullRequest dispatches a change URL to the provider that hosts it.
253+
//
254+
// Only GitHub is implemented. Adding a provider is a case here plus its own
255+
// resolver, mirroring how the merger and the change provider each dispatch on
256+
// the change's provider rather than assuming one.
257+
func resolvePullRequest(ctx context.Context, raw string) (string, error) {
258+
u, err := url.Parse(raw)
259+
if err != nil {
260+
return "", fmt.Errorf("invalid pull request URL %q: %w", raw, err)
261+
}
262+
// Every provider this could support is reached over HTTPS, so the host is what
263+
// distinguishes them rather than the scheme.
264+
if u.Host == "" {
265+
return "", fmt.Errorf("pull request URL %q has no host", raw)
266+
}
267+
return resolveGitHubPullRequest(ctx, u)
268+
}
269+
270+
// resolveGitHubPullRequest reads a pull request's head commit and builds its
271+
// change URI. Reading a public repository needs no token; GITHUB_TOKEN is used
272+
// when set, which a private repository requires.
273+
func resolveGitHubPullRequest(ctx context.Context, u *url.URL) (string, error) {
274+
// https://{host}/{owner}/{repo}/pull/{number}
275+
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
276+
if len(parts) < 4 || parts[2] != "pull" {
277+
return "", fmt.Errorf("expected a URL like https://github.com/{owner}/{repo}/pull/{number}, got %q", u)
278+
}
279+
owner, repo := parts[0], parts[1]
280+
number, err := strconv.Atoi(parts[3])
281+
if err != nil {
282+
return "", fmt.Errorf("pull request number %q in %q is not a number", parts[3], u)
283+
}
284+
285+
sha, err := githubHeadSHA(ctx, githubAPIRoot(u.Host), owner, repo, number)
286+
if err != nil {
287+
return "", err
288+
}
289+
290+
return githubchange.ChangeID{
291+
Scheme: "github",
292+
Host: u.Host,
293+
Org: owner,
294+
Repo: repo,
295+
PRNumber: number,
296+
HeadCommitSHA: sha,
297+
}.String(), nil
298+
}
299+
300+
// githubAPIRoot maps a GitHub web host to its API root. GitHub Enterprise
301+
// serves its API under the same host at /api/v3; github.com does not.
302+
func githubAPIRoot(host string) string {
303+
if host == "github.com" || host == "www.github.com" {
304+
return "https://api.github.com"
305+
}
306+
return "https://" + host + "/api/v3"
307+
}
308+
309+
// githubHeadSHA reads the current head commit of a pull request.
310+
func githubHeadSHA(ctx context.Context, apiRoot, owner, repo string, number int) (string, error) {
311+
endpoint := fmt.Sprintf("%s/repos/%s/%s/pulls/%d", apiRoot, owner, repo, number)
312+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
313+
if err != nil {
314+
return "", fmt.Errorf("building request for %s: %w", endpoint, err)
315+
}
316+
req.Header.Set("Accept", "application/vnd.github+json")
317+
if token := os.Getenv("GITHUB_TOKEN"); token != "" {
318+
req.Header.Set("Authorization", "Bearer "+token)
319+
}
320+
321+
resp, err := http.DefaultClient.Do(req)
322+
if err != nil {
323+
return "", fmt.Errorf("fetching %s: %w", endpoint, err)
324+
}
325+
defer resp.Body.Close()
326+
327+
if resp.StatusCode != http.StatusOK {
328+
// A private repository read without a token comes back as 404, which
329+
// reads as "no such pull request" unless the cause is named.
330+
hint := ""
331+
if resp.StatusCode == http.StatusNotFound && os.Getenv("GITHUB_TOKEN") == "" {
332+
hint = " (set GITHUB_TOKEN if the repository is private)"
333+
}
334+
return "", fmt.Errorf("GET %s returned %s%s", endpoint, resp.Status, hint)
335+
}
336+
337+
var body struct {
338+
Head struct {
339+
SHA string `json:"sha"`
340+
} `json:"head"`
341+
}
342+
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
343+
return "", fmt.Errorf("decoding response from %s: %w", endpoint, err)
344+
}
345+
if body.Head.SHA == "" {
346+
return "", fmt.Errorf("%s returned no head commit", endpoint)
347+
}
348+
return body.Head.SHA, nil
349+
}

0 commit comments

Comments
 (0)