internal/config: support dynamic config from a file

If the environment doesn't specify a bucket, read the dynamic
config from file.

If neither is provided, don't crash.

Fixes golang/go#41608

Change-Id: I5de91f2e394e9d02d238bcfed3f5e13f173321f9
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/257098
Trust: Jonathan Amsterdam <jba@google.com>
Run-TryBot: Jonathan Amsterdam <jba@google.com>
Reviewed-by: Julie Qiu <julie@golang.org>
diff --git a/cmd/internal/cmdconfig/cmdconfig.go b/cmd/internal/cmdconfig/cmdconfig.go
index f6f3acf..240add4 100644
--- a/cmd/internal/cmdconfig/cmdconfig.go
+++ b/cmd/internal/cmdconfig/cmdconfig.go
@@ -51,13 +51,18 @@
 func Experimenter(ctx context.Context, cfg *config.Config, getter middleware.ExperimentGetter, reportingClient *errorreporting.Client) *middleware.Experimenter {
 	if os.Getenv("GO_DISCOVERY_EXPERIMENTS_FROM_CONFIG") == "true" {
 		// Ignore getter, use dynamic config.
-		log.Infof(ctx, "using dynamic config for experiments")
-		getter = func(ctx context.Context) ([]*internal.Experiment, error) {
-			dc, err := dynconfig.Read(ctx, cfg.Bucket, cfg.DynamicObject)
-			if err != nil {
-				return nil, err
+		if cfg.DynamicConfigLocation == "" {
+			log.Warningf(ctx, "experiments are not configured")
+			getter = func(context.Context) ([]*internal.Experiment, error) { return nil, nil }
+		} else {
+			log.Infof(ctx, "using dynamic config from %s for experiments", cfg.DynamicConfigLocation)
+			getter = func(ctx context.Context) ([]*internal.Experiment, error) {
+				dc, err := dynconfig.Read(ctx, cfg.DynamicConfigLocation)
+				if err != nil {
+					return nil, err
+				}
+				return dc.Experiments, nil
 			}
-			return dc.Experiments, nil
 		}
 	}
 	e, err := middleware.NewExperimenter(ctx, 1*time.Minute, getter, reportingClient)
diff --git a/internal/config/config.go b/internal/config/config.go
index 8d88576..061d153 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -173,10 +173,9 @@
 	// In case of invalid/empty value, all logs will be printed.
 	LogLevel string
 
-	// GCS Bucket for overrides and dynamic config.
-	Bucket string
-	// GCS object name for dynamic config.
-	DynamicObject string
+	// DynamicConfigLocation is the location (either a file or gs://bucket/object) for
+	// dynamic configuration.
+	DynamicConfigLocation string
 }
 
 // AppVersionLabel returns the version label for the current instance.  This is
@@ -403,17 +402,17 @@
 			MaxTimeout:       time.Duration(GetEnvInt("GO_DISCOVERY_TEEPROXY_MAX_TIMEOUT_SECONDS", 240)) * time.Second,
 			SuccsToGreen:     GetEnvInt("GO_DISCOVERY_TEEPROXY_SUCCS_TO_GREEN", 20),
 		},
-		LogLevel:      os.Getenv("GO_DISCOVERY_LOG_LEVEL"),
-		Bucket:        os.Getenv("GO_DISCOVERY_CONFIG_BUCKET"),
-		DynamicObject: os.Getenv("GO_DISCOVERY_CONFIG_DYNAMIC"),
+		LogLevel: os.Getenv("GO_DISCOVERY_LOG_LEVEL"),
 	}
