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.
- data/LICENSE +674 -0
- data/README.rdoc +29 -0
- data/Rakefile +75 -0
- data/TODO +42 -0
- data/lib/app/models/offroad/group_state.rb +85 -0
- data/lib/app/models/offroad/mirror_info.rb +53 -0
- data/lib/app/models/offroad/model_state.rb +36 -0
- data/lib/app/models/offroad/received_record_state.rb +109 -0
- data/lib/app/models/offroad/sendable_record_state.rb +91 -0
- data/lib/app/models/offroad/system_state.rb +33 -0
- data/lib/cargo_streamer.rb +222 -0
- data/lib/controller_extensions.rb +74 -0
- data/lib/exceptions.rb +16 -0
- data/lib/migrate/20100512164608_create_offroad_tables.rb +72 -0
- data/lib/mirror_data.rb +354 -0
- data/lib/model_extensions.rb +377 -0
- data/lib/module_funcs.rb +94 -0
- data/lib/offroad.rb +30 -0
- data/lib/version.rb +3 -0
- data/lib/view_helper.rb +7 -0
- data/templates/offline.rb +36 -0
- data/templates/offline_database.yml +7 -0
- data/templates/offroad.yml +6 -0
- data/test/app_root/app/controllers/application_controller.rb +2 -0
- data/test/app_root/app/controllers/group_controller.rb +28 -0
- data/test/app_root/app/models/global_record.rb +10 -0
- data/test/app_root/app/models/group.rb +12 -0
- data/test/app_root/app/models/group_owned_record.rb +68 -0
- data/test/app_root/app/models/guest.rb +7 -0
- data/test/app_root/app/models/subrecord.rb +12 -0
- data/test/app_root/app/models/unmirrored_record.rb +4 -0
- data/test/app_root/app/views/group/download_down_mirror.html.erb +4 -0
- data/test/app_root/app/views/group/download_initial_down_mirror.html.erb +4 -0
- data/test/app_root/app/views/group/download_up_mirror.html.erb +6 -0
- data/test/app_root/app/views/group/upload_down_mirror.html.erb +1 -0
- data/test/app_root/app/views/group/upload_up_mirror.html.erb +1 -0
- data/test/app_root/app/views/layouts/mirror.html.erb +9 -0
- data/test/app_root/config/boot.rb +115 -0
- data/test/app_root/config/database.yml +6 -0
- data/test/app_root/config/environment.rb +15 -0
- data/test/app_root/config/environments/test.rb +17 -0
- data/test/app_root/config/offroad.yml +6 -0
- data/test/app_root/config/routes.rb +4 -0
- data/test/app_root/db/migrate/20100529235049_create_tables.rb +64 -0
- data/test/app_root/lib/common_hobo.rb +15 -0
- data/test/app_root/vendor/plugins/offroad/init.rb +2 -0
- data/test/functional/mirror_operations_test.rb +148 -0
- data/test/test_helper.rb +405 -0
- data/test/unit/app_state_tracking_test.rb +275 -0
- data/test/unit/cargo_streamer_test.rb +332 -0
- data/test/unit/global_data_test.rb +102 -0
- data/test/unit/group_controller_test.rb +152 -0
- data/test/unit/group_data_test.rb +435 -0
- data/test/unit/group_single_test.rb +136 -0
- data/test/unit/hobo_permissions_test.rb +57 -0
- data/test/unit/mirror_data_test.rb +1271 -0
- data/test/unit/mirror_info_test.rb +31 -0
- data/test/unit/module_funcs_test.rb +37 -0
- data/test/unit/pathological_model_test.rb +62 -0
- data/test/unit/test_framework_test.rb +86 -0
- data/test/unit/unmirrored_data_test.rb +14 -0
- 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,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
|