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: <your email> 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 }