-
-	// Check that the dynamic config is readable, but don't do anything with it.
-	if cfg.Bucket == "" {
-		return nil, errors.New("GO_DISCOVERY_CONFIG_BUCKET must be set")
-	}
-	if cfg.DynamicObject == "" {
-		return nil, errors.New("GO_DISCOVERY_CONFIG_DYNAMIC must be set")
+	bucket := os.Getenv("GO_DISCOVERY_CONFIG_BUCKET")
+	object := os.Getenv("GO_DISCOVERY_CONFIG_DYNAMIC")
+	if bucket != "" {
+		if object == "" {
+			return nil, errors.New("GO_DISCOVERY_CONFIG_DYNAMIC must be set if GO_DISCOVERY_CONFIG_BUCKET is")
+		}
+		cfg.DynamicConfigLocation = fmt.Sprintf("gs://%s/%s", bucket, object)
+	} else {
+		cfg.DynamicConfigLocation = object
 	}
 	if cfg.OnGCP() {
 		// Zone is not available in the environment but can be queried via the metadata API.
@@ -479,11 +478,11 @@
 	// to re-deploy. (Otherwise, do not use it.)
 	overrideObj := os.Getenv("GO_DISCOVERY_CONFIG_OVERRIDE")
 	if overrideObj != "" {
-		overrideBytes, err := readOverrideFile(ctx, cfg.Bucket, overrideObj)
+		overrideBytes, err := readOverrideFile(ctx, bucket, overrideObj)
 		if err != nil {
 			log.Print(err)
 		} else {
-			log.Printf("processing overrides from gs://%s/%s", cfg.Bucket, overrideObj)
+			log.Printf("processing overrides from gs://%s/%s", bucket, overrideObj)
 			processOverrides(cfg, overrideBytes)
 		}
 	}
diff --git a/internal/config/dynconfig/dynconfig.go b/internal/config/dynconfig/dynconfig.go
index 7a4815a..bfbe2af 100644
--- a/internal/config/dynconfig/dynconfig.go
+++ b/internal/config/dynconfig/dynconfig.go
@@ -9,7 +9,11 @@
 
 import (
 	"context"
+	"errors"
+	"io"
 	"io/ioutil"
+	"os"
+	"strings"
 
 	"cloud.google.com/go/storage"
 	"github.com/ghodss/yaml"
@@ -27,19 +31,38 @@
 	Experiments []*internal.Experiment
 }
 
-// Read reads dynamic configuration from the given GCS bucket and object.
-func Read(ctx context.Context, bucket, object string) (_ *DynamicConfig, err error) {
-	defer derrors.Wrap(&err, "dynconfig.Read(%q, %q)", bucket, object)
+// Read reads dynamic configuration from the given location.
+// Location may be of the form gs://bucket/object, denoting a GCS bucket.
+// Otherwise it is interpreted as a filename.
+func Read(ctx context.Context, location string) (_ *DynamicConfig, err error) {
+	defer derrors.Wrap(&err, "dynconfig.Read(%q)", location)
 
-	log.Infof(ctx, "reading dynamic config from gs://%s/%s", bucket, object)
-	client, err := storage.NewClient(ctx)
-	if err != nil {
-		return nil, err
-	}
-	defer client.Close()
-	r, err := client.Bucket(bucket).Object(object).NewReader(ctx)
-	if err != nil {
-		return nil, err
+	log.Infof(ctx, "reading dynamic config from %s", location)
+	var r io.ReadCloser
+	if strings.HasPrefix(location, "gs://") {
+		parts := strings.SplitN(location[5:], "/", 2)
+		if len(parts) != 2 {
+			return nil, errors.New("bad GCS URL")
+		}
+		bucket := parts[0]
+		object := parts[1]
+		if err != nil {
+			return nil, err
+		}
+		client, err := storage.NewClient(ctx)
+		if err != nil {
+			return nil, err
+		}
+		defer client.Close()
+		r, err = client.Bucket(bucket).Object(object).NewReader(ctx)
+		if err != nil {
+			return nil, err
+		}
+	} else {
+		r, err = os.Open(location)
+		if err != nil {
+			return nil, err
+		}
 	}
 	defer r.Close()
 	data, err := ioutil.ReadAll(r)