chef-core 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,221 @@
1
+ #
2
+ # Copyright:: Copyright (c) 2018 Chef Software Inc.
3
+ # License:: Apache License, Version 2.0
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+
18
+ require "chef_core/target_host"
19
+ require "chef_core/error"
20
+
21
+ module ChefCore
22
+
23
+ # This class will resolve a provided target host name to
24
+ # one or more TargetHost instances. The target host name passed in
25
+ # to the constructor can contain up to two ranges in the form [0-9] and [a-z].
26
+ # Multiple target host names can be passed in asa comma-separated list.
27
+ #
28
+ # Use resolver_inst.targets to get the expanded list of TargetHost instances.
29
+ class TargetResolver
30
+ MAX_EXPANDED_TARGETS = 24
31
+ SUPPORTED_PROTOCOLS = %w{winrm ssh}
32
+ def initialize(target, default_protocol, conn_options, max_expanded_targets: MAX_EXPANDED_TARGETS)
33
+ @max_expanded_targets = max_expanded_targets
34
+ @default_proto = default_protocol
35
+ @unparsed_target = target
36
+ @split_targets = @unparsed_target.split(",")
37
+ @conn_options = conn_options.dup
38
+ @default_password = @conn_options.delete(:password)
39
+ @default_user = @conn_options.delete(:user)
40
+ end
41
+
42
+ # Returns the list of targets as an array of TargetHost instances
43
+ def targets
44
+ return @targets unless @targets.nil?
45
+ expanded_urls = []
46
+ @split_targets.each do |target|
47
+ expand_targets(target).each { |t| expanded_urls << t }
48
+ end
49
+
50
+ # Apply max_expanded_targets to the total list of resolved
51
+ # targets, since multiple targets can be comma-separated.
52
+ # Limiting it only in the expression resolver could still
53
+ # yield more than the maximum.
54
+ if expanded_urls.length > @max_expanded_targets
55
+ raise TooManyTargets.new(expanded_urls.length, @max_expanded_targets)
56
+ end
57
+
58
+ @targets = expanded_urls.map do |url|
59
+ config = @conn_options.merge(config_for_target(url))
60
+ TargetHost.new(config.delete(:url), config)
61
+ end
62
+ end
63
+
64
+ def config_for_target(url)
65
+ prefix, target = prefix_from_target(url)
66
+
67
+ inline_password = nil
68
+ inline_user = nil
69
+ host = target
70
+ # Default greedy-scan of the regex means that
71
+ # $2 will resolve to content after the final "@"
72
+ # URL credentials will take precedence over the default :user
73
+ # in @conn_opts
74
+ if target =~ /(.*)@(.*)/
75
+ inline_credentials = $1
76
+ host = $2
77
+ # We'll use a non-greedy match to grab everthinmg up to the first ':'
78
+ # as username if there is no :, credentials is just the username
79
+ if inline_credentials =~ /(.+?):(.*)/
80
+ inline_user = $1
81
+ inline_password = $2
82
+ else
83
+ inline_user = inline_credentials
84
+ end
85
+ end
86
+ user, password = make_credentials(inline_user, inline_password)
87
+ { url: "#{prefix}#{host}",
88
+ user: user,
89
+ password: password }
90
+ end
91
+
92
+ # Merge the inline user/pass with the default user/pass, giving
93
+ # precedence to inline.
94
+ def make_credentials(inline_user, inline_password)
95
+ user = inline_user || @default_user
96
+ user = nil if user && user.empty?
97
+ password = (inline_password || @default_password)
98
+ password = nil if password && password.empty?
99
+ [user, password]
100
+ end
101
+
102
+ def prefix_from_target(target)
103
+ if target =~ /^(.+?):\/\/(.*)/
104
+ # We'll store the existing prefix to avoid it interfering
105
+ # with the check further below.
106
+ if SUPPORTED_PROTOCOLS.include? $1.downcase
107
+ prefix = "#{$1}://"
108
+ target = $2
109
+ else
110
+ raise UnsupportedProtocol.new($1)
111
+ end
112
+ else
113
+ prefix = "#{@default_proto}://"
114
+ end
115
+ [prefix, target]
116
+ end
117
+
118
+ def expand_targets(target)
119
+ @current_target = target # Hold onto this for error reporting
120
+ do_parse([target.downcase])
121
+ end
122
+
123
+ private
124
+
125
+ # A string matching PREFIX[x:y]POSTFIX:
126
+ # POSTFIX can contain further ranges itself
127
+ # This uses a greedy match (.*) to get include every character
128
+ # up to the last "[" in PREFIX
129
+ # $1 - prefix; $2 - x, $3 - y, $4 unproccessed/remaining text
130
+ TARGET_WITH_RANGE = /^(.*)\[([\p{Alnum}]+):([\p{Alnum}]+)\](.*)/.freeze
131
+
132
+ def do_parse(targets, depth = 0)
133
+ raise TooManyRanges.new(@current_target) if depth > 2
134
+ new_targets = []
135
+ done = false
136
+ targets.each do |target|
137
+ if TARGET_WITH_RANGE =~ target
138
+ # $1 - prefix; $2 - x, $3 - y, $4 unprocessed/remaining text
139
+ expand_range(new_targets, $1, $2, $3, $4)
140
+ else
141
+ # Nothing more to expand
142
+ done = true
143
+ new_targets << target
144
+ end
145
+ end
146
+ if done
147
+ new_targets
148
+ else
149
+ do_parse(new_targets, depth + 1)
150
+ end
151
+ end
152
+
153
+ def expand_range(dest, prefix, start, stop, suffix)
154
+ prefix ||= ""
155
+ suffix ||= ""
156
+ start_is_int = Integer(start) >= 0 rescue false
157
+ stop_is_int = Integer(stop) >= 0 rescue false
158
+
159
+ if (start_is_int && !stop_is_int) || (stop_is_int && !start_is_int)
160
+ raise InvalidRange.new(@current_target, "[#{start}:#{stop}]")
161
+ end
162
+
163
+ # Ensure that a numeric range doesn't get created as a string, which
164
+ # would make the created Range further below fail to iterate for some values
165
+ # because of ASCII sorting.
166
+ if start_is_int
167
+ start = Integer(start)
168
+ end
169
+
170
+ if stop_is_int
171
+ stop = Integer(stop)
172
+ end
173
+
174
+ # For range to iterate correctly, the values must
175
+ # be low,high
176
+ if start > stop
177
+ temp = stop; stop = start; start = temp
178
+ end
179
+ Range.new(start, stop).each do |value|
180
+ # Ranges will resolve only numbers and letters,
181
+ # not other ascii characters that happen to fall between.
182
+ if start_is_int || /^[a-z0-9]/ =~ value
183
+ dest << "#{prefix}#{value}#{suffix}"
184
+ end
185
+ # Stop expanding as soon as we go over limit to prevent
186
+ # making the user wait for a massive accidental expansion
187
+ if dest.length > @max_expanded_targets
188
+ raise TooManyTargets.new(@split_targets.length, @max_expanded_targets)
189
+ end
190
+ end
191
+ end
192
+ # Provide an error base for all targetresolver errors, to simplify
193
+ # handling when caller is not concerned with the specific failure.
194
+ class TargetResolverError < ChefCore::Error; end
195
+
196
+ class InvalidRange < TargetResolverError
197
+ def initialize(unresolved_target, given_range)
198
+ super("CHEFRANGE001", unresolved_target, given_range)
199
+ end
200
+ end
201
+
202
+ class TooManyRanges < TargetResolverError
203
+ def initialize(unresolved_target)
204
+ super("CHEFRANGE002", unresolved_target)
205
+ end
206
+ end
207
+
208
+ class TooManyTargets < TargetResolverError
209
+ def initialize(num_top_level_targets, max_targets)
210
+ super("CHEFRANGE003", num_top_level_targets, max_targets)
211
+ end
212
+ end
213
+
214
+ class UnsupportedProtocol < TargetResolverError
215
+ def initialize(attempted_protocol)
216
+ super("CHEFVAL011", attempted_protocol,
217
+ ChefCore::TargetResolver::SUPPORTED_PROTOCOLS.join(" "))
218
+ end
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,32 @@
1
+ #
2
+ # Copyright:: Copyright (c) 2018 Chef Software Inc.
3
+ # License:: Apache License, Version 2.0
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+
18
+ class Telemetry
19
+ class Session
20
+ # The telemetry session data is normally kept in .chef, which we don't have.
21
+ def session_file
22
+ ChefCore::Config.telemetry_session_file.freeze
23
+ end
24
+ end
25
+
26
+ def deliver(data = {})
27
+ if ChefCore::Telemeter.instance.enabled?
28
+ payload = event.prepare(data)
29
+ client.await.fire(payload)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,123 @@
1
+ #
2
+ # Copyright:: Copyright (c) 2018 Chef Software Inc.
3
+ # License:: Apache License, Version 2.0
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+
18
+ require "telemetry"
19
+ require "chef_core/telemeter"
20
+ require "chef_core/telemeter/patch"
21
+ require "chef_core/log"
22
+ require "chef_core/version"
23
+
24
+ module ChefCore
25
+ class Telemeter
26
+ class Sender
27
+ attr_reader :session_files, :config
28
+
29
+ def self.start_upload_thread(config)
30
+ # Find the files before we spawn the thread - otherwise
31
+ # we may accidentally pick up the current run's session file if it
32
+ # finishes before the thread scans for new files
33
+ session_files = Sender.find_session_files(config)
34
+ sender = Sender.new(session_files, config)
35
+ Thread.new { sender.run }
36
+ end
37
+
38
+ def self.find_session_files(config)
39
+ ChefCore::Log.info("Looking for telemetry data to submit")
40
+ session_search = File.join(config[:payload_dir], "telemetry-payload-*.yml")
41
+ session_files = Dir.glob(session_search)
42
+ ChefCore::Log.info("Found #{session_files.length} sessions to submit")
43
+ session_files
44
+ end
45
+
46
+ def initialize(session_files, config)
47
+ @session_files = session_files
48
+ @config = config
49
+ end
50
+
51
+ def run
52
+ if ChefCore::Telemeter.enabled?
53
+ ChefCore::Log.info("Telemetry enabled, beginning upload of previous session(s)")
54
+ # dev mode telemetry gets sent to a different location
55
+
56
+ if config[:dev_mode]
57
+ ENV["CHEF_TELEMETRY_ENDPOINT"] ||= "https://telemetry-acceptance.chef.io"
58
+ end
59
+ session_files.each { |path| process_session(path) }
60
+ else
61
+ # If telemetry is not enabled, just clean up and return. Even though
62
+ # the telemetry gem will not send if disabled, log output saying that we're submitting
63
+ # it when it has been disabled can be alarming.
64
+ ChefCore::Log.info("Telemetry disabled, clearing any existing session captures without sending them.")
65
+ session_files.each { |path| FileUtils.rm_rf(path) }
66
+ end
67
+ FileUtils.rm_rf(config[:session_file])
68
+ ChefCore::Log.info("Terminating, nothing more to do.")
69
+ rescue => e
70
+ ChefCore::Log.fatal "Sender thread aborted: '#{e}' failed at #{e.backtrace[0]}"
71
+ end
72
+
73
+ def process_session(path)
74
+ ChefCore::Log.info("Processing telemetry entries from #{path}")
75
+ content = load_and_clear_session(path)
76
+ submit_session(content)
77
+ end
78
+
79
+ def submit_session(content)
80
+ # Each file contains the actions taken within a single run of the chef tool.
81
+ # Each run is one session, so we'll first remove remove the session file
82
+ # to force creating a new one.
83
+ FileUtils.rm_rf(config[:session_file])
84
+ # We'll use the version captured in the sesion file
85
+ entries = content["entries"]
86
+ cli_version = content["version"]
87
+ total = entries.length
88
+ telemetry = Telemetry.new(product: "chef-workstation",
89
+ origin: "command-line",
90
+ product_version: cli_version,
91
+ install_context: "omnibus")
92
+ total = entries.length
93
+ entries.each_with_index do |entry, x|
94
+ submit_entry(telemetry, entry, x + 1, total)
95
+ end
96
+ end
97
+
98
+ def submit_entry(telemetry, entry, sequence, total)
99
+ ChefCore::Log.info("Submitting telemetry entry #{sequence}/#{total}: #{entry} ")
100
+ telemetry.deliver(entry)
101
+ ChefCore::Log.info("Entry #{sequence}/#{total} submitted.")
102
+ rescue => e
103
+ # No error handling in telemetry lib, so at least track the failrue
104
+ ChefCore::Log.error("Failed to send entry #{sequence}/#{total}: #{e}")
105
+ ChefCore::Log.error("Backtrace: #{e.backtrace} ")
106
+ end
107
+
108
+ private
109
+
110
+ def load_and_clear_session(path)
111
+ content = File.read(path)
112
+ # We'll remove it now instead of after we parse or submit it -
113
+ # if we fail to deliver, we don't want to be stuck resubmitting it if the problem
114
+ # was due to payload. This is a trade-off - if we get a transient error, the
115
+ # payload will be lost.
116
+ # TODO: Improve error handling so we can intelligently decide whether to
117
+ # retry a failed load or failed submit.
118
+ FileUtils.rm_rf(path)
119
+ YAML.load(content)
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,157 @@
1
+ #
2
+ # Copyright:: Copyright (c) 2018 Chef Software Inc.
3
+ # License:: Apache License, Version 2.0
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+
18
+ require "benchmark"
19
+ require "forwardable"
20
+ require "singleton"
21
+ require "json"
22
+ require "digest/sha1"
23
+ require "securerandom"
24
+ require "yaml"
25
+
26
+ module ChefCore
27
+
28
+ # This definites the Telemeter interface. Implementation thoughts for
29
+ # when we unstub it:
30
+ # - let's track the call sequence; most of our calls will be nested inside
31
+ # a main 'timed_capture', and it would be good to see ordering within nested calls.
32
+ class Telemeter
33
+ include Singleton
34
+ DEFAULT_INSTALLATION_GUID = "00000000-0000-0000-0000-000000000000".freeze
35
+
36
+ class << self
37
+ extend Forwardable
38
+ def_delegators :instance, :setup, :timed_capture, :capture, :commit, :timed_run_capture
39
+ def_delegators :instance, :pending_event_count, :last_event, :enabled?
40
+ def_delegators :instance, :make_event_payload, :config
41
+ end
42
+
43
+ attr_reader :events_to_send, :run_timestamp, :config
44
+
45
+ def setup(config)
46
+ # TODO validate required & correct keys
47
+ # :payload_dir #required
48
+ # :session_file # required
49
+ # :installation_identifier_file # required
50
+ # :enabled # false, not required
51
+ # :dev_mode # false, not required
52
+ config[:dev_mode] ||= false
53
+ config[:enabled] ||= false
54
+ require "chef_core/telemeter/sender"
55
+ @config = config
56
+ Sender.start_upload_thread(config)
57
+ end
58
+
59
+ def enabled?
60
+ require "telemetry/decision"
61
+ config[:enabled] && !Telemetry::Decision.env_opt_out?
62
+ end
63
+
64
+ def initialize
65
+ @events_to_send = []
66
+ @run_timestamp = Time.now.utc.strftime("%FT%TZ")
67
+ end
68
+
69
+ def timed_run_capture(arguments, &block)
70
+ timed_capture(:run, arguments: arguments, &block)
71
+ end
72
+
73
+ def timed_capture(name, data = {})
74
+ time = Benchmark.measure { yield }
75
+ data[:duration] = time.real
76
+ capture(name, data)
77
+ end
78
+
79
+ def capture(name, data = {})
80
+ # Adding it to the head of the list will ensure that the
81
+ # sequence of events is preserved when we send the final payload
82
+ payload = make_event_payload(name, data)
83
+ @events_to_send.unshift payload
84
+ end
85
+
86
+ def commit
87
+ if enabled?
88
+ session = convert_events_to_session
89
+ write_session(session)
90
+ end
91
+ @events_to_send = []
92
+ end
93
+
94
+ def make_event_payload(name, data)
95
+ {
96
+ event: name,
97
+ properties: {
98
+ installation_id: installation_id,
99
+ run_timestamp: run_timestamp,
100
+ host_platform: host_platform,
101
+ event_data: data,
102
+ },
103
+ }
104
+ end
105
+
106
+ def installation_id
107
+ @installation_id ||=
108
+ begin
109
+ File.read(config[:installation_identifier_file]).chomp
110
+ rescue
111
+ ChefCore::Log.info "could not read #{config[:installation_identifier_file]} - using default id"
112
+ DEFAULT_INSTALLATION_GUID
113
+ end
114
+ end
115
+
116
+ # For testing.
117
+ def pending_event_count
118
+ @events_to_send.length
119
+ end
120
+
121
+ def last_event
122
+ @events_to_send.last
123
+ end
124
+
125
+ private
126
+
127
+ def host_platform
128
+ @host_platform ||= case RUBY_PLATFORM
129
+ when /mswin|mingw|windows/
130
+ "windows"
131
+ else
132
+ RUBY_PLATFORM.split("-")[1]
133
+ end
134
+ end
135
+
136
+ def convert_events_to_session
137
+ YAML.dump({ "version" => ChefCore::VERSION,
138
+ "entries" => @events_to_send })
139
+ end
140
+
141
+ def write_session(session)
142
+ File.write(next_filename, convert_events_to_session)
143
+ end
144
+
145
+ def next_filename
146
+ id = 0
147
+ filename = ""
148
+ loop do
149
+ id += 1
150
+ filename = File.join(config[:payload_dir], "telemetry-payload-#{id}.yml")
151
+ break unless File.exist?(filename)
152
+ end
153
+ filename
154
+ end
155
+
156
+ end
157
+ end
@@ -0,0 +1,82 @@
1
+ #
2
+ # Copyright:: Copyright (c) 2017 Chef Software Inc.
3
+ # License:: Apache License, Version 2.0
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+
18
+ module ChefCore
19
+ module Text
20
+ # Represents an error loaded from translation, with
21
+ # display attributes set.
22
+ class ErrorTranslation
23
+ ATTRIBUTES = [:decorations, :header, :footer, :stack, :log].freeze
24
+ attr_reader :message, *ATTRIBUTES
25
+
26
+ def initialize(id, params: [])
27
+ error_translation = error_translation_for_id(id)
28
+
29
+ options = YAML.load(Text.errors.display_defaults, "display_defaults",
30
+ symbolize_names: true)
31
+
32
+ # Display metadata is a string containing a YAML hash that is optionally under
33
+ # the error's 'options' attribute
34
+ # Note that we couldn't use :display, as that conflicts with a method on Object.
35
+ display_opts = if error_translation.methods.include?(:options)
36
+ YAML.load(error_translation.options, @id, symbolize_names: true)
37
+ else
38
+ {}
39
+ end
40
+
41
+ options = options.merge(display_opts) unless display_opts.nil?
42
+
43
+ @message = error_translation.text(*params)
44
+
45
+ ATTRIBUTES.each do |attribute|
46
+ instance_variable_set("@#{attribute}", options.delete(attribute))
47
+ end
48
+
49
+ if options.length > 0
50
+ # Anything not in ATTRIBUTES is not supported. This will also catch
51
+ # typos in attr names
52
+ raise InvalidDisplayAttributes.new(id, options)
53
+ end
54
+ end
55
+
56
+ def inspect
57
+ inspection = "#{self}: "
58
+ ATTRIBUTES.each do |attribute|
59
+ inspection << "#{attribute}: #{send(attribute.to_s)}; "
60
+ end
61
+ inspection << "message: #{message.gsub("\n", "\\n")}"
62
+ inspection
63
+ end
64
+
65
+ private
66
+
67
+ # This is split out for simplified unit testing of error formatting.
68
+ def error_translation_for_id(id)
69
+ Text.errors.send(id)
70
+ end
71
+
72
+ class InvalidDisplayAttributes < RuntimeError
73
+ attr_reader :invalid_attrs
74
+ def initialize(id, attrs)
75
+ @invalid_attrs = attrs
76
+ super("Invalid display attributes found for #{id}: #{attrs}")
77
+ end
78
+ end
79
+
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,87 @@
1
+ #
2
+ # Copyright:: Copyright (c) 2017 Chef Software Inc.
3
+ # License:: Apache License, Version 2.0
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+
18
+ module ChefCore
19
+ module Text
20
+ # TextWrapper is a wrapper around R18n that returns all resolved values
21
+ # as Strings, and raise an error when a given i18n key is not found.
22
+ #
23
+ # This simplifies behaviors when interfacing with other libraries/components
24
+ # that don't play nicely with TranslatedString or Untranslated out of R18n.
25
+ class TextWrapper
26
+ def initialize(translation_tree)
27
+ @tree = translation_tree
28
+ @tree.translation_keys.each do |k|
29
+ # Integer keys are not translatable - they're quantity indicators in the key that
30
+ # are instead sent as arguments. If we see one here, it means it was not correctly
31
+ # labeled as plural with !!pl in the parent key
32
+ if k.class == Integer
33
+ raise MissingPlural.new(@tree.instance_variable_get(:@path), k)
34
+ end
35
+ k = k.to_sym
36
+ define_singleton_method k do |*args|
37
+ subtree = @tree.send(k, *args)
38
+ if subtree.methods.include?(:translation_keys) && !subtree.translation_keys.empty?
39
+ # If there are no more possible children, just return the translated value
40
+ TextWrapper.new(subtree)
41
+ else
42
+ subtree.to_s
43
+ end
44
+ end
45
+ end
46
+ end
47
+
48
+ def method_missing(name, *args)
49
+ raise InvalidKey.new(@tree.instance_variable_get(:@path), name)
50
+ end
51
+
52
+ # TODO - make the checks for these conditions lint steps that run during build
53
+ # instead of part of the shipped product.
54
+ class TextError < RuntimeError
55
+ attr_accessor :line
56
+ def set_call_context
57
+ # TODO - this can vary (8 isn't always right) - inspect
58
+ @line = caller(8, 1).first
59
+ if @line =~ /.*\/lib\/(.*\.rb):(\d+)/
60
+ @line = "File: #{$1} Line: #{$2}"
61
+ end
62
+ end
63
+ end
64
+
65
+ class InvalidKey < TextError
66
+ def initialize(path, terminus)
67
+ set_call_context
68
+ # Calling back into Text here seems icky, this is an error
69
+ # that only engineering should see.
70
+ message = "i18n key #{path}.#{terminus} does not exist.\n"
71
+ message << " Referenced from #{line}"
72
+ super(message)
73
+ end
74
+ end
75
+
76
+ class MissingPlural < TextError
77
+ def initialize(path, terminus)
78
+ set_call_context
79
+ message = "i18n key #{path}.#{terminus} appears to reference a pluralization.\n"
80
+ message << " Please append the plural indicator '!!pl' to the end of #{path}.\n"
81
+ message << " Referenced from #{line}"
82
+ super(message)
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end