offroad 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.
Files changed (62) hide show
  1. data/LICENSE +674 -0
  2. data/README.rdoc +29 -0
  3. data/Rakefile +75 -0
  4. data/TODO +42 -0
  5. data/lib/app/models/offroad/group_state.rb +85 -0
  6. data/lib/app/models/offroad/mirror_info.rb +53 -0
  7. data/lib/app/models/offroad/model_state.rb +36 -0
  8. data/lib/app/models/offroad/received_record_state.rb +109 -0
  9. data/lib/app/models/offroad/sendable_record_state.rb +91 -0
  10. data/lib/app/models/offroad/system_state.rb +33 -0
  11. data/lib/cargo_streamer.rb +222 -0
  12. data/lib/controller_extensions.rb +74 -0
  13. data/lib/exceptions.rb +16 -0
  14. data/lib/migrate/20100512164608_create_offroad_tables.rb +72 -0
  15. data/lib/mirror_data.rb +354 -0
  16. data/lib/model_extensions.rb +377 -0
  17. data/lib/module_funcs.rb +94 -0
  18. data/lib/offroad.rb +30 -0
  19. data/lib/version.rb +3 -0
  20. data/lib/view_helper.rb +7 -0
  21. data/templates/offline.rb +36 -0
  22. data/templates/offline_database.yml +7 -0
  23. data/templates/offroad.yml +6 -0
  24. data/test/app_root/app/controllers/application_controller.rb +2 -0
  25. data/test/app_root/app/controllers/group_controller.rb +28 -0
  26. data/test/app_root/app/models/global_record.rb +10 -0
  27. data/test/app_root/app/models/group.rb +12 -0
  28. data/test/app_root/app/models/group_owned_record.rb +68 -0
  29. data/test/app_root/app/models/guest.rb +7 -0
  30. data/test/app_root/app/models/subrecord.rb +12 -0
  31. data/test/app_root/app/models/unmirrored_record.rb +4 -0
  32. data/test/app_root/app/views/group/download_down_mirror.html.erb +4 -0
  33. data/test/app_root/app/views/group/download_initial_down_mirror.html.erb +4 -0
  34. data/test/app_root/app/views/group/download_up_mirror.html.erb +6 -0
  35. data/test/app_root/app/views/group/upload_down_mirror.html.erb +1 -0
  36. data/test/app_root/app/views/group/upload_up_mirror.html.erb +1 -0
  37. data/test/app_root/app/views/layouts/mirror.html.erb +9 -0
  38. data/test/app_root/config/boot.rb +115 -0
  39. data/test/app_root/config/database.yml +6 -0
  40. data/test/app_root/config/environment.rb +15 -0
  41. data/test/app_root/config/environments/test.rb +17 -0
  42. data/test/app_root/config/offroad.yml +6 -0
  43. data/test/app_root/config/routes.rb +4 -0
  44. data/test/app_root/db/migrate/20100529235049_create_tables.rb +64 -0
  45. data/test/app_root/lib/common_hobo.rb +15 -0
  46. data/test/app_root/vendor/plugins/offroad/init.rb +2 -0
  47. data/test/functional/mirror_operations_test.rb +148 -0
  48. data/test/test_helper.rb +405 -0
  49. data/test/unit/app_state_tracking_test.rb +275 -0
  50. data/test/unit/cargo_streamer_test.rb +332 -0
  51. data/test/unit/global_data_test.rb +102 -0
  52. data/test/unit/group_controller_test.rb +152 -0
  53. data/test/unit/group_data_test.rb +435 -0
  54. data/test/unit/group_single_test.rb +136 -0
  55. data/test/unit/hobo_permissions_test.rb +57 -0
  56. data/test/unit/mirror_data_test.rb +1271 -0
  57. data/test/unit/mirror_info_test.rb +31 -0
  58. data/test/unit/module_funcs_test.rb +37 -0
  59. data/test/unit/pathological_model_test.rb +62 -0
  60. data/test/unit/test_framework_test.rb +86 -0
  61. data/test/unit/unmirrored_data_test.rb +14 -0
  62. metadata +140 -0
