Merge branch '22027-remove-themes-for-rails' refs #22027
[arvados.git] / lib / controller / localdb / collection.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         "fmt"
10         "net/http"
11         "os"
12         "sort"
13         "strings"
14         "time"
15
16         "git.arvados.org/arvados.git/lib/ctrlctx"
17         "git.arvados.org/arvados.git/sdk/go/arvados"
18         "git.arvados.org/arvados.git/sdk/go/arvadosclient"
19         "git.arvados.org/arvados.git/sdk/go/auth"
20         "git.arvados.org/arvados.git/sdk/go/httpserver"
21 )
22
23 // CollectionGet defers to railsProxy for everything except blob
24 // signatures.
25 func (conn *Conn) CollectionGet(ctx context.Context, opts arvados.GetOptions) (arvados.Collection, error) {
26         conn.logActivity(ctx)
27         if len(opts.Select) > 0 {
28                 // We need to know IsTrashed and TrashAt to implement
29                 // signing properly, even if the caller doesn't want
30                 // them.
31                 opts.Select = append([]string{"is_trashed", "trash_at"}, opts.Select...)
32         }
33         resp, err := conn.railsProxy.CollectionGet(ctx, opts)
34         if err != nil {
35                 return resp, err
36         }
37         conn.signCollection(ctx, &resp)
38         return resp, nil
39 }
40
41 // CollectionList defers to railsProxy for everything except blob
42 // signatures.
43 func (conn *Conn) CollectionList(ctx context.Context, opts arvados.ListOptions) (arvados.CollectionList, error) {
44         conn.logActivity(ctx)
45         if len(opts.Select) > 0 {
46                 // We need to know IsTrashed and TrashAt to implement
47                 // signing properly, even if the caller doesn't want
48                 // them.
49                 opts.Select = append([]string{"is_trashed", "trash_at"}, opts.Select...)
50         }
51         resp, err := conn.railsProxy.CollectionList(ctx, opts)
52         if err != nil {
53                 return resp, err
54         }
55         for i := range resp.Items {
56                 conn.signCollection(ctx, &resp.Items[i])
57         }
58         return resp, nil
59 }
60
61 // CollectionCreate defers to railsProxy for everything except blob
62 // signatures and vocabulary checking.
63 func (conn *Conn) CollectionCreate(ctx context.Context, opts arvados.CreateOptions) (arvados.Collection, error) {
64         conn.logActivity(ctx)
65         err := conn.checkProperties(ctx, opts.Attrs["properties"])
66         if err != nil {
67                 return arvados.Collection{}, err
68         }
69         if len(opts.Select) > 0 {
70                 // We need to know IsTrashed and TrashAt to implement
71                 // signing properly, even if the caller doesn't want
72                 // them.
73                 opts.Select = append([]string{"is_trashed", "trash_at"}, opts.Select...)
74         }
75         if opts.Attrs, err = conn.applyReplaceFilesOption(ctx, "", opts.Attrs, opts.ReplaceFiles); err != nil {
76                 return arvados.Collection{}, err
77         }
78         resp, err := conn.railsProxy.CollectionCreate(ctx, opts)
79         if err != nil {
80                 return resp, err
81         }
82         conn.signCollection(ctx, &resp)
83         return resp, nil
84 }
85
86 // CollectionUpdate defers to railsProxy for everything except blob
87 // signatures and vocabulary checking.
88 func (conn *Conn) CollectionUpdate(ctx context.Context, opts arvados.UpdateOptions) (arvados.Collection, error) {
89         conn.logActivity(ctx)
90         err := conn.checkProperties(ctx, opts.Attrs["properties"])
91         if err != nil {
92                 return arvados.Collection{}, err
93         }
94         if len(opts.Select) > 0 {
95                 // We need to know IsTrashed and TrashAt to implement
96                 // signing properly, even if the caller doesn't want
97                 // them.
98                 opts.Select = append([]string{"is_trashed", "trash_at"}, opts.Select...)
99         }
100         err = conn.lockUUID(ctx, opts.UUID)
101         if err != nil {
102                 return arvados.Collection{}, err
103         }
104         if opts.Attrs, err = conn.applyReplaceFilesOption(ctx, opts.UUID, opts.Attrs, opts.ReplaceFiles); err != nil {
105                 return arvados.Collection{}, err
106         }
107         resp, err := conn.railsProxy.CollectionUpdate(ctx, opts)
108         if err != nil {
109                 return resp, err
110         }
111         conn.signCollection(ctx, &resp)
112         return resp, nil
113 }
114
115 func (conn *Conn) signCollection(ctx context.Context, coll *arvados.Collection) {
116         if coll.IsTrashed || coll.ManifestText == "" || !conn.cluster.Collections.BlobSigning {
117                 return
118         }
119         var token string
120         if creds, ok := auth.FromContext(ctx); ok && len(creds.Tokens) > 0 {
121                 token = creds.Tokens[0]
122         }
123         if token == "" {
124                 return
125         }
126         ttl := conn.cluster.Collections.BlobSigningTTL.Duration()
127         exp := time.Now().Add(ttl)
128         if coll.TrashAt != nil && !coll.TrashAt.IsZero() && coll.TrashAt.Before(exp) {
129                 exp = *coll.TrashAt
130         }
131         coll.ManifestText = arvados.SignManifest(coll.ManifestText, token, exp, ttl, []byte(conn.cluster.Collections.BlobSigningKey))
132 }
133
134 func (conn *Conn) lockUUID(ctx context.Context, uuid string) error {
135         tx, err := ctrlctx.CurrentTx(ctx)
136         if err != nil {
137                 return err
138         }
139         _, err = tx.ExecContext(ctx, `insert into uuid_locks (uuid) values ($1) on conflict (uuid) do update set n=uuid_locks.n+1`, uuid)
140         if err != nil {
141                 return err
142         }
143         return nil
144 }
145
146 // If replaceFiles is non-empty, populate attrs["manifest_text"] by
147 // starting with the content of fromUUID (or an empty collection if
148 // fromUUID is empty) and applying the specified file/directory
149 // replacements.
150 //
151 // Return value is the (possibly modified) attrs map.
152 func (conn *Conn) applyReplaceFilesOption(ctx context.Context, fromUUID string, attrs map[string]interface{}, replaceFiles map[string]string) (map[string]interface{}, error) {
153         if len(replaceFiles) == 0 {
154                 return attrs, nil
155         }
156
157         providedManifestText, _ := attrs["manifest_text"].(string)
158         if providedManifestText != "" {
159                 used := false
160                 for _, src := range replaceFiles {
161                         if strings.HasPrefix(src, "manifest_text/") {
162                                 used = true
163                                 break
164                         }
165                 }
166                 if !used {
167                         return nil, httpserver.Errorf(http.StatusBadRequest, "invalid request: attrs['manifest_text'] was provided, but would not be used because it is not referenced by any 'replace_files' entry")
168                 }
169         }
170
171         // Load the current collection (if any) and set up an
172         // in-memory filesystem.
173         var dst arvados.Collection
174         if _, replacingRoot := replaceFiles["/"]; !replacingRoot && fromUUID != "" {
175                 src, err := conn.CollectionGet(ctx, arvados.GetOptions{UUID: fromUUID})
176                 if err != nil {
177                         return nil, err
178                 }
179                 dst = src
180         }
181         dstfs, err := dst.FileSystem(&arvados.StubClient{}, &arvados.StubClient{})
182         if err != nil {
183                 return nil, err
184         }
185
186         // Sort replacements by source collection to avoid redundant
187         // reloads when a source collection is used more than
188         // once. Note empty sources (which mean "delete target path")
189         // sort first.
190         dstTodo := make([]string, 0, len(replaceFiles))
191         {
192                 srcid := make(map[string]string, len(replaceFiles))
193                 for dst, src := range replaceFiles {
194                         dstTodo = append(dstTodo, dst)
195                         if i := strings.IndexRune(src, '/'); i > 0 {
196                                 srcid[dst] = src[:i]
197                         }
198                 }
199                 sort.Slice(dstTodo, func(i, j int) bool {
200                         return srcid[dstTodo[i]] < srcid[dstTodo[j]]
201                 })
202         }
203
204         // Reject attempt to replace a node as well as its descendant
205         // (e.g., a/ and a/b/), which is unsupported, except where the
206         // source for a/ is empty (i.e., delete).
207         for _, dst := range dstTodo {
208                 if dst != "/" && (strings.HasSuffix(dst, "/") ||
209                         strings.HasSuffix(dst, "/.") ||
210                         strings.HasSuffix(dst, "/..") ||
211                         strings.Contains(dst, "//") ||
212                         strings.Contains(dst, "/./") ||
213                         strings.Contains(dst, "/../") ||
214                         !strings.HasPrefix(dst, "/")) {
215                         return nil, httpserver.Errorf(http.StatusBadRequest, "invalid replace_files target: %q", dst)
216                 }
217                 for i := 0; i < len(dst)-1; i++ {
218                         if dst[i] != '/' {
219                                 continue
220                         }
221                         outerdst := dst[:i]
222                         if outerdst == "" {
223                                 outerdst = "/"
224                         }
225                         if outersrc := replaceFiles[outerdst]; outersrc != "" {
226                                 return nil, httpserver.Errorf(http.StatusBadRequest, "replace_files: cannot operate on target %q inside non-empty target %q", dst, outerdst)
227                         }
228                 }
229         }
230
231         current := make(map[string]*arvados.Subtree)
232         // Check whether any sources are "current/...", and if so,
233         // populate current with the relevant snapshot.  Doing this
234         // ahead of time, before making any modifications to dstfs
235         // below, ensures that even instructions like {/a: current/b,
236         // b: current/a} will be handled correctly.
237         for _, src := range replaceFiles {
238                 if strings.HasPrefix(src, "current/") && current[src] == nil {
239                         current[src], err = arvados.Snapshot(dstfs, src[8:])
240                         if err != nil {
241                                 return nil, fmt.Errorf("%s: %w", src, err)
242                         }
243                 }
244         }
245
246         var srcidloaded string
247         var srcfs arvados.FileSystem
248         // Apply the requested replacements.
249         for _, dst := range dstTodo {
250                 src := replaceFiles[dst]
251                 if src == "" {
252                         if dst == "/" {
253                                 // In this case we started with a
254                                 // blank manifest, so there can't be
255                                 // anything to delete.
256                                 continue
257                         }
258                         err := dstfs.RemoveAll(dst)
259                         if err != nil {
260                                 return nil, fmt.Errorf("RemoveAll(%s): %w", dst, err)
261                         }
262                         continue
263                 }
264                 var snap *arvados.Subtree
265                 srcspec := strings.SplitN(src, "/", 2)
266                 srcid, srcpath := srcspec[0], "/"
267                 if len(srcspec) == 2 && srcspec[1] != "" {
268                         srcpath = srcspec[1]
269                 }
270                 switch {
271                 case srcid == "current":
272                         snap = current[src]
273                         if snap == nil {
274                                 return nil, fmt.Errorf("internal error: current[%s] == nil", src)
275                         }
276                 case srcid == "manifest_text":
277                         if srcidloaded == srcid {
278                                 break
279                         }
280                         srcfs = nil
281                         srccoll := &arvados.Collection{ManifestText: providedManifestText}
282                         srcfs, err = srccoll.FileSystem(&arvados.StubClient{}, &arvados.StubClient{})
283                         if err != nil {
284                                 return nil, err
285                         }
286                         srcidloaded = srcid
287                 case arvadosclient.PDHMatch(srcid):
288                         if srcidloaded == srcid {
289                                 break
290                         }
291                         srcfs = nil
292                         srccoll, err := conn.CollectionGet(ctx, arvados.GetOptions{UUID: srcid})
293                         if err != nil {
294                                 return nil, err
295                         }
296                         // We use StubClient here because we don't
297                         // want srcfs to read/write any file data or
298                         // sync collection state to/from the database.
299                         srcfs, err = srccoll.FileSystem(&arvados.StubClient{}, &arvados.StubClient{})
300                         if err != nil {
301                                 return nil, err
302                         }
303                         srcidloaded = srcid
304                 default:
305                         return nil, httpserver.Errorf(http.StatusBadRequest, "invalid source %q for replace_files[%q]: must be \"\" or \"SRC\" or \"SRC/path\" where SRC is \"current\", \"manifest_text\", or a portable data hash", src, dst)
306                 }
307                 if snap == nil {
308                         snap, err = arvados.Snapshot(srcfs, srcpath)
309                         if err != nil {
310                                 return nil, httpserver.Errorf(http.StatusBadRequest, "error getting snapshot of %q from %q: %w", srcpath, srcid, err)
311                         }
312                 }
313                 // Create intermediate dirs, in case dst is
314                 // "newdir1/newdir2/dst".
315                 for i := 1; i < len(dst)-1; i++ {
316                         if dst[i] == '/' {
317                                 err = dstfs.Mkdir(dst[:i], 0777)
318                                 if err != nil && !os.IsExist(err) {
319                                         return nil, httpserver.Errorf(http.StatusBadRequest, "error creating parent dirs for %q: %w", dst, err)
320                                 }
321                         }
322                 }
323                 err = arvados.Splice(dstfs, dst, snap)
324                 if err != nil {
325                         return nil, fmt.Errorf("error splicing snapshot onto path %q: %w", dst, err)
326                 }
327         }
328         mtxt, err := dstfs.MarshalManifest(".")
329         if err != nil {
330                 return nil, err
331         }
332         if attrs == nil {
333                 attrs = make(map[string]interface{}, 1)
334         }
335         attrs["manifest_text"] = mtxt
336         return attrs, nil
337 }