16808: add strict mode to arvados-server config-check
[arvados.git] / lib / config / load.go
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 package config
6
7 import (
8         "bytes"
9         "encoding/json"
10         "errors"
11         "flag"
12         "fmt"
13         "io"
14         "io/ioutil"
15         "os"
16         "strings"
17
18         "git.arvados.org/arvados.git/sdk/go/arvados"
19         "github.com/ghodss/yaml"
20         "github.com/imdario/mergo"
21         "github.com/sirupsen/logrus"
22 )
23
24 var ErrNoClustersDefined = errors.New("config does not define any clusters")
25
26 type Loader struct {
27         Stdin          io.Reader
28         Logger         logrus.FieldLogger
29         SkipDeprecated bool // Don't load deprecated config keys
30         SkipLegacy     bool // Don't load legacy config files
31         SkipAPICalls   bool // Don't do checks that call RailsAPI/controller
32         Strict         bool // In config-check, any warnings or diff is non-empty will result in a non-zero exit code
33
34         Path                    string
35         KeepstorePath           string
36         KeepWebPath             string
37         CrunchDispatchSlurmPath string
38         WebsocketPath           string
39         KeepproxyPath           string
40         GitHttpdPath            string
41         KeepBalancePath         string
42
43         configdata []byte
44 }
45
46 // NewLoader returns a new Loader with Stdin and Logger set to the
47 // given values, and all config paths set to their default values.
48 func NewLoader(stdin io.Reader, logger logrus.FieldLogger) *Loader {
49         ldr := &Loader{Stdin: stdin, Logger: logger}
50         // Calling SetupFlags on a throwaway FlagSet has the side
51         // effect of assigning default values to the configurable
52         // fields.
53         ldr.SetupFlags(flag.NewFlagSet("", flag.ContinueOnError))
54         return ldr
55 }
56
57 // SetupFlags configures a flagset so arguments like -config X can be
58 // used to change the loader's Path fields.
59 //
60 //      ldr := NewLoader(os.Stdin, logrus.New())
61 //      flagset := flag.NewFlagSet("", flag.ContinueOnError)
62 //      ldr.SetupFlags(flagset)
63 //      // ldr.Path == "/etc/arvados/config.yml"
64 //      flagset.Parse([]string{"-config", "/tmp/c.yaml"})
65 //      // ldr.Path == "/tmp/c.yaml"
66 func (ldr *Loader) SetupFlags(flagset *flag.FlagSet) {
67         flagset.StringVar(&ldr.Path, "config", arvados.DefaultConfigFile, "Site configuration `file` (default may be overridden by setting an ARVADOS_CONFIG environment variable)")
68         if !ldr.SkipLegacy {
69                 flagset.StringVar(&ldr.KeepstorePath, "legacy-keepstore-config", defaultKeepstoreConfigPath, "Legacy keepstore configuration `file`")
70                 flagset.StringVar(&ldr.KeepWebPath, "legacy-keepweb-config", defaultKeepWebConfigPath, "Legacy keep-web configuration `file`")
71                 flagset.StringVar(&ldr.CrunchDispatchSlurmPath, "legacy-crunch-dispatch-slurm-config", defaultCrunchDispatchSlurmConfigPath, "Legacy crunch-dispatch-slurm configuration `file`")
72                 flagset.StringVar(&ldr.WebsocketPath, "legacy-ws-config", defaultWebsocketConfigPath, "Legacy arvados-ws configuration `file`")
73                 flagset.StringVar(&ldr.KeepproxyPath, "legacy-keepproxy-config", defaultKeepproxyConfigPath, "Legacy keepproxy configuration `file`")
74                 flagset.StringVar(&ldr.GitHttpdPath, "legacy-git-httpd-config", defaultGitHttpdConfigPath, "Legacy arv-git-httpd configuration `file`")
75                 flagset.StringVar(&ldr.KeepBalancePath, "legacy-keepbalance-config", defaultKeepBalanceConfigPath, "Legacy keep-balance configuration `file`")
76                 flagset.BoolVar(&ldr.SkipLegacy, "skip-legacy", false, "Don't load legacy config files")
77                 flagset.BoolVar(&ldr.Strict, "strict", true, "Strict validation of configuration file (warnings result in non-zero exit code)")
78         }
79 }
80
81 // MungeLegacyConfigArgs checks args for a -config flag whose argument
82 // is a regular file (or a symlink to one), but doesn't have a
83 // top-level "Clusters" key and therefore isn't a valid cluster
84 // configuration file. If it finds such a flag, it replaces -config
85 // with legacyConfigArg (e.g., "-legacy-keepstore-config").
86 //
87 // This is used by programs that still need to accept "-config" as a
88 // way to specify a per-component config file until their config has
89 // been migrated.
90 //
91 // If any errors are encountered while reading or parsing a config
92 // file, the given args are not munged. We presume the same errors
93 // will be encountered again and reported later on when trying to load
94 // cluster configuration from the same file, regardless of which
95 // struct we end up using.
96 func (ldr *Loader) MungeLegacyConfigArgs(lgr logrus.FieldLogger, args []string, legacyConfigArg string) []string {
97         munged := append([]string(nil), args...)
98         for i := 0; i < len(args); i++ {
99                 if !strings.HasPrefix(args[i], "-") || strings.SplitN(strings.TrimPrefix(args[i], "-"), "=", 2)[0] != "config" {
100                         continue
101                 }
102                 var operand string
103                 if strings.Contains(args[i], "=") {
104                         operand = strings.SplitN(args[i], "=", 2)[1]
105                 } else if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
106                         i++
107                         operand = args[i]
108                 } else {
109                         continue
110                 }
111                 if fi, err := os.Stat(operand); err != nil || !fi.Mode().IsRegular() {
112                         continue
113                 }
114                 f, err := os.Open(operand)
115                 if err != nil {
116                         continue
117                 }
118                 defer f.Close()
119                 buf, err := ioutil.ReadAll(f)
120                 if err != nil {
121                         continue
122                 }
123                 var cfg arvados.Config
124                 err = yaml.Unmarshal(buf, &cfg)
125                 if err != nil {
126                         continue
127                 }
128                 if len(cfg.Clusters) == 0 {
129                         lgr.Warnf("%s is not a cluster config file -- interpreting %s as %s (please migrate your config!)", operand, "-config", legacyConfigArg)
130                         if operand == args[i] {
131                                 munged[i-1] = legacyConfigArg
132                         } else {
133                                 munged[i] = legacyConfigArg + "=" + operand
134                         }
135                 }
136         }
137
138         // Disable legacy config loading for components other than the
139         // one that was specified
140         if legacyConfigArg != "-legacy-keepstore-config" {
141                 ldr.KeepstorePath = ""
142         }
143         if legacyConfigArg != "-legacy-crunch-dispatch-slurm-config" {
144                 ldr.CrunchDispatchSlurmPath = ""
145         }
146         if legacyConfigArg != "-legacy-ws-config" {
147                 ldr.WebsocketPath = ""
148         }
149         if legacyConfigArg != "-legacy-keepweb-config" {
150                 ldr.KeepWebPath = ""
151         }
152         if legacyConfigArg != "-legacy-keepproxy-config" {
153                 ldr.KeepproxyPath = ""
154         }
155         if legacyConfigArg != "-legacy-git-httpd-config" {
156                 ldr.GitHttpdPath = ""
157         }
158         if legacyConfigArg != "-legacy-keepbalance-config" {
159                 ldr.KeepBalancePath = ""
160         }
161
162         return munged
163 }
164
165 func (ldr *Loader) loadBytes(path string) ([]byte, error) {
166         if path == "-" {
167                 return ioutil.ReadAll(ldr.Stdin)
168         }
169         f, err := os.Open(path)
170         if err != nil {
171                 return nil, err
172         }
173         defer f.Close()
174         return ioutil.ReadAll(f)
175 }
176
177 func (ldr *Loader) Load() (*arvados.Config, error) {
178         if ldr.configdata == nil {
179                 buf, err := ldr.loadBytes(ldr.Path)
180                 if err != nil {
181                         return nil, err
182                 }
183                 ldr.configdata = buf
184         }
185
186         // Load the config into a dummy map to get the cluster ID
187         // keys, discarding the values; then set up defaults for each
188         // cluster ID; then load the real config on top of the
189         // defaults.
190         var dummy struct {
191                 Clusters map[string]struct{}
192         }
193         err := yaml.Unmarshal(ldr.configdata, &dummy)
194         if err != nil {
195                 return nil, err
196         }
197         if len(dummy.Clusters) == 0 {
198                 return nil, ErrNoClustersDefined
199         }
200
201         // We can't merge deep structs here; instead, we unmarshal the
202         // default & loaded config files into generic maps, merge
203         // those, and then json-encode+decode the result into the
204         // config struct type.
205         var merged map[string]interface{}
206         for id := range dummy.Clusters {
207                 var src map[string]interface{}
208                 err = yaml.Unmarshal(bytes.Replace(DefaultYAML, []byte(" xxxxx:"), []byte(" "+id+":"), -1), &src)
209                 if err != nil {
210                         return nil, fmt.Errorf("loading defaults for %s: %s", id, err)
211                 }
212                 err = mergo.Merge(&merged, src, mergo.WithOverride)
213                 if err != nil {
214                         return nil, fmt.Errorf("merging defaults for %s: %s", id, err)
215                 }
216         }
217         var src map[string]interface{}
218         err = yaml.Unmarshal(ldr.configdata, &src)
219         if err != nil {
220                 return nil, fmt.Errorf("loading config data: %s", err)
221         }
222         ldr.logExtraKeys(merged, src, "")
223         removeSampleKeys(merged)
224         err = mergo.Merge(&merged, src, mergo.WithOverride)
225         if err != nil {
226                 return nil, fmt.Errorf("merging config data: %s", err)
227         }
228
229         // map[string]interface{} => json => arvados.Config
230         var cfg arvados.Config
231         var errEnc error
232         pr, pw := io.Pipe()
233         go func() {
234                 errEnc = json.NewEncoder(pw).Encode(merged)
235                 pw.Close()
236         }()
237         err = json.NewDecoder(pr).Decode(&cfg)
238         if errEnc != nil {
239                 err = errEnc
240         }
241         if err != nil {
242                 return nil, fmt.Errorf("transcoding config data: %s", err)
243         }
244
245         if !ldr.SkipDeprecated {
246                 err = ldr.applyDeprecatedConfig(&cfg)
247                 if err != nil {
248                         return nil, err
249                 }
250         }
251         if !ldr.SkipLegacy {
252                 // legacy file is required when either:
253                 // * a non-default location was specified
254                 // * no primary config was loaded, and this is the
255                 // legacy config file for the current component
256                 for _, err := range []error{
257                         ldr.loadOldEnvironmentVariables(&cfg),
258                         ldr.loadOldKeepstoreConfig(&cfg),
259                         ldr.loadOldKeepWebConfig(&cfg),
260                         ldr.loadOldCrunchDispatchSlurmConfig(&cfg),
261                         ldr.loadOldWebsocketConfig(&cfg),
262                         ldr.loadOldKeepproxyConfig(&cfg),
263                         ldr.loadOldGitHttpdConfig(&cfg),
264                         ldr.loadOldKeepBalanceConfig(&cfg),
265                 } {
266                         if err != nil {
267                                 return nil, err
268                         }
269                 }
270         }
271
272         // Check for known mistakes
273         for id, cc := range cfg.Clusters {
274                 for _, err = range []error{
275                         checkKeyConflict(fmt.Sprintf("Clusters.%s.PostgreSQL.Connection", id), cc.PostgreSQL.Connection),
276                         ldr.checkEmptyKeepstores(cc),
277                         ldr.checkUnlistedKeepstores(cc),
278                 } {
279                         if err != nil {
280                                 return nil, err
281                         }
282                 }
283         }
284         return &cfg, nil
285 }
286
287 func checkKeyConflict(label string, m map[string]string) error {
288         saw := map[string]bool{}
289         for k := range m {
290                 k = strings.ToLower(k)
291                 if saw[k] {
292                         return fmt.Errorf("%s: multiple entries for %q (fix by using same capitalization as default/example file)", label, k)
293                 }
294                 saw[k] = true
295         }
296         return nil
297 }
298
299 func removeSampleKeys(m map[string]interface{}) {
300         delete(m, "SAMPLE")
301         for _, v := range m {
302                 if v, _ := v.(map[string]interface{}); v != nil {
303                         removeSampleKeys(v)
304                 }
305         }
306 }
307
308 func (ldr *Loader) logExtraKeys(expected, supplied map[string]interface{}, prefix string) {
309         if ldr.Logger == nil {
310                 return
311         }
312         allowed := map[string]interface{}{}
313         for k, v := range expected {
314                 allowed[strings.ToLower(k)] = v
315         }
316         for k, vsupp := range supplied {
317                 if k == "SAMPLE" {
318                         // entry will be dropped in removeSampleKeys anyway
319                         continue
320                 }
321                 vexp, ok := allowed[strings.ToLower(k)]
322                 if expected["SAMPLE"] != nil {
323                         vexp = expected["SAMPLE"]
324                 } else if !ok {
325                         ldr.Logger.Warnf("deprecated or unknown config entry: %s%s", prefix, k)
326                         continue
327                 }
328                 if vsupp, ok := vsupp.(map[string]interface{}); !ok {
329                         // if vsupp is a map but vexp isn't map, this
330                         // will be caught elsewhere; see TestBadType.
331                         continue
332                 } else if vexp, ok := vexp.(map[string]interface{}); !ok {
333                         ldr.Logger.Warnf("unexpected object in config entry: %s%s", prefix, k)
334                 } else {
335                         ldr.logExtraKeys(vexp, vsupp, prefix+k+".")
336                 }
337         }
338 }