-
Notifications
You must be signed in to change notification settings - Fork 0
/
store.go
3373 lines (2686 loc) · 94.9 KB
/
store.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// package store holds bookings with arbitrary durations
//
// Operations required by users
// Get information on a policy
// Get information on the availability of a resource in a slot within an interval
// Book a particular slot for a particular time
// Optional extensions
// Find all slots that are free for a particular period?
// Find a random slot that can fulfil a particular request
// Present an aggregate availability for a set of slots
// Let consumer of this package, e.g. the API, define some types that contain both the description and the contents
// of the entities, if required - not much point doing it here because the openAPI generator will create its own
// types anyway.
package store
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/practable/book/internal/check"
"github.com/practable/book/internal/deny"
"github.com/practable/book/internal/diary"
"github.com/practable/book/internal/filter"
"github.com/practable/book/internal/interval"
"github.com/rs/xid"
log "github.com/sirupsen/logrus"
)
// Activity represents connection details for a live booking
type Activity struct {
BookingID string `json:"booking_id" yaml:"booking_id"`
Description Description `json:"description" yaml:"description"`
ConfigURL string `json:"config_url,omitempty" yaml:"config_url,omitempty"`
Streams map[string]Stream `json:"streams" yaml:"streams"`
UIs []UIDescribed `json:"ui" yaml:"ui"`
NotBefore time.Time `json:"nbf" yaml:"nbf"`
ExpiresAt time.Time `json:"exp" yaml:"exp"`
}
// Booking represents a promise to access an equipment that
// provided by the pool referenced in the resource of the slot
type Booking struct {
// Cancelled indicates if booking cancelled
Cancelled bool `json:"cancelled" yaml:"cancelled"`
// CancelledAt represents when the booking was cancelled
CancelledAt time.Time `json:"cancelled_at" yaml:"cancelled_at"`
// CancelledBy indicates who cancelled e.g. auto-grace-period, admin or user
CancelledBy string `json:"cancelled_by" yaml:"cancelled_by"`
// Group
Group string `json:"group" yaml:"group"`
// booking unique reference
Name string `json:"name" yaml:"name"`
// reference to policy it was booked under
Policy string `json:"policy" yaml:"policy"`
// slot name
Slot string `json:"slot" yaml:"slot"`
Started bool `json:"started" yaml:"started"`
//StartedAt is for reporting purposes, do not use to calculate usage
StartedAt string `json:"started_at" yaml:"started_at"`
//when the resource was unavailable
Unfulfilled bool `json:"unfulfilled" yaml:"unfulfilled"`
// User represents user's name
User string `json:"user" yaml:"user"`
// UsageCharged represents how much usage was charged
// This is updated on cancellation and is for convenience of admin looking at exports/reports
// Replace(Old)Bookings should calculate the usage to be charged based on the policy
// That avoids those editing bookings to upload from performing this calculation manually
UsageCharged time.Duration `json:"usage_charged" yaml:"usage_charged"`
When interval.Interval `json:"when" yaml:"when"`
}
// Description represents information to display to a user about an entity
type Description struct {
Name string `json:"name" yaml:"name"`
Type string `json:"type" yaml:"type"`
Short string `json:"short" yaml:"short"`
Long string `json:"long,omitempty" yaml:"long,omitempty"`
Further string `json:"further,omitempty" yaml:"further,omitempty"`
Thumb string `json:"thumb,omitempty" yaml:"thumb,omitempty"`
Image string `json:"image,omitempty" yaml:"image,omitempty"`
}
// DisplayGuide represents guidance to the booking app on what length slots
// to offer, how many, and how far in the future. This is to allow course staff
// to influence the offerings to students in a way that might better suit their
// teaching views.
// remember to update UnmarshalJSON if adding fields
type DisplayGuide struct {
BookAhead time.Duration `json:"book_ahead" yaml:"book_ahead"`
Duration time.Duration `json:"duration" yaml:"duration"`
Label string `json:"label" yaml:"label"`
MaxSlots int `json:"max_slots" yaml:"max_slots"`
}
// Group represents a list of policies for ease of sharing multiple policies with users
// and being able to change the policies that are supplied to a user without having to
// update all the links the user has (important if user is a course organiser on a large course!)
type Group struct {
Description string `json:"description" yaml:"description"`
Policies []string `json:"policies" yaml:"policies"`
}
// GroupDescribed includes the description to save some overhead, since it will always be
// requested by the user with the description included.
type GroupDescribed struct {
Description Description `json:"description" yaml:"description"`
// keep track of the description reference, needed for manifest export
DescriptionReference string `json:"-" yaml:"-"`
Policies []string `json:"policies" yaml:"policies"`
}
// Manifest represents all the available equipment and how to access it
// Slots are the primary entities, so reference checking starts with them
type Manifest struct {
Descriptions map[string]Description `json:"descriptions" yaml:"descriptions"`
DisplayGuides map[string]DisplayGuide `json:"display_guides" yaml:"display_guides"`
Groups map[string]Group `json:"groups" yaml:"groups"`
Policies map[string]Policy `json:"policies" yaml:"policies"`
Resources map[string]Resource `json:"resources" yaml:"resources"`
Slots map[string]Slot `json:"slots" yaml:"slots"`
Streams map[string]Stream `json:"streams" yaml:"streams"`
UIs map[string]UI `json:"uis" yaml:"uis"`
UISets map[string]UISet `json:"ui_sets" yaml:"ui_sets"`
Windows map[string]Window `json:"windows" yaml:"windows"`
}
// Policy represents what a user can book, and any limits on bookings/usage
// Unmarshaling of time.Duration works in yaml.v3, https://play.golang.org/p/-6y0zq96gVz"
// remember to update UnmarshalJSON if adding fields
type Policy struct {
// AllowStartInPastWithin gives some latitude to accept a booking starting now that gets delayed on the way to the server. A bookng at minimum acceptable duration will be reduced to as much as this duration, so that there is no need to include logic about how to handle a shift in the end time. Typically values might be 10s or 1m.
AllowStartInPastWithin time.Duration `json:"allow_start_in_past_within" yaml:"allow_start_in_past_within"`
//booking must finish within the book_ahead duration, if enforced
BookAhead time.Duration `json:"book_ahead" yaml:"book_ahead"`
Description string `json:"description" yaml:"description"`
DisplayGuides []string `json:"display_guides" yaml:"display_guides"`
// In the manifest, we will refer to display guides by reference
// For users, we want to send policy descriptions that are complete
// so store a local copy of the displayguides to ease the process of fulfilling GET policy_name requests
// but don't include this local copy of information in any manifests
DisplayGuidesMap map[string]DisplayGuide `json:"-" yaml:"-"` //local copy so that exported policies are complete but exclude from json/yaml so not duplicated in manifests
// EnforceAllowStartInPast lets a request starting before now (e.g. due to delayed communication of request) be accepted if other policies are still met.
EnforceAllowStartInPast bool `json:"enforce_allow_start_in_past" yaml:"enforce_allow_start_in_past"`
EnforceBookAhead bool `json:"enforce_book_ahead" yaml:"enforce_book_ahead"`
EnforceGracePeriod bool `json:"enforce_grace_period" yaml:"enforce_grace_period"`
EnforceMaxBookings bool `json:"enforce_max_bookings" yaml:"enforce_max_bookings"`
EnforceMaxDuration bool `json:"enforce_max_duration" yaml:"enforce_max_duration"`
EnforceMinDuration bool `json:"enforce_min_duration" yaml:"enforce_min_duration"`
EnforceMaxUsage bool `json:"enforce_max_usage" yaml:"enforce_max_usage"`
EnforceNextAvailable bool `json:"enforce_next_available" yaml:"enforce_next_available"`
EnforceStartsWithin bool `json:"enforce_starts_within" yaml:"enforce_starts_within"`
//EnforceUnlimitedUsers if true, bookings are not checked, and the token is granted if otherwise within policy. This supports hardware-less simulations to be
// included without needing to specify multiple slots. We don't set a finite limit here to avoid having to track multiple overlapping bookings when usually simulations
// run entirely in client-side code - if a simulation has a resource limit e.g. due to using some central heavyweight server to crunch data, then slots should be specified
// same as for hardware, and this option left as false.
EnforceUnlimitedUsers bool `json:"enforce_unlimited_users" yaml:"enforce_unlimited_users"`
// GracePeriod is how long after When.Start that the booking will be kept
GracePeriod time.Duration `json:"grace_period" yaml:"grace_period"`
// GracePenalty represents the time lost to finding a new user after auto-cancellation
GracePenalty time.Duration `json:"grace_penalty" yaml:"grace_penalty"`
MaxBookings int64 `json:"max_bookings" yaml:"max_bookings"`
MaxDuration time.Duration `json:"max_duration" yaml:"max_duration"`
MinDuration time.Duration `json:"min_duration" yaml:"min_duration"`
MaxUsage time.Duration `json:"max_usage" yaml:"max_usage"`
// NextAvailable allows for a small gap in bookings to give some flex in case the availability windows are presented with reduced resolution at some point in the system
// i.e. set to 2min to allow a request that is rounded up to start at the next minute after the last booking ends, instead of expecting ms precision from everyone
// Leaving this to default to zero requires the booking UI to return the exact figure given in the availability list, which probably works for now but might not later when other developers
// working on other features maybe don't realise how strict the calculation is without this allowance, or we change the precision somewhere in the system for human-readability and lose the
// exact value that the system would expect due to loss of precision - resulting in a rejected booking that is otherwise within the spirit of the policy.
// Also, some use cases might actually let this be say 15min or 30min - we can't predict the use cases, but can expect them to vary within the same booking system,
// so don't make this a system-wide parameter.
NextAvailable time.Duration `json:"next_available" yaml:"next_available"`
Slots []string `json:"slots" yaml:"slots"`
SlotMap map[string]bool `json:"-" yaml:"-"` // internal usage, do not populate from file
// booking must start within this duration from now, if enforced
StartsWithin time.Duration `json:"starts_within" yaml:"starts_within"`
}
type PolicyStatus struct {
CurrentBookings int64 `json:"current_bookings" yaml:"current_bookings"`
OldBookings int64 `json:"old_bookings" yaml:"old_bookings"`
Usage time.Duration `json:"usage" yaml:"usage"`
}
// Resource represents a physical entity that can be booked
type Resource struct {
// ConfigURL represents a hardware configuration file URL
// that may be useful to a UI
ConfigURL string `json:"config_url,omitempty" yaml:"config_url,omitempty"`
// Description is a reference to a named description of the resource
// that will probably only be shown on admin dashboards (not to students)
Description string `json:"description" yaml:"description"`
// Diary is held in memory, not in the manifest, so don't unmarshall it.
Diary *diary.Diary `json:"-" yaml:"-"`
// Streams is a list of stream types used by this resource, e.g. data, video, logging
// We autogenerate the full stream details needed by the UI when making a live activity,
// using a rule to generate the topic and filling in the other details from the stream prototype
// Streams are required because sims would still use logging, and if not
// just add a dummy stream called null so that we have a check on streams
// being included for the main use case.
Streams []string `json:"streams" yaml:"streams"`
Tests []string `json:"tests" yaml:"tests"`
//TopicStub is the name that should be used to make the topic <TopicStub>-<for>
TopicStub string `json:"topic_stub" yaml:"topic_stub"`
}
// use separate description from resource, because UISet
// All of the strings, except Name, are references to other entries
// but we can do our own consistency checking rather
// than having to replace the yaml unmarshal process
// if we used pointers and big structs as before
type Slot struct {
Description string `json:"description" yaml:"description"`
Policy string `json:"policy" yaml:"policy"`
Resource string `json:"resource" yaml:"resource"`
UISet string `json:"ui_set" yaml:"ui_set"`
Window string `json:"window" yaml:"window"`
}
// Store represents entities required to make bookings, including resources, slots, descriptions, users, policies, and bookings
// any maps to values are data that are not mutated except when the manifest is replaced so do not need to be maps to pointers
type Store struct {
*sync.RWMutex `json:"-"`
// Checker does grace checking on bookings
Checker *check.Checker
// Bookings represents all the live bookings, indexed by booking id
Bookings map[string]*Booking
denyClient *deny.Client
denyRequests chan deny.Request
// Descriptions represents all the descriptions of various entities, indexed by description name
Descriptions map[string]Description
DisableCancelAfterUse bool
DisplayGuides map[string]DisplayGuide
// Filters are how the windows are checked, mapped by window name (populated after loading window info from manifest)
Filters map[string]*filter.Filter
// Groups represent groups of policies - we bake in the description to reduce overhead on this common operation
Groups map[string]GroupDescribed
// Locked is true when we want to stop making bookings or getting info while we do uploads/maintenance
// The API handler has to check this, e.g. if locked, do not make bookings or check availability on
// behalf of users. We can't do this automatically in the methods because then we'd need some sort
// of admin override, to permit maintenance when locked (which is the whole point of locking the system)
// GraceRebound represents how long to wait before checking any bookings that were
// supposed to be checked but the store was locked (see GraceCheck)
GraceRebound time.Duration
Locked bool
// Message represents our message of the day, to send to users (e.g. to explain system is locked)
Message string
// now is a function for getting the time - useful for mocking in test
// to avoid races, we must use a setter and a getter with a mutex
now func() time.Time `json:"-" yaml:"-"`
//useful for admin dashboard - don't need to also parse logs if keep old bookings
// Old Bookings represents the
OldBookings map[string]*Booking
// TimePolicies represents all the TimePolicy(ies) in use
Policies map[string]Policy
// relaySecret holds the secret for the relays (all relays served by a book instance must share the same secret)
// Don't expose secret unnecessarily, so don't include when serialising (not that we currently serialise the store anyway)
relaySecret string `json:"-" yaml:"-"`
// how long to wait when making requests to external API (e.g. for deny)
requestTimeout time.Duration
// Resources represent all the actual physical experiments, indexed by name
Resources map[string]Resource
// Slots represent the combinations of virtual equipments and booking policies that apply to them
Slots map[string]Slot
Streams map[string]Stream
// UIs represents all the user interfaces that are available
UIs map[string]UIDescribed
// UISets represents the lists of user interfaces for particular slots
UISets map[string]UISet
// Users maps all users.
Users map[string]*User
// Window represents allowed and denied time periods for slots
Windows map[string]Window
}
type StoreStatusAdmin struct {
Bookings int64 `json:"bookings" yaml:"bookings"`
Descriptions int64 `json:"descriptions" yaml:"descriptions"`
Filters int64 `json:"filters" yaml:"filters"`
Groups int64 `json:"groups" yaml:"groups"`
Locked bool `json:"locked" yaml:"locked"`
Message string `json:"message" yaml:"message"`
Now time.Time `json:"now" yaml:"now"`
OldBookings int64 `json:"old_bookings" yaml:"old_bookings"`
Policies int64 `json:"policies" yaml:"policies"`
Resources int64 `json:"resources" yaml:"resources"`
Slots int64 `json:"slots" yaml:"slots"`
Streams int64 `json:"streams" yaml:"streams"`
UIs int64 `json:"uis" yaml:"uis"`
UISets int64 `json:"ui_sets" yaml:"ui_sets"`
Users int64 `json:"users" yaml:"users"`
Windows int64 `json:"windows" yaml:"windows"`
}
type StoreStatusUser struct {
Locked bool `json:"locked" yaml:"locked"`
Message string `json:"message" yaml:"message"`
Now time.Time `json:"now" yaml:"now"`
}
// Stream represents a prototype for a type of stream from a relay
// Streams will typically be either data, video, or logging.
// If multiple relay access servers r1, r2 etc are used,just define separate prototypes for
// each type of stream, on each relay, e.g. data-r0, data-r1 etc. Note that in future, a single
// access point will reverse proxy for multiple actual relays, so it's only if there
// are multiple access points that this would be needed.
// Streams are typically accessed via POST with bearer token to an access API
type Stream struct {
Audience string `json:"audience" yaml:"audience"`
// ConnectionType is whether for session or shell e.g. session
ConnectionType string `json:"connection_type" yaml:"connection_type"`
// For is the key in the UI's URL in which the client puts
// the relay (wss) address and code after getting them
// from the relay, e.g. data
For string `json:"for" yaml:"for"`
// Scopes represent what the client can do e.g. read, write
Scopes []string `json:"scopes" yaml:"scopes"`
// Topic is the relay topic, usually <resource name>-<for>. e.g. pend03-data
Topic string `json:"topic" yaml:"topic"`
// URL of the relay access point for this stream e.g. https://relay-access.practable.io
URL string `json:"url" yaml:"url"`
}
// UI represents a UI that can be used with a resource, for a given slot
type UI struct {
Description string `json:"description" yaml:"description"`
// URL with moustache {{key}} templating for stream connections
URL string `json:"url" yaml:"url"`
StreamsRequired []string `json:"streams_required" yaml:"streams_required"`
}
// UIDescribed represents a UI that can be used with a resource, for a given slot
// with a description - for sending to users
type UIDescribed struct {
Description Description `json:"description" yaml:"description"`
// Keep track of the description's name, needed for ExportManifest
DescriptionReference string `json:"-" yaml:"-"`
// URL with moustache {{key}} templating for stream connections
URL string `json:"url" yaml:"url"`
StreamsRequired []string `json:"streams_required" yaml:"streams_required"`
}
// UISet represents UIs that can be used with a slot
type UISet struct {
UIs []string `json:"uis" yaml:"uis"`
}
// User represents bookings and usage information associated with a single user
// remembering policies allows us to direct a user to link to a policy for a course just once, and then have that remembered
// at least until a system restart -> should be logged as a transaction
type User struct {
Bookings map[string]*Booking //map by id for retrieval
OldBookings map[string]*Booking //map by id, for admin dashboards
Groups map[string]bool //map of groups of policies that the user can access
Usage map[string]*time.Duration //map by policy for checking usage
}
type UserExternal struct {
Bookings []string `json:"bookings" yaml:"bookings"`
OldBookings []string `json:"old_bookings" yaml:"old_bookings"`
Groups []string `json:"groups" yaml:"groups"`
//map humanised durations by policy name
Usage map[string]string `json:"usage" yaml:"usage"`
}
// Window represents allowed and denied periods for slots
type Window struct {
Allowed []interval.Interval `json:"allowed" yaml:"allowed"`
Denied []interval.Interval `json:"denied" yaml:"denied"`
}
// New returns an empty store
func New() *Store {
denyClient := deny.New()
return &Store{
&sync.RWMutex{},
check.New().WithNow(func() time.Time { return time.Now() }).WithName("forStore"),
make(map[string]*Booking),
denyClient,
denyClient.Request, //can be overwritten for testing using WithDenyRequests()
make(map[string]Description),
false,
make(map[string]DisplayGuide),
make(map[string]*filter.Filter),
make(map[string]GroupDescribed),
time.Duration(time.Minute),
false,
"Welcome to the interval booking store",
func() time.Time { return time.Now() },
make(map[string]*Booking),
make(map[string]Policy),
"replaceme",
time.Second,
make(map[string]Resource),
make(map[string]Slot),
make(map[string]Stream),
make(map[string]UIDescribed),
make(map[string]UISet),
make(map[string]*User),
make(map[string]Window),
}
}
// for testing purposes, otherwise deny channel set to that of the deny.Client
func (s *Store) WithDenyRequests(d chan deny.Request) *Store {
s.Lock()
defer s.Unlock()
log.Warn("Overriding denyRequests, preventing normal operation - do not use in production")
s.denyRequests = d
return s
}
// WithDisableCancelAfterUse stops users from cancelling bookings they already started using
// this is provided in case external API calls to relay cannot be supported (e.g. due to relay version)
// note all relays need to have the same secret!
func (s *Store) WithDisableCancelAfterUse(d bool) *Store {
s.Lock()
defer s.Unlock()
s.DisableCancelAfterUse = d
return s
}
// WithNow sets the time function
func (s *Store) WithNow(now func() time.Time) *Store {
s.Lock()
defer s.Unlock()
s.now = now
s.Checker.SetNow(now)
s.denyClient.SetNow(now)
return s
}
// SetRelaySecret sets the relay secret
func (s *Store) SetRelaySecret(secret string) *Store {
s.Lock()
defer s.Unlock()
s.relaySecret = secret
s.denyClient.SetSecret(secret)
return s
}
// WithRelaySecret sets the relay secret
func (s *Store) WithRelaySecret(secret string) *Store {
s.Lock()
defer s.Unlock()
s.relaySecret = secret
s.denyClient.SetSecret(secret)
return s
}
// SetRequestTimeout sets how long to wait for external API requests, e.g. deny requests to relay
func (s *Store) SetRequestTimeout(timeout time.Duration) *Store {
s.Lock()
defer s.Unlock()
s.requestTimeout = timeout
s.denyClient.SetTimeout(timeout)
return s
}
// WithRequestTimeout sets how long to wait for external API requests, e.g. deny requests to relay
func (s *Store) WithRequestTimeout(timeout time.Duration) *Store {
s.Lock()
defer s.Unlock()
s.requestTimeout = timeout
s.denyClient.SetTimeout(timeout)
return s
}
// RelaySecret returns the relay secret
// don't use in internal functions because it will hang waiting for lock
// just use s.relaySecret directly in internal functions
func (s *Store) RelaySecret() string {
s.Lock()
defer s.Unlock()
return s.relaySecret
}
// SetNow sets the time function (useful for mocking in tests)
// Alternative named version for readability when updating the time
// function multiple times in a test
func (s *Store) SetNow(now func() time.Time) *Store {
s.Lock()
defer s.Unlock()
s.now = now
s.Checker.SetNow(now)
s.denyClient.SetNow(now)
return s
}
func (s *Store) Now() time.Time {
s.Lock()
defer s.Unlock()
return s.now()
}
func (s *Store) WithGraceRebound(d time.Duration) *Store {
s.Lock()
defer s.Unlock()
s.GraceRebound = d
return s
}
// NewUser returns a pointer to a new User
func NewUser() *User {
return &User{
make(map[string]*Booking),
make(map[string]*Booking),
make(map[string]bool),
make(map[string]*time.Duration),
}
}
// AddGroupForUser adds a group for a user so they can book with it in future
// without having to have the access code to hand
// TODO needs a corresponding DeleteGroupFor
func (s *Store) AddGroupForUser(user, group string) error {
where := "store.AddGroupFor"
log.Trace(where + " awaiting lock")
s.Lock()
log.Trace(where + " has lock")
defer func() {
s.Unlock()
log.Trace(where + " released lock")
}()
_, ok := s.Groups[group]
if !ok {
return errors.New("group " + group + " not found")
}
u, ok := s.Users[user]
if !ok { //create user if does not exist
u = NewUser()
s.Users[user] = u
}
u.Groups[group] = true
// no need to initialise any usage trackers, because (a) policies could change between now and the
// first booking, and (b) the makeBooking function initialises any trackers required at time of booking
s.Users[user] = u
return nil
}
func (s *Store) GenerateUniqueUser() string {
return xid.New().String() //Unicity guaranteed for 16,777,216 (24 bits) unique ids per second and per host/process
// but could be predicted i.e. not cryptographically secure
}
// CancelBooking cancels a booking or returns an error if not found
// Takes a lock - for external usage
func (s *Store) CancelBooking(booking Booking, cancelledBy string) error {
where := "store.CancelBooking"
log.Trace(where + " awaiting lock")
s.Lock()
log.Trace(where + " has lock")
defer func() {
s.Unlock()
log.Trace(where + " released lock")
}()
return s.cancelBooking(booking, cancelledBy)
}
// cancelBooking cancels a booking or returns an error if not found
// does not take a lock, for internal use by functions that handle taking the lock
func (s *Store) cancelBooking(booking Booking, cancelledBy string) error {
// check if booking exists and details are valid (i.e. must confirm booking contents, not just ID)
b, ok := s.Bookings[booking.Name]
if !ok {
return errors.New("not found")
}
// compare the externally relevant fields of the booking (ignore internal boolean fields
// to prevent status changes in the booking preventing cancellation
t1 := Booking{
Name: b.Name,
Policy: b.Policy,
Slot: b.Slot,
User: b.User,
When: b.When,
}
t2 := Booking{
Name: booking.Name,
Policy: booking.Policy,
Slot: booking.Slot,
User: booking.User,
When: booking.When,
}
if t1 != t2 { //spam submission with non-matching details
return errors.New("could not verify booking details")
}
if b.When.End.Before(s.now()) {
return errors.New("cannot cancel booking that has already ended")
}
msg := "cancelling a started booking failed because "
sl, ok := s.Slots[b.Slot]
if !ok { //won't happen unless manifest and bookings out of sync
return errors.New(msg + "slot " + b.Slot + " not found")
}
r, ok := s.Resources[sl.Resource]
if !ok { //won't happen unless manifest and bookings out of sync
return errors.New(msg + "resource " + sl.Resource + " not found")
}
if b.Started {
if s.DisableCancelAfterUse {
return errors.New("cannot cancel booking that has already been used")
}
// Booking has started so we will need to POST a deny request to the relay(s)
// assume a manifest may have more than one relay
// and that therefore even an experiment may have more than one relay
// although that is more of an edge case.
// task: map all the relay urls being used
// slot -> resource -> streams -> url
um := make(map[string]bool) //map of URLs from streams (this de-duplicates urls)
// streams
for _, k := range r.Streams {
st, ok := s.Streams[k]
if !ok { //won't happen unless manifest and bookings out of sync
return errors.New(msg + "stream " + k + " not found")
}
um[st.URL] = true
}
for URL := range um {
if s.denyRequests == nil {
msg = msg + "deny requests channel is nil"
log.WithFields(log.Fields{"user": b.User, "booking": b.Name}).Error(msg)
return errors.New(msg)
}
c := make(chan string)
s.denyRequests <- deny.Request{
Result: c,
URL: strings.TrimPrefix(URL, "http://"), //deny.Client scheme must be http
BookingID: b.Name,
ExpiresAt: b.When.End.Unix(),
}
DONE:
for {
select {
case result, ok := <-c:
if ok && result == "ok" {
// deny request was successful
log.WithFields(log.Fields{"user": b.User, "booking": b.Name}).Info("access cancelled at relay")
break DONE
} else {
msg = msg + " error cancelling access at relay " + result
log.WithFields(log.Fields{"user": b.User, "booking": b.Name}).Error(msg)
return errors.New(msg)
}
case <-time.After(s.requestTimeout):
msg = msg + " timed out cancelling access at relay " + URL
log.WithFields(log.Fields{"user": b.User, "booking": b.Name}).Error(msg)
return errors.New(msg)
}
}
}
// ok to cancel if get to here
}
// delete in the resource
p, ok := s.Policies[booking.Policy]
if !ok {
return errors.New(msg + "could not find policy " + booking.Policy)
}
if !p.EnforceUnlimitedUsers { //if we aren't allowing unlimited users, then we made a resource booking
err := r.Diary.Delete(booking.Name) //so delete that booking to allow others to use the cancelled time
if err != nil {
return errors.New(msg + "could not delete resource booking " + err.Error())
}
}
delete(s.Bookings, b.Name)
b.Cancelled = true
b.CancelledAt = s.now()
b.CancelledBy = cancelledBy
s.OldBookings[b.Name] = b
// adjust usage for user - original usage charge was booking length
originalCharge := b.When.End.Sub(b.When.Start)
p, err := s.getPolicy(b.Policy)
if err != nil {
msg := "cannot cancel booking because cannot get policy: " + err.Error()
log.WithFields(log.Fields{"user": b.User, "booking": b.Name}).Error(msg)
return errors.New(msg)
}
usage, err := calculateUsage(*b, p)
if err != nil {
msg := "cannot cancel booking because cannot calculate usage to refund: " + err.Error()
log.WithFields(log.Fields{"user": b.User, "booking": b.Name}).Error(msg)
return errors.New(msg)
}
refund := originalCharge - usage
// get Usage tracker so we can modify it
u, ok := s.Users[b.User]
if !ok { //might happen if server is restarted, old booking restored but user has not made any new bookings yet
// could be a prompt to create users for restored bookings ....
msg := "cancelled but could not refund usage to unknown user " + b.User
log.WithFields(log.Fields{"user": b.User, "booking": b.Name}).Error(msg)
return errors.New(msg)
}
*u.Usage[b.Policy] = *u.Usage[b.Policy] - refund //refund reduces usage
s.Users[b.User] = u
log.WithFields(log.Fields{"user": b.User, "booking": b.Name}).Info("booking cancelled")
return nil
}
// CheckBooking returns nil error if booking is ok, or an error and a slice of messages describing issues
// doesn't need a mutex, as is a support function
func (s *Store) checkBooking(b Booking) (error, []string) {
msg := []string{}
if b.Name == "" {
msg = append(msg, "missing name")
}
if b.Policy == "" {
msg = append(msg, b.Name+" missing policy")
}
if b.Slot == "" {
msg = append(msg, b.Name+" missing slot")
}
if b.User == "" {
msg = append(msg, b.Name+" missing user")
}
if (b.When == interval.Interval{}) {
msg = append(msg, b.Name+" missing when")
}
if len(msg) > 0 {
return errors.New("missing field"), msg
}
if _, ok := s.Policies[b.Policy]; !ok {
msg = append(msg, b.Name+" policy "+b.Policy+" not found")
}
if _, ok := s.Slots[b.Slot]; !ok {
msg = append(msg, b.Name+" slot "+b.Slot+" not found")
}
// we don't check whether user exists, because we create them as needed
if len(msg) > 0 {
return errors.New("missing references"), msg
}
return nil, []string{}
}
// DeleteGroupFor removes the group from the user's list of allowed groups, and deletes any
// current bookings they have policies that are only accessible to the user via that group
func (s *Store) DeleteGroupFor(user, group string) error {
where := "store.DeleteGroupFor"
log.Trace(where + " awaiting lock")
s.Lock()
log.Trace(where + " has lock")
defer func() {
s.Unlock()
log.Trace(where + " released lock")
}()
u, ok := s.Users[user]
if !ok {
return errors.New("user " + user + " not found")
}
g, ok := s.Groups[group]
if !ok {
return errors.New("group " + group + " not found")
}
delete(u.Groups, group)
s.Users[user] = u
// delete any bookings this user has under this group
// that are not covered by the same policy appearing
// in another group the user has
current := make(map[string]bool)
remove := make(map[string]bool)
for k := range u.Groups {
current[k] = true
}
for _, k := range g.Policies { //remove policies not found in the policies of the remaining groups
if _, ok := current[k]; !ok {
remove[k] = true
}
}
// get bookings so we can check policies in use
bm, err := s.getBookingsFor(user)
if err != nil {
return err
}
for _, v := range bm {
if _, ok := remove[v.Policy]; ok { //remove booking since its policy is in the remove list
err = s.cancelBooking(v, "deletePolicy")
if err != nil {
return err
}
}
}
return nil
}
// ExportBookings returns a map of all current/future bookings
func (s *Store) ExportBookings() map[string]Booking {
where := "store.ExportBookings"
log.Trace(where + " awaiting Rlock")
s.Lock()
log.Trace(where + " has Rlock")
defer func() {
s.Unlock()
log.Trace(where + " released Rlock")
}()
bm := make(map[string]Booking)
for k, v := range s.Bookings {
bm[k] = *v
}
return bm
}
// ExportManifest returns the manifest from the store
func (s *Store) ExportManifest() Manifest {
where := "store.ExportManifest"
log.Trace(where + " awaiting Rlock")
s.Lock()
log.Trace(where + " has Rlock")
defer func() {
s.Unlock()
log.Trace(where + " released Rlock")
}()
// We store the full description in the store for convenience
// but the manifest only has the name of the description in the Group
// as a reference to the description elsewhere in the manifest
// so we restore that format on export by removing all description
// except for the description reference
gm := make(map[string]Group)
for k, v := range s.Groups {
gm[k] = Group{
Description: v.DescriptionReference,
Policies: v.Policies,
}
}
// We store the full description in the store for convenience
// but the manifest only has the name of the description in the UI
// as a reference to the description elsewhere in the manifest
// so we restore that format on export by removing all description
// except for the description reference
uis := make(map[string]UI)
for k, v := range s.UIs {
uis[k] = UI{
Description: v.DescriptionReference,
URL: v.URL,
StreamsRequired: v.StreamsRequired,
}
}
// Resources have diary pointers which we should nullify by omission for security and readability
rm := make(map[string]Resource)
for k, v := range s.Resources {
rm[k] = Resource{
ConfigURL: v.ConfigURL,
Description: v.Description,
Streams: v.Streams,
Tests: v.Tests,
TopicStub: v.TopicStub,
}
}
return Manifest{
Descriptions: s.Descriptions,
DisplayGuides: s.DisplayGuides,
Groups: gm,
Policies: s.Policies,
Resources: rm,
Slots: s.Slots,
Streams: s.Streams,
UIs: uis,
UISets: s.UISets,
Windows: s.Windows,
}
}
// ExportOldBookings returns a map by name of old bookings
func (s *Store) ExportOldBookings() map[string]Booking {
where := "store.ExportOldBookings"
log.Trace(where + " awaiting Rlock")
s.Lock()
log.Trace(where + " has Rlock")
defer func() {
s.Unlock()