internal/secret: add flag support
To avoid hardcoding secret names everywhere, we want to pass them via
flags instead. As a convenience, introduce a new flag type that resolves
values of the form "secret:[project name/]<secret name>" using Secret
Manager.
This is a bit janky in the name of convenience: we need a SM client
before calling flag.Parse, which I decided should be initialized by the
user rather than implicitly. Typical usage will look like:
var token = secret.Flag("token", "token used to do the thing")
func main() {
if err := secret.InitFlagSupport(context.Background()); err != nil {
log.Fatal(err)
}
flag.Parse()
fmt.Printf("My token is %v\n", *token)
}
Supporting literal values might be unnecessary but I think it might be
helpful for local testing, and we can extend it with a file: prefix to
read from local files too.
For golang/go#51122.
Change-Id: Ie6102453c2242baf2e91b873e62e035f72a82584
Reviewed-on: https://go-review.googlesource.com/c/build/+/385185
Trust: Heschi Kreinick <heschi@google.com>
Run-TryBot: Heschi Kreinick <heschi@google.com>
Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
Auto-Submit: Heschi Kreinick <heschi@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
diff --git a/internal/secret/flag.go b/internal/secret/flag.go
new file mode 100644
index 0000000..6a860ca
--- /dev/null
+++ b/internal/secret/flag.go
@@ -0,0 +1,83 @@
+package secret
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "strings"
+
+ "cloud.google.com/go/compute/metadata"
+ secretmanager "cloud.google.com/go/secretmanager/apiv1"
+ secretmanagerpb "google.golang.org/genproto/googleapis/cloud/secretmanager/v1"
+)
+
+// FlagResolver contains the dependencies necessary to resolve a Secret flag.
+type FlagResolver struct {
+ Context context.Context
+ Client secretClient
+ DefaultProjectID string
+}
+
+// Flag declares a string flag on set that will be resolved using r.
+func (r *FlagResolver) Flag(set *flag.FlagSet, name string, usage string) *string {
+ var value string
+ suffixedUsage := usage + " [ specify `secret:[project name/]<secret name>` to read from Secret Manager ]"
+ set.Func(name, suffixedUsage, func(flagValue string) error {
+ if r.Client == nil || r.Context == nil {
+ return fmt.Errorf("secret resolver was not initialized")
+ }
+ if !strings.HasPrefix(flagValue, "secret:") {
+ value = flagValue
+ return nil
+ }
+
+ secretName := strings.TrimPrefix(flagValue, "secret:")
+ projectID := r.DefaultProjectID
+ if parts := strings.SplitN(secretName, "/", 2); len(parts) == 2 {
+ projectID, secretName = parts[0], parts[1]
+ }
+ if projectID == "" {
+ return fmt.Errorf("missing project ID: none specified in %q, and no default set (not on GCP?)", secretName)
+ }
+ r, err := r.Client.AccessSecretVersion(r.Context, &secretmanagerpb.AccessSecretVersionRequest{
+ Name: buildNamePath(projectID, secretName, "latest"),
+ })
+ if err != nil {
+ return fmt.Errorf("reading secret %q from project %v failed: %v", secretName, projectID, err)
+ }
+ value = string(r.Payload.GetData())
+ return nil
+ })
+ return &value
+}
+
+// DefaultResolver is the FlagResolver used by the convenience functions.
+var DefaultResolver FlagResolver
+
+// Flag declares a string flag on flag.CommandLine that supports Secret Manager
+// resolution for values like "secret:<secret name>". InitFlagSupport must be
+// called before flag.Parse.
+func Flag(name string, usage string) *string {
+ return DefaultResolver.Flag(flag.CommandLine, name, usage)
+}
+
+// InitFlagSupport initializes the dependencies for flags declared with Flag.
+func InitFlagSupport(ctx context.Context) error {
+ client, err := secretmanager.NewClient(ctx)
+ if err != nil {
+ return err
+ }
+ DefaultResolver = FlagResolver{
+ Context: ctx,
+ Client: client,
+ }
+ if metadata.OnGCE() {
+ projectID, err := metadata.ProjectID()
+ if err != nil {
+ return err
+ }
+ DefaultResolver.DefaultProjectID = projectID
+ }
+
+ return nil
+}
diff --git a/internal/secret/gcp_secret_manager_test.go b/internal/secret/gcp_secret_manager_test.go
index 9759218..b4671c2 100644
--- a/internal/secret/gcp_secret_manager_test.go
+++ b/internal/secret/gcp_secret_manager_test.go
@@ -6,6 +6,7 @@
import (
"context"
+ "flag"
"fmt"
"testing"
@@ -138,3 +139,47 @@
t.Errorf("BuildVersionNumber(%s, %s, %s) = %q; want=%q", "x", "y", "z", got, want)
}
}
+
+func TestFlag(t *testing.T) {
+ r := &FlagResolver{
+ Context: context.Background(),
+ Client: &fakeSecretClient{
+ accessSecretMap: map[string]string{
+ buildNamePath("project1", "secret1", "latest"): "supersecret",
+ buildNamePath("project2", "secret2", "latest"): "tippytopsecret",
+ },
+ },
+ DefaultProjectID: "project1",
+ }
+
+ tests := []struct {
+ flagVal, wantVal string
+ wantErr bool
+ }{
+ {"hey", "hey", false},
+ {"secret:secret1", "supersecret", false},
+ {"secret:project2/secret2", "tippytopsecret", false},
+ {"secret:foo", "", true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.flagVal, func(t *testing.T) {
+ fs := flag.NewFlagSet("", flag.ContinueOnError)
+ fs.Usage = func() {} // Minimize console spam; can't prevent it entirely.
+ flagVal := r.Flag(fs, "testflag", "usage")
+ err := fs.Parse([]string{"--testflag", tt.flagVal})
+ if tt.wantErr {
+ if err == nil {
+ t.Fatalf("flag parsing succeeded, should have failed")
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("flag parsing failed: %v", err)
+ }
+ if *flagVal != tt.wantVal {
+ t.Errorf("flag value = %q, want %q", *flagVal, "hey")
+ }
+ })
+ }
+}