cmd/jobs: command for working with jobs

This is a CLI tool for working with ecosystem-metrics jobs.

Currently it supports displaying, listing and canceling jobs.
It will eventually support starting jobs.

Change-Id: Iae83c08ca600947b72076e8dc213c84eb797c0c6
Reviewed-on: https://go-review.googlesource.com/c/pkgsite-metrics/+/498265
Run-TryBot: Jonathan Amsterdam <jba@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
Reviewed-by: Zvonimir Pavlinovic <zpavlinovic@google.com>
diff --git a/cmd/jobs/main.go b/cmd/jobs/main.go
new file mode 100644
index 0000000..da13d7c
--- /dev/null
+++ b/cmd/jobs/main.go
@@ -0,0 +1,204 @@
+// Copyright 2023 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Command jobs supports jobs on ecosystem-metrics.
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"flag"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"reflect"
+
+	credsapi "cloud.google.com/go/iam/credentials/apiv1"
+	credspb "cloud.google.com/go/iam/credentials/apiv1/credentialspb"
+	"golang.org/x/pkgsite-metrics/internal/jobs"
+)
+
+var env = flag.String("env", "prod", "worker environment (dev or prod)")
+
+var commands = []command{
+	{"print-identity-token", "",
+		"print an identity token", doPrintToken},
+	{"list", "",
+		"list jobs", doList},
+	{"show", "jobID...",
+		"display information about jobs", doShow},
+	{"cancel", "jobID...",
+		"cancel the jobs", doCancel},
+}
+
+type command struct {
+	name   string
+	argdoc string
+	desc   string
+	run    func(context.Context, []string) error
+}
+
+func main() {
+	flag.Usage = func() {
+		out := flag.CommandLine.Output()
+		fmt.Fprintln(out, "usage:")
+		for _, cmd := range commands {
+			fmt.Fprintf(out, "  job %s %s\n", cmd.name, cmd.argdoc)
+			fmt.Fprintf(out, "\t%s\n", cmd.desc)
+		}
+		fmt.Fprintln(out, "\ncommon flags:")
+		flag.PrintDefaults()
+	}
+
+	flag.Parse()
+	if err := run(context.Background()); err != nil {
+		fmt.Fprintf(os.Stderr, "%v\n", err)
+		flag.Usage()
+		os.Exit(2)
+	}
+}
+
+var workerURL string
+
+func run(ctx context.Context) error {
+	wu := os.Getenv("GO_ECOSYSTEM_WORKER_URL_SUFFIX")
+	if wu == "" {
+		return errors.New("need GO_ECOSYSTEM_WORKER_URL_SUFFIX environment variable")
+	}
+	workerURL = fmt.Sprintf("https://%s-%s", *env, wu)
+	name := flag.Arg(0)
+	for _, cmd := range commands {
+		if cmd.name == name {
+			return cmd.run(ctx, flag.Args()[1:])
+		}
+	}
+	return fmt.Errorf("unknown command %q", name)
+}
+
+func doShow(ctx context.Context, args []string) error {
+	token, err := requestImpersonateIdentityToken(ctx)
+	if err != nil {
+		return err
+	}
+	for _, jobID := range args {
+		if err := showJob(ctx, jobID, token); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func showJob(ctx context.Context, jobID, token string) error {
+	job, err := requestJSON[jobs.Job](ctx, "jobs/describe?jobid="+jobID, token)
+	if err != nil {
+		return err
+	}
+	rj := reflect.ValueOf(job).Elem()
+	rt := rj.Type()
+	for i := 0; i < rt.NumField(); i++ {
+		f := rt.Field(i)
+		if f.IsExported() {
+			v := rj.FieldByIndex(f.Index)
+			fmt.Printf("%s: %v\n", f.Name, v.Interface())
+		}
+	}
+	return nil
+}
+
+func doList(ctx context.Context, _ []string) error {
+	token, err := requestImpersonateIdentityToken(ctx)
+	if err != nil {
+		return err
+	}
+	body, err := httpGet(ctx, workerURL+"/jobs/list", token)
+	if err != nil {
+		return err
+	}
+	fmt.Printf("%s\n", body)
+	return nil
+}
+
+func doCancel(ctx context.Context, args []string) error {
+	token, err := requestImpersonateIdentityToken(ctx)
+	if err != nil {
+		return err
+	}
+	for _, jobID := range args {
+		if _, err := httpGet(ctx, workerURL+"/jobs/cancel?jobid="+jobID, token); err != nil {
+			return fmt.Errorf("canceling %q: %w", jobID, err)
+		}
+	}
+	return nil
+}
+
+// For testing. Can be used in place of `gcloud auth print-identity-token`.
+func doPrintToken(ctx context.Context, _ []string) error {
+	token, err := requestImpersonateIdentityToken(ctx)
+	if err != nil {
+		return err
+	}
+	fmt.Println(token)
+	return nil
+}
+
+// requestJSON requests the path from the worker, then reads the returned body
+// and unmarshals it as JSON.
+func requestJSON[T any](ctx context.Context, path, token string) (*T, error) {
+	body, err := httpGet(ctx, workerURL+"/"+path, token)
+	if err != nil {
+		return nil, err
+	}
+	var t T
+	if err := json.Unmarshal(body, &t); err != nil {
+		return nil, err
+	}
+	return &t, nil
+}
+
+// httpGet makes a GET request to the given URL with the given identity token.
+// It reads the body and returns the HTTP response and the body.
+func httpGet(ctx context.Context, url, token string) (body []byte, err error) {
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Add("Authorization", "Bearer "+token)
+	res, err := http.DefaultClient.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	defer res.Body.Close()
+	body, err = io.ReadAll(res.Body)
+	if err != nil {
+		return nil, fmt.Errorf("reading body (%s): %v", res.Status, err)
+	}
+	if res.StatusCode != 200 {
+		return nil, fmt.Errorf("%s: %s", res.Status, body)
+	}
+	return body, nil
+}
+
+// requestImpersonateIdentityToken requests an identity token for a service
+// account to impersonate from the iamcredentials service.
+// See https://cloud.google.com/iam/docs/reference/credentials/rest.
+func requestImpersonateIdentityToken(ctx context.Context) (string, error) {
+	c, err := credsapi.NewIamCredentialsClient(ctx)
+	if err != nil {
+		return "", err
+	}
+	defer c.Close()
+	serviceAccountEmail := "impersonate@go-ecosystem.iam.gserviceaccount.com"
+	req := &credspb.GenerateIdTokenRequest{
+		Name:         "projects/-/serviceAccounts/" + serviceAccountEmail,
+		Audience:     workerURL,
+		IncludeEmail: true,
+	}
+	res, err := c.GenerateIdToken(ctx, req)
+	if err != nil {
+		return "", fmt.Errorf("GenerateIdToken: %w", err)
+	}
+	return res.Token, nil
+}
diff --git a/go.mod b/go.mod
index 9357cf3..ab648c9 100644
--- a/go.mod
+++ b/go.mod
@@ -8,6 +8,7 @@
 	cloud.google.com/go/cloudtasks v1.8.0
 	cloud.google.com/go/errorreporting v0.3.0
 	cloud.google.com/go/firestore v1.9.0
+	cloud.google.com/go/iam v0.11.0
 	cloud.google.com/go/logging v1.7.0
 	cloud.google.com/go/secretmanager v1.9.0
 	cloud.google.com/go/storage v1.28.1
@@ -39,7 +40,6 @@
 require (
 	cloud.google.com/go/compute v1.18.0 // indirect
 	cloud.google.com/go/compute/metadata v0.2.3 // indirect
-	cloud.google.com/go/iam v0.11.0 // indirect
 	cloud.google.com/go/longrunning v0.3.0 // indirect
 	cloud.google.com/go/monitoring v1.8.0 // indirect
 	cloud.google.com/go/trace v1.4.0 // indirect