@@ -0,0 +1,222 @@
1
+ require 'zlib'
2
+ require 'digest/md5'
3
+
4
+ require 'exceptions'
5
+
6
+ module Offroad
7
+ class CargoStreamerError < DataError
8
+ end
9
+
10
+ private
11
+
12
+ # Class for encoding data to, and extracting data from, specially-formatted HTML comments which are called "cargo sections".
13
+ # Each such section has a name, an md5sum for verification, and some base64-encoded zlib-compressed json data.
14
+ # Multiple cargo sections can have the same name; when the cargo is later read, requests for that name will be yielded each section in turn.
15
+ # The data must always be in the form of arrays of ActiveRecord, or things that walk sufficiently like ActiveRecord
16
+ class CargoStreamer
17
+ # Models which are to be encoded need to have a method safe_to_load_from_cargo_stream? that returns true.
18
+
19
+ # Creates a new CargoStreamer on the given stream, which will be used in the given mode (must be "w" or "r").
20
+ # If the mode is "r", the file is immediately scanned to determine what cargo it contains.
21
+ def initialize(ioh, mode)
22
+ raise CargoStreamerError.new("Invalid mode: must be 'w' or 'r'") unless ["w", "r"].include?(mode)
23
+ @mode = mode
24
+
25
+ if ioh.is_a? String
26
+ raise CargoStreamerError.new("Cannot accept string as ioh in write mode") unless @mode == "r"
27
+ @ioh = StringIO.new(ioh, "r")
28
+ else
29
+ @ioh = ioh
30
+ end
31
+
32
+ scan_for_cargo if @mode == "r"
33
+ end
34
+
35
+ # Writes a cargo section with the given name and value to the IO stream.
36
+ # Options:
37
+ # * :human_readable => true - Before writing the cargo section, writes a comment with human-readable data.
38
+ # * :include => [:assoc, :other_assoc] - Includes these first-level associations in the encoded data
39
+ def write_cargo_section(name, value, options = {})
40
+ raise CargoStreamerError.new("Mode must be 'w' to write cargo data") unless @mode == "w"
41
+ raise CargoStreamerError.new("CargoStreamer section names must be strings") unless name.is_a? String
42
+ raise CargoStreamerError.new("Invalid cargo name '" + name + "'") unless name == clean_for_html_comment(name)
43
+ raise CargoStreamerError.new("Cargo name cannot include newlines") if name.include?("\n")
44
+ raise CargoStreamerError.new("Value must be an array") unless value.is_a? Array
45
+ [:to_xml, :attributes=, :valid?].each do |message|
46
+ unless value.all? { |e| e.respond_to? message }
47
+ raise CargoStreamerError.new("All elements must respond to #{message}")
48
+ end
49
+ end
50
+ unless value.all? { |e| e.class.respond_to?(:safe_to_load_from_cargo_stream?) && e.class.safe_to_load_from_cargo_stream? }
51
+ raise CargoStreamerError.new("All element classes must be models which are safe_to_load_from_cargo_stream")
52
+ end
53
+
54
+ unless options[:skip_validation]
55
+ unless value.all?(&:valid?)
56
+ raise CargoStreamerError.new("All elements must be valid")
57
+ end
58
+ end
59
+
60
+ if options[:human_readable]
61
+ human_data = value.map{ |rec|
62
+ rec.attributes.map{ |k, v| "#{k.to_s.titleize}: #{v.to_s}" }.join("\n")
63
+ }.join("\n\n")
64
+ @ioh.write "<!--\n"
65
+ @ioh.write name.titleize + "\n"
66
+ @ioh.write "\n"
67
+ @ioh.write clean_for_html_comment(human_data) + "\n"
68
+ @ioh.write "-->\n"
69
+ end
70
+
71
+ name = name.chomp
72
+
73
+ assoc_list = options[:include] || []
74
+
75
+ xml = Builder::XmlMarkup.new
76
+ xml_data = "<records>%s</records>" % value.map {
77
+ |r| r.to_xml(
78
+ :skip_instruct => true,
79
+ :skip_types => true,
80
+ :root => "record",
81
+ :indent => 0,
82
+ :include => assoc_list
83
+ ) do |xml|
84
+ xml.cargo_streamer_type r.class.name
85
+ assoc_info = assoc_list.reject{|a| r.send(a) == nil}.map{|a| "#{a.to_s}=#{r.send(a).class.name}"}.join(",")
86
+ xml.cargo_streamer_includes assoc_info
87
+ end
88
+ }.join()
89
+ deflated_data = Zlib::Deflate::deflate(xml_data)
90
+ b64_data = Base64.encode64(deflated_data).chomp
91
+ digest = Digest::MD5::hexdigest(deflated_data).chomp
92
+
93
+ @ioh.write CARGO_BEGIN + "\n"
94
+ @ioh.write name + "\n"
95
+ @ioh.write digest + "\n"
96
+ @ioh.write b64_data + "\n"
97
+ @ioh.write CARGO_END + "\n"
98
+ end
99
+
100
+ # Returns a list of cargo section names available to be read
101
+ def cargo_section_names
102
+ return @cargo_locations.keys
103
+ end
104
+
105
+ # Returns true if cargo with a given name is available
106
+ def has_cargo_named?(name)
107
+ return @cargo_locations.has_key? name
108
+ end
109
+
110
+ # Reads, verifies, decodes, and returns the first cargo section with a given name
111
+ def first_cargo_section(name)
112
+ each_cargo_section(name) do |data|
113
+ return data
114
+ end
115
+ end
116
+
117
+ # Returns the first element from the return value of first_cargo_section
118
+ def first_cargo_element(name)
119
+ arr = first_cargo_section(name)
120
+ return (arr && arr.size > 0) ? arr[0] : nil
121
+ end
122
+
123
+ # Reads, verifies, and decodes each cargo section with a given name, passing each section's decoded data to the block
124
+ def each_cargo_section(name)
125
+ raise CargoStreamerError.new("Mode must be 'r' to read cargo data") unless @mode == "r"
126
+ locations = @cargo_locations[name] or return
127
+ locations.each do |seek_location|
128
+ @ioh.seek(seek_location)
129
+ digest = ""
130
+ encoded_data = ""
131
+ @ioh.each_line do |line|
132
+ line.chomp!
133
+ if line == CARGO_END
134
+ break
135
+ elsif digest == ""
136
+ digest = line
137
+ else
138
+ encoded_data += line
139
+ end
140
+ end
141
+
142
+ yield verify_and_decode_cargo(digest, encoded_data)
143
+ end
144
+ end
145
+
146
+ private
147
+
148
+ def scan_for_cargo
149
+ # Key is cargo section name as String, value is array of seek locations to digests for that section
150
+ @cargo_locations = {}
151
+ @ioh.rewind
152
+
153
+ in_cargo = false
154
+ found_name = false
155
+ @ioh.each_line do |line|
156
+ line.chomp!
157
+ if in_cargo
158
+ if line.include? CARGO_END
159
+ in_cargo = false
160
+ found_name = false
161
+ else
162
+ unless found_name
163
+ @cargo_locations[line] ||= []
164
+ @cargo_locations[line] << @ioh.tell
165
+ found_name = true
166
+ end
167
+ end
168
+ else
169
+ if line.include? CARGO_BEGIN
170
+ in_cargo = true
171
+ end
172
+ end
173
+ end
174
+ raise CargoStreamerError.new("Input contained un-terminated cargo section") unless in_cargo == false
175
+
176
+ @ioh.rewind
177
+ end
178
+
179
+ def clean_for_html_comment(s)
180
+ s.to_s.gsub("--", "__").gsub("<", "[").gsub(">", "]")
181
+ end
182
+
183
+ def compose_record_from_hash(model_class_name, attrs_hash)
184
+ model_class = model_class_name.constantize
185
+ raise "Class #{model_class_name} does not have cargo safety method" unless model_class.respond_to? :safe_to_load_from_cargo_stream?
186
+ raise "Class #{model_class_name} is not safe_to_load_from_cargo_stream" unless model_class.safe_to_load_from_cargo_stream?
187
+
188
+ rec = model_class.new
189
+ rec.send(:attributes=, attrs_hash, false) # No attr_accessible check like this, so all attributes can be set
190
+ rec.readonly! # rec is just source data for creation of a "real" record; it shouldn't be saveable itself
191
+ rec
192
+ end
193
+
194
+ def verify_and_decode_cargo(digest, b64_data)
195
+ deflated_data = Base64.decode64(b64_data)
196
+ raise "MD5 check failure" unless Digest::MD5::hexdigest(deflated_data) == digest
197
+
198
+ # Even though we encoded an Array with Array#to_xml, there is no Array#from_xml
199
+ # So, we have to use Hash#from_xml
200
+ records = Hash.from_xml(Zlib::Inflate::inflate(deflated_data))["records"]["record"]
201
+ raise "Decode failure, unable to find records key" unless records != nil
202
+ records = [records] unless records.is_a?(Array)
203
+ return records.map do |attrs_hash|
204
+ raise "Unable to find record type" unless attrs_hash.has_key?("cargo_streamer_type")
205
+ class_name = attrs_hash.delete("cargo_streamer_type")
206
+
207
+ raise "Unable to find includes list" unless attrs_hash.has_key?("cargo_streamer_includes")
208
+ (attrs_hash.delete("cargo_streamer_includes") || "").split(",").each do |assoc_info|
209
+ assoc_name, i_class_name = assoc_info.split("=")
210
+ attrs_hash[assoc_name] = compose_record_from_hash(i_class_name, attrs_hash[assoc_name])
211
+ end
212
+
213
+ compose_record_from_hash(class_name, attrs_hash)
214
+ end
215
+ rescue StandardError => e
216
+ raise CargoStreamerError.new("Corrupted data : #{e.class.to_s} : #{e.to_s}")
217
+ end
218
+
219
+ CARGO_BEGIN = "<!-- CARGO SEGMENT"
220
+ CARGO_END = "END CARGO SEGMENT -->"
221
+ end
222
+ end
@@ -0,0 +1,74 @@
1
+ module Offroad
2
+ module ControllerExtensions
3
+ def offroad_group_controller
4
+ include InstanceMethods
5
+ end
6
+
7
+ module InstanceMethods
8
+ def render_up_mirror_file(group, filename, render_args = {})
9
+ ensure_group_offline(group)
10
+ raise PluginError.new("Cannot generate up-mirror file when app in online mode") if Offroad::app_online?
11
+ mirror_data = MirrorData.new(
12
+ group,
13
+ :skip_validation => render_args.delete(:skip_validation)
14
+ )
15
+ render_appending_mirror_data(group, filename, render_args) do |output|
16
+ mirror_data.write_upwards_data(output)
17
+ end
18
+ end
19
+
20
+ def render_down_mirror_file(group, filename, render_args = {})
21
+ ensure_group_offline(group)
22
+ raise PluginError.new("Cannot generate down-mirror file when app in offline mode") if Offroad::app_offline?
23
+ mirror_data = MirrorData.new(
24
+ group,
25
+ :initial_mode => render_args.delete(:initial_mode),
26
+ :skip_validation => render_args.delete(:skip_validation)
27
+ )
28
+ render_appending_mirror_data(group, filename, render_args) do |output|
29
+ mirror_data.write_downwards_data(output)
30
+ end
31
+ end
32
+
33
+ def load_up_mirror_file(group, data, options = {})
34
+ ensure_group_offline(group)
35
+ raise PluginError.new("Cannot accept up mirror file when app is in offline mode") if Offroad::app_offline?
36
+ m = MirrorData.new(
37
+ group,
38
+ :skip_validation => options[:skip_validation]
39
+ )
40
+ m.load_upwards_data(data)
41
+ end
42
+
43
+ def load_down_mirror_file(group, data, options = {})
44
+ ensure_group_offline(group) if group
45
+ raise PluginError.new("Cannot accept down mirror file when app is in online mode") if Offroad::app_online?
46
+ m = MirrorData.new(
47
+ group,
48
+ :initial_mode => options[:initial_mode],
49
+ :skip_validation => options[:skip_validation]
50
+ )
51
+ m.load_downwards_data(data)
52
+ end
53
+
54
+ private
55
+
56
+ def ensure_group_offline(group)
57
+ raise PluginError.new("Cannot perform mirror operations on online group") unless group.group_offline?
58
+ end
59
+
60
+ def render_appending_mirror_data(group, filename, render_args)
61
+ # Encourage browser to download this to disk instead of displaying it
62
+ headers['Content-Disposition'] = "attachment; filename=\"#{filename}\""
63
+ viewable_content = render_to_string render_args
64
+
65
+ render_proc = Proc.new do |response, output|
66
+ output.write(viewable_content)
67
+ yield output
68
+ end
69
+
70
+ render :text => render_proc
71
+ end
72
+ end
73
+ end
74
+ end
data/lib/exceptions.rb ADDED
@@ -0,0 +1,16 @@
1
+ module Offroad
2
+ class DataError < RuntimeError
3
+ end
4
+
5
+ class OldDataError < DataError
6
+ end
7
+
8
+ class ModelError < RuntimeError
9
+ end
10
+
11
+ class PluginError < RuntimeError
12
+ end
13
+
14
+ class AppModeUnknownError < RuntimeError
15
+ end
16
+ end
@@ -0,0 +1,72 @@
1
+ class CreateOffroadTables < ActiveRecord::Migration
2
+ def self.up
3
+ create_table :offroad_system_state do |t|
4
+ t.column :current_mirror_version, :integer
5
+ end
6
+
7
+ create_table :offroad_group_states do |t|
8
+ t.column :app_group_id, :integer, :null => false
9
+
10
+ # This is used to allow group_owned records to be destroyed when their parent is.
11
+ # Without this, groups with :dependent => :destroy would not be allowed on the online app.
12
+ # This is NOT used to propogate group deletion through mirror files.
13
+ t.column :group_being_destroyed, :boolean, :default => false, :null => false
14
+
15
+ # If a group is locked (which can only occur offline) then its records cannot be altered anymore.
16
+ # If a locked group is sent up to the online system, then after loading it the online system puts that group back
17
+ # online.
18
+ t.column :group_locked, :boolean, :default => false, :null => false
19
+
20
+ # On both the online and offline systems, these are the latest data versions remote side is known to have
21
+ t.column :confirmed_group_data_version, :integer, :null => false
22
+ t.column :confirmed_global_data_version, :integer, :null => false
23
+
24
+ t.column :last_installer_downloaded_at, :datetime
25
+ t.column :last_installation_at, :datetime
26
+ t.column :last_down_mirror_created_at, :datetime
27
+ t.column :last_down_mirror_loaded_at, :datetime
28
+ t.column :last_up_mirror_created_at, :datetime
29
+ t.column :last_up_mirror_loaded_at, :datetime
30
+ t.column :launcher_version, :integer
31
+ t.column :app_version, :integer
32
+ t.column :operating_system, :string, :default => "Unknown", :null => false
33
+ end
34
+ add_index :offroad_group_states, :app_group_id, :unique => true
35
+ # This lets us quickly find min(global_mirror_version) for clearing old deleted global record SRSes
36
+ add_index :offroad_group_states, :confirmed_global_data_version
37
+
38
+ create_table :offroad_model_states do |t|
39
+ t.column :app_model_name, :string, :null => false
40
+ end
41
+ add_index :offroad_model_states, :app_model_name, :unique => true
42
+
43
+ create_table :offroad_sendable_record_states do |t|
44
+ t.column :model_state_id, :integer, :null => false
45
+ t.column :local_record_id, :integer, :null => false
46
+ t.column :mirror_version, :integer, :default => 0, :null => false
47
+ t.column :deleted, :boolean, :default => false, :null => false
48
+ end
49
+ # This index is for locating the SRS for any given local app record
50
+ add_index :offroad_sendable_record_states, [:local_record_id, :model_state_id], :unique => true
51
+ # This index is for generating mirror files: for a given model need to find everything above a given mirror_version
52
+ add_index :offroad_sendable_record_states, [:model_state_id, :deleted, :mirror_version]
53
+
54
+ create_table :offroad_received_record_states do |t|
55
+ t.column :model_state_id, :integer, :null => false
56
+ t.column :group_state_id, :integer, :default => 0, :null => false # If 0, is a global record
57
+ t.column :local_record_id, :integer, :null => false
58
+ t.column :remote_record_id, :integer, :null => false
59
+ end
60
+ add_index :offroad_received_record_states, [:model_state_id, :group_state_id, :remote_record_id], :unique => true
61
+ # TODO: Perhaps index below can be removed; it enforces data integrity, but isn't actually used for lookups
62
+ add_index :offroad_received_record_states, [:model_state_id, :local_record_id], :unique => true
63
+ end
64
+
65
+ def self.down
66
+ drop_table :offroad_system_state
67
+ drop_table :offroad_group_states
68
+ drop_table :offroad_model_states
69
+ drop_table :offroad_sendable_record_states
70
+ drop_table :offroad_received_record_states
71
+ end
72
+ end