Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(712)

Side by Side Diff: luci_config/appengine/backend/datastore/ds_test.go

Issue 2576923003: Implement config service cache on top of datastore (Closed)
Patch Set: Relocated, fix, split integration test, rebase. Created 3 years, 11 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch
OLDNEW
(Empty)
1 // Copyright 2016 The LUCI Authors. All rights reserved.
2 // Use of this source code is governed under the Apache License, Version 2.0
3 // that can be found in the LICENSE file.
4
5 package datastore
6
7 import (
8 "fmt"
9 "sort"
10 "strings"
11 "testing"
12 "time"
13
14 "github.com/luci/luci-go/appengine/datastorecache"
15 memConfig "github.com/luci/luci-go/common/config/impl/memory"
16 "github.com/luci/luci-go/common/errors"
17 configPB "github.com/luci/luci-go/common/proto/config"
18 gaeformat "github.com/luci/luci-go/luci_config/appengine/format"
19 "github.com/luci/luci-go/luci_config/common/cfgtypes"
20 "github.com/luci/luci-go/luci_config/server/cfgclient"
21 "github.com/luci/luci-go/luci_config/server/cfgclient/backend"
22 "github.com/luci/luci-go/luci_config/server/cfgclient/backend/caching"
23 "github.com/luci/luci-go/luci_config/server/cfgclient/backend/client"
24 "github.com/luci/luci-go/luci_config/server/cfgclient/backend/format"
25 "github.com/luci/luci-go/luci_config/server/cfgclient/backend/testconfig "
26 "github.com/luci/luci-go/luci_config/server/cfgclient/textproto"
27 "github.com/luci/luci-go/server/auth"
28 "github.com/luci/luci-go/server/auth/authtest"
29
30 "github.com/luci/gae/impl/memory"
31
32 "github.com/golang/protobuf/proto"
33 "golang.org/x/net/context"
34
35 . "github.com/luci/luci-go/common/testing/assertions"
36 . "github.com/smartystreets/goconvey/convey"
37 )
38
39 // testCache is a generic Cache testing layer.
40 type testCache interface {
41 dsCacheBackend
42
43 setCacheErr(err error)
44 setProjectDNE(project string)
45 addConfig(configSet cfgtypes.ConfigSet, path, content string) *backend.I tem
46 addProjectConfig(name cfgtypes.ProjectName, access string)
47 addConfigSets(path string, configSets ...cfgtypes.ConfigSet) []string
48 addConfigSetURL(configSet cfgtypes.ConfigSet) string
49 }
50
51 func projectConfigWithAccess(name cfgtypes.ProjectName, access ...string) *confi gPB.ProjectCfg {
52 return &configPB.ProjectCfg{
53 Name: proto.String(string(name)),
54 Access: access,
55 }
56 }
57
58 // fakeCache is a pure in-memory testCache implementation. It is very simple,
59 // storing only raw cache key/value pairs.
60 type fakeCache struct {
61 d map[string]datastorecache.Value
62 err error
63 }
64
65 func mkFakeCache() *fakeCache {
66 return &fakeCache{
67 d: make(map[string]datastorecache.Value),
68 }
69 }
70
71 func (fc *fakeCache) Get(c context.Context, key []byte) (v datastorecache.Value, err error) {
72 if err = fc.err; err != nil {
73 return
74 }
75
76 var k caching.Key
77 caching.Decode(key, &k)
78
79 var ok bool
80 if v, ok = fc.d[string(key)]; ok {
81 return
82 }
83
84 err = datastorecache.ErrCacheExpired
85 return
86 }
87
88 func (fc *fakeCache) setCacheData(key caching.Key, d []byte) {
89 }
90
91 func (fc *fakeCache) set(key caching.Key, v *caching.Value) {
92 encKey, err := caching.Encode(&key)
93 if err != nil {
94 panic(fmt.Errorf("failed to encode key: %s", err))
95 }
96
97 if v == nil {
98 delete(fc.d, string(encKey))
99 return
100 }
101
102 encValue, err := v.Encode()
103 if err != nil {
104 panic(fmt.Errorf("failed to encode cache value: %s", err))
105 }
106
107 fc.d[string(encKey)] = datastorecache.Value{
108 Schema: dsCacheSchema,
109 Data: encValue,
110 Description: key.String(),
111 }
112 }
113
114 func (fc *fakeCache) setCacheErr(err error) { fc.err = err }
115
116 func (fc *fakeCache) setProjectDNE(project string) {
117 // Get for this project config will fail.
118 fc.set(caching.Key{
119 Schema: caching.Schema,
120 Op: caching.OpGet,
121 ConfigSet: string(cfgtypes.ProjectConfigSet(cfgtypes.ProjectName (project))),
122 Path: cfgclient.ProjectConfigPath,
123 }, nil)
124 }
125
126 func (fc *fakeCache) addConfigImpl(cs cfgtypes.ConfigSet, path, formatter, forma tData, content string) *backend.Item {
127 var (
128 item *backend.Item
129 cv caching.Value
130 )
131 if content != "" {
132 item = &backend.Item{
133 Meta: backend.Meta{
134 ConfigSet: string(cs),
135 Path: path,
136 ContentHash: "hash",
137 },
138 Content: content,
139 FormatSpec: backend.FormatSpec{formatter, formatData},
140 }
141 cv.LoadItems(item)
142 }
143
144 fc.set(caching.Key{
145 Schema: caching.Schema,
146 Authority: backend.AsService,
147 Op: caching.OpGet,
148 ConfigSet: string(cs),
149 Path: path,
150 Content: true,
151 Formatter: formatter,
152 FormatData: formatData,
153 }, &cv)
154
155 return item
156 }
157
158 func (fc *fakeCache) addConfig(cs cfgtypes.ConfigSet, path, content string) *bac kend.Item {
159 return fc.addConfigImpl(cs, path, "", "", content)
160 }
161
162 // addProjectConfig caches a "project.cfg" file for the specified project with
163 // the specified access string.
164 func (fc *fakeCache) addProjectConfig(name cfgtypes.ProjectName, access string) {
165 // We're loading the resolved version of this cache item.
166 pcfg := projectConfigWithAccess(name, access)
167 pcfgName := proto.MessageName(pcfg)
168
169 f := textproto.Formatter{}
170 formattedData, err := f.FormatItem(proto.MarshalTextString(pcfg), pcfgNa me)
171 if err != nil {
172 panic(err)
173 }
174
175 fc.addConfigImpl(cfgtypes.ProjectConfigSet(name), cfgclient.ProjectConfi gPath,
176 textproto.BinaryFormat, pcfgName, formattedData)
177 }
178
179 func (fc *fakeCache) addConfigSets(path string, configSets ...cfgtypes.ConfigSet ) []string {
180 items := make([]*backend.Item, len(configSets))
181 contents := make([]string, len(configSets))
182 for i, cs := range configSets {
183 contents[i] = string(cs)
184 items[i] = &backend.Item{
185 Meta: backend.Meta{
186 ConfigSet: string(cs),
187 Path: path,
188 ContentHash: "hash",
189 },
190 Content: contents[i],
191 }
192 }
193
194 for _, t := range []backend.GetAllTarget{backend.GetAllProject, backend. GetAllRef} {
195 var cv caching.Value
196 cv.LoadItems(items...)
197
198 fc.set(caching.Key{
199 Schema: caching.Schema,
200 Authority: backend.AsService,
201 Op: caching.OpGetAll,
202 Content: true,
203 Path: path,
204 GetAllTarget: t,
205 }, &cv)
206 }
207 return contents
208 }
209
210 func (fc *fakeCache) addConfigSetURL(configSet cfgtypes.ConfigSet) string {
211 u := fmt.Sprintf("https://exmaple.com/config-sets/%s", configSet)
212 fc.set(caching.Key{
213 Schema: caching.Schema,
214 Authority: backend.AsService,
215 Op: caching.OpConfigSetURL,
216 ConfigSet: string(configSet),
217 }, &caching.Value{
218 URL: u,
219 })
220 return u
221 }
222
223 // fullStackCache is a testCache implementation built on top of an in-memory
224 // base backend.B with the datastore Cache layer on top of it.
225 type fullStackCache struct {
226 cache *datastorecache.Cache
227 err error
228
229 data map[string]memConfig.ConfigSet
230 backend backend.B
231 junkIdx int
232 }
233
234 func (fsc *fullStackCache) Get(c context.Context, key []byte) (datastorecache.Va lue, error) {
235 if err := fsc.err; err != nil {
236 return datastorecache.Value{}, err
237 }
238 return fsc.cache.Get(c, key)
239 }
240
241 func (fsc *fullStackCache) setCacheErr(err error) { fsc.err = err }
242
243 func (fsc *fullStackCache) setProjectDNE(project string) {
244 key := "projects/" + project
245 for k := range fsc.data {
246 if k == key || strings.HasPrefix(k, key+"/") {
247 delete(fsc.data, k)
248 }
249 }
250 }
251
252 func (fsc *fullStackCache) addConfig(cs cfgtypes.ConfigSet, path, content string ) *backend.Item {
253 cset := fsc.data[string(cs)]
254 if cset == nil {
255 cset = memConfig.ConfigSet{}
256 fsc.data[string(cs)] = cset
257 }
258 if content == "" {
259 delete(cset, path)
260 return nil
261 }
262 cset[path] = content
263
264 // Pull the config right back out of the base service.
265 item, err := fsc.backend.Get(context.Background(), string(cs), path, bac kend.Params{
266 Authority: backend.AsService,
267 })
268 if err != nil {
269 panic(err)
270 }
271 return item
272 }
273
274 // addProjectConfig caches a "project.cfg" file for the specified project with
275 // the specified access string.
276 func (fsc *fullStackCache) addProjectConfig(name cfgtypes.ProjectName, access st ring) {
277 fsc.addConfig(cfgtypes.ProjectConfigSet(name), cfgclient.ProjectConfigPa th,
278 proto.MarshalTextString(projectConfigWithAccess(name, access)))
279 }
280
281 func (fsc *fullStackCache) addConfigSets(path string, configSets ...cfgtypes.Con figSet) []string {
282 // Sort the config sets list, then put it back.
283 cstr := make([]string, len(configSets))
284 for i, cs := range configSets {
285 cstr[i] = string(cs)
286 }
287 sort.Strings(cstr)
288 for i, cs := range cstr {
289 configSets[i] = cfgtypes.ConfigSet(cs)
290 }
291
292 items := make([]*backend.Item, len(configSets))
293 for i, cs := range configSets {
294 items[i] = fsc.addConfig(cs, path, string(cs))
295 }
296 return cstr
297 }
298
299 func (fsc *fullStackCache) addConfigSetURL(configSet cfgtypes.ConfigSet) string {
300 if _, ok := fsc.data[string(configSet)]; !ok {
301 fsc.data[string(configSet)] = memConfig.ConfigSet{}
302 }
303
304 // We're pretty rigid here. Whatever our backend returns is all we can
305 // return. We will just assert that anything more flexible has to confor m to
306 // this.
307 v, err := fsc.backend.ConfigSetURL(context.Background(), string(configSe t),
308 backend.Params{Authority: backend.AsService})
309 if err != nil {
310 panic(err)
311 }
312 return v.String()
313 }
314
315 // stripMeta strips cache-specific identifying information from a set of Metas.
316 func stripMeta(metas []*cfgclient.Meta) []*cfgclient.Meta {
317 for _, meta := range metas {
318 meta.ContentHash = ""
319 meta.Revision = ""
320 }
321 return metas
322 }
323
324 func testDatastoreCacheImpl(c context.Context, be backend.B, cache testCache) {
325 // Install fake auth state.
326 var authState authtest.FakeState
327 c = auth.WithState(c, &authState)
328 authState.Identity = "user:person@example.com"
329 authState.IdentityGroups = []string{"users"}
330
331 dsc := Config{
332 RefreshInterval: 1 * time.Hour,
333 FailOpen: false,
334 cache: cache,
335 }
336 c = backend.WithBackend(c, dsc.Backend(be))
337
338 testErr := errors.New("test error")
339
340 Convey(`Test Get`, func() {
341 var v string
342
343 Convey(`Config missing`, func() {
344 cache.addConfig("projects/test", "foo", "")
345
346 So(cfgclient.Get(c, cfgclient.AsService, "projects/test" , "foo", cfgclient.String(&v), nil),
347 ShouldEqual, cfgclient.ErrNoConfig)
348 })
349
350 Convey(`Config is present`, func() {
351 cache.addConfig("projects/test", "foo", "bar")
352 cache.addProjectConfig("test", "group:privileged")
353
354 Convey(`As service`, func() {
355 So(cfgclient.Get(c, cfgclient.AsService, "projec ts/test", "foo", cfgclient.String(&v), nil), ShouldBeNil)
356 So(v, ShouldEqual, "bar")
357 })
358
359 Convey(`As user, when not a project group member, fails with ErrNoConfig`, func() {
360 So(cfgclient.Get(c, cfgclient.AsUser, "projects/ test", "foo", cfgclient.String(&v), nil),
361 ShouldEqual, cfgclient.ErrNoConfig)
362 })
363
364 Convey(`As user, when a project group member, succeeds.` , func() {
365 authState.IdentityGroups = append(authState.Iden tityGroups, "privileged")
366 So(cfgclient.Get(c, cfgclient.AsUser, "projects/ test", "foo", cfgclient.String(&v), nil), ShouldBeNil)
367 So(v, ShouldEqual, "bar")
368 })
369
370 Convey(`As anonymous, fails with ErrNoConfig`, func() {
371 So(cfgclient.Get(c, cfgclient.AsAnonymous, "proj ects/test", "foo", cfgclient.String(&v), nil),
372 ShouldEqual, cfgclient.ErrNoConfig)
373 })
374 })
375 })
376
377 Convey(`Test Projects`, func() {
378 var v []string
379 var meta []*cfgclient.Meta
380
381 Convey(`When cache returns an error`, func() {
382 cache.setCacheErr(testErr)
383
384 So(cfgclient.Projects(c, cfgclient.AsService, "test.cfg" , cfgclient.StringSlice(&v), nil),
385 ShouldUnwrapTo, testErr)
386 So(cfgclient.Projects(c, cfgclient.AsUser, "test.cfg", c fgclient.StringSlice(&v), nil),
387 ShouldUnwrapTo, testErr)
388 So(cfgclient.Projects(c, cfgclient.AsAnonymous, "test.cf g", cfgclient.StringSlice(&v), nil),
389 ShouldUnwrapTo, testErr)
390 })
391
392 Convey(`With project configs installed`, func() {
393 allConfigs := cache.addConfigSets("test.cfg",
394 "projects/bar",
395 "projects/baz",
396 "projects/foo")
397
398 Convey(`As service, retrieves all configs.`, func() {
399 So(cfgclient.Projects(c, cfgclient.AsService, "t est.cfg", cfgclient.StringSlice(&v), &meta), ShouldBeNil)
400 So(v, ShouldResemble, allConfigs)
401 So(stripMeta(meta), ShouldResemble, []*cfgclient .Meta{
402 {ConfigSet: "projects/bar", Path: "test. cfg"},
403 {ConfigSet: "projects/baz", Path: "test. cfg"},
404 {ConfigSet: "projects/foo", Path: "test. cfg"},
405 })
406 })
407
408 Convey(`As user`, func() {
409 Convey(`Not a member of any projects, receives e mpty slice.`, func() {
410 cache.addProjectConfig("foo", "group:som eone")
411
412 So(cfgclient.Projects(c, cfgclient.AsUse r, "test.cfg", cfgclient.StringSlice(&v), &meta), ShouldBeNil)
413 So(v, ShouldResemble, []string(nil))
414 So(stripMeta(meta), ShouldResemble, []*c fgclient.Meta{})
415 })
416
417 Convey(`Member of "foo", gets only "foo".`, func () {
418 cache.addProjectConfig("foo", "group:use rs")
419
420 So(cfgclient.Projects(c, cfgclient.AsUse r, "test.cfg", cfgclient.StringSlice(&v), &meta), ShouldBeNil)
421 So(v, ShouldResemble, allConfigs[2:3])
422 So(stripMeta(meta), ShouldResemble, []*c fgclient.Meta{
423 {ConfigSet: "projects/foo", Path : "test.cfg"},
424 })
425 })
426
427 Convey(`Member of all projects, gets all project s.`, func() {
428 cache.addProjectConfig("foo", "group:use rs")
429 cache.addProjectConfig("bar", "group:use rs")
430 cache.addProjectConfig("baz", "group:use rs")
431
432 So(cfgclient.Projects(c, cfgclient.AsUse r, "test.cfg", cfgclient.StringSlice(&v), &meta), ShouldBeNil)
433 So(v, ShouldResemble, allConfigs)
434 So(stripMeta(meta), ShouldResemble, []*c fgclient.Meta{
435 {ConfigSet: "projects/bar", Path : "test.cfg"},
436 {ConfigSet: "projects/baz", Path : "test.cfg"},
437 {ConfigSet: "projects/foo", Path : "test.cfg"},
438 })
439 })
440 })
441 })
442 })
443
444 Convey(`Test Refs`, func() {
445 var v []string
446 var meta []*cfgclient.Meta
447
448 Convey(`When cache returns an error`, func() {
449 cache.setCacheErr(testErr)
450
451 So(cfgclient.Refs(c, cfgclient.AsService, "test.cfg", cf gclient.StringSlice(&v), nil),
452 ShouldUnwrapTo, testErr)
453 So(cfgclient.Refs(c, cfgclient.AsUser, "test.cfg", cfgcl ient.StringSlice(&v), nil),
454 ShouldUnwrapTo, testErr)
455 So(cfgclient.Refs(c, cfgclient.AsAnonymous, "test.cfg", cfgclient.StringSlice(&v), nil),
456 ShouldUnwrapTo, testErr)
457 })
458
459 Convey(`With ref configs installed`, func() {
460 allConfigs := cache.addConfigSets("test.cfg",
461 "projects/bar/refs/branches/mybranch",
462 "projects/bar/refs/heads/master",
463 "projects/foo/refs/branches/mybranch",
464 "projects/foo/refs/heads/master")
465
466 Convey(`As service, retrieves all configs.`, func() {
467 So(cfgclient.Refs(c, cfgclient.AsService, "test. cfg", cfgclient.StringSlice(&v), &meta), ShouldBeNil)
468 So(v, ShouldResemble, allConfigs)
469 So(stripMeta(meta), ShouldResemble, []*cfgclient .Meta{
470 {ConfigSet: "projects/bar/refs/branches/ mybranch", Path: "test.cfg"},
471 {ConfigSet: "projects/bar/refs/heads/mas ter", Path: "test.cfg"},
472 {ConfigSet: "projects/foo/refs/branches/ mybranch", Path: "test.cfg"},
473 {ConfigSet: "projects/foo/refs/heads/mas ter", Path: "test.cfg"},
474 })
475 })
476
477 Convey(`As user`, func() {
478 Convey(`Not a member of any projects, receives e mpty slice.`, func() {
479 cache.addProjectConfig("foo", "group:som eone")
480
481 So(cfgclient.Refs(c, cfgclient.AsUser, " test.cfg", cfgclient.StringSlice(&v), &meta), ShouldBeNil)
482 So(v, ShouldResemble, []string(nil))
483 So(stripMeta(meta), ShouldResemble, []*c fgclient.Meta{})
484 })
485
486 Convey(`Member of "foo", gets only "foo".`, func () {
487 cache.addProjectConfig("foo", "group:use rs")
488
489 So(cfgclient.Refs(c, cfgclient.AsUser, " test.cfg", cfgclient.StringSlice(&v), &meta), ShouldBeNil)
490 So(v, ShouldResemble, allConfigs[2:4])
491 So(stripMeta(meta), ShouldResemble, []*c fgclient.Meta{
492 {ConfigSet: "projects/foo/refs/b ranches/mybranch", Path: "test.cfg"},
493 {ConfigSet: "projects/foo/refs/h eads/master", Path: "test.cfg"},
494 })
495 })
496
497 Convey(`Member of all projects, gets all project s.`, func() {
498 cache.addProjectConfig("foo", "group:use rs")
499 cache.addProjectConfig("bar", "group:use rs")
500
501 So(cfgclient.Refs(c, cfgclient.AsUser, " test.cfg", cfgclient.StringSlice(&v), &meta), ShouldBeNil)
502 So(v, ShouldResemble, allConfigs)
503 So(stripMeta(meta), ShouldResemble, []*c fgclient.Meta{
504 {ConfigSet: "projects/bar/refs/b ranches/mybranch", Path: "test.cfg"},
505 {ConfigSet: "projects/bar/refs/h eads/master", Path: "test.cfg"},
506 {ConfigSet: "projects/foo/refs/b ranches/mybranch", Path: "test.cfg"},
507 {ConfigSet: "projects/foo/refs/h eads/master", Path: "test.cfg"},
508 })
509 })
510 })
511 })
512 })
513
514 Convey(`Test ConfigSetURL`, func() {
515 cache.addProjectConfig("foo", "group:someone")
516 csURL := cache.addConfigSetURL("projects/foo")
517
518 Convey(`AsService`, func() {
519 u, err := cfgclient.GetConfigSetURL(c, cfgclient.AsServi ce, "projects/foo")
520 So(err, ShouldBeNil)
521 So(u.String(), ShouldEqual, csURL)
522 })
523
524 Convey(`AsUser`, func() {
525
526 Convey(`When not a member of the group.`, func() {
527 _, err := cfgclient.GetConfigSetURL(c, cfgclient .AsUser, "projects/foo")
528 So(err, ShouldEqual, cfgclient.ErrNoConfig)
529 })
530
531 Convey(`When a member of the group.`, func() {
532 authState.IdentityGroups = append(authState.Iden tityGroups, "someone")
533
534 u, err := cfgclient.GetConfigSetURL(c, cfgclient .AsUser, "projects/foo")
535 So(err, ShouldBeNil)
536 So(u.String(), ShouldEqual, csURL)
537 })
538 })
539
540 Convey(`AsAnonymous`, func() {
541 _, err := cfgclient.GetConfigSetURL(c, cfgclient.AsAnony mous, "projects/foo")
542 So(err, ShouldEqual, cfgclient.ErrNoConfig)
543
544 // With credentials, can access.
545 authState.IdentityGroups = append(authState.IdentityGrou ps, "someone")
546 _, err = cfgclient.GetConfigSetURL(c, cfgclient.AsAnonym ous, "projects/foo")
547 So(err, ShouldBeNil)
548 })
549 })
550 }
551
552 func TestDatastoreCache(t *testing.T) {
553 t.Parallel()
554
555 Convey(`Testing with in-memory stub cache`, t, func() {
556 c := context.Background()
557 fc := mkFakeCache()
558
559 var be backend.B
560 be = &client.Backend{
561 Provider: &testconfig.Provider{
562 Base: memConfig.New(nil),
563 },
564 }
565
566 fr := gaeformat.Default()
567 be = &format.Backend{
568 B: be,
569 GetRegistry: func(context.Context) *cfgclient.FormatterR egistry { return fr },
570 }
571
572 Convey(`Standard datastore tests`, func() {
573 testDatastoreCacheImpl(c, be, fc)
574 })
575
576 Convey(`A testing setup built around the fake cache`, func() {
577 dsc := Config{
578 RefreshInterval: 1 * time.Hour,
579 FailOpen: false,
580 cache: fc,
581 }
582 c = backend.WithBackend(c, dsc.Backend(be))
583
584 Convey(`Errors with different schema.`, func() {
585 fc.addConfig("foo", "bar", "value")
586 for k, v := range fc.d {
587 v.Schema = "unknown"
588 fc.d[k] = v
589 }
590
591 var v string
592 So(cfgclient.Get(c, cfgclient.AsService, "foo", "bar", cfgclient.String(&v), nil),
593 ShouldErrLike, `response schema ("unknow n") doesn't match current`)
594 })
595 })
596 })
597 }
598
599 func TestDatastoreCacheFullStack(t *testing.T) {
600 t.Parallel()
601
602 Convey(`Testing full-stack datastore cache`, t, func() {
603 c := memory.Use(context.Background())
604
605 data := map[string]memConfig.ConfigSet{}
606
607 var be backend.B
608 be = &client.Backend{
609 Provider: &testconfig.Provider{
610 Base: memConfig.New(data),
611 },
612 }
613
614 fr := gaeformat.Default()
615 be = &format.Backend{
616 B: be,
617 GetRegistry: func(context.Context) *cfgclient.FormatterR egistry { return fr },
618 }
619
620 fsc := fullStackCache{
621 cache: &Cache,
622 data: data,
623 backend: be,
624 }
625 testDatastoreCacheImpl(c, be, &fsc)
626 })
627 }
OLDNEW
« no previous file with comments | « luci_config/appengine/backend/datastore/ds.go ('k') | luci_config/appengine/gaeconfig/default.go » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698