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