Merge commit '3b735dd9330e0989f51a76771c3303031154154e' into 21158-wf-page-list
[arvados.git] / services / api / lib / config_loader.rb
1 # Copyright (C) The Arvados Authors. All rights reserved.
2 #
3 # SPDX-License-Identifier: AGPL-3.0
4
5 # When loading YAML, deserialize :foo as ":foo", rather than raising
6 # "Psych::DisallowedClass: Tried to load unspecified class: Symbol"
7 class Psych::ScalarScanner
8   alias :orig_tokenize :tokenize
9   def tokenize string
10     return string if string =~ /^:[a-zA-Z]/
11     orig_tokenize(string)
12   end
13 end
14
15 module Psych
16   module Visitors
17     class YAMLTree < Psych::Visitors::Visitor
18       def visit_ActiveSupport_Duration o
19         seconds = o.to_i
20         outstr = ""
21         if seconds / 3600 > 0
22           outstr += "#{seconds / 3600}h"
23           seconds = seconds % 3600
24         end
25         if seconds / 60 > 0
26           outstr += "#{seconds / 60}m"
27           seconds = seconds % 60
28         end
29         if seconds > 0
30           outstr += "#{seconds}s"
31         end
32         if outstr == ""
33           outstr = "0s"
34         end
35         @emitter.scalar outstr, nil, nil, true, false, Nodes::Scalar::ANY
36       end
37
38       def visit_URI_Generic o
39         @emitter.scalar o.to_s, nil, nil, true, false, Nodes::Scalar::ANY
40       end
41
42       def visit_URI_HTTP o
43         @emitter.scalar o.to_s, nil, nil, true, false, Nodes::Scalar::ANY
44       end
45
46       def visit_Pathname o
47         @emitter.scalar o.to_s, nil, nil, true, false, Nodes::Scalar::ANY
48       end
49     end
50   end
51 end
52
53
54 module Boolean; end
55 class TrueClass; include Boolean; end
56 class FalseClass; include Boolean; end
57
58 class NonemptyString < String
59 end
60
61 class ConfigLoader
62   def initialize
63     @config_migrate_map = {}
64     @config_types = {}
65   end
66
67   def declare_config(assign_to, configtype, migrate_from=nil, migrate_fn=nil)
68     if migrate_from
69       @config_migrate_map[migrate_from] = migrate_fn || ->(cfg, k, v) {
70         ConfigLoader.set_cfg cfg, assign_to, v
71       }
72     end
73     @config_types[assign_to] = configtype
74   end
75
76
77   def migrate_config from_config, to_config
78     remainders = {}
79     from_config.each do |k, v|
80       if @config_migrate_map[k.to_sym]
81         @config_migrate_map[k.to_sym].call to_config, k, v
82       else
83         remainders[k] = v
84       end
85     end
86     remainders
87   end
88
89   def coercion_and_check check_cfg, check_nonempty: true
90     @config_types.each do |cfgkey, cfgtype|
91       cfg = check_cfg
92       k = cfgkey
93       ks = k.split '.'
94       k = ks.pop
95       ks.each do |kk|
96         cfg = cfg[kk]
97         if cfg.nil?
98           break
99         end
100       end
101
102       if cfg.nil?
103         raise "missing #{cfgkey}"
104       end
105
106       if cfgtype == String and !cfg[k]
107         cfg[k] = ""
108       end
109
110       if cfgtype == String and cfg[k].is_a? Symbol
111         cfg[k] = cfg[k].to_s
112       end
113
114       if cfgtype == Pathname and cfg[k].is_a? String
115
116         if cfg[k] == ""
117           cfg[k] = Pathname.new("")
118         else
119           cfg[k] = Pathname.new(cfg[k])
120           if !cfg[k].exist?
121             raise "#{cfgkey} path #{cfg[k]} does not exist"
122           end
123         end
124       end
125
126       if cfgtype == NonemptyString
127         if (!cfg[k] || cfg[k] == "") && check_nonempty
128           raise "#{cfgkey} cannot be empty"
129         end
130         if cfg[k].is_a? String
131           next
132         end
133       end
134
135       if cfgtype == ActiveSupport::Duration
136         if cfg[k].is_a? Integer
137           cfg[k] = cfg[k].seconds
138         elsif cfg[k].is_a? String
139           cfg[k] = ConfigLoader.parse_duration(cfg[k], cfgkey: cfgkey)
140         end
141       end
142
143       if cfgtype == URI
144         cfg[k] = URI(cfg[k])
145       end
146
147       if cfgtype == Integer && cfg[k].is_a?(String)
148         v = cfg[k].sub(/B\s*$/, '')
149         if mt = /(-?\d*\.?\d+)\s*([KMGTPE]i?)$/.match(v)
150           if mt[1].index('.')
151             v = mt[1].to_f
152           else
153             v = mt[1].to_i
154           end
155           cfg[k] = v * {
156             'K' => 1000,
157             'Ki' => 1 << 10,
158             'M' => 1000000,
159             'Mi' => 1 << 20,
160             "G" =>  1000000000,
161             "Gi" => 1 << 30,
162             "T" =>  1000000000000,
163             "Ti" => 1 << 40,
164             "P" =>  1000000000000000,
165             "Pi" => 1 << 50,
166             "E" =>  1000000000000000000,
167             "Ei" => 1 << 60,
168           }[mt[2]]
169         end
170       end
171
172       if !cfg[k].is_a? cfgtype
173         raise "#{cfgkey} expected #{cfgtype} but was #{cfg[k].class}"
174       end
175     end
176   end
177
178   def self.set_cfg cfg, k, v
179     # "foo.bar = baz" --> { cfg["foo"]["bar"] = baz }
180     ks = k.split '.'
181     k = ks.pop
182     ks.each do |kk|
183       cfg = cfg[kk]
184       if cfg.nil?
185         break
186       end
187     end
188     if !cfg.nil?
189       cfg[k] = v
190     end
191   end
192
193   def self.parse_duration(durstr, cfgkey:)
194     sign = 1
195     if durstr[0] == '-'
196       durstr = durstr[1..-1]
197       sign = -1
198     end
199     duration_re = /(\d+(\.\d+)?)(s|m|h)/
200     dursec = 0
201     while durstr != ""
202       mt = duration_re.match durstr
203       if !mt
204         raise "#{cfgkey} not a valid duration: '#{durstr}', accepted suffixes are s, m, h"
205       end
206       multiplier = {s: 1, m: 60, h: 3600}
207       dursec += (Float(mt[1]) * multiplier[mt[3].to_sym] * sign)
208       durstr = durstr[mt[0].length..-1]
209     end
210     return dursec.seconds
211   end
212
213   def self.copy_into_config src, dst
214     src.each do |k, v|
215       dst.send "#{k}=", self.to_OrderedOptions(v)
216     end
217   end
218
219   def self.to_OrderedOptions confs
220     if confs.is_a? Hash
221       opts = ActiveSupport::OrderedOptions.new
222       confs.each do |k,v|
223         opts[k] = self.to_OrderedOptions(v)
224       end
225       opts
226     elsif confs.is_a? Array
227       confs.map { |v| self.to_OrderedOptions v }
228     else
229       confs
230     end
231   end
232
233   def self.load path, erb: false
234     if File.exist? path
235       yaml = IO.read path
236       if erb
237         yaml = ERB.new(yaml).result(binding)
238       end
239       YAML.safe_load(yaml)
240     else
241       {}
242     end
243   end
244
245 end