18487: Improves vocabulary error checking, with tests.
[arvados.git] / lib / controller / localdb / conn.go
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 package localdb
6
7 import (
8         "context"
9         "encoding/json"
10         "fmt"
11         "net/http"
12         "os"
13         "strings"
14         "time"
15
16         "git.arvados.org/arvados.git/lib/controller/railsproxy"
17         "git.arvados.org/arvados.git/lib/controller/rpc"
18         "git.arvados.org/arvados.git/sdk/go/arvados"
19         "git.arvados.org/arvados.git/sdk/go/ctxlog"
20         "git.arvados.org/arvados.git/sdk/go/httpserver"
21         "github.com/sirupsen/logrus"
22 )
23
24 type railsProxy = rpc.Conn
25
26 type Conn struct {
27         cluster                    *arvados.Cluster
28         *railsProxy                // handles API methods that aren't defined on Conn itself
29         vocabularyCache            *arvados.Vocabulary
30         vocabularyFileModTime      time.Time
31         lastVocabularyRefreshCheck time.Time
32         lastVocabularyError        error
33         loginController
34 }
35
36 func NewConn(cluster *arvados.Cluster) *Conn {
37         railsProxy := railsproxy.NewConn(cluster)
38         railsProxy.RedactHostInErrors = true
39         conn := Conn{
40                 cluster:    cluster,
41                 railsProxy: railsProxy,
42         }
43         conn.loginController = chooseLoginController(cluster, &conn)
44         return &conn
45 }
46
47 func (conn *Conn) checkProperties(ctx context.Context, properties interface{}) error {
48         if properties == nil {
49                 return nil
50         }
51         var props map[string]interface{}
52         switch properties := properties.(type) {
53         case string:
54                 err := json.Unmarshal([]byte(properties), &props)
55                 if err != nil {
56                         return err
57                 }
58         case map[string]interface{}:
59                 props = properties
60         default:
61                 return fmt.Errorf("unexpected properties type %T", properties)
62         }
63         voc, err := conn.VocabularyGet(ctx)
64         if err != nil {
65                 return err
66         }
67         err = voc.Check(props)
68         if err != nil {
69                 return httpErrorf(http.StatusBadRequest, voc.Check(props).Error())
70         }
71         return nil
72 }
73
74 func (conn *Conn) maybeRefreshVocabularyCache(logger logrus.FieldLogger) error {
75         if conn.lastVocabularyRefreshCheck.Add(time.Second).After(time.Now()) {
76                 // Throttle the access to disk to at most once per second.
77                 return nil
78         }
79         conn.lastVocabularyRefreshCheck = time.Now()
80         fi, err := os.Stat(conn.cluster.API.VocabularyPath)
81         if err != nil {
82                 err = fmt.Errorf("couldn't stat vocabulary file %q: %v", conn.cluster.API.VocabularyPath, err)
83                 conn.lastVocabularyError = err
84                 return err
85         }
86         if fi.ModTime().After(conn.vocabularyFileModTime) {
87                 err = conn.loadVocabularyFile()
88                 if err != nil {
89                         conn.lastVocabularyError = err
90                         return err
91                 }
92                 conn.vocabularyFileModTime = fi.ModTime()
93                 conn.lastVocabularyError = nil
94                 logger.Info("vocabulary file reloaded successfully")
95         }
96         return nil
97 }
98
99 func (conn *Conn) loadVocabularyFile() error {
100         vf, err := os.ReadFile(conn.cluster.API.VocabularyPath)
101         if err != nil {
102                 return fmt.Errorf("while reading the vocabulary file: %v", err)
103         }
104         mk := make([]string, 0, len(conn.cluster.Collections.ManagedProperties))
105         for k := range conn.cluster.Collections.ManagedProperties {
106                 mk = append(mk, k)
107         }
108         voc, err := arvados.NewVocabulary(vf, mk)
109         if err != nil {
110                 return fmt.Errorf("while loading vocabulary file %q: %s", conn.cluster.API.VocabularyPath, err)
111         }
112         conn.vocabularyCache = voc
113         return nil
114 }
115
116 // LastVocabularyError returns the last error encountered while loading the
117 // vocabulary file.
118 // Implements health.Func
119 func (conn *Conn) LastVocabularyError() error {
120         conn.maybeRefreshVocabularyCache(ctxlog.FromContext(context.Background()))
121         return conn.lastVocabularyError
122 }
123
124 // VocabularyGet refreshes the vocabulary cache if necessary and returns it.
125 func (conn *Conn) VocabularyGet(ctx context.Context) (arvados.Vocabulary, error) {
126         if conn.cluster.API.VocabularyPath == "" {
127                 return arvados.Vocabulary{
128                         Tags: map[string]arvados.VocabularyTag{},
129                 }, nil
130         }
131         logger := ctxlog.FromContext(ctx)
132         if conn.vocabularyCache == nil {
133                 // Initial load of vocabulary file.
134                 err := conn.loadVocabularyFile()
135                 if err != nil {
136                         logger.WithError(err).Error("error loading vocabulary file")
137                         return arvados.Vocabulary{}, err
138                 }
139         }
140         err := conn.maybeRefreshVocabularyCache(logger)
141         if err != nil {
142                 logger.WithError(err).Error("error reloading vocabulary file - ignoring")
143         }
144         return *conn.vocabularyCache, nil
145 }
146
147 // Logout handles the logout of conn giving to the appropriate loginController
148 func (conn *Conn) Logout(ctx context.Context, opts arvados.LogoutOptions) (arvados.LogoutResponse, error) {
149         return conn.loginController.Logout(ctx, opts)
150 }
151
152 // Login handles the login of conn giving to the appropriate loginController
153 func (conn *Conn) Login(ctx context.Context, opts arvados.LoginOptions) (arvados.LoginResponse, error) {
154         return conn.loginController.Login(ctx, opts)
155 }
156
157 // UserAuthenticate handles the User Authentication of conn giving to the appropriate loginController
158 func (conn *Conn) UserAuthenticate(ctx context.Context, opts arvados.UserAuthenticateOptions) (arvados.APIClientAuthorization, error) {
159         return conn.loginController.UserAuthenticate(ctx, opts)
160 }
161
162 func (conn *Conn) GroupContents(ctx context.Context, options arvados.GroupContentsOptions) (arvados.ObjectList, error) {
163         // The requested UUID can be a user (virtual home project), which we just pass on to
164         // the API server.
165         if strings.Index(options.UUID, "-j7d0g-") != 5 {
166                 return conn.railsProxy.GroupContents(ctx, options)
167         }
168
169         var resp arvados.ObjectList
170
171         // Get the group object
172         respGroup, err := conn.GroupGet(ctx, arvados.GetOptions{UUID: options.UUID})
173         if err != nil {
174                 return resp, err
175         }
176
177         // If the group has groupClass 'filter', apply the filters before getting the contents.
178         if respGroup.GroupClass == "filter" {
179                 if filters, ok := respGroup.Properties["filters"].([]interface{}); ok {
180                         for _, f := range filters {
181                                 // f is supposed to be a []string
182                                 tmp, ok2 := f.([]interface{})
183                                 if !ok2 || len(tmp) < 3 {
184                                         return resp, fmt.Errorf("filter unparsable: %T, %+v, original field: %T, %+v\n", tmp, tmp, f, f)
185                                 }
186                                 var filter arvados.Filter
187                                 if attr, ok2 := tmp[0].(string); ok2 {
188                                         filter.Attr = attr
189                                 } else {
190                                         return resp, fmt.Errorf("filter unparsable: attribute must be string: %T, %+v, filter: %T, %+v\n", tmp[0], tmp[0], f, f)
191                                 }
192                                 if operator, ok2 := tmp[1].(string); ok2 {
193                                         filter.Operator = operator
194                                 } else {
195                                         return resp, fmt.Errorf("filter unparsable: operator must be string: %T, %+v, filter: %T, %+v\n", tmp[1], tmp[1], f, f)
196                                 }
197                                 filter.Operand = tmp[2]
198                                 options.Filters = append(options.Filters, filter)
199                         }
200                 } else {
201                         return resp, fmt.Errorf("filter unparsable: not an array\n")
202                 }
203                 // Use the generic /groups/contents endpoint for filter groups
204                 options.UUID = ""
205         }
206
207         return conn.railsProxy.GroupContents(ctx, options)
208 }
209
210 func httpErrorf(code int, format string, args ...interface{}) error {
211         return httpserver.ErrorWithStatus(fmt.Errorf(format, args...), code)
212 }