go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/config/appengine/gaeconfig/settings.go (about)

     1  // Copyright 2016 The LUCI Authors.
     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 gaeconfig
    16  
    17  import (
    18  	"context"
    19  	"fmt"
    20  	"html"
    21  	"html/template"
    22  	"net/url"
    23  	"strings"
    24  
    25  	"go.chromium.org/luci/gae/service/info"
    26  	"go.chromium.org/luci/server/portal"
    27  	"go.chromium.org/luci/server/settings"
    28  )
    29  
    30  // configServiceAdmins is the default value for settings.AdministratorsGroup
    31  // config setting below.
    32  const configServiceAdmins = "administrators"
    33  
    34  // Settings are stored in the datastore via appengine/gaesettings package.
    35  type Settings struct {
    36  	// ConfigServiceHost is host name (and port) of the luci-config service to
    37  	// fetch configs from.
    38  	//
    39  	// For legacy reasons, the JSON value is "config_service_url".
    40  	ConfigServiceHost string `json:"config_service_url"`
    41  
    42  	// Administrators is the auth group of users that can call the validation
    43  	// endpoint.
    44  	AdministratorsGroup string `json:"administrators_group"`
    45  }
    46  
    47  // SetIfChanged sets "s" to be the new Settings if it differs from the current
    48  // settings value.
    49  func (s *Settings) SetIfChanged(c context.Context) error {
    50  	return settings.SetIfChanged(c, settingsKey, s)
    51  }
    52  
    53  // FetchCachedSettings fetches Settings from the settings store.
    54  //
    55  // Uses in-process global cache to avoid hitting datastore often. The cache
    56  // expiration time is 1 min (see gaesettings.expirationTime), meaning
    57  // the instance will refetch settings once a minute (blocking only one unlucky
    58  // request to do so).
    59  //
    60  // Returns errors only if there's no cached value (i.e. it is the first call
    61  // to this function in this process ever) and datastore operation fails.
    62  func FetchCachedSettings(c context.Context) (Settings, error) {
    63  	s := Settings{}
    64  	switch err := settings.Get(c, settingsKey, &s); err {
    65  	case nil:
    66  		// Backwards-compatibility with full URL: translate to host.
    67  		s.ConfigServiceHost = translateConfigURLToHost(s.ConfigServiceHost)
    68  		return s, nil
    69  	case settings.ErrNoSettings:
    70  		return DefaultSettings(c), nil
    71  	default:
    72  		return Settings{}, err
    73  	}
    74  }
    75  
    76  func mustFetchCachedSettings(c context.Context) *Settings {
    77  	settings, err := FetchCachedSettings(c)
    78  	if err != nil {
    79  		panic(err)
    80  	}
    81  	return &settings
    82  }
    83  
    84  // DefaultSettings returns Settings to use if setting store is empty.
    85  func DefaultSettings(c context.Context) Settings {
    86  	return Settings{AdministratorsGroup: configServiceAdmins}
    87  }
    88  
    89  ////////////////////////////////////////////////////////////////////////////////
    90  // UI for settings.
    91  
    92  // settingsKey is used internally to identify gaeconfig settings in settings
    93  // store.
    94  const settingsKey = "gaeconfig"
    95  
    96  type settingsPage struct {
    97  	portal.BasePage
    98  }
    99  
   100  func (settingsPage) Title(c context.Context) (string, error) {
   101  	return "Configuration service settings", nil
   102  }
   103  
   104  func (settingsPage) Overview(c context.Context) (template.HTML, error) {
   105  	metadataURL := fmt.Sprintf("https://%s/api/config/v1/metadata", info.DefaultVersionHostname(c))
   106  	serviceAcc, err := info.ServiceAccount(c)
   107  	if err != nil {
   108  		return "", err
   109  	}
   110  	return template.HTML(fmt.Sprintf(`
   111  <p>This service may fetch configuration files stored centrally in an instance of
   112  <a href="https://chromium.googlesource.com/infra/luci/luci-go/+/refs/heads/main/config_service">luci-config</a>
   113  service. This page can be used to configure the location of the config service
   114  as well as parameters of the local cache that holds the fetched configuration
   115  files.</p>
   116  <p>Before your service can start fetching configs, it should be registered in
   117  the config service's registry of known services (services.cfg config file), by
   118  adding something similar to this:</p>
   119  <pre>
   120  services {
   121    id: "%s"
   122    owners: &lt;your email&gt;
   123    metadata_url: "%s"
   124    access: "%s"
   125  }
   126  </pre>
   127  <p>Refer to the documentation in the services.cfg file for more info.</p>`,
   128  		html.EscapeString(info.TrimmedAppID(c)),
   129  		html.EscapeString(metadataURL),
   130  		html.EscapeString(serviceAcc)),
   131  	), nil
   132  }
   133  
   134  func (settingsPage) Fields(c context.Context) ([]portal.Field, error) {
   135  	return []portal.Field{
   136  		{
   137  			ID:    "ConfigServiceHost",
   138  			Title: `Config service host`,
   139  			Type:  portal.FieldText,
   140  			Validator: func(v string) error {
   141  				if strings.ContainsRune(v, '/') {
   142  					return fmt.Errorf("host must be a host name, not a URL")
   143  				}
   144  				return nil
   145  			},
   146  			Help: `<p>Host name (e.g., "config.luci.app") of a config service to fetch configuration files from.</p>`,
   147  		},
   148  		{
   149  			ID:    "AdministratorsGroup",
   150  			Title: "Administrator group",
   151  			Type:  portal.FieldText,
   152  			Validator: func(v string) error {
   153  				if v == "" {
   154  					return fmt.Errorf("administrator group cannot be an empty string")
   155  				}
   156  				return nil
   157  			},
   158  			Help: `<p>Members of this group can directly call the validation endpoint
   159  of this service. Usually it is called only indirectly by the config service,
   160  but it may be useful (e.g. for debugging) to call it directly.</p>`,
   161  		},
   162  	}, nil
   163  }
   164  
   165  func (settingsPage) ReadSettings(c context.Context) (map[string]string, error) {
   166  	s := DefaultSettings(c)
   167  	err := settings.GetUncached(c, settingsKey, &s)
   168  	if err != nil && err != settings.ErrNoSettings {
   169  		return nil, err
   170  	}
   171  	return map[string]string{
   172  		"ConfigServiceHost":   s.ConfigServiceHost,
   173  		"AdministratorsGroup": s.AdministratorsGroup,
   174  	}, nil
   175  }
   176  
   177  func (settingsPage) WriteSettings(c context.Context, values map[string]string) error {
   178  	modified := Settings{
   179  		ConfigServiceHost:   translateConfigURLToHost(values["ConfigServiceHost"]),
   180  		AdministratorsGroup: values["AdministratorsGroup"],
   181  	}
   182  	return modified.SetIfChanged(c)
   183  }
   184  
   185  func translateConfigURLToHost(v string) string {
   186  	// If the host is a full URL, extract just the host component.
   187  	switch u, err := url.Parse(v); {
   188  	case err != nil:
   189  		return v
   190  	case u.Host != "":
   191  		// If we have a host (e.g., "example.com"), this will parse into the "Path"
   192  		// field with an empty host value. Therefore, if we have a "Host" value,
   193  		// we will use it directly (e.g., "http://example.com")
   194  		return u.Host
   195  	case u.Path != "":
   196  		// If this was just an empty (correct) host, it will have parsed into the
   197  		// Path field with an empty Host value.
   198  		return u.Path
   199  	default:
   200  		return v
   201  	}
   202  }
   203  
   204  func init() {
   205  	portal.RegisterPage(settingsKey, settingsPage{})
   206  }